Compare commits

...

35 Commits

Author SHA1 Message Date
mAi
2933bb8662 fix(ui): left-click-drag on empty canvas pans the view
Canvas zoom shipped pan as middle-drag / Space+drag, which left m unable
to reach a freshly-created frame outside the default viewport — the
only escape was middle-button or holding Space, neither of which is
discoverable.

Empty-canvas left-pointerdown now starts an ambiguous gesture: if the
cursor moves past a 3px screen-space threshold it promotes to a pan
(Excalidraw / Figma standard); below the threshold it falls back to
the historic "click empties the selection" UX so plain clicks still
deselect. Pointerdown on a device, frame, IO marker, port, or cable
keeps routing to its own handler. Middle-drag and Space+drag pan
unchanged.
2026-05-16 14:05:46 +02:00
mAi
98fe040364 merge: v5 — cable routing via clamps (all 6 slices)
picasso shipped on a single branch (6 commits @ 813d59b):
- Migration 007: clamps + cable_clamps with PK(cable_id,ord) +
  UNIQUE(cable_id,clamp_id). Store helpers (CRUD + Attach with
  two-pass shift + Detach gap-close + Reorder).
- HTTP endpoints under /clamps and /cables/:cid/clamps.
- Frontend: +Clamp tool + canvas placement + frame-drag carries
  clamps + clamp inspector with cables-through list and
  cascade-with-confirm delete.
- Polyline cable render through clamps. Mid-segment drag picks
  nearest segment; pointerup snaps to existing clamp within
  MID_SNAP_PX/zoom or creates fresh.
- Bundle viz: shared segments get a thick striped overlay (width
  min(12,2+N), gradient stripes by count desc / id asc).
  ×N badge on clamps with ≥2 cables.
- Export: clamps as 12x12 rounded squares (Excalidraw rectangles);
  cable arrows carry mid-vertices through clamps; bundle viz stays
  viewer-only (Excalidraw can't represent gradient strokes).
2026-05-16 14:04:37 +02:00
mAi
813d59b068 feat(v5 slice 6): export clamps + cable mid-vertices to mxdrw
Excalidraw scene now mirrors the v5 routing model:

- Clamps export as 12×12 grey rounded squares (BackgroundColor=#888888,
  StrokeColor=#555555, Roundness type 3). Distinct from the red IO
  marker diamonds so wall outlets vs. routing anchors stay readable.
  Frame_id propagates into the element's FrameID per the existing
  pattern.
- Cable arrows include clamp positions as mid-vertices in the
  `points` array. Pre-grouped + sort.Slice-sorted by ord; each
  mid-vertex is added as an (x-fromAnchor.x, y-fromAnchor.y) offset.
  startBinding / endBinding still point at the from / to endpoint
  excalidraw_ids; mid-vertices are unbound (Excalidraw doesn't have
  per-vertex binding).
- IDAssignment grows a Clamps map; PersistExcalidrawIDs accepts it
  and updates clamps.excalidraw_id on first export so re-exports
  reuse the same element ids (collab cursors / undo history survive).
- Bundle-stripe overlay is **viewer-only** — Excalidraw can't
  represent gradient strokes losslessly, so we export individual
  cable arrows and let the in-app viewer derive the bundle viz.

Tests:
- TestBuildScene_ClampsRenderAsRectangles — 2 clamps → 2 rectangle
  elements + 2 ids in IDAssignment.Clamps.
- TestBuildScene_ArrowPointsIncludeClamps — cable with 1 clamp →
  arrow.Points has 3 entries; middle vertex equals the clamp's
  position relative to fromAnchor.

This closes the v5 slice plan (§11.10). Six slices, one branch,
one redeploy below.
2026-05-16 13:58:32 +02:00
mAi
2cbefd3146 feat(v5 slice 5): shared-segment bundle viz + clamp count badges
Walks every cable's polyline, keys each vertex by stable identity
(port:N / device:N / io:N / clamp:N), and accumulates cables by
undirected segment-key. Segments with ≥ 2 cables get a thick striped
overlay line in a new <g id="canvas-bundles"> layer, drawn on top of
the individual cable lines so the shared portion reads as a bundle
while endpoints still fan out to each cable's port colour.

- Stripe width: 2 + N px, capped at 12 (design v5 §11.3).
- Stripe order: by distinct cable-type count (ties by id) per
  v5 §11.9 q4.
- Implementation: SVG <linearGradient> with hard stops oriented
  perpendicular to the segment, registered in a new
  <defs id="canvas-defs"> on every render. Bundle <line> uses
  stroke="url(#bundle-grad-…)".
- <title> child lists the cable types and total cable count for
  hover tooltips.
- Clamp render gains a ×N badge when ≥ 2 cables route through it,
  derived independently from state.cableClamps.

Helper rename: cableVertices → cableVerticesWithKeys (returns
{vertices, keys}). The keys array also feeds the shared-segment
detection — keeps the geometry + identity tracking in one pass.
2026-05-16 13:54:57 +02:00
mAi
a1de1246e5 merge: remove '+ Type' button from sidebar legend
Per m: cable-type creation lives in the admin modal; the sidebar
button was prominent for a rare action.
2026-05-16 13:52:08 +02:00
mAi
fee9bc5d26 feat(ui): remove '+ Type' button from sidebar legend
Cable type creation is managed via the admin modal (⚙ → Cable types
tab), which makes the prominent sidebar affordance unnecessary. Drop
the button element and its click handler; the legend itself (rows,
edit button per row, active-type selection) is unchanged.
2026-05-16 13:50:49 +02:00
mAi
04e7e86a52 feat(v5 slice 4): cable polyline through clamps + mid-segment drag
Cables now render as <polyline> through their cable_clamps in `ord`
sequence. Empty clamp set collapses to a straight from→to line, so
nothing visual changes for unrouted (auto-emitted) cables.

cableVertices(cable, …) resolves the endpoint anchors + each clamp's
(x, y) into the vertex array. Endpoint-replug handles continue to
operate on the first/last vertex.

Mid-segment drag — startCableMidDrag:
- Triggered by pointerdown on a *selected* cable's polyline (button=0,
  not on an endpoint handle, no Space pan).
- nearestSegmentIndex + pointSegmentDistance pick which segment m is
  bending. The dragged vertex is rendered as a temp inserted point in
  the cable's polyline via a module-level cableMidDrag preview.
- On release: snap to the nearest existing clamp within
  MID_SNAP_PX / zoom (visual constant per design v5 §11.9 q2), else
  POST a fresh clamp at the drop point. Either way, attach to the
  cable at ord = segIdx + 1 so the new vertex sits inside the segment
  m was bending. A tiny-motion (< 4 world-units) drop is treated as
  a plain click-to-select and cancelled.

Snapping to a clamp already on the cable is a no-op (UNIQUE constraint
would 409). Re-fetches cable_clamps from the snapshot after each
attach so ord shifts from the slice-1 attach helper propagate.
2026-05-16 13:50:44 +02:00
mAi
6af076a5e0 feat(v5 slice 3): clamp render + Place tool + inspector
Frontend hooks for the v5 routing primitive.

- state gains clamps + cableClamps arrays, hydrated from the snapshot
  (`clamps`, `cable_clamps`). Reset on null-project + project-404 paths.
- API helpers: createClamp / patchClamp / deleteClamp + attach / detach /
  reorder cable_clamps.
- +Clamp tool button + "C" keyboard shortcut. armTool flips the
  tool-clamp class on .canvas-wrap (crosshair cursor).
- onCanvasPointerDown routes tool === "clamp" to placeClampAt, which
  POSTs a clamp at the click position. If the click target is on a
  cable, the new clamp is also attached to that cable in one go.
- renderCanvas paints clamps as 12×12 rounded squares (per design v5
  §11.9 q1) in a new #canvas-clamps <g>. Drag uses the existing
  startDrag pipeline (kind="clamp"), which now also moves clamps when
  their containing frame is dragged.
- renderInspectorClamp shows label + position + cables-through list +
  Delete (with cascade confirm when shared).

Slice 4 wires the clamp into a cable's polyline (mid-segment drag,
visual routing); for now placing a clamp on top of a cable just
attaches it.
2026-05-16 13:48:07 +02:00
mAi
ae59dfc894 feat(v5 slice 2): clamp HTTP endpoints
Wire the v5 store helpers from slice 1 onto net/http routes:

  GET    /api/projects/:pid/clamps
  POST   /api/projects/:pid/clamps
  PATCH  /api/projects/:pid/clamps/:id
  DELETE /api/projects/:pid/clamps/:id

  POST   /api/projects/:pid/cables/:cid/clamps          — attach
  PUT    /api/projects/:pid/cables/:cid/clamps          — reorder
  DELETE /api/projects/:pid/cables/:cid/clamps/:cmid    — detach

frame_id uses the same json.RawMessage tri-state as device/io patches
(absent / null / int) via the existing parseFrameRef helper.

Snapshot endpoint (GET /api/projects/:id) now carries the clamps[] +
cable_clamps[] arrays surfaced by ListClamps + ListCableClamps in
slice 1, so the frontend gets everything in one round-trip.
2026-05-16 13:42:23 +02:00
mAi
4202d0465f feat(v5 slice 1): clamps schema + store helpers + snapshot
Migration 007 introduces the v5 routing primitive:
- clamps table (project-scoped, optional frame_id, excalidraw_id).
- cable_clamps join (cable_id, clamp_id, ord) with PK on (cable_id, ord)
  and UNIQUE (cable_id, clamp_id) to block a clamp visiting the same
  cable twice.

Store helpers in internal/db/clamps.go:
- CreateClamp / GetClamp / ListClamps / UpdateClamp / DeleteClamp —
  standard project-scoped CRUD. UpdateClamp uses FrameRef tri-state.
- AttachClampToCable — appends or inserts at a given ord. Mid-sequence
  inserts use a two-pass shift (bump by 10000, settle to ord+1) since
  SQLite UPDATE doesn't support ORDER BY and a single bulk +1 would
  collide with the UNIQUE (cable_id, ord) PK.
- DetachClampFromCable — removes the row then closes the gap.
- ReorderCableClamps — replaces the whole sequence in one tx.
- ListClampsForCable / ListCableClamps — read helpers.

Snapshot now carries clamps + cable_clamps arrays so the frontend can
hydrate everything in one call.

Tests cover create / update / cascade-delete / attach (append + insert
+ duplicate-rejected) / detach (gap closes) / reorder / snapshot.
2026-05-16 13:40:53 +02:00
mAi
8df5de193a merge: fix overbroad gitignore matching cmd/mcables/
Bare 'mcables' pattern in .gitignore + .dockerignore matched cmd/mcables/
in addition to the built binary at repo root. Root-anchored to '/mcables'.
cmd/mcables/main.go now tracked in git. Fresh worktrees / clones build
clean without copying main.go from a sibling.
2026-05-16 13:39:16 +02:00
mAi
a675c499c3 fix: root-anchor mcables ignore pattern, commit cmd/mcables/main.go
The bare `mcables` pattern in .gitignore (line 11) and .dockerignore
(line 18) was intended to ignore the built binary at the repo root, but
without a leading slash it also matched the cmd/mcables/ directory. The
result: cmd/mcables/main.go was never tracked in git, and fresh worktrees
had to copy it from a sibling to build.

