Files
diy-layout-creator/diylc/docs/plans/composite-building-blocks.md
T

19 KiB
Raw Blame History

Composite Building Blocks

Instantiating a building block as a single, self-contained component.

1. Goal

Today a building block is a named list of components stored in local config (buildingBlocks key). Placing one clones the components, gives each a fresh name and UUID, and drops them on the canvas as an auto-group. They remain individually selectable, editable and stretchable.

This document plans a second instantiation mode. In composite mode a block becomes one component on the canvas: it has its own name, it aggregates the control points of all its children, it moves and rotates as a rigid unit, and it appears as a single entry in the BOM, the netlist and the AI project description. This makes it possible to build something like an Arduino board out of an existing board component plus pin headers, save it as a block, and from then on treat it as a first-class part that other components can connect to.

Group mode is kept unchanged. Composite mode becomes the default for a plain click; group mode costs one extra click.

2. Design decisions

# Decision Choice Rationale
D1 Where child definitions live Embedded in the instance, serialized into the .diy file Projects stay portable; a file opens correctly on a machine that does not have the block in its local config. Blocks in config remain the template, not a live dependency.
D2 Which control points are exposed All of them, delegated straight through to children No curation UI needed, and isControlPointSticky / getControlPointVisibilityPolicy come along for free with correct per-child semantics.
D3 Terminal naming A child's node name is exposed unchanged when it is unique within the composite. Clashes are resolved by qualifying pins of multi-point children with the child's name (R1.1), then numbering whatever still clashes in save order (GND_1, GND_2) Blocks are also used to build custom maker boards from a blank board and solder pads, where the pad's Node Name is the terminal name and its component name (Pad3) is an arbitrary leftover of the project the block was saved in. Children are frozen (D8), so every instance of a block resolves identical names. Node.toString() prefixes the composite's name, so a netlist node reads BLK1.VCC or BLK1.R1.1.
D4 Geometry Rigid — canPointMoveFreely(i) returns false for every index This is already a first-class concept used by ~60 components (DIL_IC, Breadboard, TubeSocket…). No new plumbing.
D5 Type identity One shared ComponentType; the instance carries a blockName field, and the handful of type-name consumers prefer it ComponentProcessor caches ComponentType per Java class, so per-block types would mean changing the cache key of a widely used singleton. The field approach has a much smaller blast radius.
D6 Electrical modelling The composite is not IContinuity and not ISwitch A composite is a footprint that exposes terminals, not a circuit model. Internal point-to-point continuity and internal switching are explicitly out of scope.
D7 Internal copper Left alone — child copper still registers continuity areas Children draw through the same G2DWrapper, so startTrackingContinuityArea calls land on the composite naturally. A block built from a board with copper pours still conducts, which matches physical reality. Zero code.
D8 Child properties Opaque — the property editor shows only the composite's own properties Matches the "single component" mental model. Children are frozen as saved. Because children are serialized anyway (D1), flattened child-property editing can be layered on later with no file-format change.
D9 Rotation / mirroring Supported, by delegating to each child's own IComponentTransformer around a shared centre Reuses existing per-component transformers. If any child's type has no transformer, the composite reports that it cannot rotate.
D10 Mode selection Composite is the global default on click; group mode via the existing drop-down / right-click popup Most uses want the new behaviour, and it costs no extra click.
D11 BOM One row per composite, keyed by block name Consistent with D5 and D8.
D12 Explode / collapse Not in this change The only way to get a composite is to place a block in composite mode; the only way to get loose components is group mode. Keeps the surface area small.

Explicitly out of scope

  • Converting a placed composite back into loose components, or collapsing an arbitrary selection into a composite (D12).
  • Editing child properties after placement (D8).
  • Internal switches. A block containing an ISwitch will place fine, but the switch is inert: it contributes terminals and drawing, no switching behaviour (D6). See §7.
  • Pushing block-library edits into already-placed instances (D1).

3. Why this fits the existing architecture

Three mechanisms already exist and carry most of the weight.

