Rotating and mirroring the selection with 'Sticky Points' enabled now keeps the circuit connected

This commit is contained in:
Bane Stojkovic
2026-10-20 13:28:24 +02:00
parent 08a53f20bd
commit 0ded7a35aa
4 changed files with 339 additions and 22 deletions
@@ -23,6 +23,7 @@ import java.awt.Graphics2D;
import java.awt.Point; import java.awt.Point;
import java.awt.Rectangle; import java.awt.Rectangle;
import java.awt.dnd.DnDConstants; import java.awt.dnd.DnDConstants;
import java.awt.geom.AffineTransform;
import java.awt.geom.Area; import java.awt.geom.Area;
import java.awt.geom.Point2D; import java.awt.geom.Point2D;
import java.awt.geom.Rectangle2D; import java.awt.geom.Rectangle2D;
@@ -846,20 +847,27 @@ public class Presenter implements IPlugInPort {
boolean snapToGrid = snapTo.equalsIgnoreCase(IPlugInPort.SNAP_TO_GRID); boolean snapToGrid = snapTo.equalsIgnoreCase(IPlugInPort.SNAP_TO_GRID);
boolean snapToObjects = snapTo.equalsIgnoreCase(IPlugInPort.SNAP_TO_COMPONENTS); boolean snapToObjects = snapTo.equalsIgnoreCase(IPlugInPort.SNAP_TO_COMPONENTS);
boolean sticky = configManager.readBoolean(IPlugInPort.STICKY_POINTS_KEY, true);
if (ctrlDown) {
sticky = !sticky;
}
if (altDown) { if (altDown) {
Project oldProject = null; Project oldProject = null;
if (key == IKeyProcessor.VK_RIGHT) { if (key == IKeyProcessor.VK_RIGHT) {
oldProject = currentProject.clone(); oldProject = currentProject.clone();
rotateComponents(this.selectedComponents, 1, snapToGrid); rotateComponents(this.selectedComponents, 1, snapToGrid, sticky);
} else if (key == IKeyProcessor.VK_LEFT) { } else if (key == IKeyProcessor.VK_LEFT) {
oldProject = currentProject.clone(); oldProject = currentProject.clone();
rotateComponents(this.selectedComponents, -1, snapToGrid); rotateComponents(this.selectedComponents, -1, snapToGrid, sticky);
} else if (key == IKeyProcessor.VK_H) { } else if (key == IKeyProcessor.VK_H) {
oldProject = currentProject.clone(); oldProject = currentProject.clone();
mirrorComponents(this.selectedComponents, IComponentTransformer.HORIZONTAL, snapToGrid); mirrorComponents(this.selectedComponents, IComponentTransformer.HORIZONTAL, snapToGrid,
sticky);
} else if (key == IKeyProcessor.VK_V) { } else if (key == IKeyProcessor.VK_V) {
oldProject = currentProject.clone(); oldProject = currentProject.clone();
mirrorComponents(this.selectedComponents, IComponentTransformer.VERTICAL, snapToGrid); mirrorComponents(this.selectedComponents, IComponentTransformer.VERTICAL, snapToGrid,
sticky);
} else } else
return false; return false;
@@ -869,11 +877,6 @@ public class Presenter implements IPlugInPort {
} }
// Expand control points to include all stuck components. // Expand control points to include all stuck components.
boolean sticky = configManager.readBoolean(IPlugInPort.STICKY_POINTS_KEY, true);
if (ctrlDown) {
sticky = !sticky;
}
if (sticky) { if (sticky) {
includeStuckComponents(controlPointMap); includeStuckComponents(controlPointMap);
} }
@@ -1442,7 +1445,9 @@ public class Presenter implements IPlugInPort {
private void includeStuckComponents(Map<IDIYComponent<?>, Set<Integer>> controlPointMap, private void includeStuckComponents(Map<IDIYComponent<?>, Set<Integer>> controlPointMap,
Set<IDIYComponent<?>> excludedComponents) { Set<IDIYComponent<?>> excludedComponents) {
int oldSize = controlPointMap.size(); // counted in points rather than components, a component that already had a free point in the
// map and now gains its rigid ones exposes new points for the next iteration to match
int oldSize = countControlPoints(controlPointMap);
LOG.trace("Expanding selected component map"); LOG.trace("Expanding selected component map");
for (IDIYComponent<?> component : currentProject.getComponents()) { for (IDIYComponent<?> component : currentProject.getComponents()) {
// Check if there's a control point in the current selection // Check if there's a control point in the current selection
@@ -1496,13 +1501,13 @@ public class Presenter implements IPlugInPort {
} }
} }
} }
int newSize = controlPointMap.size(); int newSize = countControlPoints(controlPointMap);
// As long as we're adding new components, do another iteration. // As long as we're adding new points, do another iteration.
if (newSize > oldSize) { if (newSize > oldSize) {
LOG.trace("Component count changed, trying one more time."); LOG.trace("Point count changed, trying one more time.");
includeStuckComponents(controlPointMap, excludedComponents); includeStuckComponents(controlPointMap, excludedComponents);
} else { } else {
LOG.trace("Component count didn't change, done with expanding."); LOG.trace("Point count didn't change, done with expanding.");
for (Map.Entry<IDIYComponent<?>, Set<Integer>> entry : controlPointMap.entrySet()) { for (Map.Entry<IDIYComponent<?>, Set<Integer>> entry : controlPointMap.entrySet()) {
IDIYComponent<?> component = entry.getKey(); IDIYComponent<?> component = entry.getKey();
Set<Integer> selectedPoints = entry.getValue(); Set<Integer> selectedPoints = entry.getValue();
@@ -1528,6 +1533,14 @@ public class Presenter implements IPlugInPort {
} }
} }
private static int countControlPoints(Map<IDIYComponent<?>, Set<Integer>> controlPointMap) {
int count = 0;
for (Set<Integer> indices : controlPointMap.values()) {
count += indices.size();
}
return count;
}
/** /**
* A group moves as a single rigid unit, so when one member is dragged along by a stuck control * A group moves as a single rigid unit, so when one member is dragged along by a stuck control
* point the rest of the group has to follow with all of its control points. * point the rest of the group has to follow with all of its control points.
@@ -1829,7 +1842,8 @@ public class Presenter implements IPlugInPort {
if (!selectedComponents.isEmpty()) { if (!selectedComponents.isEmpty()) {
LOG.trace("Rotating selected components"); LOG.trace("Rotating selected components");
Project oldProject = currentProject.clone(); Project oldProject = currentProject.clone();
rotateComponents(this.selectedComponents, direction, isSnapToGrid()); rotateComponents(this.selectedComponents, direction, isSnapToGrid(),
configManager.readBoolean(IPlugInPort.STICKY_POINTS_KEY, true));
notifyProjectModifiedIfNeeded(oldProject, "Rotate Selection", true, true); notifyProjectModifiedIfNeeded(oldProject, "Rotate Selection", true, true);
} }
@@ -1840,11 +1854,13 @@ public class Presenter implements IPlugInPort {
* @param direction 1 for clockwise, -1 for counter-clockwise * @param direction 1 for clockwise, -1 for counter-clockwise
*/ */
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
private void rotateComponents(Collection<IDIYComponent<?>> components, int direction, boolean snapToGrid) { private void rotateComponents(Collection<IDIYComponent<?>> components, int direction,
boolean snapToGrid, boolean sticky) {
Point2D center = getCenterOf(components, snapToGrid); Point2D center = getCenterOf(components, snapToGrid);
StuckPointFollower follower = createStuckPointFollower(components, sticky);
boolean canRotate = true; boolean canRotate = true;
for (IDIYComponent<?> component : selectedComponents) { for (IDIYComponent<?> component : follower.getTransformedComponents()) {
ComponentType type = ComponentType type =
ComponentProcessor.getInstance().extractComponentTypeFrom( ComponentProcessor.getInstance().extractComponentTypeFrom(
(Class<? extends IDIYComponent<?>>) component.getClass()); (Class<? extends IDIYComponent<?>>) component.getClass());
@@ -1859,7 +1875,7 @@ public class Presenter implements IPlugInPort {
ROTATE_SELECTION, IView.YES_NO_OPTION, IView.QUESTION_MESSAGE) != IView.YES_OPTION) ROTATE_SELECTION, IView.YES_NO_OPTION, IView.QUESTION_MESSAGE) != IView.YES_OPTION)
return; return;
for (IDIYComponent<?> component : selectedComponents) { for (IDIYComponent<?> component : follower.getTransformedComponents()) {
ComponentType type = ComponentType type =
ComponentProcessor.getInstance().extractComponentTypeFrom( ComponentProcessor.getInstance().extractComponentTypeFrom(
(Class<? extends IDIYComponent<?>>) component.getClass()); (Class<? extends IDIYComponent<?>>) component.getClass());
@@ -1867,6 +1883,8 @@ public class Presenter implements IPlugInPort {
type.getTransformer().rotate(component, center, direction); type.getTransformer().rotate(component, center, direction);
} }
} }
follower.follow(AffineTransform.getRotateInstance(Math.PI / 2 * direction, center.getX(),
center.getY()));
// Remove from area map. // Remove from area map.
drawingManager.clearComponentAreaMap(); drawingManager.clearComponentAreaMap();
@@ -1879,19 +1897,22 @@ public class Presenter implements IPlugInPort {
LOG.trace("Mirroring selected components"); LOG.trace("Mirroring selected components");
Project oldProject = currentProject.clone(); Project oldProject = currentProject.clone();
mirrorComponents(selectedComponents, direction, isSnapToGrid()); mirrorComponents(selectedComponents, direction, isSnapToGrid(),
configManager.readBoolean(IPlugInPort.STICKY_POINTS_KEY, true));
notifyProjectModifiedIfNeeded(oldProject, "Mirror Selection", true, true); notifyProjectModifiedIfNeeded(oldProject, "Mirror Selection", true, true);
} }
} }
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
private void mirrorComponents(Collection<IDIYComponent<?>> components, int direction, boolean snapToGrid) { private void mirrorComponents(Collection<IDIYComponent<?>> components, int direction,
boolean snapToGrid, boolean sticky) {
Point2D center = getCenterOf(components, snapToGrid); Point2D center = getCenterOf(components, snapToGrid);
StuckPointFollower follower = createStuckPointFollower(components, sticky);
boolean canMirror = true; boolean canMirror = true;
boolean changesCircuit = false; boolean changesCircuit = false;
for (IDIYComponent<?> component : components) { for (IDIYComponent<?> component : follower.getTransformedComponents()) {
ComponentType type = ComponentType type =
ComponentProcessor.getInstance().extractComponentTypeFrom( ComponentProcessor.getInstance().extractComponentTypeFrom(
(Class<? extends IDIYComponent<?>>) component.getClass()); (Class<? extends IDIYComponent<?>>) component.getClass());
@@ -1913,7 +1934,7 @@ public class Presenter implements IPlugInPort {
MIRROR_SELECTION, IView.YES_NO_OPTION, IView.QUESTION_MESSAGE) != IView.YES_OPTION) MIRROR_SELECTION, IView.YES_NO_OPTION, IView.QUESTION_MESSAGE) != IView.YES_OPTION)
return; return;
for (IDIYComponent<?> component : components) { for (IDIYComponent<?> component : follower.getTransformedComponents()) {
ComponentType type = ComponentType type =
ComponentProcessor.getInstance().extractComponentTypeFrom( ComponentProcessor.getInstance().extractComponentTypeFrom(
(Class<? extends IDIYComponent<?>>) component.getClass()); (Class<? extends IDIYComponent<?>>) component.getClass());
@@ -1921,12 +1942,34 @@ public class Presenter implements IPlugInPort {
type.getTransformer().mirror(component, center, direction); type.getTransformer().mirror(component, center, direction);
} }
} }
if (direction == IComponentTransformer.HORIZONTAL) {
follower.follow(new AffineTransform(-1, 0, 0, 1, 2 * center.getX(), 0));
} else {
follower.follow(new AffineTransform(1, 0, 0, -1, 0, 2 * center.getY()));
}
// Remove from area map. // Remove from area map.
drawingManager.clearComponentAreaMap(); drawingManager.clearComponentAreaMap();
drawingManager.clearContinuityArea(); drawingManager.clearContinuityArea();
} }
private StuckPointFollower createStuckPointFollower(Collection<IDIYComponent<?>> components,
boolean sticky) {
Map<IDIYComponent<?>, Set<Integer>> controlPointMap =
new HashMap<IDIYComponent<?>, Set<Integer>>();
for (IDIYComponent<?> component : components) {
Set<Integer> indices = new HashSet<Integer>();
for (int i = 0; i < component.getControlPointCount(); i++) {
indices.add(i);
}
controlPointMap.put(component, indices);
}
if (sticky) {
includeStuckComponents(controlPointMap);
}
return new StuckPointFollower(controlPointMap, DrawingManager.CONTROL_POINT_SIZE);
}
private Point2D getCenterOf(Collection<IDIYComponent<?>> components, boolean snapToGrid) { private Point2D getCenterOf(Collection<IDIYComponent<?>> components, boolean snapToGrid) {
// Determine center of rotation // Determine center of rotation
double minX = Integer.MAX_VALUE; double minX = Integer.MAX_VALUE;
@@ -0,0 +1,114 @@
/*
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.presenter;
import java.awt.geom.AffineTransform;
import java.awt.geom.Point2D;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.diylc.core.IDIYComponent;
/**
* Keeps stuck control points attached while a selection is rotated or mirrored. It takes a control
* point map expanded with the stuck components: components with all of their control points in the
* map are transformed as a whole, while the remaining points, typically the free end of a wire or
* a lead, are captured before the transformation and moved onto the point they were stuck to
* afterwards.
*
* @author Branislav Stojkovic
*/
public class StuckPointFollower {
private final Set<IDIYComponent<?>> transformedComponents = new HashSet<IDIYComponent<?>>();
private final List<StuckPoint> stuckPoints = new ArrayList<StuckPoint>();
public StuckPointFollower(Map<IDIYComponent<?>, Set<Integer>> controlPointMap,
double tolerance) {
for (Map.Entry<IDIYComponent<?>, Set<Integer>> entry : controlPointMap.entrySet()) {
if (entry.getValue().size() >= entry.getKey().getControlPointCount()) {
transformedComponents.add(entry.getKey());
}
}
for (Map.Entry<IDIYComponent<?>, Set<Integer>> entry : controlPointMap.entrySet()) {
IDIYComponent<?> component = entry.getKey();
if (transformedComponents.contains(component)) {
continue;
}
for (Integer index : entry.getValue()) {
stuckPoints.add(findAnchor(component, index, tolerance));
}
}
}
public Set<IDIYComponent<?>> getTransformedComponents() {
return transformedComponents;
}
/**
* Moves every stuck point onto the current location of its anchor. A point that is not stuck
* directly to a transformed component, only to another stuck point, has no anchor and is mapped
* through {@code transform} instead.
*/
public void follow(AffineTransform transform) {
for (StuckPoint stuckPoint : stuckPoints) {
Point2D p;
if (stuckPoint.anchor() == null) {
p = transform.transform(stuckPoint.originalLocation(), null);
} else {
p = new Point2D.Double();
p.setLocation(stuckPoint.anchor().getControlPoint(stuckPoint.anchorIndex()));
}
stuckPoint.component().setControlPoint(p, stuckPoint.index());
}
}
// the anchor is remembered by index rather than by location, so the point stays on the same
// pin even when a transformer renumbers positions, as mirroring an IC does
private StuckPoint findAnchor(IDIYComponent<?> component, int index, double tolerance) {
Point2D location = new Point2D.Double();
location.setLocation(component.getControlPoint(index));
IDIYComponent<?> anchor = null;
int anchorIndex = -1;
double bestDistance = tolerance;
for (IDIYComponent<?> candidate : transformedComponents) {
for (int i = 0; i < candidate.getControlPointCount(); i++) {
if (candidate.isControlPointSticky(i)) {
double distance = location.distance(candidate.getControlPoint(i));
if (distance < bestDistance) {
bestDistance = distance;
anchor = candidate;
anchorIndex = i;
}
}
}
}
return new StuckPoint(component, index, location, anchor, anchorIndex);
}
private record StuckPoint(IDIYComponent<?> component, int index, Point2D originalLocation,
IDIYComponent<?> anchor, int anchorIndex) {
}
}
@@ -665,6 +665,10 @@
<releaseDate>2026-10-17 00:00:00.000 CET</releaseDate> <releaseDate>2026-10-17 00:00:00.000 CET</releaseDate>
<name></name> <name></name>
<changes> <changes>
<org.diylc.appframework.update.Change>
<changeType>IMPROVEMENT</changeType>
<description>Rotating and mirroring the selection with 'Sticky Points' enabled now keeps the circuit connected, the same way moving it does - wires and leads stuck to the selection stay attached to their pins, and other rigid components stuck to it are rotated or mirrored along with it</description>
</org.diylc.appframework.update.Change>
<org.diylc.appframework.update.Change> <org.diylc.appframework.update.Change>
<changeType>BUG_FIX</changeType> <changeType>BUG_FIX</changeType>
<description>Gerber export on computers set to a language that uses a decimal comma (e.g. German, French, Serbian) produced files in which drill holes, pads and traces had the wrong size - a 0.8mm hole came out as 0mm and traces and the board outline had no width, so the files could not be used for fabrication</description> <description>Gerber export on computers set to a language that uses a decimal comma (e.g. German, French, Serbian) produced files in which drill holes, pads and traces had the wrong size - a 0.8mm hole came out as 0mm and traces and the board outline had no width, so the files could not be used for fabrication</description>
@@ -0,0 +1,156 @@
/*
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.presenter;
import static org.junit.Assert.assertEquals;
import java.awt.Graphics2D;
import java.awt.geom.AffineTransform;
import java.awt.geom.Point2D;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import org.diylc.components.AbstractComponent;
import org.diylc.core.ComponentState;
import org.diylc.core.IDIYComponent;
import org.diylc.core.IDrawingObserver;
import org.diylc.core.Project;
import org.diylc.core.VisibilityPolicy;
import org.junit.Test;
public class StuckPointFollowerTest {
private static final double TOLERANCE = 7;
@Test
public void testFullyIncludedComponentsAreTransformed() {
TestPart part = new TestPart(new Point2D.Double(0, 0), new Point2D.Double(40, 0));
TestPart wire = new TestPart(new Point2D.Double(40, 0), new Point2D.Double(100, 0));
Map<IDIYComponent<?>, Set<Integer>> controlPointMap =
new HashMap<IDIYComponent<?>, Set<Integer>>();
controlPointMap.put(part, Set.of(0, 1));
controlPointMap.put(wire, Set.of(0));
StuckPointFollower follower = new StuckPointFollower(controlPointMap, TOLERANCE);
assertEquals(Set.of(part), follower.getTransformedComponents());
}
@Test
public void testStuckPointFollowsAnchorByIndex() {
TestPart part = new TestPart(new Point2D.Double(0, 0), new Point2D.Double(40, 0));
TestPart wire = new TestPart(new Point2D.Double(40, 0), new Point2D.Double(100, 0));
Map<IDIYComponent<?>, Set<Integer>> controlPointMap =
new HashMap<IDIYComponent<?>, Set<Integer>>();
controlPointMap.put(part, Set.of(0, 1));
controlPointMap.put(wire, Set.of(0));
StuckPointFollower follower = new StuckPointFollower(controlPointMap, TOLERANCE);
// a transformer that does not map points through the plain reflection, the way mirroring an
// IC moves its pins
part.setControlPoint(new Point2D.Double(-20, 30), 1);
follower.follow(new AffineTransform(-1, 0, 0, 1, 40, 0));
assertEquals(new Point2D.Double(-20, 30), wire.getControlPoint(0));
assertEquals(new Point2D.Double(100, 0), wire.getControlPoint(1));
}
@Test
public void testPointWithoutAnchorIsTransformed() {
TestPart part = new TestPart(new Point2D.Double(0, 0), new Point2D.Double(40, 0));
TestPart wire = new TestPart(new Point2D.Double(40, 20), new Point2D.Double(100, 0));
Map<IDIYComponent<?>, Set<Integer>> controlPointMap =
new HashMap<IDIYComponent<?>, Set<Integer>>();
controlPointMap.put(part, Set.of(0, 1));
controlPointMap.put(wire, Set.of(0));
StuckPointFollower follower = new StuckPointFollower(controlPointMap, TOLERANCE);
follower.follow(AffineTransform.getRotateInstance(Math.PI / 2, 20, 0));
assertEquals(0, wire.getControlPoint(0).distance(0, 20), 1e-9);
}
private static class TestPart extends AbstractComponent<Void> {
private static final long serialVersionUID = 1L;
private final Point2D[] points;
public TestPart(Point2D... points) {
this.points = points;
}
@Override
public int getControlPointCount() {
return points.length;
}
@Override
public Point2D getControlPoint(int index) {
return points[index];
}
@Override
public void setControlPoint(Point2D point, int index) {
points[index].setLocation(point);
}
@Override
public boolean isControlPointSticky(int index) {
return true;
}
@Override
public boolean canControlPointOverlap(int index) {
return true;
}
@Override
public VisibilityPolicy getControlPointVisibilityPolicy(int index) {
return VisibilityPolicy.WHEN_SELECTED;
}
@Override
public String getControlPointNodeName(int index) {
return null;
}
@Override
public Void getValue() {
return null;
}
@Override
public void setValue(Void value) {
}
@Override
public void draw(Graphics2D g2d, ComponentState componentState, boolean outlineMode,
Project project, IDrawingObserver drawingObserver) {
}
@Override
public void drawIcon(Graphics2D g2d, int width, int height) {
}
}
}