From d41aa02674520e3a9e8cc9fda1d1b0a7af7fa1da Mon Sep 17 00:00:00 2001 From: Bane Stojkovic Date: Fri, 11 Sep 2026 23:14:30 +0200 Subject: [PATCH 1/2] Added 'Right Angle Wire' and 'Right Angle Line' components that route between two points using only horizontal and vertical runs with rounded corners --- diylc/CLAUDE.md | 26 +- .../AbstractOrthogonalComponent.java | 408 ++++++++++++++++++ .../OrthogonalComponentTransformer.java | 47 ++ .../diylc-core/src/main/resources/update.xml | 16 + .../AbstractOrthogonalComponentTest.java | 217 ++++++++++ .../connectivity/OrthogonalLine.java | 195 +++++++++ .../connectivity/OrthogonalWire.java | 158 +++++++ diylc/docs/plans/orthogonal-connector.md | 214 +++++++++ 8 files changed, 1278 insertions(+), 3 deletions(-) create mode 100644 diylc/diylc-core/src/main/java/org/diylc/components/AbstractOrthogonalComponent.java create mode 100644 diylc/diylc-core/src/main/java/org/diylc/components/transform/OrthogonalComponentTransformer.java create mode 100644 diylc/diylc-core/src/test/java/org/diylc/components/AbstractOrthogonalComponentTest.java create mode 100644 diylc/diylc-library/src/main/java/org/diylc/components/connectivity/OrthogonalLine.java create mode 100644 diylc/diylc-library/src/main/java/org/diylc/components/connectivity/OrthogonalWire.java create mode 100644 diylc/docs/plans/orthogonal-connector.md diff --git a/diylc/CLAUDE.md b/diylc/CLAUDE.md index 9da7e2c0..7d17e421 100644 --- a/diylc/CLAUDE.md +++ b/diylc/CLAUDE.md @@ -64,15 +64,16 @@ The formatter is `eclipse-java-google-style.xml` (Google Java Style): - **100-column** line limit. - Source encoding is **ISO-8859-1** — do not introduce non-ASCII characters into `.java` files. Use Unicode escapes or the resource bundles instead. -- Braces on the same line; single-statement `if` bodies without braces are common in this codebase - and acceptable when you are matching the surrounding style. +- Braces on the same line, and **always braces** — an `if`, `else`, `for` or `while` body gets a + block even when it is a single statement. Plenty of older code omits them; do not copy that when + you are writing new code, and add them when you are already editing the lines in question. - Imports: `java.*` first, then third-party, then `org.diylc.*`, separated by blank lines. No wildcard imports. - Constants are `UPPER_SNAKE_CASE`. Note that component appearance constants are deliberately `public static` and **not** `final` (`BODY_COLOR`, `DEFAULT_WIDTH`), so they can be overridden at runtime; keep that convention when adding new ones. - `private static final Logger LOG = Logger.getLogger(TheClass.class);` — log4j 1.2, declared first - among the static members. + among the static members. Only declare one on a class that actually needs it; see *Logging* below. ### Comments @@ -93,6 +94,25 @@ g2d.setColor(bodyColor); Javadoc on public interfaces and abstract base classes in `diylc-core` is expected and should carry `@author`. Concrete component classes generally carry no Javadoc — do not add it just to fill space. +### Logging + +Keep logging sparse, and prefer removing a log line to adding one. A logger earns its place on a +class that has something a maintainer would want to read back: a swallowed failure, a slow or +one-off initialisation, an external file or service that may not be there. Do not declare a `LOG` +field that the class never uses. + +- `LOG.error` — something went wrong that the user or a bug report may need to explain. +- `LOG.warn` — a recoverable surprise. +- `LOG.info` — events that happen a handful of times per session, not per action. +- `LOG.debug` — anything finer. Assume it is switched off in the field. + +Never log from code on a hot path: `draw`, control-point accessors, property getters, anything the +canvas calls on every repaint or every mouse move. The cost is real and the output buries whatever +else is in the log. + +Do not wrap code in `try`/`catch` only so that there is something to log. If the block cannot +actually throw, the guard and the logger both come out. + ### License header **Every new source file starts with the GPL header**, copied verbatim from `HEADER.txt` (or from any diff --git a/diylc/diylc-core/src/main/java/org/diylc/components/AbstractOrthogonalComponent.java b/diylc/diylc-core/src/main/java/org/diylc/components/AbstractOrthogonalComponent.java new file mode 100644 index 00000000..b1a89b03 --- /dev/null +++ b/diylc/diylc-core/src/main/java/org/diylc/components/AbstractOrthogonalComponent.java @@ -0,0 +1,408 @@ +/* + + DIY Layout Creator (DIYLC). + Copyright (c) 2009-2025 held jointly by the individual authors. + + This file is part of DIYLC. + + DIYLC is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + DIYLC is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with DIYLC. If not, see . + +*/ +package org.diylc.components; + +import java.awt.Color; +import java.awt.Composite; +import java.awt.Graphics2D; +import java.awt.geom.Path2D; +import java.awt.geom.Point2D; +import java.awt.geom.Rectangle2D; +import java.util.ArrayList; +import java.util.List; + +import org.diylc.appframework.miscutils.ConfigurationManager; +import org.diylc.common.IPlugInPort; +import org.diylc.common.LineStyle; +import org.diylc.common.ObjectCache; +import org.diylc.common.OrientationHV; +import org.diylc.core.ComponentState; +import org.diylc.core.IDrawingObserver; +import org.diylc.core.IHaveLength; +import org.diylc.core.Project; +import org.diylc.core.VisibilityPolicy; +import org.diylc.core.annotations.EditableProperty; +import org.diylc.core.measures.Size; +import org.diylc.core.measures.SizeUnit; + +/** + * Base class for connectors that run only horizontally and vertically and turn at rounded + * 90-degree corners. + *

