Render optimization (#1087)

* Caching and culling improvements

* doc

* Update canvas-rendering-cache-and-culling.md

* caching for wires

* cache invalidation improvements

* caching improvements

* twisted wire performance update

* Improved grid rendering on screen and when exporting; PDF export of the grid is now vector-based and does not get blurry on zooming in

* area append improvement

* bug fix for subtracting trace cuts

* Twisted wire stroke render improvement

* various performance improvements

* lazy area merging

* regression update

* Update update.xml

* code cleanup

* Twisted wire border thickness fix
This commit is contained in:
Bane Stojkovic
2026-10-14 21:15:40 +02:00
committed by GitHub
parent 251b945e6d
commit a8d8a9b409
41 changed files with 2210 additions and 379 deletions
@@ -1,11 +1,25 @@
Switch setup: [ ALL ]
2K2.+ <-> 2K2.- <-> 2K2.- <-> C4.+ <-> C5.+ <-> C6.- <-> C7.- <-> D2.A <-> D3.A <-> D3.K <-> D4.A <-> J1.Tip <-> J2.Tip <-> J3.Tip <-> LM324.1 <-> LM324.10 <-> LM324.12 <-> LM324.3 <-> LM324.5 <-> LM324.8 <-> Q1.1 <-> Q2.1 <-> Q2.2 <-> R1.2 <-> R3.2 <-> R3.2 <-> R5.2 <-> VR1.3 <-> VR2.2 <-> VR2.3
2K2.+ <-> C4.- <-> C5.- <-> C6.+ <-> C7.+ <-> D1.K <-> D4.K <-> J3.Sleeve <-> LM324.11 <-> LM324.4 <-> Q1.2 <-> Q1.3 <-> Q2.3 <-> R5.1 <-> VR1.1
C1.+ <-> C1.- <-> J1.Sleeve <-> LM324.2 <-> LM324.9 <-> R1.1 <-> R1.1 <-> R1.2 <-> R3.1 <-> R3.1 <-> R4.1
C2.+ <-> C2.- <-> J2.Sleeve <-> LM324.13 <-> LM324.6 <-> R1.1 <-> R2.1 <-> R2.2
C3.+ <-> LM324.14 <-> LM324.7 <-> R1.2
2K2.+ <-> D1.K <-> D4.K <-> Q1.2
2K2.+ <-> D2.A <-> D3.A <-> Q2.2
2K2.- <-> 2K2.- <-> C6.- <-> C7.- <-> J1.Tip <-> J2.Tip <-> J3.Tip <-> LM324.10 <-> LM324.3 <-> LM324.5 <-> Q1.1 <-> Q2.1 <-> R5.2 <-> VR1.3 <-> VR2.3
C1.+ <-> J1.Sleeve
C1.- <-> R1.1
C2.+ <-> J2.Sleeve
C2.- <-> R2.1
C3.+ <-> LM324.7 <-> R1.2
C3.- <-> VR2.1
C4.+ <-> LM324.1 <-> R1.2
C4.- <-> VR1.1
C5.+ <-> LM324.8 <-> R3.2
C5.- <-> J3.Sleeve <-> R5.1
C6.+ <-> LM324.11 <-> Q2.3
C7.+ <-> LM324.4 <-> Q1.3
D1.A <-> D2.K
D3.K <-> D4.A
LM324.2 <-> R1.1 <-> R1.2
LM324.6 <-> R1.1 <-> R2.2
LM324.9 <-> R3.1 <-> R3.1 <-> R4.1
R3.2 <-> VR2.2
R4.2 <-> VR1.2
@@ -6,7 +6,7 @@ Switch setup: [ ALL ]
.2 <-> J1.2 <-> R2.1
1,5nf.1 <-> TREBLE.1
1,5nf.2 <-> 100K.2 <-> 68nf.1
100K 2Watt.1 <-> 100K 2watt.1 <-> 32uF.+ <-> 32uF.-
100K 2watt.1 <-> 100K 2Watt.1 <-> 32uF.+ <-> 32uF.-
100K 2Watt.2 <-> 1K.2 <-> 1K.2 <-> 1K.2 <-> 1K.2 <-> 32uF.+ <-> 33K.2
100K 2watt.2 <-> 32uF.-
100K.1 <-> 100K.1 <-> 1nF.1 <-> 33K.1 <-> 33K.1
@@ -35,7 +35,7 @@ Switch setup: [ ALL ]
220K.1 <-> 220K.1 <-> 33K.2
220K.2 <-> 68nf.2
220K.2 <-> 68nf.2
22uF 63v.+ <-> 22uf 63v.+ <-> D1.A <-> D1.K <-> D2.K <-> D3.K <-> GND <-> TREBLE.2 <-> TREBLE.3
22uf 63v.+ <-> 22uF 63v.+ <-> D1.A <-> D1.K <-> D2.K <-> D3.K <-> GND <-> TREBLE.2 <-> TREBLE.3
2K4.1 <-> 2K4.1 <-> 68nF.1
4,7nf.1 <-> 4,7nf.2
4,7nf.2 <-> 68nf.1
@@ -6,7 +6,7 @@ Switch setup: [ ALL ]
.2 <-> J1.2 <-> R2.1
1,5nf.1 <-> TREBLE.1
1,5nf.2 <-> 100K.2 <-> 68nf.1
100K 2Watt.1 <-> 100K 2watt.1 <-> 32uF.+ <-> 32uF.-
100K 2watt.1 <-> 100K 2Watt.1 <-> 32uF.+ <-> 32uF.-
100K 2Watt.2 <-> 1K.2 <-> 1K.2 <-> 1K.2 <-> 1K.2 <-> 32uF.+ <-> 33K.2
100K 2watt.2 <-> 32uF.-
100K.1 <-> 100K.1 <-> 1nF.1 <-> 33K.1 <-> 33K.1
@@ -35,7 +35,7 @@ Switch setup: [ ALL ]
220K.1 <-> 220K.1 <-> 33K.2
220K.2 <-> 68nf.2
220K.2 <-> 68nf.2
22uF 63v.+ <-> 22uf 63v.+ <-> D1.A <-> D1.K <-> D2.K <-> D3.K <-> GND <-> TREBLE.2 <-> TREBLE.3
22uf 63v.+ <-> 22uF 63v.+ <-> D1.A <-> D1.K <-> D2.K <-> D3.K <-> GND <-> TREBLE.2 <-> TREBLE.3
2K4.1 <-> 2K4.1 <-> 68nF.1
4,7nf.1 <-> 4,7nf.2
4,7nf.2 <-> 68nf.1
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 409 KiB

After

Width:  |  Height:  |  Size: 405 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1002 KiB

After

Width:  |  Height:  |  Size: 997 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 MiB

After

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 866 KiB

After

Width:  |  Height:  |  Size: 868 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 MiB

After

Width:  |  Height:  |  Size: 3.8 MiB

@@ -30,6 +30,33 @@ import java.util.Set;
public class AreaUtils {
/**
* Unions the areas pairwise, rather than adding each one to a single growing area, which would
* make every addition cost as much as everything added before it. The areas in the list are
* changed in the process.
*
* @param areas
* @return the union, or an empty area for an empty list
*/
public static Area union(List<Area> areas) {
if (areas.isEmpty()) {
return new Area();
}
List<Area> current = areas;
while (current.size() > 1) {
List<Area> next = new ArrayList<Area>((current.size() + 1) / 2);
for (int i = 0; i < current.size(); i += 2) {
Area area = current.get(i);
if (i + 1 < current.size()) {
area.add(current.get(i + 1));
}
next.add(area);
}
current = next;
}
return current.get(0);
}
/**
* Merges all areas that either overlap or are joined by connections.
*
@@ -21,43 +21,120 @@
*/
package org.diylc.presenter;
import java.awt.Shape;
import java.awt.geom.Area;
import java.awt.geom.Rectangle2D;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
/**
* What a component drew while its areas were being recorded: the shapes making up its outline and
* its continuity areas, and the bounds of everything it drew. The shapes are merged into areas only
* the first time an area is asked for, because recording happens on every drag step for the
* components being moved, while the merged areas are needed far less often.
*/
public class ComponentArea implements Serializable {
private static final long serialVersionUID = 1L;
private int zOrder;
private Area outlineArea;
private Collection<Area> continuityPositiveAreas;
private Collection<Area> continuityNegativeAreas;
private List<Shape> outlineParts;
// one list per continuity area; its shapes are merged into that area
private Collection<List<Shape>> continuityPositiveParts;
private Collection<List<Shape>> continuityNegativeParts;
// everything the component drew, including parts left out of the outline area such as labels;
// null when it was not recorded
private Rectangle2D drawnBounds;
private transient Rectangle2D outlineBounds;
private transient Area outlineArea;
private transient Collection<Area> continuityPositiveAreas;
private transient Collection<Area> continuityNegativeAreas;
public ComponentArea() {
}
public ComponentArea(int zOrder, Area outlineArea, Collection<Area> continuityPositiveAreas, Collection<Area> continuityNegativeAreas) {
public ComponentArea(int zOrder, List<Shape> outlineParts,
Collection<List<Shape>> continuityPositiveParts,
Collection<List<Shape>> continuityNegativeParts, Rectangle2D drawnBounds) {
super();
this.zOrder = zOrder;
this.outlineArea = outlineArea;
this.continuityPositiveAreas = continuityPositiveAreas;
this.continuityNegativeAreas = continuityNegativeAreas;
this.outlineParts = outlineParts;
this.continuityPositiveParts = continuityPositiveParts;
this.continuityNegativeParts = continuityNegativeParts;
this.drawnBounds = drawnBounds;
}
public int getZOrder() {
return zOrder;
}
public Area getOutlineArea() {
public boolean hasOutline() {
return !outlineParts.isEmpty();
}
/**
* @return bounds of the outline area, worked out from the recorded shapes without merging them.
* A new rectangle on every call.
*/
public synchronized Rectangle2D getOutlineBounds() {
// kept, because the cursor check asks every component on every mouse move
if (outlineBounds == null) {
Rectangle2D bounds = null;
for (Shape part : outlineParts) {
if (bounds == null) {
bounds = part.getBounds2D();
} else {
bounds.add(part.getBounds2D());
}
}
outlineBounds = bounds == null ? new Rectangle2D.Double() : bounds;
}
return (Rectangle2D) outlineBounds.clone();
}
// synchronized because the netlist may be built away from the EDT
public synchronized Area getOutlineArea() {
if (outlineArea == null) {
outlineArea = merge(outlineParts);
}
return outlineArea;
}
public Collection<Area> getContinuityPositiveAreas() {
public synchronized Collection<Area> getContinuityPositiveAreas() {
if (continuityPositiveAreas == null) {
continuityPositiveAreas = mergeEach(continuityPositiveParts);
}
return continuityPositiveAreas;
}
public Collection<Area> getContinuityNegativeAreas() {
public synchronized Collection<Area> getContinuityNegativeAreas() {
if (continuityNegativeAreas == null) {
continuityNegativeAreas = mergeEach(continuityNegativeParts);
}
return continuityNegativeAreas;
}
}
public Rectangle2D getDrawnBounds() {
return drawnBounds;
}
private static Area merge(List<Shape> parts) {
List<Area> areas = new ArrayList<Area>(parts.size());
for (Shape part : parts) {
// a new Area each time, because merging changes the areas it is given
areas.add(new Area(part));
}
return AreaUtils.union(areas);
}
private static Collection<Area> mergeEach(Collection<List<Shape>> parts) {
List<Area> areas = new ArrayList<Area>(parts.size());
for (List<Shape> area : parts) {
areas.add(merge(area));
}
return areas;
}
}
@@ -32,7 +32,7 @@ public class Counter {
return nanotime;
}
public void add(long nanotime) {
public synchronized void add(long nanotime) {
count++;
this.nanotime += nanotime;
}
@@ -18,15 +18,18 @@
package org.diylc.presenter;
import java.awt.Graphics2D;
import java.awt.geom.Area;
import java.awt.Shape;
import java.awt.geom.Point2D;
import java.awt.geom.Rectangle2D;
import java.awt.image.BufferedImage;
import java.lang.ref.SoftReference;
import java.util.Collection;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import java.util.stream.Collectors;
import org.apache.log4j.Logger;
@@ -43,19 +46,48 @@ import org.diylc.utils.ScaledBufferedImage;
/**
* A layer that injects itself between the provided {@link G2DWrapper} instance and the underlying
* {@link Graphics2D} instance. For all components that are enabled for caching, it stores a
* {@link BufferedImage} representing the component. The state of the component is recorded and
* compared next time it needs to be rendered to make sure that the cache is up to date.
* {@link BufferedImage} representing the component. The cache does not detect changes to the
* component itself: whoever changes a component must {@link #invalidate(IDIYComponent)} it. Zoom,
* component state and draw options are recorded and compared each time.
*
* @author bancika
*/
public class DrawingCache {
private Map<IDIYComponent<?>, SoftReference<CacheValue>> imageCache = new HashMap<IDIYComponent<?>, SoftReference<CacheValue>>();
private Map<String, Pair<Counter, Counter>> renderStatsByType = new HashMap<String, Pair<Counter, Counter>>();
// access-ordered, so iteration starts from the least recently used entry; guarded by itself
// because bulkPrepare renders from multiple threads
private final Map<IDIYComponent<?>, CacheValue> imageCache =
new LinkedHashMap<IDIYComponent<?>, CacheValue>(16, 0.75f, true);
private long imageCacheBytes = 0;
// lookups made by bulkPrepare, reported by logStats
private final AtomicLong cacheHits = new AtomicLong();
private final AtomicLong cacheMissesNotCached = new AtomicLong();
private final AtomicLong cacheMissesOutdated = new AtomicLong();
// entries that bulkPrepare rendered but were evicted again before the same repaint drew them,
// a sign that the visible components alone exceed the budget
private final AtomicLong rerenderedWhilePainting = new AtomicLong();
private final Map<String, Pair<Counter, Counter>> renderStatsByType =
new ConcurrentHashMap<String, Pair<Counter, Counter>>();
// when set, each entry keeps a copy of its component and every cache hit is checked against it,
// to find code paths that change a component without invalidating it
private final boolean verifyCache = "true".equalsIgnoreCase(System.getProperty(VERIFY_CACHE));
private static final Logger LOG = Logger.getLogger(DrawingCache.class);
// evicted images are released straight away rather than lingering behind soft references until
// the heap is nearly full, which lets G1 reclaim the large pixel arrays without a full GC
private static final long MAX_CACHE_BYTES =
Math.min(Runtime.getRuntime().maxMemory() / 4, 768L * 1024 * 1024);
// 32 MB per image; a large board at high zoom on a HiDPI screen would otherwise take a big share
// of the budget on its own
private static final long MAX_CACHED_IMAGE_PIXELS = 8L * 1024 * 1024;
public static String VERIFY_CACHE = "org.diylc.verifyDrawingCache";
public static DrawingCache Instance = new DrawingCache();
/**
@@ -75,8 +107,10 @@ public class DrawingCache {
ComponentType type = ComponentProcessor.getInstance()
.extractComponentTypeFrom((Class<? extends IDIYComponent<?>>) x.getComponent().getClass());
if (type != null && type.getEnableCache()) {
renderAndCache(x.getComponent(), g2d, x.getState(), outlineMode, project, zoom, scaleFactor, x.getZOrder(), drawOptions);
if (type != null && type.getEnableCache()
&& shouldCache(x.getComponent(), x.getState(), zoom, scaleFactor)) {
renderAndCache(x.getComponent(), g2d, x.getState(), outlineMode, project, zoom, scaleFactor,
x.getZOrder(), drawOptions, true);
}
});
}
@@ -98,16 +132,16 @@ public class DrawingCache {
ComponentType type = ComponentProcessor.getInstance()
.extractComponentTypeFrom((Class<? extends IDIYComponent<?>>) component.getClass());
if (type != null && type.getEnableCache()) {
// if we need to apply caching
CacheValue value = null;
if (type != null && type.getEnableCache()
&& shouldCache(component, componentState, zoom, scaleFactor)) {
value = renderAndCache(component, g2d, componentState, outlineMode, project, zoom,
scaleFactor, zOrder, drawOptions, false);
}
if (value != null) {
Point2D firstPoint = component.getControlPoint(0);
CacheValue value = renderAndCache(component, g2d, componentState, outlineMode, project, zoom, scaleFactor, zOrder, drawOptions);
if (value == null) {
return;
}
long componentStart = System.nanoTime();
// only render the cached image if it's within the clipping region
@@ -129,21 +163,17 @@ public class DrawingCache {
}
long componentEnd = System.nanoTime();
Pair<Counter, Counter> stats;
String key = component.getClass().getCanonicalName().replace("org.diylc.components.", "");
if (renderStatsByType.containsKey(key)) {
stats = renderStatsByType.get(key);
} else {
stats = new Pair<Counter, Counter>(new Counter(), new Counter());
renderStatsByType.put(key, stats);
}
stats.getSecond().add(componentEnd - componentStart);
getRenderStats(component).getSecond().add(componentEnd - componentStart);
// copy over tracking area from the cache, regardless if we rendered or not
g2d.merge(value.getCurrentArea(), value.getContinuityPositiveAreas(), value.getContinuityNegativeAreas());
g2d.merge(value.getOutlineParts(), value.getContinuityPositiveParts(),
value.getContinuityNegativeParts());
g2d.mergeDrawnBounds(value.getCacheBounds());
} else {
// no caching, just draw as usual
// either not cached, or nothing was rendered because the component is outside the clip;
// drawing it directly still records its areas, which the netlist and continuity checks read,
// while Java2D discards the pixels
component.draw(g2d, componentState, outlineMode, project, g2d);
}
g2d.stopTracking();
@@ -151,18 +181,20 @@ public class DrawingCache {
}
public void logStats() {
int totalSizeMB = imageCache.values()
.stream()
.map(x -> x.get() == null ? 0 : x.get().image.getData().getDataBuffer().getSize())
.reduce(0, Integer::sum) * 4 / 1024 / 1024; // 4 bytes per pixel
LOG.debug(String.format("Render cache contains %d elements, approx size is %d MB", imageCache.size(), totalSizeMB));
long evictedCount = imageCache.values()
.stream()
.filter(x -> x.get() == null)
.count();
LOG.debug(String.format("Render cache contains %d evicted entries", evictedCount));
synchronized (imageCache) {
LOG.debug(String.format("Render cache contains %d elements, approx size is %d MB of %d MB",
imageCache.size(), imageCacheBytes / 1024 / 1024, MAX_CACHE_BYTES / 1024 / 1024));
}
long hits = cacheHits.get();
long missesNotCached = cacheMissesNotCached.get();
long missesOutdated = cacheMissesOutdated.get();
long lookups = hits + missesNotCached + missesOutdated;
LOG.debug(String.format("Render cache hits: %d, misses: %d not cached + %d outdated, "
+ "hit rate: %.1f%%, re-rendered while painting: %d",
hits, missesNotCached, missesOutdated, lookups == 0 ? 0d : 100d * hits / lookups,
rerenderedWhilePainting.get()));
String mapAsString = renderStatsByType.entrySet().stream().sorted((e1, e2) -> -Long.compare(e1.getValue().getFirst().getNanoTime() + e1.getValue().getSecond().getNanoTime(),
e2.getValue().getFirst().getNanoTime() + e2.getValue().getSecond().getNanoTime()))
.map(e -> e.getKey() + ": render: " + e.getValue().getFirst() + ", paste: " + e.getValue().getSecond())
@@ -172,17 +204,82 @@ public class DrawingCache {
}
public void clear() {
imageCache.clear();
}
public void invalidate(Collection<IDIYComponent<?>> components) {
for (IDIYComponent<?> component : components) {
imageCache.remove(component);
synchronized (imageCache) {
for (CacheValue value : imageCache.values()) {
value.getImage().flush();
}
imageCache.clear();
imageCacheBytes = 0;
}
}
public void invalidate(Collection<IDIYComponent<?>> components) {
synchronized (imageCache) {
for (IDIYComponent<?> component : components) {
release(imageCache.remove(component));
}
}
}
public void invalidate(IDIYComponent<?> component) {
synchronized (imageCache) {
release(imageCache.remove(component));
}
}
private CacheValue getCachedValue(IDIYComponent<?> component) {
synchronized (imageCache) {
return imageCache.get(component);
}
}
private void putCachedValue(IDIYComponent<?> component, CacheValue value) {
synchronized (imageCache) {
release(imageCache.put(component, value));
imageCacheBytes += value.getSizeInBytes();
// the entry just added is the most recently used one, so it is never evicted here, even if
// it alone exceeds the budget
Iterator<CacheValue> iterator = imageCache.values().iterator();
while (imageCacheBytes > MAX_CACHE_BYTES && imageCache.size() > 1) {
CacheValue eldest = iterator.next();
iterator.remove();
release(eldest);
}
}
}
// must be called while holding the imageCache lock
private void release(CacheValue value) {
if (value == null) {
return;
}
imageCacheBytes -= value.getSizeInBytes();
// drops the accelerated (VRAM / native) copy Java2D keeps for images drawn repeatedly, which
// would otherwise live until the image is finalized
value.getImage().flush();
}
private boolean shouldCache(IDIYComponent<?> component, ComponentState componentState,
double zoom, double scaleFactor) {
// a dragged component changes on every frame, so its image would be discarded right after
// rendering while still pushing useful entries out of the cache
if (componentState == ComponentState.DRAGGING) {
return false;
}
Rectangle2D rect = component.getCachingBounds();
double pixelScale = zoom * scaleFactor;
return rect.getWidth() * pixelScale * rect.getHeight() * pixelScale <= MAX_CACHED_IMAGE_PIXELS;
}
private Pair<Counter, Counter> getRenderStats(IDIYComponent<?> component) {
String key = component.getClass().getCanonicalName().replace("org.diylc.components.", "");
return renderStatsByType.computeIfAbsent(key,
k -> new Pair<Counter, Counter>(new Counter(), new Counter()));
}
private CacheValue renderAndCache(IDIYComponent<?> component, G2DWrapper g2d, ComponentState componentState,
boolean outlineMode, Project project, double zoom, double scaleFactor, int zOrder, Set<DrawOption> drawOptions) {
boolean outlineMode, Project project, double zoom, double scaleFactor, int zOrder,
Set<DrawOption> drawOptions, boolean preparing) {
if (!g2d.getClipBounds().intersects(component.getCachingBounds())) {
return null;
@@ -192,20 +289,18 @@ public class DrawingCache {
Point2D firstPoint = component.getControlPoint(0);
// cache hit!
CacheValue value = null;
if (imageCache.containsKey(component)) {
SoftReference<CacheValue> weakReference = imageCache.get(component);
if (weakReference != null) {
value = weakReference.get();
}
}
CacheValue value = getCachedValue(component);
// only honor the cache if the component hasn't changed in the meantime
// Also check if drawOptions changed (e.g., MONOCHROMATIC affects rendering)
boolean drawOptionsChanged = value == null || !value.getDrawOptions().equals(drawOptions);
if (value == null || !value.getComponent().equalsTo(component)
|| value.getState() != componentState || value.getZoom() != zoom || value.getOutlineMode() != outlineMode
|| value.getScaleFactor() != scaleFactor || drawOptionsChanged) {
boolean stale = value == null || value.getState() != componentState || value.getZoom() != zoom
|| value.getOutlineMode() != outlineMode || value.getScaleFactor() != scaleFactor
|| !value.getDrawOptions().equals(drawOptions);
if (!stale && verifyCache && !value.getComponent().equalsTo(component)) {
LOG.warn("Cached drawing of " + component.getName() + " of type "
+ component.getClass().getName() + " was not invalidated after the component changed");
stale = true;
}
if (stale) {
// figure out size and placement of buffer image
Rectangle2D rect = component.getCachingBounds();
int width = (int)Math.round(rect.getWidth() * zoom);
@@ -217,6 +312,14 @@ public class DrawingCache {
if (width <= 0 || height <= 0) {
return null;
}
if (!preparing) {
rerenderedWhilePainting.incrementAndGet();
} else if (value == null) {
cacheMissesNotCached.incrementAndGet();
} else {
cacheMissesOutdated.incrementAndGet();
}
// create image
BufferedImage image = new ScaledBufferedImage(width, height, BufferedImage.TYPE_INT_ARGB, scaleFactor);
@@ -248,16 +351,8 @@ public class DrawingCache {
component.draw(wrapper, componentState, outlineMode, project, wrapper);
long componentEnd = System.nanoTime();
Pair<Counter, Counter> stats;
String key = component.getClass().getCanonicalName().replace("org.diylc.components.", "");
if (renderStatsByType.containsKey(key)) {
stats = renderStatsByType.get(key);
} else {
stats = new Pair<Counter, Counter>(new Counter(), new Counter());
renderStatsByType.put(key, stats);
}
stats.getFirst().add(componentEnd - componentStart);
getRenderStats(component).getFirst().add(componentEnd - componentStart);
// finalize wrapper
wrapper.finishedDrawingComponent();
@@ -273,25 +368,30 @@ public class DrawingCache {
// e.printStackTrace();
// }
if (wrapper.getCurrentArea() == null || wrapper.getCurrentArea().isEmpty()) {
if (wrapper.getOutlineParts().isEmpty()) {
LOG.warn("Empty outline area detected for " + component.getName());
}
// add to the cache
value = new CacheValue(component, image, wrapper.getCurrentArea(), wrapper.getContinuityPositiveAreas(),
wrapper.getContinuityNegativeAreas(), componentState, outlineMode, zoom, scaleFactor, dx, dy, rect, drawOptions);
imageCache.put(component, new SoftReference<DrawingCache.CacheValue>(value));
value = new CacheValue(component, image, wrapper.getOutlineParts(),
wrapper.getContinuityPositiveParts(), wrapper.getContinuityNegativeParts(),
componentState, outlineMode, zoom, scaleFactor, dx, dy, rect, drawOptions);
putCachedValue(component, value);
} else if (preparing) {
cacheHits.incrementAndGet();
}
return value;
}
private class CacheValue {
// only kept when verifyCache is set
IDIYComponent<?> component;
BufferedImage image;
Area currentArea;
Map<String, Area> continuityPositiveAreas;
Map<String, Area> continuityNegativeAreas;
// the shapes recorded while rendering, handed to the canvas wrapper on every paste
List<Shape> outlineParts;
Map<String, List<Shape>> continuityPositiveParts;
Map<String, List<Shape>> continuityNegativeParts;
ComponentState state;
boolean outlineMode;
double zoom;
@@ -301,21 +401,23 @@ public class DrawingCache {
Rectangle2D cacheBounds;
Set<DrawOption> drawOptions;
public CacheValue(IDIYComponent<?> component, BufferedImage image, Area currentArea,
Map<String, Area> continuityPositiveAreas, Map<String, Area> continuityNegativeAreas,
public CacheValue(IDIYComponent<?> component, BufferedImage image, List<Shape> outlineParts,
Map<String, List<Shape>> continuityPositiveParts,
Map<String, List<Shape>> continuityNegativeParts,
ComponentState state, boolean outlineMode, double zoom, double scaleFactor, int dx, int dy,
Rectangle2D cacheBounds, Set<DrawOption> drawOptions) {
super();
this.cacheBounds = cacheBounds;
// take a copy of the component, so we can check if it changed in the meantime
try {
this.component = component.clone();
} catch (CloneNotSupportedException e) {
if (verifyCache) {
try {
this.component = component.clone();
} catch (CloneNotSupportedException e) {
}
}
this.image = image;
this.currentArea = currentArea;
this.continuityPositiveAreas = continuityPositiveAreas;
this.continuityNegativeAreas = continuityNegativeAreas;
this.outlineParts = outlineParts;
this.continuityPositiveParts = continuityPositiveParts;
this.continuityNegativeParts = continuityNegativeParts;
this.state = state;
this.outlineMode = outlineMode;
this.zoom = zoom;
@@ -333,16 +435,16 @@ public class DrawingCache {
return image;
}
public Area getCurrentArea() {
return currentArea;
public List<Shape> getOutlineParts() {
return outlineParts;
}
public Map<String, Area> getContinuityPositiveAreas() {
return continuityPositiveAreas;
public Map<String, List<Shape>> getContinuityPositiveParts() {
return continuityPositiveParts;
}
public Map<String, Area> getContinuityNegativeAreas() {
return continuityNegativeAreas;
public Map<String, List<Shape>> getContinuityNegativeParts() {
return continuityNegativeParts;
}
public ComponentState getState() {
@@ -377,9 +479,14 @@ public class DrawingCache {
return drawOptions;
}
// TYPE_INT_ARGB, four bytes per pixel
public long getSizeInBytes() {
return (long) image.getWidth() * image.getHeight() * 4;
}
@Override
public String toString() {
return component.getName() + ":" + state + ":" + zoom;
return (component == null ? "" : component.getName() + ":") + state + ":" + zoom;
}
}
}
@@ -32,10 +32,8 @@ import java.awt.Stroke;
import java.awt.geom.AffineTransform;
import java.awt.geom.Area;
import java.awt.geom.Ellipse2D;
import java.awt.geom.Line2D;
import java.awt.geom.Point2D;
import java.awt.geom.Rectangle2D;
import java.awt.image.BufferedImage;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
@@ -76,6 +74,9 @@ public class DrawingManager {
private static boolean SHADE_EXTRA_SPACE = true;
// absorbs antialiasing and the whole-pixel placement of cached images around the drawn bounds
private static final double OFF_SCREEN_MARGIN = 4;
public static String DEBUG_COMPONENT_AREAS = "org.diylc.debugComponentAreas";
public static String DEBUG_CONTINUITY_AREAS = "org.diylc.debugContinuityAreas";
@@ -156,6 +157,9 @@ public class DrawingManager {
* @param controlPointSlot
* @param componentSlot
* @param dragInProgress
* @param movingComponents components whose control points the current drag moves, including
* those stuck to the dragged ones; they are drawn directly instead of through the cache,
* because they change on every frame
* @param externalZoom
* @param scaleFactor
* @param visibleRect
@@ -165,8 +169,9 @@ public class DrawingManager {
Set<DrawOption> drawOptions, IComponentFilter filter, Rectangle selectionRect,
Collection<IDIYComponent<?>> selectedComponents, Set<IDIYComponent<?>> lockedComponents,
Set<IDIYComponent<?>> groupedComponents, List<Point2D> controlPointSlot,
List<IDIYComponent<?>> componentSlot, boolean dragInProgress, Double externalZoom,
Double scaleFactor, Rectangle2D visibleRect) {
List<IDIYComponent<?>> componentSlot, boolean dragInProgress,
Collection<IDIYComponent<?>> movingComponents, Double externalZoom, Double scaleFactor,
Rectangle2D visibleRect) {
long totalStartTime = System.nanoTime();
failedComponents.clear();
if (project == null) {
@@ -237,8 +242,8 @@ public class DrawingManager {
// calculate size to be rendered
double extraSpace = getExtraSpace(project) * zoom;
drawGrid(project, g2d, zoom, extraSpace, canvasDimension, visibleRect, drawOptions,
extraSpaceRect, extraSpaceTx);
drawGridAndExtraSpace(project, g2d, zoom, extraSpace, canvasDimension, visibleRect,
drawOptions, extraSpaceRect, extraSpaceTx);
// apply zoom
if (Math.abs(1.0 - zoom) > 1e-4) {
@@ -268,7 +273,9 @@ public class DrawingManager {
}
}
componentStateMap.put(component, state);
componentForRenderList.add(new ComponentForRender(component, state, i));
if (!movingComponents.contains(component)) {
componentForRenderList.add(new ComponentForRender(component, state, i));
}
}
boolean outlineMode = drawOptions.contains(DrawOption.OUTLINE_MODE);
@@ -291,11 +298,16 @@ public class DrawingManager {
// Do not track the area if component is not invalidated and was
// drawn in the same state.
boolean trackArea = lastDrawnStateMap.get(component) != state;
// areas are only recorded while drawing, so a component is drawn regardless of visibility
// until they are
boolean offScreen = !trackArea && isOffScreen(component, visibleRect);
synchronized (this) {
g2dWrapper.startedDrawingComponent(i);
if (!trackArea) {
g2dWrapper.stopTracking();
} else {
g2dWrapper.startRecordingDrawnBounds();
}
Composite oldComposite = g2d.getComposite();
Font oldFont = g2d.getFont();
@@ -306,12 +318,14 @@ public class DrawingManager {
// Draw the component through the g2dWrapper.
long componentStart = System.nanoTime();
try {
if (!offScreen) {
drawComponent(project, drawOptions, scaleFactor, visibleRect, component, g2dWrapper,
state, zoom, i, outlineMode, !movingComponents.contains(component));
drawComponent(project, drawOptions, scaleFactor, visibleRect, component, g2dWrapper, state, zoom, i, outlineMode);
if (g2dWrapper.isTrackingContinuityArea()) {
LOG.info("Component " + component.getName() + " of type "
+ component.getClass().getName() + " did not stop tracking continuity area.");
if (g2dWrapper.isTrackingContinuityArea()) {
LOG.info("Component " + component.getName() + " of type "
+ component.getClass().getName() + " did not stop tracking continuity area.");
}
}
} catch (Exception e) {
LOG.error("Error drawing " + component.getName(), e);
@@ -324,19 +338,15 @@ public class DrawingManager {
g2d.setComposite(oldComposite);
g2d.setFont(oldFont);
// record render stats
long componentEnd = System.nanoTime();
Counter stats = null;
String key = component.getClass().getCanonicalName().replace("org.diylc.components.", "");
if (renderStatsByType.containsKey(key)) {
stats = renderStatsByType.get(key);
} else {
stats = new Counter();
renderStatsByType.put(key, stats);
if (!offScreen) {
String key =
component.getClass().getCanonicalName().replace("org.diylc.components.", "");
renderStatsByType.computeIfAbsent(key, k -> new Counter())
.add(System.nanoTime() - componentStart);
}
stats.add(componentEnd - componentStart);
}
ComponentArea area = g2dWrapper.finishedDrawingComponent();
if (trackArea && area != null && !area.getOutlineArea().isEmpty()) {
if (trackArea && area != null && area.hasOutline()) {
componentAreaMap.put(component, area);
lastDrawnStateMap.put(component, state);
}
@@ -608,14 +618,28 @@ public class DrawingManager {
private static void drawComponent(Project project, Set<DrawOption> drawOptions, Double scaleFactor,
Rectangle2D visibleRect, IDIYComponent<?> component, G2DWrapper g2dWrapper, ComponentState state,
double zoom, int i, boolean outlineMode) {
if (drawOptions.contains(DrawOption.ENABLE_CACHING)) // go through the DrawingCache
double zoom, int i, boolean outlineMode, boolean cacheable) {
if (cacheable && drawOptions.contains(DrawOption.ENABLE_CACHING)) // go through the DrawingCache
DrawingCache.Instance.draw(component, g2dWrapper, state,
drawOptions.contains(DrawOption.OUTLINE_MODE), project, zoom, scaleFactor, i, visibleRect, drawOptions);
else // go straight to the wrapper
component.draw(g2dWrapper, state, outlineMode, project, g2dWrapper);
}
private boolean isOffScreen(IDIYComponent<?> component, Rectangle2D visibleRect) {
if (visibleRect == null) {
return false;
}
ComponentArea area = componentAreaMap.get(component);
if (area == null || area.getDrawnBounds() == null) {
return false;
}
Rectangle2D bounds = area.getDrawnBounds();
return !visibleRect.intersects(bounds.getX() - OFF_SCREEN_MARGIN,
bounds.getY() - OFF_SCREEN_MARGIN, bounds.getWidth() + 2 * OFF_SCREEN_MARGIN,
bounds.getHeight() + 2 * OFF_SCREEN_MARGIN);
}
private void configureRenderingHints(Graphics2D g2d, Set<DrawOption> drawOptions) {
if (drawOptions.contains(DrawOption.ANTIALIASING)) {
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
@@ -651,77 +675,15 @@ public class DrawingManager {
RenderingHints.VALUE_FRACTIONALMETRICS_OFF);
}
private double zoomCached = 0;
private Dimension canvasDimensionCached;
private double extraSpaceCached;
private GridType gridTypeCached;
private BufferedImage cachedGridImage;
private void drawGrid(Project project, Graphics2D g2dIn, double zoom, double extraSpace,
Dimension canvasDimension, Rectangle2D visibleRect, Set<DrawOption> drawOptions,
Rectangle2D extraSpaceRect, AffineTransform extraSpaceTx) {
GridType gridType = GridType.LINES;
private void drawGridAndExtraSpace(Project project, Graphics2D g2dIn, double zoom,
double extraSpace, Dimension canvasDimension, Rectangle2D visibleRect,
Set<DrawOption> drawOptions, Rectangle2D extraSpaceRect, AffineTransform extraSpaceTx) {
Dimension innerCanvasDimension = getCanvasDimensions(project, zoom, false);
// draw from cache
if (zoom != zoomCached || !canvasDimension.equals(canvasDimensionCached)
|| extraSpace != extraSpaceCached || gridType != gridTypeCached) {
try {
cachedGridImage = new BufferedImage((int) canvasDimension.getWidth(),
(int) canvasDimension.getHeight(), BufferedImage.TYPE_INT_ARGB);
// create graphics
Graphics2D g2d = cachedGridImage.createGraphics();
if (drawOptions.contains(DrawOption.GRID) && gridType != GridType.NONE) {
double zoomStep = project.getGridSpacing().convertToPixels() * zoom;
float gridThickness = (float) (1f * (zoom > 1 ? 1 : zoom));
if (gridType == GridType.CROSSHAIR) {
g2d.setStroke(new BasicStroke(gridThickness, BasicStroke.CAP_BUTT,
BasicStroke.JOIN_MITER, 10f,
new float[] {(float) zoomStep / 2, (float) zoomStep / 2}, (float) zoomStep / 4));
} else if (gridType == GridType.DOT) {
g2d.setStroke(new BasicStroke(gridThickness, BasicStroke.CAP_BUTT,
BasicStroke.JOIN_MITER, 10f, new float[] {1f, (float) zoomStep - 1}, 0f));
} else {
g2d.setStroke(ObjectCache.getInstance().fetchZoomableStroke(gridThickness));
}
g2d.setColor(theme.getGridColor());
for (double i = zoomStep; i < canvasDimension.width; i += zoomStep) {
g2d.draw(new Line2D.Double(i, 0, i, canvasDimension.height - 1));
}
for (double j = zoomStep; j < canvasDimension.height; j += zoomStep) {
g2d.draw(new Line2D.Double(0, j, canvasDimension.width - 1, j));
}
// draw dots if needed
if (project.getDotSpacing() > 1 && zoomStep > 8) {
g2d.setStroke(ObjectCache.getInstance().fetchZoomableStroke(gridThickness * 3));
g2d.setColor(theme.getDotColor());
for (double i = extraSpace + zoomStep * project.getDotSpacing(); i < extraSpace
+ innerCanvasDimension.width; i += zoomStep * project.getDotSpacing())
for (double j = extraSpace + zoomStep * project.getDotSpacing(); j < extraSpace
+ innerCanvasDimension.height; j += zoomStep * project.getDotSpacing()) {
g2d.fillOval((int) Math.round(i - 1), (int) Math.round(j - 1), 3, 3);
}
}
}
} finally {
zoomCached = zoom;
canvasDimensionCached = (Dimension) canvasDimension.clone();
extraSpaceCached = extraSpace;
gridTypeCached = gridType;
}
}
if (cachedGridImage == null) {
LOG.warn("Cached grid image is null!");
return;
}
if (drawOptions.contains(DrawOption.GRID) && gridType != GridType.NONE) {
g2dIn.drawImage(cachedGridImage, 0, 0, null);
if (drawOptions.contains(DrawOption.GRID)) {
GridRenderer.draw(g2dIn, project, theme, zoom, extraSpace, canvasDimension,
innerCanvasDimension, visibleRect);
}
// manage extra space
@@ -793,7 +755,9 @@ public class DrawingManager {
List<IDIYComponent<?>> components = new ArrayList<IDIYComponent<?>>();
for (int i = 0; i < project.getComponents().size(); i++) {
ComponentArea area = componentAreaMap.get(project.getComponents().get(i));
if (area != null && area.getOutlineArea().contains(point)) {
// the bounds rule most components out without merging their outline into an area
if (area != null && area.getOutlineBounds().contains(point)
&& area.getOutlineArea().contains(point)) {
components.add(0, project.getComponents().get(i));
}
}
@@ -855,7 +819,7 @@ public class DrawingManager {
for (Map.Entry<IDIYComponent<?>, ComponentArea> entry : this.componentAreaMap.entrySet()) {
ComponentArea a = entry.getValue();
if (a == null || a.getOutlineArea() == null)
if (a == null)
continue;
IDIYComponent<?> component = entry.getKey();
@@ -871,7 +835,9 @@ public class DrawingManager {
Collection<Area> positiveAreas = a.getContinuityPositiveAreas();
if (positiveAreas != null) {
for (Area a1 : positiveAreas) {
preliminaryAreas.add(new ContinuityArea(layerId, a1));
// a copy, because negative areas are subtracted from it below and the recorded area must
// stay whole for when they move
preliminaryAreas.add(new ContinuityArea(layerId, new Area(a1)));
checkBreakout.add(false);
}
}
@@ -101,11 +101,16 @@ class G2DWrapper extends Graphics2D implements IDrawingObserver {
private Font originalFont;
private AffineTransform currentTx;
private AffineTransform initialTx;
private Area currentArea;
private Map<String, Area> continuityPositiveAreas;
private Map<String, Area> continuityNegativeAreas;
// shapes recorded for the outline and continuity areas, merged only when an area is asked for
private List<Shape> outlineParts;
private Map<String, List<Shape>> continuityPositiveParts;
private Map<String, List<Shape>> continuityNegativeParts;
// only kept with debugAreaPerformance, to spot shapes that add nothing to the outline
private Area debugOutline;
private String continuityMarker;
private Shape lastShape;
private boolean recordingDrawnBounds = false;
private Rectangle2D drawnBounds;
public enum MirrorDirection {
NONE, HORIZONTAL, VERTICAL
@@ -129,9 +134,10 @@ class G2DWrapper extends Graphics2D implements IDrawingObserver {
String debugAreaPerformanceStr = System.getProperty(DEBUG_AREA_PERFORMANCE);
debugAreaPerformance = "true".equalsIgnoreCase(debugAreaPerformanceStr);
this.zoom = zoom;
currentArea = new Area();
continuityPositiveAreas = new HashMap<String, Area>();
continuityNegativeAreas = new HashMap<String, Area>();
outlineParts = new ArrayList<Shape>();
continuityPositiveParts = new HashMap<String, List<Shape>>();
continuityNegativeParts = new HashMap<String, List<Shape>>();
debugOutline = new Area();
continuityMarker = null;
currentTx = new AffineTransform();
}
@@ -156,9 +162,10 @@ class G2DWrapper extends Graphics2D implements IDrawingObserver {
public void startedDrawingComponent(int zOrder) {
this.zOrder = zOrder;
this.drawingComponent = true;
this.currentArea = new Area();
this.continuityPositiveAreas = new HashMap<String, Area>();
this.continuityNegativeAreas = new HashMap<String, Area>();
this.outlineParts = new ArrayList<Shape>();
this.continuityPositiveParts = new HashMap<String, List<Shape>>();
this.continuityNegativeParts = new HashMap<String, List<Shape>>();
this.debugOutline = new Area();
// trackingStacks.clear();
this.continuityMarker = null;
this.originalStroke = canvasGraphics.getStroke();
@@ -169,6 +176,8 @@ class G2DWrapper extends Graphics2D implements IDrawingObserver {
this.currentTx = new AffineTransform();
this.initialTx = canvasGraphics.getTransform();
this.lastShape = null;
this.recordingDrawnBounds = false;
this.drawnBounds = null;
startTracking();
}
@@ -179,36 +188,65 @@ class G2DWrapper extends Graphics2D implements IDrawingObserver {
*/
public ComponentArea finishedDrawingComponent() {
drawingComponent = false;
recordingDrawnBounds = false;
canvasGraphics.setStroke(originalStroke);
canvasGraphics.setColor(originalColor);
canvasGraphics.setTransform(originalTx);
canvasGraphics.setComposite(originalComposite);
canvasGraphics.setFont(originalFont);
// Map<String, Integer> sorted = sortByComparator(trackingStacks, false);
return new ComponentArea(zOrder,currentArea, new ArrayList<Area>(continuityPositiveAreas.values()),
new ArrayList<Area>(continuityNegativeAreas.values()));
return new ComponentArea(zOrder, outlineParts,
new ArrayList<List<Shape>>(continuityPositiveParts.values()),
new ArrayList<List<Shape>>(continuityNegativeParts.values()), drawnBounds);
}
// start - used for caching
public Area getCurrentArea() {
return currentArea;
public List<Shape> getOutlineParts() {
return outlineParts;
}
public Map<String, Area> getContinuityPositiveAreas() {
return continuityPositiveAreas;
public Map<String, List<Shape>> getContinuityPositiveParts() {
return continuityPositiveParts;
}
public Map<String, Area> getContinuityNegativeAreas() {
return continuityNegativeAreas;
public Map<String, List<Shape>> getContinuityNegativeParts() {
return continuityNegativeParts;
}
// end - used for caching
public void merge(Area currentArea, Map<String, Area> continuityPositiveAreas, Map<String, Area> continuityNegativeAreas) {
this.currentArea = currentArea;
this.continuityPositiveAreas = continuityPositiveAreas;
this.continuityNegativeAreas = continuityNegativeAreas;
/**
* Takes over shapes recorded earlier, for a component drawn from a cached image instead of
* through this wrapper. The lists are shared rather than copied: nothing is recorded for the
* component after this, and a new component starts with new lists.
*/
public void merge(List<Shape> outlineParts, Map<String, List<Shape>> continuityPositiveParts,
Map<String, List<Shape>> continuityNegativeParts) {
this.outlineParts = outlineParts;
this.continuityPositiveParts = continuityPositiveParts;
this.continuityNegativeParts = continuityNegativeParts;
}
/**
* Starts collecting the bounds of everything the current component draws, regardless of whether
* its area is being tracked. They are returned with the area from
* {@link #finishedDrawingComponent()}.
*/
public void startRecordingDrawnBounds() {
this.recordingDrawnBounds = true;
}
/**
* Adds bounds that are already in project coordinates, for a component that was drawn from a
* cached image instead of through this wrapper.
*
* @param bounds
*/
public void mergeDrawnBounds(Rectangle2D bounds) {
if (recordingDrawnBounds && bounds != null) {
unionDrawnBounds(bounds);
}
}
@Override
@@ -254,45 +292,107 @@ class G2DWrapper extends Graphics2D implements IDrawingObserver {
Rectangle2D bounds = s.getBounds2D();
// Only cache the shape if it's not 1D.
if (bounds.getWidth() > 1 && bounds.getHeight() > 1) {
Area area = new Area(s);
area.transform(currentTx);
Area areaBefore = null;
if (debugAreaPerformance) {
areaBefore = new Area(currentArea);
}
Shape part = copyTransformed(s);
if (trackingAllowed) {
currentArea.add(area);
outlineParts.add(part);
if (debugAreaPerformance && currentArea.equals(areaBefore)) {
LOG.warn("Detected unnecessary shape tracking at", new Throwable());
if (debugAreaPerformance) {
Area areaBefore = new Area(debugOutline);
debugOutline.add(new Area(part));
if (debugOutline.equals(areaBefore)) {
LOG.warn("Detected unnecessary shape tracking at", new Throwable());
}
}
}
Map<String, Area> toAdd;
if (trackingContinuityAllowed) {
if (trackingContinuityPositive)
toAdd = continuityPositiveAreas;
else
toAdd = continuityNegativeAreas;
Map<String, List<Shape>> toAdd =
trackingContinuityPositive ? continuityPositiveParts : continuityNegativeParts;
// without a marker every shape is a continuity area of its own
String marker = continuityMarker == null ? ("internalUnique" + uniqueCounter++) : continuityMarker;
if (continuityMarker == null) {
toAdd.put(marker, area);
// markStack();
} else {
Area current = toAdd.getOrDefault(marker, null);
if (current == null) {
toAdd.put(marker, area);
// markStack();
} else
current.add(area);
}
toAdd.computeIfAbsent(marker, key -> new ArrayList<Shape>()).add(part);
}
lastShape = s;
}
}
// a copy, because a component may reuse and change a shape after drawing it; copying an Area
// shares its curves, which Area operations replace rather than change
private Shape copyTransformed(Shape s) {
if (!currentTx.isIdentity()) {
return currentTx.createTransformedShape(s);
}
return s instanceof Area ? new Area(s) : new Path2D.Double(s);
}
private void appendDrawnBounds(Shape s) {
if (!recordingDrawnBounds || s == null) {
return;
}
unionDrawnBounds(currentTx.createTransformedShape(s.getBounds2D()).getBounds2D());
}
private void appendDrawnOutlineBounds(Shape s) {
if (!recordingDrawnBounds) {
return;
}
Stroke stroke = getStroke();
if (stroke instanceof BasicStroke) {
BasicStroke basicStroke = (BasicStroke) stroke;
// square caps reach sqrt(2) half-widths past the outline, miter joins up to the miter limit
double reach = basicStroke.getLineJoin() == BasicStroke.JOIN_MITER
? Math.max(Math.sqrt(2), basicStroke.getMiterLimit()) : Math.sqrt(2);
double pad = basicStroke.getLineWidth() / 2 * reach;
Rectangle2D bounds = s.getBounds2D();
appendDrawnBounds(new Rectangle2D.Double(bounds.getX() - pad, bounds.getY() - pad,
bounds.getWidth() + 2 * pad, bounds.getHeight() + 2 * pad));
} else {
appendDrawnBounds(stroke.createStrokedShape(s).getBounds2D());
}
}
private void appendDrawnStringBounds(String str, float x, float y) {
if (!recordingDrawnBounds) {
return;
}
Rectangle2D rect = canvasGraphics.getFontMetrics().getStringBounds(str, canvasGraphics);
appendDrawnBounds(new Rectangle2D.Double(rect.getX() + x, rect.getY() + y, rect.getWidth(),
rect.getHeight()));
}
private void appendDrawnStringBounds(AttributedCharacterIterator iterator, float x, float y) {
if (!recordingDrawnBounds || iterator.getBeginIndex() == iterator.getEndIndex()) {
return;
}
Rectangle2D rect = new TextLayout(iterator, canvasGraphics.getFontRenderContext()).getBounds();
appendDrawnBounds(new Rectangle2D.Double(rect.getX() + x, rect.getY() + y, rect.getWidth(),
rect.getHeight()));
}
private void appendDrawnImageBounds(Image img, AffineTransform xform, ImageObserver observer) {
if (!recordingDrawnBounds || img == null) {
return;
}
int width = img.getWidth(observer);
int height = img.getHeight(observer);
// the size is unknown until the image has loaded, and nothing is drawn before that
if (width < 0 || height < 0) {
return;
}
Rectangle rect = new Rectangle(0, 0, width, height);
appendDrawnBounds(xform == null ? rect : xform.createTransformedShape(rect).getBounds2D());
}
private void unionDrawnBounds(Rectangle2D bounds) {
if (drawnBounds == null) {
drawnBounds = new Rectangle2D.Double(bounds.getX(), bounds.getY(), bounds.getWidth(),
bounds.getHeight());
} else {
drawnBounds.add(bounds);
}
}
@SuppressWarnings("unused")
private void markStack() {
StringWriter sw = new StringWriter();
@@ -340,6 +440,7 @@ class G2DWrapper extends Graphics2D implements IDrawingObserver {
@Override
public void draw(Shape s) {
canvasGraphics.draw(s);
appendDrawnOutlineBounds(s);
if (drawingComponent && (trackingAllowed || trackingContinuityAllowed)) {
appendShapeOutline(s);
}
@@ -376,30 +477,52 @@ class G2DWrapper extends Graphics2D implements IDrawingObserver {
} else {
canvasGraphics.drawGlyphVector(g, x, y);
}
if (recordingDrawnBounds) {
Rectangle2D bounds = g.getVisualBounds();
appendDrawnBounds(new Rectangle2D.Double(bounds.getX() + x, bounds.getY() + y,
bounds.getWidth(), bounds.getHeight()));
}
}
@Override
public boolean drawImage(Image img, AffineTransform xform, ImageObserver obs) {
// FIXME: process map
return canvasGraphics.drawImage(img, xform, obs);
boolean result = canvasGraphics.drawImage(img, xform, obs);
appendDrawnImageBounds(img, xform, obs);
return result;
}
@Override
public void drawImage(BufferedImage img, BufferedImageOp op, int x, int y) {
canvasGraphics.drawImage(img, op, x, y);
// FIXME: process map
if (recordingDrawnBounds && img != null) {
Rectangle2D bounds = op == null ? img.getRaster().getBounds() : op.getBounds2D(img);
appendDrawnBounds(new Rectangle2D.Double(bounds.getX() + x, bounds.getY() + y,
bounds.getWidth(), bounds.getHeight()));
}
}
@Override
public void drawRenderableImage(RenderableImage img, AffineTransform xform) {
canvasGraphics.drawRenderableImage(img, xform);
// FIXME: process map
if (recordingDrawnBounds && img != null) {
Rectangle2D bounds =
new Rectangle2D.Double(img.getMinX(), img.getMinY(), img.getWidth(), img.getHeight());
appendDrawnBounds(xform.createTransformedShape(bounds).getBounds2D());
}
}
@Override
public void drawRenderedImage(RenderedImage img, AffineTransform xform) {
canvasGraphics.drawRenderedImage(img, xform);
// FIXME: process map
if (recordingDrawnBounds && img != null) {
Rectangle bounds =
new Rectangle(img.getMinX(), img.getMinY(), img.getWidth(), img.getHeight());
appendDrawnBounds(xform.createTransformedShape(bounds).getBounds2D());
}
}
@Override
@@ -412,6 +535,7 @@ class G2DWrapper extends Graphics2D implements IDrawingObserver {
} else {
canvasGraphics.drawString(str, x, y);
}
appendDrawnStringBounds(str, x, y);
if (drawingComponent && trackingAllowed) {
FontMetrics fontMetrics = canvasGraphics.getFontMetrics();
Rectangle2D rect = fontMetrics.getStringBounds(str, canvasGraphics);
@@ -434,6 +558,7 @@ class G2DWrapper extends Graphics2D implements IDrawingObserver {
} else {
canvasGraphics.drawString(str, x, y);
}
appendDrawnStringBounds(str, x, y);
if (drawingComponent && trackingAllowed) {
FontMetrics fontMetrics = canvasGraphics.getFontMetrics();
Rectangle2D rect = fontMetrics.getStringBounds(str, canvasGraphics);
@@ -451,6 +576,7 @@ class G2DWrapper extends Graphics2D implements IDrawingObserver {
} else {
canvasGraphics.drawString(iterator, x, y);
}
appendDrawnStringBounds(iterator, x, y);
}
@Override
@@ -463,6 +589,7 @@ class G2DWrapper extends Graphics2D implements IDrawingObserver {
} else {
canvasGraphics.drawString(iterator, x, y);
}
appendDrawnStringBounds(iterator, x, y);
}
private void drawMirroredString(String str, float x, float y) {
@@ -529,6 +656,7 @@ class G2DWrapper extends Graphics2D implements IDrawingObserver {
@Override
public void fill(Shape s) {
canvasGraphics.fill(s);
appendDrawnBounds(s);
if (drawingComponent && (trackingAllowed || trackingContinuityAllowed)) {
appendShape(s);
}
@@ -690,6 +818,7 @@ class G2DWrapper extends Graphics2D implements IDrawingObserver {
@Override
public void clearRect(int x, int y, int width, int height) {
canvasGraphics.clearRect(x, y, width, height);
appendDrawnBounds(new Rectangle(x, y, width, height));
}
@Override
@@ -700,6 +829,7 @@ class G2DWrapper extends Graphics2D implements IDrawingObserver {
@Override
public void copyArea(int x, int y, int width, int height, int dx, int dy) {
canvasGraphics.copyArea(x, y, width, height, dx, dy);
appendDrawnBounds(new Rectangle(x + dx, y + dy, width, height));
}
@Override
@@ -715,6 +845,7 @@ class G2DWrapper extends Graphics2D implements IDrawingObserver {
@Override
public void drawArc(int x, int y, int width, int height, int startAngle, int arcAngle) {
canvasGraphics.drawArc(x, y, width, height, startAngle, arcAngle);
appendDrawnOutlineBounds(new Rectangle(x, y, width, height));
if (drawingComponent && (trackingAllowed || trackingContinuityAllowed)) {
appendShapeOutline(new Arc2D.Double(x, y, width, height, startAngle, arcAngle, Arc2D.OPEN));
}
@@ -724,44 +855,60 @@ class G2DWrapper extends Graphics2D implements IDrawingObserver {
public boolean drawImage(Image img, int x, int y, ImageObserver observer) {
boolean result = canvasGraphics.drawImage(img, x, y, observer);
appendShape(new Rectangle2D.Double(x, y, img.getWidth(observer), img.getHeight(observer)));
appendDrawnImageBounds(img, AffineTransform.getTranslateInstance(x, y), observer);
return result;
}
@Override
public boolean drawImage(Image img, int x, int y, Color bgcolor, ImageObserver observer) {
// FIXME: map
return canvasGraphics.drawImage(img, x, y, bgcolor, observer);
boolean result = canvasGraphics.drawImage(img, x, y, bgcolor, observer);
appendDrawnImageBounds(img, AffineTransform.getTranslateInstance(x, y), observer);
return result;
}
@Override
public boolean drawImage(Image img, int x, int y, int width, int height, ImageObserver observer) {
// FIXME: map
return canvasGraphics.drawImage(img, x, y, width, height, observer);
boolean result = canvasGraphics.drawImage(img, x, y, width, height, observer);
appendDrawnBounds(new Rectangle(x, y, width, height));
return result;
}
@Override
public boolean drawImage(Image img, int x, int y, int width, int height, Color bgcolor, ImageObserver observer) {
// FIXME: map
return canvasGraphics.drawImage(img, x, y, width, height, bgcolor, observer);
boolean result = canvasGraphics.drawImage(img, x, y, width, height, bgcolor, observer);
appendDrawnBounds(new Rectangle(x, y, width, height));
return result;
}
@Override
public boolean drawImage(Image img, int dx1, int dy1, int dx2, int dy2, int sx1, int sy1, int sx2, int sy2,
ImageObserver observer) {
// FIXME: map
return canvasGraphics.drawImage(img, dx1, dy1, dx2, dy2, sx1, sy1, sx2, sy2, observer);
boolean result =
canvasGraphics.drawImage(img, dx1, dy1, dx2, dy2, sx1, sy1, sx2, sy2, observer);
appendDrawnBounds(new Rectangle(Math.min(dx1, dx2), Math.min(dy1, dy2), Math.abs(dx2 - dx1),
Math.abs(dy2 - dy1)));
return result;
}
@Override
public boolean drawImage(Image img, int dx1, int dy1, int dx2, int dy2, int sx1, int sy1, int sx2, int sy2,
Color bgcolor, ImageObserver observer) {
// FIXME: map
return drawImage(img, dx1, dy1, dx2, dy2, sx1, sy1, sx2, sy2, bgcolor, observer);
boolean result =
canvasGraphics.drawImage(img, dx1, dy1, dx2, dy2, sx1, sy1, sx2, sy2, bgcolor, observer);
appendDrawnBounds(new Rectangle(Math.min(dx1, dx2), Math.min(dy1, dy2), Math.abs(dx2 - dx1),
Math.abs(dy2 - dy1)));
return result;
}
@Override
public void drawLine(int x1, int y1, int x2, int y2) {
canvasGraphics.drawLine(x1, y1, x2, y2);
appendDrawnOutlineBounds(new Line2D.Double(x1, y1, x2, y2));
if (drawingComponent && (trackingAllowed || trackingContinuityAllowed)) {
appendShapeOutline(new Line2D.Double(x1, y1, x2, y2));
}
@@ -770,6 +917,7 @@ class G2DWrapper extends Graphics2D implements IDrawingObserver {
@Override
public void drawOval(int x, int y, int width, int height) {
canvasGraphics.drawOval(x, y, width, height);
appendDrawnOutlineBounds(new Rectangle(x, y, width, height));
if (drawingComponent && (trackingAllowed || trackingContinuityAllowed)) {
appendShapeOutline(new Ellipse2D.Double(x, y, width, height));
}
@@ -778,6 +926,9 @@ class G2DWrapper extends Graphics2D implements IDrawingObserver {
@Override
public void drawPolygon(int[] points, int[] points2, int points3) {
canvasGraphics.drawPolygon(points, points2, points3);
if (points3 > 0) {
appendDrawnOutlineBounds(new Polygon(points, points2, points3));
}
if (drawingComponent && (trackingAllowed || trackingContinuityAllowed)) {
appendShapeOutline(new Polygon(points, points2, points3));
}
@@ -786,6 +937,9 @@ class G2DWrapper extends Graphics2D implements IDrawingObserver {
@Override
public void drawPolyline(int[] pointsX, int[] pointsY, int pointCount) {
canvasGraphics.drawPolyline(pointsX, pointsY, pointCount);
if (pointCount > 0) {
appendDrawnOutlineBounds(new Polygon(pointsX, pointsY, pointCount));
}
if (drawingComponent && (trackingAllowed || trackingContinuityAllowed) && pointCount > 1) {
Path2D path = new Path2D.Double();
path.moveTo(pointsX[0], pointsY[0]);
@@ -798,6 +952,7 @@ class G2DWrapper extends Graphics2D implements IDrawingObserver {
@Override
public void drawRoundRect(int x, int y, int width, int height, int arcWidth, int arcHeight) {
canvasGraphics.drawRoundRect(x, y, width, height, arcWidth, arcHeight);
appendDrawnOutlineBounds(new Rectangle(x, y, width, height));
if (drawingComponent && (trackingAllowed || trackingContinuityAllowed)) {
appendShapeOutline(new RoundRectangle2D.Double(x, y, width, height, arcWidth, arcHeight));
}
@@ -806,6 +961,7 @@ class G2DWrapper extends Graphics2D implements IDrawingObserver {
@Override
public void fillArc(int x, int y, int width, int height, int startAngle, int arcAngle) {
canvasGraphics.fillArc(x, y, width, height, startAngle, arcAngle);
appendDrawnBounds(new Rectangle(x, y, width, height));
if (drawingComponent && (trackingAllowed || trackingContinuityAllowed)) {
appendShape(new Arc2D.Double(x, y, width, height, startAngle, arcAngle, Arc2D.PIE));
}
@@ -814,6 +970,7 @@ class G2DWrapper extends Graphics2D implements IDrawingObserver {
@Override
public void fillOval(int x, int y, int width, int height) {
canvasGraphics.fillOval(x, y, width, height);
appendDrawnBounds(new Rectangle(x, y, width, height));
if (drawingComponent && (trackingAllowed || trackingContinuityAllowed)) {
appendShape(new Ellipse2D.Double(x, y, width, height));
}
@@ -822,6 +979,9 @@ class G2DWrapper extends Graphics2D implements IDrawingObserver {
@Override
public void fillPolygon(int[] points, int[] points2, int points3) {
canvasGraphics.fillPolygon(points, points2, points3);
if (points3 > 0) {
appendDrawnBounds(new Polygon(points, points2, points3));
}
if (drawingComponent && (trackingAllowed || trackingContinuityAllowed)) {
appendShape(new Polygon(points, points2, points3));
}
@@ -830,6 +990,7 @@ class G2DWrapper extends Graphics2D implements IDrawingObserver {
@Override
public void drawRect(int x, int y, int width, int height) {
canvasGraphics.drawRect(x, y, width, height);
appendDrawnOutlineBounds(new Rectangle(x, y, width, height));
if (drawingComponent && (trackingAllowed || trackingContinuityAllowed)) {
appendShapeOutline(new Rectangle(x, y, width, height));
}
@@ -838,6 +999,7 @@ class G2DWrapper extends Graphics2D implements IDrawingObserver {
@Override
public void fillRect(int x, int y, int width, int height) {
canvasGraphics.fillRect(x, y, width, height);
appendDrawnBounds(new Rectangle(x, y, width, height));
if (drawingComponent && (trackingAllowed || trackingContinuityAllowed)) {
appendShape(new Rectangle(x, y, width, height));
}
@@ -846,6 +1008,7 @@ class G2DWrapper extends Graphics2D implements IDrawingObserver {
@Override
public void fillRoundRect(int x, int y, int width, int height, int arcWidth, int arcHeight) {
canvasGraphics.fillRoundRect(x, y, width, height, arcWidth, arcHeight);
appendDrawnBounds(new Rectangle(x, y, width, height));
if (drawingComponent && (trackingAllowed || trackingContinuityAllowed)) {
appendShape(new RoundRectangle2D.Double(x, y, width, height, arcWidth, arcHeight));
}
@@ -0,0 +1,131 @@
/*
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 java.awt.BasicStroke;
import java.awt.Dimension;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.geom.Line2D;
import java.awt.geom.Rectangle2D;
import org.diylc.common.GridType;
import org.diylc.common.ObjectCache;
import org.diylc.core.Project;
import org.diylc.core.Theme;
/**
* Draws the canvas grid lines and dots directly, limited to the visible part of the canvas.
*/
public class GridRenderer {
// closest that grid lines are drawn together, in canvas pixels at the current zoom, before a
// dense grid is thinned out
public static double MIN_GRID_LINE_SPACING = 3;
/**
* Draws the grid in zoomed canvas coordinates, before the zoom transform is applied.
*
* @param g2d
* @param project
* @param theme
* @param zoom
* @param extraSpace extra space around the project, already multiplied by the zoom
* @param canvasDimension canvas size including the extra space
* @param innerCanvasDimension canvas size without the extra space
* @param visibleRect part of the canvas to draw, or null to draw all of it as exports and
* printing do
*/
public static void draw(Graphics2D g2d, Project project, Theme theme, double zoom,
double extraSpace, Dimension canvasDimension, Dimension innerCanvasDimension,
Rectangle2D visibleRect) {
GridType gridType = GridType.LINES;
Rectangle2D area =
new Rectangle2D.Double(0, 0, canvasDimension.width, canvasDimension.height);
if (visibleRect != null) {
Rectangle2D.intersect(area, visibleRect, area);
}
double zoomStep = project.getGridSpacing().convertToPixels() * zoom;
// a dense grid drawn zoomed out is reduced to every 5th line, every 25th and so on, once its
// lines are too close together to be told apart
int lineMultiple = 1;
while (zoomStep * lineMultiple < MIN_GRID_LINE_SPACING) {
lineMultiple *= 5;
}
double lineStep = zoomStep * lineMultiple;
float gridThickness = (float) (1f * (zoom > 1 ? 1 : zoom));
// grid lines are axis-aligned, so without antialiasing each one is snapped to whole pixels
// instead of being smeared across two
Object antialiasing = g2d.getRenderingHint(RenderingHints.KEY_ANTIALIASING);
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_OFF);
try {
if (gridType == GridType.CROSSHAIR) {
g2d.setStroke(new BasicStroke(gridThickness, BasicStroke.CAP_BUTT, BasicStroke.JOIN_MITER,
10f, new float[] {(float) zoomStep / 2, (float) zoomStep / 2}, (float) zoomStep / 4));
} else if (gridType == GridType.DOT) {
g2d.setStroke(new BasicStroke(gridThickness, BasicStroke.CAP_BUTT, BasicStroke.JOIN_MITER,
10f, new float[] {1f, (float) zoomStep - 1}, 0f));
} else {
g2d.setStroke(ObjectCache.getInstance().fetchZoomableStroke(gridThickness));
}
g2d.setColor(theme.getGridColor());
Line2D line = new Line2D.Double();
double maxX = Math.min(area.getMaxX(), canvasDimension.width - 1);
double maxY = Math.min(area.getMaxY(), canvasDimension.height - 1);
// positions are computed from the line index rather than accumulated, so they do not drift
for (long k = Math.max(1, (long) Math.ceil(area.getMinX() / lineStep));
k * lineStep <= maxX; k++) {
line.setLine(k * lineStep, area.getMinY(), k * lineStep, maxY);
g2d.draw(line);
}
for (long k = Math.max(1, (long) Math.ceil(area.getMinY() / lineStep));
k * lineStep <= maxY; k++) {
line.setLine(area.getMinX(), k * lineStep, maxX, k * lineStep);
g2d.draw(line);
}
if (project.getDotSpacing() > 1 && zoomStep > 8) {
g2d.setColor(theme.getDotColor());
double dotStep = zoomStep * project.getDotSpacing();
// dots are 3 pixels wide, so the ones just outside the visible rect still show partly
double dotMinX = area.getMinX() - extraSpace - 2;
double dotMinY = area.getMinY() - extraSpace - 2;
double dotMaxX = Math.min(area.getMaxX() - extraSpace + 2, innerCanvasDimension.width);
double dotMaxY = Math.min(area.getMaxY() - extraSpace + 2, innerCanvasDimension.height);
for (long i = Math.max(1, (long) Math.ceil(dotMinX / dotStep)); i * dotStep < dotMaxX;
i++) {
for (long j = Math.max(1, (long) Math.ceil(dotMinY / dotStep)); j * dotStep < dotMaxY;
j++) {
g2d.fillOval((int) Math.round(extraSpace + i * dotStep - 1),
(int) Math.round(extraSpace + j * dotStep - 1), 3, 3);
}
}
}
} finally {
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, antialiasing);
}
}
}
@@ -0,0 +1,132 @@
/*
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 java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import org.diylc.common.ComponentType;
import org.diylc.core.ComponentGroup;
import org.diylc.core.IDIYComponent;
import org.diylc.core.Project;
/**
* Answers which components are grouped together and which are locked. Both are read on every
* repaint and every mouse move, so they are worked out once and kept until {@link #invalidate()}
* is called, which must happen wherever the project's components, groups or locks may have
* changed. Asking about a different project than last time rebuilds them as well.
*/
public class GroupAndLockIndex {
private Project project;
private Map<UUID, Set<IDIYComponent<?>>> groups;
private Set<IDIYComponent<?>> lockedComponents;
public void invalidate() {
groups = null;
lockedComponents = null;
}
/**
* @param project
* @param component
* @return the project components that share a group with the specified component, matched by
* id, or just the component itself when it is not grouped. The set is read-only.
*/
public Set<IDIYComponent<?>> findGroup(Project project, IDIYComponent<?> component) {
checkProject(project);
if (groups == null) {
groups = buildGroups(project);
}
Set<IDIYComponent<?>> group = groups.get(component.getId());
return group == null ? Collections.<IDIYComponent<?>>singleton(component) : group;
}
/**
* @param project
* @return the project components that are locked, either one by one or through their layer. The
* set is read-only.
*/
public Set<IDIYComponent<?>> getLockedComponents(Project project) {
checkProject(project);
if (lockedComponents == null) {
Set<IDIYComponent<?>> locked = new HashSet<IDIYComponent<?>>();
for (IDIYComponent<?> component : project.getComponents()) {
if (isLocked(project, component)) {
locked.add(component);
}
}
lockedComponents = Collections.unmodifiableSet(locked);
}
return lockedComponents;
}
@SuppressWarnings("unchecked")
public static boolean isLocked(Project project, IDIYComponent<?> component) {
ComponentType componentType = ComponentProcessor.getInstance()
.extractComponentTypeFrom((Class<? extends IDIYComponent<?>>) component.getClass());
// for internal-use components
if (componentType == null) {
return false;
}
return project.getLockedLayers().contains((int) Math.round(componentType.getZOrder()))
|| project.getLockedComponents().contains(component);
}
private void checkProject(Project project) {
if (project != this.project) {
this.project = project;
invalidate();
}
}
// a component listed in more than one group keeps the first, as a search through the groups in
// order would find it
private static Map<UUID, Set<IDIYComponent<?>>> buildGroups(Project project) {
Map<UUID, List<IDIYComponent<?>>> componentsById = new HashMap<UUID, List<IDIYComponent<?>>>();
for (IDIYComponent<?> component : project.getComponents()) {
componentsById.computeIfAbsent(component.getId(), id -> new ArrayList<IDIYComponent<?>>())
.add(component);
}
Map<UUID, Set<IDIYComponent<?>>> groups = new HashMap<UUID, Set<IDIYComponent<?>>>();
for (ComponentGroup group : project.getGroupsEx()) {
if (group.getComponentIds() == null) {
continue;
}
Set<IDIYComponent<?>> members = new HashSet<IDIYComponent<?>>();
for (UUID id : group.getComponentIds()) {
members.addAll(componentsById.getOrDefault(id, Collections.emptyList()));
}
Set<IDIYComponent<?>> readOnlyMembers = Collections.unmodifiableSet(members);
for (UUID id : group.getComponentIds()) {
groups.putIfAbsent(id, readOnlyMembers);
}
}
return groups;
}
}
@@ -134,7 +134,7 @@ public class Presenter implements IPlugInPort {
// Maps components that have at least one dragged point to set of indices
// that designate which of their control points are being dragged.
private Map<IDIYComponent<?>, Set<Integer>> controlPointMap;
private Set<IDIYComponent<?>> lockedComponents;
private GroupAndLockIndex groupAndLockIndex;
// Utilities
// private Cloner cloner;
@@ -192,7 +192,7 @@ public class Presenter implements IPlugInPort {
this.messageDispatcher = messageDispatcher;
plugIns = new ArrayList<IPlugIn>();
selectedComponents = new HashSet<IDIYComponent<?>>();
lockedComponents = new HashSet<IDIYComponent<?>>();
groupAndLockIndex = new GroupAndLockIndex();
currentProject = new Project();
drawingManager = new DrawingManager(messageDispatcher, configManager);
projectFileManager = new ProjectFileManager(messageDispatcher);
@@ -280,7 +280,9 @@ public class Presenter implements IPlugInPort {
if (!isComponentLocked(component) && isComponentVisible(component)
&& operationMode != OperationMode.HIGHLIGHT_CONNECTED_AREAS) {
ComponentArea area = drawingManager.getComponentArea(component);
if (area != null && area.getOutlineArea() != null && scaledPoint != null && area.getOutlineArea().contains(scaledPoint)) {
// the bounds rule most components out without merging their outline into an area
if (area != null && scaledPoint != null && area.getOutlineBounds().contains(scaledPoint)
&& area.getOutlineArea().contains(scaledPoint)) {
return Cursor.getPredefinedCursor(Cursor.HAND_CURSOR);
}
}
@@ -305,6 +307,7 @@ public class Presenter implements IPlugInPort {
public void loadProject(Project project, boolean freshStart, String filename) {
LOG.info(String.format("loadProject(%s, %s)", project.getTitle(), freshStart));
this.currentProject = project;
groupAndLockIndex.invalidate();
drawingManager.clearComponentAreaMap();
drawingManager.clearContinuityArea();
DrawingCache.Instance.clear();
@@ -535,7 +538,8 @@ public class Presenter implements IPlugInPort {
getLockedComponents(),
groupedComponents,
Arrays.asList(instantiationManager.getFirstControlPoint(),
instantiationManager.getPotentialControlPoint()), componentSlotToDraw, dragInProgress, externalZoom, scaleFactor,
instantiationManager.getPotentialControlPoint()), componentSlotToDraw, dragInProgress,
getMovingComponents(), externalZoom, scaleFactor,
visibleRect);
List<String> failedComponentNames = new ArrayList<String>();
for (IDIYComponent<?> component : failedComponents) {
@@ -719,8 +723,10 @@ public class Presenter implements IPlugInPort {
}
private void notifyProjectModifiedIfNeeded(Project oldProject, String action, boolean clearContinuityArea, boolean repaint) {
groupAndLockIndex.invalidate();
// Notify the listeners.
if (oldProject == null || !currentProject.equals(oldProject)) {
invalidateChangedComponents(oldProject);
messageDispatcher.dispatchMessage(EventType.PROJECT_MODIFIED, oldProject, currentProject, action);
if (clearContinuityArea) {
drawingManager.clearContinuityArea();
@@ -732,6 +738,41 @@ public class Presenter implements IPlugInPort {
}
}
/**
* Discards what was derived from the last drawing of the component, its areas and its cached
* image, so that the next repaint draws it afresh.
*
* @param component
*/
private void componentChanged(IDIYComponent<?> component) {
drawingManager.invalidateComponent(component);
DrawingCache.Instance.invalidate(component);
}
/**
* Calls {@link #componentChanged(IDIYComponent)} for every component that differs from its
* counterpart in the specified snapshot, matched by id. The drawing cache relies on this rather
* than comparing each component on every repaint, so every edit must end up here.
*
* @param oldProject snapshot taken before the edit, or null when there is none
*/
private void invalidateChangedComponents(Project oldProject) {
if (oldProject == null) {
DrawingCache.Instance.clear();
return;
}
Map<UUID, IDIYComponent<?>> oldComponents = new HashMap<UUID, IDIYComponent<?>>();
for (IDIYComponent<?> component : oldProject.getComponents()) {
oldComponents.put(component.getId(), component);
}
for (IDIYComponent<?> component : currentProject.getComponents()) {
IDIYComponent<?> oldComponent = oldComponents.get(component.getId());
if (oldComponent == null || !oldComponent.equalsTo(component)) {
componentChanged(component);
}
}
}
private void addPendingComponentsToProject(Point2D scaledPoint, ComponentType componentTypeSlot, Template template, String[] model,
Project oldProject) {
List<IDIYComponent<?>> componentSlot = instantiationManager.getComponentSlot();
@@ -1060,12 +1101,12 @@ public class Presenter implements IPlugInPort {
for (IDIYComponent<?> c : components) {
ComponentArea compArea = drawingManager.getComponentArea(c);
if (compArea == null || compArea.getOutlineArea() == null) {
if (compArea == null) {
LOG.debug("Area is null for " + c.getName() + " of type " + c.getClass().getName());
return null;
}
Rectangle rect = compArea.getOutlineArea().getBounds();
Rectangle rect = compArea.getOutlineBounds().getBounds();
if (rect.x < minX)
minX = rect.x;
if (rect.x + rect.width > maxX)
@@ -1216,13 +1257,13 @@ public class Presenter implements IPlugInPort {
Rectangle2D bounds = null;
for (IDIYComponent<?> c : unit) {
ComponentArea area = drawingManager.getComponentArea(c);
if (area == null || area.getOutlineArea() == null) {
if (area == null) {
return null;
}
if (bounds == null) {
bounds = area.getOutlineArea().getBounds2D();
bounds = area.getOutlineBounds();
} else {
bounds.add(area.getOutlineArea().getBounds2D());
bounds.add(area.getOutlineBounds());
}
}
return bounds;
@@ -1769,9 +1810,10 @@ public class Presenter implements IPlugInPort {
}
// only the moved components need their areas re-tracked, rebuilding every area on each drag
// step makes dragging slow in large projects
// step makes dragging slow in large projects; drag steps do not go through
// notifyProjectModifiedIfNeeded, so they invalidate explicitly
for (IDIYComponent<?> c : controlPointMap.keySet()) {
drawingManager.invalidateComponent(c);
componentChanged(c);
}
drawingManager.clearContinuityArea();
@@ -1945,7 +1987,8 @@ public class Presenter implements IPlugInPort {
for (IDIYComponent<?> component : currentProject.getComponents()) {
if (!isComponentLocked(component) && isComponentVisible(component)) {
ComponentArea area = drawingManager.getComponentArea(component);
if ((area != null && area.getOutlineArea() != null) && (selectionRect != null)
if (area != null && selectionRect != null
&& area.getOutlineBounds().intersects(selectionRect)
&& area.getOutlineArea().intersects(selectionRect)) {
newSelection.addAll(findAllGroupedComponents(component));
}
@@ -1967,6 +2010,7 @@ public class Presenter implements IPlugInPort {
// There is selection, so we need to finalize the drag&drop
// operation.
if (!preDragProject.equals(currentProject)) {
invalidateChangedComponents(preDragProject);
messageDispatcher.dispatchMessage(EventType.PROJECT_MODIFIED, preDragProject, currentProject, "Drag");
drawingManager.clearContinuityArea();
projectFileManager.notifyFileChange();
@@ -1984,6 +2028,8 @@ public class Presenter implements IPlugInPort {
LOG.info(String.format("pasteComponents(%s, %s, %s)", componentTransferable, autoGroup, assignNewNames));
instantiationManager.pasteComponents(componentTransferable, this.previousScaledPoint, isSnapToGrid(),
currentProject.getGridSpacing(), autoGroup, this.currentProject, assignNewNames);
// pasting copies the clipboard's groups into the project straight away
groupAndLockIndex.invalidate();
messageDispatcher.dispatchMessage(EventType.REPAINT);
messageDispatcher.dispatchMessage(EventType.SLOT_CHANGED, instantiationManager.getComponentTypeSlot(),
instantiationManager.getFirstControlPoint());
@@ -2120,6 +2166,7 @@ public class Presenter implements IPlugInPort {
} else {
currentProject.getLockedLayers().remove(layerZOrder);
}
groupAndLockIndex.invalidate();
updateSelection(EMPTY_SELECTION);
messageDispatcher.dispatchMessage(EventType.LAYER_STATE_CHANGED, currentProject.getLockedLayers());
@@ -2480,8 +2527,8 @@ public class Presenter implements IPlugInPort {
// do a rough check, if the outlines don't intersect there's no chance of a match
// so don't waste time doing the precise check
if (area.getOutlineArea() != null && selectedArea.getOutlineArea() != null &&
!area.getOutlineArea().getBounds().intersects(selectedArea.getOutlineArea().getBounds()))
if (!area.getOutlineBounds().getBounds()
.intersects(selectedArea.getOutlineBounds().getBounds()))
continue;
// create a unified continuity area for both components
@@ -2560,6 +2607,15 @@ public class Presenter implements IPlugInPort {
currentProject.getGroupsEx().removeIf(
group -> group.getComponentIds() != null && group.getComponentIds().stream()
.anyMatch(id -> id != null && componentIds.contains(id)));
groupAndLockIndex.invalidate();
}
// the components a drag in progress moves: the dragged ones and those stuck to them
private Collection<IDIYComponent<?>> getMovingComponents() {
if (!dragInProgress || controlPointMap == null) {
return Collections.emptySet();
}
return controlPointMap.keySet();
}
/**
@@ -2571,16 +2627,7 @@ public class Presenter implements IPlugInPort {
* the minimum, set contains that single component.
*/
private Set<IDIYComponent<?>> findAllGroupedComponents(IDIYComponent<?> component) {
for (ComponentGroup group : currentProject.getGroupsEx()) {
if (group.getComponentIds().stream().anyMatch(id -> Objects.equals(id, component.getId()))) {
return currentProject.getComponents().stream()
.filter(comp -> group.getComponentIds().contains(comp.getId()))
.collect(Collectors.toSet());
}
}
return new HashSet<>(Set.of(component));
return groupAndLockIndex.findGroup(currentProject, component);
}
@Override
@@ -3040,13 +3087,7 @@ public class Presenter implements IPlugInPort {
}
private Set<IDIYComponent<?>> getLockedComponents() {
lockedComponents.clear();
for (IDIYComponent<?> component : currentProject.getComponents()) {
if (isComponentLocked(component)) {
lockedComponents.add(component);
}
}
return lockedComponents;
return groupAndLockIndex.getLockedComponents(currentProject);
}
@Override
@@ -3060,6 +3101,7 @@ public class Presenter implements IPlugInPort {
} else {
currentProject.getLockedComponents().remove(c);
}
groupAndLockIndex.invalidate();
} finally {
notifyProjectModifiedIfNeeded(oldProject, locked ? "Lock Component" : "Unlock Component", false, true);
@@ -3068,15 +3110,8 @@ public class Presenter implements IPlugInPort {
}
}
@SuppressWarnings("unchecked")
private boolean isComponentLocked(IDIYComponent<?> component) {
ComponentType componentType =
ComponentProcessor.getInstance().extractComponentTypeFrom(
(Class<? extends IDIYComponent<?>>) component.getClass());
// for internal-use components
if (componentType == null)
return false;
return currentProject.getLockedLayers().contains((int) Math.round(componentType.getZOrder())) || currentProject.getLockedComponents().contains(component);
return GroupAndLockIndex.isLocked(currentProject, component);
}
@SuppressWarnings("unchecked")
@@ -617,10 +617,26 @@
<changeType>IMPROVEMENT</changeType>
<description>Improved performance of 'Project Explorer' pane</description>
</org.diylc.appframework.update.Change>
<org.diylc.appframework.update.Change>
<changeType>IMPROVEMENT</changeType>
<description>Improved memory management when caching rendered components</description>
</org.diylc.appframework.update.Change>
<org.diylc.appframework.update.Change>
<changeType>IMPROVEMENT</changeType>
<description>Improved grid rendering on screen and when exporting; PDF export of the grid is now vector-based and does not get blurry on zooming in</description>
</org.diylc.appframework.update.Change>
<org.diylc.appframework.update.Change>
<changeType>BUG_FIX</changeType>
<description>Fixed the issue of disappearing components in some edge cases</description>
</org.diylc.appframework.update.Change>
<org.diylc.appframework.update.Change>
<changeType>BUG_FIX</changeType>
<description>Fixed netlists and 'Highlight Connected Areas' giving inconsistent results on boards with trace cuts - running them again could join strips across a cut, and a moved or deleted cut could leave the strip broken where it used to be</description>
</org.diylc.appframework.update.Change>
<org.diylc.appframework.update.Change>
<changeType>BUG_FIX</changeType>
<description>'Twisted Leads' - the last few crossings near the end of a wire were drawn with the leads overlapping instead of one passing over the other, and crossings could come out slightly different between redraws</description>
</org.diylc.appframework.update.Change>
<org.diylc.appframework.update.Change>
<changeType>BUG_FIX</changeType>
<description>When a component that belongs to a group is stuck to another component, moving that other component now drags the whole group along instead of only the stuck component</description>
@@ -6,6 +6,7 @@ import static org.junit.Assert.assertTrue;
import java.awt.AlphaComposite;
import java.awt.Graphics2D;
import java.awt.geom.AffineTransform;
import java.awt.geom.Area;
import java.awt.geom.Point2D;
import java.awt.geom.Rectangle2D;
import java.awt.image.BufferedImage;
@@ -13,6 +14,7 @@ import java.util.ArrayList;
import java.util.Collections;
import java.util.EnumSet;
import java.util.List;
import java.util.Set;
import org.diylc.appframework.miscutils.InMemoryConfigurationManager;
import org.diylc.appframework.simplemq.MessageDispatcher;
@@ -151,7 +153,8 @@ public class DrawingManagerTest {
Graphics2D g2d = img.createGraphics();
drawingManager.drawProject(g2d, project, EnumSet.of(DrawOption.ZOOM), null, null,
Collections.emptyList(), Collections.emptySet(), Collections.emptySet(), null, null, false, 1.0, 1.0, null);
Collections.emptyList(), Collections.emptySet(), Collections.emptySet(), null, null, false,
Collections.emptySet(), 1.0, 1.0, null);
g2d.dispose();
@@ -196,7 +199,8 @@ public class DrawingManagerTest {
Graphics2D g2d = img.createGraphics();
drawingManager.drawProject(g2d, project, EnumSet.of(DrawOption.ZOOM), null, null,
Collections.emptyList(), Collections.emptySet(), Collections.emptySet(), null, null, false, 1.0, 1.0, null);
Collections.emptyList(), Collections.emptySet(), Collections.emptySet(), null, null, false,
Collections.emptySet(), 1.0, 1.0, null);
g2d.dispose();
@@ -230,7 +234,8 @@ public class DrawingManagerTest {
Graphics2D g2d = img.createGraphics();
drawingManager.drawProject(g2d, project, EnumSet.of(DrawOption.ZOOM), null, null,
Collections.emptyList(), Collections.emptySet(), Collections.emptySet(), null, null, false, 1.0, 1.0, null);
Collections.emptyList(), Collections.emptySet(), Collections.emptySet(), null, null, false,
Collections.emptySet(), 1.0, 1.0, null);
g2d.dispose();
@@ -261,7 +266,8 @@ public class DrawingManagerTest {
Graphics2D g2d = img.createGraphics();
drawingManager.drawProject(g2d, project, EnumSet.of(DrawOption.ZOOM), null, null,
Collections.emptyList(), Collections.emptySet(), Collections.emptySet(), null, null, false, 1.0, 1.0, null);
Collections.emptyList(), Collections.emptySet(), Collections.emptySet(), null, null, false,
Collections.emptySet(), 1.0, 1.0, null);
g2d.dispose();
@@ -290,7 +296,8 @@ public class DrawingManagerTest {
Graphics2D g2d = img.createGraphics();
drawingManager.drawProject(g2d, project, EnumSet.of(DrawOption.ZOOM), null, null,
Collections.emptyList(), Collections.emptySet(), Collections.emptySet(), null, null, false, 1.0, 1.0, null);
Collections.emptyList(), Collections.emptySet(), Collections.emptySet(), null, null, false,
Collections.emptySet(), 1.0, 1.0, null);
g2d.dispose();
@@ -379,6 +386,352 @@ public class DrawingManagerTest {
assertEquals(-1.0, wrapper.getTransform().getScaleY(), 0.001);
}
@Test
public void testOffScreenComponentSkippedOnceAreaIsTracked() {
Project project = new Project();
DrawCountingComponent component = new DrawCountingComponent();
project.getComponents().add(component);
DrawingManager drawingManager = createDrawingManager();
drawVisibleRect(drawingManager, project, new Rectangle2D.Double(0, 0, 100, 100));
// the first pass records the area, so it has to draw even though the component is off-screen
assertEquals(1, component.drawCount);
drawVisibleRect(drawingManager, project, new Rectangle2D.Double(0, 0, 100, 100));
assertEquals(1, component.drawCount);
drawVisibleRect(drawingManager, project, new Rectangle2D.Double(400, 400, 200, 200));
assertEquals(2, component.drawCount);
drawVisibleRect(drawingManager, project, null);
assertEquals(3, component.drawCount);
}
@Test
public void testOffScreenComponentDrawnAgainAfterInvalidation() {
Project project = new Project();
DrawCountingComponent component = new DrawCountingComponent();
project.getComponents().add(component);
DrawingManager drawingManager = createDrawingManager();
drawVisibleRect(drawingManager, project, new Rectangle2D.Double(0, 0, 100, 100));
drawingManager.invalidateComponent(component);
drawVisibleRect(drawingManager, project, new Rectangle2D.Double(0, 0, 100, 100));
assertEquals(2, component.drawCount);
}
@Test
public void testUntrackedDrawingKeepsComponentOnScreen() {
Project project = new Project();
DrawCountingComponent component = new DrawCountingComponent();
component.labelPosition = new Point2D.Double(50, 50);
project.getComponents().add(component);
DrawingManager drawingManager = createDrawingManager();
drawVisibleRect(drawingManager, project, new Rectangle2D.Double(0, 0, 100, 100));
drawVisibleRect(drawingManager, project, new Rectangle2D.Double(0, 0, 100, 100));
// the label lies outside the selection area but inside the visible rect
assertEquals(2, component.drawCount);
}
@Test
public void testOffScreenCachedComponentContributesContinuity() {
Project project = new Project();
CachedConductorComponent component = new CachedConductorComponent();
project.getComponents().add(component);
DrawingManager drawingManager = createDrawingManager();
drawCachedAndClipped(drawingManager, project, 100);
// the netlist and continuity checks are built from the areas recorded while painting
assertEquals(1, drawingManager.getContinuityAreas().size());
assertEquals(1, component.drawCount);
drawCachedAndClipped(drawingManager, project, 100);
assertEquals(1, component.drawCount);
}
@Test
public void testCachedComponentRedrawnOnlyAfterInvalidation() {
Project project = new Project();
CachedConductorComponent component = new CachedConductorComponent();
project.getComponents().add(component);
DrawingManager drawingManager = createDrawingManager();
drawCachedAndClipped(drawingManager, project, 1000);
drawCachedAndClipped(drawingManager, project, 1000);
assertEquals(1, component.drawCount);
// the cache does not look for changes itself, whoever changes a component invalidates it
component.setTestField("changed");
drawCachedAndClipped(drawingManager, project, 1000);
assertEquals(1, component.drawCount);
DrawingCache.Instance.invalidate(component);
drawCachedAndClipped(drawingManager, project, 1000);
assertEquals(2, component.drawCount);
}
@Test
public void testMovingComponentBypassesCache() {
Project project = new Project();
CachedConductorComponent component = new CachedConductorComponent();
project.getComponents().add(component);
DrawingManager drawingManager = createDrawingManager();
// a component moved along by a drag is drawn directly on every frame...
drawCachedAndClipped(drawingManager, project, 1000, Set.of(component));
drawCachedAndClipped(drawingManager, project, 1000, Set.of(component));
assertEquals(2, component.drawCount);
// ...and leaves nothing in the cache, so the first ordinary repaint renders it afresh
drawCachedAndClipped(drawingManager, project, 1000);
assertEquals(3, component.drawCount);
}
private void drawCachedAndClipped(DrawingManager drawingManager, Project project, int size) {
drawCachedAndClipped(drawingManager, project, size, Collections.emptySet());
}
private void drawCachedAndClipped(DrawingManager drawingManager, Project project, int size,
Set<IDIYComponent<?>> movingComponents) {
BufferedImage img = new BufferedImage(1000, 1000, BufferedImage.TYPE_INT_ARGB);
Graphics2D g2d = img.createGraphics();
// CanvasPanel clips its buffer to the visible rect before drawing
g2d.setClip(0, 0, size, size);
drawingManager.drawProject(g2d, project, EnumSet.of(DrawOption.ZOOM, DrawOption.ENABLE_CACHING),
null, null, Collections.emptyList(), Collections.emptySet(), Collections.emptySet(), null,
null, false, movingComponents, 1.0, 1.0, new Rectangle2D.Double(0, 0, size, size));
g2d.dispose();
}
@Test
public void testOutlineAreaUnaffectedByLaterContinuityShapes() {
BufferedImage img = new BufferedImage(100, 100, BufferedImage.TYPE_INT_ARGB);
G2DWrapper wrapper =
new G2DWrapper(img.createGraphics(), 1.0, EnumSet.noneOf(DrawOption.class));
wrapper.startedDrawingComponent(0);
wrapper.startTrackingContinuityArea(true);
wrapper.setContinuityMarker("lead");
// the first shape counts towards both the outline and the marker's continuity area, the second
// only towards the continuity area
wrapper.fill(new Rectangle2D.Double(0, 0, 10, 10));
wrapper.stopTracking();
wrapper.fill(new Rectangle2D.Double(50, 50, 10, 10));
wrapper.stopTrackingContinuityArea();
ComponentArea area = wrapper.finishedDrawingComponent();
assertEquals(new Rectangle2D.Double(0, 0, 10, 10), area.getOutlineArea().getBounds2D());
assertEquals(1, area.getContinuityPositiveAreas().size());
assertEquals(new Rectangle2D.Double(0, 0, 60, 60),
area.getContinuityPositiveAreas().iterator().next().getBounds2D());
}
@Test
public void testOutlineAreaFollowsComponentTransform() {
BufferedImage img = new BufferedImage(200, 200, BufferedImage.TYPE_INT_ARGB);
G2DWrapper wrapper =
new G2DWrapper(img.createGraphics(), 1.0, EnumSet.noneOf(DrawOption.class));
wrapper.startedDrawingComponent(0);
wrapper.translate(100, 50);
wrapper.rotate(Math.PI / 2);
wrapper.fill(new Rectangle2D.Double(0, 0, 20, 10));
ComponentArea area = wrapper.finishedDrawingComponent();
// rotated a quarter turn about the translated origin, the 20 x 10 rectangle spans
// x 90..100 and y 50..70
Rectangle2D bounds = area.getOutlineArea().getBounds2D();
assertEquals(90, bounds.getMinX(), 1e-6);
assertEquals(100, bounds.getMaxX(), 1e-6);
assertEquals(50, bounds.getMinY(), 1e-6);
assertEquals(70, bounds.getMaxY(), 1e-6);
}
@Test
public void testRecordedShapeUnaffectedByLaterChanges() {
BufferedImage img = new BufferedImage(100, 100, BufferedImage.TYPE_INT_ARGB);
G2DWrapper wrapper =
new G2DWrapper(img.createGraphics(), 1.0, EnumSet.noneOf(DrawOption.class));
wrapper.startedDrawingComponent(0);
Rectangle2D shape = new Rectangle2D.Double(0, 0, 10, 10);
wrapper.fill(shape);
// shapes are merged into areas only when asked for, so the recorded one must be a copy
shape.setRect(50, 50, 10, 10);
ComponentArea area = wrapper.finishedDrawingComponent();
assertEquals(new Rectangle2D.Double(0, 0, 10, 10), area.getOutlineArea().getBounds2D());
}
@Test
public void testOutlineBoundsMatchMergedArea() {
BufferedImage img = new BufferedImage(200, 200, BufferedImage.TYPE_INT_ARGB);
G2DWrapper wrapper =
new G2DWrapper(img.createGraphics(), 1.0, EnumSet.noneOf(DrawOption.class));
wrapper.startedDrawingComponent(0);
wrapper.fill(new Rectangle2D.Double(0, 0, 10, 10));
wrapper.translate(100, 50);
wrapper.fill(new java.awt.geom.Ellipse2D.Double(0, 0, 30, 20));
ComponentArea area = wrapper.finishedDrawingComponent();
Rectangle2D merged = area.getOutlineArea().getBounds2D();
Rectangle2D bounds = area.getOutlineBounds();
assertEquals(merged.getMinX(), bounds.getMinX(), 1e-6);
assertEquals(merged.getMinY(), bounds.getMinY(), 1e-6);
assertEquals(merged.getMaxX(), bounds.getMaxX(), 1e-6);
assertEquals(merged.getMaxY(), bounds.getMaxY(), 1e-6);
}
@Test
public void testContinuityShapesMergedPerMarker() {
BufferedImage img = new BufferedImage(200, 200, BufferedImage.TYPE_INT_ARGB);
G2DWrapper wrapper =
new G2DWrapper(img.createGraphics(), 1.0, EnumSet.noneOf(DrawOption.class));
wrapper.startedDrawingComponent(0);
wrapper.startTrackingContinuityArea(true);
wrapper.setContinuityMarker("pad");
wrapper.fill(new Rectangle2D.Double(0, 0, 10, 10));
wrapper.fill(new Rectangle2D.Double(5, 0, 10, 10));
// without a marker each shape is an area of its own
wrapper.setContinuityMarker(null);
wrapper.fill(new Rectangle2D.Double(100, 0, 10, 10));
wrapper.fill(new Rectangle2D.Double(150, 0, 10, 10));
wrapper.stopTrackingContinuityArea();
ComponentArea area = wrapper.finishedDrawingComponent();
assertEquals(3, area.getContinuityPositiveAreas().size());
boolean merged = false;
for (Area a : area.getContinuityPositiveAreas()) {
merged |= a.contains(2, 5) && a.contains(13, 5);
}
assertTrue(merged);
}
@Test
public void testOutlineAreaIsUnionOfTrackedShapes() {
BufferedImage img = new BufferedImage(100, 100, BufferedImage.TYPE_INT_ARGB);
G2DWrapper wrapper =
new G2DWrapper(img.createGraphics(), 1.0, EnumSet.noneOf(DrawOption.class));
wrapper.startedDrawingComponent(0);
wrapper.fill(new Rectangle2D.Double(0, 0, 10, 10));
wrapper.fill(new Rectangle2D.Double(50, 50, 10, 10));
ComponentArea area = wrapper.finishedDrawingComponent();
assertTrue(area.getOutlineArea().contains(5, 5));
assertTrue(area.getOutlineArea().contains(55, 55));
assertTrue(!area.getOutlineArea().contains(30, 30));
}
@Test
public void testContinuityCalculationLeavesRecordedAreasIntact() {
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);
// the cut splits the strip in two for the netlist...
assertEquals(2, drawingManager.getContinuityAreas().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 =
drawingManager.getComponentArea(strip).getContinuityPositiveAreas().iterator().next();
assertTrue(recorded.contains(50, 5));
}
private void drawVisibleRect(DrawingManager drawingManager, Project project,
Rectangle2D visibleRect) {
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, visibleRect);
g2d.dispose();
}
public static class DrawCountingComponent extends MockDIYComponent {
private static final long serialVersionUID = 1L;
public int drawCount = 0;
public Point2D labelPosition = null;
@Override
public void draw(Graphics2D g2d, ComponentState componentState, boolean outlineMode,
Project project, IDrawingObserver drawingObserver) {
drawCount++;
g2d.fill(new Rectangle2D.Double(500, 500, 20, 20));
if (labelPosition != null) {
drawingObserver.stopTracking();
g2d.drawString("R1", (float) labelPosition.getX(), (float) labelPosition.getY());
}
}
}
@ComponentDescriptor(name = "CachedConductor", author = "test", category = "Test",
instanceNamePrefix = "C", description = "Cached conductive component for testing",
zOrder = IDIYComponent.COMPONENT, enableCache = true)
public static class CachedConductorComponent extends MockDIYComponent {
private static final long serialVersionUID = 1L;
public int drawCount = 0;
private Point2D controlPoint = new Point2D.Double(500, 500);
@Override
public int getControlPointCount() {
return 1;
}
@Override
public Point2D getControlPoint(int index) {
return controlPoint;
}
@Override
public Rectangle2D getCachingBounds() {
return new Rectangle2D.Double(490, 490, 40, 40);
}
@Override
public void draw(Graphics2D g2d, ComponentState componentState, boolean outlineMode,
Project project, IDrawingObserver drawingObserver) {
drawCount++;
drawingObserver.startTrackingContinuityArea(true);
g2d.fill(new Rectangle2D.Double(500, 500, 20, 20));
drawingObserver.stopTrackingContinuityArea();
}
}
public static class ConductorComponent extends MockDIYComponent {
private static final long serialVersionUID = 1L;
private Rectangle2D shape;
private boolean positive;
public ConductorComponent(Rectangle2D shape, boolean positive) {
this.shape = shape;
this.positive = positive;
}
@Override
public void draw(Graphics2D g2d, ComponentState componentState, boolean outlineMode,
Project project, IDrawingObserver drawingObserver) {
drawingObserver.startTrackingContinuityArea(positive);
g2d.fill(shape);
drawingObserver.stopTrackingContinuityArea();
}
}
// Simple delegating Graphics2D proxy for testing
private static class Graphics2DProxy extends Graphics2D {
private final Graphics2D delegate;
@@ -0,0 +1,74 @@
/*
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 java.awt.Dimension;
import java.awt.Graphics2D;
import java.awt.geom.Rectangle2D;
import java.awt.image.BufferedImage;
import org.diylc.core.Project;
import org.diylc.core.measures.Size;
import org.diylc.core.measures.SizeUnit;
import org.diylc.utils.Constants;
import org.junit.Test;
public class GridRendererTest {
@Test
public void testEveryLineDrawnWhenSpacedApart() {
// 0.5 mm is 3.94 px at 100%, so 101 lines fit in 400 px
assertEquals(101, countVerticalLines(1, new Rectangle2D.Double(0, 0, 400, 300)));
}
@Test
public void testDenseGridThinnedToEveryFifthLine() {
// 0.5 mm is 2.95 px at 75%, below the minimum, so lines are 14.76 px apart
assertEquals(27, countVerticalLines(0.75, new Rectangle2D.Double(0, 0, 400, 300)));
}
@Test
public void testOnlyVisibleRectDrawn() {
assertEquals(25, countVerticalLines(1, new Rectangle2D.Double(0, 0, 100, 300)));
}
private int countVerticalLines(double zoom, Rectangle2D visibleRect) {
Project project = new Project();
project.setGridSpacing(new Size(0.5, SizeUnit.mm));
BufferedImage img = new BufferedImage(400, 300, BufferedImage.TYPE_INT_ARGB);
Graphics2D g2d = img.createGraphics();
GridRenderer.draw(g2d, project, Constants.DEFAULT_THEME, zoom, 0, new Dimension(1000, 1000),
new Dimension(1000, 1000), visibleRect);
g2d.dispose();
// the first horizontal line is further down than this row
int lines = 0;
for (int x = 0; x < img.getWidth(); x++) {
if ((img.getRGB(x, 1) >>> 24) != 0) {
lines++;
}
}
return lines;
}
}
@@ -0,0 +1,118 @@
/*
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.assertTrue;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
import org.diylc.core.ComponentGroup;
import org.diylc.core.IDIYComponent;
import org.diylc.core.Project;
import org.diylc.testcomponents.MockDIYComponent;
import org.junit.Test;
public class GroupAndLockIndexTest {
@Test
public void testGroupMembersFoundById() {
Project project = new Project();
MockDIYComponent a = add(project);
MockDIYComponent b = add(project);
MockDIYComponent single = add(project);
group(project, a, b);
GroupAndLockIndex index = new GroupAndLockIndex();
assertEquals(Set.of(a, b), index.findGroup(project, a));
assertEquals(Set.of(a, b), index.findGroup(project, b));
assertEquals(Set.of(single), index.findGroup(project, single));
// a component outside the project, such as one being placed, is matched by its id
MockDIYComponent copy = new MockDIYComponent();
copy.setId(a.getId());
assertEquals(Set.of(a, b), index.findGroup(project, copy));
}
@Test
public void testGroupChangesSeenAfterInvalidation() {
Project project = new Project();
MockDIYComponent a = add(project);
MockDIYComponent b = add(project);
group(project, a, b);
GroupAndLockIndex index = new GroupAndLockIndex();
assertEquals(Set.of(a, b), index.findGroup(project, a));
project.getGroupsEx().clear();
index.invalidate();
assertEquals(Set.of(a), index.findGroup(project, a));
}
@Test
public void testAnotherProjectRebuildsWithoutInvalidation() {
Project first = new Project();
MockDIYComponent a = add(first);
MockDIYComponent b = add(first);
group(first, a, b);
GroupAndLockIndex index = new GroupAndLockIndex();
assertEquals(Set.of(a, b), index.findGroup(first, a));
Project second = new Project();
second.getComponents().add(a);
assertEquals(Set.of(a), index.findGroup(second, a));
}
@Test
public void testLockedComponentsIncludeLockedLayers() {
Project project = new Project();
MockDIYComponent a = add(project);
MockDIYComponent b = add(project);
GroupAndLockIndex index = new GroupAndLockIndex();
assertTrue(index.getLockedComponents(project).isEmpty());
project.getLockedComponents().add(a);
index.invalidate();
assertEquals(Set.of(a), index.getLockedComponents(project));
project.getLockedLayers().add(IDIYComponent.COMPONENT);
index.invalidate();
assertEquals(Set.of(a, b), index.getLockedComponents(project));
}
private MockDIYComponent add(Project project) {
MockDIYComponent component = new MockDIYComponent();
project.getComponents().add(component);
return component;
}
private void group(Project project, IDIYComponent<?>... components) {
Set<UUID> ids = new HashSet<UUID>();
for (IDIYComponent<?> component : components) {
ids.add(component.getId());
}
project.getGroupsEx().add(new ComponentGroup(ids));
}
}
@@ -42,7 +42,7 @@ import org.diylc.utils.Constants;
@ComponentDescriptor(name = "Hookup Wire", author = "Branislav Stojkovic", category = "Connectivity",
instanceNamePrefix = "W", description = "Flexible wire with two control points", zOrder = IDIYComponent.WIRING,
flexibleZOrder = true, bomPolicy = BomPolicy.NEVER_SHOW, creationMethod = CreationMethod.POINT_BY_POINT,
transformer = SimpleComponentTransformer.class, enableCache = false)
transformer = SimpleComponentTransformer.class, enableCache = true)
public class HookupWire extends AbstractCurvedComponent<AWG> implements IContinuity {
private static final long serialVersionUID = 1L;
@@ -46,7 +46,7 @@ import org.diylc.utils.Constants;
@ComponentDescriptor(name = "Right Angle Wire", author = "Branislav Stojkovic", category = "Connectivity",
instanceNamePrefix = "W", description = "Wire that runs horizontally and vertically and turns at right angles",
zOrder = IDIYComponent.WIRING, flexibleZOrder = true, bomPolicy = BomPolicy.NEVER_SHOW,
transformer = OrthogonalComponentTransformer.class, enableCache = false, creationMethod = CreationMethod.POINT_BY_POINT)
transformer = OrthogonalComponentTransformer.class, enableCache = true, creationMethod = CreationMethod.POINT_BY_POINT)
public class OrthogonalWire extends AbstractOrthogonalComponent<AWG> implements IContinuity {
private static final long serialVersionUID = 1L;
@@ -20,6 +20,7 @@ package org.diylc.components.connectivity;
import java.awt.BasicStroke;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.Shape;
import java.awt.Stroke;
import java.awt.geom.Area;
import java.awt.geom.CubicCurve2D;
@@ -29,9 +30,7 @@ import java.awt.geom.PathIterator;
import java.awt.geom.Point2D;
import java.awt.geom.Rectangle2D;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.stream.IntStream;
import org.diylc.common.LineStyle;
import org.diylc.common.ObjectCache;
@@ -45,6 +44,7 @@ import org.diylc.core.IDrawingObserver;
import org.diylc.core.annotations.BomPolicy;
import org.diylc.core.annotations.ComponentDescriptor;
import org.diylc.core.annotations.EditableProperty;
import org.diylc.presenter.AreaUtils;
import org.diylc.utils.Constants;
@ComponentDescriptor(name = "Twisted Leads", author = "Branislav Stojkovic",
@@ -78,6 +78,8 @@ public class TwistedWire extends AbstractCurvedComponent<Void> implements IConti
transient private Area firstLeadStripeArea = null;
transient private Area secondLeadStripeArea = null;
transient private LeadGeometry leadGeometry = null;
@Override
protected Color getDefaultColor() {
return COLOR;
@@ -97,6 +99,89 @@ public class TwistedWire extends AbstractCurvedComponent<Void> implements IConti
@Override
protected void drawCurve(Path2D curve, Graphics2D g2d, ComponentState componentState,
IDrawingObserver drawingObserver) {
// stroking paints some pixels more than once, which would show through on a translucent wire,
// so that case builds each lead as an area and paints it once instead
if (getAlpha().getValue().doubleValue() < MAX_ALPHA) {
drawCurveWithAreas(curve, g2d, componentState, drawingObserver);
} else {
drawCurveWithStrokes(curve, g2d, componentState, drawingObserver);
}
}
private void drawCurveWithStrokes(Path2D curve, Graphics2D g2d, ComponentState componentState,
IDrawingObserver drawingObserver) {
int thickness = getThickness();
if (leadGeometry == null) {
leadGeometry = buildLeadGeometry(curve, thickness);
}
boolean selected =
componentState == ComponentState.SELECTED || componentState == ComponentState.DRAGGING;
boolean normal = componentState == ComponentState.NORMAL;
Color firstColor = selected ? SELECTION_COLOR : color;
Color secondColor = selected ? SELECTION_COLOR : color2;
float outlineWidth = 0;
if (normal) {
// as wide as the 1px outline of a regular wire: the canvas narrows a thin stroke to keep it
// one pixel wide when zoomed in, so the stroke it ends up with gives that width
g2d.setStroke(ObjectCache.getInstance().fetchBasicStroke(1f));
outlineWidth = ((BasicStroke) g2d.getStroke()).getLineWidth();
}
drawLead(g2d, leadGeometry.firstLead, firstColor, normal && stripe1, stripeColor,
outlineWidth, thickness, drawingObserver);
drawLead(g2d, leadGeometry.secondLead, secondColor, normal && stripe2, stripeColor2,
outlineWidth, thickness, drawingObserver);
// the second lead was drawn last and lies on top everywhere; redrawing the first lead inside
// its own crossings puts it on top there. Both leads share one colour when selected.
if (!selected) {
Shape oldClip = g2d.getClip();
g2d.clip(leadGeometry.firstOnTop);
drawLead(g2d, leadGeometry.firstLead, firstColor, normal && stripe1, stripeColor,
outlineWidth, thickness, null);
g2d.setClip(oldClip);
}
}
// drawingObserver is null for a redraw that must not be recorded again; outlineWidth is 0 for
// no outline
private void drawLead(Graphics2D g2d, Path2D lead, Color leadColor, boolean stripe,
Color leadStripeColor, float outlineWidth, int thickness, IDrawingObserver drawingObserver) {
boolean outline = outlineWidth > 0;
float innerWidth = Math.max(thickness - outlineWidth, 1);
// the first stroke covers the whole lead, so it is the one recorded as the component's area
if (drawingObserver != null) {
drawingObserver.startTracking();
}
if (outline) {
// a dark band centred on the lead's edge, like an outline drawn around an area
g2d.setColor(leadColor.darker());
g2d.setStroke(ObjectCache.getInstance().fetchBasicStroke(thickness + outlineWidth));
g2d.draw(lead);
if (drawingObserver != null) {
drawingObserver.stopTracking();
}
g2d.setColor(leadColor);
g2d.setStroke(ObjectCache.getInstance().fetchBasicStroke(innerWidth));
g2d.draw(lead);
} else {
g2d.setColor(leadColor);
g2d.setStroke(ObjectCache.getInstance().fetchBasicStroke(thickness));
g2d.draw(lead);
if (drawingObserver != null) {
drawingObserver.stopTracking();
}
}
if (stripe) {
g2d.setColor(leadStripeColor);
g2d.setStroke(getStripeStroke(thickness, outline ? innerWidth : thickness));
g2d.draw(lead);
}
}
private void drawCurveWithAreas(Path2D curve, Graphics2D g2d, ComponentState componentState,
IDrawingObserver drawingObserver) {
int thickness = getThickness();
Color curveColor1 =
componentState == ComponentState.SELECTED || componentState == ComponentState.DRAGGING
@@ -188,6 +273,7 @@ public class TwistedWire extends AbstractCurvedComponent<Void> implements IConti
this.secondLeadArea = null;
this.firstLeadStripeArea = null;
this.secondLeadStripeArea = null;
this.leadGeometry = null;
}
@Override
@@ -199,6 +285,7 @@ public class TwistedWire extends AbstractCurvedComponent<Void> implements IConti
this.secondLeadArea = null;
this.firstLeadStripeArea = null;
this.secondLeadStripeArea = null;
this.leadGeometry = null;
}
@Override
@@ -210,6 +297,7 @@ public class TwistedWire extends AbstractCurvedComponent<Void> implements IConti
this.secondLeadArea = null;
this.firstLeadStripeArea = null;
this.secondLeadStripeArea = null;
this.leadGeometry = null;
}
@Override
@@ -272,6 +360,7 @@ public class TwistedWire extends AbstractCurvedComponent<Void> implements IConti
this.secondLeadArea = null;
this.firstLeadStripeArea = null;
this.secondLeadStripeArea = null;
this.leadGeometry = null;
}
}
@@ -289,6 +378,7 @@ public class TwistedWire extends AbstractCurvedComponent<Void> implements IConti
this.secondLeadArea = null;
this.firstLeadStripeArea = null;
this.secondLeadStripeArea = null;
this.leadGeometry = null;
}
}
@@ -296,6 +386,44 @@ public class TwistedWire extends AbstractCurvedComponent<Void> implements IConti
public void recalculate(Path2D path, float thickness, Area firstLeadArea, Area secondLeadArea,
boolean stripe) {
LeadGeometry geometry = buildLeadGeometry(path, thickness);
Stroke stroke = stripe ? getStripeStroke(thickness, thickness)
: ObjectCache.getInstance().fetchBasicStroke(thickness);
Area[] outputAreas = new Area[2];
List<List<Path2D>> segments = List.of(geometry.firstSegments, geometry.secondSegments);
for (int j = 0; j < 2; j++) {
List<Area> segmentAreas = new ArrayList<Area>();
for (Path2D p : segments.get(j)) {
segmentAreas.add(new Area(stroke.createStrokedShape(p)));
}
outputAreas[j] = AreaUtils.union(segmentAreas);
}
// at overlapping points, decide which lead goes on top and clear the overlapping area from the
// lead below; all squares of one kind are cut in a single operation, because cutting them one
// at a time costs an operation over the whole lead for each
Area firstOnTopArea = new Area(geometry.firstOnTop);
firstOnTopArea.intersect(outputAreas[0]);
outputAreas[1].subtract(firstOnTopArea);
// taken after the first cut, so where squares of both kinds overlap only one lead loses it
Area secondOnTopArea = new Area(geometry.secondOnTop);
secondOnTopArea.intersect(outputAreas[1]);
outputAreas[0].subtract(secondOnTopArea);
// dump to the output areas
firstLeadArea.add(outputAreas[0]);
secondLeadArea.add(outputAreas[1]);
}
private Stroke getStripeStroke(float thickness, float width) {
return ObjectCache.getInstance().fetchStroke(width,
new float[] {thickness / 2, thickness * 2}, thickness * 10, BasicStroke.CAP_BUTT);
}
// works out the path of each lead as it winds around the curve, and the squares around the
// crossings where each lead goes on top
private LeadGeometry buildLeadGeometry(Path2D path, float thickness) {
PathIterator iterator = path.getPathIterator(null);
float[] coords = new float[6];
Point2D current = new Point2D.Double();
@@ -329,14 +457,6 @@ public class TwistedWire extends AbstractCurvedComponent<Void> implements IConti
iterator.next();
}
Stroke stroke;
if (stripe)
stroke = ObjectCache.getInstance().fetchStroke(thickness,
new float[] {thickness / 2, thickness * 2}, thickness * 10, BasicStroke.CAP_BUTT);
else
stroke = ObjectCache.getInstance().fetchBasicStroke(thickness);
double segmentLength = thickness * 6; // chosen empirically
// Convert to polygon
@@ -368,76 +488,51 @@ public class TwistedWire extends AbstractCurvedComponent<Void> implements IConti
double sign = i % 2 == 0 ? 1 : -1;
double[] thetas = new double[] {theta - sign * Math.PI / 2, theta + sign * Math.PI / 2};
final int finalI = i;
Arrays.stream(new Integer[] { 0, 1 }).parallel().forEach((j) -> {
for (int j = 0; j < 2; j++) {
currentPaths[j].quadTo(centerX + offset * Math.cos(thetas[j]),
centerY + offset * Math.sin(thetas[j]), line.getX2(), line.getY2());
if (finalI % 2 == 0 || finalI == polygon.size() - 1) {
if (i % 2 == 0 || i == polygon.size() - 1) {
segments.get(j).add(currentPaths[j]);
currentPaths[j] = new Path2D.Double();
currentPaths[j].moveTo(line.getX2(), line.getY2());
}
});
}
Area[] outputAreas = new Area[] {new Area(), new Area()};
// create stroked areas in parallel
Arrays.stream(new Integer[] {0, 1}).parallel().forEach((j) -> {
for (Path2D p : segments.get(j)) {
outputAreas[j].add(new Area(stroke.createStrokedShape(p)));
}
});
int threadCount = 1;
while (threadCount * 4 < polygon.size() && threadCount <= 8) {
threadCount++;
}
int chunkSize = polygon.size() / threadCount;
// at overlapping points, decide which lead goes on top and clear the overlapping area from the
// lead below
IntStream.range(0, threadCount).parallel().forEach(threadIdx -> {
for (int i = threadIdx * chunkSize; i < (threadIdx + 1) * chunkSize
&& i < polygon.size(); i++) {
Line2D line = polygon.get(i);
Area pointRect1 = new Area(new Rectangle2D.Double(line.getX1() - rectSize / 2,
line.getY1() - rectSize / 2, rectSize, rectSize));
Path2D firstOnTop = new Path2D.Double();
Path2D secondOnTop = new Path2D.Double();
for (int i = 0; i < polygon.size(); i++) {
Line2D line = polygon.get(i);
Rectangle2D pointRect1 = new Rectangle2D.Double(line.getX1() - rectSize / 2,
line.getY1() - rectSize / 2, rectSize, rectSize);
if (i % 2 == 1) {
firstOnTop.append(pointRect1, false);
} else {
secondOnTop.append(pointRect1, false);
}
if (i % 2 == 1) {
pointRect1.intersect(outputAreas[0]);
synchronized (outputAreas[1]) {
outputAreas[1].subtract(pointRect1);
}
if (i == polygon.size() - 1) {
Rectangle2D pointRect2 = new Rectangle2D.Double(line.getX2() - rectSize / 2,
line.getY2() - rectSize / 2, rectSize, rectSize);
if (i % 2 == 0) {
firstOnTop.append(pointRect2, false);
} else {
pointRect1.intersect(outputAreas[1]);
synchronized (outputAreas[0]) {
outputAreas[0].subtract(pointRect1);
}
}
if (i == polygon.size() - 1) {
Area pointRect2 = new Area(new Rectangle2D.Double(line.getX2() - rectSize / 2,
line.getY2() - rectSize / 2, rectSize, rectSize));
if (i % 2 == 0) {
pointRect2.intersect(outputAreas[0]);
synchronized (outputAreas[1]) {
outputAreas[1].subtract(pointRect2);
}
} else {
pointRect2.intersect(outputAreas[1]);
synchronized (outputAreas[0]) {
outputAreas[0].subtract(pointRect2);
}
}
secondOnTop.append(pointRect2, false);
}
}
});
}
// dump to the output areas
firstLeadArea.add(outputAreas[0]);
secondLeadArea.add(outputAreas[1]);
// each lead also as a single path, with its segments as separate sub-paths, for stroking
Path2D[] leads = new Path2D[2];
for (int j = 0; j < 2; j++) {
leads[j] = new Path2D.Double();
for (Path2D p : segments.get(j)) {
leads[j].append(p, false);
}
}
return new LeadGeometry(leads[0], leads[1], segments.get(0), segments.get(1), firstOnTop,
secondOnTop);
}
// splits a curve into a series of lines
@@ -484,4 +579,25 @@ public class TwistedWire extends AbstractCurvedComponent<Void> implements IConti
public boolean arePointsConnected(int index1, int index2) {
return Math.abs(index1 - index2) == getControlPointCount() - 1;
}
private static class LeadGeometry {
final Path2D firstLead;
final Path2D secondLead;
final List<Path2D> firstSegments;
final List<Path2D> secondSegments;
// squares around the crossings where the first or the second lead goes on top
final Path2D firstOnTop;
final Path2D secondOnTop;
LeadGeometry(Path2D firstLead, Path2D secondLead, List<Path2D> firstSegments,
List<Path2D> secondSegments, Path2D firstOnTop, Path2D secondOnTop) {
this.firstLead = firstLead;
this.secondLead = secondLead;
this.firstSegments = firstSegments;
this.secondSegments = secondSegments;
this.firstOnTop = firstOnTop;
this.secondOnTop = secondOnTop;
}
}
}
@@ -306,7 +306,11 @@ public class CanvasPanel extends JComponent implements Autoscroll {
Rectangle visibleRect = getVisibleRect();
if (bufferImage == null) {
// the buffer is redrawn in full on every paint, so only a change in size, which includes the
// HiDPI scale, calls for a new one; scrolling does not
if (bufferImage == null
|| bufferImage.getWidth() != (int) Math.round(visibleRect.width * scaleFactor)
|| bufferImage.getHeight() != (int) Math.round(visibleRect.height * scaleFactor)) {
createBufferImage(visibleRect, scaleFactor);
}
Graphics2D bufferG2d = (Graphics2D) bufferImage.getGraphics();
@@ -118,7 +118,6 @@ public class CanvasPlugin implements IPlugIn{
@Override
public void adjustmentValueChanged(AdjustmentEvent e) {
getCanvasPanel().invalidateCache();
getCanvasPanel().revalidate();
}
});
@@ -127,7 +126,6 @@ public class CanvasPlugin implements IPlugIn{
@Override
public void adjustmentValueChanged(AdjustmentEvent e) {
getCanvasPanel().invalidateCache();
getCanvasPanel().revalidate();
}
});
@@ -0,0 +1,264 @@
# Canvas Rendering: Caching and Off-Screen Culling
How the main canvas avoids redrawing work on every repaint. There are two mechanisms, both in
`diylc-core/.../presenter`:
- **Image caching** (`DrawingCache`) turns an expensive component into a bitmap copy while it is
unchanged.
- **Off-screen culling** (`DrawingManager` + `G2DWrapper`) skips components that cannot be seen.
Both are optimisations only. Exports, printing and the regression suite pass no visible rect, so
culling never applies to them, and with caching off the output is the same.
## 1. The repaint pipeline
1. `CanvasPanel.paint` takes `getVisibleRect()`, translates its back buffer to it and calls
`IPlugInPort.draw(..., visibleRect)`. That reaches `DrawingManager.drawProject`.
2. `drawProject` scales `visibleRect` by `1 / zoom`, so from here on it is in **project
coordinates**, the same space as component control points.
3. If `DrawOption.ENABLE_CACHING` is set, `DrawingCache.bulkPrepare` renders every stale cacheable
component that intersects the clip, **in parallel** on the common fork-join pool.
4. Components are then drawn one by one in z-order. For each one:
- `trackArea = lastDrawnStateMap.get(c) != state`. The selection and continuity areas in
`componentAreaMap` are recorded only on a draw where this is true: the first draw after load,
an invalidation or a state change.
- `offScreen = !trackArea && isOffScreen(c, visibleRect)` (see §3).
- `g2dWrapper.startedDrawingComponent(i)`. Then either `startRecordingDrawnBounds()` (when
tracking) or `stopTracking()`.
- Unless `offScreen`, call `drawComponent`. That goes through `DrawingCache.draw` when caching
is on, and straight to `component.draw` otherwise.
- `finishedDrawingComponent()` returns a `ComponentArea`. When tracking and the outline is not
empty, it is stored in `componentAreaMap` together with the state it was drawn in.
- Recording an area only keeps copies of the tracked shapes, transformed into project
coordinates. `ComponentArea` merges them into the outline and continuity areas the first time
one is asked for, pairwise (`AreaUtils.union`), and keeps the result. `hasOutline()` and
`getOutlineBounds()` work from the shapes without merging, and hit tests check the bounds
before the area, so storing areas, selection bounds and the rulers never merge anything.
The moved components of a drag are recorded on every frame; their areas are merged only
if something asks.
- Mirrored copies for board undersides are drawn after that, outside both mechanisms.
**Areas are invalidated** by `DrawingManager.invalidateComponent`, which Presenter calls for
components moved by a drag step and for edited components. They are cleared wholesale by
`clearComponentAreaMap`: project load, undo/redo (which goes through `loadProject`), delete,
rotate, mirror and operation-mode changes. **Zoom does not invalidate areas.** They live in project coordinates.
## 2. Image caching (`DrawingCache`)
### What gets cached
A component is cached when both of these hold:
- its type has `@ComponentDescriptor(enableCache = true)` (the default is `false`);
- `shouldCache` passes:
- **not in `ComponentState.DRAGGING`**. A dragged component changes every frame, so its image
would be thrown away immediately while still pushing useful entries out;
- **not moved along by a drag.** `Presenter.getMovingComponents()` passes the keys of the drag's
control-point map, which include components stuck to the dragged ones, as
`drawProject(..., movingComponents, ...)`. Those keep their normal appearance but are left out
of `bulkPrepare` and drawn directly, for the same reason as dragged ones;
- **at most `MAX_CACHED_IMAGE_PIXELS` (8 Mi pixels, 32 MB)**, measured as
`getCachingBounds()` × `zoom` × `scaleFactor` on each axis. A big board at high zoom on a
HiDPI screen would otherwise take a large share of the budget on its own.
Anything else is drawn directly. The same component can switch between the two paths as zoom
changes.
### Entries
- **Key:** the component instance. Components do not override `equals`/`hashCode`, so this is
identity.
- **Value (`CacheValue`):**
- a `TYPE_INT_ARGB` `ScaledBufferedImage` of `getCachingBounds()` at the current zoom and HiDPI
scale;
- the outline and continuity areas captured while rendering it;
- the cache bounds;
- the parameters the entry is valid for.
- **Validity.** On every lookup an entry is checked only against state, zoom, scale factor,
outline mode and `DrawOption`s, all cheap comparisons. The cache does **not** detect changes to
the component itself. An entry stays valid until someone calls `DrawingCache.invalidate`. This
replaced a reflective `equalsTo` against a clone kept in every entry, which ran twice per
cacheable component on every repaint.
- **Invalidation lives in `Presenter`:**
- `componentChanged(c)` invalidates both the areas and the cached image of one component.
- `invalidateChangedComponents(oldProject)` runs from `notifyProjectModifiedIfNeeded` and at the
end of a drag. It matches components to the pre-edit snapshot by id and calls
`componentChanged` for each one that is new or no longer `equalsTo` its snapshot. Every edit
already takes that snapshot and runs `Project.equals`, so this adds one comparison per
component per edit, not per repaint.
- Drag steps do not go through `notifyProjectModifiedIfNeeded`, so they call `componentChanged`
for the moved components explicitly.
- `loadProject` (including undo/redo) clears the whole cache, and a null snapshot does too.
- **Placement:** the image is drawn at the first control point minus an integer offset, so it can
sit up to one unit away from the cache bounds.
- **Off-screen components are not rendered into the cache.** `renderAndCache` returns `null` when
the caching bounds miss the clip, and `DrawingCache.draw` then draws the component directly.
Java2D discards the pixels, but the wrapper still records the component's areas. This matters
because the netlist, "highlight connected areas", compare and the chatbot all read continuity
from `componentAreaMap` (`DrawingManager.getContinuityAreas`). A cached conductor such as an
eyelet, solder pad, trace cut, jack, pot or stripboard must not drop out of it just because it is
off-screen. Culling (§3) means this direct draw happens once per invalidation, not on every
repaint.
### Memory: a byte budget with LRU eviction
- `imageCache` is an **access-ordered `LinkedHashMap`**. The cache keeps a running total of image
bytes (`width × height × 4`).
- **Budget:** `MAX_CACHE_BYTES = min(maxMemory / 4, 768 MB)`, which is 768 MB with the shipped
`-Xmx4096m`.
- A quarter of the heap leaves room for the project model, undo clones, area maps and exports.
- The 768 MB cap is a few full Retina canvases' worth of overlapping images.
- **Eviction:** after each insert, least recently used entries are evicted until the total is back
under budget. The entry just inserted is never evicted, even if it alone exceeds the budget.
- **Release:** every entry that leaves the cache (evicted, replaced, `invalidate`d or `clear`ed)
has `BufferedImage.flush()` called on it. That drops Java2D's accelerated (VRAM or native) copy,
which would otherwise live until finalisation.
- **Why not `SoftReference`:** this replaced a `SoftReference` map. HotSpot keeps soft referents
until the heap is nearly full. That kept the large pixel arrays alive, which G1 allocates as
humongous objects, so they could only be reclaimed by a full GC. That was the reason the
status-bar GC button helped. Strong references with explicit eviction let G1 reclaim the arrays
in ordinary young collections.
- **Clearing:** `clear()` runs on `Presenter.loadProject` (including undo/redo).
`invalidate(components)` runs on delete.
### Threading
`bulkPrepare` calls `renderAndCache` from parallel-stream threads.
- Every read and write of `imageCache`, and of the byte total, is under `synchronized (imageCache)`.
- The lock covers only the lookup and the insert. Rendering happens outside it.
- `renderStatsByType` is a `ConcurrentHashMap`. `Counter.add` is `synchronized`.
- The hit/miss counters are `AtomicLong`s.
## 3. Off-screen culling
### Measured drawn bounds
Culling needs a rectangle that contains *everything* a component draws. Neither existing candidate
can serve:
- **the tracked outline area** leaves out whatever is drawn after `stopTracking()`, typically
labels and decoration;
- **`getCachingBounds()`** is `null` by default and is a hand-tuned guess where it is implemented.
So `G2DWrapper` measures what each component actually draws:
- **When it measures.** Only while recording, which `DrawingManager` turns on for draws where
`trackArea` is true. Ordinary repaints pay nothing.
- **What it covers.** All drawing methods feed `appendDrawnBounds`, independent of
`trackingAllowed`. The result is transformed by `currentTx` into project coordinates, the same
space as the areas.
- **Bounds per drawing call:**
- Strokes: the shape bounds padded by `lineWidth / 2 × max(√2, miterLimit)` for `JOIN_MITER`, or
`× √2` otherwise. That covers square caps and miter spikes. A non-`BasicStroke` uses
`createStrokedShape(s).getBounds2D()`.
- Strings: `FontMetrics.getStringBounds`.
- Attributed strings: `TextLayout.getBounds`.
- Glyph vectors: `getVisualBounds`.
- Images: their destination rectangle, or the transformed image rectangle.
- **Cached components** never draw through the canvas wrapper. `DrawingCache.draw` reports the
entry's cache bounds through `mergeDrawnBounds` instead.
- **Storage.** The rectangle is stored as `ComponentArea.getDrawnBounds()`. It is `null` when
nothing was recorded.
### The rule
`DrawingManager` skips `drawComponent` when **all** of these hold:
1. the component's areas are already recorded for its current state (`!trackArea`);
2. a visible rect was passed in;
3. the component has measured drawn bounds;
4. those bounds, grown by `OFF_SCREEN_MARGIN` (4 units, for antialiasing and whole-pixel image
placement), do not intersect the visible rect.
The first condition is what keeps culling safe. Areas are only ever produced by drawing, so a
component is always drawn, visible or not, until its areas exist. Clicking to select, hover,
continuity and the netlist therefore never lose a component because it was off-screen.
These are never culled:
- the first draw after load or invalidation;
- components whose outline area came out empty (they are never stored, so `trackArea` stays true);
- every draw when no visible rect is passed (exports, printing, regression);
- mirrored underside copies, control points and the component slot.
Culled components are not counted in the per-type render stats.
## 4. Rules for component code
Each rule exists so that one of the mechanisms above stays correct:
- **Draw only through the `Graphics2D` passed to `draw()`.** Do not use `create()` or reach for
an underlying graphics object. Anything drawn that way is missing from the measured bounds, and
the component can be culled while part of it is visible. No component in core or library does
this today.
- **Change components through `Presenter`.** An edit that ends in
`notifyProjectModifiedIfNeeded` with a snapshot is detected automatically. Code that changes
components on any other path, as drag steps do, must call `Presenter.componentChanged`.
Otherwise the cached image, the stored areas and the drawn bounds all go stale. With culling,
stale bounds show up as a component missing at the edge of the view. Run with
`-Dorg.diylc.verifyDrawingCache=true` (`DrawingCache.VERIFY_CACHE`) to find a missed path: each
entry then keeps a clone, and every cache hit is compared against it, logging a warning naming
the component and re-rendering it.
- **For cached types, `getCachingBounds()` must contain everything drawn.** The cached image is
clipped to it, and the culling bounds come from it.
- **`draw()` is hot.** Fetch strokes from `ObjectCache` and do not log. See `CLAUDE.md`.
## 5. Diagnostics
Logging is at `DEBUG` (`diylc-swing/src/main/resources/log4j.properties`). Output goes to the
console and to a per-run file in `~/diylc/logs/`. `DrawingManager.logStats` writes at most once
every `statReportFrequencyMs` (60 s), after a repaint:
```
DrawingManager - Render stats: {type=count time; ...} drawn components only
DrawingManager - Page stats: <repaints> <avg ms>
DrawingCache - Render cache contains N elements, approx size is X MB of <budget> MB
DrawingCache - Render cache hits: H, misses: A not cached + B outdated, hit rate: R%,
re-rendered while painting: T
DrawingCache - Cache render distribution stats: {type: render: ..., paste: ...}
```
All counters accumulate over the session.
- **hits / misses** count only the `bulkPrepare` lookups. The second lookup in `draw()` would
otherwise hold the hit rate at 50% or more.
- **not cached** means the entry was missing: a first render or an eviction. It falls as the
budget grows. If it stays high while the size sits at the budget, the budget is too small.
- **outdated** means the component changed. The budget cannot reduce it.
- **re-rendered while painting** counts entries evicted between `bulkPrepare` and `draw` in the
same repaint. Anything above 0 means the visible set alone exceeds the budget.
Baseline from a complex layout before culling: 327 repaints averaging 26 ms.
- The cache sat at 61 MB of 768 MB with a 95.5% hit rate, nothing evicted, and nothing
re-rendered while painting.
- The largest single cost was the uncached `HookupWire`: about 224 drawn per repaint, roughly
9 ms of the 26 ms.
- `TwistedWire` cost about 96 ms per render.
## 6. Known limitations and next steps
Ordered by expected payoff.
1. **Measure culling.** Repeat the baseline above on the same layout, zoomed in so that part of it
is off-screen. Compare the "Page stats" average, not per-type counts, since culled draws are
no longer counted.
2. **Run the regression suite.** It has not been run on these changes. Exports draw everything, so
the run guards against exceptions in the new bounds code rather than against culling.
3. **Zoom does not invalidate areas or drawn bounds.** Content that is larger at another zoom
could be culled at the edge of the view. Raise `OFF_SCREEN_MARGIN`, or invalidate on zoom, if
that shows up.
4. **`HookupWire` and `TwistedWire`.** If culling is not enough, either enable caching for
`HookupWire` (large, mostly empty bounding boxes, but plenty of budget) or profile it. Profile
`TwistedWire.draw` in any case.
5. **Components over the pixel limit are drawn directly.** That may make large perfboards and
stripboards slow at high zoom. Caching only the visible tile of such components would fix it.
6. **Reuse the image buffer** when a stale entry has the same pixel size: clear it and redraw.
That removes allocation churn for property edits. It must be safe against an export rendering
concurrently.
7. **`DrawingCache.Instance` is a static singleton.** `ProjectPreview` calls `loadProject`, which
clears it. If the preview runs its own `Presenter`, browsing files wipes the main canvas
cache. Check, and give each `Presenter` its own cache if so.
8. **Shrink the budget under memory pressure** with a `MemoryPoolMXBean` usage threshold. This
is only worth doing if users run with a much smaller `-Xmx`.
@@ -0,0 +1,236 @@
# Drawing Engine Performance
The remaining repaint costs in the canvas drawing pipeline, ranked by expected payoff, and what
each fix needs. The image cache and off-screen culling that already exist are described in
[`../adr/canvas-rendering-cache-and-culling.md`](../adr/canvas-rendering-cache-and-culling.md),
which also lists their own open issues. This plan covers everything else.
How a repaint flows: `CanvasPanel.paint` → `Presenter.draw` → `DrawingManager.drawProject`. That
calls `DrawingCache.bulkPrepare` (parallel), then runs the per-component loop through
`DrawingCache.draw` / `G2DWrapper` → `component.draw()`.
## 0. Measurements
**How to measure.**
- **Recording.** Add `-XX:StartFlightRecording=duration=60s,filename=drag.jfr` to the run
configuration, then drag, scroll, zoom and edit a complex layout.
- **Reading it.** `jfr view hot-methods drag.jfr`, `jfr view gc drag.jfr` and
`jfr view allocation-by-site drag.jfr` give the overview. For DIYLC call sites, export the samples
with `jfr print --json --events jdk.ExecutionSample --stack-depth 96` (or
`jdk.ObjectAllocationSample`) and group them by the innermost `org.diylc` frame.
- **Before and after a change,** compare the `DrawingManager` "Page stats" average at the same zoom
and viewport, plus the `DrawingCache` lines (see the ADR, §5). Compare total time, not per-type
counts, because culled components are not counted.
**Profile of 2026-10-14.** Complex layout, wires cached, culling and invalidation-based caching in
place. 750 CPU samples over 60 s, so treat the shares as approximate.
| Share | Where | Item |
| --- | --- | --- |
| ~52% of CPU samples | `TwistedWire.recalculate` and the `Area` operations under it, mostly on the `bulkPrepare` workers (fixed, see §7) | §7 |
| ~7% of CPU samples | `G2DWrapper.appendShape` recording `TwistedWire`'s outline (`currentArea.add`) | §2a |
| 16% of CPU samples | Marlin rasterisation (`Renderer._endRendering`): the drawing itself | — |
| ~19% of EDT samples | `Presenter.findAllGroupedComponents`, from `Presenter.draw` and `mouseMoved` | §6 |
| 958 MB / 60 s | pixel buffers for cache renders | — |
| 426 MB / 60 s | grid image rebuilt on zoom changes (fixed, see §3) | §3 |
| 386 MB / 60 s | canvas back buffer re-created on every scroll step (fixed, see §5) | §5 |
GC pauses totalled 348 ms. Most were under 40 ms, with one young-generation pause of 115 ms during
heavy allocation.
**Profile of 2026-10-14, later (P4),** mostly dragging parts with twisted wires attached: 707 CPU
samples, 383 of them `TwistedWire` geometry on the `bulkPrepare` workers, and 4.75 GB of cached
images allocated in 60 s. Wires stuck to a dragged part kept the `NORMAL` state, so every frame
re-rendered them into a new cache image. Moved-along components now bypass the cache (ADR §2).
The geometry itself still runs on every frame, because the wire's shape really changes; see §7.
Grouping fell to 12 samples after §6, and the per-component overhead (now under "Not worth
optimizing now") showed 1 sample.
**P5 (stroke-drawn twisted wires, moved components bypass the cache):** 248 CPU samples against
707 in P4 for a similar drag session; `TwistedWire` geometry 35 samples (was 383), workers 70
(was 537), total allocation 2.3 GB (was 7.4 GB), cached images 0.98 GB (was 4.75 GB), longest GC
pause 42 ms (was 71 ms). Recording areas was then the largest cost, about 37% of samples, mostly
for the moved wires on every frame; §2a addresses it.
**P6 (lazy area merging):** 174 CPU samples for a similar drag session; area recording 0 (was
92), on-demand merges from clicks and hovers 6, total allocation 1.45 GB, longest GC pause 13 ms.
The cursor check briefly cost 17 samples recomputing outline bounds on every mouse move, fixed by
caching them in `ComponentArea`. Rasterising (Marlin) is now the largest cost at about a third of
the samples, so the remaining gains come from drawing less: §2b and §5. Image copies measured 3
samples, which puts §4 low.
**Baseline before culling,** from the same layout: 327 repaints averaging 26 ms; `HookupWire`
(then uncached) about 9 ms per repaint; `TwistedWire` about 96 ms per render; `TubeSocket` 572 ms
in `DrawingManager` against 25 ms of image pasting.
## 1. Reflection on every edit
**Done:** the per-repaint reflective comparison is gone. `DrawingCache` now relies on `Presenter`
invalidating changed components; see the ADR, §2.
**What is left.** Reflection now runs once per edit instead of on every repaint. Each edit:
- clones the whole project (`AbstractComponent.clone()` walks the fields with reflection);
- runs `Project.equals`;
- runs `Presenter.invalidateChangedComponents`, which compares every component with its snapshot.
All three use the same `getDeclaredFields()` / `setAccessible` / `Field.get` walk. It is felt
mostly on repeated edits, such as holding an arrow key to nudge a large project.
**Fix.** Cache each class's comparable fields once, with `setAccessible` already applied, in a
`ClassValue`, and use it in both `equalsTo` and `clone`. Behaviour stays identical. Unit-test the
new `equalsTo` against the current one on a few real components from `diylc-library`.
**Watch.** Missed invalidation paths are now a correctness risk. Run with
`-Dorg.diylc.verifyDrawingCache=true` while testing new edit operations or plugins that change
components.
## 2. Dragging redraws everything
Each drag step invalidates the moved components, and the next frame redraws the whole visible
project.
**2a. Area recording during drags: done.** Recording areas used to merge every tracked shape into
an `Area` as it was drawn, which cost the most for the components moved on every frame. Recording
now keeps copies of the shapes, and `ComponentArea` merges them pairwise the first time an area is
asked for (ADR §1). `debugAreaPerformance` keeps an eagerly merged outline of its own for its check,
and `Snapshot`, the test recorder, now stores shapes rather than areas in its `ComponentArea`s.
**2b. Draw the drag over a frozen background.**
- At drag start, render everything that is not moving into a background image once. Each frame
then pastes that image and draws only the moving components, the control points and the
selection.
- Re-render the background when the viewport scrolls or the drag ends.
- **Stacking order:** a moving part that sits under other parts would draw on top of them while
dragging. Either accept that for the length of the drag, or split the background into a layer
below the lowest moving component and a layer above it, and draw the moving parts between them.
- Components that move along with the dragged ones without being selected (stuck components) must
count as moving. `Presenter` knows which these are from the control-point map.
- This is the biggest structural win for dragging, and the most invasive change in this plan. It
touches `CanvasPanel`, `Presenter.draw` and `DrawingManager.drawProject`.
## 3. Grid: done
`DrawingManager.drawGrid` used to cache the grid as one image the size of the whole canvas at the
current zoom. That was up to ~190 MB at high zoom, outside the cache budget, and 426–1,041 MB of
grid images were allocated per profiled minute of zooming. It was rendered at 1× and stretched on
HiDPI, and its rebuild check ignored grid spacing, dot spacing and theme.
It now lives in `GridRenderer` (tested by `GridRendererTest`), which draws the lines and dots
directly, and only those inside the visible rect:
- **Antialiasing off for the grid.** The lines are axis-aligned, so each one snaps to crisp whole
pixels instead of being smeared across two. The thickness formula is unchanged: 1 px at zoom
≥ 1, and `zoom` px below that.
- **Dense grids are thinned.** When lines would be closer than `GridRenderer.MIN_GRID_LINE_SPACING` (3 canvas
pixels at the current zoom), only every 5th line is drawn, then every 25th, and so on. Snapping
still uses the real spacing. A 0.5 mm grid shows every line at 100% and every 5th line from
75% down.
- **Exports and printing** pass no visible rect and get the whole grid as vector lines, instead of
a raster image embedded in the PDF or SVG.
One viewport of a 0.5 mm grid draws in under a millisecond. There is no cached state any more, so
the grid cannot go stale.
## 4. Image formats force slow compositing
- **The canvas back buffer** (`CanvasPanel.createBufferImage`) is `TYPE_INT_ARGB`, though the
canvas is always filled with an opaque background. Use an opaque compatible image,
`getGraphicsConfiguration().createCompatibleImage(w, h, Transparency.OPAQUE)`, kept HiDPI-scaled
as `ScaledBufferedImage` does now, so that copying it to the screen takes the fast path.
- **Cached component images** (`DrawingCache.renderAndCache`) are `TYPE_INT_ARGB`, so every paste
blends each pixel without premultiplied alpha. `TYPE_INT_ARGB_PRE` blends faster. Confirm that
`ScaledBufferedImage` and anything else reading the pixels is fine with the format.
Each is a one-line change, but Java2D's speed varies by pipeline (Metal on macOS), so keep each
change only if the page-time comparison shows a gain.
## 5. Every paint redraws the whole visible area
**Done:** the back buffer used to be thrown away on every scroll-bar event. `CanvasPlugin` called
`invalidateCache()` from both scroll listeners, which allocated 386 MB in the profiled minute.
`CanvasPanel.paint` now re-creates the buffer only when its pixel size changes, which includes a
change of HiDPI scale.
**Problem.** `CanvasPanel.paint` ignores the clip that Swing passes in. It always redraws the full
visible rect into a back buffer sized and positioned to the viewport. Scrolling, which only
exposes a thin strip, and partial repaints after a tooltip or balloon closes, both pay for a full
redraw.
**Fix.** Redraw only `g.getClipBounds()` (intersected with
the visible rect), passing that as the visible rect so that culling and the cache limit the work.
The buffer has to survive scrolling: either shift its contents and redraw the exposed strip, or
make it larger than the viewport. Moderate work. Do it after item 3, since the grid currently
assumes it draws the whole canvas.
## 6. Grouping and locking bookkeeping: done
`Presenter.draw` used to rebuild group membership and the locked-component set on every repaint,
and `mouseMoved` looked groups up on every move: about a fifth of the EDT's samples in all three
profiles. Both now come from `GroupAndLockIndex` (tested by `GroupAndLockIndexTest`), which works
them out once per project state:
- group membership is indexed by component id, so a component outside the project, such as one
being placed, resolves the same way as before; the sets it hands out are read-only;
- `Presenter` calls `invalidate()` from `notifyProjectModifiedIfNeeded`, `loadProject`, paste
(which copies the clipboard's groups straight in), `ungroupComponents`, `setLayerLocked` (which
updates the selection before notifying) and `lockComponent`;
- asking about a different project object rebuilds the index without an explicit invalidation.
**Watch.** Code that changes the current project's groups, locks or component list without going
through `Presenter` would leave the index stale until the next edit. Nothing does today; plugins
change the project through `IPlugInPort`, and `LoadlineEditorFrame` builds its own project.
## 7. Individual components
**`TwistedWire`: done in two steps.**
1. `recalculate` used to build and cut each lead by growing one area segment by segment and
cutting every crossing out of the whole lead, so its cost grew with the square of the wire's
length. The segment areas are now merged pairwise and each kind of crossing is cut in one
operation. That also removed a data race (the parallel crossing loop read one lead while
another thread subtracted from it) and a bug that left the last crossings near the end of many
wires uncut.
2. P4 still showed the geometry on every frame for wires whose ends move with a drag. Opaque
wires are now drawn with strokes and no `Area` operations: per lead, a dark stroke one pixel
wider than the lead and a coloured one a pixel narrower give the outline, the stripe is a
dashed stroke on top, and redrawing the first lead clipped to its own crossing squares puts it
on top there. `buildLeadGeometry` produces the lead paths and squares once per shape change and
is cached in `leadGeometry`. The first stroke of each lead is the one recorded as the
component's area.
Translucent wires (alpha below 100%) keep the area path, because strokes paint some pixels twice
and that would show through; its output is pixel-identical to before. On eight test renders the
stroke path is about 8x faster (97 ms against 772 ms). Opaque output matches apart from
antialiasing along the edges and a slightly finer outline, and selected wires lose faint seams the
area cuts used to leave. Stroking a lead as one path whose segments are separate sub-paths is fine:
stroke outlines are filled with the non-zero rule, so overlapping segments merge.
**`HookupWire`** is now cached and no longer shows up in the profile.
## Not worth optimizing now
- **Per-component overhead in the drawing loop** (formerly §7): the stats key built with
`getCanonicalName().replace(...)`, the `G2DWrapper` allocations on draws that record nothing, and
the `LOG.warn("Empty outline area detected ...")` in `DrawingCache.renderAndCache`. P3 and P4
show it at 1 sample each. The warning still breaks the no-logging-on-hot-paths rule and can go
whenever that file is next touched.
- **Mouse-move handling.** `Presenter.mouseMoved` walks every control point twice per move, but it
does not repaint. It only becomes worth indexing at much larger project sizes.
- **Type lookups and locking.** The `ComponentProcessor.extractComponentTypeFrom` lookups (three
per component per repaint) and the uncontended `synchronized (this)` around each component in
the drawing loop are cheap.
## Order of work
| Step | Item | Size | Risk | Needs |
| --- | --- | --- | --- | --- |
| 1 | §4 image formats | small | low | page-time comparison on macOS |
| 2 | §2b frozen drag background | large | medium | manual drag testing incl. stacking order |
| 3 | §5 redraw only the damaged area | medium | medium | scroll and tooltip testing |
| 4 | §1 cached reflection metadata for `equalsTo` / `clone` | small | low | unit test for `equalsTo` |
Re-profile next: the ranking of the remaining steps still comes from reading the code.