Building blocks (#1072)

* composite block support

* naming

* Added ability to set node name (or names) for some connectivity components

* cleanup

* Include building blocks in the Recently Used category

* Prompt to confirm the overwrite when saving over the existing building blocks; When saving building blocks, present a drop down list with most recently used building blocks

* add alpha to composite

* Update CompositeComponentTest.java

* cleanup

* Update CompositeComponent.java

* cleanup

* Update CompositeComponent.java

* <org.diylc.appframework.update.Change> 					<changeType>IMPROVEMENT</changeType> 					<description>When saving building blocks, present a drop down list with most recently used building blocks for quick selection</description> 				</org.diylc.appframework.update.Change>

* Diode components now label their terminal nodes as 'A' (anode) and 'K' (cathode) instead of '1' and '2'

* Capacitor components now label their terminal nodes as '+' and '-' instead of '1' and '2' when 'Polarized' property is ticked
This commit is contained in:
Bane Stojkovic
2026-09-04 15:32:46 +02:00
committed by GitHub
parent 26d6ca48fd
commit 047196499a
40 changed files with 1939 additions and 95 deletions
@@ -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 <http://www.gnu.org/licenses/>.
*/
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
}
@@ -56,14 +56,14 @@ public class ComponentType implements Serializable {
private boolean enableCache;
private List<String[]> 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<? extends IDIYComponent<?>> instanceClass, double zOrder,
boolean flexibleZOrder, BomPolicy bomPolicy, boolean autoEdit, IComponentTransformer transformer,
KeywordPolicy keywordPolicy, String keywordTag, boolean enableCache, List<String[]> datasheet,
boolean flexibleZOrder, BomPolicy bomPolicy, boolean autoEdit, IComponentTransformer transformer,
KeywordPolicy keywordPolicy, String keywordTag, boolean enableCache, List<String[]> datasheet,
int datasheetCreationStepCount) {
super();
this.name = name;
@@ -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;
@@ -294,11 +294,6 @@ public abstract class AbstractComponent<T> implements IDIYComponent<T> {
return null;
}
@Override
public String[] getSectionNames(int pointIndex) {
return null;
}
@Override
public boolean canPointMoveFreely(int pointIndex) {
return true;
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<String> {
private static final long serialVersionUID = 1L;
private List<IDIYComponent<?>> childComponents = new ArrayList<IDIYComponent<?>>();
// 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<IDIYComponent<?>> getChildComponents() {
if (childComponents == null) {
childComponents = new ArrayList<IDIYComponent<?>>();
}
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<String> 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<IDIYComponent<?>> clonedComponents = new ArrayList<IDIYComponent<?>>(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<IDIYComponent<?>> 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;
}
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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();
}
}
@@ -137,13 +137,6 @@ public interface IDIYComponent<T> 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
@@ -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;
}
@@ -52,8 +52,7 @@ public class Node implements Comparable<Node> {
}
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() {
@@ -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);
}
@@ -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<IDIYComponent<?>> loadBlock(String blockName,
List<IDIYComponent<?>> existingComponents) throws InvalidBlockException {
LOG.debug(String.format("loadBlock(%s)", blockName));
List<IDIYComponent<?>> existingComponents, BlockInstantiationMode mode)
throws InvalidBlockException {
LOG.debug(String.format("loadBlock(%s, %s)", blockName, mode));
Map<String, List<IDIYComponent<?>>> blocks = (Map<String, List<IDIYComponent<?>>>) configManager
.readObject(IPlugInPort.BLOCKS_KEY, null);
if (blocks != null) {
Collection<IDIYComponent<?>> 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<IDIYComponent<?>> 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<IDIYComponent<?>> clones = new ArrayList<IDIYComponent<?>>();
List<IDIYComponent<?>> testComponents = new ArrayList<IDIYComponent<?>>(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<IDIYComponent<?>> childClones = new ArrayList<IDIYComponent<?>>();
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<String> recent =
(List<String>) configManager.readObject(IPlugInPort.RECENT_COMPONENTS_KEY, null);
if (recent != null && recent.remove(IBlockProcessor.BLOCK_PREFIX + blockName))
configManager.writeValue(IPlugInPort.RECENT_COMPONENTS_KEY, recent);
}
}
@@ -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<String[]> 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<ComponentType> 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))
@@ -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:<blockName>"} (see
* {@link IBlockProcessor#BLOCK_PREFIX}).
*
* @param identifier component class name or {@code "block:<blockName>"}
*/
@SuppressWarnings("unchecked")
public static void addToRecentComponents(String identifier) {
List<String> recent = (List<String>) ConfigurationManager.getInstance()
.readObject(IPlugInPort.RECENT_COMPONENTS_KEY, new ArrayList<String>());
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<IDIYComponent<?>> instantiateComponent(ComponentType componentType, Point2D point,
Project currentProject) throws InstantiationException, IllegalAccessException, NoSuchMethodException,
@@ -311,21 +336,7 @@ public class InstantiationManager {
}
// Write to recent components
List<String> recentComponentTypes =
(List<String>) ConfigurationManager.getInstance().readObject(IPlugInPort.RECENT_COMPONENTS_KEY,
new ArrayList<ComponentType>());
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<IDIYComponent<?>> list = new ArrayList<IDIYComponent<?>>();
list.add(component);
@@ -1060,9 +1060,9 @@ public class Presenter implements IPlugInPort {
String regex = ".*" + criteria.toLowerCase() + ".*";
for (IDIYComponent<?> c : currentProject.getComponents()) {
ComponentType type = ComponentProcessor.getInstance().extractComponentTypeFrom((Class<? extends IDIYComponent<?>>) 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<IDIYComponent<?>> components = buildingBlockManager.loadBlock(blockName, currentProject.getComponents());
pasteComponents(new ComponentTransferable(components, blockName), true, true);
public void loadBlock(String blockName, BlockInstantiationMode mode) throws InvalidBlockException {
List<IDIYComponent<?>> 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
@@ -81,6 +81,7 @@ public class BomMaker {
(Class<? extends IDIYComponent<?>>) 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 = "<undefined>";
}
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));
}
}
@@ -369,6 +369,38 @@
<releaseDate>2026-08-22 00:00:00.000 CET</releaseDate>
<name></name>
<changes>
<org.diylc.appframework.update.Change>
<changeType>NEW_FEATURE</changeType>
<description>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</description>
</org.diylc.appframework.update.Change>
<org.diylc.appframework.update.Change>
<changeType>NEW_FEATURE</changeType>
<description>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</description>
</org.diylc.appframework.update.Change>
<org.diylc.appframework.update.Change>
<changeType>IMPROVEMENT</changeType>
<description>Include building blocks in the Recently Used category</description>
</org.diylc.appframework.update.Change>
<org.diylc.appframework.update.Change>
<changeType>IMPROVEMENT</changeType>
<description>When saving building blocks, present a drop down list with most recently used building blocks for quick selection</description>
</org.diylc.appframework.update.Change>
<org.diylc.appframework.update.Change>
<changeType>IMPROVEMENT</changeType>
<description>Prompt to confirm the overwrite when saving over the existing building blocks</description>
</org.diylc.appframework.update.Change>
<org.diylc.appframework.update.Change>
<changeType>IMPROVEMENT</changeType>
<description>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</description>
</org.diylc.appframework.update.Change>
<org.diylc.appframework.update.Change>
<changeType>IMPROVEMENT</changeType>
<description>Diode components now label their terminal nodes as 'A' (anode) and 'K' (cathode) instead of '1' and '2'</description>
</org.diylc.appframework.update.Change>
<org.diylc.appframework.update.Change>
<changeType>IMPROVEMENT</changeType>
<description>Capacitor components now label their terminal nodes as '+' and '-' instead of '1' and '2' when 'Polarized' property is ticked</description>
</org.diylc.appframework.update.Change>
<org.diylc.appframework.update.Change>
<changeType>IMPROVEMENT</changeType>
<description>Percentage values such as transparency Alpha, image Scale, etc, can have decimal values and not just integers</description>
@@ -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 <http://www.gnu.org/licenses/>.
*/
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 "<composite>.<child>.<local point name>"
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());
}
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<IDIYComponent<?>> 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<String, List<IDIYComponent<?>>> blocksMap = new HashMap<String, List<IDIYComponent<?>>>();
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<IDIYComponent<?>> result =
manager.loadBlock(BLOCK_NAME, new ArrayList<IDIYComponent<?>>(), 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<IDIYComponent<?>> result =
manager.loadBlock(BLOCK_NAME, new ArrayList<IDIYComponent<?>>(), 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<IDIYComponent<?>> result =
manager.loadBlock(BLOCK_NAME, new ArrayList<IDIYComponent<?>>(), 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<IDIYComponent<?>> existing = new ArrayList<IDIYComponent<?>>();
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<IDIYComponent<?>>(), BlockInstantiationMode.GROUP);
}
@Test(expected = InvalidBlockException.class)
public void compositeModeThrowsOnUnknownBlock() throws InvalidBlockException {
manager.loadBlock("NoSuchBlock", new ArrayList<IDIYComponent<?>>(), BlockInstantiationMode.COMPOSITE);
}
@Test
public void deleteBlockAlsoStripsItFromTheRecentlyUsedList() {
List<String> recent = new ArrayList<String>(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<IDIYComponent<?>> storedComponents = Arrays.<IDIYComponent<?>>asList(new SwitchTestComponent("SW1"));
Map<String, List<IDIYComponent<?>>> blocksMap = new HashMap<String, List<IDIYComponent<?>>>();
blocksMap.put("SwitchBlock", storedComponents);
when(configManager.readObject(eq(IPlugInPort.BLOCKS_KEY), org.mockito.ArgumentMatchers.any()))
.thenReturn(blocksMap);
List<IDIYComponent<?>> result = manager.loadBlock("SwitchBlock", new ArrayList<IDIYComponent<?>>(),
BlockInstantiationMode.COMPOSITE);
assertNotNull(result);
assertEquals(1, result.size());
assertTrue(((CompositeComponent) result.get(0)).getChildComponents().get(0) instanceof ISwitch);
}
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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).
* <p>
* 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;
}
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<Void> {
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) {
}
}
@@ -62,6 +62,7 @@ public class Eyelet extends AbstractTransparentComponent<String> {
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<String> {
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
@@ -71,6 +71,7 @@ public class PCBTerminalBlock extends AbstractTransparentComponent<PCBTerminalBl
private Size pitch = new Size(0.2d, SizeUnit.in);
private Size width = new Size(0.3d, SizeUnit.in);
private ScrewPosition screwPosition = ScrewPosition.Offset;
private String nodeNames = "";
public PCBTerminalBlock() {
super();
@@ -330,10 +331,30 @@ public class PCBTerminalBlock extends AbstractTransparentComponent<PCBTerminalBl
}
@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) {
// we don't want the block to produce any nodes, it just makes connections
return null;
// By default the block produces no nodes, it just makes connections. Once the user
// assigns explicit node names, each labeled point becomes a named node; unlabeled
// points fall back to their ordinal number.
String raw = getNodeNames();
if (raw.trim().isEmpty()) {
return null;
}
String[] parts = raw.split(",", -1);
String label = index < parts.length ? parts[index].trim() : "";
return label.isEmpty() ? Integer.toString(index + 1) : label;
}
@Override
@@ -71,6 +71,7 @@ public class SolderPad extends AbstractTransparentComponent<Void>
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<Void>
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
@@ -60,6 +60,7 @@ public class Turret extends AbstractTransparentComponent<String> {
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<String> {
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
@@ -71,6 +71,7 @@ public class PinHeader extends AbstractTransparentComponent<Void> {
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<Void> {
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;
@@ -203,6 +203,19 @@ public class AxialElectrolyticCapacitor extends AbstractLeadedComponent<Capacita
this.polarized = polarized;
}
@Override
public String getControlPointNodeName(int index) {
if (getPolarized()) {
switch (index) {
case 0:
return "+";
case 1:
return "-";
}
}
return super.getControlPointNodeName(index);
}
@Override
protected Shape getBodyShape() {
double lengthFinal = getLength().convertToPixels();
@@ -96,6 +96,19 @@ public class CapacitorSymbol extends AbstractSchematicLeadedSymbol<Capacitance>
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);
@@ -72,6 +72,7 @@ public class RadialElectrolytic extends AbstractRadialComponent<Capacitance> 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<Capacitance> 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<Capacitance> 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();
@@ -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() {
@@ -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);
}
@@ -92,6 +92,18 @@ public class DiodeGlass extends AbstractLeadedComponent<String> 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);
@@ -90,6 +90,18 @@ public class DiodePlastic extends AbstractLeadedComponent<String> 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);
@@ -67,6 +67,18 @@ public class LED extends AbstractLeadedComponent<String> 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);
@@ -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<String> nameBox =
new JComboBox<String>(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;
}
}
}
@SuppressWarnings("unchecked")
private boolean blockExists(String blockName) {
Map<String, ?> blocks = (Map<String, ?>) ConfigurationManager.getInstance()
.readObject(IBlockProcessor.BLOCKS_KEY, null);
return blocks != null && blocks.containsKey(blockName);
}
@SuppressWarnings("unchecked")
private List<String> getRecentBlockNames() {
List<String> recent = (List<String>) ConfigurationManager.getInstance()
.readObject(IPlugInPort.RECENT_COMPONENTS_KEY, null);
List<String> blockNames = new ArrayList<String>();
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;
}
}
@@ -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<String>) 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<String, List<ComponentType>> componentTypes =
CanvasPanel.this.plugInPort.getComponentTypes();
for (Map.Entry<String, List<ComponentType>> 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);
}
@@ -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);
}
@@ -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<String> recentComponentClassList) {
Container toolbar = getRecentToolbar();
toolbar.removeAll();
for (String componentClassName : recentComponentClassList) {
ComponentType componentType;
for (String identifier : recentComponentClassList) {
try {
componentType = ComponentProcessor.getInstance().extractComponentTypeFrom(
(Class<? extends IDIYComponent<?>>) Class.forName(componentClassName));
Component button = ComponentButtonFactory.create(plugInPort, componentType,
createVariantPopup(componentType, null));
toolbar.add(button);
if (identifier.startsWith(IBlockProcessor.BLOCK_PREFIX)) {
String blockName = identifier.substring(IBlockProcessor.BLOCK_PREFIX.length());
Component button = ComponentButtonFactory.createBuildingBlockButton(plugInPort, blockName,
createVariantPopup(null, blockName));
toolbar.add(button);
} else {
ComponentType componentType = ComponentProcessor.getInstance().extractComponentTypeFrom(
(Class<? extends IDIYComponent<?>>) Class.forName(identifier));
Component button = ComponentButtonFactory.create(plugInPort, componentType,
createVariantPopup(componentType, null));
toolbar.add(button);
}
} catch (Exception e) {
LOG.error("Could not create recent component button for " + componentClassName, e);
LOG.error("Could not create recent component button for " + identifier, e);
}
}
}
@@ -335,7 +343,8 @@ class ComponentTabbedPane extends JTabbedPane {
public void popupMenuWillBecomeVisible(PopupMenuEvent e) {
variantPopup.removeAll();
final String identifier = componentType == null ? "block:" + blockName
final String identifier = componentType == null
? IBlockProcessor.BLOCK_PREFIX + blockName
: componentType.getInstanceClass().getCanonicalName();
JMenu shortcutSubmenu = new JMenu("Assign Shortcut");
@@ -456,6 +465,7 @@ class ComponentTabbedPane extends JTabbedPane {
}
}
} else {
variantPopup.add(new InsertBlockAsGroupAction(plugInPort, blockName));
variantPopup.add(shortcutSubmenu);
variantPopup.add(new DeleteBlockAction(plugInPort, swingUI, blockName));
}
@@ -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;
}
}
}
@@ -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<String, String> shortcutMap = (HashMap<String, String>) 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;
@@ -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<Void> {
private List<IDIYComponent<?>> components; // embedded children (D1)
private String blockName; // type identity (D5)
private transient int[] childOf; // flat index -> child ordinal
private transient int[] pointOf; // flat index -> child point index
}
```
The `@ComponentDescriptor` annotation is mandatory, not cosmetic:
`extractComponentTypeFrom` returns `null` without it, and `NetlistBuilder:69`
and `:401` dereference the result.
**Point index mapping.** A flat index is built lazily from the children and
cached in the two transient arrays. Children never change after construction
(D8), so the cache never needs invalidating; it only needs rebuilding after
deserialization, which the lazy getter handles.
**Delegating members**
| Member | Behaviour |
|---|---|
| `getControlPointCount()` | Sum over children |
| `getControlPoint(i)` | Delegate |
| `setControlPoint(p, i)` | Delegate (required for placement — see §3) |
| `isControlPointSticky(i)` | Delegate |
| `canControlPointOverlap(i)` | Delegate |
| `getControlPointVisibilityPolicy(i)` | Delegate |
| `getControlPointNodeName(i)` | 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<String, List<IDIYComponent<?>>>` shape and needs no migration. Default
and imported blocks work in both modes immediately.
- **New `.diy` files containing composites** cannot be opened by older DIYLC
versions. Expected and unavoidable given D1.
- Children serialize as ordinary nested `diylc.*` elements inside the
composite's `components` list, so every existing component converter applies
unchanged.
## 6. Suggested order of work
1. `CompositeComponent` with the delegating members, the point index map, a
correct `clone()`, and `draw`. No UI yet — exercise it from a unit test that
builds a composite directly and asserts point counts, node names and rigidity.
2. `hiddenInPalette` on `ComponentDescriptor` / `ComponentType` /
`getComponentTypes()`.
3. `BlockInstantiationMode`, the `IBlockProcessor` overload,
`BuildingBlockManager.loadBlock`, `Presenter.loadBlock`.
4. UI wiring: four default call sites plus the "Insert as Separate Components"
popup items.
5. `getDisplayTypeName` and its three consumers.
6. `CompositeComponentTransformer`, and rotation/mirroring enablement.
7. Round-trip and regression tests (§8).
Steps 1–4 are the minimum for a usable feature; 5–6 complete it.
## 7. Risks
**`clone()` is the sharp edge.** `AbstractComponent.clone()` is reflective and
only deep-copies points. Without an override, two placed copies of the same
composite share one child list, and moving one moves both. This will not fail
loudly — it will look like a bizarre rendering bug. It needs a dedicated test.
**Z-order.** A composite occupies a single slot in `project.getComponents()`, so
children spanning the board, trace and component layers all render at that one
slot. For a board-like block this is correct. It does mean an external trace
cannot be sandwiched between a block's board and the parts mounted on it. The
descriptor uses `zOrder = COMPONENT` with `flexibleZOrder = true`; deriving the
z-order from the lowest child at save time is a possible refinement if this
proves annoying in practice.
**Inert internal switches.** Per D6 the composite is not `ISwitch`, so a block
containing a switch places and draws correctly but contributes no switching to
the netlist. This is a silent difference from group mode. Worth a log warning
when a composite is created from a selection containing an `ISwitch`, and worth
mentioning in the release notes.
**Variants.** `Presenter.getVariantsFor` keys on `ComponentType`, so all
composites share one variant pool regardless of block. Applying a variant to a
composite would be meaningless. Suppress the variant UI for `CompositeComponent`
selections.
**Naming collisions.** Two instances of the same block deliberately carry
identically named children (§4.4). Netlist labels stay unique because they are
prefixed with the composite's own name (`ARD1.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.