Full project rename per m's call. Single atomic commit because the codebase rename is a coupled change — go module path, env vars, DB default, Docker artefact names, and on-disk mDock paths all flip together. - go.mod: module mgit.msbls.de/m/mcables → mgit.msbls.de/m/cablegui - cmd/mcables → cmd/cablegui (git mv) - All Go imports rewritten to the new module path - Env vars: MCABLES_ADDR/MCABLES_DB → CABLEGUI_ADDR/CABLEGUI_DB - DB default path: data/mcables.db → data/cablegui.db - Dockerfile + docker-compose.yml: image, container_name, env vars, bind-mount /home/m/stacks/mcables → /home/m/stacks/cablegui, secrets /home/m/secrets/mcables → /home/m/secrets/cablegui - Makefile: bin target + run/build commands point at cmd/cablegui - .gitignore + .dockerignore: /mcables → /cablegui - README, docs/design.md, CLAUDE.md: prose + paths + image name - web/static/index.html: <title> + brand - web/static/main.js + web/web.go: header comment - internal/exporter: Scene.Source "mcables" → "cablegui" - internal/server/export.go: error-detail secrets path - internal/db/migrations/*.sql: header comments (mCables vN → CableGUI vN) Memory group_id kept as "mcables" to preserve existing memory continuity. Documented as historical in CLAUDE.md. go build ./... clean; go test -race ./... green
86 KiB
CableGUI — Design v4.1
Cable-management framework + solver for m's setup. Inventor shift 1 design, revised through v2 (rescope to multi-project framework), v3 (global cable_types + guardrails), v4 (solver-as-core), and now v4.1 — six locked answers from m's v4 review.
What changed in v4.1 (tight pass on v4)
- CableGUI is a schematic, not a physical-routing tool. Cables are straight lines between endpoints; the solver and the renderer do not care about paths, trunks, frame edges, or cable-tray polylines. "Maximum bundling" reduces to the v3 rule: ≥2 cables between the same endpoint pair → bundle them. All path-routing language has been stripped from §5b.1, §5b.2, §7, §8, §9.
- Solver fires on the Solve button (v0). Live-solve stays in §8 slices 9+ as an opt-in toggle.
- Unmet-requirement quick-fix: when the solver returns
unsatisfied[], the device inspector renders a red badge per unmet requirement with a single button — "+ Add <type> port to <device> and re-solve" — that POSTs a new port to the device AND immediately re-runsPOST /api/projects/:pid/solvein the same UI action. See §5b.4 + §7 inspector-states.- Setup templates fold INTO v4.1. New tables
setup_templates,setup_template_devices,setup_template_requirementsin migration 004 + 3 built-in templates ('Living Room', 'Home Office', 'Server Rack'). New endpointsGET /api/setup-templatesandPOST /api/projects/:pid/apply-template. UI: a "Templates" panel in the New Project flow + an "Apply template" action on an empty project. See new §2.4 + slice 6 fold-in below.- Catalog distribution: SQL seed in migration 002 (no change).
- Promote to manual: explicit button on the cable inspector (no change).
Sources: the live Cable-Management.excalidraw on mxdrw.msbls.de (used as
the visual-grammar reference, not a bootstrap import target),
mai-memory (cablegui, m), and the live mDock services for deploy
conventions (§10). v4 driven by m's product-vision clarification:
"we provide a cable manager — I say what devices we have, the app tells me how to bundle cables and how the most efficient connection looks like"
CableGUI shifts from a manual draw-and-click editor to a solver that takes a list of devices + the connections m needs and emits the cable plan + bundle recommendations. The manual editor stays (it's the only way to inspect + tweak the plan) but is no longer the primary surface.
What changed in v4 (new mental model on top of v3 mechanics)
- Hybrid device-type catalog (§2.1, §3.1). A built-in
device_typestable seeds common devices (NAS, PC, Mac, TV, Soundbar, Switch, fritz, ChromeCast, SteamLink, IOx-3/6/8, Notebook, …) with default port profiles (device_type_portsrows: cable_type + count + label). Adding a device → pick a type → ports auto-seed. m can override per instance (this PC has 3 USB, not 2). Catalog is extendable per project.connection_requirementstable (§2.2). m declares "NAS must connect to Switch via RJ45" once. Many per device. The solver consumes these.POST /api/projects/:pid/solveendpoint (§3.2). Reads devices + their ports + connection_requirements + frame positions, emits a diff ofcables+bundles. Two modes:?preview=1returns the diff without applying; default applies.- Solver objective: maximum bundling (§5b.1). Schematic only: when two or more cables share the same endpoint pair, group them into one bundle. No path or trunk geometry — CableGUI is a wiring schematic, not a routing tool. v4.1 strips all path/trunk language from the v4 draft.
- UI: device-type dropdown on device-create, Connection Requirements left panel, Solve button next to Export. Inspector shows type + ports + unmet requirements (selected device) or the driving requirement + bundle (selected cable).
- Slices reshape (§8). Catalog seeding lands early (slice 1.5); the solver MVP and connection-requirements UI move ahead of the bundle-rendering polish.
What carried over from v3 (unchanged in v4)
- CableGUI is a framework: top-level
projects, each backed by one.excalidrawdrawing.UNIQUE(projects.name).cable_typesis global. Migration 001 seeds Power/USB/HDMI/DP/RJ45.devicesUNIQUE(project_id, name);frame_idnullable; FrameRef tri-state on PATCH.- IO diamonds = wall-outlet terminators (type=Power by convention).
projects.drawing_nameauto-defaults to<name>.excalidraw.DELETE /api/projects/:pid?confirm=<name>guardrail.- No cable inventory metadata; visual + connectivity structure only.
- DB at
./data/cablegui.db(gitignored). Bind0.0.0.0:7777LAN, no auth.- Deploy on mDock under
/home/m/stacks/cablegui/, raw docker-compose.What's superseded in v4
- The "manual draw-a-cable port-to-port" flow from v3 §7 is kept as a tweak path on the solver output, but is no longer the primary device- connecting flow. The solve button is the headline action.
- The v3 §8 slice order changes — catalog + types-driven devices + solver come earlier; the manual-draw-cable slice slides later. See new §8.
0. The seed drawing — visual grammar reference
Cable-Management.excalidraw on mxdrw.msbls.de is not ingested at
runtime. It is the visual-grammar reference we lock the export onto so that
when m rebuilds LOFT and OFFICE inside CableGUI, the exported .excalidraw
looks like the seed.
Concrete numbers from the live file (180 elements):
| Kind | Count | Excalidraw shape | What it represents |
|---|---|---|---|
| Frames | 2 | frame (name) |
Sub-areas inside a project (desk, rack, …) |
| Devices | 27 | rectangle with bound text |
Hardware items |
| Ports | 74 | ellipse ~12×9 |
Connectors on a device edge, colour = cable type |
| Cables | 31 | arrow |
Typed connections between ports/devices/outlets |
| IO markers | 6 | diamond text=IO |
Wall outlet / power-entry terminators (type=Power) |
| Legend | 5 | text |
Colour key in the top-left of the frame |
| Lines | 5 | line |
Decorative (separator under the legend). Ignored. |
Legend → cable type → colour, picked up directly from the seed:
| Type | Colour | Hex |
|---|---|---|
| Power | red | #e03131 |
| USB | green | #2f9e44 |
| HDMI | blue | #1971c2 |
| DP | purple | #9c36b5 |
| RJ45 | yellow | #ffd500 |
Three observations about the seed's visual grammar — these constrain the exporter (§4):
- Ports sit on a device edge as small ellipses (~12×9), coloured by
cable type. They are not children of the device in the Excalidraw sense
(no
containerId/boundElementslink) — purely positional. When we export from CableGUI we mimic that: port ellipse at(device.x + port.x_offset, device.y + port.y_offset), stroke colour = type colour. - Cable arrows bind to elements. In the seed: 44 endpoints to ellipses
(ports), 12 to whole rectangles (device-level, no specific port), 3 to
diamonds (wall outlets). Our exporter sets
startBinding.elementId/endBinding.elementIdto whichever Excalidraw element ID we wrote for the port / device / IO marker. - IO diamonds = wall outlets. They are terminals: a cable goes from a device-port → an IO marker, meaning "this cable plugs into a wall socket outside the diagram". They are always type=Power in m's setup but the schema doesn't enforce that (a future "network jack in the wall" wouldn't fit, and we can lift the constraint then).
1. Frontend stack — vanilla JS + SVG
Locked: vanilla ES modules (TS-typed via JSDoc, no build step) + SVG
diagram surface, served from a single Go binary via embed.FS.
Why this fits m: matches the no-build-step preference; same single-binary
aesthetic as m, mai, youpcms, mExDraw. Type-checking is opt-in via
make typecheck (tsc --noEmit), not gating runtime. SVG is one DOM node
per port/device/cable → trivial hit-testing, CSS-driven colouring by
data-type=hdmi, drag via pointer events + getScreenCTM().
Escape hatch only if state for half-drawn cables + multi-select gets painful: switch to Preact-via-CDN-ESM (still no build step). Not v0.
2. SQLite schema
./data/cablegui.db (project-local, gitignored). WAL mode, FKs on.
Driver: modernc.org/sqlite (cgo-free — clean cross-compile, simple
Dockerfile).
-- 001_init.sql
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
-- A project IS a drawing. LOFT and OFFICE are separate projects.
-- One project ↔ one .excalidraw file in mExDraw.
CREATE TABLE projects (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE, -- "LOFT", "OFFICE"
drawing_name TEXT NOT NULL, -- mExDraw drawing name, e.g. "LOFT.excalidraw"
description TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Cable types: GLOBAL legend, one set shared across all projects.
-- Migration 001 seeds the 5 defaults (Power/USB/HDMI/DP/RJ45) once.
-- Renaming or recolouring a type from anywhere in the UI propagates to
-- every project's legend and to every cable already typed as it.
CREATE TABLE cable_types (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE, -- "Power", "USB", "HDMI", "DP", "RJ45"
color TEXT NOT NULL, -- "#e03131"
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- v4 — device-type catalog. Seeded built-in types live globally (so
-- multiple projects share the "NAS" definition without duplication).
-- Per-project custom types are also allowed (project_id non-null for those).
-- Renaming a built-in type doesn't propagate retroactively to existing
-- devices that already had their ports seeded — they own their port set
-- from the moment they were created.
CREATE TABLE device_types (
id INTEGER PRIMARY KEY,
project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE,
-- NULL = built-in (shared), non-null = project-custom
name TEXT NOT NULL, -- "NAS", "PC", "TV", "Switch", "IOx-8", "Custom-Foo"
kind TEXT NOT NULL DEFAULT 'generic',
-- coarse category for UI grouping: 'storage', 'compute',
-- 'display', 'audio', 'network', 'hub', 'accessory',
-- 'generic'
icon TEXT, -- emoji or short symbol (🖥, 📺, 🔊, 📡) — UI hint
description TEXT NOT NULL DEFAULT '',
built_in INTEGER NOT NULL DEFAULT 0, -- 1 for migration-seeded rows, 0 for user-created
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (project_id, name) -- two projects can both have a custom "Foo";
-- built-ins (project_id NULL) get UNIQUE on name globally
);
CREATE INDEX device_types_project_idx ON device_types(project_id);
-- v4 — port profile per device type. "NAS has 1 Power + 1 RJ45" is two
-- rows; "PC has 1 Power + 1 RJ45 + 1 HDMI + 2 USB" is four rows.
-- When a device is created with type_id=X, the seeder inserts `count`
-- rows into the `ports` table for each device_type_ports entry,
-- numbering label as "<label_prefix> N" if count > 1.
CREATE TABLE device_type_ports (
id INTEGER PRIMARY KEY,
device_type_id INTEGER NOT NULL REFERENCES device_types(id) ON DELETE CASCADE,
cable_type_id INTEGER NOT NULL REFERENCES cable_types(id) ON DELETE RESTRICT,
label_prefix TEXT NOT NULL DEFAULT '', -- "HDMI", "USB", "Power" — UI label root
count INTEGER NOT NULL DEFAULT 1 CHECK (count >= 1),
-- Position hint: the seeder lays ports along the device edge using
-- these biases (0..1 along the edge fraction). NULL = even spread.
edge TEXT NOT NULL DEFAULT 'bottom' CHECK (edge IN ('top','bottom','left','right')),
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX device_type_ports_type_idx ON device_type_ports(device_type_id);
-- A frame is a named container *inside* a project: 'desk', 'rack', 'media'.
CREATE TABLE frames (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
x REAL NOT NULL DEFAULT 0,
y REAL NOT NULL DEFAULT 0,
width REAL NOT NULL DEFAULT 1200,
height REAL NOT NULL DEFAULT 800,
excalidraw_id TEXT, -- stable across exports
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (project_id, name),
UNIQUE (project_id, excalidraw_id)
);
CREATE INDEX frames_project_idx ON frames(project_id);
-- Devices live in a frame (and transitively in a project).
-- Stored project_id is denormalised for cheap project-scoped queries; FK
-- to frame_id is the structural truth. Both are kept consistent in code.
--
-- v4 — type_id (nullable) lets a device inherit its port profile from
-- a `device_types` row. Once ports are seeded the device "owns" them;
-- changing/clearing type_id later does not retroactively re-seed (m's
-- per-instance overrides survive). Custom freeform devices (no template)
-- keep type_id NULL — that's the v3 "just a rectangle" device.
CREATE TABLE devices (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
frame_id INTEGER REFERENCES frames(id) ON DELETE SET NULL,
type_id INTEGER REFERENCES device_types(id) ON DELETE SET NULL,
-- v4: nullable; SET NULL on type delete so we don't
-- cascade-delete a device the user still wants
name TEXT NOT NULL,
color TEXT NOT NULL DEFAULT '#1e1e1e',
x REAL NOT NULL,
y REAL NOT NULL,
width REAL NOT NULL,
height REAL NOT NULL,
excalidraw_id TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (project_id, name), -- no two devices in one project share a name
UNIQUE (project_id, excalidraw_id)
);
CREATE INDEX devices_project_idx ON devices(project_id);
CREATE INDEX devices_frame_idx ON devices(frame_id);
CREATE INDEX devices_type_idx ON devices(type_id);
-- Ports belong to a device. x_offset/y_offset are relative to the device's
-- top-left so ports follow when the device moves. project_id denormalised.
CREATE TABLE ports (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
type_id INTEGER NOT NULL REFERENCES cable_types(id) ON DELETE RESTRICT,
label TEXT, -- optional ("HDMI 1", "USB-C rear")
x_offset REAL NOT NULL,
y_offset REAL NOT 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 ports_project_idx ON ports(project_id);
CREATE INDEX ports_device_idx ON ports(device_id);
CREATE INDEX ports_type_idx ON ports(type_id);
-- IO markers = wall outlets / power-entry terminators.
-- One end of a Power cable. They are NOT bridges and they do NOT pair.
CREATE TABLE io_markers (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
frame_id INTEGER REFERENCES frames(id) ON DELETE SET NULL,
label TEXT NOT NULL DEFAULT 'IO', -- "Wall A", "UPS rear", …
x REAL NOT NULL,
y REAL NOT 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 io_markers_project_idx ON io_markers(project_id);
CREATE INDEX io_markers_frame_idx ON io_markers(frame_id);
-- A cable. Each endpoint is exactly one of (port, device, io-marker).
-- All foreign-key targets must be in the same project_id as the cable —
-- enforced in code (the CHECK below only enforces the one-non-null rule).
CREATE TABLE cables (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
type_id INTEGER NOT NULL REFERENCES cable_types(id) ON DELETE RESTRICT,
label TEXT,
from_port_id INTEGER REFERENCES ports(id) ON DELETE SET NULL,
from_device_id INTEGER REFERENCES devices(id) ON DELETE SET NULL,
from_io_id INTEGER REFERENCES io_markers(id) ON DELETE SET NULL,
to_port_id INTEGER REFERENCES ports(id) ON DELETE SET NULL,
to_device_id INTEGER REFERENCES devices(id) ON DELETE SET NULL,
to_io_id INTEGER REFERENCES io_markers(id) ON DELETE SET NULL,
excalidraw_id TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
CHECK (
(from_port_id IS NOT NULL) + (from_device_id IS NOT NULL) + (from_io_id IS NOT NULL) = 1
),
CHECK (
(to_port_id IS NOT NULL) + (to_device_id IS NOT NULL) + (to_io_id IS NOT NULL) = 1
),
UNIQUE (project_id, excalidraw_id)
);
CREATE INDEX cables_project_idx ON cables(project_id);
CREATE INDEX cables_from_port_idx ON cables(from_port_id);
CREATE INDEX cables_to_port_idx ON cables(to_port_id);
CREATE INDEX cables_from_device_idx ON cables(from_device_id);
CREATE INDEX cables_to_device_idx ON cables(to_device_id);
CREATE INDEX cables_type_idx ON cables(type_id);
-- Bundles: named groups of cables that physically run together, within
-- a single project (a bundle does not span LOFT ↔ OFFICE).
CREATE TABLE bundles (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
auto INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (project_id, name)
);
CREATE INDEX bundles_project_idx ON bundles(project_id);
CREATE TABLE bundle_cables (
bundle_id INTEGER NOT NULL REFERENCES bundles(id) ON DELETE CASCADE,
cable_id INTEGER NOT NULL REFERENCES cables(id) ON DELETE CASCADE,
PRIMARY KEY (bundle_id, cable_id)
);
CREATE INDEX bundle_cables_cable_idx ON bundle_cables(cable_id);
-- v4 — connection_requirements: the input m gives the solver.
-- "NAS must connect to Switch via RJ45" is one row. Many per device.
--
-- preferred_cable_type_id is the cable type m intends — the solver
-- needs it to match port colours. NULL means "solver picks" (the solver
-- will pick the unique cable_type that is compatible with both ends'
-- available port types; if ambiguous it surfaces an error for m).
--
-- must_connect = 1 (default) means the solver MUST satisfy this; an
-- unsatisfiable must_connect surfaces as a hard error in the solve
-- result. must_connect = 0 = "nice to have, drop if you run out of
-- ports". Used for templates that over-spec.
--
-- The (from_device_id, to_device_id) pair is normalised on insert so
-- (A,B) and (B,A) are the same requirement — UNIQUE on the unordered
-- pair + cable type prevents duplicates.
CREATE TABLE connection_requirements (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
from_device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
to_device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
preferred_cable_type_id INTEGER REFERENCES cable_types(id) ON DELETE SET NULL,
must_connect INTEGER NOT NULL DEFAULT 1 CHECK (must_connect IN (0, 1)),
notes TEXT NOT NULL DEFAULT '',
-- Order-normalised pair: lo = MIN(from, to), hi = MAX(from, to). Set
-- in code on insert; the UNIQUE then prevents (A,B,Power) AND
-- (B,A,Power) from coexisting. Stored alongside the m-facing
-- from/to so the UI doesn't have to denormalise.
pair_lo INTEGER NOT NULL,
pair_hi INTEGER NOT NULL,
CHECK (from_device_id != to_device_id),
UNIQUE (project_id, pair_lo, pair_hi, preferred_cable_type_id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX conn_reqs_project_idx ON connection_requirements(project_id);
CREATE INDEX conn_reqs_pair_idx ON connection_requirements(project_id, pair_lo, pair_hi);
CREATE INDEX conn_reqs_from_idx ON connection_requirements(from_device_id);
CREATE INDEX conn_reqs_to_idx ON connection_requirements(to_device_id);
2.1 Migration sequence
- 001_init.sql (v3) — projects, frames, devices (no type_id), ports, cable_types (5 seeded), io_markers, cables, bundles, bundle_cables.
- 002_device_catalog.sql (v4) —
device_types+device_type_ports. Seeds the built-in catalog (§2.2). Addsdevices.type_id(ALTER TABLE devices ADD COLUMN type_id INTEGER REFERENCES device_types(id) ON DELETE SET NULL) and the matching index. - 003_connection_requirements.sql (v4) —
connection_requirements. Also addscables.auto(ALTER TABLE cables ADD COLUMN auto INTEGER NOT NULL DEFAULT 0) so the solver can distinguish its rows from m's hand-drawn ones (§5b.3). - 004_setup_templates.sql (v4.1 NEW) —
setup_templates+setup_template_devices+setup_template_requirements. Seeds 3 built-in templates ('Living Room', 'Home Office', 'Server Rack').
Slices 1 and 2 already shipped 001. Slice 4 lands 002; slice 5 lands 003; slice 6 lands 004 alongside the solver MVP + templates UI.
2.2 Built-in catalog seed (002 INSERTs)
The 14 built-in types m's setup uses today, with their default port
profiles. Stored as (project_id NULL, built_in 1). v4.1 added the
three peripheral types (Screen, Keyboard, Mouse) to support the Home
Office setup template:
device_types.name |
kind |
Default ports (cable_type × count) |
|---|---|---|
| NAS | storage | Power × 1; RJ45 × 1 |
| PC | compute | Power × 1; RJ45 × 1; HDMI × 1; USB × 2 |
| Mac | compute | Power × 1; HDMI × 1; USB × 2 |
| Notebook | compute | Power × 1; USB × 2 |
| TV | display | Power × 1; HDMI × 2 |
| Soundbar | audio | Power × 1; HDMI × 1 |
| Switch | network | Power × 1; RJ45 × 5 |
| fritz | network | Power × 1; RJ45 × 4 |
| ChromeCast | display | Power × 1; HDMI × 1 |
| SteamLink | compute | Power × 1; HDMI × 1; USB × 2 |
| IOx-3 | hub | Power In × 1 (top/back); Power Out × 3 (bottom/front) |
| IOx-6 | hub | Power In × 1 (top/back); Power Out × 6 (bottom/front) |
| IOx-8 | hub | Power In × 1 (top/back); Power Out × 8 (bottom/front) |
| Screen | display | Power × 1; HDMI × 1 |
| Keyboard | accessory | USB × 1 |
| Mouse | accessory | USB × 1 |
| Multi-plug 3 | hub | Power In × 1 (top/back); Power Out × 3 (bottom/front) |
| Multi-plug 4 | hub | Power In × 1 (top/back); Power Out × 4 (bottom/front) |
| Multi-plug 5 | hub | Power In × 1 (top/back); Power Out × 5 (bottom/front) |
| Multi-plug 6 | hub | Power In × 1 (top/back); Power Out × 6 (bottom/front) |
| Wifi-plug | accessory | Power In × 1 (top/back); Power Out × 1 (bottom/front) — pass-through outlet |
v5 (migration 005) added the Multi-plug 3–6 strips and the Wifi-plug
pass-through outlet. v6 (migration 006) re-shaped the IOx-* and
Multi-plug-* profiles to the "1 in on top / N out on bottom" layout —
the IOx-* devices are physical power strips, not USB hubs (m's
hardware), and the Multi-plug-* outputs are now visually distinct from
the input. Convention: top = back, bottom = front. Existing device
instances keep their already-seeded ports per §2.3 — to pick up the
new layout, delete + re-create the instance.
m can also add project-custom types at any time (UI: "+ New device
type" inside the device-create modal) with project_id = current.
2.3 Why ports are still instance-owned
When m picks a type to create a device, the seeder calls count × INSERT
into ports. From that moment on, ports are instance-level rows owned by
that device. Deleting a port from this PC doesn't touch other PCs;
changing a type's port profile (in slice 4.5) doesn't retroactively
re-seed already-created devices — it only affects subsequent device
creations.
Trade-off acknowledged: m may want a "re-seed from type" action later (slice 5+) to wipe + reset a device's ports. Out of v0 scope; not blocked by the schema.
2.4 Setup templates (v4.1 NEW)
A setup template is a named recipe of "device-types to add + connection requirements between them" that bootstraps a project from blank to solver-ready in one click. m's three archetypes:
| Template name | Devices | Default requirements |
|---|---|---|
| Living Room | TV, Soundbar, ChromeCast | TV ↔ Soundbar (HDMI, must); TV ↔ ChromeCast (HDMI, must) |
| Home Office | PC, Screen, Keyboard, Mouse | PC ↔ Screen (HDMI, must); PC ↔ Keyboard (USB, must); PC ↔ Mouse (USB, must) |
| Server Rack | NAS, Switch, fritz | NAS ↔ Switch (RJ45, must); Switch ↔ fritz (RJ45, must); fritz ↔ NAS (Power, nice) |
"Screen", "Keyboard", "Mouse" are added to the v4 built-in catalog alongside the existing 11 (Screen: Power × 1 + HDMI × 1; Keyboard: USB × 1; Mouse: USB × 1). Migration 002 grows to seed 14 built-ins.
Schema (004_setup_templates.sql):
-- A named recipe: a list of device types + requirements between them.
CREATE TABLE setup_templates (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',
built_in INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- The devices a template stamps into a project. suggested_name is
-- pre-filled into the apply-template form; m can override.
CREATE TABLE setup_template_devices (
id INTEGER PRIMARY KEY,
template_id INTEGER NOT NULL REFERENCES setup_templates(id) ON DELETE CASCADE,
device_type_id INTEGER NOT NULL REFERENCES device_types(id) ON DELETE RESTRICT,
suggested_name TEXT, -- "TV", "Bedroom TV", "Mac (work)"
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX setup_template_devices_template_idx ON setup_template_devices(template_id);
-- Requirements between devices in the template, addressed by
-- `setup_template_devices.id` (not the runtime device id — they're
-- resolved at apply time).
CREATE TABLE setup_template_requirements (
id INTEGER PRIMARY KEY,
template_id INTEGER NOT NULL REFERENCES setup_templates(id) ON DELETE CASCADE,
from_template_device_id INTEGER NOT NULL REFERENCES setup_template_devices(id) ON DELETE CASCADE,
to_template_device_id INTEGER NOT NULL REFERENCES setup_template_devices(id) ON DELETE CASCADE,
preferred_cable_type_id INTEGER REFERENCES cable_types(id) ON DELETE SET NULL,
must_connect INTEGER NOT NULL DEFAULT 1 CHECK (must_connect IN (0, 1)),
CHECK (from_template_device_id != to_template_device_id)
);
CREATE INDEX setup_template_reqs_template_idx ON setup_template_requirements(template_id);
API:
GET /api/setup-templates → [SetupTemplate {id, name, description, built_in,
devices: [{id, device_type_id,
device_type: {…},
suggested_name, sort_order}],
requirements: [{id, from_template_device_id,
to_template_device_id,
preferred_cable_type_id,
must_connect}]}, …]
Read-only; built-ins are not editable via API in v4.1.
POST /api/projects/:pid/apply-template ← {
template_id: <int>,
name_overrides: { <template_device_id>: "<name>", … },
skip_devices: [<template_device_id>, …] # optional
}
→ {
devices_added: [Device, …],
requirements_added: [ConnectionRequirement, …],
skipped_devices: [{template_device_id, reason}, …]
}
Idempotency:
- A name collision with an existing device in the
project skips that template device (reason = "name
already in use"). Caller can pass `name_overrides`
to resolve.
- Requirements whose endpoints both resolve fire;
any whose endpoint was skipped are themselves
skipped (logged in `requirements_skipped[]` — same
shape).
The whole call runs in a single transaction.
The seed migration creates the 3 built-ins + their template_devices and
template_requirements rows referencing the 14 built-in device_types and
the 5 built-in cable_types. No project_id anywhere — templates are
global.
FK shape — why project_id on every project-scoped row, not just transitively:
The structural truth is cable → port → device → frame → project. But
project-scoped queries ("give me all cables in OFFICE") would otherwise need
three joins. Denormalising project_id onto every project-scoped row is a
small, load-bearing pragma: cables WHERE project_id=? is a one-column
index hit. The cost: code must keep project_id consistent with frame_id
/ device_id on insert+update. That's enforced at the Go layer
(internal/db/store.go setter functions), not by SQL — CHECK constraints
in SQLite can't reference another table.
cable_types is the one global table — it has no project_id.
Cables reference it cross-project. Renaming or recolouring a type updates
the legend everywhere immediately and re-renders every cable of that type
on the next paint.
ON DELETE CASCADE from projects cleanly wipes a project's whole subgraph
in one statement, which is what we want when m says "delete OFFICE". The
cascade does not touch cable_types (no FK to projects).
3. Go HTTP API
Single binary cmd/cablegui, net/http, no router framework. Listens on
0.0.0.0:7777 by default (overridable via CABLEGUI_ADDR). Static frontend
from embed.FS at /, JSON API under /api/.
GET / → index.html (embedded)
GET /assets/* → JS/CSS/SVG (embedded)
GET /api/healthz → 200 ok
# Projects — top-level
GET /api/projects → [Project, …]
POST /api/projects ← {name, drawing_name?, description?}
If drawing_name is omitted, server defaults to
"<name>.excalidraw". No cable-type seeding —
cable_types is global (see /api/cable-types).
GET /api/projects/:pid → full snapshot
{project, frames, devices, ports, cables,
io_markers, bundles}
Plus the global cable_types (clients can also
fetch them via /api/cable-types). Editor's
one-shot loader.
PATCH /api/projects/:pid ← partial {name, drawing_name, description}
DELETE /api/projects/:pid?confirm=<name> Confirmation guardrail — the query param must
equal the project's current name. 400 if missing
or mismatched. Cascades through all child rows
(frames, devices, ports, cables, io_markers,
bundles, bundle_cables). Does NOT touch
cable_types.
# Cable types — GLOBAL, NOT under a project
GET /api/cable-types → [CableType, …]
POST /api/cable-types ← {name, color} # name must be unique globally
PATCH /api/cable-types/:id ← {name?, color?} # affects every project's legend + every cable using this type
DELETE /api/cable-types/:id # blocked if any cable still references it (ON DELETE RESTRICT)
# Inside a project — everything below scoped under :pid
GET /api/projects/:pid/frames
POST /api/projects/:pid/frames ← {name, x, y, width, height}
PATCH /api/projects/:pid/frames/:id
DELETE /api/projects/:pid/frames/:id
GET /api/projects/:pid/devices
POST /api/projects/:pid/devices ← {name, type_id?, frame_id?, x, y, width, height, color?}
v4: type_id (optional) seeds ports from the catalog;
without it, a freeform device (no ports) is created.
PATCH /api/projects/:pid/devices/:id (e.g. {x, y} on drag). type_id can be set or cleared;
clearing does NOT delete existing ports (instance-owned).
DELETE /api/projects/:pid/devices/:id
GET /api/projects/:pid/devices/:id/ports
POST /api/projects/:pid/devices/:id/ports ← {type_id, x_offset, y_offset, label?}
PATCH /api/projects/:pid/ports/:id
DELETE /api/projects/:pid/ports/:id
GET /api/projects/:pid/cables
POST /api/projects/:pid/cables ← {type_id, from_{port|device|io}_id,
to_{port|device|io}_id, label?}
PATCH /api/projects/:pid/cables/:id
DELETE /api/projects/:pid/cables/:id
GET /api/projects/:pid/io-markers
POST /api/projects/:pid/io-markers ← {frame_id?, label, x, y}
PATCH /api/projects/:pid/io-markers/:id
DELETE /api/projects/:pid/io-markers/:id
GET /api/projects/:pid/bundles → [{Bundle, cable_ids: [int]}, …]
POST /api/projects/:pid/bundles ← {name, cable_ids: [int]}
GET /api/projects/:pid/bundles/suggestions → [{name, cable_ids}, …] (see §5)
PATCH /api/projects/:pid/bundles/:id
DELETE /api/projects/:pid/bundles/:id
# v4 — Device-type catalog (mostly global, project-scoped writes for custom rows)
GET /api/device-types → built-in catalog (project_id NULL) — read-only listing
GET /api/projects/:pid/device-types → built-ins + this project's custom types, merged
POST /api/projects/:pid/device-types ← {name, kind?, icon?, description?, ports: [{cable_type_id, count, label_prefix?, edge?}]}
Creates a project-custom row (built_in=0); inserts
device_type_ports rows in the same transaction.
PATCH /api/projects/:pid/device-types/:id ← partial. Only project-custom types are PATCHable;
mutating a built-in row → 403 (UI hides edit affordance).
Editing ports replaces the device_type_ports rows;
existing devices' ports are NOT retroactively reseeded.
DELETE /api/projects/:pid/device-types/:id Only project-custom; built-ins → 403.
ON DELETE SET NULL on devices.type_id so devices
keep their already-seeded ports.
# v4 — Connection requirements (the solver's input)
GET /api/projects/:pid/connection-requirements → [ConnectionRequirement, …]
POST /api/projects/:pid/connection-requirements ← {from_device_id, to_device_id,
preferred_cable_type_id?, must_connect?, notes?}
Server normalises (from, to) into (pair_lo, pair_hi)
before insert; duplicate (project, pair_lo, pair_hi,
preferred_cable_type_id) → 409 conflict.
PATCH /api/projects/:pid/connection-requirements/:id
DELETE /api/projects/:pid/connection-requirements/:id
# v4 — Solver
POST /api/projects/:pid/solve ← {} (or {?preview=1} to compute without applying)
→ {
cables_added: [Cable, …],
cables_kept: [int, …], # ids preserved by the diff
cables_removed: [int, …], # ids deleted (auto cables only)
bundles_added: [{Bundle, cable_ids: [int]}, …],
bundles_removed: [int, …],
unsatisfied: [{requirement_id, reason}, …],
warnings: [string, …],
}
Default applies in a single transaction. ?preview=1
returns the same shape without writing. User-created
cables (auto=0 in the cables table; see §5.1) are
never touched — the solver only adds/removes its own.
# v4 — Solver quick-fix combo endpoint (powers the inspector's
# "+ Add <type> port to <device> and re-solve" button — §5b.4).
POST /api/projects/:pid/devices/:id/ports-and-resolve
← {type_id: <int>,
label?: <str>,
x_offset?: <num>, y_offset?: <num>}
→ {port: Port, solve: <solve response>}
Single tx: inserts the port + re-runs solve. Used by
the quick-fix UI so the unmet badge resolves in one
server round-trip.
# v4.1 — Setup templates
GET /api/setup-templates → [SetupTemplate, …]
Read-only listing of built-in (and any project-custom,
post-v4.1) templates with their device/requirement
shapes (see §2.4).
POST /api/projects/:pid/apply-template ← {template_id: <int>,
name_overrides?: { <template_device_id>: "<name>" },
skip_devices?: [<template_device_id>, …]}
→ {devices_added: [Device, …],
requirements_added: [ConnectionRequirement, …],
skipped_devices: [{template_device_id, reason}, …],
requirements_skipped: [{template_requirement_id, reason}, …]}
Idempotent in spirit: name collisions surface in
skipped_devices; m resolves with name_overrides on
re-apply. Whole call is one transaction.
# Sync — export only in MVP
POST /api/projects/:pid/sync/export → writes the project's drawing to mExDraw
(overwrites previous version; mExDraw keeps
git-version-history sidecar)
3.1 v4 wire-shape additions
ConnectionRequirement(response):{id, project_id, from_device_id, to_device_id, preferred_cable_type_id|null, must_connect: bool, notes, created_at, updated_at}.DeviceType(response):{id, project_id|null, name, kind, icon|null, description, built_in: bool, ports: [{cable_type_id, count, label_prefix, edge, sort_order}]}.cablesgets anauto: boolfield on the row (slice 5.5 migration adds the column with default 0; the solver sets 1 on its own creations). The v3 cable rows m hand-drew keepauto=0.POST /api/.../cablescontinues to defaultauto=0; only the solver writesauto=1.
No POST /api/sync/import in MVP. Import is post-MVP and only ever serves
a one-shot migration use case (e.g. seeding LOFT from the legacy
Cable-Management drawing if m later changes his mind).
All write endpoints return the updated row. Errors are
{error: "string", details?: any}. No auth.
mExDraw HTTP credentials live in MEXDRAW_BASE_URL (e.g.
https://mxdrw.msbls.de) + MEXDRAW_TOKEN (bearer). The exporter calls
PUT $MEXDRAW_BASE_URL/api/drawings/<drawing_name>.excalidraw with the
generated scene JSON.
4. Export — DB → Excalidraw (visual-grammar conformance)
CableGUI generates a .excalidraw scene from a project's rows. The seed
drawing's grammar is the contract.
4.1 Element mapping
| DB row | Excalidraw element | Notes |
|---|---|---|
projects.drawing_name |
drawing filename in mExDraw | one drawing per project |
frames |
type=frame, name=frames.name |
x/y/width/height straight across |
devices |
type=rectangle + bound text with name |
strokeColor=color, frameId=frames.excalidraw_id |
ports |
type=ellipse, ~12×9 |
strokeColor=type.color, absolute pos = (device.x + port.x_offset, device.y + port.y_offset), no containerId binding (matches seed) |
io_markers |
type=diamond with bound text=label |
small (~30×30), strokeColor = the Power cable type's colour |
cables |
type=arrow |
strokeColor=type.color, startBinding.elementId = port/device/io excalidraw_id, same for end |
cable_types legend (global) |
one type=text row per cable_types row, top-left of the project's first frame |
strokeColor=color, text=name. Pulled from the global table, regenerated each export. |
bundles |
(rendering open question — see §5) | post-MVP: render as a thick path; v0: ignored on export |
4.2 Element IDs are stable across exports
Every CableGUI row carries excalidraw_id (TEXT, generated on first export
via crypto/rand → 21-char Excalidraw-style ID). On re-export the same row
reuses the same ID. This means:
- m's
.excalidrawcollaborator-cursors, element-comments, and undo history survive a re-export. - If m manually edits a port colour in Excalidraw (someday, once import exists), we can match it back to the right DB row by ID.
4.3 What is not in the export
- The legend's decorative separator lines (the 5
type=lineelements in the seed) — purely visual, m said they're not load-bearing. - Big "enclosure" rectangles like the seed's
tAs8zMDIdesk-surface. In v0 those are imported as plain devices when m draws them, and exported as plain rectangles too. No zone/enclosure concept in the schema.
4.4 Wall-outlet IO markers
A cable with to_io_id != NULL exports to an arrow whose endBinding
points to the IO diamond's element ID. The diamond is rendered with a small
IO text label (or m.label if customised). No pair link.
5. Bundle detection — project-scoped
A bundle is a set of cables that physically run together. Bundles never cross projects (a LOFT bundle and an OFFICE bundle are separate).
MVP detection rule, on GET /api/projects/:pid/bundles/suggestions:
Within project :pid, group cables by (from_endpoint, to_endpoint):
from_endpoint = (kind, id) where kind ∈ {port, device, io} and id = whichever *_id is set
to_endpoint = same shape
Treat the endpoint pair as unordered: {A, B} == {B, A}
A candidate suggestion = any group with ≥ 2 cables.
i.e. "two or more cables run between the same two endpoints" → almost certainly a bundle. Types in the group can be mixed (Power + USB + HDMI from desk → wall).
Suggestions are reviewed in the UI; clicking Accept creates a real
bundles row (auto=0). m can also create bundles manually by
shift-clicking cables.
Rendering bundles in the SVG view is a slice 6+ concern; in the export they're ignored in v0 (open question §9).
5b. v4 — Solver
The solver is the headline addition in v4. m's product-vision sentence maps onto it directly:
"I say what devices we have, the app tells me how to bundle cables and how the most efficient connection looks like"
The solver reads a project's devices (with their ports) and
connection_requirements, and writes a set of solver-owned cables
(rows with auto=1) + bundles. m's hand-drawn cables (auto=0) are
left strictly alone — the solver only adds and removes its own.
5b.1 Objective: maximum bundling — schematic only
CableGUI is a schematic, not a physical-routing tool. Cables are straight lines between endpoints; the solver has no model of walls, floors, cable trays, or path geometry. "Maximum bundling" therefore reduces to a single rule on the schematic:
When two or more cables share the same endpoint pair (device A ↔ device B), group them into one bundle.
This is the v3 endpoint-pair rule, applied to the solver's output. m's "visually cleaner setups" benefit comes from the bundle being a single labelled set in the inspector + a single mixed-colour glyph in the render (slice 9+), rather than from any path optimisation. Anything about trunks, frame-edge corridors, or auto-routing is out of scope — filed for "post-v0 ambient" in §8.
5b.2 Algorithm (v0)
Pure function. No graph search; no LP; no path optimisation. Single pass with greedy port allocation.
solve(project) ⇒ {add, remove, bundles, unsatisfied}:
let auto_cables_before = SELECT * FROM cables WHERE project=p AND auto=1
let port_free := {port_id -> bool} initialised TRUE for every port
minus ports already used by manual cables (auto=0)
for each requirement r in order(must_connect DESC, id ASC):
let ct = r.preferred_cable_type_id
?? auto_pick_cable_type(r.from_device, r.to_device)
?? fail("ambiguous")
let pa = first_free_port(r.from_device, ct, port_free)
let pb = first_free_port(r.to_device, ct, port_free)
if !pa or !pb:
if r.must_connect: unsatisfied.push({r.id, reason})
else: skip
continue
port_free[pa] = port_free[pb] = false
add.push(cable{type=ct, from_port=pa, to_port=pb, auto=1})
// Bundle by endpoint-pair (v3 rule, applied only to auto cables).
for each (device_a, device_b) pair with ≥ 2 add-cables:
bundles_add.push({auto=1, cables: those add-cables})
// Diff against auto_cables_before to compute remove[] (any prior auto
// cable whose (from, to, type) doesn't appear in add[]).
remove = auto_cables_before - add
return {add, remove, bundles_add, unsatisfied}
first_free_port(device, cable_type, free_map) picks the lowest-id port
on the device whose type_id matches and that is still free, returning
NULL if none. The lowest-id tiebreak is deterministic so repeated
solves produce the same plan.
auto_pick_cable_type(from, to) (used when preferred_cable_type_id is
NULL): find the set of cable types T = ports(from).types ∩ ports(to).types. If |T| == 1, return it. If |T| > 1, fail
("ambiguous; specify preferred_cable_type_id"). The UI surfaces this
as a "specify type" inline edit on the requirement.
5b.3 Solver-owned vs. user-owned cables
cables.auto distinguishes them.
| Operation | Effect on auto=0 cables |
Effect on auto=1 cables |
|---|---|---|
| POST /api/.../cables (m draws by hand) | inserts auto=0 | n/a |
| PATCH cables (m moves endpoint, relabels) | applies | applies (and the cable is "promoted" to auto=0 — m owns it now) |
| DELETE cables | applies | applies |
| POST /api/.../solve | left alone (their used ports are reserved before the solver runs) | replaced wholesale (remove[] + add[] in one tx) |
This way a manual cable m doesn't want the solver to second-guess survives every solve. If m wants the solver to take it over, he deletes his hand-drawn cable and re-solves; the solver re-creates an equivalent auto cable.
5b.4 When solver fails — quick-fix UX
Three classes of failure surface in the response's unsatisfied[]:
- No compatible cable type —
T = ports(from).types ∩ ports(to).typesis empty (e.g. a Power-only device to an HDMI-only device). - Ambiguous cable type —
|T| > 1, no preferred set on the requirement. - No free port — the cable type matches but every port on one side is already used.
The solver does not auto-add ports without m's consent. v4.1 ships
an explicit one-click quick-fix per class of failure, surfaced as a red
badge on the affected device in the inspector (§7) and as a button on
each unsatisfied[] entry in the preview-diff modal:
| Failure class | Quick-fix button | What it does |
|---|---|---|
| No compatible cable type | "+ Add <preferred_type> port to <device> and re-solve" | POST /api/projects/:pid/devices/:id/ports with type_id=preferred_type + sensible default offset, then immediately POST /solve again. The preferred_type is the requirement's preferred_cable_type_id. If the requirement has no preferred type, the button reads "Specify cable type" and opens an inline cable-type picker on the requirement instead. |
| Ambiguous cable type | "Specify cable type" | Opens an inline picker on the requirement row with the candidates from T pre-listed. On select → PATCH the requirement → re-solve. |
| No free port | "+ Add <type> port to <device> and re-solve" | Same as the no-compat case but the type is already determined (it's the requirement's preferred or auto-picked type). Adds a port on whichever side ran out (the response's reason carries which_side). |
All three quick-fixes do their work in a single round-trip request from the UI perspective: the click fires a POST that either chains the port insert + the re-solve server-side, or fires both calls back-to-back from the client (server-side chaining is simpler — see §3.2 for the endpoint shape).
The quick-fix never adds a port silently; the button text always names the device + cable type so m sees what's about to mutate.
5b.5 Preview vs. apply
?preview=1 returns the same shape without writing. The UI shows a diff
modal with add[], remove[], unsatisfied[]; m clicks Apply to fire
the same endpoint without preview=1. Default (no flag) applies
immediately. Live-solve (no Solve button — every requirement edit
triggers a debounced re-solve) is parked at slice 9+ as an opt-in.
6. Sync — export-only for v0
┌─────────────────────┐
│ CableGUI DB (truth) │
└──────────┬──────────┘
│
export ▼
(push) ┌────────────────────────┐
│ <project>.excalidraw │
│ on mxdrw.msbls.de │
└────────────────────────┘
- CableGUI UI → DB: synchronous (every drag/add/remove persists immediately).
- DB → Excalidraw: manual button "Export to Excalidraw" in the header,
per project. Calls
POST /api/projects/:pid/sync/export. - Excalidraw → DB: not implemented in v0. Anything m draws in Excalidraw stays in Excalidraw until he redraws it in CableGUI.
This keeps the v0 scope tight: no conflict resolution, no element-diff import, no auto-debounce. mExDraw keeps its own version history (git sidecar in the mdraw deploy) so a bad export is recoverable from there.
When mxdrw is unreachable: the export button shows a tooltip and disables; the editor keeps working against the local DB.
Post-MVP, import returns as a one-shot migration tool (separate
cablegui-migrate CLI tool, not part of the running server) for seeding
new projects from existing .excalidraw files.
7. UI flows
The editor lives at /. Layout:
┌────────────────────────────────────────────────────────────────────┐
│ CableGUI [LOFT ▾ projects-picker] [Export] [+ Project] │ ← header
├────────┬───────────────────────────────────────────────────────────┤
│ │ │
│ Legend │ │
│ │ │
│ Power │ Diagram surface (SVG) │
│ USB │ │
│ HDMI │ ┌─desk─────────────┐ ┌─rack──────────┐ │
│ DP │ │ [Mac] [Screen] … │ │ [NAS] [fritz] │ │
│ RJ45 │ └──────────────────┘ └───────────────┘ │
│ + Type │ │
│ │ │
│ Tools │ │
│ + Dev │ │
│ + Frm │ │
│ + IO │ │
│ draw │ │
│ │ │
├────────┴───────────────────────────────────────────────────────────┤
│ Inspector (selection-dependent: project / frame / device / port / │
│ cable / bundle details and actions) │
└────────────────────────────────────────────────────────────────────┘
Flow: pick a project
Header has a dropdown "LOFT ▾". Clicking it lists all projects from
GET /api/projects; clicking one swaps the diagram (GET /api/projects/:pid
loads the full snapshot in one round-trip). The picker also shows a
+ New Project action → modal with name, drawing_name (defaults to
<name>.excalidraw), description → POST /api/projects → switches to
the new project (which has 5 seeded cable types and no frames yet).
The currently active project's id is kept in URL state
(/?project=LOFT) so reload returns to the same project.
v4.1 — Flow: apply a setup template
The New Project modal gains a "or start from a template" section under the description field. Each built-in template ('Living Room', 'Home Office', 'Server Rack') is a clickable card listing its devices + the requirement edges between them. Selecting one expands an inline override form:
- A pre-filled name for each template device (m can edit each, e.g.
rename
TVtoBedroom TV). - Per-device "skip" checkbox.
On Create, the server does POST /api/projects first; on success,
immediately fires POST /api/projects/:pid/apply-template with the
collected overrides. The response's devices_added + requirements_added
are merged into the local snapshot and the project switches to it,
already populated.
For an already-existing empty project, the inspector's project header shows an "Apply template" action that opens the same override form without the project-create round-trip.
Once the template has stamped its devices + requirements, hit Solve (§7 "Flow: run the solver") to produce the wired diagram.
Flow: add a frame
+ Frmin the left toolbar (orF).- Click + drag on the canvas → rubber-band rectangle becomes a frame.
- Name prompt centered in the frame; Enter →
POST .../frames.
Flow: add a device (v4 — type-aware)
+ Dev(orD) → click on canvas → device placeholder appears.- First field in the inline namer: type dropdown (replaces the
v1 plain-name input). Options pulled from
GET /api/projects/:pid/device-types— built-ins listed first grouped bykind, then project-custom rows, thenCustom (no type). Typing in the dropdown filters byname(m types "n" → NAS jumps to top). Below the dropdown: a name input pre-filled with the type name + a digit if a same-named device already exists ("PC", "PC-2"). - Hit Enter →
POST .../deviceswithtype_id+ name. The server seeds the ports fromdevice_type_portsin the same transaction and returns the device with itsports. - Picking
Custom (no type)keeps the v3 behaviour: rectangle, no ports, m adds ports manually via the inspector. - The device renders with its ports already visible along the configured edge.
Flow: add a port
Select a device → inspector shows + Port button. Click → cursor becomes
a "ghost port" of the active cable type (legend selection). Snap to device
edge → click commits → POST .../devices/:id/ports.
Flow: draw a cable
Click a port → port highlights. Hover any other endpoint (port / device /
IO marker) → preview cable drawn in the source's type colour. Click commits
→ POST .../cables. Shift-click to bind to a whole device. Click an IO
diamond to terminate at a wall outlet.
Flow: add an IO marker (wall outlet)
+ IO (or I) → click on canvas → small diamond placed → optional label
text edit → POST .../io-markers. By design, the only cables that
terminate at an IO marker are Power cables, but the schema doesn't enforce
that — the UI shows a soft warning if m draws a non-Power cable to an IO.
Flow: pick / edit a cable type
Legend on the left is interactive and global (the same legend shows up
in every project). Click a row → that type becomes the active "drawing
type" for the current project's session. Drag the swatch → colour picker →
updates cable_types.color via PATCH /api/cable-types/:id. + Type at
the bottom → "new cable type" modal — POST /api/cable-types. Names are
globally unique.
The modal for editing / adding shows a banner: "Cable types are shared across all projects. Renaming or recolouring affects every project that uses this type." Deleting a type that's still in use by any cable returns a 400 with the offending cable count — the client surfaces it as an inline error in the modal.
Flow: drag a device
Pointer-drag → live transform on the SVG; on pointerup,
PATCH .../devices/:id persists x, y. Ports follow because their
offsets are relative.
Flow: bundles
In the inspector with nothing else selected, "Bundle suggestions" pulls
.../bundles/suggestions. Each suggestion shows the cables highlighted
on the diagram + an Accept button. Manual: shift-click multiple cables →
"Group as bundle" → name it → save.
v4 — Flow: declare connection requirements
The left sidebar gains a Requirements section under the legend:
Cable types
Power, USB, HDMI, DP, RJ45, + Type
Requirements ← new in v4
NAS ↔ Switch RJ45 must
PC ↔ TV HDMI must
Mac ↔ Soundbar HDMI nice
+ Requirement
Click + Requirement → modal with two device pickers (autocomplete from
the project's current devices), a cable-type picker (defaults to
auto-resolve if the device pair has only one matching type), and a
must/nice toggle. POST .../connection-requirements.
Alternative gesture (no tool armed, no selection): drag from device A to device B to seed a requirement modal with the pair pre-filled. The solver-edge preview drags out from the source device's edge in a thin dashed line until release.
m can also right-click a requirement row → edit / delete.
v4 — Flow: run the solver
Header gains a Solve button next to Export.
- Click Solve (or
S) →POST /api/projects/:pid/solve?preview=1. - A diff modal opens listing
add[],remove[],unsatisfied[]— the canvas behind it dims and previews the new cables in a translucent stroke + the to-be-removed cables in a strikethrough red. - Buttons:
- Apply → fires
POST .../solve(nopreview), applies in one transaction, closes the modal, re-renders canvas with the real cables in place. - Cancel → leaves everything as it was.
- Apply → fires
- Unsatisfied requirements get their own list at the bottom of the modal, each with a quick-action button: "Specify type", "+ Add port to device X", or "Drop requirement (set must=0)".
If unsatisfied[] is non-empty, the Solve button stays in a
soft-error state (yellow) until either every requirement is satisfiable
or m explicitly accepts the partial plan.
v4 — Inspector states
| Selection | Inspector shows |
|---|---|
| nothing | empty, with "Bundle suggestions" + "Project requirements" headlines |
| project header | name, drawing_name, description (editable), device count, requirement count, Solve / Export buttons |
| frame | name (editable), x/y/w/h, contained-device count, delete |
| device | name + type + icon, ports grid (type / label / connected? / +Port), unmet requirements list with red badges. Each badge carries a single quick-fix button — "+ Add <type> port to <device> and re-solve" (no-compat-type / no-free-port cases) or "Specify cable type" (ambiguous case) per §5b.4. delete |
| port | type, label, parent device, current cable (if any), delete |
| cable (auto=1) | source/target, type, driving requirement (clickable → opens requirement edit), parent bundle (if any), label, "Promote to manual" (sets auto=0) |
| cable (auto=0) | as v3 — type, source/target, label, delete |
| bundle | name, member cables (clickable to focus), the endpoint pair (Device A ↔ Device B), auto-detected flag |
Keyboard
P switch project, F add frame, D add device, I add IO marker,
T start cable from selected port, R add requirement,
S solve project (v4), E export, Esc cancel, Backspace delete
selection, ? show shortcuts.
8. First slices — v4 reshape
Slices 1 + 2 have shipped (see git history). v4 inserts new slices ahead of the original 3-5 because the solver depends on the catalog + the requirements model, not on manual cable drawing. The old "manual port + cable draw" slice is still in scope as a tweak path on the solver output, but it follows the solver instead of leading.
| # | Slice | Status | What's shipped |
|---|---|---|---|
| 1 | Bootstrap + project CRUD + global cable_types | ✅ shipped | See git: branch mai/picasso/slice-1-bootstrap. |
| 2 | Frames + devices + drag | ✅ shipped | See git: branch mai/picasso/slice-2-frames-devices. |
| 3 (was 4) | IO markers + cable-type editing | pending | Unchanged scope. + IO places a wall-outlet diamond. Legend swatch is a colour picker; renaming a type updates the legend on the fly. + Type adds new global types. |
| 4 (NEW) | Device-type catalog + type-aware device create | pending | Migration 002: device_types + device_type_ports, seeded with the 11 built-ins (§2.2). Migration adds devices.type_id. API: GET /api/device-types, GET /api/projects/:pid/device-types. Frontend: the +Dev inline namer becomes a type dropdown + name input; choosing a built-in type seeds the device's ports on the backend. Picking Custom (no type) falls back to v3 freeform. m can create a typed NAS + see its Power + RJ45 ports appear on the canvas. |
| 4.5 (NEW) | Manage device-type catalog (per project) | pending | Modal: POST/PATCH/DELETE /api/projects/:pid/device-types for project-custom rows. Edit affordance hidden for built-ins. Lets m add an exotic device type without contributing to the built-in catalog. Validation: a custom type can't share a name with a built-in (already enforced by UNIQUE(project_id, name) + a separate code-level check against built-ins). |
| 5 (NEW) | Connection requirements UI + CRUD | pending | Migration 003: connection_requirements. API: full CRUD under /api/projects/:pid/connection-requirements. Frontend: left-sidebar "Requirements" section, + Requirement modal (autocomplete from project's current devices, cable-type picker, must/nice toggle). Drag from device A to device B gestures the same modal pre-filled. Inspector for a selected device lists its requirements. |
| 6 (v4.1 EXPANDED) | Solver MVP + Solve button + setup templates | pending | POST /api/projects/:pid/solve with ?preview=1 support. v0 algorithm (§5b.2): pure-function, greedy port allocation, endpoint-pair bundling. Migration 003 adds cables.auto. Header gains a Solve button that opens the preview-diff modal. m clicks Solve → sees the cable plan + unmet requirements (each with its quick-fix button per §5b.4) → applies. Folded in v4.1: setup templates. Migration 004 adds setup_templates + setup_template_devices + setup_template_requirements and seeds 3 built-ins ('Living Room', 'Home Office', 'Server Rack'). API: GET /api/setup-templates, POST /api/projects/:pid/apply-template. UI: a "Templates" section in the New Project modal + an "Apply template" action on empty projects → seeds devices + requirements in one transaction → Solve produces the wired diagram. |
| 7 (was 3, slimmed) | Manual port + manual cable draw | pending | The v3 flow as a tweak path on solver output. + Port on an instance-owned device; click-port → click-port creates a hand-drawn cable (auto=0). Used to override the solver's choices or to extend its plan. |
| 8 (was 5) | Export to mxdrw.msbls.de | pending | POST .../sync/export writes a .excalidraw scene per the visual grammar (§4). Bundles ignored on export in v0. |
Slices 9+ (not promised for the first coder shift):
- Live-solve mode: re-run solver on every device/requirement edit with a debounce + previewed-but-not-applied diff in a toast. Opt-in toggle in project settings.
- Bundle rendering in the SVG (a single thick line with mixed-colour stops between the endpoint pair, plus a small badge with the cable count). Cables in a bundle still render as their individual lines underneath; the bundle is a visual overlay m can toggle.
- "Re-seed from type" action on a device.
- Custom setup templates (m authors them in-UI, not just the built-in three).
- Cable inventory metadata (length/SKU) if m later wants it.
- Dark mode.
Out of scope, period (would change CableGUI's mental model): path routing, cable-tray polylines, frame-edge corridors, wall-axis bundling, 3D, anything that treats a cable as more than a labelled endpoint pair.
9. Open questions for m — all closed in v4.1
The six v4 questions are now answered. Locked answers:
- Where do paths come from? → Nowhere — CableGUI is a schematic. Cables are straight lines between endpoints. The solver does not route, the renderer does not route, and "maximum bundling" reduces to the endpoint-pair rule (§5b.1). Anything resembling a path, trunk, cable tray, or frame-edge corridor is out of scope, period (§8 "Out of scope, period").
- Live solve or button-only? → Button-only for v0. Live-solve stays parked at slice 9+ as an opt-in.
- No-compatible-port-pair UX. → Explicit quick-fix. The
unsatisfied-requirement badge in the inspector carries a single
button — "+ Add <type> port to <device> and re-solve" —
that POSTs the port AND fires
/solvein one UI action. The button text always names the device + type, so m sees what's about to mutate (§5b.4 + §7). - Setup templates. → Folded INTO v4.1, in slice 6. Migration 004
adds
setup_templates+ child tables + 3 built-ins.GET /api/setup-templatesandPOST /api/projects/:pid/apply-templateship alongside the solver (§2.4 + §3 + slice 6 in §8). Custom templates (m authors his own) parked at slice 9+. - Catalog distribution. → SQL seed in migration 002. No external file loader.
- Promote to manual. → Explicit button on the cable inspector (§7 row "cable (auto=1)"). PATCHes that only update labels stay auto.
No open design questions remain. The coder shift is gated on m's go/no-go for v4.1 — not on any unanswered design question from picasso.
10. Deployment on mDock (raw docker)
Inspected mDock's live services on 2026-05-15 to lock the conventions before writing this:
- All m-built services on mDock live under
/home/m/stacks/<project>/with a singledocker-compose.yml. Older services in/home/m/<project>/use the same pattern; the canonical-new path isstacks/. - Compose v2 (
docker compose), images built from Gitea container registry (mgit.msbls.de/m/<project>:latest),restart: unless-stoppedon every service,container_name: <project>explicit. - Host port mappings: deliberately collision-free across the host. Existing high ports in use include 3300 (mgreen), 3077 (paperless-ai), 7878 (radarr), 8082 (mgeo-tileserver), 8989 (sonarr), 9696 (prowlarr). Port 7777 is free — taking it for CableGUI.
- Bind-mount volumes:
/home/m/<project>-data:/app/datais the canonical pattern (mgreen). For project-local data we putdata/next to the compose file so agit pull && docker compose up -dis the whole deploy:/home/m/stacks/cablegui/data:/app/data. - Secrets via
env_file: /home/m/secrets/<project>/.env(msports-garmin pattern). CableGUI only needsMEXDRAW_TOKENfor export. - No reverse proxy on mDock. Services expose ports directly on the LAN
(mDock =
192.168.178.131/ Tailscalemdock). Public exposure goes via mlake/Dokploy + Caddy when needed — out of scope for CableGUI (LAN-only). - Auto-deploy via the Gitea Actions self-hosted runner already installed
on mDock (
/home/m/act-runner/, labelself-hosted:host). Push tomain→ workflow on mDock →docker compose up --build -d.
Repo layout for CableGUI
CableGUI/
├── cmd/cablegui/main.go # Go binary
├── internal/
│ ├── db/ # migrations + store
│ ├── importer/ # post-MVP only (not in MVP)
│ ├── exporter/ # DB → .excalidraw
│ └── server/ # net/http handlers
├── web/ # embedded static frontend
│ ├── index.html
│ ├── main.js # ES module entry
│ ├── style.css
│ └── lib/... # SVG helpers, store, components
├── data/ # CableGUI runtime DB lives here (gitignored)
│ └── .gitkeep
├── docs/design.md # this file
├── Dockerfile
├── docker-compose.yml
├── .gitea/workflows/deploy.yml
├── .gitignore # data/, *.db, *.db-wal, *.db-shm
├── Makefile # build, typecheck, test, run
├── go.mod / go.sum
└── README.md
Dockerfile sketch
Multi-stage; the final image is scratch because modernc.org/sqlite is
pure Go.
# syntax=docker/dockerfile:1.7
FROM golang:1.23-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" \
-o /out/cablegui ./cmd/cablegui
FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /app
COPY --from=build /out/cablegui /app/cablegui
ENV CABLEGUI_ADDR=0.0.0.0:7777
ENV CABLEGUI_DB=/app/data/cablegui.db
USER nonroot:nonroot
EXPOSE 7777
ENTRYPOINT ["/app/cablegui"]
docker-compose.yml (on mDock at /home/m/stacks/cablegui/)
services:
cablegui:
image: mgit.msbls.de/m/cablegui:latest
container_name: cablegui
restart: unless-stopped
ports:
- "7777:7777"
environment:
- TZ=Europe/Berlin
- CABLEGUI_ADDR=0.0.0.0:7777
- CABLEGUI_DB=/app/data/cablegui.db
- MEXDRAW_BASE_URL=https://mxdrw.msbls.de
env_file:
- /home/m/secrets/cablegui/.env # contains MEXDRAW_TOKEN
volumes:
- /home/m/stacks/cablegui/data:/app/data
LAN URL: http://mdock:7777 (or http://192.168.178.131:7777).
Gitea Actions deploy workflow
.gitea/workflows/deploy.yml:
name: deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- name: Build image
run: docker build -t mgit.msbls.de/m/cablegui:latest .
- name: Push image
run: |
echo "${{ secrets.GITEA_TOKEN }}" | \
docker login mgit.msbls.de -u mAi --password-stdin
docker push mgit.msbls.de/m/cablegui:latest
- name: Up
run: |
cd /home/m/stacks/cablegui
docker compose pull
docker compose up -d
Local-development run (no Docker)
make run # go run ./cmd/cablegui → :7777 against ./data/cablegui.db
make typecheck # tsc --noEmit on web/
make test # go test ./...
The repo has data/ checked-in-empty (with .gitkeep); data/*.db* is
gitignored.
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)
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-anchorcome from the existinganchorForEndpointresolver (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 + Npx (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
ordis 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.
- If the cursor is within snap-radius (~16 px) of an existing clamp:
insert that clamp into the cable's polyline at the right
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/suggestionsendpoint stays useful as a "you might want to route these through the same clamps" hint. - Explicit named bundles → still in the
bundlestable. 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: []Clampcable_clamps: []{ cable_id, clamp_id, ord }
11.9 Open questions for m
- 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. - 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.
- 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?
- 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.
- 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)
- Schema migration + tx-aware store helpers (Create/Update/DeleteClamp, AttachClampToCable, DetachClampFromCable, ReorderClamps).
- HTTP endpoints + snapshot extension.
- Frontend: clamp render + + Clamp tool + canvas placement (no cable attach yet).
- Cable polyline render via clamps, mid-segment drag-to-clamp, clamp inspector.
- Shared-segment bundle visualisation (gradient stripe + count badge).
- Export pipeline extension — mxdrw arrows with mid-points + clamp diamonds. Bundle viz stays viewer-only.
DESIGN v5 READY FOR REVIEW