From 90e10f45974aa56ebef9ec3da2c9ff9a489eefa1 Mon Sep 17 00:00:00 2001 From: Bane Stojkovic Date: Wed, 11 Nov 2026 14:56:02 +0100 Subject: [PATCH] Components can be selected by clicking them in the Underside Display mirror of a board --- .../org/diylc/presenter/DrawingManager.java | 139 +++++---------- .../java/org/diylc/presenter/Presenter.java | 20 ++- .../org/diylc/presenter/UndersideMirror.java | 167 ++++++++++++++++++ .../diylc/presenter/DrawingManagerTest.java | 23 +++ .../diylc/presenter/UndersideMirrorTest.java | 124 +++++++++++++ diylc/docs/plans/bottom-side-components.md | 44 ++--- 6 files changed, 399 insertions(+), 118 deletions(-) create mode 100644 diylc/diylc-core/src/main/java/org/diylc/presenter/UndersideMirror.java create mode 100644 diylc/diylc-core/src/test/java/org/diylc/presenter/UndersideMirrorTest.java 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 e864567c3..077bfd5f4 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 @@ -193,37 +193,12 @@ public class DrawingManager { configureRenderingHints(g2d, drawOptions); - // group components with boards that contain them - Map>> boardMap = project.getComponents().stream() - .filter(IBoard.class::isInstance) - .map(IBoard.class::cast) - .filter(b -> b.getUndersideDisplay() != null && b.getUndersideDisplay() != BoardUndersideDisplay.NONE) - .collect(Collectors.toMap(x -> x, x -> new ArrayList>())); + // group components with the boards whose underside mirror shows them Map, Set> componentBoardMap = new HashMap, Set>(); - - for (IBoard board : boardMap.keySet()) { - Rectangle2D boardRect = board.getBoardRectangle(); - if (boardRect == null) { - continue; - } - double minX = Math.min(boardRect.getMinX(), boardRect.getMaxX()); - double maxX = Math.max(boardRect.getMinX(), boardRect.getMaxX()); - double minY = Math.min(boardRect.getMinY(), boardRect.getMaxY()); - double maxY = Math.max(boardRect.getMinY(), boardRect.getMaxY()); - Rectangle2D normalizedBoardRect = new Rectangle2D.Double(minX, minY, maxX - minX, maxY - minY); + List mirroringBoards = UndersideMirror.getMirroringBoards(project); + for (IBoard board : mirroringBoards) { for (IDIYComponent c : project.getComponents()) { - if (c == board) { - continue; - } - boolean include = false; - for (int i = 0; i < c.getControlPointCount(); i++) { - if (normalizedBoardRect.contains(c.getControlPoint(i))) { - include = true; - break; - } - } - if (include) { - boardMap.get(board).add(c); + if (c != board && UndersideMirror.isMirrored(board, c)) { componentBoardMap.computeIfAbsent(c, key -> new HashSet()).add(board); } } @@ -427,15 +402,14 @@ public class DrawingManager { || component.getControlPointVisibilityPolicy(i) == VisibilityPolicy.ALWAYS)) { Point2D controlPoint = component.getControlPoint(i); - int pointSize = CONTROL_POINT_SIZE; - - g2dWrapper.setColor(SELECTED_CONTROL_POINT_COLOR.darker()); - g2dWrapper.fillOval((int) Math.round(controlPoint.getX() - pointSize / 2), - (int) Math.round(controlPoint.getY() - pointSize / 2), pointSize, pointSize); - g2dWrapper.setColor(SELECTED_CONTROL_POINT_COLOR); - g2dWrapper.fillOval((int) Math.round(controlPoint.getX() - CONTROL_POINT_SIZE / 2 + 1), - (int) Math.round(controlPoint.getY() - CONTROL_POINT_SIZE / 2 + 1), CONTROL_POINT_SIZE - 2, - CONTROL_POINT_SIZE - 2); + drawSelectedControlPoint(g2dWrapper, controlPoint); + // the part can be selected through its mirrored copy, which then shows its points too + for (IBoard board : mirroringBoards) { + if (UndersideMirror.isMirrored(board, component)) { + drawSelectedControlPoint(g2dWrapper, + UndersideMirror.createTransform(board).transform(controlPoint, null)); + } + } } } } @@ -553,9 +527,20 @@ public class DrawingManager { return failedComponents; } + private static void drawSelectedControlPoint(G2DWrapper g2dWrapper, Point2D controlPoint) { + g2dWrapper.setColor(SELECTED_CONTROL_POINT_COLOR.darker()); + g2dWrapper.fillOval((int) Math.round(controlPoint.getX() - CONTROL_POINT_SIZE / 2), + (int) Math.round(controlPoint.getY() - CONTROL_POINT_SIZE / 2), CONTROL_POINT_SIZE, + CONTROL_POINT_SIZE); + g2dWrapper.setColor(SELECTED_CONTROL_POINT_COLOR); + g2dWrapper.fillOval((int) Math.round(controlPoint.getX() - CONTROL_POINT_SIZE / 2 + 1), + (int) Math.round(controlPoint.getY() - CONTROL_POINT_SIZE / 2 + 1), CONTROL_POINT_SIZE - 2, + CONTROL_POINT_SIZE - 2); + } + private static void drawMirroredComponent(Project project, IBoard board, IDIYComponent component, G2DWrapper g2dWrapper, ComponentState state, boolean outlineMode) { - AffineTransform mirrorTx = createMirrorTransform(board); + AffineTransform mirrorTx = UndersideMirror.createTransform(board); if (mirrorTx == null) { return; } @@ -595,61 +580,6 @@ public class DrawingManager { } } - /** - * Returns the reflection that maps the board onto its underside mirror, or null when the board - * has no underside display. - */ - private static AffineTransform createMirrorTransform(IBoard board) { - if (board.getUndersideDisplay() == null || board.getUndersideDisplay() == BoardUndersideDisplay.NONE) { - return null; - } - double offset = board.getUndersideOffset() == null ? 0 : board.getUndersideOffset().convertToPixels(); - Rectangle2D boardRectangle = board.getBoardRectangle(); - if (boardRectangle == null) { - return null; - } - - double minX = Math.min(boardRectangle.getMinX(), boardRectangle.getMaxX()); - double maxX = Math.max(boardRectangle.getMinX(), boardRectangle.getMaxX()); - double minY = Math.min(boardRectangle.getMinY(), boardRectangle.getMaxY()); - double maxY = Math.max(boardRectangle.getMinY(), boardRectangle.getMaxY()); - - AffineTransform mirrorTx = new AffineTransform(); - switch (board.getUndersideDisplay()) { - case ABOVE: { - double pivotY = minY - offset / 2; - mirrorTx.translate(0, pivotY); - mirrorTx.scale(1.0, -1.0); - mirrorTx.translate(0, -pivotY); - break; - } - case BELOW: { - double pivotY = maxY + offset / 2; - mirrorTx.translate(0, pivotY); - mirrorTx.scale(1.0, -1.0); - mirrorTx.translate(0, -pivotY); - break; - } - case LEFT: { - double pivotX = minX - offset / 2; - mirrorTx.translate(pivotX, 0); - mirrorTx.scale(-1.0, 1.0); - mirrorTx.translate(-pivotX, 0); - break; - } - case RIGHT: { - double pivotX = maxX + offset / 2; - mirrorTx.translate(pivotX, 0); - mirrorTx.scale(-1.0, 1.0); - mirrorTx.translate(-pivotX, 0); - break; - } - default: - return null; - } - return mirrorTx; - } - private static void drawComponent(Project project, Set drawOptions, Double scaleFactor, Rectangle2D visibleRect, IDIYComponent component, G2DWrapper g2dWrapper, ComponentState state, double zoom, int i, boolean outlineMode, boolean cacheable) { @@ -810,6 +740,27 @@ public class DrawingManager { return components; } + /** + * Finds the components that the underside mirror of a board shows at the specified point, top + * first, or none when the point is not in a mirror. + * + * @param point + * @param project + * @return + */ + 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)) { + 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 f600cb573..70666666d 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 @@ -566,7 +566,23 @@ public class Presenter implements IPlugInPort { * @return */ public List> findComponentsAtScaled(Point2D point, boolean includeLocked) { + 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 + private List> findComponentsAtScaled(Point2D point, boolean includeLocked, + boolean throughMirror) { List> components = drawingManager.findComponentsAt(point, currentProject); + removeUnselectable(components, includeLocked); + if (throughMirror && components.isEmpty()) { + components = drawingManager.findMirroredComponentsAt(point, currentProject); + removeUnselectable(components, includeLocked); + } + return components; + } + + private void removeUnselectable(List> components, boolean includeLocked) { Iterator> iterator = components.iterator(); while (iterator.hasNext()) { IDIYComponent component = iterator.next(); @@ -574,7 +590,6 @@ public class Presenter implements IPlugInPort { iterator.remove(); } } - return components; } @Override @@ -1353,7 +1368,8 @@ public class Presenter implements IPlugInPort { this.dragStartPoint = new Point2D.Double(scaledPoint.getX(), scaledPoint.getY()); this.dragShiftDown = shiftDown; this.lockedAxis = null; - List> components = forceSelectionRect ? null : findComponentsAtScaled(scaledPoint, false); + List> components = + forceSelectionRect ? null : findComponentsAtScaled(scaledPoint, false, false); if (controlPointMap != null && !this.controlPointMap.isEmpty()) { // If we're dragging control points reset selection. updateSelection(new ArrayList>(this.controlPointMap.keySet())); diff --git a/diylc/diylc-core/src/main/java/org/diylc/presenter/UndersideMirror.java b/diylc/diylc-core/src/main/java/org/diylc/presenter/UndersideMirror.java new file mode 100644 index 000000000..63ef7c797 --- /dev/null +++ b/diylc/diylc-core/src/main/java/org/diylc/presenter/UndersideMirror.java @@ -0,0 +1,167 @@ +/* + + 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.awt.geom.Rectangle2D; +import java.util.ArrayList; +import java.util.List; + +import org.diylc.core.BoardUndersideDisplay; +import org.diylc.core.IBoard; +import org.diylc.core.IDIYComponent; +import org.diylc.core.Project; + +/** + * Geometry of the Underside Display mirror, the reflected copy of a board and the components on it + * drawn next to the board. The mirror is a second drawing of the same components, which keep their + * main-view coordinates, so the same reflection draws a component into the mirror and maps a point + * in the mirror back onto the component. A reflection is its own inverse. + */ +public class UndersideMirror { + + private UndersideMirror() { + } + + /** + * @return the boards of the project that show an underside mirror, in z-order + */ + public static List getMirroringBoards(Project project) { + List boards = new ArrayList(); + for (IDIYComponent component : project.getComponents()) { + if (component instanceof IBoard && createTransform((IBoard) component) != null) { + boards.add((IBoard) component); + } + } + return boards; + } + + /** + * @return the reflection that maps the board onto its underside mirror, or null when the board + * has no underside display + */ + public static AffineTransform createTransform(IBoard board) { + if (board.getUndersideDisplay() == null + || board.getUndersideDisplay() == BoardUndersideDisplay.NONE) { + return null; + } + Rectangle2D bounds = getBoardBounds(board); + if (bounds == null) { + return null; + } + double offset = + board.getUndersideOffset() == null ? 0 : board.getUndersideOffset().convertToPixels(); + + AffineTransform mirrorTx = new AffineTransform(); + switch (board.getUndersideDisplay()) { + case ABOVE: { + double pivotY = bounds.getMinY() - offset / 2; + mirrorTx.translate(0, pivotY); + mirrorTx.scale(1.0, -1.0); + mirrorTx.translate(0, -pivotY); + break; + } + case BELOW: { + double pivotY = bounds.getMaxY() + offset / 2; + mirrorTx.translate(0, pivotY); + mirrorTx.scale(1.0, -1.0); + mirrorTx.translate(0, -pivotY); + break; + } + case LEFT: { + double pivotX = bounds.getMinX() - offset / 2; + mirrorTx.translate(pivotX, 0); + mirrorTx.scale(-1.0, 1.0); + mirrorTx.translate(-pivotX, 0); + break; + } + case RIGHT: { + double pivotX = bounds.getMaxX() + offset / 2; + mirrorTx.translate(pivotX, 0); + mirrorTx.scale(-1.0, 1.0); + mirrorTx.translate(-pivotX, 0); + break; + } + default: + return null; + } + return mirrorTx; + } + + /** + * @return the part of the canvas the board's mirror occupies, or null when the board has no + * underside display + */ + public static Rectangle2D getMirrorRegion(IBoard board) { + AffineTransform mirrorTx = createTransform(board); + if (mirrorTx == null) { + return null; + } + return mirrorTx.createTransformedShape(getBoardBounds(board)).getBounds2D(); + } + + /** + * @return true when the board's mirror shows the component: the board itself, or a component + * with a control point on the board + */ + public static boolean isMirrored(IBoard board, IDIYComponent component) { + if (component == board) { + return true; + } + Rectangle2D bounds = getBoardBounds(board); + if (bounds == null) { + return false; + } + for (int i = 0; i < component.getControlPointCount(); i++) { + if (bounds.contains(component.getControlPoint(i))) { + return true; + } + } + return false; + } + + /** + * @return the boards whose mirror contains the point, in z-order + */ + public static List findBoardsMirroredAt(Project project, Point2D point) { + List boards = new ArrayList(); + for (IBoard board : getMirroringBoards(project)) { + if (getMirrorRegion(board).contains(point)) { + boards.add(board); + } + } + return boards; + } + + // the board rectangle may be stored with its corners in either order + private static Rectangle2D getBoardBounds(IBoard board) { + Rectangle2D rect = board.getBoardRectangle(); + if (rect == null) { + return null; + } + double minX = Math.min(rect.getMinX(), rect.getMaxX()); + double minY = Math.min(rect.getMinY(), rect.getMaxY()); + return new Rectangle2D.Double(minX, minY, Math.max(rect.getMinX(), rect.getMaxX()) - minX, + Math.max(rect.getMinY(), rect.getMaxY()) - minY); + } +} diff --git a/diylc/diylc-core/src/test/java/org/diylc/presenter/DrawingManagerTest.java b/diylc/diylc-core/src/test/java/org/diylc/presenter/DrawingManagerTest.java index 263262103..f943f2b5a 100644 --- a/diylc/diylc-core/src/test/java/org/diylc/presenter/DrawingManagerTest.java +++ b/diylc/diylc-core/src/test/java/org/diylc/presenter/DrawingManagerTest.java @@ -761,6 +761,29 @@ public class DrawingManagerTest { canvas.dispose(); } + @Test + public void testComponentFoundThroughUndersideMirror() { + Project project = new Project(); + MockBoard board = new MockBoard(); + board.setBoardRectangle(new Rectangle2D.Double(100, 100, 200, 300)); + board.setUndersideDisplay(BoardUndersideDisplay.BELOW); + board.setUndersideOffset(new Size(20.0, SizeUnit.px)); + SidePaintingComponent part = + new SidePaintingComponent(new Rectangle2D.Double(150, 150, 10, 10), null); + project.getComponents().add(board); + project.getComponents().add(part); + DrawingManager drawingManager = createDrawingManager(); + drawVisibleRect(drawingManager, project, null); + + // reflection across y = 410 puts the square at y 660..670 + Point2D inMirror = new Point2D.Double(155, 665); + assertTrue(drawingManager.findComponentsAt(inMirror, project).isEmpty()); + assertEquals(Collections.singletonList(part), + drawingManager.findMirroredComponentsAt(inMirror, project)); + assertTrue(drawingManager + .findMirroredComponentsAt(new Point2D.Double(155, 155), project).isEmpty()); + } + @Test public void testFarSideFadeLeavesContinuityUnchanged() { Area faded = drawBottomConductor(EnumSet.of(DrawOption.FADE_FAR_SIDE)); diff --git a/diylc/diylc-core/src/test/java/org/diylc/presenter/UndersideMirrorTest.java b/diylc/diylc-core/src/test/java/org/diylc/presenter/UndersideMirrorTest.java new file mode 100644 index 000000000..75ec13469 --- /dev/null +++ b/diylc/diylc-core/src/test/java/org/diylc/presenter/UndersideMirrorTest.java @@ -0,0 +1,124 @@ +/* + + 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 static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import java.awt.geom.AffineTransform; +import java.awt.geom.Point2D; +import java.awt.geom.Rectangle2D; +import java.util.Arrays; +import java.util.Collections; + +import org.diylc.core.BoardUndersideDisplay; +import org.diylc.core.Project; +import org.diylc.core.measures.Size; +import org.diylc.core.measures.SizeUnit; +import org.diylc.presenter.DrawingManagerTest.MockBoard; +import org.diylc.presenter.DrawingManagerTest.SidePaintingComponent; +import org.junit.Test; + +public class UndersideMirrorTest { + + // the board spans x 100..300 and y 100..400, with a 20px gap to its mirror + + @Test + public void testMirrorRegionBelow() { + assertEquals(new Rectangle2D.Double(100, 420, 200, 300), + UndersideMirror.getMirrorRegion(board(BoardUndersideDisplay.BELOW))); + } + + @Test + public void testMirrorRegionAbove() { + assertEquals(new Rectangle2D.Double(100, -220, 200, 300), + UndersideMirror.getMirrorRegion(board(BoardUndersideDisplay.ABOVE))); + } + + @Test + public void testMirrorRegionLeft() { + assertEquals(new Rectangle2D.Double(-120, 100, 200, 300), + UndersideMirror.getMirrorRegion(board(BoardUndersideDisplay.LEFT))); + } + + @Test + public void testMirrorRegionRight() { + assertEquals(new Rectangle2D.Double(320, 100, 200, 300), + UndersideMirror.getMirrorRegion(board(BoardUndersideDisplay.RIGHT))); + } + + @Test + public void testMirrorRegionFollowsOffset() { + MockBoard board = board(BoardUndersideDisplay.BELOW); + board.setUndersideOffset(new Size(60.0, SizeUnit.px)); + assertEquals(new Rectangle2D.Double(100, 460, 200, 300), UndersideMirror.getMirrorRegion(board)); + } + + @Test + public void testNoMirrorWithoutUndersideDisplay() { + MockBoard board = board(BoardUndersideDisplay.NONE); + assertNull(UndersideMirror.createTransform(board)); + assertNull(UndersideMirror.getMirrorRegion(board)); + } + + @Test + public void testReflectingTwiceReturnsPoint() { + for (BoardUndersideDisplay display : Arrays.asList(BoardUndersideDisplay.ABOVE, + BoardUndersideDisplay.BELOW, BoardUndersideDisplay.LEFT, BoardUndersideDisplay.RIGHT)) { + AffineTransform mirrorTx = UndersideMirror.createTransform(board(display)); + Point2D point = new Point2D.Double(123, 456); + assertEquals(point, mirrorTx.transform(mirrorTx.transform(point, null), null)); + } + } + + @Test + public void testMirroredComponentsAreOnTheBoard() { + MockBoard board = board(BoardUndersideDisplay.BELOW); + assertTrue(UndersideMirror.isMirrored(board, board)); + assertTrue(UndersideMirror.isMirrored(board, + new SidePaintingComponent(new Rectangle2D.Double(150, 150, 10, 10), null))); + assertTrue(!UndersideMirror.isMirrored(board, + new SidePaintingComponent(new Rectangle2D.Double(450, 150, 10, 10), null))); + } + + @Test + public void testFindBoardsMirroredAt() { + Project project = new Project(); + MockBoard below = board(BoardUndersideDisplay.BELOW); + MockBoard none = board(BoardUndersideDisplay.NONE); + project.getComponents().add(below); + project.getComponents().add(none); + + assertEquals(Collections.singletonList(below), + UndersideMirror.findBoardsMirroredAt(project, new Point2D.Double(150, 500))); + assertTrue(UndersideMirror.findBoardsMirroredAt(project, new Point2D.Double(150, 300)).isEmpty()); + } + + private static MockBoard board(BoardUndersideDisplay display) { + MockBoard board = new MockBoard(); + board.setBoardRectangle(new Rectangle2D.Double(100, 100, 200, 300)); + board.setUndersideDisplay(display); + board.setUndersideOffset(new Size(20.0, SizeUnit.px)); + return board; + } +} diff --git a/diylc/docs/plans/bottom-side-components.md b/diylc/docs/plans/bottom-side-components.md index 0b51e5fec..79c9d656b 100644 --- a/diylc/docs/plans/bottom-side-components.md +++ b/diylc/docs/plans/bottom-side-components.md @@ -4,8 +4,8 @@ Let a component be mounted on the bottom of a PCB, so that its silkscreen goes t 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. No component implements it yet; the next steps below -remain. +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. ## 1. Current state @@ -88,18 +88,20 @@ through the mirror's reflection before the existing logic runs. A reflection is so drawing and hit-testing use the same transform. Each stage is useful by itself and ships in order. -- **Shared mirror geometry.** Move the transform built in `DrawingManager.drawMirroredComponent` - into a helper in `org.diylc.presenter` (not into `Presenter`). It answers two questions: the - reflection for a given `IBoard`, and which board's mirror region, if any, contains a model point. - The region is the board rectangle reflected about the pivot. `drawMirroredComponent` uses the - helper, so drawing and hit-testing cannot drift apart. -- **Stage 1: selecting in the mirror.** In `Presenter.findComponentsAt` and the click and hover - paths, a point that falls in a mirror region and hits nothing in main-view space is reflected - and tested again, limited to the components that board mirrors (those whose control point lies - inside it, as in `drawMirroredComponent`). 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 control points are drawn on both copies. -- **Stage 2: dragging in the mirror.** A drag that starts in a mirror region reflects every +- **Shared mirror geometry (done).** `org.diylc.presenter.UndersideMirror` holds the reflection + for a board, the region its mirror occupies (the board rectangle reflected about the pivot), and + which components a mirror shows: the board and every component with a control point on it. + `DrawingManager` draws the mirror with it, so drawing and hit-testing cannot drift apart. +- **Stage 1: selecting in the mirror (done).** `DrawingManager.findMirroredComponentsAt` reflects a + point that falls in a mirror region and tests it again, limited to the components that board + mirrors. `Presenter.findComponentsAtScaled` falls back to it when nothing selectable is hit in + 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. @@ -137,8 +139,9 @@ order. output is byte-identical to before. - Export a board with one part on each side and open the files in a Gerber viewer: the bottom part appears only in `.gbo`, mirrored, and in the right position. -- Unit tests for the mirror geometry helper: the region for each `BoardUndersideDisplay` direction - and offset, and that reflecting a point twice returns it. +- `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. - 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. @@ -151,9 +154,6 @@ 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. Extract the mirror geometry helper, with tests, and use it in `drawMirroredComponent`. -6. Mirror stage 1: selecting through the mirror. It does not depend on steps 1-4 and can ship - early. -7. Mirror stage 2: dragging through the mirror. -8. Mirror stage 3: placing in the mirror, after Flip to Other Side exists. -9. Run the regression suite. +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.