Compare commits

...

18 Commits

Author SHA1 Message Date
mAi
7331f334a8 merge: frame resize handle (bottom-right corner)
Mirrors picasso's device-resize pattern. 10x10 handle at frame's
bottom-right; pointerdown→drag→PATCH width+height on release. Min
200x150. Doesn't interfere with frame-body drag or label-as-grip
selection.
2026-05-17 17:21:52 +02:00
mAi
1c234f3f46 feat(ui): bottom-right resize handle on frames
m: 'We should also be able to resize frames, the same way we do with
devices.' Mirrors the device-resize pattern (89686d0).

- 10×10 SVG handle drawn at each frame's bottom-right corner with class
  .frame-resize-handle + cursor: nwse-resize. Appended after the label
  so it sits on top of the rect and wins the pointerdown.
- startFrameResize captures the pointer, stops propagation so the
  rect's pointerdown (= startDrag 'frame') doesn't also fire, and
  updates f.width / f.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 200×150 minimum during the drag (frames need more room
  than devices since they host devices + IO markers + clamps).
- On pointerup: PATCH /api/projects/:pid/frames/:id with the new width
  + height. Contained children stay at their absolute positions — the
  frame body drag is what moves them; resize only changes the frame's
  own bounds, so devices/IO markers/clamps inside don't shift.
2026-05-17 17:19:53 +02:00
mAi
cff897978f merge: frame label = clickable drag grip
Drop pointer-events:none on .frame-label and add a pointerdown that
fires startDrag(e,'frame',f.id) — gives m a deterministic select-and-
drag grip at the top-left of every frame where devices/cables can't
occlude it.
2026-05-16 19:33:26 +02:00
mAi
55f8a06560 fix(ui): frame label is a clickable drag grip
Frame rect interior is occluded by devices/cables in SVG render order, so
clicking the frame to select/drag it was unreliable. Drop pointer-events:none
from .frame-label and bind the same pointerdown→startDrag('frame',id) as the
rect — the top-left label text is now a deterministic grip.
2026-05-16 19:32:14 +02:00
mAi
79e17a5cb1 merge: rename mCables → CableGUI (full)
Gitea repo: m/mCables → m/CableGUI
Docker image: m/mcables → m/cablegui
mDock paths: /home/m/stacks/{mcables→cablegui}/ + /home/m/secrets/{mcables→cablegui}/
DB filename: data/{mcables.db → cablegui.db}
Go module + env vars (MCABLES_* → CABLEGUI_*) renamed throughout.
LOFT project survived the DB filename move.
2026-05-16 15:39:16 +02:00
mAi
c206a331ec rename: mCables → CableGUI (project + repo + image + paths)
Full project rename per m's call. Single atomic commit because the
codebase rename is a coupled change — go module path, env vars, DB
default, Docker artefact names, and on-disk mDock paths all flip
together.

- go.mod: module mgit.msbls.de/m/mcables → mgit.msbls.de/m/cablegui
- cmd/mcables → cmd/cablegui (git mv)
- All Go imports rewritten to the new module path
- Env vars: MCABLES_ADDR/MCABLES_DB → CABLEGUI_ADDR/CABLEGUI_DB
- DB default path: data/mcables.db → data/cablegui.db
- Dockerfile + docker-compose.yml: image, container_name, env vars,
  bind-mount /home/m/stacks/mcables → /home/m/stacks/cablegui,
  secrets /home/m/secrets/mcables → /home/m/secrets/cablegui