Rigidity. Presenter reads canPointMoveFreely in exactly three places: Presenter.java:868 excludes the point from individual dragging, Presenter.java:1243 forces all control points into the drag set when the component is stuck to something, and Presenter.java:1418 marks the move as rigid in moveComponents. Returning false everywhere yields "moves as a unit, never stretches" with no new code.

Placement. Presenter.loadBlock (Presenter.java:2847) already routes through pasteComponents(new ComponentTransferable(components, blockName), true, true). Composite mode simply pastes a one-element list. Note that InstantiationManager.pasteComponents and updateSingleClick position components by translating every control point by the same delta via setControlPoint(p, i) — so setControlPoint must delegate per-point to the child. Rigidity is enforced by canPointMoveFreely, never by setControlPoint refusing a write.

Node naming. Node.toString() prefixes the node name with the component name, so whatever the composite returns from getControlPointNodeName reads as BLK1.<name> in the netlist with no changes to the netlist code. Only resolving clashes between children's names is new (D3).

Three things do not come for free and are the substance of the work: type identity (D5), palette visibility (§4.2), and a correct deep clone() (§7).

4. Implementation

4.1 New: CompositeComponent

diylc-core/src/main/java/org/diylc/components/composite/CompositeComponent.java

The package must sit under org.diylc.components — ComponentProcessor restricts its Reflections scan with filterInputsBy(new FilterBuilder().includePackage("org.diylc.components")), and ProjectFileManager.configure declares xStream.aliasPackage("diylc", "org.diylc.components"), so the class serializes as diylc.composite.CompositeComponent with no extra XStream configuration.

@ComponentDescriptor(
    name = "Building Block",
    description = "A group of components instantiated as a single rigid component",
    category = "Building Blocks",
    instanceNamePrefix = "BLK",
    author = "Branislav Stojkovic",
    zOrder = IDIYComponent.COMPONENT,
    flexibleZOrder = true,
    bomPolicy = BomPolicy.SHOW_ALL_NAMES,
    autoEdit = false,
    enableCache = false,
    transformer = CompositeComponentTransformer.class,
    hiddenInPalette = true)
public class CompositeComponent extends AbstractComponent<Void> {

  private List<IDIYComponent<?>> components;   // embedded children (D1)
  private String blockName;                    // type identity (D5)

  private transient int[] childOf;             // flat index -> child ordinal
  private transient int[] pointOf;             // flat index -> child point index
}

The @ComponentDescriptor annotation is mandatory, not cosmetic: extractComponentTypeFrom returns null without it, and NetlistBuilder:69 and :401 dereference the result.

Point index mapping. A flat index is built lazily from the children and cached in the two transient arrays. Children never change after construction (D8), so the cache never needs invalidating; it only needs rebuilding after deserialization, which the lazy getter handles.

Delegating members

Member Behaviour
getControlPointCount() Sum over children
getControlPoint(i) Delegate
setControlPoint(p, i) Delegate (required for placement — see §3)
isControlPointSticky(i) Delegate
canControlPointOverlap(i) Delegate
getControlPointVisibilityPolicy(i) Delegate
getControlPointNodeName(i) Resolved once alongside the index arrays (D3). Only sticky, named points take part in clash detection, so a board's non-sticky corner points never force a pad to be renamed
canPointMoveFreely(i) false (D4)
createdIn(project) Forward to every child

