Render optimization (#1087)

* Caching and culling improvements

* doc

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

* caching for wires

* cache invalidation improvements

* caching improvements

* twisted wire performance update

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

* area append improvement

* bug fix for subtracting trace cuts

* Twisted wire stroke render improvement

* various performance improvements

* lazy area merging

* regression update

* Update update.xml

* code cleanup

* Twisted wire border thickness fix
This commit is contained in:
Bane Stojkovic
2026-10-14 21:15:40 +02:00
committed by GitHub
parent 251b945e6d
commit a8d8a9b409
41 changed files with 2210 additions and 379 deletions
@@ -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));
}
}