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:
@@ -12,7 +12,7 @@ import (
|
||||
// Caller passes one map per kind; keys are the in-project row ids,
|
||||
// values are the 21-char Excalidraw element ids the exporter minted.
|
||||
func (s *Store) PersistExcalidrawIDs(projectID int64,
|
||||
frames, devices, ports, ios, cables map[int64]string,
|
||||
frames, devices, ports, ios, cables, clamps map[int64]string,
|
||||
) error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
@@ -35,6 +35,9 @@ func (s *Store) PersistExcalidrawIDs(projectID int64,
|
||||
if err := updateExIDs(tx, "cables", projectID, cables); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := updateExIDs(tx, "clamps", projectID, clamps); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"sort"
|
||||
|
||||
"mgit.msbls.de/m/mcables/internal/db"
|
||||
)
|
||||
@@ -114,6 +115,7 @@ type IDAssignment struct {
|
||||
Ports map[int64]string `json:"ports"`
|
||||
IOMarkers map[int64]string `json:"io_markers"`
|
||||
Cables map[int64]string `json:"cables"`
|
||||
Clamps map[int64]string `json:"clamps"`
|
||||
}
|
||||
|
||||
// BuildScene transforms a project snapshot into an Excalidraw Scene +
|
||||
@@ -132,6 +134,7 @@ func BuildScene(snap *db.Snapshot, nowMilli int64, genID func() string) (*Scene,
|
||||
Ports: map[int64]string{},
|
||||
IOMarkers: map[int64]string{},
|
||||
Cables: map[int64]string{},
|
||||
Clamps: map[int64]string{},
|
||||
}
|
||||
// idFor: reuse the existing excalidraw_id if present, else mint one.
|
||||
idFor := func(existing *string) string {
|
||||
@@ -381,6 +384,58 @@ func BuildScene(snap *db.Snapshot, nowMilli int64, genID func() string) (*Scene,
|
||||
})
|
||||
}
|
||||
|
||||
// Clamps — small grey rounded squares (v5 §11.7). Distinct from the
|
||||
// red IO marker diamonds so m can tell routing anchors from wall
|
||||
// outlets at a glance.
|
||||
const clampSize = 12.0
|
||||
for _, cl := range snap.Clamps {
|
||||
elID := idFor(cl.ExcalidrawID)
|
||||
a.Clamps[cl.ID] = elID
|
||||
var frameRef *string
|
||||
if cl.FrameID != nil {
|
||||
if v, ok := frameElID[*cl.FrameID]; ok {
|
||||
frameRef = &v
|
||||
}
|
||||
}
|
||||
els = append(els, Element{
|
||||
ID: elID,
|
||||
Type: "rectangle",
|
||||
X: cl.X - clampSize/2,
|
||||
Y: cl.Y - clampSize/2,
|
||||
Width: clampSize,
|
||||
Height: clampSize,
|
||||
StrokeColor: "#555555",
|
||||
BackgroundColor: "#888888",
|
||||
FillStyle: "solid",
|
||||
StrokeWidth: 1,
|
||||
StrokeStyle: "solid",
|
||||
Roughness: 0,
|
||||
Opacity: 100,
|
||||
GroupIDs: []string{},
|
||||
FrameID: frameRef,
|
||||
Roundness: &Roundness{Type: 3},
|
||||
Seed: randInt(),
|
||||
Version: 1,
|
||||
VersionNonce: randInt(),
|
||||
Updated: nowMilli,
|
||||
})
|
||||
}
|
||||
|
||||
// Pre-group cable_clamps by cable for the arrow mid-points pass.
|
||||
clampsByCable := map[int64][]db.CableClamp{}
|
||||
for _, cc := range snap.CableClamps {
|
||||
clampsByCable[cc.CableID] = append(clampsByCable[cc.CableID], cc)
|
||||
}
|
||||
for _, arr := range clampsByCable {
|
||||
// Already sorted by ListCableClamps (ORDER BY cable_id, ord),
|
||||
// but defend against unsorted inputs.
|
||||
sort.Slice(arr, func(i, j int) bool { return arr[i].Ord < arr[j].Ord })
|
||||
}
|
||||
clampPos := map[int64][2]float64{}
|
||||
for _, cl := range snap.Clamps {
|
||||
clampPos[cl.ID] = [2]float64{cl.X, cl.Y}
|
||||
}
|
||||
|
||||
// Cables — arrows with startBinding/endBinding to the port / device /
|
||||
// IO marker excalidraw_ids. Endpoint anchors (the visible "from" /
|
||||
// "to" points) come from the same anchor logic the canvas uses.
|
||||
@@ -403,6 +458,18 @@ func BuildScene(snap *db.Snapshot, nowMilli int64, genID func() string) (*Scene,
|
||||
}
|
||||
startArr := ""
|
||||
endArr := "arrow"
|
||||
// Excalidraw arrow `points` is relative to (X, Y). We anchor at
|
||||
// the from-point, so vertex 0 is always (0, 0). Mid-vertices
|
||||
// (clamps) and the final to-vertex are offsets from there.
|
||||
pts := [][2]float64{{0, 0}}
|
||||
for _, cc := range clampsByCable[c.ID] {
|
||||
pos, ok := clampPos[cc.ClampID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
pts = append(pts, [2]float64{pos[0] - fromAnchor[0], pos[1] - fromAnchor[1]})
|
||||
}
|
||||
pts = append(pts, [2]float64{toAnchor[0] - fromAnchor[0], toAnchor[1] - fromAnchor[1]})
|
||||
els = append(els, Element{
|
||||
ID: elID,
|
||||
Type: "arrow",
|
||||
@@ -422,7 +489,7 @@ func BuildScene(snap *db.Snapshot, nowMilli int64, genID func() string) (*Scene,
|
||||
Version: 1,
|
||||
VersionNonce: randInt(),
|
||||
Updated: nowMilli,
|
||||
Points: [][2]float64{{0, 0}, {toAnchor[0] - fromAnchor[0], toAnchor[1] - fromAnchor[1]}},
|
||||
Points: pts,
|
||||
StartArrowhead: &startArr,
|
||||
EndArrowhead: &endArr,
|
||||
StartBinding: bindingPtr(fromRef),
|
||||
|
||||
@@ -137,6 +137,66 @@ func TestBuildScene_BundlesIgnored(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildScene_ClampsRenderAsRectangles(t *testing.T) {
|
||||
snap := sampleSnapshot()
|
||||
snap.Clamps = []db.Clamp{
|
||||
{ID: 1, ProjectID: 1, X: 500, Y: 300},
|
||||
{ID: 2, ProjectID: 1, X: 550, Y: 320},
|
||||
}
|
||||
scene, ids := BuildScene(snap, 1700000000000, newSeq())
|
||||
if len(ids.Clamps) != 2 {
|
||||
t.Errorf("clamp ids = %d, want 2", len(ids.Clamps))
|
||||
}
|
||||
clampElIDs := map[string]bool{}
|
||||
for _, id := range ids.Clamps {
|
||||
clampElIDs[id] = true
|
||||
}
|
||||
got := 0
|
||||
for _, e := range scene.Elements {
|
||||
if clampElIDs[e.ID] && e.Type == "rectangle" {
|
||||
got++
|
||||
}
|
||||
}
|
||||
if got != 2 {
|
||||
t.Errorf("clamp rectangle elements = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildScene_ArrowPointsIncludeClamps(t *testing.T) {
|
||||
snap := sampleSnapshot()
|
||||
snap.Clamps = []db.Clamp{
|
||||
{ID: 10, ProjectID: 1, X: 350, Y: 250},
|
||||
}
|
||||
snap.CableClamps = []db.CableClamp{
|
||||
{CableID: 1000, ClampID: 10, Ord: 1},
|
||||
}
|
||||
scene, _ := BuildScene(snap, 1700000000000, newSeq())
|
||||
var arrow *Element
|
||||
for i := range scene.Elements {
|
||||
if scene.Elements[i].Type == "arrow" {
|
||||
arrow = &scene.Elements[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if arrow == nil {
|
||||
t.Fatal("no arrow in scene")
|
||||
}
|
||||
if len(arrow.Points) != 3 {
|
||||
t.Errorf("arrow points = %d, want 3 (from + clamp + to): %+v", len(arrow.Points), arrow.Points)
|
||||
}
|
||||
// First point is always (0, 0) by convention; middle point should
|
||||
// equal the clamp's position relative to the arrow's anchor.
|
||||
if arrow.Points[0][0] != 0 || arrow.Points[0][1] != 0 {
|
||||
t.Errorf("first point = %v, want [0,0]", arrow.Points[0])
|
||||
}
|
||||
// Middle vertex = clamp.x - fromAnchor.x, clamp.y - fromAnchor.y.
|
||||
// fromAnchor for port 100 = (200 + 50, 200 + 35) = (250, 235).
|
||||
wantX, wantY := 350.0-250.0, 250.0-235.0
|
||||
if arrow.Points[1][0] != wantX || arrow.Points[1][1] != wantY {
|
||||
t.Errorf("mid point = %v, want [%v, %v]", arrow.Points[1], wantX, wantY)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalScene_IsJSON(t *testing.T) {
|
||||
snap := sampleSnapshot()
|
||||
scene, _ := BuildScene(snap, 1700000000000, newSeq())
|
||||
|
||||
@@ -51,7 +51,7 @@ func (h *handlers) syncExport(w http.ResponseWriter, r *http.Request) {
|
||||
// Persist the freshly-assigned ids so the next export reuses them.
|
||||
// We pass in the full maps; PersistExcalidrawIDs is idempotent (it
|
||||
// only updates rows whose excalidraw_id is still NULL).
|
||||
if err := h.store.PersistExcalidrawIDs(pid, ids.Frames, ids.Devices, ids.Ports, ids.IOMarkers, ids.Cables); err != nil {
|
||||
if err := h.store.PersistExcalidrawIDs(pid, ids.Frames, ids.Devices, ids.Ports, ids.IOMarkers, ids.Cables, ids.Clamps); err != nil {
|
||||
writeError(w, fmt.Errorf("persist excalidraw_ids: %w", err), nil)
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user