From b76d315f440d2ec4a7a05ea08c9777cb556571d7 Mon Sep 17 00:00:00 2001 From: Bane Stojkovic Date: Wed, 11 Nov 2026 15:05:28 +0100 Subject: [PATCH] Components can be dragged and their points stretched through the Underside Display mirror of a board --- .../org/diylc/presenter/DrawingManager.java | 29 ++++- .../java/org/diylc/presenter/Presenter.java | 123 +++++++++++++----- .../test/java/org/diylc/PresenterTests.java | 76 +++++++++++ diylc/docs/plans/bottom-side-components.md | 29 +++-- 4 files changed, 206 insertions(+), 51 deletions(-) diff --git a/diylc/diylc-core/src/main/java/org/diylc/presenter/DrawingManager.java b/diylc/diylc-core/src/main/java/org/diylc/presenter/DrawingManager.java index 077bfd5f4..e9e04212a 100644 --- a/diylc/diylc-core/src/main/java/org/diylc/presenter/DrawingManager.java +++ b/diylc/diylc-core/src/main/java/org/diylc/presenter/DrawingManager.java @@ -751,9 +751,8 @@ public class DrawingManager { public List> findMirroredComponentsAt(Point2D point, Project project) { List> components = new ArrayList>(); for (IBoard board : UndersideMirror.findBoardsMirroredAt(project, point)) { - Point2D reflected = UndersideMirror.createTransform(board).transform(point, null); - for (IDIYComponent component : findComponentsAt(reflected, project)) { - if (UndersideMirror.isMirrored(board, component) && !components.contains(component)) { + for (IDIYComponent component : findMirroredComponentsAt(point, project, board)) { + if (!components.contains(component)) { components.add(component); } } @@ -761,6 +760,30 @@ public class DrawingManager { return components; } + /** + * Finds the components that the underside mirror of the specified board shows at the specified + * point, top first, or none when the point is not in that mirror. + * + * @param point + * @param project + * @param board + * @return + */ + public List> findMirroredComponentsAt(Point2D point, Project project, + IBoard board) { + List> components = new ArrayList>(); + AffineTransform mirrorTx = UndersideMirror.createTransform(board); + if (mirrorTx == null || !UndersideMirror.getMirrorRegion(board).contains(point)) { + return components; + } + for (IDIYComponent component : findComponentsAt(mirrorTx.transform(point, null), project)) { + if (UndersideMirror.isMirrored(board, component)) { + components.add(component); + } + } + return components; + } + public double getExtraSpace(Project project) { double width = project.getWidth().convertToPixels(); double height = project.getHeight().convertToPixels(); 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 70666666d..cf6ec5936 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 @@ -172,7 +172,12 @@ public class Presenter implements IPlugInPort { private Point2D dragStartPoint = null; private boolean dragShiftDown = false; private AxisLock lockedAxis = null; - + // The reflection of the Underside Display mirror a drag started in. Every point of that drag goes + // through it, so the drag acts on the components themselves; null for a drag in the main view. + private AffineTransform dragMirrorTx = null; + // the reflection of the mirror that the control points in controlPointMap were found through + private AffineTransform hoverMirrorTx = null; + private DIYTest test = null; private OperationMode operationMode; @@ -279,16 +284,8 @@ public class Presenter implements IPlugInPort { if (controlPointMap != null && !controlPointMap.isEmpty()) { return Cursor.getPredefinedCursor(Cursor.HAND_CURSOR); } - for (IDIYComponent component : currentProject.getComponents()) { - if (!isComponentLocked(component) && isComponentVisible(component) - && operationMode != OperationMode.HIGHLIGHT_CONNECTED_AREAS) { - ComponentArea area = drawingManager.getComponentArea(component); - // the bounds rule most components out without merging their outline into an area - if (area != null && scaledPoint != null && area.getOutlineBounds().contains(scaledPoint) - && area.getOutlineArea().contains(scaledPoint)) { - return Cursor.getPredefinedCursor(Cursor.HAND_CURSOR); - } - } + if (scaledPoint != null && !findComponentsAtScaled(scaledPoint, false).isEmpty()) { + return Cursor.getPredefinedCursor(Cursor.HAND_CURSOR); } } @@ -569,8 +566,8 @@ public class Presenter implements IPlugInPort { return findComponentsAtScaled(point, includeLocked, true); } - // a drag does not look through the underside mirror, because moving the mouse there moves the - // part the other way along the mirrored axis + // a drag reflects its points out of the underside mirror itself, so its hit tests must not look + // through the mirror a second time private List> findComponentsAtScaled(Point2D point, boolean includeLocked, boolean throughMirror) { List> components = drawingManager.findComponentsAt(point, currentProject); @@ -950,6 +947,42 @@ public class Presenter implements IPlugInPort { } } + /** + * Finds the control points of the selected, ungrouped components that can be dragged from the + * specified point, one per component. When the point was reflected out of the underside mirror of + * a board, only the components that mirror shows are considered. + */ + private Map, Set> findControlPointsAt(Point2D point, IBoard mirrorBoard) { + Map, Set> components = new HashMap, Set>(); + // Go backwards so we take the highest z-order components first. + for (int i = currentProject.getComponents().size() - 1; i >= 0; i--) { + IDIYComponent component = currentProject.getComponents().get(i); + if (mirrorBoard != null && selectedComponents.contains(component) + && !UndersideMirror.isMirrored(mirrorBoard, component)) { + continue; + } + for (int pointIndex = 0; pointIndex < component.getControlPointCount(); pointIndex++) { + Point2D controlPoint = component.getControlPoint(pointIndex); + // Only consider selected components that are not grouped. + if (selectedComponents.contains(component) && component.canPointMoveFreely(pointIndex) + && findAllGroupedComponents(component).size() == 1) { + try { + if (point.distance(controlPoint) < DrawingManager.CONTROL_POINT_SIZE && + component.getControlPointVisibilityPolicy(pointIndex) != VisibilityPolicy.NEVER) { + Set indices = new HashSet(); + indices.add(pointIndex); + components.put(component, indices); + break; + } + } catch (Exception e) { + LOG.warn("Error reading control point for component of type: " + component.getClass().getName()); + } + } + } + } + return components; + } + @Override public void mouseMoved(Point point, boolean ctrlDown, boolean shiftDown, boolean altDown) { if (point == null) @@ -996,26 +1029,16 @@ public class Presenter implements IPlugInPort { messageDispatcher.dispatchMessage(EventType.REPAINT); } } else { - // Go backwards so we take the highest z-order components first. - for (int i = currentProject.getComponents().size() - 1; i >= 0; i--) { - IDIYComponent component = currentProject.getComponents().get(i); - for (int pointIndex = 0; pointIndex < component.getControlPointCount(); pointIndex++) { - Point2D controlPoint = component.getControlPoint(pointIndex); - // Only consider selected components that are not grouped. - if (selectedComponents.contains(component) && component.canPointMoveFreely(pointIndex) - && findAllGroupedComponents(component).size() == 1) { - try { - if (previousScaledPoint.distance(controlPoint) < DrawingManager.CONTROL_POINT_SIZE && - component.getControlPointVisibilityPolicy(pointIndex) != VisibilityPolicy.NEVER) { - Set indices = new HashSet(); - indices.add(pointIndex); - components.put(component, indices); - break; - } - } catch (Exception e) { - LOG.warn("Error reading control point for component of type: " + component.getClass().getName()); - } - } + components = findControlPointsAt(previousScaledPoint, null); + hoverMirrorTx = null; + for (IBoard board : UndersideMirror.findBoardsMirroredAt(currentProject, previousScaledPoint)) { + if (!components.isEmpty()) { + break; + } + AffineTransform mirrorTx = UndersideMirror.createTransform(board); + components = findControlPointsAt(mirrorTx.transform(previousScaledPoint, null), board); + if (!components.isEmpty()) { + hoverMirrorTx = mirrorTx; } } } @@ -1364,6 +1387,14 @@ public class Presenter implements IPlugInPort { this.dragAction = dragAction; this.preDragProject = currentProject.clone(); Point2D scaledPoint = scalePoint(point); + if (controlPointMap != null && !controlPointMap.isEmpty()) { + this.dragMirrorTx = hoverMirrorTx; + } else if (forceSelectionRect || !findComponentsAtScaled(scaledPoint, false, false).isEmpty()) { + this.dragMirrorTx = null; + } else { + this.dragMirrorTx = findMirrorWithComponentAt(scaledPoint); + } + scaledPoint = toDragSpace(scaledPoint); this.previousDragPoint = scaledPoint; this.dragStartPoint = new Point2D.Double(scaledPoint.getX(), scaledPoint.getY()); this.dragShiftDown = shiftDown; @@ -1586,7 +1617,7 @@ public class Presenter implements IPlugInPort { boolean resizeInProgress = false; - Point2D scaledPoint = scalePoint(point); + Point2D scaledPoint = toDragSpace(scalePoint(point)); if (controlPointMap != null && !controlPointMap.isEmpty()) { // We're dragging control point(s). Point2D targetPoint = constrainToAxis(dragStartPoint, scaledPoint, dragShiftDown); @@ -2004,7 +2035,7 @@ public class Presenter implements IPlugInPort { test.addStep(DIYTest.DRAG_END, params); } - Point2D scaledPoint = scalePoint(point); + Point2D scaledPoint = toDragSpace(scalePoint(point)); if (!dragInProgress && instantiationManager.getComponentSlot() == null) { return; @@ -2056,6 +2087,7 @@ public class Presenter implements IPlugInPort { dragStartPoint = null; dragShiftDown = false; lockedAxis = null; + dragMirrorTx = null; } @Override @@ -3154,6 +3186,27 @@ public class Presenter implements IPlugInPort { } return p; } + + private Point2D toDragSpace(Point2D scaledPoint) { + return dragMirrorTx == null || scaledPoint == null ? scaledPoint + : dragMirrorTx.transform(scaledPoint, null); + } + + /** + * @return the reflection of the first underside mirror that shows a selectable component at the + * specified point, or null + */ + private AffineTransform findMirrorWithComponentAt(Point2D scaledPoint) { + for (IBoard board : UndersideMirror.findBoardsMirroredAt(currentProject, scaledPoint)) { + List> components = + drawingManager.findMirroredComponentsAt(scaledPoint, currentProject, board); + removeUnselectable(components, false); + if (!components.isEmpty()) { + return UndersideMirror.createTransform(board); + } + } + return null; + } @Override public void saveSelectionAsBlock(String blockName) { diff --git a/diylc/diylc-swing/src/test/java/org/diylc/PresenterTests.java b/diylc/diylc-swing/src/test/java/org/diylc/PresenterTests.java index 21b9ea30d..eefdace8b 100644 --- a/diylc/diylc-swing/src/test/java/org/diylc/PresenterTests.java +++ b/diylc/diylc-swing/src/test/java/org/diylc/PresenterTests.java @@ -1,8 +1,10 @@ package org.diylc; +import java.awt.Graphics2D; import java.awt.Point; import java.awt.event.MouseEvent; import java.awt.geom.Point2D; +import java.awt.image.BufferedImage; import java.io.IOException; import java.util.ArrayList; import java.util.EnumSet; @@ -17,14 +19,19 @@ import org.diylc.appframework.miscutils.ConfigurationManager; import org.diylc.appframework.miscutils.InMemoryConfigurationManager; import org.diylc.clipboard.ComponentTransferable; import org.diylc.common.ComponentType; +import org.diylc.common.DrawOption; import org.diylc.common.EventType; import org.diylc.common.IPlugIn; import org.diylc.common.IPlugInPort; +import org.diylc.components.boards.BlankBoard; import org.diylc.components.chassis.ChassisPanel; import org.diylc.components.composite.CompositeComponent; +import org.diylc.core.BoardUndersideDisplay; +import org.diylc.core.IBoard; import org.diylc.core.IDIYComponent; import org.diylc.core.Template; import org.diylc.presenter.Presenter; +import org.diylc.presenter.UndersideMirror; import org.diylc.swing.ActionFactory; import org.diylc.swing.actions.ComponentCategoryAction; import org.diylc.swing.actions.ResetCategoriesAction; @@ -281,6 +288,75 @@ public class PresenterTests extends TestBase { presenter.dragEnded(new Point(1300, 1100)); } + @Test + public void testStretchThroughUndersideMirror() { + instantiateTwoClick("Passive", "Resistor", new Point(1000, 1000), new Point(1200, 1000)); + IDIYComponent resistor = presenter.getCurrentProject().getComponents().get(0); + IBoard board = boardUnder(resistor); + Point2D start = new Point2D.Double(resistor.getControlPoint(1).getX(), resistor.getControlPoint(1).getY()); + Point inMirror = toScreen(UndersideMirror.createTransform(board).transform(start, null)); + + presenter.mouseMoved(inMirror, false, false, false); + presenter.dragStarted(inMirror, java.awt.dnd.DnDConstants.ACTION_MOVE, false, false); + presenter.dragOver(new Point(inMirror.x + 100, inMirror.y + 100)); + presenter.dragEnded(new Point(inMirror.x + 100, inMirror.y + 100)); + + // the mirror is flipped vertically, so moving down in it moves the point up on the board + Assert.assertTrue(resistor.getControlPoint(1).getX() > start.getX()); + Assert.assertTrue(resistor.getControlPoint(1).getY() < start.getY()); + } + + @Test + public void testDragThroughUndersideMirror() { + instantiateTwoClick("Passive", "Resistor", new Point(1000, 1000), new Point(1200, 1000)); + IDIYComponent resistor = presenter.getCurrentProject().getComponents().get(0); + IBoard board = boardUnder(resistor); + Point2D start0 = new Point2D.Double(resistor.getControlPoint(0).getX(), resistor.getControlPoint(0).getY()); + Point2D start1 = new Point2D.Double(resistor.getControlPoint(1).getX(), resistor.getControlPoint(1).getY()); + // the hit test reads the areas recorded while drawing + BufferedImage img = new BufferedImage(100, 100, BufferedImage.TYPE_INT_ARGB); + Graphics2D g2d = img.createGraphics(); + presenter.draw(g2d, EnumSet.of(DrawOption.ZOOM), null, null, null, null); + g2d.dispose(); + Point2D middle = new Point2D.Double((start0.getX() + start1.getX()) / 2, start0.getY()); + Point inMirror = toScreen(UndersideMirror.createTransform(board).transform(middle, null)); + + presenter.mouseMoved(inMirror, false, false, false); + presenter.dragStarted(inMirror, java.awt.dnd.DnDConstants.ACTION_MOVE, false, false); + presenter.dragOver(new Point(inMirror.x + 100, inMirror.y + 100)); + presenter.dragEnded(new Point(inMirror.x + 100, inMirror.y + 100)); + + Assert.assertTrue(resistor.getControlPoint(0).getX() > start0.getX()); + Assert.assertTrue(resistor.getControlPoint(0).getY() < start0.getY()); + Assert.assertEquals(resistor.getControlPoint(0).getY(), resistor.getControlPoint(1).getY()); + Assert.assertEquals(start1.getX() - start0.getX(), + resistor.getControlPoint(1).getX() - resistor.getControlPoint(0).getX()); + } + + // a board reaching 200px past the component on every side, with its underside mirror below it; + // the component stays selected + private IBoard boardUnder(IDIYComponent component) { + Point2D first = component.getControlPoint(0); + Point2D last = component.getControlPoint(component.getControlPointCount() - 1); + BlankBoard board = new BlankBoard(); + board.setControlPoint(new Point2D.Double(first.getX() - 200, first.getY() - 200), 0); + board.setControlPoint(new Point2D.Double(last.getX() + 200, last.getY() + 200), 1); + board.setUndersideDisplay(BoardUndersideDisplay.BELOW); + presenter.getCurrentProject().getComponents().add(0, board); + return board; + } + + // the inverse of the zoom and extra-space scaling the presenter applies to mouse points + private Point toScreen(Point2D modelPoint) { + double zoom = presenter.getZoomLevel(); + double extraSpace = InMemoryConfigurationManager.getInstance() + .readBoolean(IPlugInPort.EXTRA_SPACE_KEY, true) + ? presenter.getDrawingManager().getExtraSpace(presenter.getCurrentProject()) + : 0; + return new Point((int) Math.round((modelPoint.getX() + extraSpace) * zoom), + (int) Math.round((modelPoint.getY() + extraSpace) * zoom)); + } + @Test public void testShiftLocksAxisWhilePlacing() { Map> componentTypes = presenter.getComponentTypes(); diff --git a/diylc/docs/plans/bottom-side-components.md b/diylc/docs/plans/bottom-side-components.md index 79c9d656b..bf22193e2 100644 --- a/diylc/docs/plans/bottom-side-components.md +++ b/diylc/docs/plans/bottom-side-components.md @@ -5,7 +5,7 @@ Gerber layers and the canvas shows it as a part on the far side of the board. Status: the core Gerber side is done, with tests: `IBoardSideComponent` exists and the export sends a bottom-side part to the bottom layers, but no component implements it yet. Parts can be -selected through the Underside Display mirror. The next steps below remain. +selected, dragged and stretched through the Underside Display mirror. The next steps below remain. ## 1. Current state @@ -98,14 +98,16 @@ order. the main view; locked and hidden parts are filtered out first, so a locked part under the mirror does not block it. Clicking a part in the mirror selects the original, so the property editor, delete, copy and the popup menu all work unchanged. Selection highlighting and the selected - control points are drawn on both copies. A drag still tests the main view only, and so do the - hand cursor and control-point hover, because they promise dragging; all three move to stage 2. -- **Stage 2: dragging in the mirror.** Dragging, the hand cursor and control-point hover use the - mirror. A drag that starts in a mirror region reflects every - subsequent mouse point before it reaches the existing drag code. Movement along the mirrored axis - is therefore reversed, which is what the user sees. Snapping applies to the reflected point. Moving - the board, or changing its Underside Offset, cancels nothing; the mirror simply follows. - Control-point drags (stretching a trace) go through the same mapping. + control points are drawn on both copies. +- **Stage 2: dragging in the mirror (done).** A drag that starts on a part in a mirror, or on a + control point of a selected part there, keeps that mirror's reflection in + `Presenter.dragMirrorTx` and reflects every mouse point of the drag before it reaches the existing + drag code. Movement along the mirrored axis is therefore reversed, which is what the user sees, + and snapping and the shift axis lock apply to the reflected point. Control-point hover + (`Presenter.findControlPointsAt`) and the hand cursor look through the mirror when the main view + has nothing. A drag that starts on empty mirror space still draws a selection rectangle in + screen space. Moving the board, or changing its Underside Offset, cancels nothing; the mirror + simply follows. - **Stage 3: placing in the mirror.** A component instantiated with its first point in a mirror region is created at the reflected point. If it is an `IBoardSideComponent`, it is created with side `_1` (Bottom) and mirrored, as Flip to Other Side would do. A `PCBLayer` component is created on @@ -141,7 +143,9 @@ order. appears only in `.gbo`, mirrored, and in the right position. - `UndersideMirrorTest` covers the mirror region for each `BoardUndersideDisplay` direction and offset, that reflecting a point twice returns it, and which components a mirror shows. - `DrawingManagerTest.testComponentFoundThroughUndersideMirror` covers the hit test. + `DrawingManagerTest.testComponentFoundThroughUndersideMirror` covers the hit test, and + `PresenterTests.testStretchThroughUndersideMirror` and `testDragThroughUndersideMirror` check + that a drag through a mirror below the board moves the part up on the board. - In the app, with Underside Display set to each direction: select, drag and stretch a trace in the mirror and confirm that the original follows correctly. Place a part in the mirror and confirm that it is bottom-side, mirrored, faded on the main view and solid in the mirror. @@ -154,6 +158,5 @@ order. 2. Wrap bottom-side painting in `startDrawingSide(getBoardSide())`. 3. Add the Flip to Other Side action. 4. Split the silkscreen print-out by side. -5. Mirror stage 2: dragging through the mirror. -6. Mirror stage 3: placing in the mirror, after Flip to Other Side exists. -7. Run the regression suite. +5. Mirror stage 3: placing in the mirror, after Flip to Other Side exists. +6. Run the regression suite.