- Change `mcables` → `/mcables` in both files (still ignores the root
  binary; no longer matches the cmd subdirectory).
- Add cmd/mcables/main.go (copied from picasso's worktree, verified
  identical to head's main checkout).

Verified: `git check-ignore cmd/mcables/main.go` returns not-ignored;
a touched `./mcables` at the repo root is still ignored via `/mcables`.
`go build ./...` clean.
2026-05-16 13:38:52 +02:00
mAi
78bce498b4 merge: design v5 — cable routing via clamps (§11)
Schema (clamps + cable_clamps join), polyline-through-clamps rendering,
bundle = derived from shared-segment overlap (no detection algorithm),
clamp tool + drag-cable-midpoint-to-snap-through-clamp UX, export
maps to Excalidraw arrow mid-points.
2026-05-16 13:35:14 +02:00
mAi
359ed892ac merge: double-click port → start cable draw (dali's variant)
Adds armTool('cable') so the cursor shows crosshair during the
in-progress draw — matches m's literal 'cursor crosshair' request.

(Picasso shipped a similar fix in parallel due to a head dispatch
race; dropping picasso's variant in favour of this one.)
2026-05-16 13:29:58 +02:00
mAi
0ecd9c8b4a feat(ui): double-click a port to start a cable draw
Double-click a port → enter cable-draw mode from that port without
having to arm the cable tool first. armTool("cable") is called so
the crosshair cursor is active during the draw; the next port-click
hits the existing cable-draw-in-progress branch in onPortPointerDown
and commits the cable. Esc / clicking the source port cancels.

Single-click behaviour (select + open port inspector) is unchanged
because pointerdown still hits onPortPointerDown first; dblclick
upgrades the selection to a cable-draw source.
2026-05-16 13:29:02 +02:00
mAi
fca9fb0a0f 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
2026-05-16 13:19:55 +02:00
mAi
40ab3d2630 merge: drag-to-replug cable endpoints
Selected cable shows two endpoint handles (r=7, coloured + halo).
pointerdown on a handle starts an endpoint drag; hitTestEndpointTarget
resolves cursor over port / device / IO marker; pointerup PATCHes the
from_/to_ field. Cancel on empty canvas or same-endpoint drop.
auto=1 cables auto-promote to auto=0 when m successfully drops on a
new valid endpoint.
2026-05-16 13:17:25 +02:00
mAi
17e6b5e91c feat(ui): cable endpoint replug — drag handles to a new target
m can grab either end of a selected cable and drop it on a different
port / device / IO marker. Mechanics:

- Selected cable renders two .cable-handle circles at its endpoints
  (handle radius 7, filled in the cable's colour with a white halo +
  drop-shadow). Hidden unless the cable is selected so unrelated cables
  don't litter the canvas with grab points.
- pointerdown on a handle calls startCableReplug; the module-level
  cableReplug = {cableID, end, x, y} drives renderCanvas to anchor the
  affected endpoint at the cursor in world coords. Pointermove keeps
  the line tracking; pointerup hit-tests the cursor via
  elementsFromPoint (skipping the cable-handle itself).
- Drop target:
    port   → PATCH {from|to: {port_id}}
    device → PATCH {from|to: {device_id}}
    IO     → PATCH {from|to: {io_id}}
    empty / same endpoint → cancel (no PATCH)
- When the cable was auto=1 and the drop commits, the PATCH also sends
  promote=true so the server flips it to manual — m took control.
- preventDefault + stopPropagation on the handle pointerdown so canvas
  panning / cable-line clicks don't interfere. Pointer capture survives
  the drag leaving the SVG bounds.

CSS: .cable-handle gets grab cursor + drop-shadow; .replugging on the
canvas-wrap promotes to grabbing during the gesture.
2026-05-16 13:11:33 +02:00
mAi
9107a9f7b2 merge: device resize handle (bottom-right corner)
10x10 handle on every device, drag to resize. Min 60x30. On pointerup,
PATCH width/height + relayoutAllEdges so ports re-distribute. stopPropagation
keeps the body drag separate from the handle drag. Works at any zoom.
2026-05-16 13:07:31 +02:00
mAi
89686d0c1f feat(ui): bottom-right resize handle on devices
m: 'I want the size of devices to be customizable. A resize function at
the bottom right corner would be good.'

- 10×10 SVG handle drawn at each device's bottom-right corner with class
  .device-resize-handle + cursor: nwse-resize. Subtle grey by default,
  darker on hover so m can find it without it dominating the rect.
- startResize captures the pointer, stops propagation so the rect's
  pointerdown (= startDrag) doesn't also fire, and updates the local
  device.width / .height on every pointermove using svgPoint deltas —
  works at any zoom level via the same world-coord conversion the rest
  of the canvas uses.
- Clamps to 60×30 minimum during the drag so the rect can't collapse.
- On pointerup: PATCH /devices/:id with the new width + height, then
  relayoutAllEdges(deviceID) so ports on every edge redistribute to
  their i/(N+1) positions against the new dimensions. Right- and
  bottom-edge ports get the visible adjustment; top/left re-space too
  but their absolute positions don't change.
2026-05-16 12:59:51 +02:00
mAi
57a9154f18 merge: canvas zoom + pan (last of 6 polish tasks)
state.view = {x,y,zoom} drives SVG viewBox. Zoom clamped 0.2-5x.
- Wheel = zoom around cursor (Excalidraw-style)
- Middle-drag or Space+drag = pan
- 0 or Home = reset
- Header: zoom % indicator + Fit button (bbox + 40px padding)
- URL persists ?z=&px=&py= (cleaned when at default)
- All inputs/hit-tests stay in world coords — no changes needed to
  port/cable/drag handlers
2026-05-16 12:10:28 +02:00
mAi
6c31802522 feat(ui): canvas zoom + pan via SVG viewBox
m: wheel to zoom around the cursor, drag with middle-mouse / Space-held
to pan, `0` or `Home` to reset, Fit button to frame all content.

Implementation:
- state.view = { x, y, zoom } drives the SVG viewBox via applyViewBox().
  Base canvas is 2000×1500; viewBox = (view.x, view.y, 2000/zoom, 1500/zoom).
- Zoom clamped to 0.2x..5x. wheelZoom captures the cursor's world coord
  before + after the zoom-step and shifts view.x/y so it stays under
  the cursor (Excalidraw-style cursor-anchored zoom).
- startPan captures screen→world scale from getScreenCTM at pointerdown
  and converts pointer-move deltas into view.x/y updates — robust across
  zoom levels. Triggered by middle-mouse OR Space+drag. Releases pointer
  capture + persists the view on pointerup.
- resetView (0 / Home) restores zoom=1, x=0, y=0.
- fitToContent walks frames + devices + IO markers, computes their bbox
  with 40px padding, picks zoom = min(BASE_W/bw, BASE_H/bh), and centres
  the bbox inside the viewBox (compensating for aspect-ratio meet).
- Header gets a "100%" zoom indicator + Fit button. URL persists view
  as ?z=1.200&px=…&py=… so reload returns to the same view.

Because everything goes through viewBox (not CSS transform), svgPoint
still maps screen pixels to world coords via getScreenCTM. Existing
hit-tests, drag, port/cable placement all keep working unchanged.
2026-05-16 12:05:24 +02:00
mAi
46e8474c2b merge: requirements UX — per-device primary + all-view in admin
Device inspector gains a Requirements section + Requirement button
pre-filled with the current device's id. The global Requirements
section is removed from the left sidebar — legend + tools reclaim
the space. All-requirements view moves into the admin modal as a
5th tab.
2026-05-16 12:00:32 +02:00
mAi
9aa395854d feat(ui): requirements live in the device inspector + admin tab
m wants 'this device connects to ...' declared from the device itself,
not a global sidebar list.

- Device inspector gets a '+ Requirement' button under its Requirements
  section. Click pre-fills the modal with from_device_id = this device,
  so m only picks the other endpoint + cable type + must/nice.
- Existing requirement rows in the device inspector remain clickable —
  they jump to the requirement's own inspector pane.
- New 5th admin tab 'Requirements' carries the all-projects-wide list
  with Edit + Delete actions per row and a single '+ Add requirement'
  entry point (uses the same modal). Edit/Add close the admin modal
  so the requirement modal isn't stacked on top.
- Left sidebar 'Requirements' section + '+ Requirement' button removed.
  The legend + tools sections reclaim the freed real estate.

renderRequirements() and the renderRequirements call site in render()
deleted (no consumer left). #btn-add-requirement boot wiring removed.
2026-05-16 11:59:08 +02:00
mAi
f08c48e9b5 merge: admin modal — projects + cable types + device types + templates
⚙ button in header opens a tabbed modal:
- Projects: list, rename name/drawing_name/description, delete with
  typed-name confirm. patchProject API helper added.
- Cable types: global-scope banner, name + colour edit + delete
  (blocked on use) + add.
- Device types: built-ins read-only with locked badge; project-custom
  name/kind/icon/description CRUD. Port-profile reshape deferred —
  flagged in the UI.
- Setup templates: read-only with expanded member devices +
  requirements.

Modal over full page — fits the no-build vanilla-JS shape. Verified
on mDock (PATCH project rename + description round-trips).
2026-05-16 11:55:26 +02:00
mAi
6cd5925f4c feat(ui): admin modal — projects + cable types + device types + templates
Header gear ('⚙ Admin') opens a wide modal with four tabs:

- **Projects** — list, rename, edit drawing_name + description, delete
  with typed-name confirm. Wires the existing PATCH /projects/:id and
  DELETE /projects/:id?confirm=<name> endpoints; renaming was previously
  only reachable via the API.
- **Cable types** — full CRUD with the global-scope banner. Mirrors the
  legend's quick edit but in a tabular list, plus an inline "+ Add"
  form at the bottom.
- **Device types** — built-ins listed read-only with a locked badge
  showing kind, description, and port profile (each port row tinted
  with the cable_type's colour). Project-custom types under the active
  project get editable name / kind / icon / description + Delete.
  Port-profile editing on custom types is still deferred (port-profile
  reshape will land in a follow-up).
- **Setup templates** — read-only list of built-ins with member devices
  and connection requirements expanded under each.

The modal re-fetches projects / cable types / setup templates on open
so it reflects current state regardless of what m did via inspector
panes while it was closed.

Files:
- index.html: ⚙ Admin button + #modal-admin dialog scaffold.
- main.js: patchProject + createDeviceType/patchDeviceType/deleteDeviceType
  API helpers; openAdminModal + switchAdminTab + 4 render functions.
- style.css: .admin-shell / .admin-tabs / .admin-row + state classes.
2026-05-16 11:51:05 +02:00
mAi
9773063008 merge: port editor in sidebar — type + edge + name; +Port retired
Port inspector now has a Type dropdown (PATCH /ports/:id with
type_id), keeps edge picker + label input + delete + back-link.

Replaces the canvas-armed +Port tool with a sidebar 'Add port' form
(reached via +Port button in the device inspector). Form fields:
Type, Edge, Label with auto-default '<type> <next-index>' that stops
auto-updating once m hand-edits. Submit → POST → relayout edge for
even spacing → selection switches to the new port's editor.

Port rows in the device inspector's list now click-to-select.

Removed scaffolding: tool === 'port' branch, armPortTool,
placePortAt, snapToDeviceEdge, .tool-port cursor CSS.
2026-05-16 11:45:25 +02:00
mAi
61bc1dcf43 feat(ui): port editor + add-port form in the sidebar inspector
m: 'Add port' should be a sidebar form, not a two-step canvas gesture.

- Port inspector gains a Type dropdown (read /api/cable-types via
  state.cableTypes, PATCH /ports/:id with type_id). Edge picker + label
  + delete from prior shift are unchanged.
- New "Add port" form rendered from selection.kind === "port_new":
  Type / Edge / Label, Create + Cancel buttons. Default label is the
  next free index for the chosen type on this device ("HDMI 3" if two
  HDMIs already live there). Recomputes when m changes the type, but
  stops recomputing as soon as m hand-edits the label.
- +Port in the device inspector now flips selection to port_new,
  rendering the form. Submit → POST → switch to the new port's editor.
  No second canvas click required.
- Clicking a port row in the device inspector's port list selects that
  port and opens its editor (same surface as canvas-click).
- "← <device name>" back-link in both port editor and add-port form
  jumps back to the device inspector.

Removed: state.tool === "port" branch, armPortTool helper, placePortAt
function, .tool-port CSS, state.portToolDevice / portToolTypeID. The
canvas-armed +Port tool was the user-trip-wire perseus flagged; the
sidebar form replaces it entirely.

snapToDeviceEdge also removed — placePortAt was its only caller; the
edgeCentre + portEdge + relayoutEdge trio fully owns port placement
now.

Port rows in the device inspector get a hover background + pointer
cursor to read as clickable.
2026-05-16 11:40:45 +02:00
mAi
056777f1c1 merge: template-apply creates frame + grid-places devices inside
ApplyTemplate now creates a frame named after the template
('Living Room' etc, suffixed on collision), computes a uniform grid
(cols=min(ceil(sqrt(N)),4), rows=ceil(N/cols)), and places each
device inside the frame with frame_id set.

Frontend unchanged — activateProject re-hydrates the snapshot
including the new frame.

Tests cover frame creation + in-frame placement + name-collision
suffix. Verified on mDock: Living Room template → frame (200,200,
294×200) with TV/Soundbar at row 0 and ChromeCast wrapping to row 1.
2026-05-16 11:35:25 +02:00
mAi
2aff5eb04d feat(template): apply-template lands devices inside a named frame
Before: ApplyTemplate dropped devices in a horizontal row at fixed
canvas coords with frame_id NULL — devices appeared anywhere and m
had no way to express "these belong together".

Now: each apply creates a frame named after the template (suffixed
"…  2/3/…" on name collision) and lays the devices out in a uniform
grid inside it. Grid is roughly square (cols = ceil(sqrt(N)), capped
at 4) with 30/50 px gaps and 32/48 px padding. Each device gets the
new frame's id and grid-cell coords.

Schema unchanged. ApplyTemplateResult.frames_added carries the new
frame so the frontend can refresh the canvas without a full snapshot
reload.

Tests:
- TestApplyTemplate_CreatesFrameAndPlacesDevicesInside — frame is
  created with the template's name, every device has frame_id set,
  every device sits inside the frame rect, no two devices share a
  grid cell.
- TestApplyTemplate_FrameNameSuffixOnCollision — pre-existing
  "Living Room" frame in the project ⇒ template's frame named
  "Living Room 2".
- Existing tests unchanged.
2026-05-16 11:30:32 +02:00
mAi
5c11bf33cb merge: port UX bundle — selection feedback + even-spacing + onUp + device colour
3 commits (491db73, b28fc0c, 86264d1):
- +Port now sets state.selection on the new port → inspector switches
  to the port panel + halo shows
- Ports relayout to even spacing along the affected edge on every
  add/delete/edge-change (no more invisible stacking)
- startDrag.onUp captures the rect in closure instead of reading
  currentTarget after pointerup (no more 'classList of null' spam)
- Device colour: dropped CSS stroke/fill hard-codes, inline style now
  paints the rect — picker actually changes the visible colour

All verified end-to-end on the deployed image.
2026-05-16 11:25:32 +02:00
mAi
86264d1284 fix(ui): device colour now actually shows on the canvas
CSS .device-rect hard-coded stroke + fill, overriding the
stroke=${d.color} SVG attribute the JS wrote. Author CSS beats
presentation attributes, so changing the device colour via the
inspector picker was invisible.

Drop the stroke/fill overrides from .device-rect; set both inline
on the rect element instead — stroke = the chosen colour, fill =
a 12% tint via color-mix so the device reads coloured without
becoming garish. Inline style beats class CSS, so the picker works.

Frames + IO markers don't currently expose a colour picker, so no
analogous fix needed there.
2026-05-16 11:23:47 +02:00
mAi
b28fc0c565 fix(ui): even-spacing relayout on every port-set change
m's stronger invariant: ports must never overlap and must line up on
their edge. Replace the slide-collision dedup with full even-spacing
re-layout — for N ports on an edge, position i goes to axis · i/(N+1)
for i=1..N.

- New portEdge(port, dev) — snaps a port's current offsets to the
  nearest of the four edges (same heuristic as snapToDeviceEdge).
- New relayoutEdge(deviceID, edge) — re-spaces every port on the
  device-edge and PATCHes the ones whose offsets actually change.
  Sort key: x_offset for top/bottom, y_offset for left/right —
  preserves m's "I dropped it roughly here" order.

Applied on:
- placePortAt — re-layout the edge after the new port is created.
- inspector edge picker — capture oldEdge, PATCH the port to the
  centre of newEdge, then re-layout BOTH old and new edges.
- port delete — re-layout the edge the deleted port was on so the
  survivors collapse back to even spacing.

snapToDeviceEdge reverted to its pre-dedup shape (drop the existingPorts
arg and resolveCollision helper); the layout invariant is owned by
relayoutEdge now. edgeOf folded into portEdge.
2026-05-16 11:19:16 +02:00
mAi
491db730eb fix(ui): +Port feedback + snap dedup + startDrag closure-capture
Three changes from sherlock's Playwright debug (docs/sherlock-+port-bug.md):

1. Select the freshly-placed port. placePortAt now sets
   state.selection = {kind:"port", id:port.id} before render() so the
   inspector switches to the port panel and the .selected halo makes
   the new circle visible — fixes m's "+Port does nothing" perception
   (the port WAS being created server-side; it just rendered invisibly
   stacked under an existing one and the inspector stayed on the device).

2. Snap-to-edge dedup. snapToDeviceEdge now takes the existing ports
   on the device; if the computed (xOff, yOff) lands within 8px of a
   peer on the same edge, slide along the edge in 16px steps until a
   free slot is found. Eliminates pixel-perfect port stacks.

3. startDrag closure-capture. onUp asynchronously referenced
   e.currentTarget after pointerup nulled it, throwing a TypeError
   in the console on every click-only device selection. Capture
   dragTarget in the outer closure and use that inside add/remove.
2026-05-16 11:12:13 +02:00
mAi
90157dfd14 merge: migration 006 — IOx-* and Multi-plug-* are power strips
m: 'IOx-8 should have 8 powerports on the front, one on the back'.
Migration 006 reshapes all 8 power-distribution types (IOx-3/6/8,
Multi-plug 3/4/5/6, Wifi-plug) into 1 Power In on top (back) +
N Power Out on bottom (front).

Existing devices keep their old ports per design §2.3 — delete +
recreate to pick up the new layout.

Verified on mDock: IOx-8 ports = [(top, Power In, 1), (bottom,
Power Out, 8)].
2026-05-16 11:08:13 +02:00
20 changed files with 3253 additions and 176 deletions

View File

@@ -15,7 +15,7 @@ data
# Build artefacts
bin
mcables
/mcables
# Editor cruft
.vscode

2
.gitignore vendored
View File

@@ -8,7 +8,7 @@ data/*.db-shm
# Build artefacts
bin/
mcables
/mcables
# Editor
.vscode/

64
cmd/mcables/main.go Normal file
View File

@@ -0,0 +1,64 @@
package main
import (
"context"
"errors"
"log"
"net/http"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
"mgit.msbls.de/m/mcables/internal/db"
"mgit.msbls.de/m/mcables/internal/server"
"mgit.msbls.de/m/mcables/web"
)
func main() {
addr := envOr("MCABLES_ADDR", "0.0.0.0:7777")
dbPath := envOr("MCABLES_DB", "./data/mcables.db")
if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil {
log.Fatalf("mkdir data dir: %v", err)
}
store, err := db.Open(dbPath)
if err != nil {
log.Fatalf("open db: %v", err)
}
defer store.Close()
if err := db.Migrate(store.DB()); err != nil {
log.Fatalf("migrate: %v", err)
}
srv := &http.Server{
Addr: addr,
Handler: server.New(store, web.Static()),
ReadHeaderTimeout: 5 * time.Second,
}
go func() {
log.Printf("mcables listening on %s (db=%s)", addr, dbPath)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("listen: %v", err)
}
}()
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
<-stop
log.Printf("shutting down")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = srv.Shutdown(ctx)
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}

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

351
internal/db/clamps.go Normal file
View File

@@ -0,0 +1,351 @@
package db
import (
"database/sql"
"errors"
"fmt"
"strings"
)
// ClampCreate is the create-shape for a new clamp.
type ClampCreate struct {
X float64
Y float64
Label string
FrameID *int64
}
// ClampUpdate is the partial-update shape.
type ClampUpdate struct {
X *float64
Y *float64
Label *string
// FrameID tri-state: nil = leave alone; non-nil pointer to nil ptr
// would be ambiguous, so we use FrameRef like devices.
FrameID FrameRef
}
// CreateClamp inserts a new clamp inside a project.
func (s *Store) CreateClamp(projectID int64, c ClampCreate) (*Clamp, error) {
if _, err := s.GetProject(projectID); err != nil {
return nil, err
}
if c.FrameID != nil {
if _, err := s.GetFrame(projectID, *c.FrameID); err != nil {
return nil, fmt.Errorf("%w: frame_id: %v", ErrInvalidInput, err)
}
}
res, err := s.db.Exec(
`INSERT INTO clamps (project_id, x, y, label, frame_id)
VALUES (?, ?, ?, ?, ?)`,
projectID, c.X, c.Y, strings.TrimSpace(c.Label), nullableInt64(c.FrameID),
)
if err != nil {
return nil, mapWriteErr(err)
}
id, _ := res.LastInsertId()
return s.GetClamp(projectID, id)
}
// GetClamp returns a single clamp scoped to the project.
func (s *Store) GetClamp(projectID, id int64) (*Clamp, error) {
var c Clamp
var frame sql.NullInt64
var ex sql.NullString
err := s.db.QueryRow(
`SELECT id, project_id, x, y, label, frame_id, excalidraw_id, created_at, updated_at
FROM clamps WHERE id = ? AND project_id = ?`, id, projectID,
).Scan(&c.ID, &c.ProjectID, &c.X, &c.Y, &c.Label, &frame, &ex, &c.CreatedAt, &c.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
if frame.Valid {
v := frame.Int64
c.FrameID = &v
}
if ex.Valid {
c.ExcalidrawID = &ex.String
}
return &c, nil
}
// ListClamps returns every clamp in a project, ordered by id.
func (s *Store) ListClamps(projectID int64) ([]Clamp, error) {
rows, err := s.db.Query(
`SELECT id, project_id, x, y, label, frame_id, excalidraw_id, created_at, updated_at
FROM clamps WHERE project_id = ? ORDER BY id`, projectID,
)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Clamp{}
for rows.Next() {
var c Clamp
var frame sql.NullInt64
var ex sql.NullString
if err := rows.Scan(&c.ID, &c.ProjectID, &c.X, &c.Y, &c.Label, &frame, &ex, &c.CreatedAt, &c.UpdatedAt); err != nil {
return nil, err
}
if frame.Valid {
v := frame.Int64
c.FrameID = &v
}
if ex.Valid {
c.ExcalidrawID = &ex.String
}
out = append(out, c)
}
return out, rows.Err()
}
// UpdateClamp applies a partial update.
func (s *Store) UpdateClamp(projectID, id int64, u ClampUpdate) (*Clamp, error) {
cur, err := s.GetClamp(projectID, id)
if err != nil {
return nil, err
}
if u.X != nil {
cur.X = *u.X
}
if u.Y != nil {
cur.Y = *u.Y
}
if u.Label != nil {
cur.Label = strings.TrimSpace(*u.Label)
}
if u.FrameID.Set {
if u.FrameID.ID == nil {
cur.FrameID = nil
} else {
if _, err := s.GetFrame(projectID, *u.FrameID.ID); err != nil {
return nil, fmt.Errorf("%w: frame_id: %v", ErrInvalidInput, err)
}
id := *u.FrameID.ID
cur.FrameID = &id
}
}
if _, err := s.db.Exec(
`UPDATE clamps SET x = ?, y = ?, label = ?, frame_id = ?, updated_at = datetime('now')
WHERE id = ? AND project_id = ?`,
cur.X, cur.Y, cur.Label, nullableInt64(cur.FrameID), id, projectID,
); err != nil {
return nil, mapWriteErr(err)
}
return s.GetClamp(projectID, id)
}
// DeleteClamp removes a clamp. cable_clamps rows cascade.
func (s *Store) DeleteClamp(projectID, id int64) error {
res, err := s.db.Exec(`DELETE FROM clamps WHERE id = ? AND project_id = ?`, id, projectID)
if err != nil {
return mapWriteErr(err)
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrNotFound
}
return nil
}
// ListCableClamps returns every (cable_id, clamp_id, ord) row in a
// project, joined through cables to scope by project_id.
func (s *Store) ListCableClamps(projectID int64) ([]CableClamp, error) {
rows, err := s.db.Query(
`SELECT cc.cable_id, cc.clamp_id, cc.ord
FROM cable_clamps cc
JOIN cables c ON c.id = cc.cable_id
WHERE c.project_id = ?
ORDER BY cc.cable_id, cc.ord`, projectID,
)
if err != nil {
return nil, err
}
defer rows.Close()
out := []CableClamp{}
for rows.Next() {
var cc CableClamp
if err := rows.Scan(&cc.CableID, &cc.ClampID, &cc.Ord); err != nil {
return nil, err
}
out = append(out, cc)
}
return out, rows.Err()
}
// ListClampsForCable returns the clamps on a cable in ord sequence.
func (s *Store) ListClampsForCable(projectID, cableID int64) ([]CableClamp, error) {
if _, err := s.GetCable(projectID, cableID); err != nil {
return nil, err
}
rows, err := s.db.Query(
`SELECT cable_id, clamp_id, ord
FROM cable_clamps WHERE cable_id = ? ORDER BY ord`, cableID,
)
if err != nil {
return nil, err
}
defer rows.Close()
out := []CableClamp{}
for rows.Next() {
var cc CableClamp
if err := rows.Scan(&cc.CableID, &cc.ClampID, &cc.Ord); err != nil {
return nil, err
}
out = append(out, cc)
}
return out, rows.Err()
}
// AttachClampToCable inserts a (cable, clamp) row. If `ord` is 0, the
// clamp is appended at the end. Otherwise existing rows at or after
// `ord` shift up by 1 to make room.
func (s *Store) AttachClampToCable(projectID, cableID, clampID int64, ord int) (*CableClamp, error) {
if _, err := s.GetCable(projectID, cableID); err != nil {
return nil, err
}
if _, err := s.GetClamp(projectID, clampID); err != nil {
return nil, fmt.Errorf("%w: clamp not found", ErrInvalidInput)
}
tx, err := s.db.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback()
// Refuse loops — UNIQUE (cable_id, clamp_id) enforces this, but a
// pre-check gives a clearer error.
var exists int
if err := tx.QueryRow(
`SELECT COUNT(*) FROM cable_clamps WHERE cable_id = ? AND clamp_id = ?`,
cableID, clampID,
).Scan(&exists); err != nil {
return nil, err
}
if exists > 0 {
return nil, fmt.Errorf("%w: clamp %d already on cable %d", ErrConflict, clampID, cableID)
}
var maxOrd sql.NullInt64
if err := tx.QueryRow(
`SELECT MAX(ord) FROM cable_clamps WHERE cable_id = ?`, cableID,
).Scan(&maxOrd); err != nil {
return nil, err
}
current := 0
if maxOrd.Valid {
current = int(maxOrd.Int64)
}
if ord <= 0 || ord > current+1 {
ord = current + 1
} else if ord <= current {
// Shift existing rows at ord..current up by 1 to free the slot.
// SQLite UPDATE doesn't support ORDER BY (no UPDATE-with-temp
// trick available), so a single `ord = ord + 1` would collide
// with the UNIQUE (cable_id, ord) constraint during the bulk
// update. Two-pass avoids the conflict: bump to a high offset
// first, then settle back to ord+1.
if _, err := tx.Exec(
`UPDATE cable_clamps SET ord = ord + 10000
WHERE cable_id = ? AND ord >= ?`, cableID, ord,
); err != nil {
return nil, mapWriteErr(err)
}
if _, err := tx.Exec(
`UPDATE cable_clamps SET ord = ord - 10000 + 1
WHERE cable_id = ? AND ord >= ?`, cableID, 10000+ord,
); err != nil {
return nil, mapWriteErr(err)
}
}
if _, err := tx.Exec(
`INSERT INTO cable_clamps (cable_id, clamp_id, ord) VALUES (?, ?, ?)`,
cableID, clampID, ord,
); err != nil {
return nil, mapWriteErr(err)
}
if err := tx.Commit(); err != nil {
return nil, err
}
return &CableClamp{CableID: cableID, ClampID: clampID, Ord: ord}, nil
}
// DetachClampFromCable removes a clamp from a cable's polyline. The
// trailing rows close up to keep `ord` contiguous.
func (s *Store) DetachClampFromCable(projectID, cableID, clampID int64) error {
if _, err := s.GetCable(projectID, cableID); err != nil {
return err
}
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
var removed sql.NullInt64
if err := tx.QueryRow(
`SELECT ord FROM cable_clamps WHERE cable_id = ? AND clamp_id = ?`,
cableID, clampID,
).Scan(&removed); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
return err
}
if _, err := tx.Exec(
`DELETE FROM cable_clamps WHERE cable_id = ? AND clamp_id = ?`,
cableID, clampID,
); err != nil {
return mapWriteErr(err)
}
// Close the gap: anyone with ord > removed slides down by 1.
if _, err := tx.Exec(
`UPDATE cable_clamps SET ord = ord - 1
WHERE cable_id = ? AND ord > ?`, cableID, removed.Int64,
); err != nil {
return mapWriteErr(err)
}
return tx.Commit()
}
// ReorderCableClamps replaces the whole clamp sequence on a cable with
// the given clamp IDs, in order. Every member of clampIDs must already
// be a valid clamp in the same project; duplicates → ErrConflict.
func (s *Store) ReorderCableClamps(projectID, cableID int64, clampIDs []int64) ([]CableClamp, error) {
if _, err := s.GetCable(projectID, cableID); err != nil {
return nil, err
}
seen := map[int64]bool{}
for _, cid := range clampIDs {
if seen[cid] {
return nil, fmt.Errorf("%w: duplicate clamp %d", ErrConflict, cid)
}
seen[cid] = true
if _, err := s.GetClamp(projectID, cid); err != nil {
return nil, fmt.Errorf("%w: clamp %d not in project", ErrInvalidInput, cid)
}
}
tx, err := s.db.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback()
if _, err := tx.Exec(`DELETE FROM cable_clamps WHERE cable_id = ?`, cableID); err != nil {
return nil, mapWriteErr(err)
}
out := make([]CableClamp, 0, len(clampIDs))
for i, cid := range clampIDs {
ord := i + 1
if _, err := tx.Exec(
`INSERT INTO cable_clamps (cable_id, clamp_id, ord) VALUES (?, ?, ?)`,
cableID, cid, ord,
); err != nil {
return nil, mapWriteErr(err)
}
out = append(out, CableClamp{CableID: cableID, ClampID: cid, Ord: ord})
}
if err := tx.Commit(); err != nil {
return nil, err
}
return out, nil
}

188
internal/db/clamps_test.go Normal file
View File

@@ -0,0 +1,188 @@
package db
import (
"errors"
"testing"
)
func TestCreateClamp_Basic(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
c, err := s.CreateClamp(p.ID, ClampCreate{X: 100, Y: 200, Label: "trunk-1"})
if err != nil {
t.Fatalf("create: %v", err)
}
if c.X != 100 || c.Y != 200 || c.Label != "trunk-1" {
t.Errorf("bad shape: %+v", c)
}
if c.ProjectID != p.ID {
t.Errorf("project_id mismatch: got %d, want %d", c.ProjectID, p.ID)
}
}
func TestUpdateClamp_PositionAndLabel(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
c, _ := s.CreateClamp(p.ID, ClampCreate{X: 0, Y: 0})
nx, ny := 50.0, 75.0
lbl := "renamed"
upd, err := s.UpdateClamp(p.ID, c.ID, ClampUpdate{X: &nx, Y: &ny, Label: &lbl})
if err != nil {
t.Fatalf("update: %v", err)
}
if upd.X != 50 || upd.Y != 75 || upd.Label != "renamed" {
t.Errorf("update didn't take: %+v", upd)
}
}
func TestDeleteClamp_CascadesToCableClamps(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
pcT := builtInTypeID(t, s, "PC")
a, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "A", TypeID: &pcT, X: 0, Y: 0, Width: 100, Height: 35})
b, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "B", TypeID: &pcT, X: 200, Y: 0, Width: 100, Height: 35})
cab, _ := s.CreateCable(p.ID, CableCreate{TypeID: 1,
From: CableEndpoint{DeviceID: &a.ID}, To: CableEndpoint{DeviceID: &b.ID}})
cl, _ := s.CreateClamp(p.ID, ClampCreate{X: 100, Y: 50})
if _, err := s.AttachClampToCable(p.ID, cab.ID, cl.ID, 0); err != nil {
t.Fatalf("attach: %v", err)
}
if err := s.DeleteClamp(p.ID, cl.ID); err != nil {
t.Fatalf("delete: %v", err)
}
rows, _ := s.ListClampsForCable(p.ID, cab.ID)
if len(rows) != 0 {
t.Errorf("cable_clamps not cleared: %+v", rows)
}
}
func TestAttachClampToCable_AppendsAndOrders(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
pcT := builtInTypeID(t, s, "PC")
a, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "A", TypeID: &pcT, X: 0, Y: 0, Width: 100, Height: 35})
b, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "B", TypeID: &pcT, X: 200, Y: 0, Width: 100, Height: 35})
cab, _ := s.CreateCable(p.ID, CableCreate{TypeID: 1,
From: CableEndpoint{DeviceID: &a.ID}, To: CableEndpoint{DeviceID: &b.ID}})
c1, _ := s.CreateClamp(p.ID, ClampCreate{X: 50, Y: 0})
c2, _ := s.CreateClamp(p.ID, ClampCreate{X: 100, Y: 0})
c3, _ := s.CreateClamp(p.ID, ClampCreate{X: 150, Y: 0})
cc1, _ := s.AttachClampToCable(p.ID, cab.ID, c1.ID, 0)
cc2, _ := s.AttachClampToCable(p.ID, cab.ID, c2.ID, 0)
cc3, _ := s.AttachClampToCable(p.ID, cab.ID, c3.ID, 0)
if cc1.Ord != 1 || cc2.Ord != 2 || cc3.Ord != 3 {
t.Errorf("ord sequence wrong: %d, %d, %d", cc1.Ord, cc2.Ord, cc3.Ord)
}
}
func TestAttachClampToCable_InsertShiftsExisting(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
pcT := builtInTypeID(t, s, "PC")
a, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "A", TypeID: &pcT, X: 0, Y: 0, Width: 100, Height: 35})
b, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "B", TypeID: &pcT, X: 200, Y: 0, Width: 100, Height: 35})
cab, _ := s.CreateCable(p.ID, CableCreate{TypeID: 1,
From: CableEndpoint{DeviceID: &a.ID}, To: CableEndpoint{DeviceID: &b.ID}})
c1, _ := s.CreateClamp(p.ID, ClampCreate{X: 50, Y: 0})
c2, _ := s.CreateClamp(p.ID, ClampCreate{X: 100, Y: 0})
c3, _ := s.CreateClamp(p.ID, ClampCreate{X: 150, Y: 0})
_, _ = s.AttachClampToCable(p.ID, cab.ID, c1.ID, 0) // ord=1
_, _ = s.AttachClampToCable(p.ID, cab.ID, c2.ID, 0) // ord=2
// Insert c3 between c1 and c2 → c3 gets ord=2, old c2 bumps to 3.
if _, err := s.AttachClampToCable(p.ID, cab.ID, c3.ID, 2); err != nil {
t.Fatalf("attach mid: %v", err)
}
got, _ := s.ListClampsForCable(p.ID, cab.ID)
if len(got) != 3 {
t.Fatalf("len = %d, want 3: %+v", len(got), got)
}
want := []struct{ id int64; ord int }{
{c1.ID, 1}, {c3.ID, 2}, {c2.ID, 3},
}
for i, w := range want {
if got[i].ClampID != w.id || got[i].Ord != w.ord {
t.Errorf("[%d] got %+v, want clamp=%d ord=%d", i, got[i], w.id, w.ord)
}
}
}
func TestAttachClampToCable_DuplicateRejected(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
pcT := builtInTypeID(t, s, "PC")
a, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "A", TypeID: &pcT, X: 0, Y: 0, Width: 100, Height: 35})
b, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "B", TypeID: &pcT, X: 200, Y: 0, Width: 100, Height: 35})
cab, _ := s.CreateCable(p.ID, CableCreate{TypeID: 1,
From: CableEndpoint{DeviceID: &a.ID}, To: CableEndpoint{DeviceID: &b.ID}})
c1, _ := s.CreateClamp(p.ID, ClampCreate{X: 50, Y: 0})
_, _ = s.AttachClampToCable(p.ID, cab.ID, c1.ID, 0)
if _, err := s.AttachClampToCable(p.ID, cab.ID, c1.ID, 0); !errors.Is(err, ErrConflict) {
t.Errorf("duplicate err = %v, want ErrConflict", err)
}
}
func TestDetachClampFromCable_ClosesGap(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
pcT := builtInTypeID(t, s, "PC")
a, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "A", TypeID: &pcT, X: 0, Y: 0, Width: 100, Height: 35})
b, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "B", TypeID: &pcT, X: 200, Y: 0, Width: 100, Height: 35})
cab, _ := s.CreateCable(p.ID, CableCreate{TypeID: 1,
From: CableEndpoint{DeviceID: &a.ID}, To: CableEndpoint{DeviceID: &b.ID}})
c1, _ := s.CreateClamp(p.ID, ClampCreate{X: 50, Y: 0})
c2, _ := s.CreateClamp(p.ID, ClampCreate{X: 100, Y: 0})
c3, _ := s.CreateClamp(p.ID, ClampCreate{X: 150, Y: 0})
_, _ = s.AttachClampToCable(p.ID, cab.ID, c1.ID, 0)
_, _ = s.AttachClampToCable(p.ID, cab.ID, c2.ID, 0)
_, _ = s.AttachClampToCable(p.ID, cab.ID, c3.ID, 0)
if err := s.DetachClampFromCable(p.ID, cab.ID, c2.ID); err != nil {
t.Fatalf("detach: %v", err)
}
got, _ := s.ListClampsForCable(p.ID, cab.ID)
if len(got) != 2 {
t.Fatalf("len = %d, want 2", len(got))
}
if got[0].ClampID != c1.ID || got[0].Ord != 1 {
t.Errorf("[0] = %+v", got[0])
}
if got[1].ClampID != c3.ID || got[1].Ord != 2 {
t.Errorf("[1] = %+v", got[1])
}
}
func TestReorderCableClamps_FullReplace(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
pcT := builtInTypeID(t, s, "PC")
a, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "A", TypeID: &pcT, X: 0, Y: 0, Width: 100, Height: 35})
b, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "B", TypeID: &pcT, X: 200, Y: 0, Width: 100, Height: 35})
cab, _ := s.CreateCable(p.ID, CableCreate{TypeID: 1,
From: CableEndpoint{DeviceID: &a.ID}, To: CableEndpoint{DeviceID: &b.ID}})
c1, _ := s.CreateClamp(p.ID, ClampCreate{X: 50, Y: 0})
c2, _ := s.CreateClamp(p.ID, ClampCreate{X: 100, Y: 0})
c3, _ := s.CreateClamp(p.ID, ClampCreate{X: 150, Y: 0})
if _, err := s.ReorderCableClamps(p.ID, cab.ID, []int64{c3.ID, c1.ID, c2.ID}); err != nil {
t.Fatalf("reorder: %v", err)
}
got, _ := s.ListClampsForCable(p.ID, cab.ID)
if len(got) != 3 {
t.Fatalf("len = %d, want 3", len(got))
}
if got[0].ClampID != c3.ID || got[1].ClampID != c1.ID || got[2].ClampID != c2.ID {
t.Errorf("order wrong: %+v", got)
}
}
func TestSnapshot_IncludesClamps(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
_, _ = s.CreateClamp(p.ID, ClampCreate{X: 10, Y: 20})
_, _ = s.CreateClamp(p.ID, ClampCreate{X: 30, Y: 40})
snap, err := s.Snapshot(p.ID)
if err != nil {
t.Fatalf("snapshot: %v", err)
}
if len(snap.Clamps) != 2 {
t.Errorf("clamps in snapshot = %d, want 2", len(snap.Clamps))
}
}

View File

@@ -12,7 +12,7 @@ import (
// Caller passes one map per kind; keys are the in-project row ids,
// values are the 21-char Excalidraw element ids the exporter minted.
func (s *Store) PersistExcalidrawIDs(projectID int64,
frames, devices, ports, ios, cables map[int64]string,
frames, devices, ports, ios, cables, clamps map[int64]string,
) error {
tx, err := s.db.Begin()
if err != nil {
@@ -35,6 +35,9 @@ func (s *Store) PersistExcalidrawIDs(projectID int64,
if err := updateExIDs(tx, "cables", projectID, cables); err != nil {
return err
}
if err := updateExIDs(tx, "clamps", projectID, clamps); err != nil {
return err
}
return tx.Commit()
}

View File

@@ -0,0 +1,31 @@
-- mCables v5 — cable routing via clamps. See docs/design.md §11.
--
-- A clamp is a physical anchor placed on the canvas. A cable's polyline
-- runs from its `from` endpoint → its clamps in `ord` sequence → its
-- `to` endpoint. Cables that share an ordered pair of consecutive
-- clamps are visibly bundled along that segment (computed live by the
-- frontend; no detection pass).
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 INDEX clamps_frame_idx ON clamps(frame_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-based along from→to
PRIMARY KEY (cable_id, ord),
UNIQUE (cable_id, clamp_id)
);
CREATE INDEX cable_clamps_clamp_idx ON cable_clamps(clamp_id);

View File

@@ -191,10 +191,11 @@ type UnsatisfiedReq struct {
// ApplyTemplateResult is the response from POST /apply-template.
type ApplyTemplateResult struct {
DevicesAdded []Device `json:"devices_added"`
RequirementsAdded []ConnectionRequirement `json:"requirements_added"`
SkippedDevices []SkippedTemplateDevice `json:"skipped_devices"`
RequirementsSkipped []SkippedTemplateReq `json:"requirements_skipped"`
FramesAdded []Frame `json:"frames_added"`
DevicesAdded []Device `json:"devices_added"`
RequirementsAdded []ConnectionRequirement `json:"requirements_added"`
SkippedDevices []SkippedTemplateDevice `json:"skipped_devices"`
RequirementsSkipped []SkippedTemplateReq `json:"requirements_skipped"`
}
type SkippedTemplateDevice struct {
@@ -219,4 +220,29 @@ type Snapshot struct {
Bundles []Bundle `json:"bundles"`
CableTypes []CableType `json:"cable_types"`
ConnectionRequirements []ConnectionRequirement `json:"connection_requirements"`
Clamps []Clamp `json:"clamps"`
CableClamps []CableClamp `json:"cable_clamps"`
}
// Clamp is a routing anchor on the canvas. Cables route through clamps
// in `ord` sequence (see cable_clamps), giving m a physical handle on
// where bundles converge.
type Clamp struct {
ID int64 `json:"id"`
ProjectID int64 `json:"project_id"`
X float64 `json:"x"`
Y float64 `json:"y"`
Label string `json:"label"`
FrameID *int64 `json:"frame_id"`
ExcalidrawID *string `json:"excalidraw_id,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// CableClamp is one (cable, clamp, ord) row. Ord is 1-based along the
// cable's from→to direction.
type CableClamp struct {
CableID int64 `json:"cable_id"`
ClampID int64 `json:"clamp_id"`
Ord int `json:"ord"`
}

View File

@@ -4,6 +4,7 @@ import (
"database/sql"
"errors"
"fmt"
"math"
"strings"
)
@@ -161,6 +162,7 @@ func (s *Store) ApplyTemplate(projectID, templateID int64, opts ApplyTemplateOpt
}
out := &ApplyTemplateResult{
FramesAdded: []Frame{},
DevicesAdded: []Device{},
RequirementsAdded: []ConnectionRequirement{},
SkippedDevices: []SkippedTemplateDevice{},
@@ -171,8 +173,8 @@ func (s *Store) ApplyTemplate(projectID, templateID int64, opts ApplyTemplateOpt
opts.OriginX, opts.OriginY = 200, 200
}
// Pull existing device names in the project so we can pre-check
// collisions without aborting the whole transaction.
// Pull existing device + frame names in the project so we can
// pre-check collisions without aborting the whole transaction.
existing, err := s.ListDevices(projectID, nil)
if err != nil {
return nil, err
@@ -181,6 +183,14 @@ func (s *Store) ApplyTemplate(projectID, templateID int64, opts ApplyTemplateOpt
for _, d := range existing {
nameTaken[d.Name] = true
}
existingFrames, err := s.ListFrames(projectID)
if err != nil {
return nil, err
}
frameNameTaken := map[string]bool{}
for _, f := range existingFrames {
frameNameTaken[f.Name] = true
}
tx, err := s.db.Begin()
if err != nil {
@@ -188,6 +198,37 @@ func (s *Store) ApplyTemplate(projectID, templateID int64, opts ApplyTemplateOpt
}
defer tx.Rollback()
// Plan a uniform grid for the template's devices inside a new frame
// named after the template. The grid drives both frame size and
// per-device (x, y). Devices that get skipped (name collision /
// SkipDevices) leave their grid cell empty.
const (
devW, devH = 100.0, 35.0
gapX, gapY = 30.0, 50.0
padX, padY = 32.0, 48.0 // padY larger so the frame title clears row 1
)
n := len(tmpl.Devices)
cols := 1
if n > 0 {
cols = min(int(math.Ceil(math.Sqrt(float64(n)))), 4)
}
rows := 1
if n > 0 {
rows = (n + cols - 1) / cols
}
frameW := padX*2 + float64(cols)*devW + float64(cols-1)*gapX
frameH := padY + padX + float64(rows)*devH + float64(rows-1)*gapY
frameName := pickFrameName(tmpl.Name, frameNameTaken)
frame, err := createFrameTx(tx, projectID, FrameCreate{
Name: frameName, X: opts.OriginX, Y: opts.OriginY,
Width: frameW, Height: frameH,
})
if err != nil {
return nil, fmt.Errorf("seed frame %q: %w", frameName, err)
}
out.FramesAdded = append(out.FramesAdded, *frame)
// Map: template_device_id → newly-created device_id (or 0 if skipped).
tmplToDevice := map[int64]int64{}
@@ -215,17 +256,22 @@ func (s *Store) ApplyTemplate(projectID, templateID int64, opts ApplyTemplateOpt
tmplToDevice[td.ID] = 0
continue
}
// Lay out devices in a horizontal row near the origin, 150 px apart.
x := opts.OriginX + float64(i)*150
y := opts.OriginY
// Use createDeviceTx so the port-seeding share the same transaction.
// Grid cell (col, row) within the frame. Cell anchor is the
// top-left of the device rect; offsets are added to the frame's
// own (x, y) so the device sits inside the frame.
col := i % cols
row := i / cols
x := frame.X + padX + float64(col)*(devW+gapX)
y := frame.Y + padY + float64(row)*(devH+gapY)
// Use createDeviceTx so port-seeding shares the same transaction.
d, err := s.createDeviceTx(tx, projectID, DeviceCreate{
Name: name,
TypeID: &td.DeviceTypeID,
FrameID: &frame.ID,
X: x,
Y: y,
Width: 100,
Height: 35,
Width: devW,
Height: devH,
})
if err != nil {
return nil, fmt.Errorf("seed %s: %w", name, err)
@@ -294,6 +340,58 @@ func (s *Store) ApplyTemplate(projectID, templateID int64, opts ApplyTemplateOpt
return out, nil
}
// pickFrameName returns a frame name that doesn't collide with anything
// in `taken`. Tries the template name first, then "<name> 2", "<name> 3",
// and so on.
func pickFrameName(base string, taken map[string]bool) string {
if !taken[base] {
return base
}
for i := 2; ; i++ {
candidate := fmt.Sprintf("%s %d", base, i)
if !taken[candidate] {
return candidate
}
}
}
// createFrameTx inserts a frame inside the caller's transaction. Mirrors
// the validation in CreateFrame (name + positive size) but avoids the
// s.db.Exec call so ApplyTemplate can keep everything on the same
// connection under MaxOpenConns(1).
func createFrameTx(tx *sql.Tx, projectID int64, f FrameCreate) (*Frame, error) {
name := strings.TrimSpace(f.Name)
if name == "" {
return nil, fmt.Errorf("%w: name is required", ErrInvalidInput)
}
if f.Width <= 0 || f.Height <= 0 {
return nil, fmt.Errorf("%w: width and height must be positive", ErrInvalidInput)
}
res, err := tx.Exec(
`INSERT INTO frames (project_id, name, x, y, width, height)
VALUES (?, ?, ?, ?, ?, ?)`,
projectID, name, f.X, f.Y, f.Width, f.Height,
)
if err != nil {
return nil, mapWriteErr(err)
}
id, _ := res.LastInsertId()
var out Frame
var ex sql.NullString
err = tx.QueryRow(
`SELECT id, project_id, name, x, y, width, height, excalidraw_id, created_at, updated_at
FROM frames WHERE id = ? AND project_id = ?`, id, projectID,
).Scan(&out.ID, &out.ProjectID, &out.Name, &out.X, &out.Y, &out.Width, &out.Height,
&ex, &out.CreatedAt, &out.UpdatedAt)
if err != nil {
return nil, err
}
if ex.Valid {
out.ExcalidrawID = &ex.String
}
return &out, nil
}
// createDeviceTx is a tx-aware variant of CreateDevice used by
// ApplyTemplate so seeding the template's devices + their ports stays
// inside one atomic apply.

View File

@@ -234,6 +234,76 @@ func TestApplyTemplate_HomeOffice_ThenSolve(t *testing.T) {
}
}
func TestApplyTemplate_CreatesFrameAndPlacesDevicesInside(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
tmpls, _ := s.ListSetupTemplates()
var lr SetupTemplate
for _, tm := range tmpls {
if tm.Name == "Living Room" {
lr = tm
break
}
}
res, err := s.ApplyTemplate(p.ID, lr.ID, ApplyTemplateOptions{})
if err != nil {
t.Fatalf("apply: %v", err)
}
if len(res.FramesAdded) != 1 {
t.Fatalf("frames added = %d, want 1", len(res.FramesAdded))
}
frame := res.FramesAdded[0]
if frame.Name != "Living Room" {
t.Errorf("frame name = %q, want %q", frame.Name, "Living Room")
}
for _, d := range res.DevicesAdded {
if d.FrameID == nil || *d.FrameID != frame.ID {
t.Errorf("device %q: frame_id = %v, want %d", d.Name, d.FrameID, frame.ID)
}
// Device top-left should be inside the frame rect.
if d.X < frame.X || d.X+d.Width > frame.X+frame.Width {
t.Errorf("device %q: x=%v width=%v outside frame [%v..%v]", d.Name, d.X, d.Width, frame.X, frame.X+frame.Width)
}
if d.Y < frame.Y || d.Y+d.Height > frame.Y+frame.Height {
t.Errorf("device %q: y=%v height=%v outside frame [%v..%v]", d.Name, d.Y, d.Height, frame.Y, frame.Y+frame.Height)
}
}
// No two devices share the same (X, Y) — the grid layout spreads them out.
seen := map[[2]float64]string{}
for _, d := range res.DevicesAdded {
key := [2]float64{d.X, d.Y}
if prev, ok := seen[key]; ok {
t.Errorf("devices %q and %q share grid cell (%v, %v)", prev, d.Name, d.X, d.Y)
}
seen[key] = d.Name
}
}
func TestApplyTemplate_FrameNameSuffixOnCollision(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
// Pre-create a frame called "Living Room" so the template's frame name collides.
_, _ = s.CreateFrame(p.ID, FrameCreate{Name: "Living Room", X: 0, Y: 0, Width: 100, Height: 100})
tmpls, _ := s.ListSetupTemplates()
var lr SetupTemplate
for _, tm := range tmpls {
if tm.Name == "Living Room" {
lr = tm
break
}
}
res, err := s.ApplyTemplate(p.ID, lr.ID, ApplyTemplateOptions{})
if err != nil {
t.Fatalf("apply: %v", err)
}
if len(res.FramesAdded) != 1 {
t.Fatalf("frames added = %d, want 1", len(res.FramesAdded))
}
if res.FramesAdded[0].Name != "Living Room 2" {
t.Errorf("frame name = %q, want %q (suffixed)", res.FramesAdded[0].Name, "Living Room 2")
}
}
func TestApplyTemplate_NameCollisionSkipped(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")

View File

@@ -187,6 +187,14 @@ func (s *Store) Snapshot(id int64) (*Snapshot, error) {
if err != nil {
return nil, err
}
clamps, err := s.ListClamps(id)
if err != nil {
return nil, err
}
cableClamps, err := s.ListCableClamps(id)
if err != nil {
return nil, err
}
return &Snapshot{
Project: *p,
Frames: frames,
@@ -197,6 +205,8 @@ func (s *Store) Snapshot(id int64) (*Snapshot, error) {
Bundles: bundles,
CableTypes: types,
ConnectionRequirements: reqs,
Clamps: clamps,
CableClamps: cableClamps,
}, nil
}

View File

@@ -11,6 +11,7 @@ import (
"encoding/json"
"fmt"
"math/big"
"sort"
"mgit.msbls.de/m/mcables/internal/db"
)
@@ -114,6 +115,7 @@ type IDAssignment struct {
Ports map[int64]string `json:"ports"`
IOMarkers map[int64]string `json:"io_markers"`
Cables map[int64]string `json:"cables"`
Clamps map[int64]string `json:"clamps"`
}
// BuildScene transforms a project snapshot into an Excalidraw Scene +
@@ -132,6 +134,7 @@ func BuildScene(snap *db.Snapshot, nowMilli int64, genID func() string) (*Scene,
Ports: map[int64]string{},
IOMarkers: map[int64]string{},
Cables: map[int64]string{},
Clamps: map[int64]string{},
}
// idFor: reuse the existing excalidraw_id if present, else mint one.
idFor := func(existing *string) string {
@@ -381,6 +384,58 @@ func BuildScene(snap *db.Snapshot, nowMilli int64, genID func() string) (*Scene,
})
}
// Clamps — small grey rounded squares (v5 §11.7). Distinct from the
// red IO marker diamonds so m can tell routing anchors from wall
// outlets at a glance.
const clampSize = 12.0
for _, cl := range snap.Clamps {
elID := idFor(cl.ExcalidrawID)
a.Clamps[cl.ID] = elID
var frameRef *string
if cl.FrameID != nil {
if v, ok := frameElID[*cl.FrameID]; ok {
frameRef = &v
}
}
els = append(els, Element{
ID: elID,
Type: "rectangle",
X: cl.X - clampSize/2,
Y: cl.Y - clampSize/2,
Width: clampSize,
Height: clampSize,
StrokeColor: "#555555",
BackgroundColor: "#888888",
FillStyle: "solid",
StrokeWidth: 1,
StrokeStyle: "solid",
Roughness: 0,
Opacity: 100,
GroupIDs: []string{},
FrameID: frameRef,
Roundness: &Roundness{Type: 3},
Seed: randInt(),
Version: 1,
VersionNonce: randInt(),
Updated: nowMilli,
})
}
// Pre-group cable_clamps by cable for the arrow mid-points pass.
clampsByCable := map[int64][]db.CableClamp{}
for _, cc := range snap.CableClamps {
clampsByCable[cc.CableID] = append(clampsByCable[cc.CableID], cc)
}
for _, arr := range clampsByCable {
// Already sorted by ListCableClamps (ORDER BY cable_id, ord),
// but defend against unsorted inputs.
sort.Slice(arr, func(i, j int) bool { return arr[i].Ord < arr[j].Ord })
}
clampPos := map[int64][2]float64{}
for _, cl := range snap.Clamps {
clampPos[cl.ID] = [2]float64{cl.X, cl.Y}
}
// Cables — arrows with startBinding/endBinding to the port / device /
// IO marker excalidraw_ids. Endpoint anchors (the visible "from" /
// "to" points) come from the same anchor logic the canvas uses.
@@ -403,6 +458,18 @@ func BuildScene(snap *db.Snapshot, nowMilli int64, genID func() string) (*Scene,
}
startArr := ""
endArr := "arrow"
// Excalidraw arrow `points` is relative to (X, Y). We anchor at
// the from-point, so vertex 0 is always (0, 0). Mid-vertices
// (clamps) and the final to-vertex are offsets from there.
pts := [][2]float64{{0, 0}}
for _, cc := range clampsByCable[c.ID] {
pos, ok := clampPos[cc.ClampID]
if !ok {
continue
}
pts = append(pts, [2]float64{pos[0] - fromAnchor[0], pos[1] - fromAnchor[1]})
}
pts = append(pts, [2]float64{toAnchor[0] - fromAnchor[0], toAnchor[1] - fromAnchor[1]})
els = append(els, Element{
ID: elID,
Type: "arrow",
@@ -422,7 +489,7 @@ func BuildScene(snap *db.Snapshot, nowMilli int64, genID func() string) (*Scene,
Version: 1,
VersionNonce: randInt(),
Updated: nowMilli,
Points: [][2]float64{{0, 0}, {toAnchor[0] - fromAnchor[0], toAnchor[1] - fromAnchor[1]}},
Points: pts,
StartArrowhead: &startArr,
EndArrowhead: &endArr,
StartBinding: bindingPtr(fromRef),

View File

@@ -137,6 +137,66 @@ func TestBuildScene_BundlesIgnored(t *testing.T) {
}
}
func TestBuildScene_ClampsRenderAsRectangles(t *testing.T) {
snap := sampleSnapshot()
snap.Clamps = []db.Clamp{
{ID: 1, ProjectID: 1, X: 500, Y: 300},
{ID: 2, ProjectID: 1, X: 550, Y: 320},
}
scene, ids := BuildScene(snap, 1700000000000, newSeq())
if len(ids.Clamps) != 2 {
t.Errorf("clamp ids = %d, want 2", len(ids.Clamps))
}
clampElIDs := map[string]bool{}
for _, id := range ids.Clamps {
clampElIDs[id] = true
}
got := 0
for _, e := range scene.Elements {
if clampElIDs[e.ID] && e.Type == "rectangle" {
got++
}
}
if got != 2 {
t.Errorf("clamp rectangle elements = %d, want 2", got)
}
}
func TestBuildScene_ArrowPointsIncludeClamps(t *testing.T) {
snap := sampleSnapshot()
snap.Clamps = []db.Clamp{
{ID: 10, ProjectID: 1, X: 350, Y: 250},
}
snap.CableClamps = []db.CableClamp{
{CableID: 1000, ClampID: 10, Ord: 1},
}
scene, _ := BuildScene(snap, 1700000000000, newSeq())
var arrow *Element
for i := range scene.Elements {
if scene.Elements[i].Type == "arrow" {
arrow = &scene.Elements[i]
break
}
}
if arrow == nil {
t.Fatal("no arrow in scene")
}
if len(arrow.Points) != 3 {
t.Errorf("arrow points = %d, want 3 (from + clamp + to): %+v", len(arrow.Points), arrow.Points)
}
// First point is always (0, 0) by convention; middle point should
// equal the clamp's position relative to the arrow's anchor.
if arrow.Points[0][0] != 0 || arrow.Points[0][1] != 0 {
t.Errorf("first point = %v, want [0,0]", arrow.Points[0])
}
// Middle vertex = clamp.x - fromAnchor.x, clamp.y - fromAnchor.y.
// fromAnchor for port 100 = (200 + 50, 200 + 35) = (250, 235).
wantX, wantY := 350.0-250.0, 250.0-235.0
if arrow.Points[1][0] != wantX || arrow.Points[1][1] != wantY {
t.Errorf("mid point = %v, want [%v, %v]", arrow.Points[1], wantX, wantY)
}
}
func TestMarshalScene_IsJSON(t *testing.T) {
snap := sampleSnapshot()
scene, _ := BuildScene(snap, 1700000000000, newSeq())

195
internal/server/clamps.go Normal file
View File

@@ -0,0 +1,195 @@
package server
import (
"encoding/json"
"errors"
"net/http"
"mgit.msbls.de/m/mcables/internal/db"
)
type clampCreate struct {
X float64 `json:"x"`
Y float64 `json:"y"`
Label string `json:"label,omitempty"`
FrameID json.RawMessage `json:"frame_id,omitempty"`
}
type clampPatch struct {
X *float64 `json:"x,omitempty"`
Y *float64 `json:"y,omitempty"`
Label *string `json:"label,omitempty"`
FrameID json.RawMessage `json:"frame_id,omitempty"`
}
type cableClampAttach struct {
ClampID int64 `json:"clamp_id"`
Ord int `json:"ord,omitempty"`
}
type cableClampReorder struct {
ClampIDs []int64 `json:"clamp_ids"`
}
func (h *handlers) listClamps(w http.ResponseWriter, r *http.Request) {
pid, ok := parseInt64Path(r, "pid")
if !ok {
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
return
}
cs, err := h.store.ListClamps(pid)
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusOK, cs)
}
func (h *handlers) createClamp(w http.ResponseWriter, r *http.Request) {
pid, ok := parseInt64Path(r, "pid")
if !ok {
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
return
}
var body clampCreate
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
return
}
ref, err := parseFrameRef(body.FrameID)
if err != nil {
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
return
}
c, err := h.store.CreateClamp(pid, db.ClampCreate{
X: body.X, Y: body.Y, Label: body.Label, FrameID: ref.ID,
})
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusCreated, c)
}
func (h *handlers) patchClamp(w http.ResponseWriter, r *http.Request) {
pid, ok := parseInt64Path(r, "pid")
if !ok {
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
return
}
id, ok := parseInt64Path(r, "id")
if !ok {
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
return
}
var body clampPatch
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
return
}
ref, err := parseFrameRef(body.FrameID)
if err != nil {
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
return
}
c, err := h.store.UpdateClamp(pid, id, db.ClampUpdate{
X: body.X, Y: body.Y, Label: body.Label, FrameID: ref,
})
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusOK, c)
}
func (h *handlers) deleteClamp(w http.ResponseWriter, r *http.Request) {
pid, ok := parseInt64Path(r, "pid")
if !ok {
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
return
}
id, ok := parseInt64Path(r, "id")
if !ok {
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
return
}
if err := h.store.DeleteClamp(pid, id); err != nil {
writeError(w, err, nil)
return
}
w.WriteHeader(http.StatusNoContent)
}
// POST /api/projects/:pid/cables/:cid/clamps — attach a clamp to a cable.
func (h *handlers) attachClampToCable(w http.ResponseWriter, r *http.Request) {
pid, ok := parseInt64Path(r, "pid")
if !ok {
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
return
}
cid, ok := parseInt64Path(r, "cid")
if !ok {
writeError(w, db.ErrInvalidInput, "cid must be a positive integer")
return
}
var body cableClampAttach
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
return
}
cc, err := h.store.AttachClampToCable(pid, cid, body.ClampID, body.Ord)
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusCreated, cc)
}
// DELETE /api/projects/:pid/cables/:cid/clamps/:cmid — detach a clamp.
func (h *handlers) detachClampFromCable(w http.ResponseWriter, r *http.Request) {
pid, ok := parseInt64Path(r, "pid")
if !ok {
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
return
}
cid, ok := parseInt64Path(r, "cid")
if !ok {
writeError(w, db.ErrInvalidInput, "cid must be a positive integer")
return
}
cmid, ok := parseInt64Path(r, "cmid")
if !ok {
writeError(w, db.ErrInvalidInput, "cmid must be a positive integer")
return
}
if err := h.store.DetachClampFromCable(pid, cid, cmid); err != nil {
writeError(w, err, nil)
return
}
w.WriteHeader(http.StatusNoContent)
}
// PUT /api/projects/:pid/cables/:cid/clamps — replace clamp sequence.
func (h *handlers) reorderCableClamps(w http.ResponseWriter, r *http.Request) {
pid, ok := parseInt64Path(r, "pid")
if !ok {
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
return
}
cid, ok := parseInt64Path(r, "cid")
if !ok {
writeError(w, db.ErrInvalidInput, "cid must be a positive integer")
return
}
var body cableClampReorder
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
return
}
out, err := h.store.ReorderCableClamps(pid, cid, body.ClampIDs)
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusOK, out)
}

View File

@@ -51,7 +51,7 @@ func (h *handlers) syncExport(w http.ResponseWriter, r *http.Request) {
// Persist the freshly-assigned ids so the next export reuses them.
// We pass in the full maps; PersistExcalidrawIDs is idempotent (it
// only updates rows whose excalidraw_id is still NULL).
if err := h.store.PersistExcalidrawIDs(pid, ids.Frames, ids.Devices, ids.Ports, ids.IOMarkers, ids.Cables); err != nil {
if err := h.store.PersistExcalidrawIDs(pid, ids.Frames, ids.Devices, ids.Ports, ids.IOMarkers, ids.Cables, ids.Clamps); err != nil {
writeError(w, fmt.Errorf("persist excalidraw_ids: %w", err), nil)
return
}

View File

@@ -93,6 +93,15 @@ func New(store *db.Store, frontend fs.FS) http.Handler {
// Slice 8 — export to mxdrw.msbls.de
mux.HandleFunc("POST /api/projects/{pid}/sync/export", h.syncExport)
// v5 — clamps + cable routing.
mux.HandleFunc("GET /api/projects/{pid}/clamps", h.listClamps)
mux.HandleFunc("POST /api/projects/{pid}/clamps", h.createClamp)
mux.HandleFunc("PATCH /api/projects/{pid}/clamps/{id}", h.patchClamp)
mux.HandleFunc("DELETE /api/projects/{pid}/clamps/{id}", h.deleteClamp)
mux.HandleFunc("POST /api/projects/{pid}/cables/{cid}/clamps", h.attachClampToCable)
mux.HandleFunc("PUT /api/projects/{pid}/cables/{cid}/clamps", h.reorderCableClamps)
mux.HandleFunc("DELETE /api/projects/{pid}/cables/{cid}/clamps/{cmid}", h.detachClampFromCable)
// Frontend (embedded). Serve "/" → index.html via http.FileServerFS.
// Wrap in noCache so the browser revalidates with the ETag/Last-Modified
// the file server already emits — without this, browsers cache aggressively

View File

@@ -23,6 +23,11 @@
<button type="button" id="btn-apply-template" class="btn">Apply template…</button>
<button type="button" id="btn-solve" class="btn btn-primary">Solve</button>
<button type="button" id="btn-export" class="btn">Export</button>
<button type="button" id="btn-admin" class="btn" title="Admin: projects, cable types, device types, setup templates">⚙ Admin</button>
<span class="zoom-cluster">
<span id="zoom-pct" title="Zoom — scroll on canvas, or 0/Home to reset">100%</span>
<button type="button" id="btn-fit" class="btn btn-tiny" title="Fit content to view">Fit</button>
</span>
<span id="toast" class="toast" hidden></span>
</header>
@@ -31,12 +36,6 @@
<section class="legend">
<h2 class="sidebar-heading">Cable types</h2>
<ul id="legend-list" class="legend-list"></ul>
<button type="button" id="btn-add-type" class="btn btn-tiny">+ Type</button>
</section>
<section class="requirements">
<h2 class="sidebar-heading">Requirements</h2>
<ul id="requirement-list" class="requirement-list"></ul>
<button type="button" id="btn-add-requirement" class="btn btn-tiny">+ Requirement</button>
</section>
<section class="tools">
<h2 class="sidebar-heading">Tools</h2>
@@ -44,6 +43,7 @@
<li><button type="button" id="tool-frame" class="btn btn-tiny" data-tool="frame">+ Frame</button></li>
<li><button type="button" id="tool-device" class="btn btn-tiny" data-tool="device">+ Device</button></li>
<li><button type="button" id="tool-io" class="btn btn-tiny" data-tool="io">+ IO</button></li>
<li><button type="button" id="tool-clamp" class="btn btn-tiny" data-tool="clamp" title="Click canvas to drop a clamp. Cables can then route through it.">+ Clamp</button></li>
<li><button type="button" id="tool-req" class="btn btn-tiny" data-tool="req">Drag req A→B</button></li>
<li><button type="button" id="tool-cable" class="btn btn-tiny" data-tool="cable" title="Click a port to start, then click another port / device / IO marker">Draw cable</button></li>
</ul>
@@ -52,10 +52,13 @@
<section class="canvas-wrap" aria-label="Diagram">
<svg id="canvas" viewBox="0 0 2000 1500" preserveAspectRatio="xMidYMid meet">
<defs id="canvas-defs"></defs>
<g id="canvas-frames"></g>
<g id="canvas-devices"></g>
<g id="canvas-ports"></g>
<g id="canvas-cables"></g>
<g id="canvas-bundles"></g>
<g id="canvas-clamps"></g>
<g id="canvas-io"></g>
</svg>
<p id="empty-hint" class="empty-hint">
@@ -224,6 +227,24 @@
</form>
</dialog>
<!-- Admin: projects + cable types + device types + setup templates -->
<dialog id="modal-admin" class="modal modal-wide" aria-labelledby="adm-title">
<div class="admin-shell">
<header class="admin-header">
<h2 id="adm-title">Admin</h2>
<button type="button" class="btn btn-link admin-close" data-close></button>
</header>
<nav class="admin-tabs" role="tablist">
<button type="button" class="admin-tab" data-admin-tab="projects" role="tab" aria-selected="true">Projects</button>
<button type="button" class="admin-tab" data-admin-tab="cable-types" role="tab">Cable types</button>
<button type="button" class="admin-tab" data-admin-tab="device-types" role="tab">Device types</button>
<button type="button" class="admin-tab" data-admin-tab="setup-templates" role="tab">Setup templates</button>
<button type="button" class="admin-tab" data-admin-tab="requirements" role="tab">Requirements</button>
</nav>
<section class="admin-body" id="admin-body" role="tabpanel"></section>
</div>
</dialog>
<script type="module" src="/main.js"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -183,14 +183,27 @@ body {
pointer-events: none;
}
/* Stroke + fill come from the device's user-set colour, written as
inline style in renderCanvas — leaving them out of .device-rect so
the author CSS doesn't override the inline style. */
.device-rect {
fill: #fff;
stroke: var(--text);
stroke-width: 1.5;
}
.device-rect.selected { stroke-width: 3; }
.device-rect:hover { filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.15)); }
/* Bottom-right resize affordance per device. Subtle grey by default,
stronger on hover so m can find it without it dominating the rect. */
.device-resize-handle {
fill: rgba(120, 120, 120, 0.35);
stroke: rgba(60, 60, 60, 0.45);
stroke-width: 1;
cursor: nwse-resize;
}
.device-resize-handle:hover {
fill: rgba(60, 60, 60, 0.65);
}
.device-label {
fill: var(--text);
font-size: 12px;
@@ -214,11 +227,44 @@ body {
.canvas-wrap.tool-device #canvas *,
.canvas-wrap.tool-io #canvas,
.canvas-wrap.tool-io #canvas *,
.canvas-wrap.tool-port #canvas,
.canvas-wrap.tool-port #canvas *,
.canvas-wrap.tool-clamp #canvas,
.canvas-wrap.tool-clamp #canvas *,
.canvas-wrap.tool-cable #canvas,
.canvas-wrap.tool-cable #canvas * { cursor: crosshair !important; }
/* Clamps — small grey rounded squares (v5 §11). Cables route through
them in `ord` sequence. */
.clamp {
fill: rgba(120, 120, 120, 0.85);
stroke: rgba(40, 40, 40, 0.85);
stroke-width: 1.5;
cursor: grab;
}
.clamp.selected {
stroke-width: 3;
filter: drop-shadow(0 0 4px var(--accent));
}
.clamp-label {
fill: var(--text-muted);
font-size: 10px;
pointer-events: none;
}
/* Shared-segment count badge — m sees ×N next to clamps that route
≥ 2 cables. */
.clamp-badge {
fill: var(--text);
font-size: 10px;
font-weight: 700;
pointer-events: none;
}
/* Bundle overlay — thick striped polyline drawn on top of individual
cables along shared segments. v5 §11.3. */
.bundle-line {
fill: none;
pointer-events: none;
opacity: 0.85;
}
.btn-link {
background: transparent;
border: 0;
@@ -236,6 +282,27 @@ body {
filter: drop-shadow(0 0 4px var(--accent));
}
/* Zoom cluster — % + Fit button next to Admin. */
.zoom-cluster {
display: inline-flex;
align-items: center;
gap: 6px;
margin-left: 8px;
padding-left: 12px;
border-left: 1px solid var(--border);
}
#zoom-pct {
font-size: 12px;
color: var(--text-muted);
min-width: 38px;
text-align: right;
font-variant-numeric: tabular-nums;
}
.canvas-wrap.panning #canvas,
.canvas-wrap.panning #canvas * { cursor: grabbing !important; }
.canvas-wrap.space-pan-ready #canvas,
.canvas-wrap.space-pan-ready #canvas * { cursor: grab !important; }
/* Header toast — slice 8 export feedback */
.toast {
display: inline-block;
@@ -295,8 +362,11 @@ body {
align-items: center;
gap: 6px;
font-size: 12px;
padding: 2px 0;
padding: 2px 4px;
border-radius: 4px;
cursor: pointer;
}
.port-row:hover { background: var(--surface-2); }
.port-row .swatch,
.swatch {
display: inline-block;
@@ -372,8 +442,121 @@ body {
.cable-line:hover { stroke-width: 4; }
.cable-line.selected { stroke-width: 4; }
/* Endpoint handles — only rendered for the currently-selected cable.
Grab cursor on idle, grabbing while dragging (.replugging on root). */
.cable-handle {
cursor: grab;
stroke-width: 2;
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.35));
}
.cable-handle:hover { stroke-width: 3; }
.canvas-wrap.replugging .cable-handle,
.canvas-wrap.replugging #canvas * { cursor: grabbing !important; }
/* Solve preview-diff modal */
.modal-wide { width: 560px; }
/* Admin modal — wider, tabbed */
.modal-wide.admin-shell-host { width: 760px; }
#modal-admin { width: 760px; max-width: 90vw; }
.admin-shell { padding: 16px; min-height: 460px; }
.admin-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.admin-header h2 { margin: 0; }
.admin-close { font-size: 16px; padding: 4px 8px; }
.admin-tabs {
display: flex;
gap: 2px;
border-bottom: 1px solid var(--border);
margin-bottom: 12px;
}
.admin-tab {
background: transparent;
border: 0;
border-bottom: 2px solid transparent;
padding: 8px 12px;
font: inherit;
color: var(--text-muted);
cursor: pointer;
}
.admin-tab:hover { color: var(--text); }
.admin-tab[aria-selected="true"] {
color: var(--text);
border-bottom-color: var(--accent);
}
.admin-body {
font-size: 13px;
max-height: 60vh;
overflow-y: auto;
}
.admin-row {
display: grid;
gap: 6px 12px;
padding: 8px 0;
border-bottom: 1px solid var(--border);
}
.admin-row:last-child { border-bottom: 0; }
.admin-row .field { display: grid; grid-template-columns: 110px 1fr; align-items: center; }
.admin-row .field span { color: var(--text-muted); font-size: 12px; }
.admin-row .field input,
.admin-row .field textarea,
.admin-row .field select {
width: 100%;
font: inherit;
padding: 4px 6px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg);
color: var(--text);
}
.admin-row .actions { display: flex; gap: 6px; justify-content: flex-end; }
.admin-row.locked { opacity: 0.85; }
.admin-row .locked-badge {
display: inline-block;
font-size: 11px;
padding: 1px 6px;
border-radius: 3px;
background: var(--surface-2);
color: var(--text-muted);
}
.admin-row-title {
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 600;
margin-bottom: 4px;
}
.admin-row-title .swatch { display: inline-block; }
.admin-empty { color: var(--text-muted); padding: 16px 0; }
.admin-add-row {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
.port-profile-list {
margin: 4px 0 0 0;
padding: 0;
list-style: none;
font-size: 12px;
color: var(--text-muted);
}
.port-profile-list li {
display: flex;
align-items: center;
gap: 6px;
padding: 2px 0;
}
.tmpl-detail {
margin: 4px 0 0 0;
font-size: 12px;
color: var(--text-muted);
}
.tmpl-detail ul { margin: 4px 0 0 16px; padding: 0; }
.sv-body { font-size: 13px; }
.sv-body h3 {
font-size: 11px;