# Drawing Engine Performance The remaining repaint costs in the canvas drawing pipeline, ranked by expected payoff, and what each fix needs. The image cache and off-screen culling that already exist are described in [`../adr/canvas-rendering-cache-and-culling.md`](../adr/canvas-rendering-cache-and-culling.md), which also lists their own open issues. This plan covers everything else. How a repaint flows: `CanvasPanel.paint` → `Presenter.draw` → `DrawingManager.drawProject`. That calls `DrawingCache.bulkPrepare` (parallel), then runs the per-component loop through `DrawingCache.draw` / `G2DWrapper` → `component.draw()`. ## 0. Measurements **How to measure.** - **Recording.** Add `-XX:StartFlightRecording=duration=60s,filename=drag.jfr` to the run configuration, then drag, scroll, zoom and edit a complex layout. - **Reading it.** `jfr view hot-methods drag.jfr`, `jfr view gc drag.jfr` and `jfr view allocation-by-site drag.jfr` give the overview. For DIYLC call sites, export the samples with `jfr print --json --events jdk.ExecutionSample --stack-depth 96` (or `jdk.ObjectAllocationSample`) and group them by the innermost `org.diylc` frame. - **Before and after a change,** compare the `DrawingManager` "Page stats" average at the same zoom and viewport, plus the `DrawingCache` lines (see the ADR, §5). Compare total time, not per-type counts, because culled components are not counted. **Profile of 2026-10-14.** Complex layout, wires cached, culling and invalidation-based caching in place. 750 CPU samples over 60 s, so treat the shares as approximate. | Share | Where | Item | | --- | --- | --- | | ~52% of CPU samples | `TwistedWire.recalculate` and the `Area` operations under it, mostly on the `bulkPrepare` workers (fixed, see §7) | §7 | | ~7% of CPU samples | `G2DWrapper.appendShape` recording `TwistedWire`'s outline (`currentArea.add`) | §2a | | 16% of CPU samples | Marlin rasterisation (`Renderer._endRendering`): the drawing itself | — | | ~19% of EDT samples | `Presenter.findAllGroupedComponents`, from `Presenter.draw` and `mouseMoved` | §6 | | 958 MB / 60 s | pixel buffers for cache renders | — | | 426 MB / 60 s | grid image rebuilt on zoom changes (fixed, see §3) | §3 | | 386 MB / 60 s | canvas back buffer re-created on every scroll step (fixed, see §5) | §5 | GC pauses totalled 348 ms. Most were under 40 ms, with one young-generation pause of 115 ms during heavy allocation. **Profile of 2026-10-14, later (P4),** mostly dragging parts with twisted wires attached: 707 CPU samples, 383 of them `TwistedWire` geometry on the `bulkPrepare` workers, and 4.75 GB of cached images allocated in 60 s. Wires stuck to a dragged part kept the `NORMAL` state, so every frame re-rendered them into a new cache image. Moved-along components now bypass the cache (ADR §2). The geometry itself still runs on every frame, because the wire's shape really changes; see §7. Grouping fell to 12 samples after §6, and the per-component overhead (now under "Not worth optimizing now") showed 1 sample. **P5 (stroke-drawn twisted wires, moved components bypass the cache):** 248 CPU samples against 707 in P4 for a similar drag session; `TwistedWire` geometry 35 samples (was 383), workers 70 (was 537), total allocation 2.3 GB (was 7.4 GB), cached images 0.98 GB (was 4.75 GB), longest GC pause 42 ms (was 71 ms). Recording areas was then the largest cost, about 37% of samples, mostly for the moved wires on every frame; §2a addresses it. **P6 (lazy area merging):** 174 CPU samples for a similar drag session; area recording 0 (was 92), on-demand merges from clicks and hovers 6, total allocation 1.45 GB, longest GC pause 13 ms. The cursor check briefly cost 17 samples recomputing outline bounds on every mouse move, fixed by caching them in `ComponentArea`. Rasterising (Marlin) is now the largest cost at about a third of the samples, so the remaining gains come from drawing less: §2b and §5. Image copies measured 3 samples, which puts §4 low. **Baseline before culling,** from the same layout: 327 repaints averaging 26 ms; `HookupWire` (then uncached) about 9 ms per repaint; `TwistedWire` about 96 ms per render; `TubeSocket` 572 ms in `DrawingManager` against 25 ms of image pasting. ## 1. Reflection on every edit **Done:** the per-repaint reflective comparison is gone. `DrawingCache` now relies on `Presenter` invalidating changed components; see the ADR, §2. **What is left.** Reflection now runs once per edit instead of on every repaint. Each edit: - clones the whole project (`AbstractComponent.clone()` walks the fields with reflection); - runs `Project.equals`; - runs `Presenter.invalidateChangedComponents`, which compares every component with its snapshot. All three use the same `getDeclaredFields()` / `setAccessible` / `Field.get` walk. It is felt mostly on repeated edits, such as holding an arrow key to nudge a large project. **Fix.** Cache each class's comparable fields once, with `setAccessible` already applied, in a `ClassValue`, and use it in both `equalsTo` and `clone`. Behaviour stays identical. Unit-test the new `equalsTo` against the current one on a few real components from `diylc-library`. **Watch.** Missed invalidation paths are now a correctness risk. Run with `-Dorg.diylc.verifyDrawingCache=true` while testing new edit operations or plugins that change components. ## 2. Dragging redraws everything Each drag step invalidates the moved components, and the next frame redraws the whole visible project. **2a. Area recording during drags: done.** Recording areas used to merge every tracked shape into an `Area` as it was drawn, which cost the most for the components moved on every frame. Recording now keeps copies of the shapes, and `ComponentArea` merges them pairwise the first time an area is asked for (ADR §1). `debugAreaPerformance` keeps an eagerly merged outline of its own for its check, and `Snapshot`, the test recorder, now stores shapes rather than areas in its `ComponentArea`s. **2b. Draw the drag over a frozen background.** - At drag start, render everything that is not moving into a background image once. Each frame then pastes that image and draws only the moving components, the control points and the selection. - Re-render the background when the viewport scrolls or the drag ends. - **Stacking order:** a moving part that sits under other parts would draw on top of them while dragging. Either accept that for the length of the drag, or split the background into a layer below the lowest moving component and a layer above it, and draw the moving parts between them. - Components that move along with the dragged ones without being selected (stuck components) must count as moving. `Presenter` knows which these are from the control-point map. - This is the biggest structural win for dragging, and the most invasive change in this plan. It touches `CanvasPanel`, `Presenter.draw` and `DrawingManager.drawProject`. ## 3. Grid: done `DrawingManager.drawGrid` used to cache the grid as one image the size of the whole canvas at the current zoom. That was up to ~190 MB at high zoom, outside the cache budget, and 426–1,041 MB of grid images were allocated per profiled minute of zooming. It was rendered at 1× and stretched on HiDPI, and its rebuild check ignored grid spacing, dot spacing and theme. It now lives in `GridRenderer` (tested by `GridRendererTest`), which draws the lines and dots directly, and only those inside the visible rect: - **Antialiasing off for the grid.** The lines are axis-aligned, so each one snaps to crisp whole pixels instead of being smeared across two. The thickness formula is unchanged: 1 px at zoom ≥ 1, and `zoom` px below that. - **Dense grids are thinned.** When lines would be closer than `GridRenderer.MIN_GRID_LINE_SPACING` (3 canvas pixels at the current zoom), only every 5th line is drawn, then every 25th, and so on. Snapping still uses the real spacing. A 0.5 mm grid shows every line at 100% and every 5th line from 75% down. - **Exports and printing** pass no visible rect and get the whole grid as vector lines, instead of a raster image embedded in the PDF or SVG. One viewport of a 0.5 mm grid draws in under a millisecond. There is no cached state any more, so the grid cannot go stale. ## 4. Image formats force slow compositing - **The canvas back buffer** (`CanvasPanel.createBufferImage`) is `TYPE_INT_ARGB`, though the canvas is always filled with an opaque background. Use an opaque compatible image, `getGraphicsConfiguration().createCompatibleImage(w, h, Transparency.OPAQUE)`, kept HiDPI-scaled as `ScaledBufferedImage` does now, so that copying it to the screen takes the fast path. - **Cached component images** (`DrawingCache.renderAndCache`) are `TYPE_INT_ARGB`, so every paste blends each pixel without premultiplied alpha. `TYPE_INT_ARGB_PRE` blends faster. Confirm that `ScaledBufferedImage` and anything else reading the pixels is fine with the format. Each is a one-line change, but Java2D's speed varies by pipeline (Metal on macOS), so keep each change only if the page-time comparison shows a gain. ## 5. Every paint redraws the whole visible area **Done:** the back buffer used to be thrown away on every scroll-bar event. `CanvasPlugin` called `invalidateCache()` from both scroll listeners, which allocated 386 MB in the profiled minute. `CanvasPanel.paint` now re-creates the buffer only when its pixel size changes, which includes a change of HiDPI scale. **Problem.** `CanvasPanel.paint` ignores the clip that Swing passes in. It always redraws the full visible rect into a back buffer sized and positioned to the viewport. Scrolling, which only exposes a thin strip, and partial repaints after a tooltip or balloon closes, both pay for a full redraw. **Fix.** Redraw only `g.getClipBounds()` (intersected with the visible rect), passing that as the visible rect so that culling and the cache limit the work. The buffer has to survive scrolling: either shift its contents and redraw the exposed strip, or make it larger than the viewport. Moderate work. Do it after item 3, since the grid currently assumes it draws the whole canvas. ## 6. Grouping and locking bookkeeping: done `Presenter.draw` used to rebuild group membership and the locked-component set on every repaint, and `mouseMoved` looked groups up on every move: about a fifth of the EDT's samples in all three profiles. Both now come from `GroupAndLockIndex` (tested by `GroupAndLockIndexTest`), which works them out once per project state: - group membership is indexed by component id, so a component outside the project, such as one being placed, resolves the same way as before; the sets it hands out are read-only; - `Presenter` calls `invalidate()` from `notifyProjectModifiedIfNeeded`, `loadProject`, paste (which copies the clipboard's groups straight in), `ungroupComponents`, `setLayerLocked` (which updates the selection before notifying) and `lockComponent`; - asking about a different project object rebuilds the index without an explicit invalidation. **Watch.** Code that changes the current project's groups, locks or component list without going through `Presenter` would leave the index stale until the next edit. Nothing does today; plugins change the project through `IPlugInPort`, and `LoadlineEditorFrame` builds its own project. ## 7. Individual components **`TwistedWire`: done in two steps.** 1. `recalculate` used to build and cut each lead by growing one area segment by segment and cutting every crossing out of the whole lead, so its cost grew with the square of the wire's length. The segment areas are now merged pairwise and each kind of crossing is cut in one operation. That also removed a data race (the parallel crossing loop read one lead while another thread subtracted from it) and a bug that left the last crossings near the end of many wires uncut. 2. P4 still showed the geometry on every frame for wires whose ends move with a drag. Opaque wires are now drawn with strokes and no `Area` operations: per lead, a dark stroke one pixel wider than the lead and a coloured one a pixel narrower give the outline, the stripe is a dashed stroke on top, and redrawing the first lead clipped to its own crossing squares puts it on top there. `buildLeadGeometry` produces the lead paths and squares once per shape change and is cached in `leadGeometry`. The first stroke of each lead is the one recorded as the component's area. Translucent wires (alpha below 100%) keep the area path, because strokes paint some pixels twice and that would show through; its output is pixel-identical to before. On eight test renders the stroke path is about 8x faster (97 ms against 772 ms). Opaque output matches apart from antialiasing along the edges and a slightly finer outline, and selected wires lose faint seams the area cuts used to leave. Stroking a lead as one path whose segments are separate sub-paths is fine: stroke outlines are filled with the non-zero rule, so overlapping segments merge. **`HookupWire`** is now cached and no longer shows up in the profile. ## Not worth optimizing now - **Per-component overhead in the drawing loop** (formerly §7): the stats key built with `getCanonicalName().replace(...)`, the `G2DWrapper` allocations on draws that record nothing, and the `LOG.warn("Empty outline area detected ...")` in `DrawingCache.renderAndCache`. P3 and P4 show it at 1 sample each. The warning still breaks the no-logging-on-hot-paths rule and can go whenever that file is next touched. - **Mouse-move handling.** `Presenter.mouseMoved` walks every control point twice per move, but it does not repaint. It only becomes worth indexing at much larger project sizes. - **Type lookups and locking.** The `ComponentProcessor.extractComponentTypeFrom` lookups (three per component per repaint) and the uncontended `synchronized (this)` around each component in the drawing loop are cheap. ## Order of work | Step | Item | Size | Risk | Needs | | --- | --- | --- | --- | --- | | 1 | §4 image formats | small | low | page-time comparison on macOS | | 2 | §2b frozen drag background | large | medium | manual drag testing incl. stacking order | | 3 | §5 redraw only the damaged area | medium | medium | scroll and tooltip testing | | 4 | §1 cached reflection metadata for `equalsTo` / `clone` | small | low | unit test for `equalsTo` | Re-profile next: the ranking of the remaining steps still comes from reading the code.