>(),
+ BlockInstantiationMode.COMPOSITE);
+
+ assertNotNull(result);
+ assertEquals(1, result.size());
+ assertTrue(((CompositeComponent) result.get(0)).getChildComponents().get(0) instanceof ISwitch);
+ }
+}
diff --git a/diylc/diylc-core/src/test/java/org/diylc/testcomponents/SwitchTestComponent.java b/diylc/diylc-core/src/test/java/org/diylc/testcomponents/SwitchTestComponent.java
new file mode 100644
index 00000000..a1847ccb
--- /dev/null
+++ b/diylc/diylc-core/src/test/java/org/diylc/testcomponents/SwitchTestComponent.java
@@ -0,0 +1,65 @@
+/*
+
+ 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.testcomponents;
+
+import java.awt.geom.Point2D;
+
+import org.diylc.core.ISwitch;
+
+/**
+ * Smallest possible {@link ISwitch} implementation, used to verify that a building block
+ * containing a switch can still be instantiated as a {@code CompositeComponent} (design decision
+ * D6 in {@code docs/plans/composite-building-blocks.md} - the composite itself is not
+ * {@code ISwitch}, so the switch places and draws but contributes no switching behavior).
+ *
+ * Deliberately a public top-level class, not a nested one: {@link
+ * org.diylc.components.AbstractComponent#clone()} instantiates the no-arg constructor via plain
+ * reflection (no {@code setAccessible}), which fails across packages unless both the class and
+ * the constructor are public.
+ */
+public class SwitchTestComponent extends TwoPointTestComponent implements ISwitch {
+
+ private static final long serialVersionUID = 1L;
+
+ public SwitchTestComponent() {
+ this("SW");
+ }
+
+ public SwitchTestComponent(String name) {
+ super(name, new Point2D.Double(0, 0), new Point2D.Double(10, 0));
+ }
+
+ @Override
+ public int getPositionCount() {
+ return 2;
+ }
+
+ @Override
+ public String getPositionName(int position) {
+ return position == 0 ? "ON" : "OFF";
+ }
+
+ @Override
+ public boolean arePointsConnected(int index1, int index2, int position) {
+ return position == 0;
+ }
+}
diff --git a/diylc/diylc-core/src/test/java/org/diylc/testcomponents/TwoPointTestComponent.java b/diylc/diylc-core/src/test/java/org/diylc/testcomponents/TwoPointTestComponent.java
new file mode 100644
index 00000000..a26ce00a
--- /dev/null
+++ b/diylc/diylc-core/src/test/java/org/diylc/testcomponents/TwoPointTestComponent.java
@@ -0,0 +1,114 @@
+/*
+
+ 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.testcomponents;
+
+import java.awt.Graphics2D;
+import java.awt.geom.Point2D;
+
+import org.diylc.components.AbstractComponent;
+import org.diylc.core.ComponentState;
+import org.diylc.core.IDIYComponent;
+import org.diylc.core.IDrawingObserver;
+import org.diylc.core.Project;
+import org.diylc.core.VisibilityPolicy;
+import org.diylc.core.annotations.ComponentDescriptor;
+
+/**
+ * Minimal two-point component used to exercise {@code CompositeComponent} and
+ * {@code BuildingBlockManager} in tests: two control points, individually configurable
+ * stickiness, node naming left at the {@link AbstractComponent} default (the 1-based point
+ * index), so a composite's per-child node name delegation can be verified precisely.
+ */
+@ComponentDescriptor(name = "Two Point Test", category = "Test", author = "Test",
+ description = "test", instanceNamePrefix = "TP", zOrder = IDIYComponent.COMPONENT)
+public class TwoPointTestComponent extends AbstractComponent {
+
+ private static final long serialVersionUID = 1L;
+
+ private Point2D p0;
+ private Point2D p1;
+ private boolean sticky0 = true;
+ private boolean sticky1 = true;
+
+ public TwoPointTestComponent() {
+ this("", new Point2D.Double(0, 0), new Point2D.Double(10, 0));
+ }
+
+ public TwoPointTestComponent(String name, Point2D p0, Point2D p1) {
+ setName(name);
+ this.p0 = p0;
+ this.p1 = p1;
+ }
+
+ public void setSticky(int index, boolean sticky) {
+ if (index == 0)
+ sticky0 = sticky;
+ else
+ sticky1 = sticky;
+ }
+
+ @Override
+ public int getControlPointCount() {
+ return 2;
+ }
+
+ @Override
+ public Point2D getControlPoint(int index) {
+ return index == 0 ? p0 : p1;
+ }
+
+ @Override
+ public void setControlPoint(Point2D point, int index) {
+ if (index == 0)
+ p0 = point;
+ else
+ p1 = point;
+ }
+
+ @Override
+ public boolean isControlPointSticky(int index) {
+ return index == 0 ? sticky0 : sticky1;
+ }
+
+ @Override
+ public VisibilityPolicy getControlPointVisibilityPolicy(int index) {
+ return VisibilityPolicy.ALWAYS;
+ }
+
+ @Override
+ public Void getValue() {
+ return null;
+ }
+
+ @Override
+ public void setValue(Void value) {
+ }
+
+ @Override
+ public void draw(Graphics2D g2d, ComponentState componentState, boolean outlineMode, Project project,
+ IDrawingObserver drawingObserver) {
+ }
+
+ @Override
+ public void drawIcon(Graphics2D g2d, int width, int height) {
+ }
+}
diff --git a/diylc/diylc-library/src/main/java/org/diylc/components/connectivity/Eyelet.java b/diylc/diylc-library/src/main/java/org/diylc/components/connectivity/Eyelet.java
index 47862d5e..57a9caa4 100644
--- a/diylc/diylc-library/src/main/java/org/diylc/components/connectivity/Eyelet.java
+++ b/diylc/diylc-library/src/main/java/org/diylc/components/connectivity/Eyelet.java
@@ -62,6 +62,7 @@ public class Eyelet extends AbstractTransparentComponent {
private Color color = COLOR;
private Point2D.Double point = new Point2D.Double(0, 0);
private String value = "";
+ private String nodeName = "";
@Override
public void draw(Graphics2D g2d, ComponentState componentState, boolean outlineMode, Project project,
@@ -173,9 +174,22 @@ public class Eyelet extends AbstractTransparentComponent {
this.value = value;
}
+ @EditableProperty(name = "Node Name", defaultable = false)
+ public String getNodeName() {
+ if (nodeName == null) {
+ nodeName = "";
+ }
+ return nodeName;
+ }
+
+ public void setNodeName(String nodeName) {
+ this.nodeName = nodeName;
+ }
+
@Override
- public String getControlPointNodeName(int index) {
- return null;
+ public String getControlPointNodeName(int index) {
+ String n = getNodeName();
+ return n.trim().isEmpty() ? null : n.trim();
}
@Override
diff --git a/diylc/diylc-library/src/main/java/org/diylc/components/connectivity/PCBTerminalBlock.java b/diylc/diylc-library/src/main/java/org/diylc/components/connectivity/PCBTerminalBlock.java
index 2a08df04..f7ca250c 100644
--- a/diylc/diylc-library/src/main/java/org/diylc/components/connectivity/PCBTerminalBlock.java
+++ b/diylc/diylc-library/src/main/java/org/diylc/components/connectivity/PCBTerminalBlock.java
@@ -71,6 +71,7 @@ public class PCBTerminalBlock extends AbstractTransparentComponent
private Size holeSize = HOLE_SIZE;
private PCBLayer layer = PCBLayer._1;
private HoleType holeType = HoleType.NTPH;
+ private String nodeName = "";
@Override
public void draw(Graphics2D g2d, ComponentState componentState, boolean outlineMode,
@@ -246,9 +247,22 @@ public class SolderPad extends AbstractTransparentComponent
return null;
}
+ @EditableProperty(name = "Node Name", defaultable = false)
+ public String getNodeName() {
+ if (nodeName == null) {
+ nodeName = "";
+ }
+ return nodeName;
+ }
+
+ public void setNodeName(String nodeName) {
+ this.nodeName = nodeName;
+ }
+
@Override
public String getControlPointNodeName(int index) {
- return null;
+ String n = getNodeName();
+ return n.trim().isEmpty() ? null : n.trim();
}
@Override
diff --git a/diylc/diylc-library/src/main/java/org/diylc/components/connectivity/Turret.java b/diylc/diylc-library/src/main/java/org/diylc/components/connectivity/Turret.java
index 93654a08..551de534 100644
--- a/diylc/diylc-library/src/main/java/org/diylc/components/connectivity/Turret.java
+++ b/diylc/diylc-library/src/main/java/org/diylc/components/connectivity/Turret.java
@@ -60,6 +60,7 @@ public class Turret extends AbstractTransparentComponent {
private Color color = COLOR;
private Point2D.Double point = new Point2D.Double(0, 0);
private String value = "";
+ private String nodeName = "";
@Override
public void draw(Graphics2D g2d, ComponentState componentState, boolean outlineMode, Project project,
@@ -168,9 +169,22 @@ public class Turret extends AbstractTransparentComponent {
this.value = value;
}
+ @EditableProperty(name = "Node Name", defaultable = false)
+ public String getNodeName() {
+ if (nodeName == null) {
+ nodeName = "";
+ }
+ return nodeName;
+ }
+
+ public void setNodeName(String nodeName) {
+ this.nodeName = nodeName;
+ }
+
@Override
- public String getControlPointNodeName(int index) {
- return null;
+ public String getControlPointNodeName(int index) {
+ String n = getNodeName();
+ return n.trim().isEmpty() ? null : n.trim();
}
@Override
diff --git a/diylc/diylc-library/src/main/java/org/diylc/components/electromechanical/PinHeader.java b/diylc/diylc-library/src/main/java/org/diylc/components/electromechanical/PinHeader.java
index 8d10ef09..66154edc 100644
--- a/diylc/diylc-library/src/main/java/org/diylc/components/electromechanical/PinHeader.java
+++ b/diylc/diylc-library/src/main/java/org/diylc/components/electromechanical/PinHeader.java
@@ -71,6 +71,7 @@ public class PinHeader extends AbstractTransparentComponent {
private int columnCount = 5;
private Size spacing = PIN_SPACING;
private Boolean shrouded = false;
+ private String nodeNames = "";
private Color bodyColor = BODY_COLOR;
private Color shroudColor = BODY_COLOR.darker();
@@ -342,6 +343,31 @@ public class PinHeader extends AbstractTransparentComponent {
this.shroudColor = shroudColor;
}
+ @EditableProperty(name = "Node Names", defaultable = false)
+ public String getNodeNames() {
+ if (nodeNames == null) {
+ nodeNames = "";
+ }
+ return nodeNames;
+ }
+
+ public void setNodeNames(String nodeNames) {
+ this.nodeNames = nodeNames;
+ }
+
+ @Override
+ public String getControlPointNodeName(int index) {
+ // Comma-separated list, one entry per pin in control point order. When the list is
+ // blank, or an individual entry is missing/blank, the pin keeps its ordinal number.
+ String raw = getNodeNames();
+ if (raw.trim().isEmpty()) {
+ return Integer.toString(index + 1);
+ }
+ String[] parts = raw.split(",", -1);
+ String label = index < parts.length ? parts[index].trim() : "";
+ return label.isEmpty() ? Integer.toString(index + 1) : label;
+ }
+
@Override
public boolean canPointMoveFreely(int pointIndex) {
return false;
diff --git a/diylc/diylc-library/src/main/java/org/diylc/components/passive/AxialElectrolyticCapacitor.java b/diylc/diylc-library/src/main/java/org/diylc/components/passive/AxialElectrolyticCapacitor.java
index 2c62d5c1..dddf6a5d 100644
--- a/diylc/diylc-library/src/main/java/org/diylc/components/passive/AxialElectrolyticCapacitor.java
+++ b/diylc/diylc-library/src/main/java/org/diylc/components/passive/AxialElectrolyticCapacitor.java
@@ -203,6 +203,19 @@ public class AxialElectrolyticCapacitor extends AbstractLeadedComponent
this.polarized = polarized;
}
+ @Override
+ public String getControlPointNodeName(int index) {
+ if (getPolarized()) {
+ switch (index) {
+ case 0:
+ return "+";
+ case 1:
+ return "-";
+ }
+ }
+ return super.getControlPointNodeName(index);
+ }
+
public void drawIcon(Graphics2D g2d, int width, int height) {
g2d.rotate(-Math.PI / 4, width / 2, height / 2);
g2d.setColor(LEAD_COLOR);
diff --git a/diylc/diylc-library/src/main/java/org/diylc/components/passive/RadialElectrolytic.java b/diylc/diylc-library/src/main/java/org/diylc/components/passive/RadialElectrolytic.java
index 4a4f8ab8..bd61068f 100644
--- a/diylc/diylc-library/src/main/java/org/diylc/components/passive/RadialElectrolytic.java
+++ b/diylc/diylc-library/src/main/java/org/diylc/components/passive/RadialElectrolytic.java
@@ -72,6 +72,7 @@ public class RadialElectrolytic extends AbstractRadialComponent imp
private boolean polarized = true;
private boolean folded = false;
private Size height = HEIGHT;
+ @Deprecated
private boolean invert = false;
public RadialElectrolytic() {
@@ -304,7 +305,7 @@ public class RadialElectrolytic extends AbstractRadialComponent imp
this.height = height;
}
- @EditableProperty(name = "Invert Polarity")
+// @EditableProperty(name = "Invert Polarity")
public boolean getInvert() {
return invert;
}
@@ -313,6 +314,19 @@ public class RadialElectrolytic extends AbstractRadialComponent imp
this.invert = invert;
}
+ @Override
+ public String getControlPointNodeName(int index) {
+ if (getPolarized()) {
+ switch (index) {
+ case 0:
+ return getInvert() ? "-" : "+";
+ case 1:
+ return getInvert() ? "+" : "-";
+ }
+ }
+ return super.getControlPointNodeName(index);
+ }
+
@Override
protected Shape getBodyShape() {
double height = (int) getHeight().convertToPixels();
diff --git a/diylc/diylc-library/src/main/java/org/diylc/components/semiconductors/AbstractDiodeSymbol.java b/diylc/diylc-library/src/main/java/org/diylc/components/semiconductors/AbstractDiodeSymbol.java
index 525f3e17..c1cba73e 100644
--- a/diylc/diylc-library/src/main/java/org/diylc/components/semiconductors/AbstractDiodeSymbol.java
+++ b/diylc/diylc-library/src/main/java/org/diylc/components/semiconductors/AbstractDiodeSymbol.java
@@ -85,6 +85,18 @@ public abstract class AbstractDiodeSymbol extends AbstractSchematicLeadedSymbol<
@Override
protected void decorateComponentBody(Graphics2D g2d, boolean outlineMode) {}
+ @Override
+ public String getControlPointNodeName(int index) {
+ switch (index) {
+ case 0:
+ return "A";
+ case 1:
+ return "K";
+ default:
+ return null;
+ }
+ }
+
@Deprecated
@Override
public Size getLength() {
diff --git a/diylc/diylc-library/src/main/java/org/diylc/components/semiconductors/AbstractTransistorPackage.java b/diylc/diylc-library/src/main/java/org/diylc/components/semiconductors/AbstractTransistorPackage.java
index e4475241..b82e7130 100644
--- a/diylc/diylc-library/src/main/java/org/diylc/components/semiconductors/AbstractTransistorPackage.java
+++ b/diylc/diylc-library/src/main/java/org/diylc/components/semiconductors/AbstractTransistorPackage.java
@@ -244,6 +244,9 @@ public abstract class AbstractTransistorPackage extends AbstractLabeledComponent
public String getControlPointNodeName(int index) {
if (index >= 3)
return null;
+ if (getPinout() != null) {
+ return getPinout().toPinout().substring(index, index + 1);
+ }
return Integer.toString(index + 1);
}
diff --git a/diylc/diylc-library/src/main/java/org/diylc/components/semiconductors/DiodeGlass.java b/diylc/diylc-library/src/main/java/org/diylc/components/semiconductors/DiodeGlass.java
index 82bcf1b8..de975e35 100644
--- a/diylc/diylc-library/src/main/java/org/diylc/components/semiconductors/DiodeGlass.java
+++ b/diylc/diylc-library/src/main/java/org/diylc/components/semiconductors/DiodeGlass.java
@@ -92,6 +92,18 @@ public class DiodeGlass extends AbstractLeadedComponent implements IGerb
this.value = value;
}
+ @Override
+ public String getControlPointNodeName(int index) {
+ switch (index) {
+ case 0:
+ return "A";
+ case 1:
+ return "K";
+ default:
+ return null;
+ }
+ }
+
public void drawIcon(Graphics2D g2d, int width, int height) {
g2d.rotate(-Math.PI / 4, width / 2, height / 2);
g2d.setColor(LEAD_COLOR_ICON);
diff --git a/diylc/diylc-library/src/main/java/org/diylc/components/semiconductors/DiodePlastic.java b/diylc/diylc-library/src/main/java/org/diylc/components/semiconductors/DiodePlastic.java
index 7a552db0..594721af 100644
--- a/diylc/diylc-library/src/main/java/org/diylc/components/semiconductors/DiodePlastic.java
+++ b/diylc/diylc-library/src/main/java/org/diylc/components/semiconductors/DiodePlastic.java
@@ -90,6 +90,18 @@ public class DiodePlastic extends AbstractLeadedComponent implements IGe
this.value = value;
}
+ @Override
+ public String getControlPointNodeName(int index) {
+ switch (index) {
+ case 0:
+ return "A";
+ case 1:
+ return "K";
+ default:
+ return null;
+ }
+ }
+
public void drawIcon(Graphics2D g2d, int width, int height) {
g2d.rotate(-Math.PI / 4, width / 2, height / 2);
g2d.setColor(LEAD_COLOR_ICON);
diff --git a/diylc/diylc-library/src/main/java/org/diylc/components/semiconductors/LED.java b/diylc/diylc-library/src/main/java/org/diylc/components/semiconductors/LED.java
index e36da35c..ca3da9e9 100644
--- a/diylc/diylc-library/src/main/java/org/diylc/components/semiconductors/LED.java
+++ b/diylc/diylc-library/src/main/java/org/diylc/components/semiconductors/LED.java
@@ -67,6 +67,18 @@ public class LED extends AbstractLeadedComponent implements IGerberCompo
this.value = value;
}
+ @Override
+ public String getControlPointNodeName(int index) {
+ switch (index) {
+ case 0:
+ return "A";
+ case 1:
+ return "K";
+ default:
+ return null;
+ }
+ }
+
public void drawIcon(Graphics2D g2d, int width, int height) {
g2d.setStroke(ObjectCache.getInstance().fetchBasicStroke(1));
g2d.rotate(-Math.PI / 4, width / 2, height / 2);
diff --git a/diylc/diylc-swing/src/main/java/org/diylc/swing/actions/edit/SaveAsBlockAction.java b/diylc/diylc-swing/src/main/java/org/diylc/swing/actions/edit/SaveAsBlockAction.java
index c51d7cab..19e97b7b 100644
--- a/diylc/diylc-swing/src/main/java/org/diylc/swing/actions/edit/SaveAsBlockAction.java
+++ b/diylc/diylc-swing/src/main/java/org/diylc/swing/actions/edit/SaveAsBlockAction.java
@@ -1,9 +1,18 @@
package org.diylc.swing.actions.edit;
+import java.awt.BorderLayout;
import java.awt.event.ActionEvent;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
import javax.swing.AbstractAction;
+import javax.swing.JComboBox;
+import javax.swing.JLabel;
import javax.swing.JOptionPane;
+import javax.swing.JPanel;
+import org.diylc.appframework.miscutils.ConfigurationManager;
+import org.diylc.common.IBlockProcessor;
import org.diylc.common.IPlugInPort;
import org.diylc.swing.ActionFactory;
import org.diylc.utils.IconLoader;
@@ -12,6 +21,9 @@ public class SaveAsBlockAction extends AbstractAction {
private static final long serialVersionUID = 1L;
+ /** How many recently used building blocks to offer in the name drop-down. */
+ private static final int MAX_RECENT_BLOCKS = 10;
+
private IPlugInPort plugInPort;
public SaveAsBlockAction(IPlugInPort plugInPort) {
@@ -24,10 +36,70 @@ public class SaveAsBlockAction extends AbstractAction {
@Override
public void actionPerformed(ActionEvent e) {
ActionFactory.LOG.info("Save as building block triggered");
- String templateName = JOptionPane.showInputDialog(null, "Block name:",
- "Save as Building Block", JOptionPane.PLAIN_MESSAGE);
- if (templateName != null && !templateName.trim().isEmpty()) {
- plugInPort.saveSelectionAsBlock(templateName);
+
+ JComboBox nameBox =
+ new JComboBox(getRecentBlockNames().toArray(new String[0]));
+ nameBox.setEditable(true);
+ // Start empty so an existing block is never overwritten by accident. The drop-down still
+ // offers the most recently used blocks (most recent first), so a user editing a block can
+ // pick its name back with a single click instead of retyping it.
+ nameBox.setSelectedItem("");
+
+ JPanel panel = new JPanel(new BorderLayout(5, 5));
+ panel.add(new JLabel("Block name:"), BorderLayout.NORTH);
+ panel.add(nameBox, BorderLayout.CENTER);
+
+ // Loop so that declining the overwrite prompt returns to the name dialog (with whatever was
+ // typed still in it) rather than cancelling the whole action.
+ while (true) {
+ int result = JOptionPane.showConfirmDialog(null, panel, "Save as Building Block",
+ JOptionPane.OK_CANCEL_OPTION, JOptionPane.PLAIN_MESSAGE);
+ if (result != JOptionPane.OK_OPTION)
+ return;
+
+ // getEditor().getItem() captures in-progress typing as well as a picked item
+ Object selected = nameBox.getEditor().getItem();
+ String blockName = selected == null ? null : selected.toString().trim();
+ if (blockName == null || blockName.isEmpty())
+ return;
+
+ if (blockExists(blockName)) {
+ int overwrite = JOptionPane.showConfirmDialog(null,
+ "A building block named \"" + blockName + "\" already exists. Overwrite it?",
+ "Save as Building Block", JOptionPane.YES_NO_CANCEL_OPTION,
+ JOptionPane.WARNING_MESSAGE);
+ if (overwrite == JOptionPane.NO_OPTION)
+ continue; // back to the name dialog
+ if (overwrite != JOptionPane.YES_OPTION)
+ return; // Cancel or dialog closed
+ }
+
+ plugInPort.saveSelectionAsBlock(blockName);
+ return;
}
}
-}
\ No newline at end of file
+
+ @SuppressWarnings("unchecked")
+ private boolean blockExists(String blockName) {
+ Map blocks = (Map) ConfigurationManager.getInstance()
+ .readObject(IBlockProcessor.BLOCKS_KEY, null);
+ return blocks != null && blocks.containsKey(blockName);
+ }
+
+ @SuppressWarnings("unchecked")
+ private List getRecentBlockNames() {
+ List recent = (List) ConfigurationManager.getInstance()
+ .readObject(IPlugInPort.RECENT_COMPONENTS_KEY, null);
+ List blockNames = new ArrayList();
+ if (recent != null) {
+ for (String identifier : recent) {
+ if (identifier.startsWith(IBlockProcessor.BLOCK_PREFIX)) {
+ blockNames.add(identifier.substring(IBlockProcessor.BLOCK_PREFIX.length()));
+ if (blockNames.size() >= MAX_RECENT_BLOCKS)
+ break;
+ }
+ }
+ }
+ return blockNames;
+ }
+}
diff --git a/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/canvas/CanvasPanel.java b/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/canvas/CanvasPanel.java
index 6940e2fb..5627a24a 100644
--- a/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/canvas/CanvasPanel.java
+++ b/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/canvas/CanvasPanel.java
@@ -45,8 +45,10 @@ import org.apache.log4j.Logger;
import org.diylc.appframework.miscutils.IConfigListener;
import org.diylc.appframework.miscutils.IConfigurationManager;
+import org.diylc.common.BlockInstantiationMode;
import org.diylc.common.ComponentType;
import org.diylc.common.DrawOption;
+import org.diylc.common.IBlockProcessor;
import org.diylc.common.IPlugInPort;
import org.diylc.common.IBlockProcessor.InvalidBlockException;
import org.diylc.swing.plugins.tree.TreePanel;
@@ -223,6 +225,17 @@ public class CanvasPanel extends JComponent implements Autoscroll {
(List) configManager.readObject(IPlugInPort.RECENT_COMPONENTS_KEY, null);
if (recent != null && !recent.isEmpty()) {
String clazz = recent.get(0);
+ if (clazz.startsWith(IBlockProcessor.BLOCK_PREFIX)) {
+ String blockName = clazz.substring(IBlockProcessor.BLOCK_PREFIX.length());
+ try {
+ CanvasPanel.this.plugInPort.loadBlock(blockName, BlockInstantiationMode.COMPOSITE);
+ // hack: fake mouse movement to repaint
+ CanvasPanel.this.plugInPort.mouseMoved(getMousePosition(), false, false, false);
+ } catch (InvalidBlockException ex) {
+ LOG.error("Could not repeat last building block: " + blockName);
+ }
+ return;
+ }
Map> componentTypes =
CanvasPanel.this.plugInPort.getComponentTypes();
for (Map.Entry> entry : componentTypes.entrySet()) {
@@ -249,10 +262,10 @@ public class CanvasPanel extends JComponent implements Autoscroll {
String typeName = shortcutMap.get("F" + i);
if (typeName == null)
return;
- if (typeName.startsWith("block:")) {
- String blockName = typeName.substring(6);
+ if (typeName.startsWith(IBlockProcessor.BLOCK_PREFIX)) {
+ String blockName = typeName.substring(IBlockProcessor.BLOCK_PREFIX.length());
try {
- plugInPort.loadBlock(blockName);
+ plugInPort.loadBlock(blockName, BlockInstantiationMode.COMPOSITE);
} catch (InvalidBlockException e) {
LOG.error("Could not find block assigned to shortcut: " + blockName);
}
diff --git a/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/toolbox/ComponentButtonFactory.java b/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/toolbox/ComponentButtonFactory.java
index dd7fac83..ac69ecb7 100644
--- a/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/toolbox/ComponentButtonFactory.java
+++ b/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/toolbox/ComponentButtonFactory.java
@@ -53,6 +53,7 @@ import org.diylc.appframework.miscutils.Utils;
import org.diylc.swingframework.openide.DropDownButtonFactory;
import com.google.common.collect.Lists;
+import org.diylc.common.BlockInstantiationMode;
import org.diylc.common.ComponentType;
import org.diylc.common.IPlugInPort;
import org.diylc.common.IBlockProcessor.InvalidBlockException;
@@ -316,7 +317,7 @@ public class ComponentButtonFactory {
@Override
public void actionPerformed(ActionEvent e) {
try {
- plugInPort.loadBlock(blockName);
+ plugInPort.loadBlock(blockName, BlockInstantiationMode.COMPOSITE);
} catch (InvalidBlockException e1) {
LOG.error("Error loading building block", e1);
}
diff --git a/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/toolbox/ComponentTabbedPane.java b/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/toolbox/ComponentTabbedPane.java
index a7c2c9f0..4de76b9c 100644
--- a/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/toolbox/ComponentTabbedPane.java
+++ b/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/toolbox/ComponentTabbedPane.java
@@ -48,6 +48,7 @@ import org.diylc.appframework.miscutils.IConfigListener;
import org.diylc.common.ComponentType;
import org.diylc.common.Favorite;
+import org.diylc.common.IBlockProcessor;
import org.diylc.common.IPlugInPort;
import org.diylc.common.Favorite.FavoriteType;
import org.diylc.core.IDIYComponent;
@@ -59,6 +60,7 @@ import org.diylc.swing.ISwingUI;
import org.diylc.utils.IconLoader;
import org.diylc.swing.plugins.tree.TreePanel;
import org.diylc.swing.plugins.tree.TreePanel.DeleteBlockAction;
+import org.diylc.swing.plugins.tree.TreePanel.InsertBlockAsGroupAction;
import org.diylc.swing.plugins.tree.TreePanel.SelectAllAction;
/**
@@ -286,16 +288,22 @@ class ComponentTabbedPane extends JTabbedPane {
private void refreshRecentComponentsToolbar(List recentComponentClassList) {
Container toolbar = getRecentToolbar();
toolbar.removeAll();
- for (String componentClassName : recentComponentClassList) {
- ComponentType componentType;
+ for (String identifier : recentComponentClassList) {
try {
- componentType = ComponentProcessor.getInstance().extractComponentTypeFrom(
- (Class extends IDIYComponent>>) Class.forName(componentClassName));
- Component button = ComponentButtonFactory.create(plugInPort, componentType,
- createVariantPopup(componentType, null));
- toolbar.add(button);
+ if (identifier.startsWith(IBlockProcessor.BLOCK_PREFIX)) {
+ String blockName = identifier.substring(IBlockProcessor.BLOCK_PREFIX.length());
+ Component button = ComponentButtonFactory.createBuildingBlockButton(plugInPort, blockName,
+ createVariantPopup(null, blockName));
+ toolbar.add(button);
+ } else {
+ ComponentType componentType = ComponentProcessor.getInstance().extractComponentTypeFrom(
+ (Class extends IDIYComponent>>) Class.forName(identifier));
+ Component button = ComponentButtonFactory.create(plugInPort, componentType,
+ createVariantPopup(componentType, null));
+ toolbar.add(button);
+ }
} catch (Exception e) {
- LOG.error("Could not create recent component button for " + componentClassName, e);
+ LOG.error("Could not create recent component button for " + identifier, e);
}
}
}
@@ -335,7 +343,8 @@ class ComponentTabbedPane extends JTabbedPane {
public void popupMenuWillBecomeVisible(PopupMenuEvent e) {
variantPopup.removeAll();
- final String identifier = componentType == null ? "block:" + blockName
+ final String identifier = componentType == null
+ ? IBlockProcessor.BLOCK_PREFIX + blockName
: componentType.getInstanceClass().getCanonicalName();
JMenu shortcutSubmenu = new JMenu("Assign Shortcut");
@@ -456,6 +465,7 @@ class ComponentTabbedPane extends JTabbedPane {
}
}
} else {
+ variantPopup.add(new InsertBlockAsGroupAction(plugInPort, blockName));
variantPopup.add(shortcutSubmenu);
variantPopup.add(new DeleteBlockAction(plugInPort, swingUI, blockName));
}
diff --git a/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/tree/CustomTreeModel.java b/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/tree/CustomTreeModel.java
index 8f86c950..bbfd6032 100644
--- a/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/tree/CustomTreeModel.java
+++ b/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/tree/CustomTreeModel.java
@@ -37,8 +37,10 @@ import org.apache.log4j.Logger;
import org.diylc.appframework.miscutils.ConfigurationManager;
import org.diylc.appframework.miscutils.IConfigListener;
+import org.diylc.common.BlockInstantiationMode;
import org.diylc.common.ComponentType;
import org.diylc.common.Favorite;
+import org.diylc.common.IBlockProcessor;
import org.diylc.common.IPlugInPort;
import org.diylc.common.Favorite.FavoriteType;
import org.diylc.common.IBlockProcessor.InvalidBlockException;
@@ -142,7 +144,7 @@ public class CustomTreeModel implements TreeModel {
&& System.currentTimeMillis() - previousActionTime > 100) {
previousActionTime = System.currentTimeMillis();
try {
- plugInPort.loadBlock(fav.getName());
+ plugInPort.loadBlock(fav.getName(), BlockInstantiationMode.COMPOSITE);
} catch (InvalidBlockException e1) {
e1.printStackTrace();
}
@@ -155,8 +157,37 @@ public class CustomTreeModel implements TreeModel {
}
if (RECENTLY_USED.equals(category)) {
- for (String className : this.recentComponents) {
- ComponentType type = this.typesByClass.get(className);
+ for (String identifier : this.recentComponents) {
+ if (identifier.startsWith(IBlockProcessor.BLOCK_PREFIX)) {
+ final String blockName = identifier.substring(IBlockProcessor.BLOCK_PREFIX.length());
+ // skip blocks that no longer exist
+ if (!this.blocks.contains(blockName))
+ continue;
+ if (this.searchText != null && !blockName.toLowerCase().contains(this.searchText))
+ continue;
+ visibleTypes.add(new TreeNode(blockName, new MouseAdapter() {
+
+ long previousActionTime = 0;
+
+ @Override
+ public void mouseClicked(MouseEvent e) {
+ if (e == null || SwingUtilities.isLeftMouseButton(e)
+ && System.currentTimeMillis() - previousActionTime > 100) {
+ previousActionTime = System.currentTimeMillis();
+ try {
+ plugInPort.loadBlock(blockName, BlockInstantiationMode.COMPOSITE);
+ } catch (InvalidBlockException e1) {
+ e1.printStackTrace();
+ }
+ }
+ }
+ }, true));
+ continue;
+ }
+ final ComponentType type = this.typesByClass.get(identifier);
+ // skip unknown component classes
+ if (type == null)
+ continue;
if (this.searchText == null || type.getName().toLowerCase().contains(this.searchText)) {
visibleTypes.add(new TreeNode(type, new MouseAdapter() {
@@ -182,7 +213,7 @@ public class CustomTreeModel implements TreeModel {
&& System.currentTimeMillis() - previousActionTime > 100) {
previousActionTime = System.currentTimeMillis();
try {
- plugInPort.loadBlock(block);
+ plugInPort.loadBlock(block, BlockInstantiationMode.COMPOSITE);
} catch (InvalidBlockException e1) {
e1.printStackTrace();
}
@@ -233,10 +264,16 @@ public class CustomTreeModel implements TreeModel {
}
if (RECENTLY_USED.equals(category)) {
- for (String className : this.recentComponents) {
- ComponentType type = typesByClass.get(className);
- if (type.getName().toLowerCase().contains(searchText))
- return true;
+ for (String identifier : this.recentComponents) {
+ if (identifier.startsWith(IBlockProcessor.BLOCK_PREFIX)) {
+ if (identifier.substring(IBlockProcessor.BLOCK_PREFIX.length()).toLowerCase()
+ .contains(searchText))
+ return true;
+ } else {
+ ComponentType type = typesByClass.get(identifier);
+ if (type != null && type.getName().toLowerCase().contains(searchText))
+ return true;
+ }
}
}
diff --git a/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/tree/TreePanel.java b/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/tree/TreePanel.java
index ea1904ce..f8b1687c 100644
--- a/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/tree/TreePanel.java
+++ b/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/tree/TreePanel.java
@@ -60,10 +60,13 @@ import org.apache.log4j.lf5.viewer.categoryexplorer.TreeModelAdapter;
import org.diylc.appframework.miscutils.ConfigurationManager;
import org.diylc.appframework.miscutils.IConfigListener;
+import org.diylc.common.BlockInstantiationMode;
import org.diylc.common.ComponentType;
import org.diylc.common.Favorite;
+import org.diylc.common.IBlockProcessor;
import org.diylc.common.IPlugInPort;
import org.diylc.common.Favorite.FavoriteType;
+import org.diylc.common.IBlockProcessor.InvalidBlockException;
import org.diylc.core.IDIYComponent;
import org.diylc.core.IView;
import org.diylc.core.Template;
@@ -313,7 +316,8 @@ public class TreePanel extends JPanel {
TreeNode payload = (TreeNode) selectedNode;
final ComponentType componentType = payload.getComponentType();
- final String identifier = componentType == null ? "block:" + payload.toString()
+ final String identifier = componentType == null
+ ? IBlockProcessor.BLOCK_PREFIX + payload.toString()
: componentType.getInstanceClass().getCanonicalName();
JMenu shortcutSubmenu = new JMenu("Assign Shortcut");
@@ -434,6 +438,7 @@ public class TreePanel extends JPanel {
}
}
} else if (getTreeModel().isLeaf(selectedNode)) {
+ popup.add(new InsertBlockAsGroupAction(plugInPort, payload.toString()));
popup.add(shortcutSubmenu);
popup.add(new DeleteBlockAction(plugInPort, swingUI, payload.toString()));
}
@@ -494,7 +499,8 @@ public class TreePanel extends JPanel {
HashMap shortcutMap = (HashMap) ConfigurationManager
.getInstance().readObject(TreePanel.COMPONENT_SHORTCUT_KEY, null);
- String identifier = payload.getComponentType() == null ? "block:" + payload.toString()
+ String identifier = payload.getComponentType() == null
+ ? IBlockProcessor.BLOCK_PREFIX + payload.toString()
: payload.getComponentType().getInstanceClass().getCanonicalName();
if (shortcutMap != null && shortcutMap.containsValue(identifier)) {
for (String key : shortcutMap.keySet()) {
@@ -565,6 +571,37 @@ public class TreePanel extends JPanel {
}
}
+ /**
+ * Left click on a building block node inserts it in composite mode (a single rigid component)
+ * by default - see design decision D10 in {@code docs/plans/composite-building-blocks.md}.
+ * This action offers the one extra click needed to fall back to the original behavior:
+ * every component of the block instantiated separately and auto-grouped.
+ */
+ public static class InsertBlockAsGroupAction extends AbstractAction {
+
+ private static final long serialVersionUID = 1L;
+
+ private IPlugInPort plugInPort;
+ private String blockName;
+
+ public InsertBlockAsGroupAction(IPlugInPort plugInPort, String blockName) {
+ super();
+ this.plugInPort = plugInPort;
+ this.blockName = blockName;
+ putValue(AbstractAction.NAME, "Insert as Grouped Components");
+ }
+
+ @Override
+ public void actionPerformed(ActionEvent e) {
+ LOG.info(getValue(AbstractAction.NAME) + " triggered");
+ try {
+ plugInPort.loadBlock(blockName, BlockInstantiationMode.GROUP);
+ } catch (InvalidBlockException e1) {
+ LOG.error("Could not find building block: " + blockName, e1);
+ }
+ }
+ }
+
public static class DeleteBlockAction extends AbstractAction {
private static final long serialVersionUID = 1L;
diff --git a/diylc/docs/plans/composite-building-blocks.md b/diylc/docs/plans/composite-building-blocks.md
new file mode 100644
index 00000000..97d57edf
--- /dev/null
+++ b/diylc/docs/plans/composite-building-blocks.md
@@ -0,0 +1,339 @@
+# 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 | `getSectionNames(i)` returns the child's name; `getControlPointNodeName(i)` returns the child's node name | `Node.toString()` already renders `component.getName() + "." + section + "." + nodeName`, so a netlist node reads `ARD1.PH1.3` with no changes to the netlist code. |
+| 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.getDisplayName()` prefixes the node name with
+`getSectionNames(pointIndex)[0]` when there is exactly one section, and
+`Node.toString()` prefixes that with the component name. Returning the child's
+name as the section gives hierarchical netlist labels for free.
+
+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.
+
+```java
+@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 {
+
+ private List> 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)` | Child's node name, prefixed with the child's own section name when it has exactly one |
+| `getSectionNames(i)` | `new String[] { child.getName() }` |
+| `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`
+
+```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 are namespaced under the composite's name
+ (D3), so running them through `createUniqueName` against the project would
+ produce meaningless drift (`R1` becoming `R37`) and break netlist labels
+ between two instances of the same block. 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:
+
+```java
+// 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>>` 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.PH1.3` vs `ARD2.PH1.3`). 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; `getSectionNames`/`getControlPointNodeName` produce
+ `ARD1.PH1.3` through `Node.toString()`.
+- **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.