design(v5): cable routing via clamps — §11

m's bundling primitive: a clamp is a physical anchor on the canvas;
cables route through clamps in order; cables that share a consecutive
clamp pair are visibly bundled on that segment. Overlap is the bundle —
no detection pass.

Section covers:
- 11.1 Schema: clamps table + cable_clamps join, migration 007. Clamps
  carry frame_id so frame-drag carries them.
- 11.2 Cable rendering: <polyline> through [from, clamp₁..n, to];
  endpoint-replug handles stay on first/last vertices.
- 11.3 Bundle visualisation: shared segments rendered as a 2+N px
  striped line; clamp icon shows ×N count when shared. Computed live
  on every renderCanvas — O(C·N̄), trivial at v0 scale.
- 11.4 UI: +Clamp tool (C shortcut), mid-segment drag-to-snap (snap
  radius ~16 px / zoom), clamp inspector, right-click remove-from-cable.
- 11.5 Existing bundles table: keep, repurpose. Implicit bundles are
  derived from shared clamp segments; explicit named bundles still live
  in the table.
- 11.6 Solver coupling: v0 solver still emits straight cables; m
  hand-routes after. v5.1 future work for solver-suggested clamps.
- 11.7 Export: clamps export as small grey diamonds; cable arrows use
  Excalidraw's points array for mid-vertices. Bundle stripes are
  viewer-only (Excalidraw can't represent them losslessly).
- 11.8 API additions: clamp CRUD, attach/detach/reorder cable clamps.
  Snapshot grows clamps + cable_clamps arrays.
- 11.9 Five open questions for m (icon shape, snap radius scaling,
  cascade-on-delete confirm, stripe order, solver respect for manual
  clamp routing).
- 11.10 6-step slice plan post-approval.

DESIGN v5 READY FOR REVIEW
This commit is contained in:
mAi
2026-05-16 13:19:55 +02:00
parent 40ab3d2630
commit fca9fb0a0f

View File

@@ -1438,4 +1438,228 @@ gitignored.
---
DESIGN v4.1 READY FOR REVIEW
## 11. v5 — Cable routing via clamps
m's bundling primitive: a **clamp** is a physical anchor on the canvas
(think cable tie / clip). A cable routes from its `from` endpoint,
through zero or more clamps **in order**, to its `to` endpoint. Two
cables that share an ordered pair of consecutive clamps are visibly
bundled along that segment — no detection pass, no inference: the
overlap *is* the bundle.
This replaces the abandoned waypoints + segment-detection approach.
v0's straight-line schematic stays as the empty-clamps case
(`cable_clamps` is empty for a fresh solver-emitted cable).
### 11.1 Schema (migration 007)
```sql
CREATE TABLE clamps (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
x REAL NOT NULL,
y REAL NOT NULL,
label TEXT NOT NULL DEFAULT '',
frame_id INTEGER REFERENCES frames(id) ON DELETE SET NULL,
excalidraw_id TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (project_id, excalidraw_id)
);
CREATE INDEX clamps_project_idx ON clamps(project_id);
CREATE TABLE cable_clamps (
cable_id INTEGER NOT NULL REFERENCES cables(id) ON DELETE CASCADE,
clamp_id INTEGER NOT NULL REFERENCES clamps(id) ON DELETE CASCADE,
ord INTEGER NOT NULL, -- 1..N along from→to
PRIMARY KEY (cable_id, ord),
UNIQUE (cable_id, clamp_id) -- a cable can't visit the same clamp twice
);
CREATE INDEX cable_clamps_clamp_idx ON cable_clamps(clamp_id);
```
`frame_id` on clamps mirrors devices + IO markers — m can put a clamp
inside a frame and the frame-drag carries it.
`UNIQUE (cable_id, clamp_id)` blocks loops. `ord` is a small int, 1-based;
nothing requires it to be contiguous (m can renumber 1, 2, 3 → 1, 3, 5
during edits and the renderer is fine with that), but the UI keeps them
contiguous on every mutation for sanity.
### 11.2 Cable rendering model
Each cable resolves to a polyline `[from-anchor, clamp₁, clamp₂, …, clampₙ, to-anchor]`
where:
- `from-anchor` / `to-anchor` come from the existing `anchorForEndpoint`
resolver (port / device / IO).
- clamp anchors are `(clamp.x, clamp.y)` directly — clamps don't have a
width/height to centre.
For N=0 clamps the result is the v0 straight line. For N≥1 we render
a `<polyline>` instead of a `<line>`.
The endpoint-replug handles from §10 (cable-replug) stay on the **first
and last** vertices. Mid-polyline vertices get their own clamp-handle —
small grab points only on the selected cable, which behave like
clamp-detach when dragged onto empty canvas (drop a clamp off the
cable's path).
### 11.3 Bundle visualisation — derived from shared segments
A **segment** is a directed pair `(A, B)` where A and B are consecutive
nodes of a cable's polyline. Two cables share a segment when their
polyline contains the same A→B (or B→A — segment matching is
undirected).
For each segment, compute `cables[]` — the cables that traverse it.
If `len(cables) ≥ 2`, render the segment as a single thick line on top
of the individual ones:
- **Width**: `2 + N` px (N = cable count). Caps at ~12 px.
- **Colour**: a striped pattern, one stripe per distinct cable type in
the bundle, ordered by cable_type.id. SVG `<linearGradient>` with
hard stops produces the stripe band cheaply; render it on a sibling
`<polyline>` over the individual lines.
- **Tooltip**: `<title>` child listing the cables ("Power · USB · HDMI").
At a clamp where ≥ 2 cables meet, the clamp icon (10×10 rounded square)
shows a small count badge (`×N`) when N > 1. At fan-out points
(endpoint with no clamp before it on the polyline) the individual
coloured lines re-emerge, so m sees which port each strand goes to.
Shared-segment computation is O(C·N̄) where C = #cables and N̄ = average
polyline length. For a v0-sized project (≤ ~30 cables, ≤ ~5 clamps per
cable) this is trivial. We rebuild the segment map on every renderCanvas
— no caching layer.
### 11.4 UI gestures
**+ Clamp tool (`C` shortcut, also a sidebar button):**
- Click empty canvas → place a clamp at the cursor (POST `/clamps`).
Standalone clamp — not on any cable yet.
- Click a cable line → insert this clamp into that cable. The new clamp
sits at the click position (snapped to the nearest point on the
cable's polyline) and its `ord` is computed so it falls between the
two existing vertices it lies between.
**Drag a cable's mid-segment:**
- Pointerdown on a cable line (not on an endpoint handle) and drag.
Live preview shows a bend at the cursor. Pointerup:
- If the cursor is within snap-radius (~16 px) of an existing clamp:
insert that clamp into the cable's polyline at the right `ord`.
- Otherwise: create a fresh clamp at the release point and insert it.
**Clamp inspector** (selecting a clamp on the canvas):
- Position (x, y editable + label)
- "Cables through this clamp": list with each cable's two endpoints,
click → select that cable
- "Remove from this cable" (per row) → DELETE the matching cable_clamps
row; cable's polyline collapses around the gap.
- "Delete clamp" → cascade-removes from every cable_clamps row.
**Right-click on a clamp icon ON a cable** → "Remove from this cable"
inline.
**Frame drag** carries clamps the same way it carries devices + IO
markers (clamp.frame_id mirrors the existing pattern, drag handler
already iterates frame-contained items).
### 11.5 Relationship to the existing `bundles` table
**Recommendation: keep `bundles` and `bundle_cables`, repurpose them.**
- Implicit/auto bundles → derived live from shared clamp segments. No
DB rows. The §5 `GET /bundles/suggestions` endpoint stays useful as a
"you might want to route these through the same clamps" hint.
- Explicit named bundles → still in the `bundles` table. m names a
group ("desk → wall trunk"), the UI offers "route all members through
these clamps" as a one-click action. Useful for the case where m
wants a stable label on a logical bundle that isn't yet routed.
Migration 007 leaves `bundles` + `bundle_cables` untouched. A v6 cleanup
can drop them if m decides the explicit-named path isn't worth keeping.
### 11.6 Solver coupling
The v0 solver still emits **straight cables** — no clamp rows. m
hand-routes after Solve. The solver's preview-diff is unaffected
(solver compares endpoint pairs; clamp routing is independent of the
endpoint identity).
Future v5.1: solver-suggested clamps based on shared paths between
endpoint pairs. Out of scope here.
### 11.7 Export to mxdrw
Clamps map to small diamond elements (separate from IO markers — IO
diamonds are red wall-outlets; clamps are grey routing points).
`excalidraw_id` is stable across re-exports per the existing pattern.
Cable arrows become Excalidraw `arrow` elements with mid-points (the
clamp positions) when N≥1 — Excalidraw supports multi-vertex arrows
via the `points` array. Each `startBinding` / `endBinding` resolves to
the from/to anchor's excalidraw_id; mid-vertices are unbound.
Bundle visualisation (thick striped lines on shared segments) is **not
exported** in v0 — Excalidraw doesn't natively support gradient strokes,
and the mxdrw round-trip would lose them. We export each cable as its
own polyline; bundling is a viewer-only concept.
### 11.8 API additions
```
POST /api/projects/:pid/clamps { x, y, label?, frame_id? } → Clamp
PATCH /api/projects/:pid/clamps/:id { x?, y?, label?, frame_id? } → Clamp
DELETE /api/projects/:pid/clamps/:id
POST /api/projects/:pid/cables/:cid/clamps { clamp_id, ord? } → CableClamp
DELETE /api/projects/:pid/cables/:cid/clamps/:cmid
# Convenience: re-order clamps on a cable in one call
PUT /api/projects/:pid/cables/:cid/clamps { clamp_ids: [int, int, …] }
```
Snapshot endpoint grows two arrays:
- `clamps: []Clamp`
- `cable_clamps: []{ cable_id, clamp_id, ord }`
### 11.9 Open questions for m
1. **Clamp icon shape.** Diamond (overlaps visually with IO markers
when zoomed out), small filled circle (overlaps with port circles),
or rounded square `` 10×10? Recommend rounded square — distinct from
everything else on the canvas today.
2. **Snap radius when inserting onto a cable.** ~16 px world-units feels
right at 1× zoom. Should it scale with zoom (visual constant) or stay
world-constant (gesture stays the same regardless of zoom)? Recommend
visual constant — divide by current zoom.
3. **Clamp deletion when shared.** If a clamp is used by 4 cables and m
clicks "Delete clamp", do we (a) refuse with a "still in use" prompt,
(b) cascade-remove from all 4 cables, or (c) cascade silently? Current
draft says cascade silently. Worth a confirmation?
4. **Bundle stripe order.** Cable-type id is stable but arbitrary; visual
order on a thick line affects readability. Order by stripe-count
(Power first if 3 Power + 1 USB), or by cable-type-id (deterministic
but unrelated to importance)? Recommend by-count, ties broken by id.
5. **Solver respect for existing routing.** When m re-runs Solve after
hand-routing, should the solver preserve existing clamp routing on
user-owned (`auto=0`) cables? Auto cables are wiped + rebuilt, so
their clamps disappear with them — that's expected. But manual cables
with clamps should clearly keep them. Confirm.
### 11.10 Slice plan (post-design)
1. Schema migration + tx-aware store helpers (Create/Update/DeleteClamp,
AttachClampToCable, DetachClampFromCable, ReorderClamps).
2. HTTP endpoints + snapshot extension.
3. Frontend: clamp render + + Clamp tool + canvas placement (no
cable attach yet).
4. Cable polyline render via clamps, mid-segment drag-to-clamp,
clamp inspector.
5. Shared-segment bundle visualisation (gradient stripe + count badge).
6. Export pipeline extension — mxdrw arrows with mid-points + clamp
diamonds. Bundle viz stays viewer-only.
---
DESIGN v5 READY FOR REVIEW