'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

This commit is contained in:
Bane Stojkovic
2026-11-11 10:21:44 +01:00
parent 9f6124f719
commit a6329c45bd
6 changed files with 168 additions and 24 deletions
@@ -312,7 +312,8 @@ public class ChatbotService {
return null; return null;
} }
DrawingManager drawingManager = plugInPort.getDrawingManager(); DrawingManager drawingManager = plugInPort.getDrawingManager();
List<ContinuityArea> continuityAreas = drawingManager.getContinuityAreas(); List<ContinuityArea> continuityAreas =
drawingManager.getContinuityAreas(plugInPort.getCurrentProject());
return AiProjectBuilder.build(plugInPort.getCurrentProject(), continuityAreas, component -> { return AiProjectBuilder.build(plugInPort.getCurrentProject(), continuityAreas, component -> {
ComponentArea area = drawingManager.getComponentArea(component); ComponentArea area = drawingManager.getComponentArea(component);
return area == null ? null : area.getOutlineBounds(); return area == null ? null : area.getOutlineBounds();
@@ -133,7 +133,10 @@ public class DrawingCache {
.extractComponentTypeFrom((Class<? extends IDIYComponent<?>>) component.getClass()); .extractComponentTypeFrom((Class<? extends IDIYComponent<?>>) component.getClass());
CacheValue value = null; 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() if (type != null && type.getEnableCache()
&& (visibleRect == null || visibleRect.intersects(component.getCachingBounds()))
&& shouldCache(component, componentState, zoom, scaleFactor)) { && shouldCache(component, componentState, zoom, scaleFactor)) {
value = renderAndCache(component, g2d, componentState, outlineMode, project, zoom, value = renderAndCache(component, g2d, componentState, outlineMode, project, zoom,
scaleFactor, zOrder, drawOptions, false); scaleFactor, zOrder, drawOptions, false);
@@ -32,6 +32,7 @@ import java.awt.Stroke;
import java.awt.geom.AffineTransform; import java.awt.geom.AffineTransform;
import java.awt.geom.Area; import java.awt.geom.Area;
import java.awt.geom.Ellipse2D; import java.awt.geom.Ellipse2D;
import java.awt.geom.NoninvertibleTransformException;
import java.awt.geom.Point2D; import java.awt.geom.Point2D;
import java.awt.geom.Rectangle2D; import java.awt.geom.Rectangle2D;
import java.util.ArrayList; import java.util.ArrayList;
@@ -234,7 +235,9 @@ public class DrawingManager {
g2dWrapper.setColor(theme.getBgColor()); g2dWrapper.setColor(theme.getBgColor());
g2dWrapper.fillRect(0, 0, canvasDimension.width, canvasDimension.height); 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(); Rectangle2D extraSpaceRect = new Rectangle2D.Double();
AffineTransform extraSpaceTx = new AffineTransform(); AffineTransform extraSpaceTx = new AffineTransform();
@@ -253,6 +256,11 @@ public class DrawingManager {
visibleRect.getWidth() / zoom, visibleRect.getHeight() / zoom); 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 mainComposite = g2d.getComposite();
// Composite alphaComposite = // Composite alphaComposite =
// AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 0.5f); // AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 0.5f);
@@ -304,10 +312,15 @@ public class DrawingManager {
synchronized (this) { synchronized (this) {
g2dWrapper.startedDrawingComponent(i); g2dWrapper.startedDrawingComponent(i);
Shape visibleClip = null;
if (!trackArea) { if (!trackArea) {
g2dWrapper.stopTracking(); g2dWrapper.stopTracking();
} else { } else {
g2dWrapper.startRecordingDrawnBounds(); g2dWrapper.startRecordingDrawnBounds();
if (recordingClip != null) {
visibleClip = g2d.getClip();
g2d.setClip(recordingClip);
}
} }
Composite oldComposite = g2d.getComposite(); Composite oldComposite = g2d.getComposite();
Font oldFont = g2d.getFont(); Font oldFont = g2d.getFont();
@@ -346,9 +359,15 @@ public class DrawingManager {
} }
} }
ComponentArea area = g2dWrapper.finishedDrawingComponent(); 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()) { if (trackArea && area != null && area.hasOutline()) {
componentAreaMap.put(component, area); componentAreaMap.put(component, area);
lastDrawnStateMap.put(component, state); lastDrawnStateMap.put(component, state);
// the graph was built from the areas this one replaces
continuityGraphCache = null;
} }
try { try {
@@ -641,6 +660,18 @@ public class DrawingManager {
bounds.getHeight() + 2 * OFF_SCREEN_MARGIN); 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<DrawOption> drawOptions) { private void configureRenderingHints(Graphics2D g2d, Set<DrawOption> drawOptions) {
if (drawOptions.contains(DrawOption.ANTIALIASING)) { if (drawOptions.contains(DrawOption.ANTIALIASING)) {
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
@@ -805,26 +836,35 @@ public class DrawingManager {
messageDispatcher.dispatchMessage(EventType.REPAINT); messageDispatcher.dispatchMessage(EventType.REPAINT);
} }
public void findContinuityAreaAtPoint(Point2D p) { public void findContinuityAreaAtPoint(Project project, Point2D p) {
if (continuityGraphCache == null) if (continuityGraphCache == null) {
continuityGraphCache = getContinuityGraph(); continuityGraphCache = getContinuityGraph(project);
}
currentContinuityAreas = continuityGraphCache.findAreasFor(p); currentContinuityAreas = continuityGraphCache.findAreasFor(p);
} }
public List<ContinuityArea> 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<ContinuityArea> getContinuityAreas(Project project) {
// Find all individual continuity areas for all components // Find all individual continuity areas for all components
List<ContinuityArea> preliminaryAreas = new ArrayList<ContinuityArea>(); List<ContinuityArea> preliminaryAreas = new ArrayList<ContinuityArea>();
List<Boolean> checkBreakout = new ArrayList<Boolean>(); List<Boolean> checkBreakout = new ArrayList<Boolean>();
for (Map.Entry<IDIYComponent<?>, ComponentArea> entry : this.componentAreaMap.entrySet()) { // in z-order rather than the order the areas were recorded in, which changes every time a
ComponentArea a = entry.getValue(); // 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; continue;
}
IDIYComponent<?> component = entry.getKey();
int layerId; int layerId;
if (component instanceof ILayeredComponent) { if (component instanceof ILayeredComponent) {
@@ -877,10 +917,13 @@ public class DrawingManager {
return areas; return areas;
} }
public ContinuityGraph getContinuityGraph() { public ContinuityGraph getContinuityGraph(Project project) {
Set<Connection> connections = new HashSet<Connection>(); Set<Connection> connections = new HashSet<Connection>();
int z = 0; int z = 0;
for (IDIYComponent<?> c : this.componentAreaMap.keySet()) { for (IDIYComponent<?> c : project.getComponents()) {
if (!componentAreaMap.containsKey(c)) {
continue;
}
if (c instanceof IContinuity) { if (c instanceof IContinuity) {
for (int i = 0; i < c.getControlPointCount() - 1; i++) for (int i = 0; i < c.getControlPointCount() - 1; i++)
for (int j = i + 1; j < c.getControlPointCount(); j++) for (int j = i + 1; j < c.getControlPointCount(); j++)
@@ -890,13 +933,13 @@ public class DrawingManager {
z++; z++;
} }
List<ContinuityArea> areas = getContinuityAreas(); List<ContinuityArea> areas = getContinuityAreas(project);
return NetlistBuilder.buildContinuityGraph(areas, connections); return NetlistBuilder.buildContinuityGraph(areas, connections);
} }
public List<Area> getContinuityAreaProximity(float threshold) { public List<Area> getContinuityAreaProximity(Project project, float threshold) {
List<ContinuityArea> continuityAreas = getContinuityAreas(); List<ContinuityArea> continuityAreas = getContinuityAreas(project);
Stroke s = ObjectCache.getInstance().fetchBasicStroke(threshold - 1); // value eyeballed for Stroke s = ObjectCache.getInstance().fetchBasicStroke(threshold - 1); // value eyeballed for
// approx good results // approx good results
@@ -685,7 +685,7 @@ public class Presenter implements IPlugInPort {
LOG.error("Unknown creation method: " + componentTypeSlot.getCreationMethod()); LOG.error("Unknown creation method: " + componentTypeSlot.getCreationMethod());
} }
} else if (operationMode == OperationMode.HIGHLIGHT_CONNECTED_AREAS || altDown) { } else if (operationMode == OperationMode.HIGHLIGHT_CONNECTED_AREAS || altDown) {
drawingManager.findContinuityAreaAtPoint(scaledPoint); drawingManager.findContinuityAreaAtPoint(currentProject, scaledPoint);
messageDispatcher.dispatchMessage(EventType.REPAINT); messageDispatcher.dispatchMessage(EventType.REPAINT);
} else { } else {
List<IDIYComponent<?>> newSelection = new ArrayList<IDIYComponent<?>>(selectedComponents); List<IDIYComponent<?>> newSelection = new ArrayList<IDIYComponent<?>>(selectedComponents);
@@ -3188,7 +3188,7 @@ public class Presenter implements IPlugInPort {
@Override @Override
public List<Netlist> extractNetlists(boolean includeSwitches) throws NetlistException { public List<Netlist> extractNetlists(boolean includeSwitches) throws NetlistException {
List<ContinuityArea> continuityAreas = drawingManager.getContinuityAreas(); List<ContinuityArea> continuityAreas = drawingManager.getContinuityAreas(currentProject);
return NetlistBuilder.extractNetlists(includeSwitches, currentProject, continuityAreas); return NetlistBuilder.extractNetlists(includeSwitches, currentProject, continuityAreas);
} }
@@ -3246,12 +3246,13 @@ public class Presenter implements IPlugInPort {
areas.put(c, componentArea); areas.put(c, componentArea);
} }
return new Snapshot(currentProject.clone(), new HashSet<IDIYComponent<?>>(selectedComponents), areas, return new Snapshot(currentProject.clone(), new HashSet<IDIYComponent<?>>(selectedComponents), areas,
new HashSet<ContinuityArea>(drawingManager.getContinuityAreas())); new HashSet<ContinuityArea>(drawingManager.getContinuityAreas(currentProject)));
} }
@Override @Override
public List<Area> checkContinuityAreaProximity(Size threshold) { public List<Area> checkContinuityAreaProximity(Size threshold) {
return drawingManager.getContinuityAreaProximity((float)threshold.convertToPixels()); return drawingManager.getContinuityAreaProximity(currentProject,
(float) threshold.convertToPixels());
} }
@Override @Override
@@ -641,6 +641,10 @@
<changeType>BUG_FIX</changeType> <changeType>BUG_FIX</changeType>
<description>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</description> <description>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</description>
</org.diylc.appframework.update.Change> </org.diylc.appframework.update.Change>
<org.diylc.appframework.update.Change>
<changeType>BUG_FIX</changeType>
<description>'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</description>
</org.diylc.appframework.update.Change>
</changes> </changes>
<url>https://github.com/bancika/diy-layout-creator/releases</url> <url>https://github.com/bancika/diy-layout-creator/releases</url>
</org.diylc.appframework.update.Version> </org.diylc.appframework.update.Version>
@@ -445,7 +445,7 @@ public class DrawingManagerTest {
drawCachedAndClipped(drawingManager, project, 100); drawCachedAndClipped(drawingManager, project, 100);
// the netlist and continuity checks are built from the areas recorded while painting // 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); assertEquals(1, component.drawCount);
drawCachedAndClipped(drawingManager, project, 100); drawCachedAndClipped(drawingManager, project, 100);
@@ -639,7 +639,7 @@ public class DrawingManagerTest {
drawVisibleRect(drawingManager, project, null); drawVisibleRect(drawingManager, project, null);
// the cut splits the strip in two for the netlist... // 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 // ...but the strip's own recorded area must stay whole, so that moving or deleting the cut
// does not leave a hole behind // does not leave a hole behind
Area recorded = Area recorded =
@@ -647,6 +647,66 @@ public class DrawingManagerTest {
assertTrue(recorded.contains(50, 5)); 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, private void drawVisibleRect(DrawingManager drawingManager, Project project,
Rectangle2D visibleRect) { Rectangle2D visibleRect) {
BufferedImage img = new BufferedImage(1000, 1000, BufferedImage.TYPE_INT_ARGB); 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 // Simple delegating Graphics2D proxy for testing
private static class Graphics2DProxy extends Graphics2D { private static class Graphics2DProxy extends Graphics2D {
private final Graphics2D delegate; private final Graphics2D delegate;