stretch between

This commit is contained in:
Bane Stojkovic
2026-09-13 21:09:52 +02:00
parent 3ed51658a2
commit 823a59d455
12 changed files with 268 additions and 24 deletions
@@ -295,9 +295,39 @@ public abstract class AbstractComponent<T> implements IDIYComponent<T> {
}
@Override
public boolean canPointMoveFreely(int pointIndex) {
public boolean canPointMoveFreely(int pointIndex) {
return true;
}
@Override
public void stretchBetween(Point2D first, Point2D second) {
int count = getControlPointCount();
// fresh instances because implementations are free to keep the point objects they are handed
setControlPoint(new Point2D.Double(first.getX(), first.getY()), 0);
if (count > 1) {
setControlPoint(new Point2D.Double(second.getX(), second.getY()), count - 1);
}
for (int i = 1; i < count - 1; i++) {
setControlPoint(getStretchedPoint(first, second, i), i);
}
}
/**
* Decides where a control point that is neither of the two ends lands when the component is
* stretched between them, see {@link #stretchBetween(Point2D, Point2D)}. The default spaces the
* points evenly along the straight line joining the ends. Override to place a point that carries
* a meaning of its own, such as one that positions a label.
*
* @param first
* @param second
* @param index
* @return position of the control point at the specified index.
*/
protected Point2D getStretchedPoint(Point2D first, Point2D second, int index) {
double t = (double) index / (getControlPointCount() - 1);
return new Point2D.Double(first.getX() + t * (second.getX() - first.getX()),
first.getY() + t * (second.getY() - first.getY()));
}
@Override
public Rectangle2D getCachingBounds() {
@@ -52,6 +52,7 @@ public abstract class AbstractCurvedComponent<T> extends AbstractTransparentComp
public static Color GUIDELINE_COLOR = Color.blue;
public static Size DEFAULT_SIZE = new Size(1d, SizeUnit.in);
public static double CREATION_CURVATURE = 0.1;
// for backward compatibility
protected Point2D[] controlPoints = null;
@@ -287,6 +288,31 @@ public abstract class AbstractCurvedComponent<T> extends AbstractTransparentComp
// }
}
/**
* Bows the curve to one side instead of leaving the intermediate points on the straight line
* joining the ends, so that a freshly created component reads as a curve that can be reshaped
* rather than as a rigid line. The sine envelope vanishes at both ends and peaks in the middle,
* which yields a symmetric arc for any point count. The offset always points down the screen so
* that the wire sags the same way no matter which end was drawn first.
*/
@Override
protected Point2D getStretchedPoint(Point2D first, Point2D second, int index) {
Point2D p = super.getStretchedPoint(first, second, index);
double dx = second.getX() - first.getX();
double dy = second.getY() - first.getY();
double length = Math.hypot(dx, dy);
if (length == 0) {
return p;
}
if (dx < 0 || (dx == 0 && dy > 0)) {
dx = -dx;
dy = -dy;
}
double t = (double) index / (getControlPointCount() - 1);
double offset = CREATION_CURVATURE * length * Math.sin(Math.PI * t);
return new Point2D.Double(p.getX() - dy / length * offset, p.getY() + dx / length * offset);
}
private Point2D findThirdPoint(Point2D p0, Point2D p) {
return new Point2D.Double(2 * p0.getX() - p.getX(), 2 * p0.getY() - p.getY());
}
@@ -237,24 +237,40 @@ public abstract class AbstractOrthogonalComponent<T> extends AbstractTransparent
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);
newPoints[i] = getBendPoint(start, end, i, bendCount);
}
this.controlPoints = newPoints;
this.pointCount = pointCount;
}
@Override
protected Point2D getStretchedPoint(Point2D first, Point2D second, int index) {
return getBendPoint(first, second, index, getControlPointCount() - 2);
}
/**
* Spreads 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.
*
* @param start
* @param end
* @param index
* @param bendCount number of control points between the two ends
* @return position of the bend point at the specified index.
*/
private Point2D getBendPoint(Point2D start, Point2D end, int index, int bendCount) {
double along = (double) index / bendCount;
double across = (2d * index - 1) / (2d * bendCount);
boolean horizontalFirst = getStartDirection() == OrientationHV.HORIZONTAL;
double tx = horizontalFirst ? across : along;
double ty = horizontalFirst ? along : across;
return new Point2D.Double(start.getX() + (end.getX() - start.getX()) * tx, start.getY()
+ (end.getY() - start.getY()) * ty);
}
/**
* 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
@@ -105,6 +105,17 @@ public interface IDIYComponent<T> extends Serializable, Cloneable {
*/
void setControlPoint(Point2D point, int index);
/**
* Positions the component so that it spans the two points, which is how components created with
* <code>CreationMethod.POINT_BY_POINT</code> are shaped while the user is placing them. The first
* and the last control point go to the two ends; where the points in between land is up to the
* component.
*
* @param first
* @param second
*/
void stretchBetween(Point2D first, Point2D second);
/**
* @param index
* @return true, if the specified control point may stick to control points of other components.
@@ -123,23 +123,21 @@ public class InstantiationManager {
firstControlPoint = scaledPoint;
componentSlot = instantiateComponent(componentTypeSlot, firstControlPoint, currentProject);
// Set the other control point to the same location, we'll
// move it later when mouse moves.
componentSlot.get(0).setControlPoint(firstControlPoint, 0);
componentSlot.get(0).setControlPoint(firstControlPoint, 1);
// Collapse the component onto the first point, we'll stretch it later when mouse moves.
componentSlot.get(0).stretchBetween(firstControlPoint, firstControlPoint);
}
/**
* Updates component in the slot with the new second control point.
*
* Updates component in the slot with the new end control point.
*
* @param scaledPoint
* @return true, if any change is made
*/
public boolean updatePointByPoint(Point2D scaledPoint) {
boolean changeMade = !scaledPoint.equals(potentialControlPoint);
potentialControlPoint = scaledPoint;
if (componentSlot != null && !componentSlot.isEmpty()) {
componentSlot.get(0).setControlPoint(scaledPoint, 1);
if (componentSlot != null && !componentSlot.isEmpty() && firstControlPoint != null) {
componentSlot.get(0).stretchBetween(firstControlPoint, scaledPoint);
}
return changeMade;
}
@@ -0,0 +1,131 @@
/*
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.Path2D;
import java.awt.geom.Point2D;
import org.junit.Test;
import org.diylc.components.AbstractCurvedComponent.PointCount;
import org.diylc.core.ComponentState;
import org.diylc.core.IDrawingObserver;
public class AbstractCurvedComponentTest {
private static final double DELTA = 1e-6;
@Test
public void stretchBetween_putsTheEndsExactlyOnTheSuppliedPoints() {
CurvedTestComponent c = new CurvedTestComponent();
c.stretchBetween(new Point2D.Double(10, 20), new Point2D.Double(110, 20));
assertPoint(10, 20, c.getControlPoint(0));
assertPoint(110, 20, c.getControlPoint(3));
}
@Test
public void stretchBetween_bowsTheIntermediatePointsOffTheChord() {
CurvedTestComponent c = new CurvedTestComponent();
c.stretchBetween(new Point2D.Double(0, 0), new Point2D.Double(100, 0));
assertTrue("first handle should not sit on the chord", c.getControlPoint(1).getY() > 1);
assertTrue("second handle should not sit on the chord", c.getControlPoint(2).getY() > 1);
}
@Test
public void stretchBetween_bowIsSymmetric() {
CurvedTestComponent c = new CurvedTestComponent();
c.stretchBetween(new Point2D.Double(0, 0), new Point2D.Double(100, 0));
assertEquals(c.getControlPoint(1).getY(), c.getControlPoint(2).getY(), DELTA);
assertEquals(100 - c.getControlPoint(2).getX(), c.getControlPoint(1).getX(), DELTA);
}
@Test
public void stretchBetween_sagsTheSameWayRegardlessOfDrawingDirection() {
CurvedTestComponent forward = new CurvedTestComponent();
forward.stretchBetween(new Point2D.Double(0, 0), new Point2D.Double(100, 0));
CurvedTestComponent backward = new CurvedTestComponent();
backward.stretchBetween(new Point2D.Double(100, 0), new Point2D.Double(0, 0));
assertEquals(forward.getControlPoint(1).getY(), backward.getControlPoint(2).getY(), DELTA);
assertEquals(forward.getControlPoint(2).getY(), backward.getControlPoint(1).getY(), DELTA);
}
@Test
public void stretchBetween_scalesTheBowWithTheDistance() {
CurvedTestComponent shortWire = new CurvedTestComponent();
shortWire.stretchBetween(new Point2D.Double(0, 0), new Point2D.Double(50, 0));
CurvedTestComponent longWire = new CurvedTestComponent();
longWire.stretchBetween(new Point2D.Double(0, 0), new Point2D.Double(200, 0));
assertEquals(4 * shortWire.getControlPoint(1).getY(), longWire.getControlPoint(1).getY(), DELTA);
}
@Test
public void stretchBetween_collapsesOntoASinglePointWithoutDividingByZero() {
CurvedTestComponent c = new CurvedTestComponent();
c.stretchBetween(new Point2D.Double(40, 40), new Point2D.Double(40, 40));
for (int i = 0; i < c.getControlPointCount(); i++) {
assertPoint(40, 40, c.getControlPoint(i));
}
}
@Test
public void stretchBetween_keepsTheEndsForEveryPointCount() {
for (PointCount pointCount : PointCount.values()) {
CurvedTestComponent c = new CurvedTestComponent();
c.setPointCount(pointCount);
c.stretchBetween(new Point2D.Double(0, 0), new Point2D.Double(100, 60));
assertPoint(0, 0, c.getControlPoint(0));
assertPoint(100, 60, c.getControlPoint(c.getControlPointCount() - 1));
}
}
private static void assertPoint(double x, double y, Point2D actual) {
assertEquals(x, actual.getX(), DELTA);
assertEquals(y, actual.getY(), DELTA);
}
private static class CurvedTestComponent extends AbstractCurvedComponent<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) {}
}
}
@@ -130,6 +130,32 @@ public class AbstractOrthogonalComponentTest {
assertPoint(100, 50, c.getControlPoint(2));
}
// --- stretching ---
@Test
public void stretchBetween_keepsTheEndsAndStaysAxisAligned() {
OrthogonalTestComponent c = component(0, 0, 100, 50, 60, 20);
c.stretchBetween(new Point2D.Double(10, 10), new Point2D.Double(210, 110));
assertPoint(10, 10, c.getControlPoint(0));
assertPoint(210, 110, c.getControlPoint(2));
assertAxisAligned(c);
}
@Test
public void stretchBetween_placesTheBendsWhereSetPointCountWould() {
OrthogonalTestComponent stretched = component(0, 0, 100, 50, 60, 20);
stretched.setPointCount(PointCount.FIVE);
stretched.stretchBetween(new Point2D.Double(0, 0), new Point2D.Double(100, 50));
OrthogonalTestComponent expanded = component(0, 0, 100, 50, 60, 20);
expanded.setPointCount(PointCount.FIVE);
for (int i = 0; i < expanded.getControlPointCount(); i++) {
assertPoint(expanded.getControlPoint(i).getX(), expanded.getControlPoint(i).getY(),
stretched.getControlPoint(i));
}
}
// --- transformer ---
@Test
@@ -33,6 +33,7 @@ import org.diylc.components.AbstractLeadedComponent;
import org.diylc.components.LineEndingStyle;
import org.diylc.components.transform.SimpleComponentTransformer;
import org.diylc.core.ComponentState;
import org.diylc.core.CreationMethod;
import org.diylc.core.IDIYComponent;
import org.diylc.core.IDrawingObserver;
import org.diylc.core.ILayeredComponent;
@@ -50,7 +51,7 @@ import org.diylc.core.measures.SizeUnit;
instanceNamePrefix = "Trace", description = "Curved copper trace with two control points",
zOrder = IDIYComponent.TRACE, bomPolicy = BomPolicy.NEVER_SHOW,
keywordPolicy = KeywordPolicy.SHOW_TAG, keywordTag = "PCB", transformer = SimpleComponentTransformer.class,
enableCache = true)
enableCache = true, creationMethod = CreationMethod.POINT_BY_POINT)
public class CurvedTrace extends AbstractCurvedComponent<Void> implements ILayeredComponent, IGerberComponentSimple {
private static final long serialVersionUID = 1L;
@@ -30,6 +30,7 @@ import org.diylc.common.ObjectCache;
import org.diylc.components.AbstractCurvedComponent;
import org.diylc.components.transform.SimpleComponentTransformer;
import org.diylc.core.ComponentState;
import org.diylc.core.CreationMethod;
import org.diylc.core.IContinuity;
import org.diylc.core.IDIYComponent;
import org.diylc.core.IDrawingObserver;
@@ -40,7 +41,7 @@ import org.diylc.utils.Constants;
@ComponentDescriptor(name = "Hookup Wire", author = "Branislav Stojkovic", category = "Connectivity",
instanceNamePrefix = "W", description = "Flexible wire with two control points", zOrder = IDIYComponent.WIRING,
flexibleZOrder = true, bomPolicy = BomPolicy.NEVER_SHOW,
flexibleZOrder = true, bomPolicy = BomPolicy.NEVER_SHOW, creationMethod = CreationMethod.POINT_BY_POINT,
transformer = SimpleComponentTransformer.class, enableCache = false)
public class HookupWire extends AbstractCurvedComponent<AWG> implements IContinuity {
@@ -35,6 +35,7 @@ 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.CreationMethod;
import org.diylc.core.IContinuity;
import org.diylc.core.IDIYComponent;
import org.diylc.core.IDrawingObserver;
@@ -49,7 +50,7 @@ import org.diylc.core.measures.SizeUnit;
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)
transformer = OrthogonalComponentTransformer.class, enableCache = false, creationMethod = CreationMethod.POINT_BY_POINT)
public class OrthogonalLine extends AbstractOrthogonalComponent<Void> implements IContinuity {
private static final long serialVersionUID = 1L;
@@ -34,6 +34,7 @@ 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.CreationMethod;
import org.diylc.core.IContinuity;
import org.diylc.core.IDIYComponent;
import org.diylc.core.IDrawingObserver;
@@ -45,7 +46,7 @@ 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)
transformer = OrthogonalComponentTransformer.class, enableCache = false, creationMethod = CreationMethod.POINT_BY_POINT)
public class OrthogonalWire extends AbstractOrthogonalComponent<AWG> implements IContinuity {
private static final long serialVersionUID = 1L;
@@ -38,6 +38,7 @@ import org.diylc.common.ObjectCache;
import org.diylc.components.AbstractCurvedComponent;
import org.diylc.components.transform.SimpleComponentTransformer;
import org.diylc.core.ComponentState;
import org.diylc.core.CreationMethod;
import org.diylc.core.IContinuity;
import org.diylc.core.IDIYComponent;
import org.diylc.core.IDrawingObserver;
@@ -50,7 +51,8 @@ import org.diylc.utils.Constants;
category = "Connectivity", instanceNamePrefix = "W",
description = "A pair of flexible leads twisted tightly together", zOrder = IDIYComponent.WIRING,
flexibleZOrder = true, bomPolicy = BomPolicy.NEVER_SHOW,
transformer = SimpleComponentTransformer.class, enableCache = true)
transformer = SimpleComponentTransformer.class, enableCache = true,
creationMethod = CreationMethod.POINT_BY_POINT)
public class TwistedWire extends AbstractCurvedComponent<Void> implements IContinuity {
private static final long serialVersionUID = 1L;