From a6329c45bdd2c6cfbd886b90ce586f7a173eb406 Mon Sep 17 00:00:00 2001 From: Bane Stojkovic Date: Wed, 11 Nov 2026 10:21:44 +0100 Subject: [PATCH 1/2] 'Highlight Connected Areas', netlists and AI analysis could ignore trace cuts after the board under them was moved or edited, or when the cuts were scrolled out of view, and showed the whole strip as connected --- .../chatbot/service/ChatbotService.java | 3 +- .../org/diylc/presenter/DrawingCache.java | 3 + .../org/diylc/presenter/DrawingManager.java | 77 +++++++++++---- .../java/org/diylc/presenter/Presenter.java | 9 +- .../diylc-core/src/main/resources/update.xml | 4 + .../diylc/presenter/DrawingManagerTest.java | 96 ++++++++++++++++++- 6 files changed, 168 insertions(+), 24 deletions(-) diff --git a/diylc/diylc-core/src/main/java/org/diylc/plugins/chatbot/service/ChatbotService.java b/diylc/diylc-core/src/main/java/org/diylc/plugins/chatbot/service/ChatbotService.java index 055394faf..54b10ac08 100644 --- a/diylc/diylc-core/src/main/java/org/diylc/plugins/chatbot/service/ChatbotService.java +++ b/diylc/diylc-core/src/main/java/org/diylc/plugins/chatbot/service/ChatbotService.java @@ -312,7 +312,8 @@ public class ChatbotService { return null; } DrawingManager drawingManager = plugInPort.getDrawingManager(); - List continuityAreas = drawingManager.getContinuityAreas(); + List continuityAreas = + drawingManager.getContinuityAreas(plugInPort.getCurrentProject()); return AiProjectBuilder.build(plugInPort.getCurrentProject(), continuityAreas, component -> { ComponentArea area = drawingManager.getComponentArea(component); return area == null ? null : area.getOutlineBounds(); diff --git a/diylc/diylc-core/src/main/java/org/diylc/presenter/DrawingCache.java b/diylc/diylc-core/src/main/java/org/diylc/presenter/DrawingCache.java index 097d2a9d5..48b3d8e32 100644 --- a/diylc/diylc-core/src/main/java/org/diylc/presenter/DrawingCache.java +++ b/diylc/diylc-core/src/main/java/org/diylc/presenter/DrawingCache.java @@ -133,7 +133,10 @@ public class DrawingCache { .extractComponentTypeFrom((Class>) component.getClass()); CacheValue value = null; + // a component out of view is drawn only to record its areas, which drawing it directly does + // without rendering an image that would push visible ones out of the cache if (type != null && type.getEnableCache() + && (visibleRect == null || visibleRect.intersects(component.getCachingBounds())) && shouldCache(component, componentState, zoom, scaleFactor)) { value = renderAndCache(component, g2d, componentState, outlineMode, project, zoom, scaleFactor, zOrder, drawOptions, false); diff --git a/diylc/diylc-core/src/main/java/org/diylc/presenter/DrawingManager.java b/diylc/diylc-core/src/main/java/org/diylc/presenter/DrawingManager.java index 0a2e1890a..57ee72438 100644 --- a/diylc/diylc-core/src/main/java/org/diylc/presenter/DrawingManager.java +++ b/diylc/diylc-core/src/main/java/org/diylc/presenter/DrawingManager.java @@ -32,6 +32,7 @@ import java.awt.Stroke; import java.awt.geom.AffineTransform; import java.awt.geom.Area; import java.awt.geom.Ellipse2D; +import java.awt.geom.NoninvertibleTransformException; import java.awt.geom.Point2D; import java.awt.geom.Rectangle2D; import java.util.ArrayList; @@ -234,7 +235,9 @@ public class DrawingManager { g2dWrapper.setColor(theme.getBgColor()); g2dWrapper.fillRect(0, 0, canvasDimension.width, canvasDimension.height); - g2d.clip(new Rectangle(new Point(0, 0), canvasDimension)); + Rectangle canvasRect = new Rectangle(new Point(0, 0), canvasDimension); + g2d.clip(canvasRect); + Shape canvasDeviceClip = g2d.getTransform().createTransformedShape(canvasRect); Rectangle2D extraSpaceRect = new Rectangle2D.Double(); AffineTransform extraSpaceTx = new AffineTransform(); @@ -253,6 +256,11 @@ public class DrawingManager { visibleRect.getWidth() / zoom, visibleRect.getHeight() / zoom); } + // components skip drawing themselves when the clip leaves them out, so a component whose + // areas are recorded is drawn against the whole canvas rather than the visible part of it; + // otherwise a trace cut scrolled out of view would not cut anything + Shape recordingClip = toUserSpace(g2d, canvasDeviceClip); + // Composite mainComposite = g2d.getComposite(); // Composite alphaComposite = // AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 0.5f); @@ -304,10 +312,15 @@ public class DrawingManager { synchronized (this) { g2dWrapper.startedDrawingComponent(i); + Shape visibleClip = null; if (!trackArea) { g2dWrapper.stopTracking(); } else { g2dWrapper.startRecordingDrawnBounds(); + if (recordingClip != null) { + visibleClip = g2d.getClip(); + g2d.setClip(recordingClip); + } } Composite oldComposite = g2d.getComposite(); Font oldFont = g2d.getFont(); @@ -346,9 +359,15 @@ public class DrawingManager { } } ComponentArea area = g2dWrapper.finishedDrawingComponent(); + // after finishedDrawingComponent, which reverts the transform the clip was taken in + if (visibleClip != null) { + g2d.setClip(visibleClip); + } if (trackArea && area != null && area.hasOutline()) { componentAreaMap.put(component, area); lastDrawnStateMap.put(component, state); + // the graph was built from the areas this one replaces + continuityGraphCache = null; } try { @@ -641,6 +660,18 @@ public class DrawingManager { bounds.getHeight() + 2 * OFF_SCREEN_MARGIN); } + /** + * @return the specified device-space shape in the current user space of the graphics, or null + * when its transform cannot be inverted + */ + private static Shape toUserSpace(Graphics2D g2d, Shape deviceShape) { + try { + return g2d.getTransform().createInverse().createTransformedShape(deviceShape); + } catch (NoninvertibleTransformException e) { + return null; + } + } + private void configureRenderingHints(Graphics2D g2d, Set drawOptions) { if (drawOptions.contains(DrawOption.ANTIALIASING)) { g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); @@ -805,26 +836,35 @@ public class DrawingManager { messageDispatcher.dispatchMessage(EventType.REPAINT); } - public void findContinuityAreaAtPoint(Point2D p) { - if (continuityGraphCache == null) - continuityGraphCache = getContinuityGraph(); + public void findContinuityAreaAtPoint(Project project, Point2D p) { + if (continuityGraphCache == null) { + continuityGraphCache = getContinuityGraph(project); + } currentContinuityAreas = continuityGraphCache.findAreasFor(p); } - - public List getContinuityAreas() { + + /** + * Returns the continuity areas recorded for the components of the specified project, with + * negative areas subtracted from the positive areas of the components below them. + * + * @param project + * @return + */ + public List getContinuityAreas(Project project) { // Find all individual continuity areas for all components List preliminaryAreas = new ArrayList(); List checkBreakout = new ArrayList(); - for (Map.Entry, ComponentArea> entry : this.componentAreaMap.entrySet()) { - ComponentArea a = entry.getValue(); + // in z-order rather than the order the areas were recorded in, which changes every time a + // component is redrawn after an edit; a trace cut only cuts what lies below it + for (IDIYComponent component : project.getComponents()) { + ComponentArea a = componentAreaMap.get(component); - if (a == null) + if (a == null) { continue; - - IDIYComponent component = entry.getKey(); - + } + int layerId; if (component instanceof ILayeredComponent) { @@ -877,10 +917,13 @@ public class DrawingManager { return areas; } - public ContinuityGraph getContinuityGraph() { + public ContinuityGraph getContinuityGraph(Project project) { Set connections = new HashSet(); int z = 0; - for (IDIYComponent c : this.componentAreaMap.keySet()) { + for (IDIYComponent c : project.getComponents()) { + if (!componentAreaMap.containsKey(c)) { + continue; + } if (c instanceof IContinuity) { for (int i = 0; i < c.getControlPointCount() - 1; i++) for (int j = i + 1; j < c.getControlPointCount(); j++) @@ -890,13 +933,13 @@ public class DrawingManager { z++; } - List areas = getContinuityAreas(); + List areas = getContinuityAreas(project); return NetlistBuilder.buildContinuityGraph(areas, connections); } - public List getContinuityAreaProximity(float threshold) { - List continuityAreas = getContinuityAreas(); + public List getContinuityAreaProximity(Project project, float threshold) { + List continuityAreas = getContinuityAreas(project); Stroke s = ObjectCache.getInstance().fetchBasicStroke(threshold - 1); // value eyeballed for // approx good results diff --git a/diylc/diylc-core/src/main/java/org/diylc/presenter/Presenter.java b/diylc/diylc-core/src/main/java/org/diylc/presenter/Presenter.java index fd792530b..f600cb573 100644 --- a/diylc/diylc-core/src/main/java/org/diylc/presenter/Presenter.java +++ b/diylc/diylc-core/src/main/java/org/diylc/presenter/Presenter.java @@ -685,7 +685,7 @@ public class Presenter implements IPlugInPort { LOG.error("Unknown creation method: " + componentTypeSlot.getCreationMethod()); } } else if (operationMode == OperationMode.HIGHLIGHT_CONNECTED_AREAS || altDown) { - drawingManager.findContinuityAreaAtPoint(scaledPoint); + drawingManager.findContinuityAreaAtPoint(currentProject, scaledPoint); messageDispatcher.dispatchMessage(EventType.REPAINT); } else { List> newSelection = new ArrayList>(selectedComponents); @@ -3188,7 +3188,7 @@ public class Presenter implements IPlugInPort { @Override public List extractNetlists(boolean includeSwitches) throws NetlistException { - List continuityAreas = drawingManager.getContinuityAreas(); + List continuityAreas = drawingManager.getContinuityAreas(currentProject); return NetlistBuilder.extractNetlists(includeSwitches, currentProject, continuityAreas); } @@ -3246,12 +3246,13 @@ public class Presenter implements IPlugInPort { areas.put(c, componentArea); } return new Snapshot(currentProject.clone(), new HashSet>(selectedComponents), areas, - new HashSet(drawingManager.getContinuityAreas())); + new HashSet(drawingManager.getContinuityAreas(currentProject))); } @Override public List checkContinuityAreaProximity(Size threshold) { - return drawingManager.getContinuityAreaProximity((float)threshold.convertToPixels()); + return drawingManager.getContinuityAreaProximity(currentProject, + (float) threshold.convertToPixels()); } @Override diff --git a/diylc/diylc-core/src/main/resources/update.xml b/diylc/diylc-core/src/main/resources/update.xml index fb25aec5d..8b04e3ddc 100644 --- a/diylc/diylc-core/src/main/resources/update.xml +++ b/diylc/diylc-core/src/main/resources/update.xml @@ -641,6 +641,10 @@ BUG_FIX Projects with a resistor, capacitor or other leaded component whose Width or Length had been cleared in the property editor could not be opened; such values now fall back to the default size, and Width and Length can no longer be left empty + + BUG_FIX + 'Highlight Connected Areas', netlists and AI analysis could ignore trace cuts after the board under them was moved or edited, or when the cuts were scrolled out of view, and showed the whole strip as connected + https://github.com/bancika/diy-layout-creator/releases diff --git a/diylc/diylc-core/src/test/java/org/diylc/presenter/DrawingManagerTest.java b/diylc/diylc-core/src/test/java/org/diylc/presenter/DrawingManagerTest.java index f2ce5fdea..922ac782c 100644 --- a/diylc/diylc-core/src/test/java/org/diylc/presenter/DrawingManagerTest.java +++ b/diylc/diylc-core/src/test/java/org/diylc/presenter/DrawingManagerTest.java @@ -445,7 +445,7 @@ public class DrawingManagerTest { drawCachedAndClipped(drawingManager, project, 100); // the netlist and continuity checks are built from the areas recorded while painting - assertEquals(1, drawingManager.getContinuityAreas().size()); + assertEquals(1, drawingManager.getContinuityAreas(project).size()); assertEquals(1, component.drawCount); drawCachedAndClipped(drawingManager, project, 100); @@ -639,7 +639,7 @@ public class DrawingManagerTest { drawVisibleRect(drawingManager, project, null); // the cut splits the strip in two for the netlist... - assertEquals(2, drawingManager.getContinuityAreas().size()); + assertEquals(2, drawingManager.getContinuityAreas(project).size()); // ...but the strip's own recorded area must stay whole, so that moving or deleting the cut // does not leave a hole behind Area recorded = @@ -647,6 +647,66 @@ public class DrawingManagerTest { assertTrue(recorded.contains(50, 5)); } + @Test + public void testCutAppliesAfterStripIsRedrawn() { + Project project = new Project(); + ConductorComponent strip = new ConductorComponent(new Rectangle2D.Double(0, 0, 100, 10), true); + ConductorComponent cut = new ConductorComponent(new Rectangle2D.Double(40, 0, 20, 10), false); + project.getComponents().add(strip); + project.getComponents().add(cut); + DrawingManager drawingManager = createDrawingManager(); + drawVisibleRect(drawingManager, project, null); + + // an edit to the board records its areas again, after those of the cuts already on it + drawingManager.invalidateComponent(strip); + drawVisibleRect(drawingManager, project, null); + + assertEquals(2, drawingManager.getContinuityAreas(project).size()); + } + + @Test + public void testCutOutOfViewAppliesWhenAreasAreRecorded() { + Project project = new Project(); + project.getComponents() + .add(new ConductorComponent(new Rectangle2D.Double(0, 0, 300, 10), true)); + project.getComponents() + .add(new SelfCullingConductorComponent(new Rectangle2D.Double(240, 0, 20, 10), false)); + DrawingManager drawingManager = createDrawingManager(); + + drawCachedAndClipped(drawingManager, project, 100); + + assertEquals(2, drawingManager.getContinuityAreas(project).size()); + } + + @Test + public void testHighlightFollowsAreasRecordedAfterIt() { + Project project = new Project(); + project.getComponents() + .add(new ConductorComponent(new Rectangle2D.Double(0, 0, 100, 10), true)); + DrawingManager drawingManager = createDrawingManager(); + drawVisibleRect(drawingManager, project, null); + drawingManager.findContinuityAreaAtPoint(project, new Point2D.Double(10, 5)); + + // nothing was edited, the cut was only drawn for the first time, e.g. scrolled into view + project.getComponents() + .add(new ConductorComponent(new Rectangle2D.Double(40, 0, 20, 10), false)); + drawVisibleRect(drawingManager, project, null); + drawingManager.findContinuityAreaAtPoint(project, new Point2D.Double(10, 5)); + + BufferedImage img = render(drawingManager, project); + assertTrue(img.getRGB(10, 5) != img.getRGB(80, 5)); + } + + private BufferedImage render(DrawingManager drawingManager, Project project) { + BufferedImage img = new BufferedImage(1000, 1000, BufferedImage.TYPE_INT_ARGB); + Graphics2D g2d = img.createGraphics(); + drawingManager.drawProject(g2d, project, EnumSet.of(DrawOption.ZOOM), null, null, + Collections.emptyList(), Collections.emptySet(), Collections.emptySet(), null, null, false, + Collections.emptySet(), 1.0, 1.0, null); + g2d.dispose(); + return img; + } + private void drawVisibleRect(DrawingManager drawingManager, Project project, Rectangle2D visibleRect) { BufferedImage img = new BufferedImage(1000, 1000, BufferedImage.TYPE_INT_ARGB); @@ -732,6 +792,38 @@ public class DrawingManagerTest { } } + // skips drawing when clipped out, as TraceCut and the boards do + public static class SelfCullingConductorComponent extends ConductorComponent { + + private static final long serialVersionUID = 1L; + + private Point2D center; + + public SelfCullingConductorComponent(Rectangle2D shape, boolean positive) { + super(shape, positive); + this.center = new Point2D.Double(shape.getCenterX(), shape.getCenterY()); + } + + @Override + public int getControlPointCount() { + return 1; + } + + @Override + public Point2D getControlPoint(int index) { + return center; + } + + @Override + public void draw(Graphics2D g2d, ComponentState componentState, boolean outlineMode, + Project project, IDrawingObserver drawingObserver) { + if (checkPointsClipped(g2d.getClip())) { + return; + } + super.draw(g2d, componentState, outlineMode, project, drawingObserver); + } + } + // Simple delegating Graphics2D proxy for testing private static class Graphics2DProxy extends Graphics2D { private final Graphics2D delegate; From 2e6354883f6168ae952d7481a94210ca8a821760 Mon Sep 17 00:00:00 2001 From: Bane Stojkovic Date: Wed, 11 Nov 2026 10:21:58 +0100 Subject: [PATCH 2/2] Updated the regression-test skill: the suite has just under 100 projects, the classpath is built in the same reactor run as the compile, and the expected version is the newest one in update.xml --- diylc/.claude/skills/regression-test/SKILL.md | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/diylc/.claude/skills/regression-test/SKILL.md b/diylc/.claude/skills/regression-test/SKILL.md index e4d3cb51a..406e0f678 100644 --- a/diylc/.claude/skills/regression-test/SKILL.md +++ b/diylc/.claude/skills/regression-test/SKILL.md @@ -1,6 +1,6 @@ --- name: regression-test -description: Run the DIYLC end-to-end regression suite, which renders ~700 sample .diy projects and compares PNG exports and netlists against reference outputs. Use after any change to rendering, component drawing, serialization, netlist or analyzer logic, or when asked to check for regressions or regenerate reference files. +description: Run the DIYLC end-to-end regression suite, which renders just under 100 sample .diy projects and compares PNG exports and netlists against reference outputs. Use after any change to rendering, component drawing, serialization, netlist or analyzer logic, or when asked to check for regressions or regenerate reference files. --- # Running the DIYLC regression suite @@ -29,16 +29,18 @@ already has the arguments and JVM flags. Offer that first. The runner needs the `--add-opens` / `--add-exports` flags and a classpath spanning all three modules; `mvn exec:java` will not work because it runs in Maven's own classloader and fails to find -Reflections. Build a classpath and fork a JVM: +Reflections. Build a classpath and fork a JVM. + +The classpath has to be built in the same reactor run as the compile. Running +`dependency:build-classpath` on a single module fails with a `DependencyResolutionException`, +because `diylc-processor` is not installed in the local repository and the plugin can only resolve +it from the reactor. The relative `outputFile` gives each module its own `target/cp.txt`: ```bash cd diylc -SP=/tmp # or the session scratchpad -mvn -q -pl diylc-swing -am test-compile -mvn -q -pl diylc-swing dependency:build-classpath -Dmdep.outputFile=$SP/cp-swing.txt -Dmdep.includeScope=test -mvn -q -pl diylc-core dependency:build-classpath -Dmdep.outputFile=$SP/cp-core.txt -Dmdep.includeScope=test -mvn -q -pl diylc-library dependency:build-classpath -Dmdep.outputFile=$SP/cp-lib.txt -Dmdep.includeScope=test +mvn -q -pl diylc-swing -am test-compile dependency:build-classpath \ + -Dmdep.outputFile=target/cp.txt -Dmdep.includeScope=test java \ --add-exports java.desktop/com.apple.eawt.event=ALL-UNNAMED \ @@ -49,19 +51,20 @@ java \ --add-opens java.desktop/java.awt=ALL-UNNAMED \ --add-opens java.desktop/java.awt.font=ALL-UNNAMED \ --add-opens java.desktop/java.awt.geom=ALL-UNNAMED \ - -cp "diylc-swing/target/test-classes:diylc-swing/target/classes:diylc-core/target/classes:diylc-library/target/classes:$(cat $SP/cp-swing.txt):$(cat $SP/cp-core.txt):$(cat $SP/cp-lib.txt)" \ + -cp "diylc-swing/target/test-classes:diylc-swing/target/classes:diylc-core/target/classes:diylc-library/target/classes:$(cat diylc-swing/target/cp.txt):$(cat diylc-core/target/cp.txt):$(cat diylc-library/target/cp.txt)" \ org.diylc.RegressionTestRunner \ "$(cd .. && pwd)/diylc-regression-data" TEST [filter] ``` -Sanity-check the log line `Presenter - Current DIYLC version: 6.4.0` near the end. If it reports -`5.0.0`, the `--add-opens` flags are missing and the run is invalid. +Sanity-check the log line `Presenter - Current DIYLC version: ...`. It should report the newest +version in `diylc-core/src/main/resources/update.xml`; if it reports `5.0.0`, the `--add-opens` +flags are missing and the run is invalid. ### Arguments 1. Path to `diylc-regression-data` 2. `TEST` or `PREPARE` -3. Optional filter — a substring matched against file paths. **Use it.** The full suite is ~700 +3. Optional filter — a substring matched against file paths. The full suite is just under 100 projects; while narrowing down an issue, run one file or one directory at a time (e.g. `splitter2`, or `guitar`).