// License: GPL. For details, see LICENSE file. package org.openstreetmap.josm.plugins.turnlanes.gui; import static java.lang.Math.PI; import static java.lang.Math.abs; import static java.lang.Math.cos; import static java.lang.Math.sin; import static java.lang.Math.tan; import static org.openstreetmap.josm.plugins.turnlanes.gui.GuiUtil.angle; import static org.openstreetmap.josm.plugins.turnlanes.gui.GuiUtil.area; import static org.openstreetmap.josm.plugins.turnlanes.gui.GuiUtil.closest; import static org.openstreetmap.josm.plugins.turnlanes.gui.GuiUtil.intersection; import static org.openstreetmap.josm.plugins.turnlanes.gui.GuiUtil.line; import static org.openstreetmap.josm.plugins.turnlanes.gui.GuiUtil.loc; import static org.openstreetmap.josm.plugins.turnlanes.gui.GuiUtil.minAngleDiff; import static org.openstreetmap.josm.plugins.turnlanes.gui.GuiUtil.relativePoint; import java.awt.AlphaComposite; import java.awt.BasicStroke; import java.awt.Color; import java.awt.Composite; import java.awt.Graphics2D; import java.awt.Shape; import java.awt.Stroke; import java.awt.geom.AffineTransform; import java.awt.geom.Area; import java.awt.geom.Ellipse2D; import java.awt.geom.Line2D; import java.awt.geom.Path2D; import java.awt.geom.Point2D; import java.awt.geom.Rectangle2D; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; import org.openstreetmap.josm.data.osm.Node; import org.openstreetmap.josm.data.osm.OsmPrimitive; import org.openstreetmap.josm.data.osm.Way; import org.openstreetmap.josm.plugins.turnlanes.model.Lane; import org.openstreetmap.josm.plugins.turnlanes.model.Road; import org.openstreetmap.josm.plugins.turnlanes.model.Utils; class RoadGui { final class ViaConnector extends InteractiveElement { private final Road.End end; private final Line2D line; private final float strokeWidth; ViaConnector(Road.End end) { this.end = end; this.line = new Line2D.Double(getLeftCorner(end), getRightCorner(end)); this.strokeWidth = (float) (3 * getContainer().getLaneWidth() / 4); } @Override void paint(Graphics2D g2d, State state) { if (isVisible(state)) { g2d.setStroke(new BasicStroke(strokeWidth, BasicStroke.CAP_ROUND, BasicStroke.JOIN_MITER)); g2d.setColor(Color.ORANGE); g2d.draw(line); } } @Override boolean contains(Point2D p, State state) { if (!isVisible(state)) { return false; } final Point2D closest = closest(line, p); return p.distance(closest) <= strokeWidth / 2; } private boolean isVisible(State state) { if (!(state instanceof State.Connecting)) { return false; } final State.Connecting s = (State.Connecting) state; if (s.getJunction().equals(end.getJunction()) || equals(s.getBacktrackViaConnector())) { return true; } else if (!s.getViaConnectors().isEmpty() && s.getViaConnectors().get(s.getViaConnectors().size() - 1).getRoadModel().equals(getRoadModel())) { return true; } return false; } private Road getRoadModel() { return getModel(); } public RoadGui getRoad() { return RoadGui.this; } @Override Type getType() { return Type.VIA_CONNECTOR; } @Override int getZIndex() { return 1; } public Road.End getRoadEnd() { return end; } public Point2D getCenter() { return relativePoint(line.getP1(), line.getP1().distance(line.getP2()) / 2, angle(line.getP1(), line.getP2())); } } private final class Extender extends InteractiveElement { private final Road.End end; private final Way way; private final Line2D line; Extender(Road.End end, Way way, double angle) { this.end = end; this.way = way; this.line = new Line2D.Double(a.getPoint(), relativePoint(a.getPoint(), getContainer().getLaneWidth() * 4, angle)); } @Override void paint(Graphics2D g2d, State state) { g2d.setStroke(getContainer().getConnectionStroke()); g2d.setColor(Color.CYAN); g2d.draw(line); } @Override boolean contains(Point2D p, State state) { final BasicStroke stroke = (BasicStroke) getContainer().getConnectionStroke(); final double strokeWidth = stroke.getLineWidth(); final Point2D closest = closest(line, p); return p.distance(closest) <= strokeWidth / 2; } @Override State click(State old) { end.extend(way); return new State.Invalid(old); } @Override Type getType() { return Type.EXTENDER; } @Override int getZIndex() { return 0; } } private final class LaneAdder extends InteractiveElement { private final Road.End end; private final Lane.Kind kind; private final Point2D center; private final Ellipse2D background; LaneAdder(Road.End end, Lane.Kind kind) { this.end = end; this.kind = kind; final double a = getAngle(end) + PI; final Point2D lc = getLeftCorner(end); final Point2D rc = getRightCorner(end); final double r = connectorRadius; final double cx; final double cy; if (kind == Lane.Kind.EXTRA_LEFT) { final JunctionGui j = getContainer().getGui(end.getJunction()); final Point2D i = intersection(line(j.getPoint(), a), new Line2D.Double(lc, rc)); cx = i.getX() + 21d / 16 * r * (2 * cos(a) + cos(a - PI / 2)); cy = i.getY() - 21d / 16 * r * (2 * sin(a) + sin(a - PI / 2)); } else { cx = rc.getX() + 21d / 16 * r * (2 * cos(a) + cos(a + PI / 2)); cy = rc.getY() - 21d / 16 * r * (2 * sin(a) + sin(a + PI / 2)); } center = new Point2D.Double(cx, cy); background = new Ellipse2D.Double(cx - r, cy - r, 2 * r, 2 * r); } @Override void paint(Graphics2D g2d, State state) { if (!isVisible(state)) { return; } g2d.setColor(Color.DARK_GRAY); g2d.fill(background); final double l = 2 * connectorRadius / 3; final Line2D v = new Line2D.Double(center.getX(), center.getY() - l, center.getX(), center.getY() + l); final Line2D h = new Line2D.Double(center.getX() - l, center.getY(), center.getX() + l, center.getY()); g2d.setStroke(new BasicStroke((float) (connectorRadius / 5))); g2d.setColor(Color.WHITE); g2d.draw(v); g2d.draw(h); } private boolean isVisible(State state) { return end.getJunction().isPrimary(); } @Override boolean contains(Point2D p, State state) { return isVisible(state) && background.contains(p); } @Override Type getType() { return Type.LANE_ADDER; } @Override int getZIndex() { return 2; } @Override public State click(State old) { end.addLane(kind); return new State.Invalid(old); } } final class IncomingConnector extends InteractiveElement { private final Road.End end; private final List<LaneGui> lanes; private final Point2D center = new Point2D.Double(); private final Ellipse2D circle = new Ellipse2D.Double(); private IncomingConnector(Road.End end) { this.end = end; final List<LaneGui> lanes = new ArrayList<>(end.getLanes().size()); for (Lane l : end.getOppositeEnd().getLanes()) { lanes.add(new LaneGui(RoadGui.this, l)); } this.lanes = Collections.unmodifiableList(lanes); } @Override public void paintBackground(Graphics2D g2d, State state) { if (isActive(state)) { final Composite old = g2d.getComposite(); g2d.setComposite(((AlphaComposite) old).derive(0.2f)); g2d.setColor(new Color(255, 127, 31)); for (LaneGui l : lanes) { l.fill(g2d); } g2d.setComposite(old); } } @Override public void paint(Graphics2D g2d, State state) { if (isVisible(state)) { final Composite old = g2d.getComposite(); if (isActive(state)) { g2d.setComposite(((AlphaComposite) old).derive(1f)); } g2d.setColor(Color.LIGHT_GRAY); g2d.fill(circle); g2d.setComposite(old); } } private boolean isActive(State state) { if (!(state instanceof State.IncomingActive)) { return false; } final Road.End roadEnd = ((State.IncomingActive) state).getRoadEnd(); return roadEnd.equals(getRoadEnd()); } private boolean isVisible(State state) { if (getModel().isPrimary() || !getRoadEnd().getJunction().isPrimary() || getRoadEnd().getOppositeEnd().getLanes().isEmpty()) { return false; } if (state instanceof State.Connecting) { return ((State.Connecting) state).getJunction().equals(getRoadEnd().getJunction()); } return true; } @Override public boolean contains(Point2D p, State state) { if (!isVisible(state)) { return false; } else if (circle.contains(p)) { return true; } for (LaneGui l : lanes) { if (l.contains(p)) { return true; } } return false; } @Override public Type getType() { return Type.INCOMING_CONNECTOR; } @Override public State activate(State old) { return new State.IncomingActive(getRoadEnd()); } public Point2D getCenter() { return (Point2D) center.clone(); } void move(double x, double y) { final double r = connectorRadius; center.setLocation(x, y); circle.setFrame(x - r, y - r, 2 * r, 2 * r); } public Road.End getRoadEnd() { return end; } public List<LaneGui> getLanes() { return lanes; } @Override int getZIndex() { return 1; } public void add(LaneGui lane) { lanes.add(lane); } } // TODO rework to be a SegmentGui (with getModel()) private final class Segment { final Point2D to; final Point2D from; final Segment prev; final Segment next; final double length; final double angle; Segment(Segment next, List<Point2D> bends, JunctionGui a) { final Point2D head = (Point2D) bends.get(0).clone(); final List<Point2D> tail = bends.subList(1, bends.size()); this.next = next; this.to = head; this.from = (Point2D) (tail.isEmpty() ? a.getPoint() : tail.get(0)).clone(); this.prev = tail.isEmpty() ? null : new Segment(this, tail, a); this.length = from.distance(to); this.angle = angle(from, to); // TODO create a factory method for the segments list and pass it to // the constructor(s) segments.add(this); } Segment(JunctionGui b, List<Point2D> bends, JunctionGui a) { this((Segment) null, prepended(bends, (Point2D) b.getPoint().clone()), a); } private double getFromOffset() { return prev == null ? 0 : prev.getFromOffset() + prev.length; } public double getOffset(double x, double y) { return getOffsetInternal(new Point2D.Double(x, y), -1, Double.POSITIVE_INFINITY); } private double getOffsetInternal(Point2D p, double offset, double quality) { final Point2D closest = closest(new Line2D.Double(from, to), p); final double myQuality = closest.distance(p); if (myQuality < quality) { quality = myQuality; final Line2D normal = line(p, angle + PI / 2); final Point2D isect = intersection(normal, new Line2D.Double(from, to)); final double d = from.distance(isect); final boolean negative = Math.abs(angle(from, isect) - angle) > 1; offset = getFromOffset() + (negative ? -1 : 1) * d; } return next == null ? offset : next.getOffsetInternal(p, offset, quality); } public Path append(Path path, boolean forward, double offset) { if (ROUND_CORNERS) { final Segment n = forward ? prev : next; final Point2D s = forward ? to : from; final Point2D e = forward ? from : to; if (n == null) { return path.lineTo(e.getX(), e.getY(), length - offset); } final double a = minAngleDiff(angle, n.angle); final double d = 3 * outerMargin + getWidth(getModel().getToEnd(), (forward && a < 0) || (!forward && a > 0)); final double l = d * tan(abs(a)); if (length - offset < l / 2 || n.length < l / 2) { return n.append(path.lineTo(e.getX(), e.getY(), length - offset), forward, 0); } else { final Point2D p = relativePoint(e, l / 2, angle(e, s)); final Path line = path.lineTo(p.getX(), p.getY(), length - l / 2 - offset); final Path curve = line.curveTo(d, d, a, l); return n.append(curve, forward, l / 2); } } else if (forward) { final Path tmp = path.lineTo(from.getX(), from.getY(), length); return prev == null ? tmp : prev.append(tmp, forward, 0); } else { final Path tmp = path.lineTo(to.getX(), to.getY(), length); return next == null ? tmp : next.append(tmp, forward, 0); } } } /** * This should become a setting, but rounding is (as of yet) still slightly buggy and a low * priority. */ private static final boolean ROUND_CORNERS = false; private static List<Point2D> prepended(List<Point2D> bends, Point2D point) { final List<Point2D> result = new ArrayList<>(bends.size() + 1); result.add(point); result.addAll(bends); return result; } private final GuiContainer container; private final double innerMargin; private final double outerMargin; private final float lineWidth; private final Stroke regularStroke; private final Stroke dashedStroke; private final JunctionGui a; private final JunctionGui b; private final double length; private final IncomingConnector incomingA; private final IncomingConnector incomingB; private final Road road; private final List<Segment> segments = new ArrayList<>(); final double connectorRadius; RoadGui(GuiContainer container, Road road) { this.container = container; this.road = road; this.a = container.getGui(road.getFromEnd().getJunction()); this.b = container.getGui(road.getToEnd().getJunction()); this.incomingA = new IncomingConnector(road.getFromEnd()); this.incomingB = new IncomingConnector(road.getToEnd()); final List<Point2D> bends = new ArrayList<>(); final List<Node> nodes = road.getRoute().getNodes(); for (int i = nodes.size() - 2; i > 0; --i) { bends.add(container.translateAndScale(loc(nodes.get(i)))); } // they add themselves to this.segments new Segment(b, bends, a); double l = 0; for (Segment s : segments) { l += s.length; } this.length = l; this.innerMargin = !incomingA.getLanes().isEmpty() && !incomingB.getLanes().isEmpty() ? 1 * container .getLaneWidth() / 15 : 0; this.outerMargin = container.getLaneWidth() / 6; this.connectorRadius = 3 * container.getLaneWidth() / 8; this.lineWidth = (float) (container.getLaneWidth() / 30); this.regularStroke = new BasicStroke(2 * lineWidth); this.dashedStroke = new BasicStroke(lineWidth, BasicStroke.CAP_BUTT, BasicStroke.JOIN_ROUND, 10f, new float[] { (float) (container.getLaneWidth() / 2), (float) (container.getLaneWidth() / 3) }, 0); } public JunctionGui getA() { return a; } public JunctionGui getB() { return b; } public Line2D getLeftCurb(Road.End end) { return GuiUtil.line(getCorner(end, true), getAngle(end) + PI); } public Line2D getRightCurb(Road.End end) { return GuiUtil.line(getCorner(end, false), getAngle(end) + PI); } private Point2D getLeftCorner(Road.End end) { return getCorner(end, true); } private Point2D getRightCorner(Road.End end) { return getCorner(end, false); } private Point2D getCorner(Road.End end, boolean left) { final JunctionGui j = end.isFromEnd() ? a : b; final double w = left ? getWidth(end, true) : getWidth(end, false); final double s = (left ? 1 : -1); final double a = getAngle(end) + PI; final double t = left ? j.getLeftTrim(end) : j.getRightTrim(end); final double dx = s * cos(PI / 2 - a) * w + cos(a) * t; final double dy = s * sin(PI / 2 - a) * w - sin(a) * t; return new Point2D.Double(j.x + dx, j.y + dy); } private double getWidth(Road.End end, boolean left) { if (!end.getRoad().equals(road)) { throw new IllegalArgumentException(); } final int lcForward = incomingA.getLanes().size(); final int lcBackward = incomingB.getLanes().size(); final double LW = getContainer().getLaneWidth(); final double M = innerMargin + outerMargin; if (end.isToEnd()) { return (left ? lcBackward : lcForward) * LW + M; } else { return (left ? lcForward : lcBackward) * LW + M; } } List<InteractiveElement> paint(Graphics2D g2d) { final List<InteractiveElement> result = new ArrayList<>(); result.addAll(paintLanes(g2d)); if (getModel().isPrimary()) { result.add(new ViaConnector(getModel().getFromEnd())); result.add(new ViaConnector(getModel().getToEnd())); } else { result.addAll(laneAdders()); result.addAll(extenders(getModel().getFromEnd())); result.addAll(extenders(getModel().getToEnd())); } g2d.setColor(Color.RED); for (Segment s : segments) { g2d.fill(new Ellipse2D.Double(s.from.getX() - 1, s.from.getY() - 1, 2, 2)); } return result; } private List<LaneAdder> laneAdders() { final List<LaneAdder> result = new ArrayList<>(4); if (!incomingA.getLanes().isEmpty()) { result.add(new LaneAdder(getModel().getToEnd(), Lane.Kind.EXTRA_LEFT)); result.add(new LaneAdder(getModel().getToEnd(), Lane.Kind.EXTRA_RIGHT)); } if (!incomingB.getLanes().isEmpty()) { result.add(new LaneAdder(getModel().getFromEnd(), Lane.Kind.EXTRA_LEFT)); result.add(new LaneAdder(getModel().getFromEnd(), Lane.Kind.EXTRA_RIGHT)); } return result; } private List<Extender> extenders(Road.End end) { if (!end.isExtendable()) { return Collections.emptyList(); } final List<Extender> result = new ArrayList<>(); final Node n = end.getJunction().getNode(); for (Way w : OsmPrimitive.getFilteredList(n.getReferrers(), Way.class)) { if (w.getNodesCount() > 1 && !end.getWay().equals(w) && w.isFirstLastNode(n) && Utils.isRoad(w)) { final Node nextNode = w.firstNode().equals(n) ? w.getNode(1) : w.getNode(w.getNodesCount() - 2); final Point2D nextNodeLoc = getContainer().translateAndScale(loc(nextNode)); result.add(new Extender(end, w, angle(a.getPoint(), nextNodeLoc))); } } return result; } public Road getModel() { return road; } public IncomingConnector getConnector(Road.End end) { return end.isFromEnd() ? incomingA : incomingB; } private List<InteractiveElement> paintLanes(Graphics2D g2d) { final Path2D middleLines = new Path2D.Double(); g2d.setStroke(regularStroke); final boolean forward = !incomingA.getLanes().isEmpty(); final boolean backward = !incomingB.getLanes().isEmpty(); final Path2D middleArea; if (forward && backward) { paintLanes(g2d, middleLines, true); paintLanes(g2d, middleLines, false); middleLines.closePath(); middleArea = middleLines; g2d.setColor(new Color(160, 160, 160)); } else if (forward || backward) { paintLanes(g2d, middleLines, forward); middleArea = new Path2D.Double(); middleArea.append(middleLines.getPathIterator(null), false); middleArea.append(middlePath(backward).offset(outerMargin, -1, -1, outerMargin).getIterator(), true); middleArea.closePath(); g2d.setColor(Color.GRAY); } else { throw new AssertionError(); } g2d.fill(middleArea); g2d.setColor(Color.WHITE); g2d.draw(middleLines); final List<InteractiveElement> result = new ArrayList<>(); moveIncoming(getModel().getFromEnd()); moveIncoming(getModel().getToEnd()); result.add(incomingA); result.add(incomingB); for (IncomingConnector c : Arrays.asList(incomingA, incomingB)) { int offset = 0; for (LaneGui l : c.getLanes()) { moveOutgoing(l, offset++); result.add(l.outgoing); if (l.getModel().isExtra()) { result.add(l.lengthSlider); } } } return result; } private void paintLanes(Graphics2D g2d, Path2D middleLines, boolean forward) { final Shape clip = clip(); g2d.clip(clip); final Path middle = middlePath(forward); Path innerPath = middle.offset(innerMargin, -1, -1, innerMargin); final List<Path> linePaths = new ArrayList<>(); linePaths.add(innerPath); for (LaneGui l : forward ? incomingA.getLanes() : incomingB.getLanes()) { l.setClip(clip); innerPath = l.recalculate(innerPath, middleLines); linePaths.add(innerPath); } final Path2D area = new Path2D.Double(); area(area, middle, innerPath.offset(outerMargin, -1, -1, outerMargin)); g2d.setColor(Color.GRAY); g2d.fill(area); g2d.setColor(Color.WHITE); final Path2D lines = new Path2D.Double(); lines.append(innerPath.getIterator(), false); g2d.draw(lines); AffineTransform at = g2d.getTransform(); // Draw dashed lines only if the affine transform supports it (see #14757) if (!Double.isNaN(at.getTranslateX()) && !Double.isNaN(at.getTranslateY())) { g2d.setColor(Color.WHITE); g2d.setStroke(dashedStroke); for (Path p : linePaths) { lines.reset(); lines.append(p.getIterator(), false); g2d.draw(lines); } g2d.setStroke(regularStroke); } // g2d.setColor(new Color(32, 128, 192)); // lines.reset(); // lines.append(middle.getIterator(), false); // g2d.draw(lines); g2d.setClip(null); } private Shape clip() { final Area clip = new Area(new Rectangle2D.Double(-100000, -100000, 200000, 200000)); clip.subtract(new Area(negativeClip(true))); clip.subtract(new Area(negativeClip(false))); return clip; } private Shape negativeClip(boolean forward) { final Road.End end = forward ? getModel().getToEnd() : getModel().getFromEnd(); final JunctionGui j = forward ? b : a; final Line2D lc = getLeftCurb(end); final Line2D rc = getRightCurb(end); final Path2D negativeClip = new Path2D.Double(); final double d = rc.getP1().distance(j.getPoint()) + lc.getP1().distance(j.getPoint()); final double cm = 0.01 / getContainer().getMpp(); // 1 centimeter final double rca = angle(rc) + PI; final double lca = angle(lc) + PI; final Point2D r1 = relativePoint(relativePoint(rc.getP1(), 1, angle(lc.getP1(), rc.getP1())), cm, rca); final Point2D r2 = relativePoint(r1, d, rca); final Point2D l1 = relativePoint(relativePoint(lc.getP1(), 1, angle(rc.getP1(), lc.getP1())), cm, lca); final Point2D l2 = relativePoint(l1, d, lca); negativeClip.moveTo(r1.getX(), r1.getY()); negativeClip.lineTo(r2.getX(), r2.getY()); negativeClip.lineTo(l2.getX(), l2.getY()); negativeClip.lineTo(l1.getX(), l1.getY()); negativeClip.closePath(); return negativeClip; } public Path getLaneMiddle(boolean forward) { final Path mid = middlePath(!forward); final double w = getWidth(forward ? getModel().getFromEnd() : getModel().getToEnd(), true); final double o = (w - outerMargin) / 2; return o > 0 ? mid.offset(-o, -1, -1, -o) : mid; } private Path middlePath(boolean forward) { final Path path = forward ? Path.create(b.x, b.y) : Path.create(a.x, a.y); final Segment first = forward ? segments.get(segments.size() - 1) : segments.get(0); return first.append(path, forward, 0); } private void moveIncoming(Road.End end) { final Point2D lc = getLeftCorner(end); final Point2D rc = getRightCorner(end); final Line2D cornerLine = new Line2D.Double(lc, rc); final double a = getAngle(end); final Line2D roadLine = line(getContainer().getGui(end.getJunction()).getPoint(), a); final Point2D i = intersection(roadLine, cornerLine); // TODO fix depending on angle(i, lc) final double offset = innerMargin + (getWidth(end, true) - innerMargin - outerMargin) / 2; final Point2D loc = relativePoint(i, offset, angle(i, lc)); getConnector(end).move(loc.getX(), loc.getY()); } private void moveOutgoing(LaneGui lane, int offset) { final Road.End end = lane.getModel().getOutgoingRoadEnd(); final Point2D lc = getLeftCorner(end); final Point2D rc = getRightCorner(end); final Line2D cornerLine = new Line2D.Double(lc, rc); final double a = getAngle(end); final Line2D roadLine = line(getContainer().getGui(end.getJunction()).getPoint(), a); final Point2D i = intersection(roadLine, cornerLine); // TODO fix depending on angle(i, rc) final double d = innerMargin + (2 * offset + 1) * getContainer().getLaneWidth() / 2; final Point2D loc = relativePoint(i, d, angle(i, rc)); lane.outgoing.move(loc.getX(), loc.getY()); } public JunctionGui getJunction(Road.End end) { if (!getModel().equals(end.getRoad())) { throw new IllegalArgumentException(); } return end.isFromEnd() ? getA() : getB(); } public double getAngle(Road.End end) { if (!getModel().equals(end.getRoad())) { throw new IllegalArgumentException(); } if (end.isToEnd()) { return segments.get(segments.size() - 1).angle; } else { final double angle = segments.get(0).angle; return angle > PI ? angle - PI : angle + PI; } } public double getWidth(Road.End end) { return getWidth(end, true) + getWidth(end, false); } public double getLength() { return length; } public double getOffset(double x, double y) { return segments.get(0).getOffset(x, y); } public GuiContainer getContainer() { return container; } public List<LaneGui> getLanes() { final List<LaneGui> result = new ArrayList<>(); result.addAll(incomingB.getLanes()); result.addAll(incomingA.getLanes()); return Collections.unmodifiableList(result); } public List<LaneGui> getLanes(Road.End end) { return getConnector(end.getOppositeEnd()).getLanes(); } }