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/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) {
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.