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
@@ -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
|
||||
|
||||
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 409 KiB After Width: | Height: | Size: 405 KiB |
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 2.1 MiB |
|
Before Width: | Height: | Size: 2.2 MiB After Width: | Height: | Size: 2.2 MiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1002 KiB After Width: | Height: | Size: 997 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 3.7 MiB After Width: | Height: | Size: 3.7 MiB |
|
Before Width: | Height: | Size: 2.5 MiB After Width: | Height: | Size: 2.5 MiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 866 KiB After Width: | Height: | Size: 868 KiB |
|
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.
|
||||