Document the Schematic View in docs/ard

Add docs/ard/ (architecture & decision records):
- README.md index
- schematic-view-architecture.md: end-to-end overview - modules, file map,
  generation/sync pipeline, the ManhattanRouter, the tab + detached-window
  UI, how to add a factory, testing, known limitations and gotchas
- 0001..0007: decision records for the embedded SchematicView, the factory
  SPI, wires-as-components + routing, the second-Presenter tab, the wire
  layer lock + reroute-on-move, the SchematicBox control-point model, and
  switch handling

schematic_view_plan.md now points at docs/ard.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FWD3QJxbr7RuEHxyFEYeEY
This commit is contained in:
Bane Stojkovic
2026-09-03 11:51:23 +02:00
parent 5997fcfe9c
commit 1669feafe4
10 changed files with 652 additions and 0 deletions
@@ -0,0 +1,47 @@
# 0001 — Store the schematic as a lazy `SchematicView` on `Project`
**Status:** accepted
## Context
The schematic derived from a layout needs to live somewhere. Options:
1. A sidecar file (`project.diy.schematic`).
2. A separate top-level entity the app manages in parallel.
3. A field on `Project`, serialized inside the `.diy` file.
Constraints:
- One file per project is a strong existing expectation.
- The vast majority of users will never open the Schematic View; their files must not change.
- Schematic edits (moving a symbol) should participate in undo/redo and the modified flag with
no bespoke plumbing.
- Older DIYLC versions must still open new files.
## Decision
Add `private SchematicView schematicView` to `Project`, serialized via XStream with the rest
of the project.
- The field stays `null` until the user first opens the view. `Project.getSchematicView()`
returns it as-is; `Project.getOrCreateSchematicView()` lazily allocates it.
- `SchematicView` holds the schematic `components` (symbols + wires), a
`Map<UUID, List<UUID>> physicalToSchematicMap` (physical component id → schematic symbol
ids, 1:N), and canvas `Size`s.
- `Project.clone()` and `Project.equals()` include the schematic view.
## Consequences
- **+** Single file. No new file formats, no sync-of-two-files problem.
- **+** Undo/redo and the dirty flag work for free: the app already snapshots `Project` via
`clone()` and diffs via `equals()`.
- **+** Backward compatible: a `null` field serializes to nothing; XStream on old versions
ignores the unknown element.
- **−** Opening the Schematic View calls `getOrCreateSchematicView()`, which flips the project
to *modified* even if the user changes nothing. This is intentional (the plan wants the
schematic to persist once generated) but is a surprising side effect.
- **−** `SchematicView` in `equals()` slightly enlarges the cost of every undo-snapshot
comparison once the view exists.
- Pin mappings are deliberately **not** stored in `SchematicView` — they are a pure function
of the physical component and are recomputed by re-running the factory. This keeps the
serialized form small and avoids a second source of truth.
@@ -0,0 +1,57 @@
# 0002 — Per-type `ISchematicFactory`, declared on `@ComponentDescriptor`, with a generic fallback
**Status:** accepted
## Context
Every physical component type needs to become one or more schematic symbols. The mapping is
type-specific and sometimes complex:
- A resistor → one resistor symbol, pin 0 → pin 0, pin 1 → pin 1.
- A dual-triode tube socket → two triode symbols, with the heater pins shared to one section.
- A DIP IC → a box, or (for a dual op-amp) two op-amp symbols.
There are hundreds of component types. Most are simple; a handful are not. We need something
scalable that keeps per-type logic with the type, and that still produces *something* for a
type nobody has written a mapping for.
## Decision
A strategy interface plus an annotation attribute plus a fallback:
```java
interface ISchematicFactory {
List<SchematicSymbolMapping> createSchematicSymbols(IDIYComponent<?> physicalComponent);
}
```
- `SchematicSymbolMapping` = a fully configured symbol instance + `Map<Integer,Integer>`
pin mapping (physical control-point index → schematic control-point index) + optional
section label. The factory returns *ready* symbols; the builder only positions them.
- Types opt in via `@ComponentDescriptor(schematicFactory = XxxSchematicFactory.class)`.
`ComponentProcessor` copies it to `ComponentType.getSchematicFactoryClass()`. The annotation
default is `ISchematicFactory.class` itself, used purely as a "none declared" marker.
- **`GenericBoxSchematicFactory`** is the fallback for any type without a declared factory: it
inspects the sticky, node-named control points and builds a `SchematicBox` with one labeled
terminal per node, copying name and value.
- `AbstractSimpleSchematicFactory` is a base for the common 1:1 case — subclasses just supply
`createSymbol()` and (optionally) `electricalPinCount()`.
The interface and `SchematicSymbolMapping` live in `diylc-core` (the annotation references
the interface). Concrete factories live in `diylc-library` next to the symbol classes they
instantiate.
## Consequences
- **+** Per-type logic lives with the type. Adding coverage is: write a factory class, add one
annotation attribute. No central registry to edit.
- **+** Full control for hard cases: the factory returns concrete instances, so it decides the
symbol class, property values, and which sections get shared pins.
- **+** Every component gets a symbol — no gaps, no crashes on unmapped types.
- **−** The factory must be stateless (it is instantiated once and reused). This is a
convention, not enforced.
- **−** `diylc-core` gains an `org.diylc.schematic` package that `diylc-library` also uses
(split package across jars). DIYLC already does this for `org.diylc.components`, so it is
consistent, but it is a smell.
- Pin mappings from the factory are recomputed on demand, never serialized (see
[0001](0001-schematic-embedded-in-project.md)).
@@ -0,0 +1,45 @@
# 0003 — `SchematicWire` is a real component; connections are drawn, not implied
**Status:** accepted
## Context
Nets need to be drawn as schematic wires with 90° bends. Two options:
1. **Wires as components:** a `SchematicWire` `IDIYComponent` stored in the `SchematicView`,
drawn by the normal pipeline like any symbol.
2. **Wires rendered on the fly:** compute routes from the netlist at paint time and draw them
in a custom overlay; nothing is stored.
Option 2 is tempting because "there is nothing to select or drag, so it cannot be edited".
## Decision
Wires are components (`org.diylc.components.schematic.SchematicWire`).
- Implements `IContinuity` (so the connection is real to any downstream analysis).
- Stores `sourceComponentId` + `sourcePinIndex`, `targetComponentId` + `targetPinIndex`, and
the calculated `routePoints`. Control points are the route way-points; only the two endpoints
are sticky; visibility is `NEVER`; `canPointMoveFreely` is `false`.
- Z-order is `IDIYComponent.WIRING`. Symbols are `IDIYComponent.COMPONENT`.
- `clone()` is overridden to deep-copy `routePoints` (the base clone only deep-copies
`Point2D` / `Point2D[]`, not a `List`).
Routing is a separate pure class, `ManhattanRouter`:
- Straight segment when endpoints are axis-aligned; otherwise candidate L / HVH / VHV routes.
- Each candidate scored on length, bend count, crossings with existing segments, collinear
overlap length, and whether it leaves each pin in the expected direction
(`SchematicBuilder.exitDirection` — away from the symbol centroid along the dominant axis).
- Lowest score wins; result is de-duplicated and collinear middle points removed.
## Consequences
- **+** Wires persist in the `.diy` file, are picked up by the existing image/PDF export
paths, and can gain user-editable bend points later without a redesign.
- **+** Routing runs once per generation / move, not on every repaint.
- **+** Reuses the rendering pipeline — no custom paint overlay, no second code path.
- **−** Wires are selectable/draggable by default in a `Presenter`. Making them inert needs an
explicit mechanism — see [0005](0005-wire-layer-lock-and-reroute.md).
- **−** The router is a heuristic scorer over a fixed candidate set, not a real grid/A\*
router. It avoids overlap pairwise but is not globally crossing-aware.
@@ -0,0 +1,56 @@
# 0004 — The Schematic tab reuses the real canvas via a second `Presenter` + `CanvasPlugin`
**Status:** accepted
## Context
The Schematic View lives on a tab next to Layout. It must render *exactly* like the layout
canvas: rulers, scroll bars, mouse-wheel / trackpad-gesture zoom, the same look. Early
iterations used a plain `JScrollPane` around a lightweight custom panel with its own zoom
buttons; the user rejected that — same UI, no extra controls.
`CanvasPanel.paint()` calls `plugInPort.draw(...)`, `RulerScrollPane` is built around a
`ProjectDrawingProvider(plugInPort, ...)`, and zoom is `plugInPort.setZoomLevel(...)`. In
other words, the canvas is inseparable from a `Presenter`. `Presenter` is a ~3000-line class
bound to a single `currentProject`.
## Decision
`SchematicTabPlugin` owns a **second `Presenter`** and a **second `CanvasPlugin`**:
```java
schematicPresenter = new Presenter(swingUI, configManager, /*importVariantsAndBlocks*/ false);
schematicCanvasPlugin = new CanvasPlugin(swingUI, configManager);
schematicPresenter.installPlugin(() -> schematicCanvasPlugin); // injects its RulerScrollPane
schematicPresenter.installPlugin(SchematicChangeListener::new); // reroute-on-move listener
```
- Both `CanvasPlugin`s inject their `RulerScrollPane` into the same center `BoxLayout` panel;
`SchematicTabPlugin` toggles which one is `setVisible(true)`. `CanvasPlugin` gained a
`getCanvasScrollComponent()` accessor for this.
- The tab strip is a `JToolBar` with two `JToggleButton`s, injected below the scroll panes.
- Switching to Schematic: `SchematicSynchronizer.synchronize(layoutProject, continuityAreas)`,
then wrap `layoutProject.getOrCreateSchematicView()` in a throwaway `Project` (shares the
same component instances) and `schematicPresenter.loadProject(wrapper, true, null)`, then
swap visibility and `scrollToCenterAndShowContents()`.
- `IPlugInPort` / `INetlistProcessor` gained `getContinuityAreas()` (delegates to the
drawing manager) so the tab can build the netlist without reaching into `Presenter`
internals.
The detached *Analyze → Schematic View…* window (`SchematicViewFrame` / `SchematicPanel`)
is a separate, `Presenter`-free read-only renderer kept for PNG export.
## Consequences
- **+** Pixel-identical canvas: rulers, scroll, wheel/gesture zoom, theme — all for free.
- **+** Independent zoom/scroll state per tab.
- **+** Symbol selection and drag work through the standard `Presenter`.
- **−** A second `Presenter` is heavyweight: it builds its own `DrawingManager`,
`ProjectFileManager`, `InstantiationManager`, `VariantManager`, `BuildingBlockManager` and
registers config listeners. `importVariantsAndBlocks=false` skips the expensive part.
- **−** `loadProject` on the schematic presenter calls `DrawingCache.Instance.clear()`, a
process-wide cache — a minor cross-effect on the layout canvas.
- **−** The schematic canvas is a full editing `Presenter`; nothing yet blocks symbol
add/delete/paste/value-edit, and its undo stack is separate from the layout's.
- The tab and the detached window are two independent renderers of the same data. Acceptable
redundancy; could be unified later.
@@ -0,0 +1,48 @@
# 0005 — Wires are made inert by locking the `WIRING` layer; they re-route on symbol move
**Status:** accepted
## Context
On the schematic the user may move symbols but must **not** be able to touch wires — no
selecting, no dragging, and above all no pulling a wire endpoint off a component. Symbols stay
fully movable. Because wires are real components ([0003](0003-wires-as-components-manhattan-routing.md)),
a `Presenter` will happily select and drag them.
There is no `IDIYComponent.isSelectable()` hook. The available levers are the project's
`lockedComponents` set and `lockedLayers` set. `Presenter.isComponentLocked()` already gates
click-select, rubber-band select, drag, and control-point grabbing off both.
## Decision
The wrapper `Project` used by the schematic tab locks the whole `WIRING` layer:
```java
wrapper.getLockedLayers().add(IDIYComponent.WIRING);
```
`SchematicWire` is the only thing on that layer (symbols are on `COMPONENT`), so every wire
becomes inert while symbols stay editable.
Locked components render at 0.5 alpha (`DrawOption.LOCKED_ALPHA`, on by default), which would
fade the wires. `SchematicWire.draw()` therefore forces `AlphaComposite.SrcOver` for its own
drawing and restores the previous composite afterwards.
**Re-routing on move:** `SchematicTabPlugin` installs a tiny `IPlugIn` on the schematic
presenter that listens for `EventType.PROJECT_MODIFIED` (dispatched once by `Presenter` when
a drag-move ends). On that event it calls `SchematicBuilder.rerouteWires(components)`, which
looks every wire's source/target symbols up by id, reads their current pin positions, and
re-runs `ManhattanRouter`. A `sameRoute` check skips the repaint when nothing changed; a
`rerouting` guard prevents re-entrancy.
## Consequences
- **+** One line makes wires completely non-interactive; symbols are untouched.
- **+** Uses the intended DIYLC mechanism (locked layers) rather than a new concept.
- **+** Wires follow symbols the user drags around, live.
- **−** `rerouteWires` recomputes *all* wires on every move, not just the affected ones. Fine
for typical net counts; not optimized.
- **−** The opaque-composite workaround in `SchematicWire.draw()` is a wart that exists only
because the wire is drawn on a locked layer.
- **−** If a future change puts something other than wires on the `WIRING` layer in the
schematic, it would be locked too.
@@ -0,0 +1,70 @@
# 0006 — `SchematicBox` is a rigid multi-pin component; it must not recompute in `setControlPoint`
**Status:** accepted
## Context
`SchematicBox` is a rectangle with N terminals on its four sides. Its pin positions are a
function of an anchor (control point 0) and the per-side node counts. The natural
implementation — "when the anchor moves, recompute all the pins" — was wired into
`setControlPoint`:
```java
// original, buggy
public void setControlPoint(Point2D point, int index) {
controlPoints[index].setLocation(point);
if (index == 0) updateControlPoints(); // rebuilds the whole control-point array
}
```
This breaks against how `Presenter` moves a rigid component. For a component whose
`canPointMoveFreely(i)` is `false`, the drag code adds **every** control-point index to the
move set and then applies the delta one index at a time:
```java
for (Integer index : indices) { // HashSet — order not guaranteed
Point2D old = c.getControlPoint(index);
c.setControlPoint(new Point2D.Double(old.getX() + dx, old.getY() + dy), index);
}
```
With the recompute in place, setting index 0 rebuilds pins 1..N relative to the new anchor;
the loop then reads those *already moved* pins and adds the delta again. Pins ended up moved
by 2× the drag, by different amounts depending on the `HashSet` iteration order — "control
points randomly move off the edge".
The same shape of bug hit `SchematicBuilder.moveAnchorTo` during initial placement: it read
`getControlPoint(i)` inside its loop after setting index 0, so box pins were double-offset,
which is why the first generation put every wire on one node until the user nudged the symbol.
`ICSymbol` — the component `SchematicBox` was modelled on — does **not** have this problem: its
`setControlPoint` only sets the one point, and it relies on `canPointMoveFreely == false` for
the presenter to translate all points together.
## Decision
Follow the `ICSymbol` model exactly:
- `SchematicBox.setControlPoint(point, index)` moves **only** that point. No recompute.
- `updateControlPoints()` runs only from the constructor and the node-list setters
(`setLeftNodes` etc.). When the pin count is unchanged it mutates the existing `Point2D`
objects in place (so references held by drawing/selection code stay valid); it allocates a
new array only when the count changes.
- `SchematicBuilder.moveAnchorTo` snapshots every target position *before* mutating any
control point, so a component that recomputes on set cannot be double-offset.
During a drag the presenter translates every pin by the same delta, which turns a valid box
configuration into another valid one — the pins stay on the (translated) edge without any
recompute.
## Consequences
- **+** Boxes drag cleanly; initial generation and re-sync produce identical routing.
- **+** `moveAnchorTo` is now robust for any current or future component that recomputes on
`setControlPoint`.
- **−** If someone moves *only* control point 0 of a `SchematicBox` programmatically (not via
the presenter's rigid-drag path), the body will move but the pins will not. Nothing in the
codebase does this, but it is a latent sharp edge.
- **Rule of thumb:** a rigid multi-control-point component must never mutate points other than
the one passed to `setControlPoint`. Geometry recompute belongs in explicit
structure-changing setters, not in `setControlPoint`.
+41
View File
@@ -0,0 +1,41 @@
# 0007 — Switches are drawn as symbols, not treated as connections
**Status:** accepted
## Context
`SchematicBuilder.isEligible()` excludes `IContinuity` implementors from the schematic — wires,
traces, solder bridges, board strips are connections, not symbols.
But **`ISwitch extends IContinuity`**. So the naive check silently dropped every switch from
the schematic, even though the netlist still contained the switch's terminals with real
connections. Symptom: switches present in the generated netlist, invisible on the diagram.
The netlist is built with `NetlistBuilder.extractNetlists(includeSwitches = false, ...)`. In
that mode `NetlistBuilder` itself treats a switch as a regular component (a node per terminal),
using the guard `c instanceof IContinuity && !(c instanceof ISwitch)` to decide what is a pure
connection. The schematic filter has to match that logic.
## Decision
`isEligible()` returns `true` for `ISwitch` *before* the `IContinuity` check:
```java
if (component instanceof ICommonNode) return true;
if (component instanceof ISwitch) return true; // drawn as a symbol
if (component instanceof IContinuity) return false; // wires, traces, ...
```
There is no dedicated switch factory yet, so a switch falls through to
`GenericBoxSchematicFactory` — a labeled box with one terminal per lug — and is wired like any
other symbol.
## Consequences
- **+** Switches appear on the schematic with their connections, matching the netlist.
- **+** The schematic filter and `NetlistBuilder`'s own switch logic now agree.
- **−** A switch renders as a generic box, not a proper switch symbol. A
`SwitchSchematicFactory` → `SwitchLatchingSymbol` (or a rotary/multi-pole symbol) is future
work.
- **General principle:** anywhere the code says "exclude `IContinuity`", check whether it
should special-case `ISwitch`.
+33
View File
@@ -0,0 +1,33 @@
# Architecture & Decision Records (ARD)
This folder documents non-obvious design decisions in DIYLC so that a person — or an
AI assistant — picking up the code later can understand *why* it looks the way it does,
not just *what* it does.
Each record is self-contained and uses a fixed shape:
- **Status** — accepted / superseded / proposed
- **Context** — the problem and the constraints that forced a choice
- **Decision** — what we did
- **Consequences** — what this buys us, what it costs, and the traps to watch for
## Index
### Schematic View
The Schematic View turns a physical layout into a schematic diagram, on demand, reusing
the existing rendering pipeline. Start with the architecture overview, then the records.
| Doc | Topic |
|---|---|
| [`schematic-view-architecture.md`](schematic-view-architecture.md) | End-to-end overview: modules, files, the generation pipeline, routing, the UI, how to extend it, testing, limitations |
| [`0001-schematic-embedded-in-project.md`](0001-schematic-embedded-in-project.md) | Storing the schematic as a lazy `SchematicView` field on `Project` |
| [`0002-schematic-factory-spi.md`](0002-schematic-factory-spi.md) | `@ComponentDescriptor.schematicFactory` + `ISchematicFactory`, with a generic box fallback |
| [`0003-wires-as-components-manhattan-routing.md`](0003-wires-as-components-manhattan-routing.md) | `SchematicWire` as a real component; the `ManhattanRouter` |
| [`0004-schematic-tab-second-presenter.md`](0004-schematic-tab-second-presenter.md) | The Layout \| Schematic tab reuses the real canvas via a second `Presenter` + `CanvasPlugin` |
| [`0005-wire-layer-lock-and-reroute.md`](0005-wire-layer-lock-and-reroute.md) | Wires are made non-interactive by locking the `WIRING` layer; they re-route on symbol move |
| [`0006-schematicbox-control-point-model.md`](0006-schematicbox-control-point-model.md) | Why `SchematicBox` must behave as a rigid multi-pin component |
| [`0007-switch-handling.md`](0007-switch-handling.md) | Switches (`ISwitch extends IContinuity`) are drawn as symbols, not treated as wires |
The original feature specification and its running status live in
[`../schematic_view_plan.md`](../schematic_view_plan.md).
@@ -0,0 +1,251 @@
# Schematic View — Architecture Overview
## What it does
DIYLC projects are *physical layouts* — components placed on a board with leads, traces and
wires. The Schematic View derives a **schematic diagram** from that layout on demand: every
real component becomes a schematic symbol, every net becomes an auto-routed wire, and the
result is drawn with the same rendering pipeline as the layout.
It is **derived, not authored**: the user does not draw the schematic. They can rearrange
symbols; wires follow. Switching to the Schematic tab (re)generates it.
## Design principles
1. **Reuse the rendering pipeline.** Schematic symbols are ordinary `IDIYComponent`s drawn by
the same `DrawingManager` that draws layout components. Wires are components too.
2. **Per-type mapping, with a fallback.** Each physical component type can name an
`ISchematicFactory`; anything without one gets a generic labeled box. Every component gets
*some* symbol.
3. **The layout is the source of truth.** The schematic is regenerated / reconciled from the
layout + its netlist. User edits to the schematic are limited to symbol position.
4. **Zero cost when unused.** The `SchematicView` field on `Project` stays `null` until the
user first opens the view, so existing `.diy` files are byte-for-byte unchanged.
## Module layout
DIYLC has three Maven modules; `diylc-library` depends on `diylc-core`, `diylc-swing` depends
on both. The Schematic View is spread across all three:
```
diylc-core data model + SPI (no rendering, no Swing)
org.diylc.core.SchematicView embedded in Project
org.diylc.core.annotations.ComponentDescriptor.schematicFactory()
org.diylc.common.ComponentType.schematicFactoryClass
org.diylc.schematic.ISchematicFactory the strategy interface
org.diylc.schematic.SchematicSymbolMapping factory output: symbol + pin map
org.diylc.common.INetlistProcessor.getContinuityAreas() (impl on Presenter)
diylc-library components + generation logic
org.diylc.components.schematic.SchematicBox generic rectangular symbol
org.diylc.components.schematic.SchematicWire auto-routed Manhattan wire (IContinuity)
org.diylc.schematic.ManhattanRouter routing algorithm (pure)
org.diylc.schematic.GenericBoxSchematicFactory default fallback
org.diylc.schematic.AbstractSimpleSchematicFactory 1:1 pin-for-pin base
org.diylc.schematic.{Resistor,Capacitor,Diode,Transistor,Inductor,Potentiometer}SchematicFactory
org.diylc.schematic.SchematicBuilder initial generation
org.diylc.schematic.SchematicSynchronizer incremental reconcile
diylc-swing UI
org.diylc.swing.plugins.schematic.SchematicTabPlugin Layout | Schematic tab strip
org.diylc.swing.plugins.schematic.SchematicMenuPlugin Analyze > Schematic View...
org.diylc.swing.plugins.schematic.SchematicViewFrame detached viewer window
org.diylc.swing.plugins.schematic.SchematicPanel read-only renderer for the window
org.diylc.swing.plugins.canvas.CanvasPlugin.getCanvasScrollComponent() (new accessor)
```
## Data model
### `SchematicView` (embedded in `Project`)
```java
class SchematicView implements Serializable, Cloneable {
List<IDIYComponent<?>> components; // symbols + wires, z-order ascending
Map<UUID, List<UUID>> physicalToSchematicMap; // physical component id -> schematic symbol id(s)
Size width, height, gridSpacing;
}
```
- `Project.getSchematicView()` returns the field (may be `null`).
- `Project.getOrCreateSchematicView()` lazily creates it — **calling this makes the schematic
persist with the project from now on**.
- `Project.clone()` and `Project.equals()` include the schematic view, so schematic edits go
through the normal dirty / undo-snapshot machinery.
- Serialized via XStream alongside the rest of `Project`. Older DIYLC ignores the unknown field.
The `physicalToSchematicMap` is 1:N to support multi-section components (a dual triode → two
triode symbols). Pin mappings are **not** stored — they are a pure function of the physical
component and are recomputed by re-running the factory whenever needed.
### `ISchematicFactory` / `SchematicSymbolMapping`
```java
interface ISchematicFactory {
List<SchematicSymbolMapping> createSchematicSymbols(IDIYComponent<?> physicalComponent);
}
class SchematicSymbolMapping {
IDIYComponent<?> schematicSymbol; // fully configured, only needs positioning
Map<Integer, Integer> pinMapping; // physical control-point index -> schematic control-point index
String sectionLabel; // optional, e.g. "Section A"
}
```
A type declares its factory on the annotation:
```java
@ComponentDescriptor(name = "Resistor", ..., schematicFactory = ResistorSchematicFactory.class)
```
`ComponentProcessor.extractComponentTypeFrom` reads it into
`ComponentType.getSchematicFactoryClass()`. The default (`ISchematicFactory.class` itself,
used only as a "none" marker) means **no factory declared → use the generic box**.
## Generation pipeline — `SchematicBuilder.build(project, continuityAreas)`
```mermaid
graph TD
A[Project + ContinuityAreas] --> B["NetlistBuilder.extractNetlists(includeSwitches=false)"]
B --> C[single Netlist: Groups of Nodes]
A --> D[for each component: isEligible?]
D -->|ICommonNode| E[clone through as-is]
D -->|ISwitch or has sticky pins| F["resolve factory (declared or GenericBox)"]
D -->|IContinuity and not ISwitch, or no sticky pins| G[excluded, silently]
F --> H["factory.createSchematicSymbols() -> SymbolEntry{symbol, pinMapping}"]
E --> I[record physicalToSchematicMap]
H --> I
I --> J["placeSymbols: degree-sorted grid (moveAnchorTo)"]
J --> K["createWires: per Netlist Group, chain the schematic pins, ManhattanRouter.route each pair"]
K --> L[sort by z-order, store into SchematicView, resize canvas to fit]
```
Key points:
- **Eligibility** (`SchematicBuilder.isEligible`):
- `ICommonNode` (ground, common node) → eligible, cloned through unchanged.
- `ISwitch` → eligible (drawn as a symbol; see [0007](0007-switch-handling.md)).
- other `IContinuity` (wires, traces, solder bridges, board strips) → excluded.
- no sticky control points (labels, images, shapes, boards) → excluded.
- Exclusions are silent — no warning.
- **Factory resolution** caches one instance per factory class. Factory exceptions fall back to
the generic box.
- **Placement** sorts components by connection degree (sum over netlist groups of
`groupSize - 1`) descending, then drops them into a `ceil(sqrt(n))`-column grid. Multi-symbol
mappings stack vertically within the cell. `moveAnchorTo` translates *every* control point so
control point 0 lands on the target — it snapshots targets first, see
[0006](0006-schematicbox-control-point-model.md).
- **Wiring** walks each `Group`, resolves every `Node` (physical component + pin index) to a
schematic `(symbol, schematicPinIndex, location)` via the factory's `pinMapping`, sorts the
pins left-to-right/top-to-bottom, and creates a `SchematicWire` between consecutive pins
(a chain, not a star). Each wire stores `sourceComponentId/sourcePinIndex` and
`targetComponentId/targetPinIndex` plus the routed `routePoints`. Already-placed wire
segments are fed to the router as obstacles.
## Incremental sync — `SchematicSynchronizer.synchronize(project, continuityAreas)`
Runs every time the user switches to the Schematic tab. If the view is empty it does a full
`SchematicBuilder.build`. Otherwise:
- **Kept** components: re-run the factory to get fresh symbols + pin maps, but keep the
*existing* positioned symbol instance, only refreshing its name/value. (Pin maps are pure
functions of the physical component, so recomputing is safe and cheaper than persisting.)
- **New** components (id in layout, not in map): place fresh symbols in the next free grid cell.
- **Removed** components (id in map, not in layout): drop their symbols.
- **Wires** are always rebuilt from scratch from the current netlist — they are cheap.
## Routing — `ManhattanRouter`
Pure, no dependencies on the rest of the system. `route(start, end, startDir, endDir,
obstacles, grid)` returns axis-aligned way-points from `start` to `end`.
- Aligned endpoints → a single straight segment.
- Otherwise it generates candidate routes: two L shapes, several
horizontal-vertical-horizontal routes (vertical middle leg at candidate x positions) and
several vertical-horizontal-vertical routes, using midpoint / quarter points / grid offsets.
- Each candidate is scored: `length*0.01 + bends*6 + crossings*15 + overlapLength*0.5 +
directionMismatchPenalty`. `startDir` / `endDir` are the directions the wire should leave
each pin (away from the symbol centroid, dominant axis — `SchematicBuilder.exitDirection`).
- Lowest score wins; the result is de-duplicated and collinear middle points are dropped.
`SchematicBuilder.rerouteWires(components)` re-routes every wire from the *current* symbol pin
positions (looked up by component id) — used after the user moves a symbol.
## UI
### The tab — `SchematicTabPlugin`
The tab renders through the **real canvas**, not a bespoke widget. See
[0004](0004-schematic-tab-second-presenter.md).
- The plugin owns a second `Presenter` and a second `CanvasPlugin`, installed on that
presenter. Both `CanvasPlugin`s inject their `RulerScrollPane` into the center `BoxLayout`
panel; the plugin shows exactly one at a time.
- A `JToolBar` with two `JToggleButton`s (`Layout` / `Schematic`) is injected below them.
**No zoom or refresh buttons** — rulers, scroll bars and wheel/gesture zoom come from the
`RulerScrollPane`; refresh is automatic on tab switch.
- Switching to **Schematic**: `SchematicSynchronizer.synchronize(...)` on the layout project,
wrap the resulting `SchematicView` in a throwaway `Project`, `loadProject()` it into the
schematic presenter, swap the visible scroll pane, `scrollToCenterAndShowContents()`.
- The wrapper project **locks the `WIRING` layer** so wires are inert; a listener on the
schematic presenter re-routes wires on `PROJECT_MODIFIED` (fires once when a drag-move ends).
See [0005](0005-wire-layer-lock-and-reroute.md).
### The detached window — `SchematicMenuPlugin` / `SchematicViewFrame` / `SchematicPanel`
*Analyze → Schematic View…* opens a separate window that renders the `SchematicView` through
a private `DrawingManager` (read-only, no `Presenter`) and adds PNG export. It is independent
of the tab and is the simplest possible viewer.
## How to add a dedicated symbol for a component type
1. Write an `ISchematicFactory` in `diylc-library` under `org.diylc.schematic`. For a simple
1:1 mapping, extend `AbstractSimpleSchematicFactory` and just return `new XxxSymbol()` from
`createSymbol()` (override `electricalPinCount()` if not 2).
2. Point the physical component at it: add `schematicFactory = XxxSchematicFactory.class` to
its `@ComponentDescriptor` and import the class.
3. If the symbol's pin order does not match the physical component's control-point order,
build the `pinMapping` explicitly instead of using the base class.
4. For multi-section parts (dual triode, dual op-amp) return more than one
`SchematicSymbolMapping`, each with its own `pinMapping` and a `sectionLabel`.
No other wiring is needed — `SchematicBuilder` discovers the factory through `ComponentType`.
## Testing
Pure-logic pieces have unit tests in `diylc-library/src/test/java/org/diylc/schematic/`:
- `ManhattanRouterTest` — straight vs. bent routes, axis-alignment, endpoint fidelity.
- `GenericBoxSchematicFactoryTest` — pin count / labels, switch eligibility.
- `SchematicRerouteTest` — wire endpoints follow a moved symbol; `SchematicBox` drag/placement
geometry (every pin translated by exactly the drag delta, in any index order).
`SchematicViewIntegrationTest` (in `diylc-swing`) exercises build → sync → render end to end
through a real `Presenter`. **Note:** the `diylc-swing` test module currently fails to resolve
`org.reflections` in some local setups (pre-existing — `PresenterTests` fails the same way),
so this test may not run locally; it is expected to run in CI.
## Known limitations
- The schematic canvas blocks wire interaction, but not symbol add / delete / paste /
value-edit; there is no shared undo with the layout history.
- `rerouteWires` recomputes *all* wires on every move; routing is not globally crossing-aware.
- Placement is a degree-sorted grid, not signal-flow or true clustering.
- Multi-section (1:N) factories for tubes, dual op-amps and multi-pole switches are not done —
those fall back to the generic box.
- 3-pin factories (`Transistor`, `Potentiometer`) map legs to symbol legs in index order,
which may not match every package's pin-out.
- No print or PDF/SVG export for the schematic (PNG only, from the detached window).
- The schematic symbols do not follow rotation/mirroring cleanly (`SchematicBox` derives its
body from an axis-aligned anchor).
## Gotchas for future work
- `ISwitch extends IContinuity`. Any "exclude `IContinuity`" check must special-case switches.
- `SchematicView` participating in `Project.equals()` means opening the schematic marks the
project modified (intended, but surprising).
- A rigid multi-pin component (`SchematicBox`, `ICSymbol`) must **not** recompute its other
control points inside `setControlPoint` — the presenter drags all points one at a time and a
recompute double-applies the delta. See [0006](0006-schematicbox-control-point-model.md).
- Component source files in this repo have mixed CRLF/LF line endings and ISO-8859-1 encoding.
Bulk-editing them with a tool that normalizes newlines produces enormous phantom diffs —
preserve the original newline style per file.
+4
View File
@@ -1,5 +1,9 @@
# DIYLC Schematic View — Feature Specification
> This is the original spec plus a running status section (§11). For how the shipped code is
> actually put together and *why*, see [`ard/schematic-view-architecture.md`](ard/schematic-view-architecture.md)
> and the decision records in [`ard/`](ard/README.md).
## Overview
Add the ability to generate and display a schematic diagram representation of a physical layout project at any time. The schematic is derived from the existing netlist infrastructure and rendered using DIYLC's existing rendering pipeline. Users can view, rearrange, and export the schematic to verify their circuit at a glance.