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,
|
// 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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user