diff --git a/diylc/diylc-core/src/main/java/org/diylc/presenter/Presenter.java b/diylc/diylc-core/src/main/java/org/diylc/presenter/Presenter.java index 291f21286..f9e65800e 100644 --- a/diylc/diylc-core/src/main/java/org/diylc/presenter/Presenter.java +++ b/diylc/diylc-core/src/main/java/org/diylc/presenter/Presenter.java @@ -23,6 +23,7 @@ import java.awt.Graphics2D; import java.awt.Point; import java.awt.Rectangle; import java.awt.dnd.DnDConstants; +import java.awt.geom.AffineTransform; import java.awt.geom.Area; import java.awt.geom.Point2D; import java.awt.geom.Rectangle2D; @@ -846,20 +847,27 @@ public class Presenter implements IPlugInPort { boolean snapToGrid = snapTo.equalsIgnoreCase(IPlugInPort.SNAP_TO_GRID); boolean snapToObjects = snapTo.equalsIgnoreCase(IPlugInPort.SNAP_TO_COMPONENTS); + boolean sticky = configManager.readBoolean(IPlugInPort.STICKY_POINTS_KEY, true); + if (ctrlDown) { + sticky = !sticky; + } + if (altDown) { Project oldProject = null; if (key == IKeyProcessor.VK_RIGHT) { oldProject = currentProject.clone(); - rotateComponents(this.selectedComponents, 1, snapToGrid); + rotateComponents(this.selectedComponents, 1, snapToGrid, sticky); } else if (key == IKeyProcessor.VK_LEFT) { oldProject = currentProject.clone(); - rotateComponents(this.selectedComponents, -1, snapToGrid); + rotateComponents(this.selectedComponents, -1, snapToGrid, sticky); } else if (key == IKeyProcessor.VK_H) { oldProject = currentProject.clone(); - mirrorComponents(this.selectedComponents, IComponentTransformer.HORIZONTAL, snapToGrid); + mirrorComponents(this.selectedComponents, IComponentTransformer.HORIZONTAL, snapToGrid, + sticky); } else if (key == IKeyProcessor.VK_V) { oldProject = currentProject.clone(); - mirrorComponents(this.selectedComponents, IComponentTransformer.VERTICAL, snapToGrid); + mirrorComponents(this.selectedComponents, IComponentTransformer.VERTICAL, snapToGrid, + sticky); } else return false; @@ -869,11 +877,6 @@ public class Presenter implements IPlugInPort { } // Expand control points to include all stuck components. - boolean sticky = configManager.readBoolean(IPlugInPort.STICKY_POINTS_KEY, true); - if (ctrlDown) { - sticky = !sticky; - } - if (sticky) { includeStuckComponents(controlPointMap); } @@ -1442,7 +1445,9 @@ public class Presenter implements IPlugInPort { private void includeStuckComponents(Map, Set> controlPointMap, Set> 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"); for (IDIYComponent component : currentProject.getComponents()) { // Check if there's a control point in the current selection @@ -1496,13 +1501,13 @@ public class Presenter implements IPlugInPort { } } } - int newSize = controlPointMap.size(); - // As long as we're adding new components, do another iteration. + int newSize = countControlPoints(controlPointMap); + // As long as we're adding new points, do another iteration. if (newSize > oldSize) { - LOG.trace("Component count changed, trying one more time."); + LOG.trace("Point count changed, trying one more time."); includeStuckComponents(controlPointMap, excludedComponents); } else { - LOG.trace("Component count didn't change, done with expanding."); + LOG.trace("Point count didn't change, done with expanding."); for (Map.Entry, Set> entry : controlPointMap.entrySet()) { IDIYComponent component = entry.getKey(); Set selectedPoints = entry.getValue(); @@ -1528,6 +1533,14 @@ public class Presenter implements IPlugInPort { } } + private static int countControlPoints(Map, Set> controlPointMap) { + int count = 0; + for (Set 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 * 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()) { LOG.trace("Rotating selected components"); 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); } @@ -1840,11 +1854,13 @@ public class Presenter implements IPlugInPort { * @param direction 1 for clockwise, -1 for counter-clockwise */ @SuppressWarnings("unchecked") - private void rotateComponents(Collection> components, int direction, boolean snapToGrid) { + private void rotateComponents(Collection> components, int direction, + boolean snapToGrid, boolean sticky) { Point2D center = getCenterOf(components, snapToGrid); + StuckPointFollower follower = createStuckPointFollower(components, sticky); boolean canRotate = true; - for (IDIYComponent component : selectedComponents) { + for (IDIYComponent component : follower.getTransformedComponents()) { ComponentType type = ComponentProcessor.getInstance().extractComponentTypeFrom( (Class>) component.getClass()); @@ -1859,7 +1875,7 @@ public class Presenter implements IPlugInPort { ROTATE_SELECTION, IView.YES_NO_OPTION, IView.QUESTION_MESSAGE) != IView.YES_OPTION) return; - for (IDIYComponent component : selectedComponents) { + for (IDIYComponent component : follower.getTransformedComponents()) { ComponentType type = ComponentProcessor.getInstance().extractComponentTypeFrom( (Class>) component.getClass()); @@ -1867,6 +1883,8 @@ public class Presenter implements IPlugInPort { type.getTransformer().rotate(component, center, direction); } } + follower.follow(AffineTransform.getRotateInstance(Math.PI / 2 * direction, center.getX(), + center.getY())); // Remove from area map. drawingManager.clearComponentAreaMap(); @@ -1879,19 +1897,22 @@ public class Presenter implements IPlugInPort { LOG.trace("Mirroring selected components"); 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); } } @SuppressWarnings("unchecked") - private void mirrorComponents(Collection> components, int direction, boolean snapToGrid) { + private void mirrorComponents(Collection> components, int direction, + boolean snapToGrid, boolean sticky) { Point2D center = getCenterOf(components, snapToGrid); + StuckPointFollower follower = createStuckPointFollower(components, sticky); boolean canMirror = true; boolean changesCircuit = false; - for (IDIYComponent component : components) { + for (IDIYComponent component : follower.getTransformedComponents()) { ComponentType type = ComponentProcessor.getInstance().extractComponentTypeFrom( (Class>) component.getClass()); @@ -1913,7 +1934,7 @@ public class Presenter implements IPlugInPort { MIRROR_SELECTION, IView.YES_NO_OPTION, IView.QUESTION_MESSAGE) != IView.YES_OPTION) return; - for (IDIYComponent component : components) { + for (IDIYComponent component : follower.getTransformedComponents()) { ComponentType type = ComponentProcessor.getInstance().extractComponentTypeFrom( (Class>) component.getClass()); @@ -1921,12 +1942,34 @@ public class Presenter implements IPlugInPort { 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. drawingManager.clearComponentAreaMap(); drawingManager.clearContinuityArea(); } + private StuckPointFollower createStuckPointFollower(Collection> components, + boolean sticky) { + Map, Set> controlPointMap = + new HashMap, Set>(); + for (IDIYComponent component : components) { + Set indices = new HashSet(); + 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> components, boolean snapToGrid) { // Determine center of rotation double minX = Integer.MAX_VALUE; diff --git a/diylc/diylc-core/src/main/java/org/diylc/presenter/StuckPointFollower.java b/diylc/diylc-core/src/main/java/org/diylc/presenter/StuckPointFollower.java new file mode 100644 index 000000000..5ccfb057f --- /dev/null +++ b/diylc/diylc-core/src/main/java/org/diylc/presenter/StuckPointFollower.java @@ -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 . + +*/ +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> transformedComponents = new HashSet>(); + private final List stuckPoints = new ArrayList(); + + public StuckPointFollower(Map, Set> controlPointMap, + double tolerance) { + for (Map.Entry, Set> entry : controlPointMap.entrySet()) { + if (entry.getValue().size() >= entry.getKey().getControlPointCount()) { + transformedComponents.add(entry.getKey()); + } + } + for (Map.Entry, Set> 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> 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) { + } +} diff --git a/diylc/diylc-core/src/main/resources/update.xml b/diylc/diylc-core/src/main/resources/update.xml index 70c4e135a..d96ce767d 100644 --- a/diylc/diylc-core/src/main/resources/update.xml +++ b/diylc/diylc-core/src/main/resources/update.xml @@ -665,6 +665,10 @@ 2026-10-17 00:00:00.000 CET + + IMPROVEMENT + 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 + BUG_FIX 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 diff --git a/diylc/diylc-core/src/test/java/org/diylc/presenter/StuckPointFollowerTest.java b/diylc/diylc-core/src/test/java/org/diylc/presenter/StuckPointFollowerTest.java new file mode 100644 index 000000000..73bca5eae --- /dev/null +++ b/diylc/diylc-core/src/test/java/org/diylc/presenter/StuckPointFollowerTest.java @@ -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 . + +*/ +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, Set> controlPointMap = + new HashMap, Set>(); + 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, Set> controlPointMap = + new HashMap, Set>(); + 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, Set> controlPointMap = + new HashMap, Set>(); + 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 { + + 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) { + } + } +}