Own members

  • draw(...) — forwards to each child in order with the same Graphics2D and IDrawingObserver, passing ComponentState.NORMAL so children do not each paint their own red highlight. When state != NORMAL the composite then draws a single dashed outline around the aggregate bounds. (DIYLC has no central selection rendering; components draw their own, so the composite must draw its own.)
  • drawIcon(...) — a generic block glyph. extractComponentTypeFrom calls drawIcon on a default-constructed instance with no children, so this must not touch components.
  • getValue() / setValue() — null / no-op. getValueForDisplay() inherits the AbstractComponent behaviour of returning "" for a null value, which keeps the BOM row (BomMaker skips rows with a null value).
  • getBlockName() — @EditableProperty(name = "Block"), read-only in practice.
  • clone() — must be overridden. AbstractComponent.clone() copies fields reflectively and only deep-copies Point2D and Point2D[]. The components list would be shared by reference between clones, so moving one instance would move every copy. The override deep-clones each child (via each child's own clone(), which preserves its id, matching AbstractComponent.clone()'s contract) and leaves the transient index arrays to rebuild lazily. Building BlockManager.loadBlock is a separate path and explicitly assigns fresh ids when it clones source components to place a brand new instance. See §7.

4.2 Hiding the type from the palette

ComponentProcessor.getComponentTypes() groups every annotated class by category and feeds the toolbox tabs and the component tree. A composite is never created from the palette, so it should not appear there — but it still needs a resolvable ComponentType for the netlist, BOM and name generation.

Add boolean hiddenInPalette() default false to ComponentDescriptor, carry it on ComponentType, and skip such types inside getComponentTypes(). extractComponentTypeFrom is unaffected, so every existing consumer keeps working. This is one new annotation attribute and one continue.

4.3 New: CompositeComponentTransformer

diylc-core/src/main/java/org/diylc/components/composite/CompositeComponentTransformer.java

canRotate(c)              -> every child's ComponentType has a transformer that canRotate(child)
canMirror(c)              -> same, for canMirror
mirroringChangesCircuit() -> true if any child's transformer says so
rotate(c, center, dir)    -> for each child: childType.getTransformer().rotate(child, center, dir)
mirror(c, center, dir)    -> likewise

Passing the same center to every child keeps the assembly geometrically consistent. A child whose type declares no transformer makes the whole composite non-rotatable, which is honest — silently leaving one child un-rotated would be worse.

4.4 Instantiation mode

New enum org.diylc.common.BlockInstantiationMode { GROUP, COMPOSITE }.

IBlockProcessor gains loadBlock(String blockName, BlockInstantiationMode mode). The existing single-argument loadBlock is kept and delegates with COMPOSITE, so the default is the new behaviour (D10) and no call site is left behind.

BuildingBlockManager.loadBlock(blockName, existingComponents, mode):

  • GROUP — exactly today's behaviour.
  • COMPOSITE — clone the stored components with fresh UUIDs but keep their saved names unchanged. Child names surface in terminal names when pins clash (D3), so running them through createUniqueName against the project would produce meaningless drift (R1 becoming R37) and make two instances of the same block resolve different netlist labels. Then wrap them in a CompositeComponent, set blockName, and give the composite itself a project-unique name via createUniqueName.

Presenter.loadBlock passes the mode through. For composite mode, call pasteComponents(..., autoGroup = false, ...) — there is nothing to group.

4.5 Type-name consumers (D5)

Add a helper so the special case lives in one place:

// ComponentProcessor
public static String getDisplayTypeName(IDIYComponent<?> c, ComponentType type) {
  if (c instanceof CompositeComponent cc && cc.getBlockName() != null)
    return cc.getBlockName();
  return type.getName();
}

Call sites to update:

File Line Effect
AiProjectBuilder.mapComponent :178 The AI sees "Arduino Uno" instead of "Building Block" — the main reason for D5
BomMaker.getBom :80, :100-101 Grouping key and BOM row label become the block name (D11)
Presenter.selectMatching :1064 "select matching" finds blocks by their block name

SpiceSumarizer:94,107 should be audited but likely needs no change — a composite is not a recognised SPICE element and will fall through as it does for any other unmodelled component.

4.6 UI wiring

Four call sites reach loadBlock, all through IPlugInPort:

File Line Change
ComponentButtonFactory.createBuildingBlockButton :319 Default action → COMPOSITE
CustomTreeModel (favorites) :145 Click → COMPOSITE
CustomTreeModel (Building Blocks category) :185 Click → COMPOSITE
CanvasPanel.functionKeyPressed :255 F-key shortcut → COMPOSITE

