diff --git a/docs/design.md b/docs/design.md index ab39a41..87d2391 100644 --- a/docs/design.md +++ b/docs/design.md @@ -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 `` instead of a ``. + +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 `` with + hard stops produces the stripe band cheaply; render it on a sibling + `` over the individual lines. +- **Tooltip**: `` 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