Components can be selected by clicking them in the Underside Display mirror of a board

This commit is contained in:
Bane Stojkovic
2026-11-11 14:56:02 +01:00
parent 3506c97403
commit 90e10f4597
6 changed files with 399 additions and 118 deletions
@@ -193,37 +193,12 @@ public class DrawingManager {
configureRenderingHints(g2d, drawOptions);
// group components with boards that contain them
Map<IBoard, List<IDIYComponent<?>>> 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<IDIYComponent<?>>()));
// group components with the boards whose underside mirror shows them
Map<IDIYComponent<?>, Set<IBoard>> componentBoardMap = new HashMap<IDIYComponent<?>, Set<IBoard>>();
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<IBoard> 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<IBoard>()).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<DrawOption> 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<IDIYComponent<?>> findMirroredComponentsAt(Point2D point, Project project) {
List<IDIYComponent<?>> components = new ArrayList<IDIYComponent<?>>();
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();
@@ -566,7 +566,23 @@ public class Presenter implements IPlugInPort {
* @return
*/
public List<IDIYComponent<?>> 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<IDIYComponent<?>> findComponentsAtScaled(Point2D point, boolean includeLocked,
boolean throughMirror) {
List<IDIYComponent<?>> 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<IDIYComponent<?>> components, boolean includeLocked) {
Iterator<IDIYComponent<?>> 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<IDIYComponent<?>> components = forceSelectionRect ? null : findComponentsAtScaled(scaledPoint, false);
List<IDIYComponent<?>> components =
forceSelectionRect ? null : findComponentsAtScaled(scaledPoint, false, false);
if (controlPointMap != null && !this.controlPointMap.isEmpty()) {
// If we're dragging control points reset selection.
updateSelection(new ArrayList<IDIYComponent<?>>(this.controlPointMap.keySet()));
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<IBoard> getMirroringBoards(Project project) {
List<IBoard> boards = new ArrayList<IBoard>();
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<IBoard> findBoardsMirroredAt(Project project, Point2D point) {
List<IBoard> boards = new ArrayList<IBoard>();
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);
}
}
@@ -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));
@@ -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 <http://www.gnu.org/licenses/>.
*/
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;
}
}
+22 -22
View File
@@ -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.