schematic improvements

This commit is contained in:
Bane Stojkovic
2026-09-13 20:31:06 +02:00
parent 078d03d59b
commit b5ba302e93
6 changed files with 289 additions and 16 deletions
@@ -123,7 +123,7 @@ public class SchematicBuilder {
List<OrthogonalLine> wires = createWires(netlist, entriesByPhysicalId);
schematicComponents.addAll(wires);
schematicComponents.sort(Comparator.comparingDouble(SchematicBuilder::zOrderOf));
schematicComponents.sort(SCHEMATIC_ORDER);
SchematicView view = project.getOrCreateSchematicView();
view.getComponents().clear();
@@ -441,6 +441,17 @@ public class SchematicBuilder {
}
}
/**
* Paint and hit-test order for the schematic view. Wires go underneath the symbols, and not where
* their {@link IDIYComponent#WIRING} z-order would otherwise put them, because the canvas hands a
* click to the last matching component in this list. A wire runs right up to the pin it connects
* to and its hit area is widened to three pixels, so a wire drawn on top would swallow the clicks
* meant for the symbol underneath it and the user could not drag the symbol at all.
*/
static final Comparator<IDIYComponent<?>> SCHEMATIC_ORDER =
Comparator.comparingInt((IDIYComponent<?> c) -> c instanceof OrthogonalLine ? 0 : 1)
.thenComparingDouble(SchematicBuilder::zOrderOf);
static double zOrderOf(IDIYComponent<?> component) {
ComponentType type = ComponentProcessor.getInstance()
.extractComponentTypeFrom((Class<? extends IDIYComponent<?>>) component.getClass());
@@ -157,7 +157,7 @@ public class SchematicSynchronizer {
List<IDIYComponent<?>> result = new ArrayList<IDIYComponent<?>>(keptSymbols);
result.addAll(wires);
result.sort(java.util.Comparator.comparingDouble(SchematicBuilder::zOrderOf));
result.sort(SchematicBuilder.SCHEMATIC_ORDER);
view.getComponents().clear();
view.getComponents().addAll(result);
@@ -0,0 +1,104 @@
/*
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.swing.plugins.schematic;
import org.diylc.appframework.miscutils.IConfigListener;
import org.diylc.appframework.miscutils.IConfigurationManager;
import org.diylc.common.IPlugInPort;
/**
* The application's configuration as the schematic canvas sees it: everything is delegated, except
* that locked components are never dimmed.
* <p>
* The schematic keeps its whole wiring layer locked so that generated wires cannot be selected,
* dragged or deleted. Locked components are normally painted at half alpha to signal that they are
* untouchable, which here would wash out every wire on the drawing and read as a rendering fault
* rather than a signal. Overriding the preference for this canvas alone leaves the user's own
* setting untouched on the layout canvas.
*
* @author Branislav Stojkovic
*/
public class SchematicConfigurationManager<T> implements IConfigurationManager<T> {
private final IConfigurationManager<T> delegate;
public static <T> IConfigurationManager<T> wrap(IConfigurationManager<T> delegate) {
return new SchematicConfigurationManager<T>(delegate);
}
private SchematicConfigurationManager(IConfigurationManager<T> delegate) {
this.delegate = delegate;
}
@Override
public boolean readBoolean(String key, boolean defaultValue) {
if (IPlugInPort.LOCKED_ALPHA.equals(key)) {
return false;
}
return delegate.readBoolean(key, defaultValue);
}
@Override
public void addConfigListener(String key, IConfigListener listener) {
delegate.addConfigListener(key, listener);
}
@Override
public String readString(String key, String defaultValue) {
return delegate.readString(key, defaultValue);
}
@Override
public int readInt(String key, int defaultValue) {
return delegate.readInt(key, defaultValue);
}
@Override
public float readFloat(String key, float defaultValue) {
return delegate.readFloat(key, defaultValue);
}
@Override
public double readDouble(String key, double defaultValue) {
return delegate.readDouble(key, defaultValue);
}
@Override
public Object readObject(String key, Object defaultValue) {
return delegate.readObject(key, defaultValue);
}
@Override
public void writeValue(String key, Object value) {
delegate.writeValue(key, value);
}
@Override
public T getSerializer() {
return delegate.getSerializer();
}
@Override
public void initialize(String appName) {
delegate.initialize(appName);
}
}
@@ -43,6 +43,7 @@ import org.diylc.common.BadPositionException;
import org.diylc.common.EventType;
import org.diylc.common.IPlugIn;
import org.diylc.common.IPlugInPort;
import org.diylc.core.IDIYComponent;
import org.diylc.core.Project;
import org.diylc.core.SchematicView;
import org.diylc.presenter.ContinuityArea;
@@ -63,8 +64,10 @@ import org.diylc.swing.plugins.canvas.CanvasPlugin;
* </p>
*
* <p>
* The schematic canvas is not restricted yet, so nothing prevents selecting or nudging symbols; a
* dedicated restricted mode (no add / delete / paste, shared undo) is a planned follow-up.
* Symbols are movable; the generated wires are not. The whole wiring layer is locked so that a wire
* cannot be selected, dragged or deleted, and because a locked component is skipped by the sticky
* point expansion that would otherwise drag its endpoints along, the wires are regenerated from the
* symbols' current pin positions after every change.
* </p>
*
* @author Branislav Stojkovic
@@ -84,6 +87,7 @@ public class SchematicTabPlugin implements IPlugIn {
private JComponent schematicScroll;
private JToggleButton schematicTab;
private Project schematicProject;
private boolean rewiring;
public SchematicTabPlugin(ISwingUI swingUI, IConfigurationManager<?> configManager,
CanvasPlugin layoutCanvasPlugin) {
@@ -99,9 +103,11 @@ public class SchematicTabPlugin implements IPlugIn {
// A second presenter + canvas plugin gives the schematic the exact same rendering, scrolling
// and zoom behaviour as the layout canvas.
this.schematicPresenter = new Presenter(swingUI, configManager, false);
this.schematicCanvasPlugin = new CanvasPlugin(swingUI, configManager);
IConfigurationManager<?> schematicConfig = SchematicConfigurationManager.wrap(configManager);
this.schematicPresenter = new Presenter(swingUI, schematicConfig, false);
this.schematicCanvasPlugin = new CanvasPlugin(swingUI, schematicConfig);
this.schematicPresenter.installPlugin(() -> schematicCanvasPlugin);
this.schematicPresenter.installPlugin(SchematicChangeListener::new);
this.schematicScroll = schematicCanvasPlugin.getCanvasScrollComponent();
this.schematicScroll.setVisible(false);
@@ -184,6 +190,10 @@ public class SchematicTabPlugin implements IPlugIn {
wrapper.setGridSpacing(view.getGridSpacing());
wrapper.setFont(layoutProject.getFont());
wrapper.getComponents().addAll(view.getComponents());
// Generated wires must never be touched by the user: lock the whole WIRING layer so they cannot
// be selected, dragged or detached from symbols. Symbols live on the COMPONENT layer and stay
// fully movable.
wrapper.getLockedLayers().add(IDIYComponent.WIRING);
return wrapper;
}
@@ -197,6 +207,32 @@ public class SchematicTabPlugin implements IPlugIn {
}
}
/**
* Regenerates the wires from the symbols' current pin positions. A locked wire is invisible to the
* sticky point expansion that moves attached endpoints along with a dragged component, so this is
* what keeps the drawing connected after the user moves a symbol.
*/
private void onSchematicModified() {
if (rewiring || schematicProject == null) {
return;
}
rewiring = true;
try {
Project layoutProject = plugInPort.getCurrentProject();
new SchematicSynchronizer().synchronize(layoutProject, plugInPort.getContinuityAreas());
SchematicView view = layoutProject.getOrCreateSchematicView();
schematicProject.setWidth(view.getWidth());
schematicProject.setHeight(view.getHeight());
schematicProject.getComponents().clear();
schematicProject.getComponents().addAll(view.getComponents());
schematicPresenter.refresh();
} catch (Exception e) {
LOG.error("Could not re-wire the schematic", e);
} finally {
rewiring = false;
}
}
@Override
public EnumSet<EventType> getSubscribedEventTypes() {
return EnumSet.noneOf(EventType.class);
@@ -206,4 +242,25 @@ public class SchematicTabPlugin implements IPlugIn {
public void processMessage(EventType eventType, Object... params) {
// no-op
}
/** Listens on the schematic presenter for symbol moves and triggers the re-wiring. */
private class SchematicChangeListener implements IPlugIn {
@Override
public void connect(IPlugInPort plugInPort) {
// nothing to do; we only need the message subscription
}
@Override
public EnumSet<EventType> getSubscribedEventTypes() {
return EnumSet.of(EventType.PROJECT_MODIFIED);
}
@Override
public void processMessage(EventType eventType, Object... params) {
if (eventType == EventType.PROJECT_MODIFIED) {
SwingUtilities.invokeLater(SchematicTabPlugin.this::onSchematicModified);
}
}
}
}
@@ -5,9 +5,11 @@ import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import java.awt.Point;
import java.awt.geom.Point2D;
import java.awt.image.BufferedImage;
import org.diylc.components.connectivity.OrthogonalLine;
import org.diylc.core.IDIYComponent;
import org.diylc.core.Project;
import org.diylc.core.SchematicView;
import org.diylc.schematic.SchematicBuilder;
@@ -59,6 +61,84 @@ public class SchematicViewIntegrationTest extends TestBase {
assertEquals(1, project.getSchematicView().getPhysicalToSchematicMap().size());
}
@Test
public void wiresAreOrderedUnderneathTheSymbols() {
instantiateTwoClick("Passive", "Resistor", new Point(1000, 1000), new Point(1200, 1000));
instantiateTwoClick("Passive", "Resistor", new Point(1200, 1000), new Point(1400, 1000));
Project project = presenter.getCurrentProject();
new SchematicBuilder().build(project, presenter.getContinuityAreas());
// the canvas paints in list order and hands a click to the last match, so wires belong first
boolean seenSymbol = false;
for (IDIYComponent<?> component : project.getSchematicView().getComponents()) {
if (component instanceof OrthogonalLine) {
assertTrue("wire found after a symbol", !seenSymbol);
} else {
seenSymbol = true;
}
}
assertTrue(seenSymbol);
}
@Test
public void stretchingASymbolReconnectsTheWiresOnSync() {
instantiateTwoClick("Passive", "Resistor", new Point(1000, 1000), new Point(1200, 1000));
instantiateTwoClick("Passive", "Resistor", new Point(1200, 1000), new Point(1400, 1000));
Project project = presenter.getCurrentProject();
new SchematicBuilder().build(project, presenter.getContinuityAreas());
SchematicView view = project.getSchematicView();
assertTrue(countWires(view) > 0);
assertEndpointsSitOnPins(view);
// stretch a symbol by dragging a single pin, which is the case that used to leave the wire
// behind: the pin the wire was drawn to is now somewhere else
IDIYComponent<?> moved = null;
for (IDIYComponent<?> component : view.getComponents()) {
if (!(component instanceof OrthogonalLine)) {
moved = component;
break;
}
}
assertNotNull(moved);
Point2D pin = moved.getControlPoint(1);
moved.setControlPoint(new Point2D.Double(pin.getX() + 300, pin.getY() + 400), 1);
new SchematicSynchronizer().synchronize(project, presenter.getContinuityAreas());
view = project.getSchematicView();
assertTrue(countWires(view) > 0);
assertEndpointsSitOnPins(view);
}
private static long countWires(SchematicView view) {
return view.getComponents().stream().filter(c -> c instanceof OrthogonalLine).count();
}
private static void assertEndpointsSitOnPins(SchematicView view) {
for (IDIYComponent<?> wire : view.getComponents()) {
if (!(wire instanceof OrthogonalLine)) {
continue;
}
for (int end : new int[] {0, wire.getControlPointCount() - 1}) {
Point2D endpoint = wire.getControlPoint(end);
boolean onPin = false;
for (IDIYComponent<?> symbol : view.getComponents()) {
if (symbol instanceof OrthogonalLine) {
continue;
}
for (int i = 0; i < symbol.getControlPointCount(); i++) {
if (symbol.getControlPoint(i).distance(endpoint) < 0.6) {
onPin = true;
}
}
}
assertTrue("wire endpoint " + endpoint + " is not on any symbol pin", onPin);
}
}
}
@Test
public void viewerPanelRendersWithoutError() {
instantiateTwoClick("Passive", "Resistor", new Point(1000, 1000), new Point(1200, 1000));
+31 -10
View File
@@ -233,15 +233,36 @@ again.
Three consequences worth knowing:
- **Wires are editable now.** `SchematicTabPlugin` used to lock the whole `WIRING`
layer so that auto-routed wires could not be selected or dragged. That lock is
gone, both because sticky endpoints need it gone and because a hand-adjustable
wire is the point of the swap. It also retires the opaque-composite workaround
`SchematicWire.draw` needed to escape the reduced alpha that locked components
are painted with.
- **Wires stay locked, and that is what forces the regeneration.** The whole
`WIRING` layer is locked in the schematic wrapper project so a wire cannot be
selected, dragged or deleted. `Presenter.includeStuckComponents` skips locked
components, so a locked wire is invisible to the sticky-point expansion that
drags attached endpoints along with a moved symbol. Locking and automatic
reconnection are therefore coupled: the wires have to be regenerated explicitly.
`SchematicTabPlugin` re-runs `SchematicSynchronizer` on `PROJECT_MODIFIED`,
which keeps the placed symbols and rebuilds every wire from their current pin
positions. That covers moving a whole symbol and, importantly, dragging a single
pin of one.
- **Locked components are painted at half alpha.** On a schematic where every wire
is locked that washes out the entire drawing.
`SchematicConfigurationManager` wraps the application's configuration for the
schematic canvas alone and reports `lockedAlpha` as false, leaving the user's own
setting untouched on the layout canvas. This replaces the opaque-composite
workaround the old wire class carried inside its `draw`.
- **Obstacle avoidance is gone.** The router used to steer a new route away from
overlapping or crossing the wires already placed. Nothing replaces that, so a
dense schematic can produce wires that run over each other. The user can now drag
them apart, which was impossible before.
- **The reroute listener is gone.** `SchematicChangeListener` existed only to call
`rerouteWires` after every project modification. Nothing subscribes in its place.
dense schematic can produce wires that run over each other.
### Wires are ordered under the symbols
The schematic used to be sorted by z-order, which puts `WIRING` above `COMPONENT`.
`SchematicBuilder.SCHEMATIC_ORDER` replaces that sort in both the builder and the
synchronizer, ranking wires first and falling back to z-order for everything else.
It is the right paint order for a schematic, symbols drawing over the wires behind
them, and it keeps clicks reaching the symbols even if the wiring layer is ever
unlocked, since the canvas hands a click to the last matching component in the
list.
Three tests in `SchematicViewIntegrationTest` pin the behaviour down: that wires
are ordered before symbols, that the generated wires' endpoints sit on symbol pins,
and that stretching a symbol by one pin and re-syncing puts them back on pins.