better curvature

This commit is contained in:
Bane Stojkovic
2026-09-13 21:27:52 +02:00
parent 556dedc9a3
commit 69bb77d307
3 changed files with 135 additions and 16 deletions
@@ -289,11 +289,18 @@ 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.
* Waves the curve from side to side instead of leaving the intermediate points on the straight
* line joining the ends, so that a freshly created component reads as a slack wire that can be
* reshaped rather than as a rigid line. Successive handles go to opposite sides, which turns the
* default four point curve into an S, while the points that lie on the curve itself stay on the
* straight line and the sine envelope fades the excursions out towards both ends. The first
* excursion always points down the screen so that the shape does not depend on which end was
* drawn first.
* <p>
* Keeping the on-curve points on the line is what makes the wave survive: a handle either side
* of one of them then comes out as its exact mirror, which is the continuity rule that the
* smoothing in {@link #draw} enforces, so that pass leaves the shape alone instead of pulling
* one handle across to satisfy it.
*/
@Override
protected Point2D getStretchedPoint(Point2D first, Point2D second, int index) {
@@ -301,7 +308,7 @@ public abstract class AbstractCurvedComponent<T> extends AbstractTransparentComp
double dx = second.getX() - first.getX();
double dy = second.getY() - first.getY();
double length = Math.hypot(dx, dy);
if (length == 0) {
if (length == 0 || isOnCurve(index)) {
return p;
}
if (dx < 0 || (dx == 0 && dy > 0)) {
@@ -309,10 +316,44 @@ public abstract class AbstractCurvedComponent<T> extends AbstractTransparentComp
dy = -dy;
}
double t = (double) index / (getControlPointCount() - 1);
double offset = CREATION_CURVATURE * length * Math.sin(Math.PI * t);
double offset = getStretchedSide(index) * CREATION_CURVATURE * length * Math.sin(Math.PI * t);
return new Point2D.Double(p.getX() - dy / length * offset, p.getY() + dx / length * offset);
}
/**
* @param index
* @return true if the control point at the specified index lies on the curve rather than pulling
* it from the side, which follows from how {@link #draw} chains the segments together.
*/
private boolean isOnCurve(int index) {
if (index == 0 || index == getControlPointCount() - 1) {
return true;
}
if (getPointCount() == PointCount.FIVE) {
return index == 2;
}
if (getPointCount() == PointCount.SEVEN) {
return index == 3;
}
return false;
}
/**
* @param index
* @return which side of the straight line the handle at the specified index is pushed to,
* counting handles rather than control points so that an on-curve point in between does
* not break the alternation.
*/
private int getStretchedSide(int index) {
int handleCount = 0;
for (int i = 1; i < index; i++) {
if (!isOnCurve(i)) {
handleCount++;
}
}
return handleCount % 2 == 0 ? 1 : -1;
}
private Point2D findThirdPoint(Point2D p0, Point2D p) {
return new Point2D.Double(2 * p0.getX() - p.getX(), 2 * p0.getY() - p.getY());
}
@@ -481,6 +481,14 @@
<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>
<org.diylc.appframework.update.Change>
<changeType>NEW_FEATURE</changeType>
<description>Double click on a component type in the tree or the toolbar to instantiate it at once, even for those that are intended to be placed point-by-point</description>
</org.diylc.appframework.update.Change>
<org.diylc.appframework.update.Change>
<changeType>IMPROVEMENT</changeType>
<description>All curved components ('Curved Trace', 'Hookup Wire', 'Twisted Leads') are now getting created between the two points by default instead of getting created with some preset shape and length</description>
</org.diylc.appframework.update.Change>
</changes>
<url>https://github.com/bancika/diy-layout-creator/releases</url>
</org.diylc.appframework.update.Version>
@@ -48,33 +48,103 @@ public class AbstractCurvedComponentTest {
}
@Test
public void stretchBetween_bowsTheIntermediatePointsOffTheChord() {
public void stretchBetween_movesTheIntermediatePointsOffTheChord() {
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);
assertTrue("first handle should not sit on the chord",
Math.abs(c.getControlPoint(1).getY()) > 1);
assertTrue("second handle should not sit on the chord",
Math.abs(c.getControlPoint(2).getY()) > 1);
}
@Test
public void stretchBetween_bowIsSymmetric() {
public void stretchBetween_putsConsecutivePointsOnOppositeSides() {
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);
assertTrue("the first excursion should point down the screen", c.getControlPoint(1).getY() > 0);
assertTrue("the second excursion should point the other way", c.getControlPoint(2).getY() < 0);
}
@Test
public void stretchBetween_alternatesSidesForEveryPointCount() {
for (PointCount pointCount : PointCount.values()) {
CurvedTestComponent c = new CurvedTestComponent();
c.setPointCount(pointCount);
c.stretchBetween(new Point2D.Double(0, 0), new Point2D.Double(100, 0));
double previous = 0;
for (int i = 1; i < c.getControlPointCount() - 1; i++) {
double current = c.getControlPoint(i).getY();
// points that lie on the curve stay on the chord and do not take part in the alternation
if (Math.abs(current) < DELTA) {
continue;
}
if (previous != 0) {
assertTrue(pointCount + " handle " + i + " should sit opposite the previous one",
previous * current < 0);
}
previous = current;
}
}
}
@Test
public void stretchBetween_keepsTheOnCurvePointsOnTheStraightLine() {
for (PointCount pointCount : new PointCount[] {PointCount.FIVE, PointCount.SEVEN}) {
CurvedTestComponent c = new CurvedTestComponent();
c.setPointCount(pointCount);
c.stretchBetween(new Point2D.Double(0, 0), new Point2D.Double(100, 0));
assertEquals(pointCount + " should keep its middle point on the chord", 0,
c.getControlPoint(middleIndex(pointCount)).getY(), DELTA);
}
}
/**
* The smoothing in {@code draw} mirrors the handles around a point that lies on the curve, and
* rewrites one of them when they are not already mirrored. Placing them mirrored to begin with
* is what keeps that pass from pulling the freshly created wave out of shape.
*/
@Test
public void stretchBetween_placesHandlesTheWayTheSmoothingRuleWants() {
for (PointCount pointCount : new PointCount[] {PointCount.FIVE, PointCount.SEVEN}) {
CurvedTestComponent c = new CurvedTestComponent();
c.setPointCount(pointCount);
c.stretchBetween(new Point2D.Double(0, 0), new Point2D.Double(100, 60));
int middle = middleIndex(pointCount);
Point2D before = c.getControlPoint(middle - 1);
Point2D on = c.getControlPoint(middle);
Point2D after = c.getControlPoint(middle + 1);
assertEquals(pointCount + " handles should mirror horizontally", 2 * on.getX() - before.getX(),
after.getX(), DELTA);
assertEquals(pointCount + " handles should mirror vertically", 2 * on.getY() - before.getY(),
after.getY(), DELTA);
}
}
private static int middleIndex(PointCount pointCount) {
return pointCount == PointCount.FIVE ? 2 : 3;
}
@Test
public void stretchBetween_waveIsSymmetricAboutTheMiddle() {
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() {
public void stretchBetween_wavesTheSameWayRegardlessOfDrawingDirection() {
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);
for (int i = 1; i < forward.getControlPointCount() - 1; i++) {
assertEquals(forward.getControlPoint(i).getY(), backward.getControlPoint(i).getY(), DELTA);
}
}
@Test
public void stretchBetween_scalesTheBowWithTheDistance() {
public void stretchBetween_scalesTheWaveWithTheDistance() {
CurvedTestComponent shortWire = new CurvedTestComponent();
shortWire.stretchBetween(new Point2D.Double(0, 0), new Point2D.Double(50, 0));
CurvedTestComponent longWire = new CurvedTestComponent();