- Makefile: bin target + run/build commands point at cmd/cablegui
- .gitignore + .dockerignore: /mcables → /cablegui
- README, docs/design.md, CLAUDE.md: prose + paths + image name
- web/static/index.html: <title> + brand
- web/static/main.js + web/web.go: header comment
- internal/exporter: Scene.Source "mcables" → "cablegui"
- internal/server/export.go: error-detail secrets path
- internal/db/migrations/*.sql: header comments (mCables vN → CableGUI vN)

Memory group_id kept as "mcables" to preserve existing memory continuity.
Documented as historical in CLAUDE.md.

go build ./... clean; go test -race ./... green
2026-05-16 15:35:42 +02:00
mAi
2b4c574508 merge: left-click-drag on empty canvas pans the view 2026-05-16 14:05:56 +02:00
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
40 changed files with 1763 additions and 177 deletions

View File

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

2
.gitignore vendored
View File

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

View File

@@ -1,11 +1,11 @@
# mCables — Project Instructions # CableGUI — Project Instructions
## Project Overview ## Project Overview
Cable-management **framework + solver** for m's setup. m declares his Cable-management **framework + solver** for m's setup. m declares his
**devices** and the **connection requirements** between them ("NAS must **devices** and the **connection requirements** between them ("NAS must
connect to Switch via RJ45"). mCables runs a solver that emits the cable connect to Switch via RJ45"). CableGUI runs a solver that emits the cable
plan + bundle recommendations. mCables is a **schematic**, not a plan + bundle recommendations. CableGUI is a **schematic**, not a
physical-routing tool — cables are straight lines between endpoints; the physical-routing tool — cables are straight lines between endpoints; the
"maximum bundling" objective is satisfied by the endpoint-pair rule "maximum bundling" objective is satisfied by the endpoint-pair rule
(when two or more cables share the same A↔B endpoint pair, group them (when two or more cables share the same A↔B endpoint pair, group them
@@ -13,16 +13,17 @@ into one bundle). The visual editor is still there for tweaking the
plan, but the solver is the headline. plan, but the solver is the headline.
Each cable-managed environment (LOFT, OFFICE, …) is a separate Each cable-managed environment (LOFT, OFFICE, …) is a separate
**mCables project**, and each project is backed by exactly one Excalidraw **CableGUI project**, and each project is backed by exactly one Excalidraw
drawing. The framework provides a visual web interface backed by a Go drawing. The framework provides a visual web interface backed by a Go
HTTP API and SQLite, plus an export pipeline that writes `.excalidraw` HTTP API and SQLite, plus an export pipeline that writes `.excalidraw`
files via mExDraw. files via mExDraw.
**Memory group_id:** `mcables` **Memory group_id:** `mcables` (kept historical — all prior memories live
under this id; renaming would orphan them)
**No CLI.** Frontend-first — every interaction is through the visual **No CLI.** Frontend-first — every interaction is through the visual
interface. The backend serves the UI and the API; there is no interface. The backend serves the UI and the API; there is no
`mcables` shell binary intended for humans. `cablegui` shell binary intended for humans.
## Goal ## Goal
@@ -53,7 +54,7 @@ interface. The backend serves the UI and the API; there is no
| Layer | Tech | Notes | | Layer | Tech | Notes |
|---|---|---| |---|---|---|
| DB | SQLite | `./data/mcables.db` (project-local, gitignored). Driver: `modernc.org/sqlite` (cgo-free). | | DB | SQLite | `./data/cablegui.db` (project-local, gitignored). Driver: `modernc.org/sqlite` (cgo-free). |
| Backend | Go | `net/http` HTTP API + static frontend via `embed.FS`. Standard library + minimal deps. Single binary. | | Backend | Go | `net/http` HTTP API + static frontend via `embed.FS`. Standard library + minimal deps. Single binary. |
| Frontend | Vanilla JS modules + SVG, no build step | TypeScript types via JSDoc, optional `tsc --noEmit` in CI. Preact-via-CDN-ESM is the documented fallback if vanilla state gets painful — no build step either way. | | Frontend | Vanilla JS modules + SVG, no build step | TypeScript types via JSDoc, optional `tsc --noEmit` in CI. Preact-via-CDN-ESM is the documented fallback if vanilla state gets painful — no build step either way. |
| Diagram I/O | mExDraw HTTP API | `PUT https://mxdrw.msbls.de/api/drawings/<name>.excalidraw` with `Authorization: Bearer $MEXDRAW_TOKEN`. (The `mcp__mexdraw__*` MCP tools are not currently configured for this project — workers use the raw HTTP API.) | | Diagram I/O | mExDraw HTTP API | `PUT https://mxdrw.msbls.de/api/drawings/<name>.excalidraw` with `Authorization: Bearer $MEXDRAW_TOKEN`. (The `mcp__mexdraw__*` MCP tools are not currently configured for this project — workers use the raw HTTP API.) |
@@ -112,14 +113,14 @@ interface. The backend serves the UI and the API; there is no
## Deployment — mDock, raw docker (NOT Dokploy) ## Deployment — mDock, raw docker (NOT Dokploy)
mCables runs on **mDock** (`192.168.178.131` on the LAN, Tailscale `mdock`) CableGUI runs on **mDock** (`192.168.178.131` on the LAN, Tailscale `mdock`)
as a **plain docker-compose service**. Dokploy is for public mlake/mRiver as a **plain docker-compose service**. Dokploy is for public mlake/mRiver
stuff; mDock uses raw `docker compose` per the conventions of the existing stuff; mDock uses raw `docker compose` per the conventions of the existing
mDock services (mgreen, mgeo, msports-garmin, paperless, …). mDock services (mgreen, mgeo, msports-garmin, paperless, …).
- Repo layout on mDock: `/home/m/stacks/mcables/` with `docker-compose.yml`, - Repo layout on mDock: `/home/m/stacks/cablegui/` with `docker-compose.yml`,
`data/` bind-mount, secrets in `/home/m/secrets/mcables/.env`. `data/` bind-mount, secrets in `/home/m/secrets/cablegui/.env`.
- Image: `mgit.msbls.de/m/mcables:latest` (built and pushed by a Gitea - Image: `mgit.msbls.de/m/cablegui:latest` (built and pushed by a Gitea
Actions workflow on push to `main`, runs on the self-hosted runner on Actions workflow on push to `main`, runs on the self-hosted runner on
mDock with label `self-hosted:host`). mDock with label `self-hosted:host`).
- Port mapping: `7777:7777`, exposed on the LAN — no reverse proxy. - Port mapping: `7777:7777`, exposed on the LAN — no reverse proxy.
@@ -127,12 +128,12 @@ mDock services (mgreen, mgeo, msports-garmin, paperless, …).
- LAN URL: `http://mdock:7777`. - LAN URL: `http://mdock:7777`.
- No auth — LAN-trusted. - No auth — LAN-trusted.
Local dev (no Docker): `go run ./cmd/mcables` against `./data/mcables.db`. Local dev (no Docker): `go run ./cmd/cablegui` against `./data/cablegui.db`.
## Seed drawing — visual grammar reference, **not** a runtime importer ## Seed drawing — visual grammar reference, **not** a runtime importer
`mxdrw.msbls.de/draw/Cable-Management.excalidraw` is **reference material `mxdrw.msbls.de/draw/Cable-Management.excalidraw` is **reference material
only**. mCables does **not** auto-ingest it. m will rebuild LOFT and OFFICE only**. CableGUI does **not** auto-ingest it. m will rebuild LOFT and OFFICE
from scratch inside the tool — the seed exists so the **exporter** mimics from scratch inside the tool — the seed exists so the **exporter** mimics
its visual grammar: its visual grammar:
@@ -163,13 +164,13 @@ Legend colours (global, seeded once by migration 001):
## Out of scope (v0) ## Out of scope (v0)
- Multi-user. mCables is m-only. - Multi-user. CableGUI is m-only.
- Auth / sharing — LAN-trusted on mDock. - Auth / sharing — LAN-trusted on mDock.
- Mobile / responsive — desktop browser only. - Mobile / responsive — desktop browser only.
- Cable inventory beyond visual structure (no length, no purchase history, - Cable inventory beyond visual structure (no length, no purchase history,
no SKU). Strictly visual structure for v0. no SKU). Strictly visual structure for v0.
- Import from `.excalidraw` at runtime. If a one-shot migration is ever - Import from `.excalidraw` at runtime. If a one-shot migration is ever
needed, a separate `mcables-migrate` CLI tool is the right shape, not a needed, a separate `cablegui-migrate` CLI tool is the right shape, not a
hot API endpoint. hot API endpoint.
## Worker Preferences ## Worker Preferences

View File

@@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1.7 # syntax=docker/dockerfile:1.7
# #
# mCables — single-stage build → distroless runtime image. # CableGUI — single-stage build → distroless runtime image.
# go.mod requires go 1.25; modernc.org/sqlite is pure Go so CGO_ENABLED=0 # go.mod requires go 1.25; modernc.org/sqlite is pure Go so CGO_ENABLED=0
# and a distroless/static runtime is all we need. # and a distroless/static runtime is all we need.
@@ -17,20 +17,20 @@ COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build \ RUN CGO_ENABLED=0 GOOS=linux go build \
-trimpath \ -trimpath \
-ldflags="-s -w" \ -ldflags="-s -w" \
-o /out/mcables \ -o /out/cablegui \
./cmd/mcables ./cmd/cablegui
FROM gcr.io/distroless/static-debian12:nonroot FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /app WORKDIR /app
COPY --from=build /out/mcables /app/mcables COPY --from=build /out/cablegui /app/cablegui
ENV MCABLES_ADDR=0.0.0.0:7777 \ ENV CABLEGUI_ADDR=0.0.0.0:7777 \
MCABLES_DB=/app/data/mcables.db CABLEGUI_DB=/app/data/cablegui.db
EXPOSE 7777 EXPOSE 7777
# Run as UID:GID 1000:1000 to match m on mDock — the bind-mounted # Run as UID:GID 1000:1000 to match m on mDock — the bind-mounted
# /home/m/stacks/mcables/data is owned by m:m, so the container can write # /home/m/stacks/cablegui/data is owned by m:m, so the container can write
# to it without chowning the host dir. distroless/static-debian12 accepts # to it without chowning the host dir. distroless/static-debian12 accepts
# arbitrary numeric UIDs; the Go binary doesn't need a /etc/passwd entry. # arbitrary numeric UIDs; the Go binary doesn't need a /etc/passwd entry.
USER 1000:1000 USER 1000:1000
ENTRYPOINT ["/app/mcables"] ENTRYPOINT ["/app/cablegui"]

View File

@@ -1,14 +1,14 @@
.PHONY: build run test typecheck fmt clean .PHONY: build run test typecheck fmt clean
BIN := bin/mcables BIN := bin/cablegui
PKG := ./... PKG := ./...
build: build:
@mkdir -p bin @mkdir -p bin
go build -trimpath -ldflags="-s -w" -o $(BIN) ./cmd/mcables go build -trimpath -ldflags="-s -w" -o $(BIN) ./cmd/cablegui
run: run:
go run ./cmd/mcables go run ./cmd/cablegui
test: test:
go test -race $(PKG) go test -race $(PKG)

View File

@@ -1,9 +1,9 @@
# mCables # CableGUI
Cable-management **framework** for m's setup — visual web editor backed by Cable-management **framework** for m's setup — visual web editor backed by
a single Go binary + SQLite, generating Excalidraw drawings via mExDraw. a single Go binary + SQLite, generating Excalidraw drawings via mExDraw.
Each cable-managed environment (LOFT, OFFICE, …) is a separate mCables Each cable-managed environment (LOFT, OFFICE, …) is a separate CableGUI
*project*; each project is backed by exactly one `.excalidraw` drawing on *project*; each project is backed by exactly one `.excalidraw` drawing on
mxdrw.msbls.de. mxdrw.msbls.de.
@@ -23,7 +23,7 @@ end-to-end; the SVG canvas is intentionally empty until slice 2.
## Run it ## Run it
```sh ```sh
go run ./cmd/mcables go run ./cmd/cablegui
# open http://localhost:7777 # open http://localhost:7777
``` ```
@@ -31,18 +31,18 @@ Or built:
```sh ```sh
make build make build
./bin/mcables ./bin/cablegui
``` ```
The binary serves the frontend from an embedded `web/static/` and the The binary serves the frontend from an embedded `web/static/` and the
JSON API under `/api/`. SQLite lives at `./data/mcables.db` by default. JSON API under `/api/`. SQLite lives at `./data/cablegui.db` by default.
### Environment ### Environment
| Var | Default | Notes | | Var | Default | Notes |
|---|---|---| |---|---|---|
| `MCABLES_ADDR` | `0.0.0.0:7777` | Listen address. | | `CABLEGUI_ADDR` | `0.0.0.0:7777` | Listen address. |
| `MCABLES_DB` | `./data/mcables.db` | SQLite path. Parent dir is created on boot. | | `CABLEGUI_DB` | `./data/cablegui.db` | SQLite path. Parent dir is created on boot. |
| `MEXDRAW_BASE_URL` | `https://mxdrw.msbls.de` | Base URL for mExDraw export. | | `MEXDRAW_BASE_URL` | `https://mxdrw.msbls.de` | Base URL for mExDraw export. |
| `MEXDRAW_USER` | (unset) | Username for the mxdrw HTTP Basic Auth on export. Required. | | `MEXDRAW_USER` | (unset) | Username for the mxdrw HTTP Basic Auth on export. Required. |
| `MEXDRAW_PASS` | (unset) | Password for the mxdrw HTTP Basic Auth on export. Required. | | `MEXDRAW_PASS` | (unset) | Password for the mxdrw HTTP Basic Auth on export. Required. |
@@ -78,37 +78,35 @@ DELETE /api/cable-types/:id ← 409 in_use if any cable references
## Deploy to mDock ## Deploy to mDock
mCables runs on **mDock** at `http://mdock:7777` as a docker-compose CableGUI runs on **mDock** at `http://mdock:7777` as a docker-compose
service under `/home/m/stacks/mcables/`. Pattern matches the other service under `/home/m/stacks/cablegui/`. Pattern matches the other
mDock services (mgreen-journal, mgeo, msports-garmin, …) — no Dokploy, mDock services (mgreen-journal, mgeo, msports-garmin, …) — no Dokploy,
no reverse proxy, LAN-trusted. no reverse proxy, LAN-trusted.
### Manual deploy (first roll) ### Manual deploy
1. **Build + push the image** (from any host with docker; today the 1. **Build + push the image** (image now lives under `m/` in Gitea):
image lives in mAi's Gitea namespace because mAi doesn't have write
access to `m/`):
```sh ```sh
docker build -t mgit.msbls.de/mai/mcables:latest . docker build -t mgit.msbls.de/m/cablegui:latest .
awk '/machine mgit.msbls.de/{getline; getline; print $2}' ~/.netrc-mai \ awk '/machine mgit.msbls.de/{getline; getline; print $2}' ~/.netrc \
| docker login mgit.msbls.de -u mAi --password-stdin | docker login mgit.msbls.de -u m --password-stdin
docker push mgit.msbls.de/mai/mcables:latest docker push mgit.msbls.de/m/cablegui:latest
``` ```
2. **Prepare directories on mDock** (one-time): 2. **Prepare directories on mDock** (one-time):
```sh ```sh
ssh mdock 'mkdir -p /home/m/stacks/mcables/data /home/m/secrets/mcables \ ssh mdock 'mkdir -p /home/m/stacks/cablegui/data /home/m/secrets/cablegui \
&& touch /home/m/secrets/mcables/.env \ && touch /home/m/secrets/cablegui/.env \
&& chmod 0600 /home/m/secrets/mcables/.env' && chmod 0600 /home/m/secrets/cablegui/.env'
scp docker-compose.yml mdock:/home/m/stacks/mcables/docker-compose.yml scp docker-compose.yml mdock:/home/m/stacks/cablegui/docker-compose.yml
``` ```
3. **Pull + start**: 3. **Pull + start**:
```sh ```sh
ssh mdock 'cd /home/m/stacks/mcables && docker compose pull && docker compose up -d' ssh mdock 'cd /home/m/stacks/cablegui && docker compose pull && docker compose up -d'
``` ```
4. **Verify** from any LAN host: 4. **Verify** from any LAN host:
@@ -119,11 +117,11 @@ no reverse proxy, LAN-trusted.
``` ```
To **update** to a new build: rebuild + push the image, then To **update** to a new build: rebuild + push the image, then
`ssh mdock 'cd /home/m/stacks/mcables && docker compose pull && docker compose up -d'`. `ssh mdock 'cd /home/m/stacks/cablegui && docker compose pull && docker compose up -d'`.
### Persistence ### Persistence
SQLite lives at `/home/m/stacks/mcables/data/mcables.db` on the host SQLite lives at `/home/m/stacks/cablegui/data/cablegui.db` on the host
(bind-mounted into the container at `/app/data`). Container runs as (bind-mounted into the container at `/app/data`). Container runs as
UID 1000:1000 to align with `m:m` ownership on mDock — DB files end UID 1000:1000 to align with `m:m` ownership on mDock — DB files end
up owned by `m`, the host user. up owned by `m`, the host user.

View File

@@ -11,14 +11,14 @@ import (
"syscall" "syscall"
"time" "time"
"mgit.msbls.de/m/mcables/internal/db" "mgit.msbls.de/m/cablegui/internal/db"
"mgit.msbls.de/m/mcables/internal/server" "mgit.msbls.de/m/cablegui/internal/server"
"mgit.msbls.de/m/mcables/web" "mgit.msbls.de/m/cablegui/web"
) )
func main() { func main() {
addr := envOr("MCABLES_ADDR", "0.0.0.0:7777") addr := envOr("CABLEGUI_ADDR", "0.0.0.0:7777")
dbPath := envOr("MCABLES_DB", "./data/mcables.db") dbPath := envOr("CABLEGUI_DB", "./data/cablegui.db")
if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil { if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil {
log.Fatalf("mkdir data dir: %v", err) log.Fatalf("mkdir data dir: %v", err)
@@ -41,7 +41,7 @@ func main() {
} }
go func() { go func() {
log.Printf("mcables listening on %s (db=%s)", addr, dbPath) log.Printf("cablegui listening on %s (db=%s)", addr, dbPath)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("listen: %v", err) log.Fatalf("listen: %v", err)
} }

View File

@@ -1,20 +1,20 @@
# mCables — production compose for mDock. # CableGUI — production compose for mDock.
# Lives at /home/m/stacks/mcables/docker-compose.yml on mDock. # Lives at /home/m/stacks/cablegui/docker-compose.yml on mDock.
# Matches the existing mDock service patterns (mgreen, mgeo, …). # Matches the existing mDock service patterns (mgreen, mgeo, …).
services: services:
mcables: cablegui:
image: mgit.msbls.de/m/mcables:latest image: mgit.msbls.de/m/cablegui:latest
container_name: mcables container_name: cablegui
restart: unless-stopped restart: unless-stopped
ports: ports:
- "7777:7777" - "7777:7777"
environment: environment:
- TZ=Europe/Berlin - TZ=Europe/Berlin
- MCABLES_ADDR=0.0.0.0:7777 - CABLEGUI_ADDR=0.0.0.0:7777
- MCABLES_DB=/app/data/mcables.db - CABLEGUI_DB=/app/data/cablegui.db
env_file: env_file:
# MEXDRAW_USER + MEXDRAW_PASS for the mxdrw HTTP Basic Auth on export. # MEXDRAW_USER + MEXDRAW_PASS for the mxdrw HTTP Basic Auth on export.
- /home/m/secrets/mcables/.env - /home/m/secrets/cablegui/.env
volumes: volumes:
- /home/m/stacks/mcables/data:/app/data - /home/m/stacks/cablegui/data:/app/data

View File

@@ -1,4 +1,4 @@
# mCables — Design v4.1 # CableGUI — Design v4.1
Cable-management **framework + solver** for m's setup. Inventor shift 1 Cable-management **framework + solver** for m's setup. Inventor shift 1
design, revised through v2 (rescope to multi-project framework), v3 design, revised through v2 (rescope to multi-project framework), v3
@@ -6,7 +6,7 @@ design, revised through v2 (rescope to multi-project framework), v3
**v4.1 — six locked answers from m's v4 review**. **v4.1 — six locked answers from m's v4 review**.
> **What changed in v4.1** (tight pass on v4) > **What changed in v4.1** (tight pass on v4)
> 1. **mCables is a schematic, not a physical-routing tool.** Cables are > 1. **CableGUI is a schematic, not a physical-routing tool.** Cables are
> straight lines between endpoints; the solver and the renderer do not > straight lines between endpoints; the solver and the renderer do not
> care about paths, trunks, frame edges, or cable-tray polylines. > care about paths, trunks, frame edges, or cable-tray polylines.
> "Maximum bundling" reduces to the v3 rule: **≥2 cables between the > "Maximum bundling" reduces to the v3 rule: **≥2 cables between the
@@ -33,13 +33,13 @@ design, revised through v2 (rescope to multi-project framework), v3
Sources: the live `Cable-Management.excalidraw` on mxdrw.msbls.de (used as Sources: the live `Cable-Management.excalidraw` on mxdrw.msbls.de (used as
the *visual-grammar reference*, not a bootstrap import target), the *visual-grammar reference*, not a bootstrap import target),
`mai-memory` (`mcables`, `m`), and the live mDock services for deploy `mai-memory` (`cablegui`, `m`), and the live mDock services for deploy
conventions (§10). v4 driven by m's product-vision clarification: conventions (§10). v4 driven by m's product-vision clarification:
> "we provide a cable manager — I say what devices we have, the app tells > "we provide a cable manager — I say what devices we have, the app tells
> me how to bundle cables and how the most efficient connection looks like" > me how to bundle cables and how the most efficient connection looks like"
mCables shifts from a manual draw-and-click editor to a **solver** that CableGUI shifts from a manual draw-and-click editor to a **solver** that
takes a list of devices + the connections m needs and emits the cable takes a list of devices + the connections m needs and emits the cable
plan + bundle recommendations. The manual editor stays (it's the only way plan + bundle recommendations. The manual editor stays (it's the only way
to inspect + tweak the plan) but is no longer the primary surface. to inspect + tweak the plan) but is no longer the primary surface.
@@ -60,7 +60,7 @@ to inspect + tweak the plan) but is no longer the primary surface.
> without applying; default applies. > without applying; default applies.
> - **Solver objective: maximum bundling** (§5b.1). Schematic only: when > - **Solver objective: maximum bundling** (§5b.1). Schematic only: when
> two or more cables share the same endpoint pair, group them into one > two or more cables share the same endpoint pair, group them into one
> bundle. No path or trunk geometry — mCables is a wiring schematic, > bundle. No path or trunk geometry — CableGUI is a wiring schematic,
> not a routing tool. v4.1 strips all path/trunk language from the v4 > not a routing tool. v4.1 strips all path/trunk language from the v4
> draft. > draft.
> - **UI: device-type dropdown** on device-create, **Connection > - **UI: device-type dropdown** on device-create, **Connection
@@ -72,7 +72,7 @@ to inspect + tweak the plan) but is no longer the primary surface.
> bundle-rendering polish. > bundle-rendering polish.
> >
> **What carried over from v3 (unchanged in v4)** > **What carried over from v3 (unchanged in v4)**
> - mCables is a framework: top-level `projects`, each backed by one > - CableGUI is a framework: top-level `projects`, each backed by one
> `.excalidraw` drawing. `UNIQUE(projects.name)`. > `.excalidraw` drawing. `UNIQUE(projects.name)`.
> - `cable_types` is global. Migration 001 seeds Power/USB/HDMI/DP/RJ45. > - `cable_types` is global. Migration 001 seeds Power/USB/HDMI/DP/RJ45.
> - `devices` UNIQUE(project_id, name); `frame_id` nullable; FrameRef > - `devices` UNIQUE(project_id, name); `frame_id` nullable; FrameRef
@@ -81,8 +81,8 @@ to inspect + tweak the plan) but is no longer the primary surface.
> - `projects.drawing_name` auto-defaults to `<name>.excalidraw`. > - `projects.drawing_name` auto-defaults to `<name>.excalidraw`.
> - `DELETE /api/projects/:pid?confirm=<name>` guardrail. > - `DELETE /api/projects/:pid?confirm=<name>` guardrail.
> - No cable inventory metadata; visual + connectivity structure only. > - No cable inventory metadata; visual + connectivity structure only.
> - DB at `./data/mcables.db` (gitignored). Bind `0.0.0.0:7777` LAN, no auth. > - DB at `./data/cablegui.db` (gitignored). Bind `0.0.0.0:7777` LAN, no auth.
> - Deploy on mDock under `/home/m/stacks/mcables/`, raw docker-compose. > - Deploy on mDock under `/home/m/stacks/cablegui/`, raw docker-compose.
> >
> **What's superseded in v4** > **What's superseded in v4**
> - The "manual draw-a-cable port-to-port" flow from v3 §7 is *kept* as a > - The "manual draw-a-cable port-to-port" flow from v3 §7 is *kept* as a
@@ -97,7 +97,7 @@ to inspect + tweak the plan) but is no longer the primary surface.
`Cable-Management.excalidraw` on mxdrw.msbls.de is **not** ingested at `Cable-Management.excalidraw` on mxdrw.msbls.de is **not** ingested at
runtime. It is the visual-grammar reference we lock the export onto so that runtime. It is the visual-grammar reference we lock the export onto so that
when m rebuilds LOFT and OFFICE inside mCables, the exported `.excalidraw` when m rebuilds LOFT and OFFICE inside CableGUI, the exported `.excalidraw`
looks like the seed. looks like the seed.
Concrete numbers from the live file (180 elements): Concrete numbers from the live file (180 elements):
@@ -128,7 +128,7 @@ Three observations about the seed's visual grammar — these constrain the
1. **Ports sit on a device edge as small ellipses (~12×9)**, coloured by 1. **Ports sit on a device edge as small ellipses (~12×9)**, coloured by
cable type. They are not children of the device in the Excalidraw sense cable type. They are not children of the device in the Excalidraw sense
(no `containerId`/`boundElements` link) — purely positional. When we (no `containerId`/`boundElements` link) — purely positional. When we
export from mCables we mimic that: port ellipse at `(device.x + export from CableGUI we mimic that: port ellipse at `(device.x +
port.x_offset, device.y + port.y_offset)`, stroke colour = type colour. port.x_offset, device.y + port.y_offset)`, stroke colour = type colour.
2. **Cable arrows bind to elements**. In the seed: 44 endpoints to ellipses 2. **Cable arrows bind to elements**. In the seed: 44 endpoints to ellipses
(ports), 12 to whole rectangles (device-level, no specific port), 3 to (ports), 12 to whole rectangles (device-level, no specific port), 3 to
@@ -161,7 +161,7 @@ painful: switch to Preact-via-CDN-ESM (still no build step). Not v0.
## 2. SQLite schema ## 2. SQLite schema
`./data/mcables.db` (project-local, gitignored). WAL mode, FKs on. `./data/cablegui.db` (project-local, gitignored). WAL mode, FKs on.
Driver: **`modernc.org/sqlite`** (cgo-free — clean cross-compile, simple Driver: **`modernc.org/sqlite`** (cgo-free — clean cross-compile, simple
Dockerfile). Dockerfile).
@@ -609,8 +609,8 @@ cascade does **not** touch `cable_types` (no FK to projects).
## 3. Go HTTP API ## 3. Go HTTP API
Single binary `cmd/mcables`, `net/http`, no router framework. Listens on Single binary `cmd/cablegui`, `net/http`, no router framework. Listens on
`0.0.0.0:7777` by default (overridable via `MCABLES_ADDR`). Static frontend `0.0.0.0:7777` by default (overridable via `CABLEGUI_ADDR`). Static frontend
from `embed.FS` at `/`, JSON API under `/api/`. from `embed.FS` at `/`, JSON API under `/api/`.
``` ```
@@ -780,7 +780,7 @@ generated scene JSON.
## 4. Export — DB → Excalidraw (visual-grammar conformance) ## 4. Export — DB → Excalidraw (visual-grammar conformance)
mCables generates a `.excalidraw` scene from a project's rows. The seed CableGUI generates a `.excalidraw` scene from a project's rows. The seed
drawing's grammar is the contract. drawing's grammar is the contract.
### 4.1 Element mapping ### 4.1 Element mapping
@@ -798,7 +798,7 @@ drawing's grammar is the contract.
### 4.2 Element IDs are stable across exports ### 4.2 Element IDs are stable across exports
Every mCables row carries `excalidraw_id` (TEXT, generated on first export Every CableGUI row carries `excalidraw_id` (TEXT, generated on first export
via `crypto/rand` → 21-char Excalidraw-style ID). On re-export the same row via `crypto/rand` → 21-char Excalidraw-style ID). On re-export the same row
reuses the same ID. This means: reuses the same ID. This means:
@@ -866,7 +866,7 @@ left strictly alone — the solver only adds and removes its own.
### 5b.1 Objective: maximum bundling — schematic only ### 5b.1 Objective: maximum bundling — schematic only
mCables is a **schematic**, not a physical-routing tool. Cables are CableGUI is a **schematic**, not a physical-routing tool. Cables are
straight lines between endpoints; the solver has no model of walls, straight lines between endpoints; the solver has no model of walls,
floors, cable trays, or path geometry. "Maximum bundling" therefore floors, cable trays, or path geometry. "Maximum bundling" therefore
reduces to a single rule on the schematic: reduces to a single rule on the schematic:
@@ -988,7 +988,7 @@ triggers a debounced re-solve) is parked at slice 9+ as an opt-in.
``` ```
┌─────────────────────┐ ┌─────────────────────┐
mCables DB (truth) │ │ CableGUI DB (truth) │
└──────────┬──────────┘ └──────────┬──────────┘
export ▼ export ▼
@@ -998,11 +998,11 @@ triggers a debounced re-solve) is parked at slice 9+ as an opt-in.
└────────────────────────┘ └────────────────────────┘
``` ```
- mCables UI → DB: synchronous (every drag/add/remove persists immediately). - CableGUI UI → DB: synchronous (every drag/add/remove persists immediately).
- DB → Excalidraw: **manual** button "Export to Excalidraw" in the header, - DB → Excalidraw: **manual** button "Export to Excalidraw" in the header,
per project. Calls `POST /api/projects/:pid/sync/export`. per project. Calls `POST /api/projects/:pid/sync/export`.
- Excalidraw → DB: **not implemented** in v0. Anything m draws in - Excalidraw → DB: **not implemented** in v0. Anything m draws in
Excalidraw stays in Excalidraw until he redraws it in mCables. Excalidraw stays in Excalidraw until he redraws it in CableGUI.
This keeps the v0 scope tight: no conflict resolution, no element-diff This keeps the v0 scope tight: no conflict resolution, no element-diff
import, no auto-debounce. mExDraw keeps its own version history (git import, no auto-debounce. mExDraw keeps its own version history (git
@@ -1012,7 +1012,7 @@ When mxdrw is unreachable: the export button shows a tooltip and disables;
the editor keeps working against the local DB. the editor keeps working against the local DB.
Post-MVP, import returns as a one-shot migration tool (separate Post-MVP, import returns as a one-shot migration tool (separate
`mcables-migrate` CLI tool, not part of the running server) for seeding `cablegui-migrate` CLI tool, not part of the running server) for seeding
new projects from existing `.excalidraw` files. new projects from existing `.excalidraw` files.
--- ---
@@ -1023,7 +1023,7 @@ The editor lives at `/`. Layout:
``` ```
┌────────────────────────────────────────────────────────────────────┐ ┌────────────────────────────────────────────────────────────────────┐
mCables [LOFT ▾ projects-picker] [Export] [+ Project] │ ← header │ CableGUI [LOFT ▾ projects-picker] [Export] [+ Project] │ ← header
├────────┬───────────────────────────────────────────────────────────┤ ├────────┬───────────────────────────────────────────────────────────┤
│ │ │ │ │ │
│ Legend │ │ │ Legend │ │
@@ -1254,7 +1254,7 @@ Slices 9+ (not promised for the first coder shift):
- Cable inventory metadata (length/SKU) if m later wants it. - Cable inventory metadata (length/SKU) if m later wants it.
- Dark mode. - Dark mode.
Out of scope, period (would change mCables's mental model): path Out of scope, period (would change CableGUI's mental model): path
routing, cable-tray polylines, frame-edge corridors, wall-axis bundling, routing, cable-tray polylines, frame-edge corridors, wall-axis bundling,
3D, anything that treats a cable as more than a labelled endpoint pair. 3D, anything that treats a cable as more than a labelled endpoint pair.
@@ -1264,7 +1264,7 @@ routing, cable-tray polylines, frame-edge corridors, wall-axis bundling,
The six v4 questions are now answered. Locked answers: The six v4 questions are now answered. Locked answers:
1. **Where do paths come from?** → **Nowhere — mCables is a schematic.** 1. **Where do paths come from?** → **Nowhere — CableGUI is a schematic.**
Cables are straight lines between endpoints. The solver does not Cables are straight lines between endpoints. The solver does not
route, the renderer does not route, and "maximum bundling" reduces to route, the renderer does not route, and "maximum bundling" reduces to
the endpoint-pair rule (§5b.1). Anything resembling a path, trunk, the endpoint-pair rule (§5b.1). Anything resembling a path, trunk,
@@ -1307,25 +1307,25 @@ before writing this:
- Host port mappings: deliberately collision-free across the host. Existing - Host port mappings: deliberately collision-free across the host. Existing
high ports in use include 3300 (mgreen), 3077 (paperless-ai), 7878 high ports in use include 3300 (mgreen), 3077 (paperless-ai), 7878
(radarr), 8082 (mgeo-tileserver), 8989 (sonarr), 9696 (prowlarr). (radarr), 8082 (mgeo-tileserver), 8989 (sonarr), 9696 (prowlarr).
**Port 7777 is free** — taking it for mCables. **Port 7777 is free** — taking it for CableGUI.
- Bind-mount volumes: `/home/m/<project>-data:/app/data` is the canonical - Bind-mount volumes: `/home/m/<project>-data:/app/data` is the canonical
pattern (mgreen). For project-local data we put `data/` *next to* the pattern (mgreen). For project-local data we put `data/` *next to* the
compose file so a `git pull && docker compose up -d` is the whole deploy: compose file so a `git pull && docker compose up -d` is the whole deploy:
`/home/m/stacks/mcables/data:/app/data`. `/home/m/stacks/cablegui/data:/app/data`.
- Secrets via `env_file: /home/m/secrets/<project>/.env` (msports-garmin - Secrets via `env_file: /home/m/secrets/<project>/.env` (msports-garmin
pattern). mCables only needs `MEXDRAW_TOKEN` for export. pattern). CableGUI only needs `MEXDRAW_TOKEN` for export.
- No reverse proxy on mDock. Services expose ports directly on the LAN - No reverse proxy on mDock. Services expose ports directly on the LAN
(mDock = `192.168.178.131` / Tailscale `mdock`). Public exposure goes via (mDock = `192.168.178.131` / Tailscale `mdock`). Public exposure goes via
mlake/Dokploy + Caddy when needed — out of scope for mCables (LAN-only). mlake/Dokploy + Caddy when needed — out of scope for CableGUI (LAN-only).
- Auto-deploy via the Gitea Actions self-hosted runner already installed - Auto-deploy via the Gitea Actions self-hosted runner already installed
on mDock (`/home/m/act-runner/`, label `self-hosted:host`). Push to on mDock (`/home/m/act-runner/`, label `self-hosted:host`). Push to
`main` → workflow on mDock → `docker compose up --build -d`. `main` → workflow on mDock → `docker compose up --build -d`.
### Repo layout for mCables ### Repo layout for CableGUI
``` ```
mCables/ CableGUI/
├── cmd/mcables/main.go # Go binary ├── cmd/cablegui/main.go # Go binary
├── internal/ ├── internal/
│ ├── db/ # migrations + store │ ├── db/ # migrations + store
│ ├── importer/ # post-MVP only (not in MVP) │ ├── importer/ # post-MVP only (not in MVP)
@@ -1336,7 +1336,7 @@ mCables/
│ ├── main.js # ES module entry │ ├── main.js # ES module entry
│ ├── style.css │ ├── style.css
│ └── lib/... # SVG helpers, store, components │ └── lib/... # SVG helpers, store, components
├── data/ # mCables runtime DB lives here (gitignored) ├── data/ # CableGUI runtime DB lives here (gitignored)
│ └── .gitkeep │ └── .gitkeep
├── docs/design.md # this file ├── docs/design.md # this file
├── Dockerfile ├── Dockerfile
@@ -1361,37 +1361,37 @@ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" \ RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" \
-o /out/mcables ./cmd/mcables -o /out/cablegui ./cmd/cablegui
FROM gcr.io/distroless/static-debian12:nonroot FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /app WORKDIR /app
COPY --from=build /out/mcables /app/mcables COPY --from=build /out/cablegui /app/cablegui
ENV MCABLES_ADDR=0.0.0.0:7777 ENV CABLEGUI_ADDR=0.0.0.0:7777
ENV MCABLES_DB=/app/data/mcables.db ENV CABLEGUI_DB=/app/data/cablegui.db
USER nonroot:nonroot USER nonroot:nonroot
EXPOSE 7777 EXPOSE 7777
ENTRYPOINT ["/app/mcables"] ENTRYPOINT ["/app/cablegui"]
``` ```
### docker-compose.yml (on mDock at `/home/m/stacks/mcables/`) ### docker-compose.yml (on mDock at `/home/m/stacks/cablegui/`)
```yaml ```yaml
services: services:
mcables: cablegui:
image: mgit.msbls.de/m/mcables:latest image: mgit.msbls.de/m/cablegui:latest
container_name: mcables container_name: cablegui
restart: unless-stopped restart: unless-stopped
ports: ports:
- "7777:7777" - "7777:7777"
environment: environment:
- TZ=Europe/Berlin - TZ=Europe/Berlin
- MCABLES_ADDR=0.0.0.0:7777 - CABLEGUI_ADDR=0.0.0.0:7777
- MCABLES_DB=/app/data/mcables.db - CABLEGUI_DB=/app/data/cablegui.db
- MEXDRAW_BASE_URL=https://mxdrw.msbls.de - MEXDRAW_BASE_URL=https://mxdrw.msbls.de
env_file: env_file:
- /home/m/secrets/mcables/.env # contains MEXDRAW_TOKEN - /home/m/secrets/cablegui/.env # contains MEXDRAW_TOKEN
volumes: volumes:
- /home/m/stacks/mcables/data:/app/data - /home/m/stacks/cablegui/data:/app/data
``` ```
LAN URL: `http://mdock:7777` (or `http://192.168.178.131:7777`). LAN URL: `http://mdock:7777` (or `http://192.168.178.131:7777`).
@@ -1412,15 +1412,15 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Build image - name: Build image
run: docker build -t mgit.msbls.de/m/mcables:latest . run: docker build -t mgit.msbls.de/m/cablegui:latest .
- name: Push image - name: Push image
run: | run: |
echo "${{ secrets.GITEA_TOKEN }}" | \ echo "${{ secrets.GITEA_TOKEN }}" | \
docker login mgit.msbls.de -u mAi --password-stdin docker login mgit.msbls.de -u mAi --password-stdin
docker push mgit.msbls.de/m/mcables:latest docker push mgit.msbls.de/m/cablegui:latest
- name: Up - name: Up
run: | run: |
cd /home/m/stacks/mcables cd /home/m/stacks/cablegui
docker compose pull docker compose pull
docker compose up -d docker compose up -d
``` ```
@@ -1428,7 +1428,7 @@ jobs:
### Local-development run (no Docker) ### Local-development run (no Docker)
``` ```
make run # go run ./cmd/mcables → :7777 against ./data/mcables.db make run # go run ./cmd/cablegui → :7777 against ./data/cablegui.db
make typecheck # tsc --noEmit on web/ make typecheck # tsc --noEmit on web/
make test # go test ./... make test # go test ./...
``` ```

2
go.mod
View File

@@ -1,4 +1,4 @@
module mgit.msbls.de/m/mcables module mgit.msbls.de/m/cablegui
go 1.25.5 go 1.25.5

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

@@ -1,4 +1,4 @@
// Package db owns SQLite access for mCables: migrations runner + the // Package db owns SQLite access for CableGUI: migrations runner + the
// query layer (store.go). The Store wraps a *sql.DB with helpers; tests // query layer (store.go). The Store wraps a *sql.DB with helpers; tests
// and the HTTP layer take a *Store, never a raw *sql.DB. // and the HTTP layer take a *Store, never a raw *sql.DB.
package db package db

View File

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

View File

@@ -1,4 +1,4 @@
-- mCables v3 initial schema. See docs/design.md §2. -- CableGUI v3 initial schema. See docs/design.md §2.
-- A project IS a drawing. LOFT and OFFICE are separate projects. -- A project IS a drawing. LOFT and OFFICE are separate projects.
-- One project ↔ one .excalidraw file in mExDraw. -- One project ↔ one .excalidraw file in mExDraw.

View File

@@ -1,4 +1,4 @@
-- mCables v4 device-type catalog. See docs/design.md §2.1 + §2.2. -- CableGUI v4 device-type catalog. See docs/design.md §2.1 + §2.2.
-- v4 — device-type catalog. Built-in types live globally (project_id NULL). -- v4 — device-type catalog. Built-in types live globally (project_id NULL).
-- Per-project custom types use project_id = X. -- Per-project custom types use project_id = X.

View File

@@ -1,4 +1,4 @@
-- mCables v4.1 connection requirements + solver-owned cable flag. -- CableGUI v4.1 connection requirements + solver-owned cable flag.
-- See docs/design.md §2.1 + §2 connection_requirements + §5b.3. -- See docs/design.md §2.1 + §2 connection_requirements + §5b.3.
-- The solver's input: "device A must connect to device B via cable type T". -- The solver's input: "device A must connect to device B via cable type T".

View File

@@ -1,4 +1,4 @@
-- mCables v4.1 setup templates. See docs/design.md §2.4. -- CableGUI v4.1 setup templates. See docs/design.md §2.4.
-- --
-- A template is a named recipe of (device_types + requirements) that -- A template is a named recipe of (device_types + requirements) that
-- bootstraps a project from blank to solver-ready in one apply call. -- bootstraps a project from blank to solver-ready in one apply call.

View File

@@ -1,4 +1,4 @@
-- mCables v5 — catalog: power-distribution devices. -- CableGUI v5 — catalog: power-distribution devices.
-- Adds 5 built-in device_types (project_id NULL, built_in=1). -- Adds 5 built-in device_types (project_id NULL, built_in=1).
-- --
-- Multi-plug N exposes Power × (N+1) ports — one input + N outputs. The -- Multi-plug N exposes Power × (N+1) ports — one input + N outputs. The

View File

@@ -1,4 +1,4 @@
-- mCables v6 — fix IOx-* and Multi-plug-* + Wifi-plug port profiles. -- CableGUI v6 — fix IOx-* and Multi-plug-* + Wifi-plug port profiles.
-- --
-- v4 seeded the IOx-3 / IOx-6 / IOx-8 as USB hubs (Power × 1 + USB × N), -- v4 seeded the IOx-3 / IOx-6 / IOx-8 as USB hubs (Power × 1 + USB × N),
-- but m's physical IOx-* devices are power strips (1 power input on -- but m's physical IOx-* devices are power strips (1 power input on

View File

@@ -0,0 +1,31 @@
-- CableGUI 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

@@ -220,4 +220,29 @@ type Snapshot struct {
Bundles []Bundle `json:"bundles"` Bundles []Bundle `json:"bundles"`
CableTypes []CableType `json:"cable_types"` CableTypes []CableType `json:"cable_types"`
ConnectionRequirements []ConnectionRequirement `json:"connection_requirements"` 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

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

View File

@@ -11,8 +11,9 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"math/big" "math/big"
"sort"
"mgit.msbls.de/m/mcables/internal/db" "mgit.msbls.de/m/cablegui/internal/db"
) )
// Scene is the top-level Excalidraw file format. Keys mirror what the // Scene is the top-level Excalidraw file format. Keys mirror what the
@@ -114,6 +115,7 @@ type IDAssignment struct {
Ports map[int64]string `json:"ports"` Ports map[int64]string `json:"ports"`
IOMarkers map[int64]string `json:"io_markers"` IOMarkers map[int64]string `json:"io_markers"`
Cables map[int64]string `json:"cables"` Cables map[int64]string `json:"cables"`
Clamps map[int64]string `json:"clamps"`
} }
// BuildScene transforms a project snapshot into an Excalidraw Scene + // 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{}, Ports: map[int64]string{},
IOMarkers: map[int64]string{}, IOMarkers: map[int64]string{},
Cables: map[int64]string{}, Cables: map[int64]string{},
Clamps: map[int64]string{},
} }
// idFor: reuse the existing excalidraw_id if present, else mint one. // idFor: reuse the existing excalidraw_id if present, else mint one.
idFor := func(existing *string) string { 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 / // Cables — arrows with startBinding/endBinding to the port / device /
// IO marker excalidraw_ids. Endpoint anchors (the visible "from" / // IO marker excalidraw_ids. Endpoint anchors (the visible "from" /
// "to" points) come from the same anchor logic the canvas uses. // "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 := "" startArr := ""
endArr := "arrow" 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{ els = append(els, Element{
ID: elID, ID: elID,
Type: "arrow", Type: "arrow",
@@ -422,7 +489,7 @@ func BuildScene(snap *db.Snapshot, nowMilli int64, genID func() string) (*Scene,
Version: 1, Version: 1,
VersionNonce: randInt(), VersionNonce: randInt(),
Updated: nowMilli, Updated: nowMilli,
Points: [][2]float64{{0, 0}, {toAnchor[0] - fromAnchor[0], toAnchor[1] - fromAnchor[1]}}, Points: pts,
StartArrowhead: &startArr, StartArrowhead: &startArr,
EndArrowhead: &endArr, EndArrowhead: &endArr,
StartBinding: bindingPtr(fromRef), StartBinding: bindingPtr(fromRef),
@@ -470,7 +537,7 @@ func BuildScene(snap *db.Snapshot, nowMilli int64, genID func() string) (*Scene,
scene := &Scene{ scene := &Scene{
Type: "excalidraw", Type: "excalidraw",
Version: 2, Version: 2,
Source: "mcables", Source: "cablegui",
Elements: els, Elements: els,
AppState: AppState{ AppState: AppState{
GridSize: nil, GridSize: nil,

View File

@@ -5,7 +5,7 @@ import (
"strings" "strings"
"testing" "testing"
"mgit.msbls.de/m/mcables/internal/db" "mgit.msbls.de/m/cablegui/internal/db"
) )
// deterministic id generator for tests // deterministic id generator for tests
@@ -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) { func TestMarshalScene_IsJSON(t *testing.T) {
snap := sampleSnapshot() snap := sampleSnapshot()
scene, _ := BuildScene(snap, 1700000000000, newSeq()) scene, _ := BuildScene(snap, 1700000000000, newSeq())

View File

@@ -5,7 +5,7 @@ import (
"errors" "errors"
"net/http" "net/http"
"mgit.msbls.de/m/mcables/internal/db" "mgit.msbls.de/m/cablegui/internal/db"
) )
type cableEndpointBody struct { type cableEndpointBody struct {

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/cablegui/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

@@ -5,7 +5,7 @@ import (
"errors" "errors"
"net/http" "net/http"
"mgit.msbls.de/m/mcables/internal/db" "mgit.msbls.de/m/cablegui/internal/db"
) )
type connReqCreate struct { type connReqCreate struct {

View File

@@ -5,7 +5,7 @@ import (
"errors" "errors"
"net/http" "net/http"
"mgit.msbls.de/m/mcables/internal/db" "mgit.msbls.de/m/cablegui/internal/db"
) )
type deviceTypePortBody struct { type deviceTypePortBody struct {

View File

@@ -12,8 +12,8 @@ import (
"strings" "strings"
"time" "time"
"mgit.msbls.de/m/mcables/internal/db" "mgit.msbls.de/m/cablegui/internal/db"
"mgit.msbls.de/m/mcables/internal/exporter" "mgit.msbls.de/m/cablegui/internal/exporter"
) )
// syncExport runs the project's snapshot through the exporter, persists // syncExport runs the project's snapshot through the exporter, persists
@@ -34,7 +34,7 @@ func (h *handlers) syncExport(w http.ResponseWriter, r *http.Request) {
if user == "" || pass == "" { if user == "" || pass == "" {
writeJSON(w, http.StatusBadRequest, errorBody{ writeJSON(w, http.StatusBadRequest, errorBody{
Error: "MEXDRAW_USER / MEXDRAW_PASS not set", Error: "MEXDRAW_USER / MEXDRAW_PASS not set",
Details: "Add MEXDRAW_USER and MEXDRAW_PASS to /home/m/secrets/mcables/.env on mDock and restart the container — mxdrw expects HTTP Basic Auth", Details: "Add MEXDRAW_USER and MEXDRAW_PASS to /home/m/secrets/cablegui/.env on mDock and restart the container — mxdrw expects HTTP Basic Auth",
}) })
return return
} }
@@ -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. // Persist the freshly-assigned ids so the next export reuses them.
// We pass in the full maps; PersistExcalidrawIDs is idempotent (it // We pass in the full maps; PersistExcalidrawIDs is idempotent (it
// only updates rows whose excalidraw_id is still NULL). // 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) writeError(w, fmt.Errorf("persist excalidraw_ids: %w", err), nil)
return return
} }

View File

@@ -5,7 +5,7 @@ import (
"errors" "errors"
"net/http" "net/http"
"mgit.msbls.de/m/mcables/internal/db" "mgit.msbls.de/m/cablegui/internal/db"
) )
// ---------------------------------------------------------------- frames // ---------------------------------------------------------------- frames

View File

@@ -6,7 +6,7 @@ import (
"net/http" "net/http"
"strconv" "strconv"
"mgit.msbls.de/m/mcables/internal/db" "mgit.msbls.de/m/cablegui/internal/db"
) )
type handlers struct { type handlers struct {

View File

@@ -5,7 +5,7 @@ import (
"errors" "errors"
"net/http" "net/http"
"mgit.msbls.de/m/mcables/internal/db" "mgit.msbls.de/m/cablegui/internal/db"
) )
type ioMarkerCreate struct { type ioMarkerCreate struct {

View File

@@ -5,7 +5,7 @@ import (
"errors" "errors"
"net/http" "net/http"
"mgit.msbls.de/m/mcables/internal/db" "mgit.msbls.de/m/cablegui/internal/db"
) )
type portCreate struct { type portCreate struct {

View File

@@ -7,10 +7,10 @@ import (
"io/fs" "io/fs"
"net/http" "net/http"
"mgit.msbls.de/m/mcables/internal/db" "mgit.msbls.de/m/cablegui/internal/db"
) )
// New returns an http.Handler serving the mCables API at /api/ and the // New returns an http.Handler serving the CableGUI API at /api/ and the
// embedded frontend at /. The frontend FS should be rooted such that // embedded frontend at /. The frontend FS should be rooted such that
// "index.html" is at its root. // "index.html" is at its root.
func New(store *db.Store, frontend fs.FS) http.Handler { func New(store *db.Store, frontend fs.FS) http.Handler {
@@ -93,6 +93,15 @@ func New(store *db.Store, frontend fs.FS) http.Handler {
// Slice 8 — export to mxdrw.msbls.de // Slice 8 — export to mxdrw.msbls.de
mux.HandleFunc("POST /api/projects/{pid}/sync/export", h.syncExport) 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. // Frontend (embedded). Serve "/" → index.html via http.FileServerFS.
// Wrap in noCache so the browser revalidates with the ETag/Last-Modified // Wrap in noCache so the browser revalidates with the ETag/Last-Modified
// the file server already emits — without this, browsers cache aggressively // the file server already emits — without this, browsers cache aggressively

View File

@@ -5,7 +5,7 @@ import (
"errors" "errors"
"net/http" "net/http"
"mgit.msbls.de/m/mcables/internal/db" "mgit.msbls.de/m/cablegui/internal/db"
) )
func (h *handlers) solve(w http.ResponseWriter, r *http.Request) { func (h *handlers) solve(w http.ResponseWriter, r *http.Request) {

View File

@@ -3,12 +3,12 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>mCables</title> <title>CableGUI</title>
<link rel="stylesheet" href="/style.css" /> <link rel="stylesheet" href="/style.css" />
</head> </head>
<body> <body>
<header class="topbar"> <header class="topbar">
<span class="brand">mCables</span> <span class="brand">CableGUI</span>
<div class="project-picker"> <div class="project-picker">
<label for="project-select" class="sr-only">Project</label> <label for="project-select" class="sr-only">Project</label>
<select id="project-select" aria-label="Active project"> <select id="project-select" aria-label="Active project">
@@ -36,7 +36,6 @@
<section class="legend"> <section class="legend">
<h2 class="sidebar-heading">Cable types</h2> <h2 class="sidebar-heading">Cable types</h2>
<ul id="legend-list" class="legend-list"></ul> <ul id="legend-list" class="legend-list"></ul>
<button type="button" id="btn-add-type" class="btn btn-tiny">+ Type</button>
</section> </section>
<section class="tools"> <section class="tools">
<h2 class="sidebar-heading">Tools</h2> <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-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-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-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-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> <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> </ul>
@@ -52,10 +52,13 @@
<section class="canvas-wrap" aria-label="Diagram"> <section class="canvas-wrap" aria-label="Diagram">
<svg id="canvas" viewBox="0 0 2000 1500" preserveAspectRatio="xMidYMid meet"> <svg id="canvas" viewBox="0 0 2000 1500" preserveAspectRatio="xMidYMid meet">
<defs id="canvas-defs"></defs>
<g id="canvas-frames"></g> <g id="canvas-frames"></g>
<g id="canvas-devices"></g> <g id="canvas-devices"></g>
<g id="canvas-ports"></g> <g id="canvas-ports"></g>
<g id="canvas-cables"></g> <g id="canvas-cables"></g>
<g id="canvas-bundles"></g>
<g id="canvas-clamps"></g>
<g id="canvas-io"></g> <g id="canvas-io"></g>
</svg> </svg>
<p id="empty-hint" class="empty-hint"> <p id="empty-hint" class="empty-hint">

View File

@@ -1,4 +1,4 @@
// mCables frontend entry — vanilla ES module, no build step. // CableGUI frontend entry — vanilla ES module, no build step.
// //
// Slice 2 adds: frame + device rendering, +Frm/+Dev tools, drag-to-position, // Slice 2 adds: frame + device rendering, +Frm/+Dev tools, drag-to-position,
// inline naming, inspector for selection. State stays minimal: one // inline naming, inspector for selection. State stays minimal: one
@@ -41,6 +41,7 @@
const API = "/api"; const API = "/api";
const SVG_NS = "http://www.w3.org/2000/svg"; const SVG_NS = "http://www.w3.org/2000/svg";
const IO_SIZE = 30; // diamond bounding-box side (the rotated rect's width/height) const IO_SIZE = 30; // diamond bounding-box side (the rotated rect's width/height)
const CLAMP_SIZE = 12; // small rounded square for routing clamps (v5 §11)
const state = { const state = {
/** @type {Project[]} */ projects: [], /** @type {Project[]} */ projects: [],
@@ -55,8 +56,11 @@ const state = {
/** @type {Cable[]} */ cables: [], /** @type {Cable[]} */ cables: [],
/** @type {Bundle[]} */ bundles: [], /** @type {Bundle[]} */ bundles: [],
/** @type {SetupTemplate[]} */ setupTemplates: [], /** @type {SetupTemplate[]} */ setupTemplates: [],
/** v5 — routing anchors. */
/** @type {Clamp[]} */ clamps: [],
/** @type {CableClamp[]} */ cableClamps: [],
activeTypeId: /** @type {number|null} */ (null), activeTypeId: /** @type {number|null} */ (null),
/** "frame" | "device" | "io" | "req" | "cable" | null */ /** "frame" | "device" | "io" | "req" | "cable" | "clamp" | null */
tool: /** @type {string|null} */ (null), tool: /** @type {string|null} */ (null),
/** Canvas viewport — drives the SVG viewBox. */ /** Canvas viewport — drives the SVG viewBox. */
view: { x: 0, y: 0, zoom: 1 }, view: { x: 0, y: 0, zoom: 1 },
@@ -64,7 +68,7 @@ const state = {
spaceHeld: false, spaceHeld: false,
/** Slice-7: when the user clicked a source port, this is its id. */ /** Slice-7: when the user clicked a source port, this is its id. */
cableDrawFromPortID: /** @type {number|null} */ (null), cableDrawFromPortID: /** @type {number|null} */ (null),
/** @type {({kind: "frame"|"device"|"io"|"cable_type"|"requirement"|"cable"|"bundle"|"port", id: number} | {kind: "port_new", device_id: number}) | null} */ selection: null, /** @type {({kind: "frame"|"device"|"io"|"cable_type"|"requirement"|"cable"|"bundle"|"port"|"clamp", id: number} | {kind: "port_new", device_id: number}) | null} */ selection: null,
}; };
// ---------- API client ---------- // // ---------- API client ---------- //
@@ -135,6 +139,15 @@ const listSetupTemplates = () => api("GET", `/setup-templates`);
const applyTemplate = (pid, body) => api("POST", `/projects/${pid}/apply-template`, body); const applyTemplate = (pid, body) => api("POST", `/projects/${pid}/apply-template`, body);
const syncExport = (pid) => api("POST", `/projects/${pid}/sync/export`, {}); const syncExport = (pid) => api("POST", `/projects/${pid}/sync/export`, {});
// v5 — clamps + cable_clamps.
const listClamps = (pid) => api("GET", `/projects/${pid}/clamps`);
const createClamp = (pid, body) => api("POST", `/projects/${pid}/clamps`, body);
const patchClamp = (pid, id, body) => api("PATCH", `/projects/${pid}/clamps/${id}`, body);
const deleteClamp = (pid, id) => api("DELETE", `/projects/${pid}/clamps/${id}`);
const attachClampToCable = (pid, cid, body) => api("POST", `/projects/${pid}/cables/${cid}/clamps`, body);
const detachClampFromCable = (pid, cid, cmid) => api("DELETE", `/projects/${pid}/cables/${cid}/clamps/${cmid}`);
const reorderCableClamps = (pid, cid, body) => api("PUT", `/projects/${pid}/cables/${cid}/clamps`, body);
// ---------- DOM helpers ---------- // // ---------- DOM helpers ---------- //
const $ = (sel) => /** @type {HTMLElement} */ (document.querySelector(sel)); const $ = (sel) => /** @type {HTMLElement} */ (document.querySelector(sel));
@@ -258,6 +271,52 @@ function startPan(e) {
svg.addEventListener("pointercancel", onUp); svg.addEventListener("pointercancel", onUp);
} }
// Left-click on empty canvas: ambiguous between "deselect" and "pan".
// We resolve by movement — under the drag threshold m gets the historic
// "click empties the selection" behaviour; past the threshold the gesture
// promotes to a pan (Excalidraw / Figma standard). 3px screen-space dead
// zone is enough that a steady click doesn't accidentally nudge the view.
const EMPTY_CANVAS_PAN_THRESHOLD_PX = 3;
function startEmptyCanvasGesture(e) {
const svg = /** @type {SVGSVGElement} */ ($("#canvas"));
const ctm = svg.getScreenCTM();
if (!ctm) return;
const scaleX = ctm.a, scaleY = ctm.d;
const startClientX = e.clientX, startClientY = e.clientY;
const startViewX = state.view.x, startViewY = state.view.y;
let panning = false;
try { svg.setPointerCapture(e.pointerId); } catch {}
const onMove = (ev) => {
const dx = ev.clientX - startClientX;
const dy = ev.clientY - startClientY;
if (!panning) {
if (Math.hypot(dx, dy) <= EMPTY_CANVAS_PAN_THRESHOLD_PX) return;
panning = true;
$(".canvas-wrap").classList.add("panning");
}
state.view.x = startViewX - dx / scaleX;
state.view.y = startViewY - dy / scaleY;
applyViewBox();
};
const onUp = (ev) => {
svg.removeEventListener("pointermove", onMove);
svg.removeEventListener("pointerup", onUp);
svg.removeEventListener("pointercancel", onUp);
try { svg.releasePointerCapture(ev.pointerId); } catch {}
if (panning) {
$(".canvas-wrap").classList.remove("panning");
setViewInURL();
} else if (state.selection) {
state.selection = null;
render();
}
};
svg.addEventListener("pointermove", onMove);
svg.addEventListener("pointerup", onUp);
svg.addEventListener("pointercancel", onUp);
}
function resetView() { function resetView() {
state.view.zoom = 1; state.view.zoom = 1;
state.view.x = 0; state.view.x = 0;
@@ -393,11 +452,17 @@ function renderCanvas() {
const gFrames = $("#canvas-frames"); const gFrames = $("#canvas-frames");
const gDevices = $("#canvas-devices"); const gDevices = $("#canvas-devices");
const gCables = $("#canvas-cables"); const gCables = $("#canvas-cables");
const gBundles = $("#canvas-bundles");
const gClamps = $("#canvas-clamps");
const gIO = $("#canvas-io"); const gIO = $("#canvas-io");
const gDefs = $("#canvas-defs");
gFrames.innerHTML = ""; gFrames.innerHTML = "";
gDevices.innerHTML = ""; gDevices.innerHTML = "";
gCables.innerHTML = ""; gCables.innerHTML = "";
gBundles.innerHTML = "";
gClamps.innerHTML = "";
gIO.innerHTML = ""; gIO.innerHTML = "";
gDefs.innerHTML = "";
for (const f of state.frames) { for (const f of state.frames) {
const g = svgEl("g", { "data-frame-id": f.id }); const g = svgEl("g", { "data-frame-id": f.id });
@@ -415,8 +480,24 @@ function renderCanvas() {
}); });
label.textContent = f.name; label.textContent = f.name;
g.append(rect, label); g.append(rect, label);
// Bottom-right resize handle. Mirrors the device pattern — sits on
// top of the rect so its pointerdown wins, with stopPropagation in
// startFrameResize blocking the rect's startDrag underneath.
const FHSZ = 10;
const fHandle = svgEl("rect", {
x: f.x + f.width - FHSZ,
y: f.y + f.height - FHSZ,
width: FHSZ, height: FHSZ,
class: "frame-resize-handle",
"data-frame-id": f.id,
});
fHandle.addEventListener("pointerdown", (e) => startFrameResize(e, f.id));
g.append(fHandle);
gFrames.append(g); gFrames.append(g);
rect.addEventListener("pointerdown", (e) => startDrag(e, "frame", f.id)); rect.addEventListener("pointerdown", (e) => startDrag(e, "frame", f.id));
label.addEventListener("pointerdown", (e) => startDrag(e, "frame", f.id));
} }
const portsByDevice = new Map(); const portsByDevice = new Map();
@@ -539,30 +620,103 @@ function renderCanvas() {
}); });
} }
// Cables — straight lines between resolved endpoint anchors. // Clamps — small grey rounded squares (per design v5 §11.9 q1).
// Auto-cables render with dashed stroke so m sees which the solver // Slice 4 wires them into cable polylines; for slice 3 they just
// placed; manual cables are solid. // render + drag + select. Slice 5 adds a ×N count badge for clamps
// with ≥2 cables through them.
const cablesPerClamp = new Map();
for (const cc of state.cableClamps) {
cablesPerClamp.set(cc.clamp_id, (cablesPerClamp.get(cc.clamp_id) || 0) + 1);
}
for (const cl of state.clamps) {
const g = svgEl("g", { "data-clamp-id": cl.id });
const sz = CLAMP_SIZE;
const rect = svgEl("rect", {
x: cl.x - sz / 2, y: cl.y - sz / 2, width: sz, height: sz,
rx: 2, ry: 2,
class: "clamp" + (state.selection?.kind === "clamp" && state.selection.id === cl.id ? " selected" : "") + " svg-draggable",
});
g.append(rect);
const n = cablesPerClamp.get(cl.id) || 0;
if (n >= 2) {
const badge = svgEl("text", {
x: cl.x + sz / 2 + 2, y: cl.y - sz / 2 - 1,
class: "clamp-badge",
});
badge.textContent = `×${n}`;
g.append(badge);
}
if (cl.label) {
const label = svgEl("text", {
x: cl.x + sz / 2 + 4, y: cl.y + 3,
class: "clamp-label",
});
label.textContent = cl.label;
g.append(label);
}
gClamps.append(g);
rect.addEventListener("pointerdown", (e) => startDrag(e, "clamp", cl.id));
}
// Cables — polyline through endpoint(from) → clamps in ord sequence
// → endpoint(to). With zero clamps this collapses to a v0 straight
// line. Auto-cables render dashed; manual solid.
const portByID = new Map(state.ports.map((p) => [p.id, p])); const portByID = new Map(state.ports.map((p) => [p.id, p]));
const deviceByID = new Map(state.devices.map((d) => [d.id, d])); const deviceByID = new Map(state.devices.map((d) => [d.id, d]));
const ioByID = new Map(state.ioMarkers.map((m) => [m.id, m])); const ioByID = new Map(state.ioMarkers.map((m) => [m.id, m]));
const clampByID = new Map(state.clamps.map((cl) => [cl.id, cl]));
// Pre-group cable_clamps by cable, sorted by ord.
const clampsByCable = new Map();
for (const cc of state.cableClamps) {
let arr = clampsByCable.get(cc.cable_id);
if (!arr) { arr = []; clampsByCable.set(cc.cable_id, arr); }
arr.push(cc);
}
for (const arr of clampsByCable.values()) arr.sort((a, b) => a.ord - b.ord);
// sharedSegments: segmentKey → { a, b, cables:[Cable] }. Built up
// during the per-cable loop, then walked in a second pass for the
// bundle overlay layer (v5 §11.3).
const sharedSegments = new Map();
for (const c of state.cables) { for (const c of state.cables) {
let fromAnchor = anchorForEndpoint(c.from_port_id, c.from_device_id, c.from_io_id, portByID, deviceByID, ioByID); const built = cableVerticesWithKeys(c, portByID, deviceByID, ioByID, clampByID, clampsByCable);
let toAnchor = anchorForEndpoint(c.to_port_id, c.to_device_id, c.to_io_id, portByID, deviceByID, ioByID); if (!built) continue;
if (!fromAnchor || !toAnchor) continue; const { vertices, keys } = built;
// Bundle accumulator — record this cable on every segment of its
// resolved polyline keyed by an undirected pair of vertex IDs.
for (let i = 0; i < keys.length - 1; i++) {
const a = keys[i], b = keys[i + 1];
const segKey = a < b ? `${a}|${b}` : `${b}|${a}`;
let bucket = sharedSegments.get(segKey);
if (!bucket) {
bucket = { a: vertices[i], b: vertices[i + 1], cables: [] };
sharedSegments.set(segKey, bucket);
}
bucket.cables.push(c);
}
// Replug preview: while m drags an endpoint handle, override the // Replug preview: while m drags an endpoint handle, override the
// affected end with the live cursor world position so the line // affected end with the live cursor world position so the line
// tracks the pointer. // tracks the pointer. Mid-vertices (clamps) are unchanged.
if (cableReplug && cableReplug.cableID === c.id) { if (cableReplug && cableReplug.cableID === c.id) {
if (cableReplug.end === "from") fromAnchor = { x: cableReplug.x, y: cableReplug.y }; const idx = cableReplug.end === "from" ? 0 : vertices.length - 1;
else toAnchor = { x: cableReplug.x, y: cableReplug.y }; vertices[idx] = { x: cableReplug.x, y: cableReplug.y };
}
// Mid-segment drag preview: while m is bending a segment, insert
// a temp vertex at the cursor so the line tracks. On release this
// becomes a real clamp (or snaps to a nearby existing one).
if (cableMidDrag && cableMidDrag.cableID === c.id) {
const at = cableMidDrag.segmentIdx + 1;
vertices.splice(at, 0, { x: cableMidDrag.x, y: cableMidDrag.y });
} }
const isSelected = state.selection?.kind === "cable" && state.selection.id === c.id; const isSelected = state.selection?.kind === "cable" && state.selection.id === c.id;
const color = cableTypeColor.get(c.type_id) || "#888"; const color = cableTypeColor.get(c.type_id) || "#888";
const line = svgEl("line", { const pointsStr = vertices.map((v) => `${v.x},${v.y}`).join(" ");
x1: fromAnchor.x, y1: fromAnchor.y, const line = svgEl("polyline", {
x2: toAnchor.x, y2: toAnchor.y, points: pointsStr,
class: "cable-line" + (c.auto ? " auto" : "") + (isSelected ? " selected" : ""), class: "cable-line" + (c.auto ? " auto" : "") + (isSelected ? " selected" : ""),
stroke: color, stroke: color,
fill: "none",
"data-cable-id": c.id, "data-cable-id": c.id,
}); });
line.addEventListener("click", (e) => { line.addEventListener("click", (e) => {
@@ -570,12 +724,20 @@ function renderCanvas() {
state.selection = { kind: "cable", id: c.id }; state.selection = { kind: "cable", id: c.id };
render(); render();
}); });
line.addEventListener("pointerdown", (e) => {
// Selected cable + non-endpoint click → start a mid-segment drag
// that inserts (or snaps to) a clamp on release. Bypasses the
// canvas-level handler so panning / device drag don't fire.
if (isSelected && e.button === 0 && !state.spaceHeld) {
startCableMidDrag(e, c, vertices);
}
});
gCables.append(line); gCables.append(line);
// Endpoint handles — only on the currently-selected cable. Two small // Endpoint handles — first + last vertex when selected.
// filled circles m can grab to drag the endpoint onto a new target.
if (isSelected) { if (isSelected) {
for (const end of ["from", "to"]) { const first = vertices[0];
const a = end === "from" ? fromAnchor : toAnchor; const last = vertices[vertices.length - 1];
for (const [end, a] of [["from", first], ["to", last]]) {
const h = svgEl("circle", { const h = svgEl("circle", {
cx: a.x, cy: a.y, r: 7, cx: a.x, cy: a.y, r: 7,
class: "cable-handle", class: "cable-handle",
@@ -589,6 +751,97 @@ function renderCanvas() {
} }
} }
} }
// ---- bundle viz: shared segments + clamp count badges (v5 §11.3) ----
let gradSeq = 0;
for (const [segKey, bucket] of sharedSegments) {
if (bucket.cables.length < 2) continue;
// Distinct cable type IDs in this bundle, ordered by count desc
// (ties by id asc) per design v5 §11.9 q4.
const counts = new Map();
for (const c of bucket.cables) {
counts.set(c.type_id, (counts.get(c.type_id) || 0) + 1);
}
const distinctTypes = [...counts.entries()]
.sort((a, b) => b[1] - a[1] || a[0] - b[0])
.map(([id]) => id);
// Build a linearGradient perpendicular to the segment so the stripes
// run ACROSS the segment's thickness (visually: stripes parallel to
// the cable direction).
const { a, b } = bucket;
const dx = b.x - a.x, dy = b.y - a.y;
const len = Math.hypot(dx, dy) || 1;
// Perpendicular unit vector — gradient runs along this so the stops
// become bands along the segment's direction.
const px = -dy / len, py = dx / len;
const thickness = Math.min(12, 2 + bucket.cables.length);
const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2;
// Stops: hard-edged segments, one band per type.
const gradID = `bundle-grad-${gradSeq++}-${segKey.replace(/[^a-z0-9-]/gi, "_")}`;
const grad = svgEl("linearGradient", {
id: gradID,
gradientUnits: "userSpaceOnUse",
x1: mx + px * thickness / 2,
y1: my + py * thickness / 2,
x2: mx - px * thickness / 2,
y2: my - py * thickness / 2,
});
const n = distinctTypes.length;
for (let i = 0; i < n; i++) {
const color = cableTypeColor.get(distinctTypes[i]) || "#888";
const startStop = svgEl("stop", { offset: `${(i / n) * 100}%`, "stop-color": color });
const endStop = svgEl("stop", { offset: `${((i + 1) / n) * 100}%`, "stop-color": color });
grad.append(startStop, endStop);
}
gDefs.append(grad);
// Tooltip listing the bundled cable types.
const titleText = distinctTypes
.map((id) => cableTypeColor.has(id) ? state.cableTypes.find((t) => t.id === id)?.name ?? `#${id}` : `#${id}`)
.join(" · ") + ` (${bucket.cables.length} cables)`;
const overlay = svgEl("line", {
x1: a.x, y1: a.y, x2: b.x, y2: b.y,
class: "bundle-line",
stroke: `url(#${gradID})`,
"stroke-width": thickness,
});
const title = svgEl("title", {});
title.textContent = titleText;
overlay.append(title);
gBundles.append(overlay);
}
}
// Compute the resolved polyline vertices for a cable plus a stable
// vertex-key per vertex used to detect shared segments for bundle viz.
// Vertex keys:
// - port:<id> for a port-anchored endpoint
// - device:<id> for a device-anchored endpoint (no port)
// - io:<id> for an IO-anchored endpoint
// - clamp:<id> for a mid-vertex
// Returns null if either endpoint can't be resolved.
function cableVerticesWithKeys(c, portByID, deviceByID, ioByID, clampByID, clampsByCable) {
const fromAnchor = anchorForEndpoint(c.from_port_id, c.from_device_id, c.from_io_id, portByID, deviceByID, ioByID);
const toAnchor = anchorForEndpoint(c.to_port_id, c.to_device_id, c.to_io_id, portByID, deviceByID, ioByID);
if (!fromAnchor || !toAnchor) return null;
function endpointKey(portID, deviceID, ioID) {
if (portID != null) return `port:${portID}`;
if (deviceID != null) return `device:${deviceID}`;
return `io:${ioID}`;
}
const vertices = [fromAnchor];
const keys = [endpointKey(c.from_port_id, c.from_device_id, c.from_io_id)];
const clamps = clampsByCable.get(c.id) || [];
for (const cc of clamps) {
const cl = clampByID.get(cc.clamp_id);
if (cl) {
vertices.push({ x: cl.x, y: cl.y });
keys.push(`clamp:${cl.id}`);
}
}
vertices.push(toAnchor);
keys.push(endpointKey(c.to_port_id, c.to_device_id, c.to_io_id));
return { vertices, keys };
} }
/** Resolve a cable endpoint to {x, y} on the canvas. Returns null when /** Resolve a cable endpoint to {x, y} on the canvas. Returns null when
@@ -629,6 +882,7 @@ function renderInspector() {
case "cable": return renderInspectorCable(body, state.selection.id); case "cable": return renderInspectorCable(body, state.selection.id);
case "port": return renderInspectorPort(body, state.selection.id); case "port": return renderInspectorPort(body, state.selection.id);
case "port_new": return renderInspectorPortNew(body, state.selection.device_id); case "port_new": return renderInspectorPortNew(body, state.selection.device_id);
case "clamp": return renderInspectorClamp(body, state.selection.id);
default: body.innerHTML = `<p class="muted">Nothing selected.</p>`; default: body.innerHTML = `<p class="muted">Nothing selected.</p>`;
} }
} }
@@ -1217,6 +1471,112 @@ function renderInspectorIO(body, id) {
}); });
} }
// Clamp inspector — label + position + cables-through list + delete.
// Slice 4 wires the cables-through list to actual data; for slice 3 it
// reads whatever's already on state.cableClamps (initially empty for a
// freshly-placed clamp).
function renderInspectorClamp(body, id) {
const cl = state.clamps.find((x) => x.id === id);
if (!cl) { body.innerHTML = ""; return; }
const frame = cl.frame_id ? state.frames.find((f) => f.id === cl.frame_id) : null;
const cableTypeColor = new Map(state.cableTypes.map((t) => [t.id, t.color]));
const deviceByID = new Map(state.devices.map((d) => [d.id, d]));
const portByID = new Map(state.ports.map((p) => [p.id, p]));
const ioByID = new Map(state.ioMarkers.map((m) => [m.id, m]));
const cablesThrough = state.cableClamps
.filter((cc) => cc.clamp_id === id)
.map((cc) => state.cables.find((c) => c.id === cc.cable_id))
.filter(Boolean);
function endpointLabel(c, end) {
const portID = end === "from" ? c.from_port_id : c.to_port_id;
const devID = end === "from" ? c.from_device_id : c.to_device_id;
const ioID = end === "from" ? c.from_io_id : c.to_io_id;
if (portID != null) {
const p = portByID.get(portID);
const d = p && deviceByID.get(p.device_id);
return `${d?.name ?? "?"} · ${p?.label ?? "port"}`;
}
if (devID != null) return deviceByID.get(devID)?.name ?? "(missing device)";
if (ioID != null) return ioByID.get(ioID)?.label ?? "(missing IO)";
return "?";
}
const cablesHtml = cablesThrough.length
? cablesThrough.map((c) => `
<div class="port-row" data-cable-id="${c.id}">
<span class="swatch" style="background:${cableTypeColor.get(c.type_id) || "#888"}"></span>
<span class="label">${escapeHtml(endpointLabel(c, "from"))}${escapeHtml(endpointLabel(c, "to"))}</span>
<span class="conn">
<button type="button" class="btn-link clamp-detach" data-cable-id="${c.id}" title="Remove this clamp from the cable">×</button>
</span>
</div>`).join("")
: `<p class="muted" style="font-size:12px">Not on any cable yet.</p>`;
body.innerHTML = `
<p class="section-title">Clamp</p>
<label class="field">
<span>Label</span>
<input class="inline-input" id="clamp-label" value="" />
</label>
<dl>
<dt>x</dt><dd id="clamp-x"></dd>
<dt>y</dt><dd id="clamp-y"></dd>
<dt>frame</dt><dd id="clamp-frame"></dd>
</dl>
<p class="section-title">Cables through</p>
${cablesHtml}
<div class="inspector-actions">
<button type="button" class="btn btn-danger btn-tiny" id="clamp-delete">Delete clamp</button>
</div>
`;
body.querySelector("#clamp-label").value = cl.label;
body.querySelector("#clamp-x").textContent = cl.x.toFixed(0);
body.querySelector("#clamp-y").textContent = cl.y.toFixed(0);
body.querySelector("#clamp-frame").textContent = frame ? frame.name : "—";
bindDebouncedRename(body.querySelector("#clamp-label"), async (label) => {
if (!state.active) return;
const updated = await patchClamp(state.active.id, cl.id, { label });
Object.assign(cl, updated);
renderCanvas();
});
// Per-cable detach in the cables-through list.
body.querySelectorAll(".clamp-detach").forEach((btn) => {
btn.addEventListener("click", async (e) => {
e.stopPropagation();
if (!state.active) return;
const cableID = Number(btn.getAttribute("data-cable-id"));
try {
await detachClampFromCable(state.active.id, cableID, cl.id);
// Re-fetch snapshot fragment to keep ord contiguous.
const snap = await getSnapshot(state.active.id);
state.cableClamps = snap.cable_clamps || [];
render();
} catch (ex) {
alert(`Detach failed: ${ex.message}`);
}
});
});
body.querySelector("#clamp-delete").addEventListener("click", async () => {
if (!state.active) return;
const n = cablesThrough.length;
const prompt = n > 0
? `This clamp is on ${n} cable(s). Delete it and remove from all of them?`
: "Delete this clamp?";
if (!confirm(prompt)) return;
try {
await deleteClamp(state.active.id, cl.id);
state.clamps = state.clamps.filter((c) => c.id !== id);
state.cableClamps = state.cableClamps.filter((cc) => cc.clamp_id !== id);
state.selection = null;
render();
} catch (ex) {
alert(`Delete failed: ${ex.message}`);
}
});
}
// Port editor — type / edge / label / delete. m can also navigate back // Port editor — type / edge / label / delete. m can also navigate back
// to the device by clicking "back to device" or anywhere on the device. // to the device by clicking "back to device" or anywhere on the device.
function renderInspectorPort(body, id) { function renderInspectorPort(body, id) {
@@ -1568,6 +1928,8 @@ async function activateProject(id) {
state.requirements = []; state.requirements = [];
state.cables = []; state.cables = [];
state.bundles = []; state.bundles = [];
state.clamps = [];
state.cableClamps = [];
state.selection = null; state.selection = null;
setActiveInURL(null); setActiveInURL(null);
render(); render();
@@ -1584,6 +1946,8 @@ async function activateProject(id) {
state.bundles = snap.bundles || []; state.bundles = snap.bundles || [];
state.requirements = snap.connection_requirements || []; state.requirements = snap.connection_requirements || [];
state.cableTypes = snap.cable_types || []; state.cableTypes = snap.cable_types || [];
state.clamps = snap.clamps || [];
state.cableClamps = snap.cable_clamps || [];
state.selection = null; state.selection = null;
setActiveInURL(id); setActiveInURL(id);
// Hydrate the device-type catalog for this project — used by the // Hydrate the device-type catalog for this project — used by the
@@ -1607,6 +1971,8 @@ async function activateProject(id) {
state.requirements = []; state.requirements = [];
state.cables = []; state.cables = [];
state.bundles = []; state.bundles = [];
state.clamps = [];
state.cableClamps = [];
setActiveInURL(null); setActiveInURL(null);
render(); render();
} else { } else {
@@ -1624,6 +1990,7 @@ function armTool(tool) {
wrap.classList.toggle("tool-frame", tool === "frame"); wrap.classList.toggle("tool-frame", tool === "frame");
wrap.classList.toggle("tool-device", tool === "device"); wrap.classList.toggle("tool-device", tool === "device");
wrap.classList.toggle("tool-cable", tool === "cable"); wrap.classList.toggle("tool-cable", tool === "cable");
wrap.classList.toggle("tool-clamp", tool === "clamp");
for (const btn of document.querySelectorAll("[data-tool]")) { for (const btn of document.querySelectorAll("[data-tool]")) {
btn.classList.toggle("armed", btn.getAttribute("data-tool") === tool); btn.classList.toggle("armed", btn.getAttribute("data-tool") === tool);
} }
@@ -1655,6 +2022,7 @@ function bindTools() {
else if (e.key === "f" || e.key === "F") armTool("frame"); else if (e.key === "f" || e.key === "F") armTool("frame");
else if (e.key === "d" || e.key === "D") armTool("device"); else if (e.key === "d" || e.key === "D") armTool("device");
else if (e.key === "i" || e.key === "I") armTool("io"); else if (e.key === "i" || e.key === "I") armTool("io");
else if (e.key === "c" || e.key === "C") armTool("clamp");
else if (e.key === "r" || e.key === "R") armTool("req"); else if (e.key === "r" || e.key === "R") armTool("req");
else if (e.key === "s" || e.key === "S") openSolveModal(); else if (e.key === "s" || e.key === "S") openSolveModal();
}); });
@@ -1679,6 +2047,11 @@ let rubberStart = /** @type {{x:number,y:number}|null} */ (null);
// on a .cable-handle, used by renderCanvas to anchor the dragged end // on a .cable-handle, used by renderCanvas to anchor the dragged end
// at the cursor; cleared on pointerup (commit or cancel). // at the cursor; cleared on pointerup (commit or cancel).
let cableReplug = /** @type {{cableID: number, end: "from"|"to", x: number, y: number}|null} */ (null); let cableReplug = /** @type {{cableID: number, end: "from"|"to", x: number, y: number}|null} */ (null);
// Mid-segment drag — m grabs a point on a cable's polyline (not on an
// endpoint handle, not on an existing clamp vertex) and drags. On
// release, either snap to a nearby clamp or create a fresh one at the
// drop point and insert at the right `ord`.
let cableMidDrag = /** @type {{cableID: number, segmentIdx: number, x: number, y: number}|null} */ (null);
function onCanvasPointerDown(e) { function onCanvasPointerDown(e) {
// Pan gestures win over every tool. Middle-click and Space+drag both // Pan gestures win over every tool. Middle-click and Space+drag both
@@ -1714,18 +2087,29 @@ function onCanvasPointerDown(e) {
placeDeviceAt(p); placeDeviceAt(p);
return; return;
} }
if (state.tool === "clamp") {
e.preventDefault();
placeClampAt(p, e);
return;
}
if (state.tool === "io") { if (state.tool === "io") {
e.preventDefault(); e.preventDefault();
placeIOMarkerAt(p); placeIOMarkerAt(p);
return; return;
} }
// No tool armed: clicks that started on a device/frame/io go to their // No tool armed: clicks that started on a device/frame/io/clamp/port/cable
// own handlers (drag / select). Leave them alone. // go to their own handlers (drag / select / replug). Leave them alone.
if (e.target instanceof Element && e.target.closest("[data-device-id], [data-frame-id], [data-io-id]")) return; if (e.target instanceof Element && e.target.closest("[data-device-id], [data-frame-id], [data-io-id], [data-clamp-id], [data-port-id], [data-cable-id]")) return;
// Plain canvas click = clear selection. // Empty-canvas left-click without an active cable draw: start a
if (state.selection) { state.selection = null; render(); } // maybe-pan gesture. It promotes to a pan once the cursor crosses the
// drag threshold; if m clicks without dragging it falls back to the
// historic "clear selection" UX. Other buttons fall through (middle is
// already handled above, right-click is the browser context menu).
if (e.button === 0 && state.cableDrawFromPortID == null) {
startEmptyCanvasGesture(e);
}
} }
function startFrameRubberBand(e, p0) { function startFrameRubberBand(e, p0) {
@@ -2016,6 +2400,51 @@ function startResize(e, deviceID) {
svg.addEventListener("pointercancel", onUp); svg.addEventListener("pointercancel", onUp);
} }
// Frame bottom-right resize gesture. Mirrors startResize for devices,
// but PATCHes /frames/:id and uses a larger minimum (frames host
// devices + IO markers + clamps, so 200×150 is the smallest useful
// canvas). Contained children stay at their absolute positions — the
// frame body drag is what moves them; resize only changes the frame's
// own bounds.
function startFrameResize(e, frameID) {
if (!state.active) return;
// Hard-stop so the rect's pointerdown doesn't also fire startDrag.
e.stopPropagation();
e.preventDefault();
const f = state.frames.find((x) => x.id === frameID);
if (!f) return;
const startWidth = f.width, startHeight = f.height;
const startWorld = svgPoint(e);
const svg = /** @type {SVGSVGElement} */ ($("#canvas"));
try { svg.setPointerCapture(e.pointerId); } catch {}
const MIN_FRAME_W = 200, MIN_FRAME_H = 150;
const onMove = (ev) => {
const p = svgPoint(ev);
f.width = Math.max(MIN_FRAME_W, startWidth + (p.x - startWorld.x));
f.height = Math.max(MIN_FRAME_H, startHeight + (p.y - startWorld.y));
renderCanvas();
};
const onUp = async (ev) => {
svg.removeEventListener("pointermove", onMove);
svg.removeEventListener("pointerup", onUp);
svg.removeEventListener("pointercancel", onUp);
try { svg.releasePointerCapture(ev.pointerId); } catch {}
if (f.width === startWidth && f.height === startHeight) return;
try {
const updated = await patchFrame(state.active.id, f.id, {
width: f.width, height: f.height,
});
Object.assign(f, updated);
renderCanvas();
} catch (err) {
alert(`Resize failed: ${err.message}`);
}
};
svg.addEventListener("pointermove", onMove);
svg.addEventListener("pointerup", onUp);
svg.addEventListener("pointercancel", onUp);
}
// Find the topmost canvas element under (clientX, clientY) that maps to // Find the topmost canvas element under (clientX, clientY) that maps to
// a cable endpoint target. Returns { kind, id } for port / device / IO, // a cable endpoint target. Returns { kind, id } for port / device / IO,
// or null when m dropped on empty canvas. // or null when m dropped on empty canvas.
@@ -2105,6 +2534,121 @@ function startCableReplug(e, cableID, end) {
svg.addEventListener("pointercancel", onUp); svg.addEventListener("pointercancel", onUp);
} }
// Mid-segment cable drag: m grabs a point on a selected cable's
// polyline (not on an endpoint handle) and drags. On release, snap to
// the nearest clamp within MID_SNAP world-units, or create a fresh one
// at the drop point. Either way, attach it to the cable at the right
// ord so the new vertex sits inside the segment m was bending.
const MID_SNAP_PX = 16; // visual constant — divided by current zoom
function startCableMidDrag(e, cable, vertices) {
if (!state.active) return;
// Refuse if the click target is an endpoint handle — let the replug
// handler own that gesture.
if (e.target instanceof Element && e.target.classList.contains("cable-handle")) return;
e.stopPropagation();
e.preventDefault();
const svg = /** @type {SVGSVGElement} */ ($("#canvas"));
const start = svgPoint(e);
// Identify which segment the click landed closest to.
const segIdx = nearestSegmentIndex(vertices, start);
try { svg.setPointerCapture(e.pointerId); } catch {}
$(".canvas-wrap").classList.add("replugging");
cableMidDrag = { cableID: cable.id, segmentIdx: segIdx, x: start.x, y: start.y };
renderCanvas();
const onMove = (ev) => {
const p = svgPoint(ev);
cableMidDrag = { cableID: cable.id, segmentIdx: segIdx, x: p.x, y: p.y };
renderCanvas();
};
const onUp = async (ev) => {
svg.removeEventListener("pointermove", onMove);
svg.removeEventListener("pointerup", onUp);
svg.removeEventListener("pointercancel", onUp);
try { svg.releasePointerCapture(ev.pointerId); } catch {}
$(".canvas-wrap").classList.remove("replugging");
const dropWorld = svgPoint(ev);
cableMidDrag = null;
// Cancel if the cursor barely moved (≤ a few px in world coords) —
// m probably clicked the cable to select it, not bend it.
if (Math.hypot(dropWorld.x - start.x, dropWorld.y - start.y) < 4) {
renderCanvas();
return;
}
// Snap radius in world coords — visual constant per design v5 §11.9 q2.
const snapRadius = MID_SNAP_PX / state.view.zoom;
let nearest = null;
let bestDist = Infinity;
for (const cl of state.clamps) {
const d = Math.hypot(cl.x - dropWorld.x, cl.y - dropWorld.y);
if (d < bestDist) { bestDist = d; nearest = cl; }
}
try {
let clampID;
if (nearest && bestDist <= snapRadius) {
// Snap onto existing clamp — but only if it's not already on
// this cable (UNIQUE constraint would 409). Skip silently in
// that case rather than spamming an alert.
const already = state.cableClamps.some(
(cc) => cc.cable_id === cable.id && cc.clamp_id === nearest.id,
);
if (already) { renderCanvas(); return; }
clampID = nearest.id;
} else {
// Fresh clamp at the drop point.
const frame = frameAt(dropWorld.x, dropWorld.y);
const newClamp = await createClamp(state.active.id, {
x: dropWorld.x, y: dropWorld.y,
frame_id: frame ? frame.id : undefined,
});
state.clamps.push(newClamp);
clampID = newClamp.id;
}
// Insert at ord = segIdx + 1 (1-based; segmentIdx is the segment
// between vertices[segIdx] and vertices[segIdx + 1]).
const cc = await attachClampToCable(state.active.id, cable.id, {
clamp_id: clampID, ord: segIdx + 1,
});
// Refresh cable_clamps so the new ord + any shifted neighbours
// are reflected without a full snapshot reload.
const snap = await getSnapshot(state.active.id);
state.cableClamps = snap.cable_clamps || [];
render();
// Silence unused-var lint without dropping the result.
void cc;
} catch (err) {
alert(`Insert clamp failed: ${err.message}`);
renderCanvas();
}
};
svg.addEventListener("pointermove", onMove);
svg.addEventListener("pointerup", onUp);
svg.addEventListener("pointercancel", onUp);
}
// Index of the segment in `vertices` closest to point p. Segment i sits
// between vertices[i] and vertices[i+1].
function nearestSegmentIndex(vertices, p) {
let best = 0;
let bestDist = Infinity;
for (let i = 0; i < vertices.length - 1; i++) {
const a = vertices[i], b = vertices[i + 1];
const d = pointSegmentDistance(p, a, b);
if (d < bestDist) { bestDist = d; best = i; }
}
return best;
}
// Shortest distance from point p to the line segment ab.
function pointSegmentDistance(p, a, b) {
const dx = b.x - a.x, dy = b.y - a.y;
const lenSq = dx * dx + dy * dy;
if (lenSq === 0) return Math.hypot(p.x - a.x, p.y - a.y);
let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / lenSq;
t = Math.max(0, Math.min(1, t));
return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy));
}
/** Port-click flow: /** Port-click flow:
* - A cable draw is in progress (cableDrawFromPortID set): * - A cable draw is in progress (cableDrawFromPortID set):
* same port → cancel; another port → finish the cable. * same port → cancel; another port → finish the cable.
@@ -2239,6 +2783,40 @@ async function createPortFromForm(deviceID, typeID, edge, label) {
} }
} }
// + Clamp tool: drop a standalone routing anchor at the click. If the
// click landed on a cable (slice 4 will detect this), the clamp will
// also be attached to that cable mid-segment. For slice 3 we just
// place it.
async function placeClampAt(p, e) {
if (!state.active) return;
armTool(null);
// Did the click hit a cable? If so, attach the new clamp to that cable.
const cableEl = e && e.target instanceof Element
? e.target.closest("[data-cable-id]")
: null;
const cableID = cableEl ? Number(cableEl.getAttribute("data-cable-id")) : null;
const frame = frameAt(p.x, p.y);
try {
const c = await createClamp(state.active.id, {
x: p.x, y: p.y,
frame_id: frame ? frame.id : undefined,
});
state.clamps.push(c);
if (cableID) {
try {
const cc = await attachClampToCable(state.active.id, cableID, { clamp_id: c.id });
state.cableClamps.push(cc);
} catch (ex) {
alert(`Attach to cable failed: ${ex.message}`);
}
}
state.selection = { kind: "clamp", id: c.id };
render();
} catch (err) {
alert(`Create clamp failed: ${err.message}`);
}
}
async function placeIOMarkerAt(p) { async function placeIOMarkerAt(p) {
if (!state.active) return; if (!state.active) return;
armTool(null); armTool(null);
@@ -2329,19 +2907,21 @@ function startDrag(e, kind, id) {
const svg = /** @type {SVGSVGElement} */ ($("#canvas")); const svg = /** @type {SVGSVGElement} */ ($("#canvas"));
const start = svgPoint(e); const start = svgPoint(e);
/** @type {Frame|Device|IOMarker|undefined} */ /** @type {Frame|Device|IOMarker|Clamp|undefined} */
let obj; let obj;
if (kind === "frame") obj = state.frames.find((f) => f.id === id); if (kind === "frame") obj = state.frames.find((f) => f.id === id);
else if (kind === "device") obj = state.devices.find((d) => d.id === id); else if (kind === "device") obj = state.devices.find((d) => d.id === id);
else if (kind === "io") obj = state.ioMarkers.find((m) => m.id === id); else if (kind === "io") obj = state.ioMarkers.find((m) => m.id === id);
else if (kind === "clamp") obj = state.clamps.find((c) => c.id === id);
if (!obj) return; if (!obj) return;
const startX = obj.x; const startX = obj.x;
const startY = obj.y; const startY = obj.y;
// For frame drags, remember the contained devices + IO markers + their // For frame drags, remember the contained devices + IO markers + clamps
// offsets so they follow the frame visually + persist on release. // + their offsets so they follow the frame visually + persist on release.
let trackedDevices = /** @type {{d: Device, sx: number, sy: number}[]} */ ([]); let trackedDevices = /** @type {{d: Device, sx: number, sy: number}[]} */ ([]);
let trackedIOs = /** @type {{m: IOMarker, sx: number, sy: number}[]} */ ([]); let trackedIOs = /** @type {{m: IOMarker, sx: number, sy: number}[]} */ ([]);
let trackedClamps = /** @type {{c: Clamp, sx: number, sy: number}[]} */ ([]);
if (kind === "frame") { if (kind === "frame") {
for (const d of state.devices) { for (const d of state.devices) {
if (d.frame_id === obj.id) { if (d.frame_id === obj.id) {
@@ -2353,6 +2933,11 @@ function startDrag(e, kind, id) {
trackedIOs.push({ m, sx: m.x, sy: m.y }); trackedIOs.push({ m, sx: m.x, sy: m.y });
} }
} }
for (const c of state.clamps) {
if (c.frame_id === obj.id) {
trackedClamps.push({ c, sx: c.x, sy: c.y });
}
}
} }
// Capture the rect element NOW: by the time onUp fires async, the // Capture the rect element NOW: by the time onUp fires async, the
@@ -2376,6 +2961,7 @@ function startDrag(e, kind, id) {
if (kind === "frame") { if (kind === "frame") {
for (const t of trackedDevices) { t.d.x = t.sx + dx; t.d.y = t.sy + dy; } for (const t of trackedDevices) { t.d.x = t.sx + dx; t.d.y = t.sy + dy; }
for (const t of trackedIOs) { t.m.x = t.sx + dx; t.m.y = t.sy + dy; } for (const t of trackedIOs) { t.m.x = t.sx + dx; t.m.y = t.sy + dy; }
for (const t of trackedClamps) { t.c.x = t.sx + dx; t.c.y = t.sy + dy; }
} }
renderCanvas(); renderCanvas();
}; };
@@ -2392,12 +2978,14 @@ function startDrag(e, kind, id) {
if (kind === "frame") { if (kind === "frame") {
const f = /** @type {Frame} */ (obj); const f = /** @type {Frame} */ (obj);
await patchFrame(state.active.id, f.id, { x: f.x, y: f.y }); await patchFrame(state.active.id, f.id, { x: f.x, y: f.y });
// Persist contained devices + IO markers too. // Persist contained devices + IO markers + clamps too.
await Promise.all([ await Promise.all([
...trackedDevices.map((t) => ...trackedDevices.map((t) =>
patchDevice(state.active.id, t.d.id, { x: t.d.x, y: t.d.y })), patchDevice(state.active.id, t.d.id, { x: t.d.x, y: t.d.y })),
...trackedIOs.map((t) => ...trackedIOs.map((t) =>
patchIOMarker(state.active.id, t.m.id, { x: t.m.x, y: t.m.y })), patchIOMarker(state.active.id, t.m.id, { x: t.m.x, y: t.m.y })),
...trackedClamps.map((t) =>
patchClamp(state.active.id, t.c.id, { x: t.c.x, y: t.c.y })),
]); ]);
} else if (kind === "device") { } else if (kind === "device") {
const d = /** @type {Device} */ (obj); const d = /** @type {Device} */ (obj);
@@ -2412,7 +3000,7 @@ function startDrag(e, kind, id) {
d.frame_id = newFrameID; d.frame_id = newFrameID;
} }
await patchDevice(state.active.id, d.id, patchBody); await patchDevice(state.active.id, d.id, patchBody);
} else /* io */ { } else if (kind === "io") {
const m = /** @type {IOMarker} */ (obj); const m = /** @type {IOMarker} */ (obj);
const cx = m.x + IO_SIZE / 2; const cx = m.x + IO_SIZE / 2;
const cy = m.y + IO_SIZE / 2; const cy = m.y + IO_SIZE / 2;
@@ -2424,6 +3012,16 @@ function startDrag(e, kind, id) {
m.frame_id = newFrameID; m.frame_id = newFrameID;
} }
await patchIOMarker(state.active.id, m.id, patchBody); await patchIOMarker(state.active.id, m.id, patchBody);
} else /* clamp */ {
const c = /** @type {Clamp} */ (obj);
const targetFrame = frameAt(c.x, c.y);
const newFrameID = targetFrame ? targetFrame.id : null;
const patchBody = { x: c.x, y: c.y };
if ((c.frame_id ?? null) !== newFrameID) {
patchBody.frame_id = newFrameID;
c.frame_id = newFrameID;
}
await patchClamp(state.active.id, c.id, patchBody);
} }
} catch (err) { } catch (err) {
alert(`Save failed: ${err.message}`); alert(`Save failed: ${err.message}`);
@@ -3224,7 +3822,6 @@ async function boot() {
bindCloseButtons($("#modal-admin")); bindCloseButtons($("#modal-admin"));
$("#btn-new-project").addEventListener("click", openNewProjectModal); $("#btn-new-project").addEventListener("click", openNewProjectModal);
$("#btn-add-type").addEventListener("click", () => openCableTypeModal(null));
$("#btn-delete-project").addEventListener("click", openDeleteProjectModal); $("#btn-delete-project").addEventListener("click", openDeleteProjectModal);
$("#btn-admin").addEventListener("click", openAdminModal); $("#btn-admin").addEventListener("click", openAdminModal);
$("#btn-solve").addEventListener("click", openSolveModal); $("#btn-solve").addEventListener("click", openSolveModal);

View File

@@ -180,7 +180,20 @@ body {
fill: var(--accent); fill: var(--accent);
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
pointer-events: none; cursor: grab;
}
/* Frame bottom-right resize affordance. Mirrors .device-resize-handle
but uses the accent-on-frame palette so it reads as part of the frame
chrome rather than the device. */
.frame-resize-handle {
fill: rgba(0, 0, 0, 0.15);
stroke: rgba(0, 0, 0, 0.25);
stroke-width: 1;
cursor: nwse-resize;
}
.frame-resize-handle:hover {
fill: rgba(0, 0, 0, 0.3);
} }
/* Stroke + fill come from the device's user-set colour, written as /* Stroke + fill come from the device's user-set colour, written as
@@ -227,9 +240,44 @@ body {
.canvas-wrap.tool-device #canvas *, .canvas-wrap.tool-device #canvas *,
.canvas-wrap.tool-io #canvas, .canvas-wrap.tool-io #canvas,
.canvas-wrap.tool-io #canvas *, .canvas-wrap.tool-io #canvas *,
.canvas-wrap.tool-clamp #canvas,
.canvas-wrap.tool-clamp #canvas *,
.canvas-wrap.tool-cable #canvas, .canvas-wrap.tool-cable #canvas,
.canvas-wrap.tool-cable #canvas * { cursor: crosshair !important; } .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 { .btn-link {
background: transparent; background: transparent;
border: 0; border: 0;

View File

@@ -1,5 +1,5 @@
// Package web bundles the frontend (HTML/JS/CSS) into the Go binary // Package web bundles the frontend (HTML/JS/CSS) into the Go binary
// via embed.FS so deploying mCables means shipping one file. // via embed.FS so deploying CableGUI means shipping one file.
package web package web
import ( import (