Added 'Right Angle Wire' and 'Right Angle Line' components that route between two points using only horizontal and vertical runs with rounded corners

This commit is contained in:
Bane Stojkovic
2026-09-11 23:14:30 +02:00
parent 14bdeec281
commit d41aa02674
8 changed files with 1278 additions and 3 deletions
+23 -3
View File
@@ -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
@@ -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 <http://www.gnu.org/licenses/>.
*/
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.
* <p>
* 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
* <code>s</code> segments has <code>s - 2</code> 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<T> extends AbstractTransparentComponent<T>
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<Point2D> buildVertices() {
Point2D[] p = getControlPoints();
boolean horizontalFirst = getStartDirection() == OrientationHV.HORIZONTAL;
List<Point2D> vertices = new ArrayList<Point2D>();
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<Point2D> 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<Point2D> 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<Point2D> 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();
}
}
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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);
}
}
}
@@ -468,5 +468,21 @@
</changes>
<url>https://github.com/bancika/diy-layout-creator/releases</url>
</org.diylc.appframework.update.Version>
<org.diylc.appframework.update.Version>
<versionNumber>
<major>6</major>
<minor>6</minor>
<build>0</build>
</versionNumber>
<releaseDate>2026-09-11 00:00:00.000 CET</releaseDate>
<name></name>
<changes>
<org.diylc.appframework.update.Change>
<changeType>NEW_FEATURE</changeType>
<description>Added 'Right Angle Wire' and 'Right Angle Line' components that route between two points using only horizontal and vertical runs with rounded corners</description>
</org.diylc.appframework.update.Change>
</changes>
<url>https://github.com/bancika/diy-layout-creator/releases</url>
</org.diylc.appframework.update.Version>
</a>
</java.util.Arrays_-ArrayList>
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<Point2D> expected = new ArrayList<Point2D>();
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<Point2D> 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<Point2D> 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<Point2D> 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<Void> {
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) {}
}
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<Void> 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<Point2D> 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;
}
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<AWG> 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;
}
}
+214
View File
@@ -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<T>` | `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.