8.0 KiB
Align & Distribute
Align or distribute the selected components by their drawn outline. The commands sit in an Align & Distribute submenu in both the Edit menu and the canvas popup menu.
1. Behaviour
Every operation works on the outline Area that DrawingManager records for each component
(ComponentArea.getOutlineArea()). Because that area covers everything the component draws,
including strokes and labels, alignment means visual alignment, not control-point alignment.
Units. The selection is split into units. A unit is one component group (the union of its members' outline areas) or one ungrouped component. A unit always moves rigidly, so groups are never pulled apart. A selected component with no recorded area, for example because it was not drawn, is left out of the operation.
Reference. Everything is measured against the bounding box of all units, so align and distribute never move anything outside the space the selection already covers.
| Command | Effect on each unit |
|---|---|
| Align Left / Right | Moves its left (right) edge to the bbox's left (right) edge |
| Align Top / Bottom | Moves its top (bottom) edge to the bbox's top (bottom) edge |
| Align Horizontal Centers | Moves its center X to the bbox's center X (units end up in a column) |
| Align Vertical Centers | Moves its center Y to the bbox's center Y (units end up in a row) |
| Distribute Centers Horizontally / Vertically | Sorts units by center, keeps the first and last in place and spaces the centers evenly between them |
| Distribute Spacing Horizontally / Vertically | Sorts units by center, keeps the first unit's leading edge and the last unit's trailing edge in place, and makes every gap between neighbours equal. The gap can come out negative (overlap) when the units are wider than the span; that is allowed. |
Sorting breaks ties on the leading edge, then on project (z-)order, so the result is deterministic.
Snapping. When the snap-to-grid setting is on, each unit's delta is rounded to the project
grid spacing (CalcUtils.roundToGrid). Control points therefore stay on the grid, at the cost of
alignment that is only approximate to within half a grid step. With snapping off, alignment is
exact to the pixel.
Stuck components. Each unit's move goes through includeStuckComponents, as a mouse drag
does, so wires and leads attached to a moved part stretch to stay connected. A component that is
itself a unit of the operation is never also dragged as another unit's stuck component, otherwise
it would move twice.
Undo. A whole operation is one undo step, labelled with the command name.
Enablement. The align commands need at least 2 units and the distribute commands at least 3, since distributing 2 units leaves both in place. Units are counted, not components, so one selected group counts as 1.
2. Architecture
Swing actions ──► IPlugInPort (ISelectionProcessor) ──► Presenter ──► AlignmentManager
│ (pure geometry)
├─ DrawingManager.getComponentArea
└─ includeStuckComponents + moveComponents
diylc-core
org.diylc.common.AlignmentMode (enum): LEFT, RIGHT, TOP, BOTTOM,
HORIZONTAL_CENTER, VERTICAL_CENTER.
org.diylc.common.DistributionMode (enum): HORIZONTAL_CENTERS, HORIZONTAL_SPACING,
VERTICAL_CENTERS, VERTICAL_SPACING.
Both enums carry a toString() display label, used for the menu text and the undo label, the
same way other org.diylc.common enums do.
org.diylc.presenter.AlignmentManager: a stateless service, named like its siblings
VariantManager and BuildingBlockManager, and created in the Presenter constructor. It does
geometry only. It knows nothing about Project, DrawingManager or how a move is applied, which
keeps it unit-testable with plain rectangles.
public List<Point2D> align(List<Rectangle2D> unitBounds, AlignmentMode mode, Double gridStep);
public List<Point2D> distribute(List<Rectangle2D> unitBounds, DistributionMode mode, Double gridStep);
Each call returns one delta per input rectangle, in input order. gridStep == null means no
snapping. Fewer than 2 rectangles (or fewer than 3 for distribute) returns all-zero deltas.
ISelectionProcessor gains three methods, each with Javadoc as the rest of that interface has:
void alignSelection(AlignmentMode mode);
void distributeSelection(DistributionMode mode);
int getSelectionUnitCount(); // drives enablement in the Swing layer
Presenter implements them as follows:
- Build the units from
selectedComponentsusing the existing group lookup (findAllGroupedComponents), walking the project in z-order. For each unit, combine the bounds of the outline areas fromdrawingManager.getComponentArea(...). Skip any unit with a missing area. - Ask
AlignmentManagerfor deltas, passing the grid spacing in pixels only when snapping to the grid is on. - Take
oldProject = currentProject.clone()once. Then, for each unit whose rounded delta is non-zero: build a control-point map containing every point of the unit's components, runincludeStuckComponents(an overload takes the set to exclude) with the other units' components excluded, and callmoveComponents(map, dx, dy, false, false). Snapping already happened in step 2, so the move itself must not snap. - Call
notifyProjectModifiedIfNeeded(oldProject, mode.toString(), true, true).
All areas are read in step 1, before anything moves, because a move invalidates the moved
components' areas in DrawingManager.
moveComponents refuses a move that would push a control point off the canvas or make points
collide. That cannot happen to the units themselves, since they stay inside the selection bbox.
It can in principle happen to a dragged stuck component at the canvas edge, and in that case only
that unit's move is skipped. This is the same behaviour as a drag.
diylc-swing
actions/edit/AlignSelectionActionandactions/edit/DistributeSelectionActionfollow the shape ofRotateSelectionAction: they hold the mode, take the name from it, log oneActionFactory.LOG.info(...)line and delegate toplugInPort. Each gets acreate...method inActionFactory.EditMenuPlugin: addALIGN_TITLE = "Align & Distribute", injected withinjectSubmenudirectly after the Transform Selection submenu. It holds the six align actions, a separator, then the four distribute actions, with lazy getters like the existing ones.refreshActions()sets enablement fromgetSelectionUnitCount().ComponentPopupMenu: add agetAlignMenu()TranslatedMenuaftergetTransformMenu(), with the same contents, and set enablement inprepareAndShowAt.
refreshActions() runs on SELECTION_CHANGED. Grouping or ungrouping without a selection change
can leave the enablement stale until the next refresh. That is harmless, because the Presenter
treats a too-small unit count as a no-op.
Tests
diylc-core/src/test/java/org/diylc/presenter/AlignmentManagerTest covers each align mode, both
distribute variants on both axes, ties in the sort order, negative spacing, grid snapping,
already-aligned input (all-zero deltas) and the below-minimum unit counts. Presenter wiring and
menus get no unit tests, as is usual for this codebase. Rendering, serialization and netlist output
do not change, so no regression run is needed.
3. Open items
Everything in section 2 is implemented. The remaining items:
- Icons are in
diylc-swing-images/(align_*.png,distribute_*.png) and registered inIconLoader. Each action picks its icon from its mode; the submenu usesalign_distribute.png. - Shortcuts. None yet. Rotate already uses Alt+arrows, and nudge has its own shortcut.
- Action bar. The commands are not on the context toolbar in
ActionBarPlugin. - Manual check in the running app: groups, stuck wires, snap on and off, and undo.