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.
This commit is contained in:
mAi
2026-05-16 13:58:32 +02:00
parent 2cbefd3146
commit 813d59b068
4 changed files with 133 additions and 3 deletions

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

@@ -11,6 +11,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"math/big" "math/big"
"sort"
"mgit.msbls.de/m/mcables/internal/db" "mgit.msbls.de/m/mcables/internal/db"
) )
@@ -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),

View File

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

View File

@@ -51,7 +51,7 @@ func (h *handlers) syncExport(w http.ResponseWriter, r *http.Request) {
// Persist the freshly-assigned ids so the next export reuses them. // 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
} }