+ * The first and the last control point are the sticky ends. Each control point in between is a free + * bend vertex that the route reaches with a horizontal move followed by a vertical one, or the + * other way around when {@link #getStartDirection()} is vertical. Both coordinates of a bend point + * therefore carry meaning and the arrangement never has to be guessed: an orthogonal route of + * s segments has s - 2 degrees of freedom, and this template spends + * exactly two per bend point. Segments of zero length collapse silently, which is how the simple L, + * horizontal-vertical-horizontal and vertical-horizontal-vertical shapes are expressed: a bend + * point sharing a column with the start swallows the leading segment, one sharing a row with the + * end swallows the trailing one. All four combinations of leaving and arriving on either axis are + * therefore reachable by dragging alone, which is why the start direction is not offered as a + * property. + * + * @author Branislav Stojkovic + */ +public abstract class AbstractOrthogonalComponent extends AbstractTransparentComponent + implements IHaveLength { + + private static final long serialVersionUID = 1L; + + public static Size DEFAULT_SIZE = new Size(1d, SizeUnit.in); + public static Size DEFAULT_CORNER_RADIUS = new Size(0.05d, SizeUnit.in); + + // rounding is skipped below this radius in pixels, where a quadratic corner is indistinguishable + // from a sharp one + private static final double MIN_CORNER_RADIUS = 1d; + + private static final double EPSILON = 1e-6; + + protected Point2D[] controlPoints = new Point2D[] {new Point2D.Double(0, 0), + new Point2D.Double(DEFAULT_SIZE.convertToPixels() / 2, DEFAULT_SIZE.convertToPixels()), + new Point2D.Double(DEFAULT_SIZE.convertToPixels(), DEFAULT_SIZE.convertToPixels())}; + + protected Color color = getDefaultColor(); + protected PointCount pointCount = PointCount.THREE; + protected OrientationHV startDirection = OrientationHV.HORIZONTAL; + protected LineStyle style = LineStyle.SOLID; + protected Size cornerRadius = DEFAULT_CORNER_RADIUS; + + /** + * Draws the specified route onto graphics. + * + * @param curve + * @param g2d + * @param componentState + * @param drawingObserver + */ + protected abstract void drawCurve(Path2D curve, Graphics2D g2d, ComponentState componentState, + IDrawingObserver drawingObserver); + + /** + * @return default color. + */ + protected abstract Color getDefaultColor(); + + @Override + public void drawIcon(Graphics2D g2d, int width, int height) { + Path2D path = createIconPath(width, height); + g2d.setColor(getDefaultColor().darker()); + g2d.setStroke(ObjectCache.getInstance().fetchBasicStroke(3)); + g2d.draw(path); + g2d.setColor(getDefaultColor()); + g2d.setStroke(ObjectCache.getInstance().fetchBasicStroke(1)); + g2d.draw(path); + } + + /** + * @param width + * @param height + * @return the elbow drawn in the toolbox, so that subclasses that stroke it differently do not + * have to restate the shape. + */ + protected Path2D createIconPath(int width, int height) { + Path2D path = new Path2D.Double(); + path.moveTo(1, height - 3); + path.lineTo(width / 2d - 2, height - 3); + path.quadTo(width / 2d, height - 3, width / 2d, height - 5); + path.lineTo(width / 2d, 5); + path.quadTo(width / 2d, 3, width / 2d + 2, 3); + path.lineTo(width - 1, 3); + return path; + } + + @Override + public void draw(Graphics2D g2d, ComponentState componentState, boolean outlineMode, Project project, + IDrawingObserver drawingObserver) { + if (checkPointsClipped(g2d.getClip())) { + return; + } + + Composite oldComposite = applyAlpha(g2d); + + drawCurve(buildPath(), g2d, componentState, drawingObserver); + + g2d.setComposite(oldComposite); + } + + /** + * Expands the control points into the vertices of the route. Every control point is reached by a + * move along one axis followed by a move along the other, the end point included, so the loop + * below is the whole routing rule. Zero-length segments are dropped as they are produced. + * + * @return route vertices, starting with the first and ending with the last control point. + */ + protected List buildVertices() { + Point2D[] p = getControlPoints(); + boolean horizontalFirst = getStartDirection() == OrientationHV.HORIZONTAL; + List vertices = new ArrayList(); + addVertex(vertices, p[0]); + for (int i = 1; i < p.length; i++) { + Point2D previous = p[i - 1]; + Point2D next = p[i]; + if (horizontalFirst) { + addVertex(vertices, new Point2D.Double(next.getX(), previous.getY())); + } else { + addVertex(vertices, new Point2D.Double(previous.getX(), next.getY())); + } + addVertex(vertices, next); + } + return vertices; + } + + /** + * Builds the route as a path with rounded corners. The radius is clamped to half of each adjacent + * segment so that short segments bend rather than overshoot. + * + * @return path following the route. + */ + protected Path2D buildPath() { + List vertices = buildVertices(); + double radius = getCornerRadius().convertToPixels(); + Path2D path = new Path2D.Double(); + Point2D first = vertices.get(0); + path.moveTo(first.getX(), first.getY()); + for (int i = 1; i < vertices.size() - 1; i++) { + Point2D previous = vertices.get(i - 1); + Point2D corner = vertices.get(i); + Point2D next = vertices.get(i + 1); + double r = Math.min(radius, Math.min(previous.distance(corner), corner.distance(next)) / 2); + if (r < MIN_CORNER_RADIUS) { + path.lineTo(corner.getX(), corner.getY()); + continue; + } + Point2D in = interpolate(corner, previous, r); + Point2D out = interpolate(corner, next, r); + path.lineTo(in.getX(), in.getY()); + path.quadTo(corner.getX(), corner.getY(), out.getX(), out.getY()); + } + Point2D last = vertices.get(vertices.size() - 1); + path.lineTo(last.getX(), last.getY()); + return path; + } + + private static void addVertex(List vertices, Point2D p) { + if (!vertices.isEmpty() && vertices.get(vertices.size() - 1).distance(p) < EPSILON) { + return; + } + vertices.add(p); + } + + private static Point2D interpolate(Point2D from, Point2D towards, double distance) { + double t = distance / from.distance(towards); + return new Point2D.Double(from.getX() + (towards.getX() - from.getX()) * t, from.getY() + + (towards.getY() - from.getY()) * t); + } + + protected Point2D[] getControlPoints() { + return this.controlPoints; + } + + @EditableProperty(name = "Point Count") + public PointCount getPointCount() { + if (pointCount == null) { + pointCount = PointCount.THREE; + } + return pointCount; + } + + public void setPointCount(PointCount pointCount) { + if (getPointCount() == pointCount) { + return; + } + + Point2D[] p = getControlPoints(); + Point2D start = p[0]; + Point2D end = p[p.length - 1]; + Point2D[] newPoints = new Point2D[pointCount.count]; + newPoints[0] = start; + newPoints[pointCount.count - 1] = end; + + // spread the bend points so that the route comes out as an even staircase: they advance evenly + // along the axis the route travels last, and sit halfway between those steps on the other axis + int bendCount = pointCount.count - 2; + boolean horizontalFirst = getStartDirection() == OrientationHV.HORIZONTAL; + for (int i = 1; i <= bendCount; i++) { + double along = (double) i / bendCount; + double across = (2d * i - 1) / (2d * bendCount); + double tx = horizontalFirst ? across : along; + double ty = horizontalFirst ? along : across; + newPoints[i] = + new Point2D.Double(start.getX() + (end.getX() - start.getX()) * tx, start.getY() + + (end.getY() - start.getY()) * ty); + } + + this.controlPoints = newPoints; + this.pointCount = pointCount; + } + + /** + * Not an editable property on purpose. Every route shape is reachable by dragging the bend + * points, so exposing this would only give the user a second, less discoverable way to do the + * same thing. It is kept as state because a quarter-turn rotation swaps the two axes and the + * transformer has to record that. + * + * @return axis the route travels along when it leaves the first control point. + */ + public OrientationHV getStartDirection() { + if (startDirection == null) { + startDirection = OrientationHV.HORIZONTAL; + } + return startDirection; + } + + public void setStartDirection(OrientationHV startDirection) { + this.startDirection = startDirection; + } + + @EditableProperty(name = "Corner Radius") + public Size getCornerRadius() { + if (cornerRadius == null) { + cornerRadius = DEFAULT_CORNER_RADIUS; + } + return cornerRadius; + } + + public void setCornerRadius(Size cornerRadius) { + this.cornerRadius = cornerRadius; + } + + @EditableProperty(name = "Color") + public Color getLeadColor() { + return color; + } + + public void setLeadColor(Color color) { + this.color = color; + } + + @EditableProperty(name = "Style") + public LineStyle getStyle() { + if (style == null) { + style = LineStyle.SOLID; + } + return style; + } + + public void setStyle(LineStyle style) { + this.style = style; + } + + @EditableProperty(name = "Length") + public Size getLength() { + return calculateLength(); + } + + @Override + public Size calculateLength() { + // corner rounding shaves a fraction of the radius off each bend, which is well below the + // precision this readout is used at + List vertices = buildVertices(); + double d = 0; + for (int i = 1; i < vertices.size(); i++) { + d += vertices.get(i - 1).distance(vertices.get(i)); + } + + SizeUnit unit; + if (ConfigurationManager.getInstance().readBoolean(IPlugInPort.METRIC_KEY, true)) { + unit = SizeUnit.mm; + } else { + unit = SizeUnit.in; + } + + return new Size(d * SizeUnit.px.getFactor() / unit.getFactor(), unit); + } + + @Override + public int getControlPointCount() { + return getControlPoints().length; + } + + @Override + public Point2D getControlPoint(int index) { + return getControlPoints()[index]; + } + + @Override + public void setControlPoint(Point2D point, int index) { + getControlPoints()[index].setLocation(point); + } + + @Override + public boolean isControlPointSticky(int index) { + return index == 0 || index == getControlPointCount() - 1; + } + + @Override + public boolean canControlPointOverlap(int index) { + return index > 0 && index < getControlPointCount() - 1; + } + + @Override + public VisibilityPolicy getControlPointVisibilityPolicy(int index) { + return VisibilityPolicy.WHEN_SELECTED; + } + + @Override + public Rectangle2D getCachingBounds() { + double minX = Integer.MAX_VALUE; + double maxX = Integer.MIN_VALUE; + double minY = Integer.MAX_VALUE; + double maxY = Integer.MIN_VALUE; + double margin = 50; // hard coded, replace with something meaningful + // every route vertex borrows both of its coordinates from a control point, so the control + // points alone bound the whole route + for (int i = 0; i < getControlPointCount(); i++) { + Point2D p = getControlPoint(i); + if (p.getX() < minX) { + minX = p.getX(); + } + if (p.getX() > maxX) { + maxX = p.getX(); + } + if (p.getY() < minY) { + minY = p.getY(); + } + if (p.getY() > maxY) { + maxY = p.getY(); + } + } + + return new Rectangle2D.Double(minX - margin, minY - margin, maxX - minX + 2 * margin, maxY - minY + + 2 * margin); + } + + public enum PointCount { + THREE(3), FOUR(4), FIVE(5); + + private int count; + + private PointCount(int count) { + this.count = count; + } + + @Override + public String toString() { + return name().substring(0, 1) + name().substring(1).toLowerCase(); + } + } +} diff --git a/diylc/diylc-core/src/main/java/org/diylc/components/transform/OrthogonalComponentTransformer.java b/diylc/diylc-core/src/main/java/org/diylc/components/transform/OrthogonalComponentTransformer.java new file mode 100644 index 00000000..91547c22 --- /dev/null +++ b/diylc/diylc-core/src/main/java/org/diylc/components/transform/OrthogonalComponentTransformer.java @@ -0,0 +1,47 @@ +/* + + DIY Layout Creator (DIYLC). + Copyright (c) 2009-2025 held jointly by the individual authors. + + This file is part of DIYLC. + + DIYLC is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + DIYLC is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with DIYLC. If not, see . + +*/ +package org.diylc.components.transform; + +import java.awt.geom.Point2D; + +import org.diylc.common.OrientationHV; +import org.diylc.components.AbstractOrthogonalComponent; +import org.diylc.core.IDIYComponent; + +/** + * Rotates and mirrors orthogonal connectors. Quarter-turn rotation turns every horizontal run of + * the route into a vertical one, so the start direction has to follow the points. Mirroring leaves + * both axes in place and needs nothing beyond moving the points. + * + * @author Branislav Stojkovic + */ +public class OrthogonalComponentTransformer extends SimpleComponentTransformer { + + @Override + public void rotate(IDIYComponent component, Point2D center, int direction) { + super.rotate(component, center, direction); + if (component instanceof AbstractOrthogonalComponent orthogonal) { + orthogonal.setStartDirection(orthogonal.getStartDirection() == OrientationHV.HORIZONTAL + ? OrientationHV.VERTICAL : OrientationHV.HORIZONTAL); + } + } +} diff --git a/diylc/diylc-core/src/main/resources/update.xml b/diylc/diylc-core/src/main/resources/update.xml index 8724f8ed..93faa1c1 100644 --- a/diylc/diylc-core/src/main/resources/update.xml +++ b/diylc/diylc-core/src/main/resources/update.xml @@ -468,5 +468,21 @@ https://github.com/bancika/diy-layout-creator/releases + + + 6 + 6 + 0 + + 2026-09-11 00:00:00.000 CET + + + + NEW_FEATURE + Added 'Right Angle Wire' and 'Right Angle Line' components that route between two points using only horizontal and vertical runs with rounded corners + + + https://github.com/bancika/diy-layout-creator/releases + diff --git a/diylc/diylc-core/src/test/java/org/diylc/components/AbstractOrthogonalComponentTest.java b/diylc/diylc-core/src/test/java/org/diylc/components/AbstractOrthogonalComponentTest.java new file mode 100644 index 00000000..4d39beb4 --- /dev/null +++ b/diylc/diylc-core/src/test/java/org/diylc/components/AbstractOrthogonalComponentTest.java @@ -0,0 +1,217 @@ +/* + + DIY Layout Creator (DIYLC). + Copyright (c) 2009-2025 held jointly by the individual authors. + + This file is part of DIYLC. + + DIYLC is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + DIYLC is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with DIYLC. If not, see . + +*/ +package org.diylc.components; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import java.awt.Color; +import java.awt.Graphics2D; +import java.awt.geom.AffineTransform; +import java.awt.geom.Path2D; +import java.awt.geom.Point2D; +import java.util.ArrayList; +import java.util.List; + +import org.junit.Test; + +import org.diylc.common.OrientationHV; +import org.diylc.components.AbstractOrthogonalComponent.PointCount; +import org.diylc.components.transform.OrthogonalComponentTransformer; +import org.diylc.core.ComponentState; +import org.diylc.core.IDrawingObserver; + +public class AbstractOrthogonalComponentTest { + + private static final double DELTA = 1e-6; + + // --- routing --- + + @Test + public void buildVertices_bendOnEndRow_producesHorizontalVerticalHorizontal() { + OrthogonalTestComponent c = component(0, 0, 100, 50, 60, 50); + assertRoute(c, 0, 0, 60, 0, 60, 50, 100, 50); + } + + @Test + public void buildVertices_bendOnStartColumn_producesVerticalHorizontalVertical() { + OrthogonalTestComponent c = component(0, 0, 100, 50, 0, 30); + assertRoute(c, 0, 0, 0, 30, 100, 30, 100, 50); + } + + @Test + public void buildVertices_bendInsideTheBox_producesThreeBends() { + OrthogonalTestComponent c = component(0, 0, 100, 50, 60, 20); + assertRoute(c, 0, 0, 60, 0, 60, 20, 100, 20, 100, 50); + } + + @Test + public void buildVertices_bendOnBoxCorner_producesSingleBend() { + OrthogonalTestComponent c = component(0, 0, 100, 50, 100, 0); + assertRoute(c, 0, 0, 100, 0, 100, 50); + } + + @Test + public void buildVertices_bendOnOppositeBoxCorner_leavesVerticallyAndArrivesHorizontally() { + OrthogonalTestComponent c = component(0, 0, 100, 50, 0, 50); + assertRoute(c, 0, 0, 0, 50, 100, 50); + } + + @Test + public void buildVertices_bendOutsideTheBox_detoursAround() { + OrthogonalTestComponent c = component(0, 0, 100, 50, -40, 90); + assertRoute(c, 0, 0, -40, 0, -40, 90, 100, 90, 100, 50); + } + + @Test + public void buildVertices_verticalStart_swapsTheAxes() { + OrthogonalTestComponent c = component(0, 0, 100, 50, 60, 20); + c.setStartDirection(OrientationHV.VERTICAL); + assertRoute(c, 0, 0, 0, 20, 60, 20, 60, 50, 100, 50); + } + + @Test + public void buildVertices_collapsedRoute_keepsASingleVertex() { + OrthogonalTestComponent c = component(10, 10, 10, 10, 10, 10); + assertRoute(c, 10, 10); + } + + @Test + public void buildVertices_everySegmentIsAxisAligned() { + for (OrientationHV startDirection : OrientationHV.values()) { + for (double x = -50; x <= 150; x += 25) { + for (double y = -50; y <= 150; y += 25) { + OrthogonalTestComponent c = component(0, 0, 100, 50, x, y); + c.setStartDirection(startDirection); + assertAxisAligned(c); + } + } + } + } + + // --- point count --- + + @Test + public void setPointCount_keepsTheEndsAndStaysAxisAligned() { + OrthogonalTestComponent c = component(0, 0, 100, 50, 60, 50); + c.setPointCount(PointCount.FIVE); + assertEquals(5, c.getControlPointCount()); + assertPoint(0, 0, c.getControlPoint(0)); + assertPoint(100, 50, c.getControlPoint(4)); + assertAxisAligned(c); + } + + @Test + public void setPointCount_backAndForth_restoresTheEnds() { + OrthogonalTestComponent c = component(0, 0, 100, 50, 60, 50); + c.setPointCount(PointCount.FOUR); + c.setPointCount(PointCount.THREE); + assertEquals(3, c.getControlPointCount()); + assertPoint(0, 0, c.getControlPoint(0)); + assertPoint(100, 50, c.getControlPoint(2)); + } + + // --- transformer --- + + @Test + public void rotate_producesTheRotatedRoute() { + OrthogonalTestComponent c = component(0, 0, 100, 50, 60, 20); + AffineTransform rotate = AffineTransform.getRotateInstance(Math.PI / 2, 0, 0); + List expected = new ArrayList(); + for (Point2D p : c.buildVertices()) { + expected.add(rotate.transform(p, null)); + } + + new OrthogonalComponentTransformer().rotate(c, new Point2D.Double(0, 0), 1); + + assertEquals(OrientationHV.VERTICAL, c.getStartDirection()); + List actual = c.buildVertices(); + assertEquals(expected.size(), actual.size()); + for (int i = 0; i < expected.size(); i++) { + assertPoint(expected.get(i).getX(), expected.get(i).getY(), actual.get(i)); + } + } + + @Test + public void mirror_keepsTheStartDirection() { + OrthogonalTestComponent c = component(0, 0, 100, 50, 60, 20); + new OrthogonalComponentTransformer().mirror(c, new Point2D.Double(50, 25), 1); + assertEquals(OrientationHV.HORIZONTAL, c.getStartDirection()); + } + + // --- helpers --- + + private static OrthogonalTestComponent component(double startX, double startY, double endX, double endY, + double bendX, double bendY) { + OrthogonalTestComponent c = new OrthogonalTestComponent(); + c.setControlPoint(new Point2D.Double(startX, startY), 0); + c.setControlPoint(new Point2D.Double(bendX, bendY), 1); + c.setControlPoint(new Point2D.Double(endX, endY), 2); + return c; + } + + private static void assertRoute(OrthogonalTestComponent c, double... expected) { + List vertices = c.buildVertices(); + assertEquals(expected.length / 2, vertices.size()); + for (int i = 0; i < vertices.size(); i++) { + assertPoint(expected[2 * i], expected[2 * i + 1], vertices.get(i)); + } + } + + private static void assertPoint(double x, double y, Point2D actual) { + assertEquals(x, actual.getX(), DELTA); + assertEquals(y, actual.getY(), DELTA); + } + + private static void assertAxisAligned(OrthogonalTestComponent c) { + List vertices = c.buildVertices(); + for (int i = 1; i < vertices.size(); i++) { + Point2D previous = vertices.get(i - 1); + Point2D current = vertices.get(i); + boolean aligned = Math.abs(previous.getX() - current.getX()) < DELTA + || Math.abs(previous.getY() - current.getY()) < DELTA; + assertTrue("segment " + previous + " to " + current + " is not axis aligned", aligned); + } + } + + private static class OrthogonalTestComponent extends AbstractOrthogonalComponent { + + private static final long serialVersionUID = 1L; + + @Override + protected void drawCurve(Path2D curve, Graphics2D g2d, ComponentState componentState, + IDrawingObserver drawingObserver) {} + + @Override + protected Color getDefaultColor() { + return Color.black; + } + + @Override + public Void getValue() { + return null; + } + + @Override + public void setValue(Void value) {} + } +} diff --git a/diylc/diylc-library/src/main/java/org/diylc/components/connectivity/OrthogonalLine.java b/diylc/diylc-library/src/main/java/org/diylc/components/connectivity/OrthogonalLine.java new file mode 100644 index 00000000..960e003a --- /dev/null +++ b/diylc/diylc-library/src/main/java/org/diylc/components/connectivity/OrthogonalLine.java @@ -0,0 +1,195 @@ +/* + + DIY Layout Creator (DIYLC). + Copyright (c) 2009-2025 held jointly by the individual authors. + + This file is part of DIYLC. + + DIYLC is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + DIYLC is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with DIYLC. If not, see . + +*/ +package org.diylc.components.connectivity; + +import java.awt.BasicStroke; +import java.awt.Color; +import java.awt.Graphics2D; +import java.awt.Polygon; +import java.awt.Stroke; +import java.awt.geom.AffineTransform; +import java.awt.geom.Path2D; +import java.awt.geom.Point2D; +import java.util.List; + +import org.diylc.common.ObjectCache; +import org.diylc.components.AbstractOrthogonalComponent; +import org.diylc.components.transform.OrthogonalComponentTransformer; +import org.diylc.core.ComponentState; +import org.diylc.core.IContinuity; +import org.diylc.core.IDIYComponent; +import org.diylc.core.IDrawingObserver; +import org.diylc.core.annotations.BomPolicy; +import org.diylc.core.annotations.ComponentDescriptor; +import org.diylc.core.annotations.EditableProperty; +import org.diylc.core.annotations.KeywordPolicy; +import org.diylc.core.measures.Size; +import org.diylc.core.measures.SizeUnit; + +@ComponentDescriptor(name = "Right Angle Line", author = "Branislav Stojkovic", category = "Schematic Symbols", + instanceNamePrefix = "LN", description = "Line with rounded right angle turns and optional arrows", + zOrder = IDIYComponent.WIRING, flexibleZOrder = true, bomPolicy = BomPolicy.NEVER_SHOW, autoEdit = false, + keywordPolicy = KeywordPolicy.SHOW_TAG, keywordTag = "Schematic", + transformer = OrthogonalComponentTransformer.class, enableCache = false) +public class OrthogonalLine extends AbstractOrthogonalComponent implements IContinuity { + + private static final long serialVersionUID = 1L; + + public static Color COLOR = Color.black; + public static Size THICKNESS = new Size(1d, SizeUnit.px); + public static Size ARROW_SIZE = new Size(5d, SizeUnit.px); + + private Size thickness = THICKNESS; + private Size arrowSize = ARROW_SIZE; + private boolean arrowStart = false; + private boolean arrowEnd = false; + + private transient Polygon arrow; + + @Override + protected Color getDefaultColor() { + return COLOR; + } + + @Override + public void drawIcon(Graphics2D g2d, int width, int height) { + g2d.setStroke(ObjectCache.getInstance().fetchBasicStroke(1)); + g2d.setColor(COLOR); + g2d.draw(createIconPath(width, height)); + } + + @Override + protected void drawCurve(Path2D curve, Graphics2D g2d, ComponentState componentState, + IDrawingObserver drawingObserver) { + float thickness = (float) getThickness().convertToPixels(); + Stroke stroke = null; + switch (getStyle()) { + case SOLID: + stroke = ObjectCache.getInstance().fetchBasicStroke(thickness); + break; + case DASHED: + stroke = ObjectCache.getInstance().fetchStroke(thickness, new float[] {thickness * 2, thickness * 4}, + thickness * 4, BasicStroke.CAP_SQUARE); + break; + case DOTTED: + stroke = ObjectCache.getInstance().fetchStroke(thickness, new float[] {thickness, thickness * 5}, 0, + BasicStroke.CAP_ROUND); + break; + } + g2d.setStroke(stroke); + g2d.setColor(componentState == ComponentState.SELECTED || componentState == ComponentState.DRAGGING + ? SELECTION_COLOR : color); + g2d.draw(curve); + + if (getArrowStart() || getArrowEnd()) { + List vertices = buildVertices(); + // a route that collapses onto a single point has no direction to point an arrow along + if (vertices.size() > 1) { + if (getArrowStart()) { + drawArrow(g2d, vertices.get(0), vertices.get(1)); + } + if (getArrowEnd()) { + drawArrow(g2d, vertices.getLast(), vertices.get(vertices.size() - 2)); + } + } + } + } + + /** + * The arrow head is filled in the line color with its tip on the end point, so the line running + * underneath it does not have to be trimmed. + */ + private void drawArrow(Graphics2D g2d, Point2D tip, Point2D towards) { + AffineTransform oldTx = g2d.getTransform(); + g2d.translate(tip.getX(), tip.getY()); + g2d.rotate(Math.atan2(towards.getY() - tip.getY(), towards.getX() - tip.getX()) + Math.PI / 2); + g2d.fill(getArrow()); + g2d.setTransform(oldTx); + } + + public Polygon getArrow() { + if (arrow == null) { + arrow = new Polygon(); + int t = (int) getArrowSize().convertToPixels(); + arrow.addPoint(0, 0); + arrow.addPoint(-t, -t * 2); + arrow.addPoint(t, -t * 2); + } + return arrow; + } + + @EditableProperty + public Size getThickness() { + if (thickness == null) { + thickness = THICKNESS; + } + return thickness; + } + + public void setThickness(Size thickness) { + this.thickness = thickness; + } + + @EditableProperty(name = "Arrow Size") + public Size getArrowSize() { + if (arrowSize == null) { + arrowSize = ARROW_SIZE; + } + return arrowSize; + } + + public void setArrowSize(Size arrowSize) { + this.arrowSize = arrowSize; + arrow = null; + } + + @EditableProperty(name = "Start Arrow") + public boolean getArrowStart() { + return arrowStart; + } + + public void setArrowStart(boolean arrowStart) { + this.arrowStart = arrowStart; + } + + @EditableProperty(name = "End Arrow") + public boolean getArrowEnd() { + return arrowEnd; + } + + public void setArrowEnd(boolean arrowEnd) { + this.arrowEnd = arrowEnd; + } + + @Override + public Void getValue() { + return null; + } + + @Override + public void setValue(Void value) {} + + @Override + public boolean arePointsConnected(int index1, int index2) { + return Math.abs(index1 - index2) == getControlPointCount() - 1; + } +} diff --git a/diylc/diylc-library/src/main/java/org/diylc/components/connectivity/OrthogonalWire.java b/diylc/diylc-library/src/main/java/org/diylc/components/connectivity/OrthogonalWire.java new file mode 100644 index 00000000..0d0e3255 --- /dev/null +++ b/diylc/diylc-library/src/main/java/org/diylc/components/connectivity/OrthogonalWire.java @@ -0,0 +1,158 @@ +/* + + DIY Layout Creator (DIYLC). + Copyright (c) 2009-2025 held jointly by the individual authors. + + This file is part of DIYLC. + + DIYLC is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + DIYLC is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with DIYLC. If not, see . + +*/ +package org.diylc.components.connectivity; + +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.Path2D; + +import org.diylc.common.ObjectCache; +import org.diylc.components.AbstractOrthogonalComponent; +import org.diylc.components.transform.OrthogonalComponentTransformer; +import org.diylc.core.ComponentState; +import org.diylc.core.IContinuity; +import org.diylc.core.IDIYComponent; +import org.diylc.core.IDrawingObserver; +import org.diylc.core.annotations.BomPolicy; +import org.diylc.core.annotations.ComponentDescriptor; +import org.diylc.core.annotations.EditableProperty; +import org.diylc.utils.Constants; + +@ComponentDescriptor(name = "Right Angle Wire", author = "Branislav Stojkovic", category = "Connectivity", + instanceNamePrefix = "W", description = "Wire that runs horizontally and vertically and turns at right angles", + zOrder = IDIYComponent.WIRING, flexibleZOrder = true, bomPolicy = BomPolicy.NEVER_SHOW, + transformer = OrthogonalComponentTransformer.class, enableCache = false) +public class OrthogonalWire extends AbstractOrthogonalComponent implements IContinuity { + + private static final long serialVersionUID = 1L; + + public static Color COLOR = Color.green; + public static Color STRIPE_COLOR = Color.yellow; + public static double INSULATION_THICKNESS_PCT = 0.3; + + protected AWG gauge = AWG._22; + + protected boolean striped = false; + protected Color stripeColor = STRIPE_COLOR; + + @Override + protected Color getDefaultColor() { + return COLOR; + } + + @Override + protected void drawCurve(Path2D curve, Graphics2D g2d, ComponentState componentState, IDrawingObserver drawingObserver) { + int thickness = (int) (gauge.diameterIn() * Constants.PIXELS_PER_INCH * (1 + 2 * INSULATION_THICKNESS_PCT)) - 1; + Color curveColor = + componentState == ComponentState.SELECTED || componentState == ComponentState.DRAGGING ? SELECTION_COLOR + : color; + g2d.setColor(curveColor); + Stroke stroke = null; + Stroke tracingStroke = null; + switch (getStyle()) { + case SOLID: + stroke = ObjectCache.getInstance().fetchBasicStroke(thickness); + tracingStroke = stroke; + break; + case DASHED: + stroke = + ObjectCache.getInstance().fetchStroke(thickness, new float[] {thickness * 2, thickness * 3}, thickness * 4, BasicStroke.CAP_SQUARE); + tracingStroke = ObjectCache.getInstance().fetchBasicStroke(thickness); + break; + case DOTTED: + stroke = ObjectCache.getInstance().fetchStroke(thickness, new float[] {thickness / 4, thickness * 3}, 0, BasicStroke.CAP_ROUND); + tracingStroke = ObjectCache.getInstance().fetchBasicStroke(thickness); + break; + } + Shape s = stroke.createStrokedShape(curve); + + if (stroke == tracingStroke) { + g2d.fill(s); + drawingObserver.stopTracking(); + } else { + // draw without tracking + drawingObserver.stopTracking(); + g2d.fill(s); + // track with a simplified stroke without the dashes and dots to improve performance; + Shape traceShape = tracingStroke.createStrokedShape(curve); + drawingObserver.startTracking(); + Composite oldComposite = g2d.getComposite(); + g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.DST, 0f)); + g2d.fill(traceShape); + g2d.setComposite(oldComposite); + drawingObserver.stopTracking(); + } + + if (getStriped()) { + stroke = ObjectCache.getInstance().fetchStroke(thickness, new float[] { thickness / 2, thickness * 2 }, thickness * 10, BasicStroke.CAP_BUTT); + Shape stripe = stroke.createStrokedShape(curve); + g2d.setColor(getStripeColor()); + g2d.fill(stripe); + } + + if (componentState == ComponentState.NORMAL) { + g2d.setColor(color.darker()); + g2d.setStroke(ObjectCache.getInstance().fetchBasicStroke(1f)); + g2d.draw(s); + } + } + + @EditableProperty(name = "AWG") + public AWG getValue() { + return gauge; + } + + public void setValue(AWG gauge) { + this.gauge = gauge; + } + + @EditableProperty(name = "Stripe") + public boolean getStriped() { + return striped; + } + + public void setStriped(boolean striped) { + this.striped = striped; + } + + @EditableProperty(name = "Stripe Color") + public Color getStripeColor() { + if (stripeColor == null) { + stripeColor = STRIPE_COLOR; + } + return stripeColor; + } + + public void setStripeColor(Color stripeColor) { + this.stripeColor = stripeColor; + } + + @Override + public boolean arePointsConnected(int index1, int index2) { + return Math.abs(index1 - index2) == getControlPointCount() - 1; + } +} diff --git a/diylc/docs/plans/orthogonal-connector.md b/diylc/docs/plans/orthogonal-connector.md new file mode 100644 index 00000000..00822753 --- /dev/null +++ b/diylc/docs/plans/orthogonal-connector.md @@ -0,0 +1,214 @@ +# Orthogonal Connector + +A wire that runs only horizontally and vertically, turning at rounded 90-degree +corners. + +## 1. Goal + +`HookupWire` draws a free Bezier curve between two sticky endpoints; +`Line` draws a single straight segment. Neither can express the routing style +used in schematics and block diagrams, where a connection leaves a terminal on +an axis, turns at right angles, and arrives on an axis. This document designs +that third kind of connector. + +The shapes that must be reachable are, at minimum, horizontal-vertical-horizontal +and vertical-horizontal-vertical. Simple L bends and longer staircases should +fall out of the same mechanism rather than needing a separate component. + +## 2. How many handles does the route actually need + +An orthogonal route is a polyline whose segments alternate between horizontal +and vertical. Both endpoints are pinned, so counting the free numbers is easy: +a route of `s` segments has `s - 1` corners and `s - 2` continuous degrees of +freedom, plus **one discrete bit** saying whether the first segment leaves the +start point horizontally or vertically. + +| Segments | Shape | Corners | Continuous DOF | Bit | +| --- | --- | --- | --- | --- | +| 2 | `HV` / `VH` (an L) | 1 | 0 | yes | +| 3 | `HVH` / `VHV` | 2 | 1 | yes | +| 4 | `HVHV` / `VHVH` | 3 | 2 | yes | +| 6 | `HVHVHV` / … | 5 | 4 | yes | + +This is the crux of the problem stated in the request. A single interior control +point carries two numbers, but the `HVH` / `VHV` case needs *one* number and +*one bit*. One point is simultaneously one coordinate too many and one bit too +few, so no scheme that reads only that point's position can resolve the +arrangement without a heuristic that flips as the user drags across a diagonal. + +The line in the table that is exactly parameterised is the four-segment one: three +corners, two degrees of freedom, and a middle corner that is a genuine free point +in the plane. That observation drives the whole design. + +## 3. The chosen parameterisation + +**Each interior control point is a real bend vertex, and the route between +consecutive vertices is a fixed `H` then `V` pair.** For one interior point `h` +the route is + +``` + A ────────────┐ A = start (sticky) + │ c1 = (h.x, A.y) (derived) + h ─────────┐ h = the handle (draggable) + │ c2 = (B.x, h.y) (derived) + B B = end (sticky) +``` + +that is `A → (h.x, A.y) → h → (B.x, h.y) → B`: four segments, alternating, +starting horizontal and ending vertical. Both of the handle's coordinates are +used, nothing is discarded, and there is no hidden state. + +The requested arrangements are reached by letting segments collapse to zero +length, which is what snap-to-grid makes trivial: + +| Handle placed at | Route degenerates to | +| --- | --- | +| anywhere | `HVHV`, three visible bends | +| `h.y == B.y` | `HVH`, two bends, vertical leg at `h.x` | +| `h.x == A.x` | `VHV`, two bends, horizontal leg at `h.y` | +| `h == (A.x, B.y)` | `VH`, one bend | +| `h == (B.x, A.y)` | `HV`, one bend | + +The handle may sit outside the box spanned by the endpoints, which gives routes +that detour around obstacles. Nothing special is needed for that case. + +### Generalising + +`k` interior points chain the same template: + +``` +A → (h1.x, A.y) → h1 → (h2.x, h1.y) → h2 → … → (B.x, hk.y) → B +``` + +This is `2k + 2` segments and `2k` degrees of freedom, matching the `s - 2` rule +exactly. Every additional handle buys two more bends, every stored number is +meaningful, and the normalisation code is the same loop regardless of `k`. The +number of handles is an enum property, mirroring `AbstractCurvedComponent`'s +`Point Count`. + +### The bit does not need a property + +The template above always leaves the start horizontally, so in principle there is +a bit to expose. In practice there is not, because the collapses in the table +already cover every way a route can meet its endpoints: + +| Leaves the start | Arrives at the end | Reached by | +| --- | --- | --- | +| horizontally | vertically | bend point anywhere | +| horizontally | horizontally | bend point on the end row | +| vertically | vertically | bend point on the start column | +| vertically | horizontally | bend point on the opposite corner of the box | + +A `Start Direction` property would therefore be a second, less discoverable way +of doing what dragging already does, so it is not offered. The single shape it +would add is the mirror-image staircase through a given bend point, which one +more bend point reaches anyway. + +The bit does survive as a plain field, because a quarter-turn rotation swaps the +two axes and an `H V H V` route becomes a `V H V H` one, which the fixed template +cannot express. `OrthogonalComponentTransformer` records the swap there so that +rotating a component yields exactly the rotated route. Nothing else reads or +writes it, and it never appears in the property editor. + +### Why not the alternatives + +| Scheme | Verdict | +| --- | --- | +| One handle, only one coordinate honoured, arrangement from an enum | Half of the handle's motion is visibly inert with no explanation, and it does not generalise past two bends without a second property. | +| One handle, arrangement inferred from which side of the diagonal it is on | The route snaps between `HVH` and `VHV` mid-drag. Predictable to implement, unpleasant to use. | +| Store corners raw and project them onto a legal path on every read | Over-parameterised: `2k` stored numbers for `k - 1` degrees of freedom, so the projection needs to know which point moved last, and a whole-component drag rewrites the raw values and loses the arrangement. | + +## 4. Class layout + +| Class | Module | Role | +| --- | --- | --- | +| `AbstractOrthogonalComponent` | `diylc-core`, `org.diylc.components` | Sibling of `AbstractCurvedComponent`. Owns the control points, the point-count and start-direction properties, the corner radius, path construction, length and caching bounds. Declares `protected abstract void drawCurve(Path2D, Graphics2D, ComponentState, IDrawingObserver)`, the same hook `AbstractCurvedComponent` uses, so concrete subclasses stay thin. | +| `OrthogonalComponentTransformer` | `diylc-core`, `org.diylc.components.transform` | Delegates point rotation to `SimpleComponentTransformer`, then flips `Start Direction`. Mirroring needs no flip and passes straight through. | +| `OrthogonalWire` | `diylc-library`, `org.diylc.components.connectivity` | The layout part, shown as `Right Angle Wire` under `Connectivity`. `AWG` value, stripe support, `IContinuity`. Its body is close to a copy of `HookupWire`'s, which is the point of reusing the `drawCurve` hook. | +| `OrthogonalLine` | `diylc-library`, `org.diylc.components.connectivity` | The schematic part, shown as `Right Angle Line` under `Schematic Symbols`. Draws the same route as a plain stroke of a given thickness, one pixel by default, with the optional arrow heads `Line` offers. | + +The two concrete parts differ only in `drawCurve` and in the properties each adds +on top of the base. `Right Angle Line` mirrors `Line`'s property set exactly: +thickness, colour, style, arrow size and the two arrow toggles. It also carries +`IContinuity`, which `Line` does not, because it sits in `Schematic Symbols` +rather than `Shapes` and a wire drawn between two symbols is expected to show up +in the netlist. + +Its arrow heads take their direction from the first and the last route segment +rather than from the straight line between the endpoints, and they are not +trimmed out of the stroke the way `Line` trims its own. The head is filled in the +line colour with its tip on the endpoint, so whatever runs underneath it is +already hidden. + +## 5. Geometry + +`buildVertices()` returns the full vertex list including any zero-length +collapses, then consecutive duplicates are dropped. `buildPath()` walks the +deduplicated list and rounds each corner: + +``` +r_i = min(cornerRadius, len(v[i-1], v[i]) / 2, len(v[i], v[i+1]) / 2) +``` + +The corner is drawn as a quadratic Bezier whose control point is the corner +itself, from the point `r_i` before it to the point `r_i` after it. Clamping to +half of each adjacent segment means short segments and collapsed segments +degrade to sharp corners instead of overshooting; an arc would look marginally +better at large radii but the difference is invisible at the radii this +component is for. + +`Corner Radius` is a `Size` property, default around `0.05in`, `0` giving hard +corners. + +## 6. Component contract + +| Member | Behaviour | +| --- | --- | +| `creationMethod` | `SINGLE_CLICK`, as `HookupWire`. `POINT_BY_POINT` only ever assigns control points `0` and `1`, so it cannot place a component whose second endpoint is not index 1. | +| `isControlPointSticky` | `true` only for the first and last index. | +| `canControlPointOverlap` | `true` for interior indices. | +| `getControlPointVisibilityPolicy` | `WHEN_SELECTED` for every index. | +| `getControlPointNodeName` | `null` for interior indices. | +| `arePointsConnected` | `true` only for the pair `{0, count - 1}`, exactly as `HookupWire`. | +| Selection guidelines | Draw the dashed guideline polyline through the derived vertices with tracking off, as `AbstractCurvedComponent.draw` does. | +| Hit area | Falls out of filling the stroked path while the drawing observer is tracking. | +| `getLength` | Exact sum of segment lengths, so `IHaveLength` is honest here in a way the Bezier approximation is not. | +| `enableCache` | `false`, matching `HookupWire`. | + +## 7. Defaults + +Dropped on an empty canvas the component spans `DEFAULT_SIZE` diagonally, with +the single handle at `((A.x + B.x) / 2, B.y)`, which renders as a plain `HVH` +route. That is the shape most users expect from a right-angle connector, and one +drag of the handle reaches everything else. + +## 8. What was built + +| File | Module | +| --- | --- | +| `org/diylc/components/AbstractOrthogonalComponent.java` | `diylc-core` | +| `org/diylc/components/transform/OrthogonalComponentTransformer.java` | `diylc-core` | +| `org/diylc/components/connectivity/OrthogonalWire.java` | `diylc-library` | +| `org/diylc/components/connectivity/OrthogonalLine.java` | `diylc-library` | +| `org/diylc/components/AbstractOrthogonalComponentTest.java` | `diylc-core`, tests | + +The three open questions were settled as follows. + +1. The component is called `Right Angle Wire` and sits in `Connectivity`, next to + `Hookup Wire`, whose drawing code it shares through the `drawCurve` hook. Its + property list differs from `Hookup Wire`'s only in carrying `Corner Radius` + where the curved wire carries `Smooth`. +2. The handle count ships configurable from the start, as `Point Count` with + values `Three`, `Four` and `Five`, matching `AbstractCurvedComponent`'s + property of the same name. Changing it spreads the bend points into an even + staircase between the two ends. +3. No snapping was added. The clean shapes are already one grid step away, and a + snap that pulls a handle onto the start column or end row would fight + snap-to-grid rather than complement it. This is the decision most worth + revisiting, since with no `Start Direction` property those two alignments are + now the only route to the two-bend shapes. + +The test covers the routing template in both start directions, the degenerate +shapes, the invariant that every produced segment is axis aligned, point-count +changes, and the transformer contract that rotating the component yields exactly +the rotated route. From 3ed51658a242a28e2f504127d7508beeafb14f8b Mon Sep 17 00:00:00 2001 From: Bane Stojkovic Date: Fri, 11 Sep 2026 23:14:37 +0200 Subject: [PATCH 2/2] cleanup --- .../org/diylc/swing/plugins/analyze/AnalyzeMenuPlugin.java | 3 --- .../main/java/org/diylc/swing/plugins/cloud/CloudPlugIn.java | 3 --- .../swing/plugins/cloud/actions/ChangePasswordAction.java | 2 -- .../diylc/swing/plugins/cloud/actions/CreateAccountAction.java | 2 -- .../org/diylc/swing/plugins/cloud/actions/LoginAction.java | 2 -- .../diylc/swing/plugins/cloud/actions/ManageAccountAction.java | 2 -- 6 files changed, 14 deletions(-) diff --git a/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/analyze/AnalyzeMenuPlugin.java b/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/analyze/AnalyzeMenuPlugin.java index 3ea4ae96..2a8cf20d 100644 --- a/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/analyze/AnalyzeMenuPlugin.java +++ b/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/analyze/AnalyzeMenuPlugin.java @@ -18,7 +18,6 @@ */ package org.diylc.swing.plugins.analyze; -import org.apache.log4j.Logger; import org.diylc.appframework.miscutils.ConfigurationManager; import org.diylc.common.*; import org.diylc.swing.ActionFactory; @@ -43,8 +42,6 @@ import java.util.List; */ public class AnalyzeMenuPlugin implements IPlugIn { - private static final Logger LOG = Logger.getLogger(AnalyzeMenuPlugin.class); - private static final String ANALYZE_TITLE = "Analyze"; private static final String EXPORT_TITLE = "Export"; diff --git a/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/cloud/CloudPlugIn.java b/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/cloud/CloudPlugIn.java index 3868e045..6d6387c6 100644 --- a/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/cloud/CloudPlugIn.java +++ b/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/cloud/CloudPlugIn.java @@ -23,7 +23,6 @@ package org.diylc.swing.plugins.cloud; import java.util.EnumSet; -import org.apache.log4j.Logger; import org.diylc.appframework.miscutils.InMemoryConfigurationManager; import org.diylc.plugins.cloud.service.CloudService; @@ -41,8 +40,6 @@ public class CloudPlugIn implements IPlugIn { private static final String ONLINE_TITLE = "Cloud"; - private final static Logger LOG = Logger.getLogger(CloudPlugIn.class); - private ISwingUI swingUI; private IPlugInPort plugInPort; private IPlugInPort thumbnailPresenter; diff --git a/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/cloud/actions/ChangePasswordAction.java b/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/cloud/actions/ChangePasswordAction.java index 48bdbb1f..65d1d32f 100644 --- a/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/cloud/actions/ChangePasswordAction.java +++ b/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/cloud/actions/ChangePasswordAction.java @@ -23,7 +23,6 @@ package org.diylc.swing.plugins.cloud.actions; import java.awt.event.ActionEvent; import javax.swing.AbstractAction; -import org.apache.log4j.Logger; import org.diylc.common.ITask; import org.diylc.core.IView; import org.diylc.swing.ISwingUI; @@ -35,7 +34,6 @@ import org.diylc.utils.IconLoader; public class ChangePasswordAction extends AbstractAction { private static final long serialVersionUID = 1L; - private static final Logger LOG = Logger.getLogger(ChangePasswordAction.class); private final CloudPlugIn cloudPlugIn; public ChangePasswordAction(CloudPlugIn cloudPlugIn) { diff --git a/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/cloud/actions/CreateAccountAction.java b/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/cloud/actions/CreateAccountAction.java index 12bc7172..d2252f63 100644 --- a/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/cloud/actions/CreateAccountAction.java +++ b/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/cloud/actions/CreateAccountAction.java @@ -23,7 +23,6 @@ package org.diylc.swing.plugins.cloud.actions; import java.awt.event.ActionEvent; import javax.swing.AbstractAction; -import org.apache.log4j.Logger; import org.diylc.common.ITask; import org.diylc.core.IView; import org.diylc.swing.ISwingUI; @@ -35,7 +34,6 @@ import org.diylc.utils.IconLoader; public class CreateAccountAction extends AbstractAction { private static final long serialVersionUID = 1L; - private static final Logger LOG = Logger.getLogger(CreateAccountAction.class); private final CloudPlugIn cloudPlugIn; public CreateAccountAction(CloudPlugIn cloudPlugIn) { diff --git a/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/cloud/actions/LoginAction.java b/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/cloud/actions/LoginAction.java index 0dad8d57..ca5c3b24 100644 --- a/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/cloud/actions/LoginAction.java +++ b/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/cloud/actions/LoginAction.java @@ -23,7 +23,6 @@ package org.diylc.swing.plugins.cloud.actions; import java.awt.event.ActionEvent; import javax.swing.AbstractAction; -import org.apache.log4j.Logger; import org.diylc.common.ITask; import org.diylc.core.IView; import org.diylc.plugins.cloud.service.CloudException; @@ -36,7 +35,6 @@ import org.diylc.utils.IconLoader; public class LoginAction extends AbstractAction { private static final long serialVersionUID = 1L; - private static final Logger LOG = Logger.getLogger(LoginAction.class); private final CloudPlugIn cloudPlugIn; public LoginAction(CloudPlugIn cloudPlugIn) { diff --git a/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/cloud/actions/ManageAccountAction.java b/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/cloud/actions/ManageAccountAction.java index 1aa433ca..922ac0ab 100644 --- a/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/cloud/actions/ManageAccountAction.java +++ b/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/cloud/actions/ManageAccountAction.java @@ -23,7 +23,6 @@ package org.diylc.swing.plugins.cloud.actions; import java.awt.event.ActionEvent; import javax.swing.AbstractAction; -import org.apache.log4j.Logger; import org.diylc.common.ITask; import org.diylc.core.IView; import org.diylc.plugins.cloud.service.CloudException; @@ -36,7 +35,6 @@ import org.diylc.utils.IconLoader; public class ManageAccountAction extends AbstractAction { private static final long serialVersionUID = 1L; - private static final Logger LOG = Logger.getLogger(ManageAccountAction.class); private final CloudPlugIn cloudPlugIn; public ManageAccountAction(CloudPlugIn cloudPlugIn) {