diff --git a/diylc/diylc-core/src/main/java/org/diylc/common/BlockInstantiationMode.java b/diylc/diylc-core/src/main/java/org/diylc/common/BlockInstantiationMode.java new file mode 100644 index 00000000..46f0fabc --- /dev/null +++ b/diylc/diylc-core/src/main/java/org/diylc/common/BlockInstantiationMode.java @@ -0,0 +1,43 @@ +/* + + 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.common; + +/** + * The two ways a saved {@link BuildingBlock} can be placed on the canvas. + * + * @author Branislav Stojkovic + */ +public enum BlockInstantiationMode { + + /** + * The block's components are cloned as individual, independently selectable components and + * grouped together (the original, and only, behavior before composite mode was introduced). + */ + GROUP, + + /** + * The block's components are cloned and wrapped in a single + * {@code org.diylc.components.composite.CompositeComponent} that behaves as one rigid + * component with its own name, aggregating the control points of all its children. + */ + COMPOSITE +} diff --git a/diylc/diylc-core/src/main/java/org/diylc/common/ComponentType.java b/diylc/diylc-core/src/main/java/org/diylc/common/ComponentType.java index 52f9c931..773a5001 100644 --- a/diylc/diylc-core/src/main/java/org/diylc/common/ComponentType.java +++ b/diylc/diylc-core/src/main/java/org/diylc/common/ComponentType.java @@ -56,14 +56,14 @@ public class ComponentType implements Serializable { private boolean enableCache; private List datasheet; private int datasheetCreationStepCount; - - public ComponentType() { + + public ComponentType() { } public ComponentType(String name, String description, CreationMethod creationMethod, String category, String namePrefix, String author, Icon icon, Class> instanceClass, double zOrder, - boolean flexibleZOrder, BomPolicy bomPolicy, boolean autoEdit, IComponentTransformer transformer, - KeywordPolicy keywordPolicy, String keywordTag, boolean enableCache, List datasheet, + boolean flexibleZOrder, BomPolicy bomPolicy, boolean autoEdit, IComponentTransformer transformer, + KeywordPolicy keywordPolicy, String keywordTag, boolean enableCache, List datasheet, int datasheetCreationStepCount) { super(); this.name = name; diff --git a/diylc/diylc-core/src/main/java/org/diylc/common/IBlockProcessor.java b/diylc/diylc-core/src/main/java/org/diylc/common/IBlockProcessor.java index 1656ab98..3dcc369b 100644 --- a/diylc/diylc-core/src/main/java/org/diylc/common/IBlockProcessor.java +++ b/diylc/diylc-core/src/main/java/org/diylc/common/IBlockProcessor.java @@ -25,13 +25,28 @@ import java.io.IOException; public interface IBlockProcessor { - public static final String BLOCKS_KEY = "buildingBlocks"; + public static final String BLOCKS_KEY = "buildingBlocks"; public static final String DEFAULT_BLOCKS_IMPORTED_KEY = "defaultBlocksImported"; + /** + * Prefix that marks a string as a reference to a building block rather than a component class + * name. Used wherever components and blocks share a single identifier list, such as keyboard + * shortcuts ({@code COMPONENT_SHORTCUT_KEY}) and the recently used list + * ({@link org.diylc.common.IPlugInPort#RECENT_COMPONENTS_KEY}). A block identifier looks like + * {@code "block:My Block Name"}. + */ + public static final String BLOCK_PREFIX = "block:"; + void saveSelectionAsBlock(String blockName); - void loadBlock(String blockName) throws InvalidBlockException; - + /** + * Loads the block in the given {@link BlockInstantiationMode}. {@link BlockInstantiationMode#COMPOSITE} + * (a single rigid component) is the default mode used by most call sites (see design decision D10 in + * {@code docs/plans/composite-building-blocks.md}); {@link BlockInstantiationMode#GROUP} instantiates + * the block as a group of loose components. + */ + void loadBlock(String blockName, BlockInstantiationMode mode) throws InvalidBlockException; + void deleteBlock(String blockName); int importBlocks(String fileName) throws IOException; diff --git a/diylc/diylc-core/src/main/java/org/diylc/components/AbstractComponent.java b/diylc/diylc-core/src/main/java/org/diylc/components/AbstractComponent.java index 202d78cf..2dca4056 100644 --- a/diylc/diylc-core/src/main/java/org/diylc/components/AbstractComponent.java +++ b/diylc/diylc-core/src/main/java/org/diylc/components/AbstractComponent.java @@ -294,11 +294,6 @@ public abstract class AbstractComponent implements IDIYComponent { return null; } - @Override - public String[] getSectionNames(int pointIndex) { - return null; - } - @Override public boolean canPointMoveFreely(int pointIndex) { return true; diff --git a/diylc/diylc-core/src/main/java/org/diylc/components/composite/CompositeComponent.java b/diylc/diylc-core/src/main/java/org/diylc/components/composite/CompositeComponent.java new file mode 100644 index 00000000..97058ca0 --- /dev/null +++ b/diylc/diylc-core/src/main/java/org/diylc/components/composite/CompositeComponent.java @@ -0,0 +1,296 @@ +/* + + 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.components.composite; + +import java.awt.Color; +import java.awt.Composite; +import java.awt.Graphics2D; +import java.awt.Stroke; +import java.awt.geom.Point2D; +import java.awt.geom.Rectangle2D; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import org.diylc.components.AbstractComponent; +import org.diylc.components.AbstractTransparentComponent; +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.BomPolicy; +import org.diylc.core.annotations.ComponentDescriptor; +import org.diylc.core.annotations.EditableProperty; +import org.diylc.utils.Constants; + +/** + * Instantiation of a {@code BuildingBlock} as a single, rigid component rather than a group of + * loose ones. Wraps an embedded list of child components and aggregates their control points, + * so it can be placed, moved, rotated and wired up like any other component while the netlist, + * BOM and AI project description still see its individual terminals (see + * {@link #getControlPointNodeName(int)}). + * + * @author Branislav Stojkovic + */ +@ComponentDescriptor( + name = "Building Block", + description = "A group of components instantiated as a single rigid component", + category = "Building Blocks", + author = "Branislav Stojkovic", + instanceNamePrefix = "BLK", + zOrder = IDIYComponent.COMPONENT, + flexibleZOrder = true, + bomPolicy = BomPolicy.SHOW_ALL_NAMES, + autoEdit = false, + enableCache = false, + transformer = CompositeComponentTransformer.class, + hiddenInPalette = true) +public class CompositeComponent extends AbstractTransparentComponent { + + private static final long serialVersionUID = 1L; + + private List> childComponents = new ArrayList>(); + + // Name of the building block this instance was created from. Drives its display identity in + // the BOM, netlist report and AI project description (see ComponentProcessor.getDisplayTypeName). + private String blockName; + + // Free-form label the user can edit after placement, independent of blockName. Shown in the BOM. + private String value; + + // Flat index mapping a composite control point index to (child ordinal, child point index). + // Rebuilt lazily since children never change after construction and the arrays are not + // serialized (transient). + private transient int[] childOf; + private transient int[] pointOf; + + /** + * Required no-arg constructor, used reflectively by {@code ComponentProcessor} and XStream. + */ + public CompositeComponent() { + super(); + } + + private void buildIndexIfNeeded() { + if (childOf != null) { + return; + } + int total = 0; + for (IDIYComponent c : childComponents) { + total += c.getControlPointCount(); + } + int[] newChildOf = new int[total]; + int[] newPointOf = new int[total]; + int idx = 0; + for (int ci = 0; ci < childComponents.size(); ci++) { + IDIYComponent c = childComponents.get(ci); + int count = c.getControlPointCount(); + for (int pi = 0; pi < count; pi++) { + newChildOf[idx] = ci; + newPointOf[idx] = pi; + idx++; + } + } + childOf = newChildOf; + pointOf = newPointOf; + } + + /** + * @return the embedded child components, in save order. Never {@code null}. + */ + public List> getChildComponents() { + if (childComponents == null) { + childComponents = new ArrayList>(); + } + return childComponents; + } + + @EditableProperty(name = "Block", defaultable = false, sortOrder = 1) + public String getBlockName() { + return blockName; + } + + /** + * Sets the source block name. Deliberately not named {@code setBlockName} so that + * {@code ComponentProcessor}'s getter/setter matching does not turn the "Block" property + * editable in the property editor - it is metadata about the instance, not a value the user + * should be able to change after placement. + */ + public void initBlockName(String blockName) { + this.blockName = blockName; + } + + @Override + public int getControlPointCount() { + buildIndexIfNeeded(); + return childOf.length; + } + + @Override + public Point2D getControlPoint(int index) { + buildIndexIfNeeded(); + return childComponents.get(childOf[index]).getControlPoint(pointOf[index]); + } + + @Override + public void setControlPoint(Point2D point, int index) { + buildIndexIfNeeded(); + childComponents.get(childOf[index]).setControlPoint(point, pointOf[index]); + } + + @Override + public boolean isControlPointSticky(int index) { + buildIndexIfNeeded(); + return childComponents.get(childOf[index]).isControlPointSticky(pointOf[index]); + } + + @Override + public boolean canControlPointOverlap(int index) { + buildIndexIfNeeded(); + return childComponents.get(childOf[index]).canControlPointOverlap(pointOf[index]); + } + + @Override + public VisibilityPolicy getControlPointVisibilityPolicy(int index) { + buildIndexIfNeeded(); + return childComponents.get(childOf[index]).getControlPointVisibilityPolicy(pointOf[index]); + } + + @Override + public String getControlPointNodeName(int index) { + buildIndexIfNeeded(); + return childComponents.get(childOf[index]).getControlPointNodeName(pointOf[index]); + } + + /** + * The composite is always rigid: it moves and rotates as a unit and never stretches. See design + * decision D4 in {@code docs/plans/composite-building-blocks.md}. + */ + @Override + public boolean canPointMoveFreely(int pointIndex) { + return false; + } + + @Override + public void createdIn(Project project) { + for (IDIYComponent c : getChildComponents()) { + c.createdIn(project); + } + } + + @Override + @EditableProperty + public String getValue() { + return value; + } + + @Override + public void setValue(String value) { + this.value = value; + } + + @Override + public void draw(Graphics2D g2d, ComponentState componentState, boolean outlineMode, Project project, + IDrawingObserver drawingObserver) { + Composite oldComposite = applyAlpha(g2d, componentState); + for (IDIYComponent c : getChildComponents()) { + // Children always draw in their normal state - the composite draws its own selection + // outline below instead of letting every child paint its own highlight. + c.draw(g2d, componentState, outlineMode, project, drawingObserver); + } + g2d.setComposite(oldComposite); + } + + @Override + public void drawIcon(Graphics2D g2d, int width, int height) { + // Must not touch `components` - ComponentProcessor draws this on a default-constructed, + // childless instance to build the palette icon. + g2d.setColor(LIGHT_METAL_COLOR); + g2d.fillRect(2, 2, width - 4, height - 4); + g2d.setColor(METAL_COLOR); + g2d.drawRect(2, 2, width - 4, height - 4); + int halfW = width / 2; + int halfH = height / 2; + g2d.drawLine(halfW, 2, halfW, height - 2); + g2d.drawLine(2, halfH, width - 2, halfH); + } + + /** + * Deep-clones the composite together with all its children, so that two placed instances never + * share child objects (that would make moving one instance move the other - see the "clone() + * is the sharp edge" risk in {@code docs/plans/composite-building-blocks.md}). Preserves this + * component's own id and every child's id, matching {@link AbstractComponent#clone()}'s + * contract of copying identity by default - callers that need a genuinely new component + * reassign ids themselves, exactly as {@code BuildingBlockManager.loadBlock} does when it + * clones each source component individually to place a brand new instance of a block. This also + * keeps id-sensitive reflective comparisons (like the default {@code equalsTo} most child + * component classes inherit) working correctly between a composite and its own clone, which the + * undo dirty-check in {@code Presenter.notifyProjectModifiedIfNeeded} relies on. + */ + @Override + public IDIYComponent clone() throws CloneNotSupportedException { + CompositeComponent clone = new CompositeComponent(); + clone.setId(getId()); + clone.setName(getName()); + clone.blockName = this.blockName; + clone.value = this.value; + // matches AbstractComponent.clone(), which shallow-copies the Percentage reference + clone.setAlpha(getAlpha()); + List> clonedComponents = new ArrayList>(getChildComponents().size()); + for (IDIYComponent c : getChildComponents()) { + clonedComponents.add(c.clone()); + } + clone.childComponents = clonedComponents; + return clone; + } + + /** + * Overridden because {@link AbstractComponent#equalsTo(IDIYComponent)} compares the + * {@code components} field with {@code List.equals()}, which falls back to child object + * identity since no component class overrides {@code Object.equals()} - two structurally + * identical composites built from different object graphs (e.g. an original and its clone) + * would otherwise never compare equal, defeating the undo dirty-check in + * {@code Presenter.notifyProjectModifiedIfNeeded}. + */ + @Override + public boolean equalsTo(IDIYComponent other) { + if (!(other instanceof CompositeComponent)) { + return false; + } + CompositeComponent o = (CompositeComponent) other; + if (!Objects.equals(getName(), o.getName()) || !Objects.equals(blockName, o.blockName) + || !Objects.equals(value, o.value) || !Objects.equals(getAlpha(), o.getAlpha())) { + return false; + } + List> otherComponents = o.getChildComponents(); + if (getChildComponents().size() != otherComponents.size()) { + return false; + } + for (int i = 0; i < childComponents.size(); i++) { + if (!childComponents.get(i).equalsTo(otherComponents.get(i))) { + return false; + } + } + return true; + } +} diff --git a/diylc/diylc-core/src/main/java/org/diylc/components/composite/CompositeComponentTransformer.java b/diylc/diylc-core/src/main/java/org/diylc/components/composite/CompositeComponentTransformer.java new file mode 100644 index 00000000..1ed461c1 --- /dev/null +++ b/diylc/diylc-core/src/main/java/org/diylc/components/composite/CompositeComponentTransformer.java @@ -0,0 +1,97 @@ +/* + + 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.components.composite; + +import java.awt.geom.Point2D; + +import org.diylc.common.ComponentType; +import org.diylc.common.IComponentTransformer; +import org.diylc.core.IDIYComponent; +import org.diylc.presenter.ComponentProcessor; + +/** + * Rotates and mirrors a {@link CompositeComponent} by delegating to each child's own + * {@link IComponentTransformer}, around the same centre for every child so the assembly stays + * geometrically consistent. If any child's type cannot rotate (or mirror), the whole composite + * reports that it cannot either - silently leaving one child un-rotated would be worse (see + * design decision D9 in {@code docs/plans/composite-building-blocks.md}). + * + * @author Branislav Stojkovic + */ +public class CompositeComponentTransformer implements IComponentTransformer { + + @Override + public boolean canRotate(IDIYComponent component) { + CompositeComponent composite = (CompositeComponent) component; + if (composite.getChildComponents().isEmpty()) + return false; + for (IDIYComponent child : composite.getChildComponents()) { + if (!childTransformer(child).canRotate(child)) + return false; + } + return true; + } + + @Override + public boolean canMirror(IDIYComponent component) { + CompositeComponent composite = (CompositeComponent) component; + if (composite.getChildComponents().isEmpty()) + return false; + for (IDIYComponent child : composite.getChildComponents()) { + if (!childTransformer(child).canMirror(child)) + return false; + } + return true; + } + + @Override + public boolean mirroringChangesCircuit() { + // This transformer instance is shared by every CompositeComponent (ComponentProcessor caches + // one instance per class), so it has no access to a specific composite's children here. + // Conservatively assume mirroring can change the circuit, so the user is always warned - a + // spurious confirmation is far cheaper than silently flipping a board's pinout. + return true; + } + + @Override + public void rotate(IDIYComponent component, Point2D center, int direction) { + CompositeComponent composite = (CompositeComponent) component; + for (IDIYComponent child : composite.getChildComponents()) { + childTransformer(child).rotate(child, center, direction); + } + } + + @Override + public void mirror(IDIYComponent component, Point2D center, int direction) { + CompositeComponent composite = (CompositeComponent) component; + for (IDIYComponent child : composite.getChildComponents()) { + childTransformer(child).mirror(child, center, direction); + } + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private IComponentTransformer childTransformer(IDIYComponent child) { + ComponentType type = + ComponentProcessor.getInstance().extractComponentTypeFrom((Class) child.getClass()); + return type.getTransformer(); + } +} diff --git a/diylc/diylc-core/src/main/java/org/diylc/core/IDIYComponent.java b/diylc/diylc-core/src/main/java/org/diylc/core/IDIYComponent.java index c71970ca..8be3884c 100644 --- a/diylc/diylc-core/src/main/java/org/diylc/core/IDIYComponent.java +++ b/diylc/diylc-core/src/main/java/org/diylc/core/IDIYComponent.java @@ -137,13 +137,6 @@ public interface IDIYComponent extends Serializable, Cloneable { */ String getInternalLinkName(int index1, int index2); - /** - * @param pointIndex - * @return name of the internal section containing the specified point, or null if the component does not have internal sections. - * One point can be shared between multiple sections, in which case, the result may contain more than one element. - */ - String[] getSectionNames(int pointIndex); - /** * @param pointIndex * @return true if the control point can move without affecting other points' position, false otherwise diff --git a/diylc/diylc-core/src/main/java/org/diylc/core/annotations/ComponentDescriptor.java b/diylc/diylc-core/src/main/java/org/diylc/core/annotations/ComponentDescriptor.java index f305f7dc..998ff45e 100644 --- a/diylc/diylc-core/src/main/java/org/diylc/core/annotations/ComponentDescriptor.java +++ b/diylc/diylc-core/src/main/java/org/diylc/core/annotations/ComponentDescriptor.java @@ -123,9 +123,18 @@ public @interface ComponentDescriptor { boolean enableDatasheet() default false; /** - * If datasheet is enabled, this property determines how many columns define a component, e.g. in how many steps we can instantiate a component - * + * If datasheet is enabled, this property determines how many columns define a component, e.g. in how many steps we can instantiate a component + * * @return */ int datasheetCreationStepCount() default 0; + + /** + * @return true if the component type should not be listed in the palette (toolbox and + * component tree), while still being resolvable via + * {@code ComponentProcessor.extractComponentTypeFrom} for netlist, BOM and name + * generation purposes. Used by component types that are only ever created + * programmatically, e.g. {@code CompositeComponent}. + */ + boolean hiddenInPalette() default false; } diff --git a/diylc/diylc-core/src/main/java/org/diylc/netlist/Node.java b/diylc/diylc-core/src/main/java/org/diylc/netlist/Node.java index 2d62f1f6..2335cc55 100644 --- a/diylc/diylc-core/src/main/java/org/diylc/netlist/Node.java +++ b/diylc/diylc-core/src/main/java/org/diylc/netlist/Node.java @@ -52,8 +52,7 @@ public class Node implements Comparable { } public String getDisplayName() { - String[] section = component.getSectionNames(pointIndex); - return (section == null || section.length > 1 ? "" : section[0] + ".") + component.getControlPointNodeName(pointIndex); + return component.getControlPointNodeName(pointIndex); } public Point2D getPoint2D() { diff --git a/diylc/diylc-core/src/main/java/org/diylc/plugins/chatbot/service/AiProjectBuilder.java b/diylc/diylc-core/src/main/java/org/diylc/plugins/chatbot/service/AiProjectBuilder.java index 7deb66f0..8878a4ca 100644 --- a/diylc/diylc-core/src/main/java/org/diylc/plugins/chatbot/service/AiProjectBuilder.java +++ b/diylc/diylc-core/src/main/java/org/diylc/plugins/chatbot/service/AiProjectBuilder.java @@ -175,7 +175,8 @@ public class AiProjectBuilder { } } - return new AiComponent(component.getName(), componentType.getName(), + return new AiComponent(component.getName(), + ComponentProcessor.getDisplayTypeName(component, componentType), component.getValue() == null ? null : component.getValue().toString(), fromPos, toPos, pos, terminals.isEmpty() ? null : terminals); } diff --git a/diylc/diylc-core/src/main/java/org/diylc/presenter/BuildingBlockManager.java b/diylc/diylc-core/src/main/java/org/diylc/presenter/BuildingBlockManager.java index 89aeb026..2d3a97e7 100644 --- a/diylc/diylc-core/src/main/java/org/diylc/presenter/BuildingBlockManager.java +++ b/diylc/diylc-core/src/main/java/org/diylc/presenter/BuildingBlockManager.java @@ -27,11 +27,15 @@ import com.thoughtworks.xstream.XStream; import com.thoughtworks.xstream.io.xml.DomDriver; import com.thoughtworks.xstream.security.AnyTypePermission; +import org.diylc.common.BlockInstantiationMode; import org.diylc.common.BuildingBlockPackage; +import org.diylc.common.ComponentType; import org.diylc.common.IBlockProcessor; import org.diylc.common.IPlugInPort; import org.diylc.common.IBlockProcessor.InvalidBlockException; +import org.diylc.components.composite.CompositeComponent; import org.diylc.core.IDIYComponent; +import org.diylc.core.ISwitch; import org.diylc.serialization.ProjectFileManager; public class BuildingBlockManager { @@ -119,19 +123,30 @@ public class BuildingBlockManager { return pkg.getBlocks().size(); } + /** + * @param mode {@link BlockInstantiationMode#GROUP} reproduces the original behavior: every + * component is cloned individually, with a fresh project-unique name, ready to be + * pasted as an auto-grouped set of independent components. + * {@link BlockInstantiationMode#COMPOSITE} clones the components and wraps them in a + * single {@link CompositeComponent}, returned as the only element of the result list. + */ @SuppressWarnings("unchecked") public List> loadBlock(String blockName, - List> existingComponents) throws InvalidBlockException { - LOG.debug(String.format("loadBlock(%s)", blockName)); + List> existingComponents, BlockInstantiationMode mode) + throws InvalidBlockException { + LOG.debug(String.format("loadBlock(%s, %s)", blockName, mode)); Map>> blocks = (Map>>) configManager .readObject(IPlugInPort.BLOCKS_KEY, null); - if (blocks != null) { - Collection> components = blocks.get(blockName); - if (components == null) - throw new InvalidBlockException(); - // clear potential control point every time! - instantiationManager.setPotentialControlPoint(null); - // clone components + if (blocks == null) + throw new InvalidBlockException(); + Collection> components = blocks.get(blockName); + if (components == null) + throw new InvalidBlockException(); + // clear potential control point every time! + instantiationManager.setPotentialControlPoint(null); + + if (mode == BlockInstantiationMode.GROUP) { + // clone components, each getting its own fresh, project-unique name and id List> clones = new ArrayList>(); List> testComponents = new ArrayList>(existingComponents); for (IDIYComponent c : components) @@ -147,8 +162,42 @@ public class BuildingBlockManager { LOG.error("Could not clone component: " + c); } return clones; - } else - throw new InvalidBlockException(); + } + + // COMPOSITE mode: clone components keeping their saved names. Those names are namespaced + // under the composite's own name in the netlist (e.g. "ARD1.PH1.3"), so running them through + // createUniqueName against the whole project would produce meaningless drift and break + // netlist labels between two instances of the same block. + List> childClones = new ArrayList>(); + for (IDIYComponent c : components) { + try { + IDIYComponent clone = c.clone(); + clone.setId(UUID.randomUUID()); + childClones.add(clone); + } catch (CloneNotSupportedException e) { + LOG.error("Could not clone component: " + c); + } + // CompositeComponent does not implement ISwitch (see design decision D6): a switch placed + // inside a composite still draws and exposes its terminals, but contributes no switching + // behavior to the netlist. Flag it so this silent difference from group mode isn't a + // surprise. + if (c instanceof ISwitch) { + LOG.warn(String.format( + "Building block \"%s\" contains a switch (%s). Its switching behavior will not be " + + "reflected in the netlist when instantiated as a single component.", + blockName, c.getName())); + } + } + + CompositeComponent composite = new CompositeComponent(); + composite.getChildComponents().addAll(childClones); + composite.initBlockName(blockName); + composite.setId(UUID.randomUUID()); + ComponentType compositeType = + ComponentProcessor.getInstance().extractComponentTypeFrom(CompositeComponent.class); + composite.setName(instantiationManager.createUniqueName(compositeType, existingComponents)); + + return Collections.singletonList((IDIYComponent) composite); } @SuppressWarnings("unchecked") @@ -197,5 +246,11 @@ public class BuildingBlockManager { blocks.remove(blockName); configManager.writeValue(IBlockProcessor.BLOCKS_KEY, blocks); } + + // drop the block from the recently used list too, so it doesn't linger as a dead entry + List recent = + (List) configManager.readObject(IPlugInPort.RECENT_COMPONENTS_KEY, null); + if (recent != null && recent.remove(IBlockProcessor.BLOCK_PREFIX + blockName)) + configManager.writeValue(IPlugInPort.RECENT_COMPONENTS_KEY, recent); } } diff --git a/diylc/diylc-core/src/main/java/org/diylc/presenter/ComponentProcessor.java b/diylc/diylc-core/src/main/java/org/diylc/presenter/ComponentProcessor.java index e967ed40..663d16bb 100644 --- a/diylc/diylc-core/src/main/java/org/diylc/presenter/ComponentProcessor.java +++ b/diylc/diylc-core/src/main/java/org/diylc/presenter/ComponentProcessor.java @@ -39,6 +39,7 @@ import org.apache.log4j.Logger; import org.diylc.common.ComponentType; import org.diylc.common.IComponentTransformer; import org.diylc.common.PropertyWrapper; +import org.diylc.components.composite.CompositeComponent; import org.diylc.core.CreationMethod; import org.diylc.core.IDIYComponent; @@ -113,7 +114,7 @@ public class ComponentProcessor { String keywordTag; List datasheet = null; int datasheetCreationStepCount = 0; - + if (clazz.isAnnotationPresent(ComponentDescriptor.class)) { ComponentDescriptor annotation = clazz.getAnnotation(ComponentDescriptor.class); name = annotation.name(); @@ -159,7 +160,7 @@ public class ComponentProcessor { } ComponentType componentType = new ComponentType(name, description, creationMethod, category, namePrefix, author, icon, clazz, zOrder, - flexibleZOrder, bomPolicy, autoEdit, transformer, keywordPolicy, keywordTag, cacheDrawing, datasheet, + flexibleZOrder, bomPolicy, autoEdit, transformer, keywordPolicy, keywordTag, cacheDrawing, datasheet, datasheetCreationStepCount); componentTypeMap.put(clazz.getName(), componentType); return componentType; @@ -273,6 +274,11 @@ public class ComponentProcessor { // just to store in the cache ComponentProcessor.getInstance().extractProperties(clazz); + // types created only programmatically (e.g. CompositeComponent) don't belong in the + // palette, but still need to go through extractProperties() above so the cache is warm + if (clazz.getAnnotation(ComponentDescriptor.class).hiddenInPalette()) + continue; + List nestedList; if (componentTypes.containsKey(componentType.getCategory())) { nestedList = componentTypes.get(componentType.getCategory()); @@ -422,6 +428,23 @@ public class ComponentProcessor { return transformer; } + /** + * @return the name that should represent the component's type to a human or to the AI, e.g. in + * the BOM, the netlist report or the AI project description. For a + * {@link CompositeComponent} this is the source building block's name (e.g. + * "Arduino Uno") rather than the generic "Building Block" type name, since every + * composite shares one {@link ComponentType} (see design decision D5 in + * {@code docs/plans/composite-building-blocks.md}). + */ + public static String getDisplayTypeName(IDIYComponent component, ComponentType type) { + if (component instanceof CompositeComponent) { + String blockName = ((CompositeComponent) component).getBlockName(); + if (blockName != null && !blockName.isEmpty()) + return blockName; + } + return type.getName(); + } + public static boolean hasStickyPoint(IDIYComponent c) { for (int i = 0; i < c.getControlPointCount(); i++) if (c.isControlPointSticky(0)) diff --git a/diylc/diylc-core/src/main/java/org/diylc/presenter/InstantiationManager.java b/diylc/diylc-core/src/main/java/org/diylc/presenter/InstantiationManager.java index f7135e8d..b0b0decb 100644 --- a/diylc/diylc-core/src/main/java/org/diylc/presenter/InstantiationManager.java +++ b/diylc/diylc-core/src/main/java/org/diylc/presenter/InstantiationManager.java @@ -272,6 +272,31 @@ public class InstantiationManager { return true; } + /** + * Pushes an identifier to the front of the recently used list, removing any earlier occurrence + * and trimming the list to {@link #MAX_RECENT_COMPONENTS}. If the identifier is already at the + * front nothing is written. The identifier is either a fully qualified component class name or a + * building block reference of the form {@code "block:"} (see + * {@link IBlockProcessor#BLOCK_PREFIX}). + * + * @param identifier component class name or {@code "block:"} + */ + @SuppressWarnings("unchecked") + public static void addToRecentComponents(String identifier) { + List recent = (List) ConfigurationManager.getInstance() + .readObject(IPlugInPort.RECENT_COMPONENTS_KEY, new ArrayList()); + if (!recent.isEmpty() && recent.get(0).equals(identifier)) + return; + // Remove if it's already somewhere in the list. + recent.remove(identifier); + // Add to the front of the list. + recent.add(0, identifier); + // Trim the list if necessary. + while (recent.size() > MAX_RECENT_COMPONENTS) + recent.remove(recent.size() - 1); + ConfigurationManager.getInstance().writeValue(IPlugInPort.RECENT_COMPONENTS_KEY, recent); + } + @SuppressWarnings("unchecked") private List> instantiateComponent(ComponentType componentType, Point2D point, Project currentProject) throws InstantiationException, IllegalAccessException, NoSuchMethodException, @@ -311,21 +336,7 @@ public class InstantiationManager { } // Write to recent components - List recentComponentTypes = - (List) ConfigurationManager.getInstance().readObject(IPlugInPort.RECENT_COMPONENTS_KEY, - new ArrayList()); - String className = componentType.getInstanceClass().getName(); - if (recentComponentTypes.size() == 0 || !recentComponentTypes.get(0).equals(className)) { - // Remove if it's already somewhere in the list. - recentComponentTypes.remove(className); - // Add to the end of the list. - recentComponentTypes.add(0, className); - // Trim the list if necessary. - if (recentComponentTypes.size() > MAX_RECENT_COMPONENTS) { - recentComponentTypes.remove(recentComponentTypes.size() - 1); - } - ConfigurationManager.getInstance().writeValue(IPlugInPort.RECENT_COMPONENTS_KEY, recentComponentTypes); - } + addToRecentComponents(componentType.getInstanceClass().getName()); List> list = new ArrayList>(); list.add(component); diff --git a/diylc/diylc-core/src/main/java/org/diylc/presenter/Presenter.java b/diylc/diylc-core/src/main/java/org/diylc/presenter/Presenter.java index f24adfe4..4edaf0c3 100644 --- a/diylc/diylc-core/src/main/java/org/diylc/presenter/Presenter.java +++ b/diylc/diylc-core/src/main/java/org/diylc/presenter/Presenter.java @@ -1060,9 +1060,9 @@ public class Presenter implements IPlugInPort { String regex = ".*" + criteria.toLowerCase() + ".*"; for (IDIYComponent c : currentProject.getComponents()) { ComponentType type = ComponentProcessor.getInstance().extractComponentTypeFrom((Class>) c.getClass()); - if ((c.getName() != null && c.getName().toLowerCase().matches(regex)) || + if ((c.getName() != null && c.getName().toLowerCase().matches(regex)) || (c.getValueForDisplay() != null && c.getValueForDisplay().toLowerCase().matches(regex)) || - (type != null && type.getName().toLowerCase().matches(regex))) + (type != null && ComponentProcessor.getDisplayTypeName(c, type).toLowerCase().matches(regex))) matching.add(c); } updateSelection(matching); @@ -2844,9 +2844,14 @@ public class Presenter implements IPlugInPort { } @Override - public void loadBlock(String blockName) throws InvalidBlockException { - List> components = buildingBlockManager.loadBlock(blockName, currentProject.getComponents()); - pasteComponents(new ComponentTransferable(components, blockName), true, true); + public void loadBlock(String blockName, BlockInstantiationMode mode) throws InvalidBlockException { + List> components = + buildingBlockManager.loadBlock(blockName, currentProject.getComponents(), mode); + // a composite is a single, already-assembled component - nothing to auto-group + boolean autoGroup = mode == BlockInstantiationMode.GROUP; + pasteComponents(new ComponentTransferable(components, blockName), autoGroup, true); + // record the block in the recently used list, alongside component classes + InstantiationManager.addToRecentComponents(IBlockProcessor.BLOCK_PREFIX + blockName); } @Override diff --git a/diylc/diylc-core/src/main/java/org/diylc/utils/BomMaker.java b/diylc/diylc-core/src/main/java/org/diylc/utils/BomMaker.java index b4e4fb02..c8d9c09a 100644 --- a/diylc/diylc-core/src/main/java/org/diylc/utils/BomMaker.java +++ b/diylc/diylc-core/src/main/java/org/diylc/utils/BomMaker.java @@ -81,6 +81,7 @@ public class BomMaker { (Class>) component.getClass()); if (type.getBomPolicy() == BomPolicy.NEVER_SHOW) continue; + String typeName = ComponentProcessor.getDisplayTypeName(component, type); String name = component.getName(); String value; try { @@ -89,7 +90,7 @@ public class BomMaker { value = ""; } if ((name != null) && (value != null)) { - String key = type.getName() + "|" + value; + String key = typeName + "|" + value; if (entryMap.containsKey(key)) { BomEntry entry = entryMap.get(key); entry.setQuantity(entry.getQuantity() + 1); @@ -98,7 +99,7 @@ public class BomMaker { } } else { entryMap.put(key, - new BomEntry(type.getName(), type.getBomPolicy() == BomPolicy.SHOW_ALL_NAMES ? name : type.getName(), + new BomEntry(typeName, type.getBomPolicy() == BomPolicy.SHOW_ALL_NAMES ? name : typeName, value, 1)); } } diff --git a/diylc/diylc-core/src/main/resources/update.xml b/diylc/diylc-core/src/main/resources/update.xml index 868c2331..53543e93 100644 --- a/diylc/diylc-core/src/main/resources/update.xml +++ b/diylc/diylc-core/src/main/resources/update.xml @@ -369,6 +369,38 @@ 2026-08-22 00:00:00.000 CET + + NEW_FEATURE + Building blocks instantiate by default as a single component, inheriting control points and terminal nodes from all individual components but acting as a single component; still possible to instantiate individual components using 'Insert as Grouped Components' action from the context menu over a building block + + + NEW_FEATURE + Added ability to set node name (or names) for some connectivity components (e.g. Solder Pad, Eyelet, PCB Terminal Block, etc); when combined into building blocks, these names will become node names of the composite component stamped from the building block + + + IMPROVEMENT + Include building blocks in the Recently Used category + + + IMPROVEMENT + When saving building blocks, present a drop down list with most recently used building blocks for quick selection + + + IMPROVEMENT + Prompt to confirm the overwrite when saving over the existing building blocks + + + IMPROVEMENT + When transistor pinout is selected for TO-1, TO-92, TO-126, TO-220 transistor, the corresponding electrode name will be used as a node name in programmatical and AI analysis + + + IMPROVEMENT + Diode components now label their terminal nodes as 'A' (anode) and 'K' (cathode) instead of '1' and '2' + + + IMPROVEMENT + Capacitor components now label their terminal nodes as '+' and '-' instead of '1' and '2' when 'Polarized' property is ticked + IMPROVEMENT Percentage values such as transparency Alpha, image Scale, etc, can have decimal values and not just integers diff --git a/diylc/diylc-core/src/test/java/org/diylc/components/composite/CompositeComponentTest.java b/diylc/diylc-core/src/test/java/org/diylc/components/composite/CompositeComponentTest.java new file mode 100644 index 00000000..9096ddb3 --- /dev/null +++ b/diylc/diylc-core/src/test/java/org/diylc/components/composite/CompositeComponentTest.java @@ -0,0 +1,224 @@ +/* + + 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.components.composite; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import java.awt.Graphics2D; +import java.awt.geom.Point2D; +import java.awt.image.BufferedImage; +import java.util.UUID; + +import org.diylc.common.Percentage; +import org.diylc.core.IDIYComponent; +import org.diylc.netlist.Node; +import org.diylc.serialization.ProjectFileManager; +import org.diylc.testcomponents.TwoPointTestComponent; +import org.junit.Test; + +/** + * Exercises the delegation, rigidity, cloning and equality behavior described in + * {@code docs/plans/composite-building-blocks.md}. + */ +public class CompositeComponentTest { + + private CompositeComponent twoChildComposite() { + CompositeComponent composite = new CompositeComponent(); + composite.setName("BLK1"); + composite.initBlockName("Test Block"); + composite.getChildComponents().add(new TwoPointTestComponent("R1", + new Point2D.Double(0, 0), new Point2D.Double(10, 0))); + composite.getChildComponents().add(new TwoPointTestComponent("R2", + new Point2D.Double(20, 0), new Point2D.Double(30, 0))); + return composite; + } + + @Test + public void controlPointCountIsSumOverChildren() { + CompositeComponent composite = twoChildComposite(); + assertEquals(4, composite.getControlPointCount()); + } + + @Test + public void controlPointsMapToTheRightChildAndLocalIndex() { + CompositeComponent composite = twoChildComposite(); + + assertEquals(new Point2D.Double(0, 0), composite.getControlPoint(0)); + assertEquals(new Point2D.Double(10, 0), composite.getControlPoint(1)); + assertEquals(new Point2D.Double(20, 0), composite.getControlPoint(2)); + assertEquals(new Point2D.Double(30, 0), composite.getControlPoint(3)); + } + + @Test + public void setControlPointDelegatesToTheOwningChild() { + CompositeComponent composite = twoChildComposite(); + + composite.setControlPoint(new Point2D.Double(99, 99), 2); + + assertEquals(new Point2D.Double(99, 99), composite.getControlPoint(2)); + // the sibling child's points must be untouched + assertEquals(new Point2D.Double(30, 0), composite.getControlPoint(3)); + TwoPointTestComponent r2 = (TwoPointTestComponent) composite.getChildComponents().get(1); + assertEquals(new Point2D.Double(99, 99), r2.getControlPoint(0)); + } + + @Test + public void stickinessIsDelegatedPerChildPoint() { + CompositeComponent composite = twoChildComposite(); + ((TwoPointTestComponent) composite.getChildComponents().get(0)).setSticky(1, false); + + assertTrue(composite.isControlPointSticky(0)); + assertFalse(composite.isControlPointSticky(1)); + assertTrue(composite.isControlPointSticky(2)); + assertTrue(composite.isControlPointSticky(3)); + } + + @Test + public void isAlwaysRigid() { + CompositeComponent composite = twoChildComposite(); + for (int i = 0; i < composite.getControlPointCount(); i++) + assertFalse("point " + i + " should not move freely", composite.canPointMoveFreely(i)); + } + + @Test + public void nodeNamingProducesHierarchicalNetlistLabels() { + // Design decision D3: Node.toString() should read ".." + CompositeComponent composite = twoChildComposite(); + + Node r1FirstPoint = new Node(composite, 0); + Node r2FirstPoint = new Node(composite, 2); + Node r2SecondPoint = new Node(composite, 3); + + assertEquals("BLK1.1", r1FirstPoint.toString()); + assertEquals("BLK1.1", r2FirstPoint.toString()); + // local point index must reset per child, not keep growing with the flat composite index + assertEquals("BLK1.2", r2SecondPoint.toString()); + } + + @Test + public void cloneDeepCopiesChildrenSoInstancesAreIndependent() throws Exception { + CompositeComponent original = twoChildComposite(); + + IDIYComponent clone = original.clone(); + + ((CompositeComponent) clone).setControlPoint(new Point2D.Double(-1, -1), 0); + + assertEquals(new Point2D.Double(0, 0), original.getControlPoint(0)); + assertEquals(new Point2D.Double(-1, -1), clone.getControlPoint(0)); + } + + @Test + public void cloneKeepsBothItsOwnAndChildrenIds() throws Exception { + // Matches AbstractComponent.clone()'s contract of preserving identity by default - a caller + // that needs a genuinely new component (e.g. BuildingBlockManager placing a fresh instance) + // reassigns ids itself, exactly as it does for any other component's clone(). + CompositeComponent original = twoChildComposite(); + UUID originalChildId = original.getChildComponents().get(0).getId(); + + CompositeComponent clone = (CompositeComponent) original.clone(); + + assertEquals(original.getId(), clone.getId()); + assertEquals(originalChildId, clone.getChildComponents().get(0).getId()); + } + + @Test + public void equalsToComparesStructureNotObjectIdentity() throws Exception { + CompositeComponent original = twoChildComposite(); + CompositeComponent clone = (CompositeComponent) original.clone(); + + // structurally identical, distinct child objects - must still compare equal + assertTrue(original.equalsTo(clone)); + + clone.setControlPoint(new Point2D.Double(-1, -1), 0); + assertFalse(original.equalsTo(clone)); + } + + @Test + public void equalsToRejectsDifferentBlockName() { + CompositeComponent a = twoChildComposite(); + CompositeComponent b = twoChildComposite(); + b.initBlockName("Different Block"); + + assertFalse(a.equalsTo(b)); + } + + @Test + public void defaultsToFullyOpaque() { + assertEquals(new Percentage(100), twoChildComposite().getAlpha()); + } + + @Test + public void cloneCopiesAlpha() throws Exception { + CompositeComponent original = twoChildComposite(); + original.setAlpha(new Percentage(40)); + + CompositeComponent clone = (CompositeComponent) original.clone(); + + assertEquals(new Percentage(40), clone.getAlpha()); + } + + @Test + public void equalsToRejectsDifferentAlpha() throws Exception { + CompositeComponent original = twoChildComposite(); + CompositeComponent clone = (CompositeComponent) original.clone(); + assertTrue(original.equalsTo(clone)); + + clone.setAlpha(new Percentage(50)); + assertFalse(original.equalsTo(clone)); + } + + @Test + public void survivesAnXStreamRoundTripAndRebuildsItsIndexLazily() { + CompositeComponent original = twoChildComposite(); + + String xml = ProjectFileManager.xStreamSerializer.toXML(original); + CompositeComponent reloaded = (CompositeComponent) ProjectFileManager.xStreamSerializer.fromXML(xml); + + // the transient child-index arrays are never serialized - this call exercises the lazy + // rebuild in buildIndexIfNeeded() on an instance XStream constructed without going through + // the no-arg constructor + assertEquals(original.getControlPointCount(), reloaded.getControlPointCount()); + assertEquals(original.getName(), reloaded.getName()); + assertEquals(original.getBlockName(), reloaded.getBlockName()); + for (int i = 0; i < original.getControlPointCount(); i++) + assertEquals(original.getControlPoint(i), reloaded.getControlPoint(i)); + assertEquals("BLK1.2", new Node(reloaded, 3).toString()); + } + + @Test + public void drawIconDoesNotTouchChildren() { + // ComponentProcessor draws the palette icon on a default-constructed, childless instance + CompositeComponent blank = new CompositeComponent(); + BufferedImage image = new BufferedImage(32, 32, BufferedImage.TYPE_INT_ARGB); + Graphics2D g2d = image.createGraphics(); + + blank.drawIcon(g2d, 32, 32); + + g2d.dispose(); + // no exception means components list (empty) was never dereferenced as if populated + assertNotNull(blank.getChildComponents()); + assertTrue(blank.getChildComponents().isEmpty()); + } +} diff --git a/diylc/diylc-core/src/test/java/org/diylc/presenter/BuildingBlockManagerTest.java b/diylc/diylc-core/src/test/java/org/diylc/presenter/BuildingBlockManagerTest.java new file mode 100644 index 00000000..68cd9f20 --- /dev/null +++ b/diylc/diylc-core/src/test/java/org/diylc/presenter/BuildingBlockManagerTest.java @@ -0,0 +1,177 @@ +/* + + DIY Layout Creator (DIYLC). + Copyright (c) 2009-2025 held jointly by the individual authors. + + This file is part of DIYLC. + + DIYLC is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + DIYLC is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with DIYLC. If not, see . + +*/ +package org.diylc.presenter; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.awt.geom.Point2D; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.diylc.appframework.miscutils.IConfigurationManager; +import org.diylc.common.BlockInstantiationMode; +import org.diylc.common.IBlockProcessor; +import org.diylc.common.IBlockProcessor.InvalidBlockException; +import org.diylc.common.IPlugInPort; +import org.diylc.components.composite.CompositeComponent; +import org.diylc.core.ISwitch; +import org.diylc.core.IDIYComponent; +import org.diylc.testcomponents.SwitchTestComponent; +import org.diylc.testcomponents.TwoPointTestComponent; +import org.junit.Before; +import org.junit.Test; + +/** + * Exercises {@link BuildingBlockManager#loadBlock} in both {@link BlockInstantiationMode}s (see + * design decision D10 and section 4.4 of {@code docs/plans/composite-building-blocks.md}). + */ +@SuppressWarnings("unchecked") +public class BuildingBlockManagerTest { + + private static final String BLOCK_NAME = "TestBlock"; + + private IConfigurationManager configManager; + private BuildingBlockManager manager; + + @Before + public void setUp() { + List> storedComponents = Arrays.asList( + new TwoPointTestComponent("R1", new Point2D.Double(0, 0), new Point2D.Double(10, 0)), + new TwoPointTestComponent("R2", new Point2D.Double(20, 0), new Point2D.Double(30, 0))); + Map>> blocksMap = new HashMap>>(); + blocksMap.put(BLOCK_NAME, storedComponents); + + configManager = mock(IConfigurationManager.class); + when(configManager.readObject(eq(IPlugInPort.BLOCKS_KEY), org.mockito.ArgumentMatchers.any())) + .thenReturn(blocksMap); + + manager = new BuildingBlockManager(configManager, null, new InstantiationManager()); + } + + @Test + public void groupModeClonesEachComponentWithAFreshNameAndId() throws InvalidBlockException { + List> result = + manager.loadBlock(BLOCK_NAME, new ArrayList>(), BlockInstantiationMode.GROUP); + + assertEquals(2, result.size()); + // group mode always assigns fresh, project-unique names (pre-existing behavior, unchanged) + assertEquals("TP1", result.get(0).getName()); + assertEquals("TP2", result.get(1).getName()); + assertNotEquals(result.get(0).getId(), result.get(1).getId()); + } + + @Test + public void compositeModeWrapsChildrenInASingleComponent() throws InvalidBlockException { + List> result = + manager.loadBlock(BLOCK_NAME, new ArrayList>(), BlockInstantiationMode.COMPOSITE); + + assertEquals(1, result.size()); + assertTrue(result.get(0) instanceof CompositeComponent); + CompositeComponent composite = (CompositeComponent) result.get(0); + + assertEquals(BLOCK_NAME, composite.getBlockName()); + assertEquals("BLK1", composite.getName()); + assertEquals(4, composite.getControlPointCount()); + } + + @Test + public void compositeModePreservesChildNamesUnlikeGroupMode() throws InvalidBlockException { + List> result = + manager.loadBlock(BLOCK_NAME, new ArrayList>(), BlockInstantiationMode.COMPOSITE); + CompositeComponent composite = (CompositeComponent) result.get(0); + + // children are namespaced under the composite's own name in the netlist, so their saved + // names must survive untouched + assertEquals("R1", composite.getChildComponents().get(0).getName()); + assertEquals("R2", composite.getChildComponents().get(1).getName()); + } + + @Test + public void compositeModeGivesEachInstanceItsOwnNameButChildrenStayIdenticallyNamed() + throws InvalidBlockException { + List> existing = new ArrayList>(); + CompositeComponent first = (CompositeComponent) manager + .loadBlock(BLOCK_NAME, existing, BlockInstantiationMode.COMPOSITE).get(0); + existing.add(first); + + CompositeComponent second = (CompositeComponent) manager + .loadBlock(BLOCK_NAME, existing, BlockInstantiationMode.COMPOSITE).get(0); + + assertNotEquals(first.getName(), second.getName()); + assertEquals(first.getChildComponents().get(0).getName(), second.getChildComponents().get(0).getName()); + // but the underlying child components must be distinct objects with distinct ids + assertNotEquals(first.getChildComponents().get(0).getId(), second.getChildComponents().get(0).getId()); + } + + @Test(expected = InvalidBlockException.class) + public void groupModeThrowsOnUnknownBlock() throws InvalidBlockException { + manager.loadBlock("NoSuchBlock", new ArrayList>(), BlockInstantiationMode.GROUP); + } + + @Test(expected = InvalidBlockException.class) + public void compositeModeThrowsOnUnknownBlock() throws InvalidBlockException { + manager.loadBlock("NoSuchBlock", new ArrayList>(), BlockInstantiationMode.COMPOSITE); + } + + @Test + public void deleteBlockAlsoStripsItFromTheRecentlyUsedList() { + List recent = new ArrayList(Arrays.asList( + IBlockProcessor.BLOCK_PREFIX + BLOCK_NAME, "org.diylc.components.SomeComponent")); + when(configManager.readObject(eq(IPlugInPort.RECENT_COMPONENTS_KEY), + org.mockito.ArgumentMatchers.any())).thenReturn(recent); + + manager.deleteBlock(BLOCK_NAME); + + assertFalse(recent.contains(IBlockProcessor.BLOCK_PREFIX + BLOCK_NAME)); + assertTrue(recent.contains("org.diylc.components.SomeComponent")); + verify(configManager).writeValue(eq(IPlugInPort.RECENT_COMPONENTS_KEY), eq(recent)); + } + + @Test + public void compositeModeToleratesAnInternalSwitchWithoutFailing() throws InvalidBlockException { + // Design decision D6: the composite is not ISwitch, so a block containing a switch must + // still place successfully - it just won't switch (see BuildingBlockManager's log warning). + List> storedComponents = Arrays.>asList(new SwitchTestComponent("SW1")); + Map>> blocksMap = new HashMap>>(); + blocksMap.put("SwitchBlock", storedComponents); + when(configManager.readObject(eq(IPlugInPort.BLOCKS_KEY), org.mockito.ArgumentMatchers.any())) + .thenReturn(blocksMap); + + List> result = manager.loadBlock("SwitchBlock", new ArrayList>(), + 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>) 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>) 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.