Group mode is reached from the drop-down popup the toolbox button already carries. ComponentTabbedPane.createVariantPopup(null, blockName) builds it for blocks today (currently only the "Assign Shortcut" submenu); add an "Insert as Separate Components" item at the top. Mirror it in the tree's right-click menu for block nodes.

5. Serialization and compatibility

  • Existing .diy files are unaffected — they contain no composites.
  • Existing blocks in config are unaffected. The mode is chosen at placement, not stored on the block, so buildingBlocks keeps its Map<String, List<IDIYComponent<?>>> shape and needs no migration. Default and imported blocks work in both modes immediately.
  • New .diy files containing composites cannot be opened by older DIYLC versions. Expected and unavoidable given D1.
  • Children serialize as ordinary nested diylc.* elements inside the composite's components list, so every existing component converter applies unchanged.

6. Suggested order of work

  1. CompositeComponent with the delegating members, the point index map, a correct clone(), and draw. No UI yet — exercise it from a unit test that builds a composite directly and asserts point counts, node names and rigidity.
  2. hiddenInPalette on ComponentDescriptor / ComponentType / getComponentTypes().
  3. BlockInstantiationMode, the IBlockProcessor overload, BuildingBlockManager.loadBlock, Presenter.loadBlock.
  4. UI wiring: four default call sites plus the "Insert as Separate Components" popup items.
  5. getDisplayTypeName and its three consumers.
  6. CompositeComponentTransformer, and rotation/mirroring enablement.
  7. Round-trip and regression tests (§8).

Steps 1–4 are the minimum for a usable feature; 5–6 complete it.

7. Risks

clone() is the sharp edge. AbstractComponent.clone() is reflective and only deep-copies points. Without an override, two placed copies of the same composite share one child list, and moving one moves both. This will not fail loudly — it will look like a bizarre rendering bug. It needs a dedicated test.

Z-order. A composite occupies a single slot in project.getComponents(), so children spanning the board, trace and component layers all render at that one slot. For a board-like block this is correct. It does mean an external trace cannot be sandwiched between a block's board and the parts mounted on it. The descriptor uses zOrder = COMPONENT with flexibleZOrder = true; deriving the z-order from the lowest child at save time is a possible refinement if this proves annoying in practice.

Inert internal switches. Per D6 the composite is not ISwitch, so a block containing a switch places and draws correctly but contributes no switching to the netlist. This is a silent difference from group mode. Worth a log warning when a composite is created from a selection containing an ISwitch, and worth mentioning in the release notes.

Variants. Presenter.getVariantsFor keys on ComponentType, so all composites share one variant pool regardless of block. Applying a variant to a composite would be meaningless. Suppress the variant UI for CompositeComponent selections.

Naming collisions. Two instances of the same block deliberately carry identically named children (§4.4). Netlist labels stay unique because they are prefixed with the composite's own name (ARD1.R1.1 vs ARD2.R1.1). Any code that assumes component names are globally unique across the project would be affected — none is known, but worth watching.

8. Testing

  • Unit — point count equals the sum over children; getControlPoint(i) tracks the child after the child moves; canPointMoveFreely is false for every index; getControlPointNodeName passes unique names through, qualifies clashing pins of multi-point children and numbers the remaining clashes (D3).
  • Clone isolation — clone a composite, move the clone, assert the original's points did not move.
  • Round trip — save a project containing a composite, reload it, assert the point count, names and geometry survive, and that the transient index arrays rebuild lazily.
  • Both modes — place the same block in group and composite mode; assert the group yields N components and the composite yields one with the same total point count and the same terminal coordinates.
  • Connectivity — place a composite built from a board with copper, run the netlist, confirm internal copper still forms continuity areas (D7) while no IContinuity point-to-point connections are reported (D6).
  • BOM — two instances of one block collapse to a single row with quantity 2, labelled by block name.
  • Regression — run the existing diylc-regression-data suite to confirm nothing in the ordinary drawing/netlist path shifted.