From 2aff5eb04d4f7b587fbdc423f776e1cb7421205a Mon Sep 17 00:00:00 2001 From: mAi Date: Sat, 16 May 2026 11:30:32 +0200 Subject: [PATCH] feat(template): apply-template lands devices inside a named frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before: ApplyTemplate dropped devices in a horizontal row at fixed canvas coords with frame_id NULL — devices appeared anywhere and m had no way to express "these belong together". Now: each apply creates a frame named after the template (suffixed "… 2/3/…" on name collision) and lays the devices out in a uniform grid inside it. Grid is roughly square (cols = ceil(sqrt(N)), capped at 4) with 30/50 px gaps and 32/48 px padding. Each device gets the new frame's id and grid-cell coords. Schema unchanged. ApplyTemplateResult.frames_added carries the new frame so the frontend can refresh the canvas without a full snapshot reload. Tests: - TestApplyTemplate_CreatesFrameAndPlacesDevicesInside — frame is created with the template's name, every device has frame_id set, every device sits inside the frame rect, no two devices share a grid cell. - TestApplyTemplate_FrameNameSuffixOnCollision — pre-existing "Living Room" frame in the project ⇒ template's frame named "Living Room 2". - Existing tests unchanged. --- internal/db/models.go | 9 +-- internal/db/setup_templates.go | 114 ++++++++++++++++++++++++++++++--- internal/db/solver_test.go | 70 ++++++++++++++++++++ 3 files changed, 181 insertions(+), 12 deletions(-) diff --git a/internal/db/models.go b/internal/db/models.go index 1a09447..990e016 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -191,10 +191,11 @@ type UnsatisfiedReq struct { // ApplyTemplateResult is the response from POST /apply-template. type ApplyTemplateResult struct { - DevicesAdded []Device `json:"devices_added"` - RequirementsAdded []ConnectionRequirement `json:"requirements_added"` - SkippedDevices []SkippedTemplateDevice `json:"skipped_devices"` - RequirementsSkipped []SkippedTemplateReq `json:"requirements_skipped"` + FramesAdded []Frame `json:"frames_added"` + DevicesAdded []Device `json:"devices_added"` + RequirementsAdded []ConnectionRequirement `json:"requirements_added"` + SkippedDevices []SkippedTemplateDevice `json:"skipped_devices"` + RequirementsSkipped []SkippedTemplateReq `json:"requirements_skipped"` } type SkippedTemplateDevice struct { diff --git a/internal/db/setup_templates.go b/internal/db/setup_templates.go index 2f04511..bf57542 100644 --- a/internal/db/setup_templates.go +++ b/internal/db/setup_templates.go @@ -4,6 +4,7 @@ import ( "database/sql" "errors" "fmt" + "math" "strings" ) @@ -161,6 +162,7 @@ func (s *Store) ApplyTemplate(projectID, templateID int64, opts ApplyTemplateOpt } out := &ApplyTemplateResult{ + FramesAdded: []Frame{}, DevicesAdded: []Device{}, RequirementsAdded: []ConnectionRequirement{}, SkippedDevices: []SkippedTemplateDevice{}, @@ -171,8 +173,8 @@ func (s *Store) ApplyTemplate(projectID, templateID int64, opts ApplyTemplateOpt opts.OriginX, opts.OriginY = 200, 200 } - // Pull existing device names in the project so we can pre-check - // collisions without aborting the whole transaction. + // Pull existing device + frame names in the project so we can + // pre-check collisions without aborting the whole transaction. existing, err := s.ListDevices(projectID, nil) if err != nil { return nil, err @@ -181,6 +183,14 @@ func (s *Store) ApplyTemplate(projectID, templateID int64, opts ApplyTemplateOpt for _, d := range existing { nameTaken[d.Name] = true } + existingFrames, err := s.ListFrames(projectID) + if err != nil { + return nil, err + } + frameNameTaken := map[string]bool{} + for _, f := range existingFrames { + frameNameTaken[f.Name] = true + } tx, err := s.db.Begin() if err != nil { @@ -188,6 +198,37 @@ func (s *Store) ApplyTemplate(projectID, templateID int64, opts ApplyTemplateOpt } defer tx.Rollback() + // Plan a uniform grid for the template's devices inside a new frame + // named after the template. The grid drives both frame size and + // per-device (x, y). Devices that get skipped (name collision / + // SkipDevices) leave their grid cell empty. + const ( + devW, devH = 100.0, 35.0 + gapX, gapY = 30.0, 50.0 + padX, padY = 32.0, 48.0 // padY larger so the frame title clears row 1 + ) + n := len(tmpl.Devices) + cols := 1 + if n > 0 { + cols = min(int(math.Ceil(math.Sqrt(float64(n)))), 4) + } + rows := 1 + if n > 0 { + rows = (n + cols - 1) / cols + } + frameW := padX*2 + float64(cols)*devW + float64(cols-1)*gapX + frameH := padY + padX + float64(rows)*devH + float64(rows-1)*gapY + frameName := pickFrameName(tmpl.Name, frameNameTaken) + + frame, err := createFrameTx(tx, projectID, FrameCreate{ + Name: frameName, X: opts.OriginX, Y: opts.OriginY, + Width: frameW, Height: frameH, + }) + if err != nil { + return nil, fmt.Errorf("seed frame %q: %w", frameName, err) + } + out.FramesAdded = append(out.FramesAdded, *frame) + // Map: template_device_id → newly-created device_id (or 0 if skipped). tmplToDevice := map[int64]int64{} @@ -215,17 +256,22 @@ func (s *Store) ApplyTemplate(projectID, templateID int64, opts ApplyTemplateOpt tmplToDevice[td.ID] = 0 continue } - // Lay out devices in a horizontal row near the origin, 150 px apart. - x := opts.OriginX + float64(i)*150 - y := opts.OriginY - // Use createDeviceTx so the port-seeding share the same transaction. + // Grid cell (col, row) within the frame. Cell anchor is the + // top-left of the device rect; offsets are added to the frame's + // own (x, y) so the device sits inside the frame. + col := i % cols + row := i / cols + x := frame.X + padX + float64(col)*(devW+gapX) + y := frame.Y + padY + float64(row)*(devH+gapY) + // Use createDeviceTx so port-seeding shares the same transaction. d, err := s.createDeviceTx(tx, projectID, DeviceCreate{ Name: name, TypeID: &td.DeviceTypeID, + FrameID: &frame.ID, X: x, Y: y, - Width: 100, - Height: 35, + Width: devW, + Height: devH, }) if err != nil { return nil, fmt.Errorf("seed %s: %w", name, err) @@ -294,6 +340,58 @@ func (s *Store) ApplyTemplate(projectID, templateID int64, opts ApplyTemplateOpt return out, nil } +// pickFrameName returns a frame name that doesn't collide with anything +// in `taken`. Tries the template name first, then " 2", " 3", +// and so on. +func pickFrameName(base string, taken map[string]bool) string { + if !taken[base] { + return base + } + for i := 2; ; i++ { + candidate := fmt.Sprintf("%s %d", base, i) + if !taken[candidate] { + return candidate + } + } +} + +// createFrameTx inserts a frame inside the caller's transaction. Mirrors +// the validation in CreateFrame (name + positive size) but avoids the +// s.db.Exec call so ApplyTemplate can keep everything on the same +// connection under MaxOpenConns(1). +func createFrameTx(tx *sql.Tx, projectID int64, f FrameCreate) (*Frame, error) { + name := strings.TrimSpace(f.Name) + if name == "" { + return nil, fmt.Errorf("%w: name is required", ErrInvalidInput) + } + if f.Width <= 0 || f.Height <= 0 { + return nil, fmt.Errorf("%w: width and height must be positive", ErrInvalidInput) + } + res, err := tx.Exec( + `INSERT INTO frames (project_id, name, x, y, width, height) + VALUES (?, ?, ?, ?, ?, ?)`, + projectID, name, f.X, f.Y, f.Width, f.Height, + ) + if err != nil { + return nil, mapWriteErr(err) + } + id, _ := res.LastInsertId() + var out Frame + var ex sql.NullString + err = tx.QueryRow( + `SELECT id, project_id, name, x, y, width, height, excalidraw_id, created_at, updated_at + FROM frames WHERE id = ? AND project_id = ?`, id, projectID, + ).Scan(&out.ID, &out.ProjectID, &out.Name, &out.X, &out.Y, &out.Width, &out.Height, + &ex, &out.CreatedAt, &out.UpdatedAt) + if err != nil { + return nil, err + } + if ex.Valid { + out.ExcalidrawID = &ex.String + } + return &out, nil +} + // createDeviceTx is a tx-aware variant of CreateDevice used by // ApplyTemplate so seeding the template's devices + their ports stays // inside one atomic apply. diff --git a/internal/db/solver_test.go b/internal/db/solver_test.go index 6c3a6b9..f87b079 100644 --- a/internal/db/solver_test.go +++ b/internal/db/solver_test.go @@ -234,6 +234,76 @@ func TestApplyTemplate_HomeOffice_ThenSolve(t *testing.T) { } } +func TestApplyTemplate_CreatesFrameAndPlacesDevicesInside(t *testing.T) { + s := newTestStore(t) + p, _ := s.CreateProject("LOFT", "", "") + tmpls, _ := s.ListSetupTemplates() + var lr SetupTemplate + for _, tm := range tmpls { + if tm.Name == "Living Room" { + lr = tm + break + } + } + res, err := s.ApplyTemplate(p.ID, lr.ID, ApplyTemplateOptions{}) + if err != nil { + t.Fatalf("apply: %v", err) + } + if len(res.FramesAdded) != 1 { + t.Fatalf("frames added = %d, want 1", len(res.FramesAdded)) + } + frame := res.FramesAdded[0] + if frame.Name != "Living Room" { + t.Errorf("frame name = %q, want %q", frame.Name, "Living Room") + } + for _, d := range res.DevicesAdded { + if d.FrameID == nil || *d.FrameID != frame.ID { + t.Errorf("device %q: frame_id = %v, want %d", d.Name, d.FrameID, frame.ID) + } + // Device top-left should be inside the frame rect. + if d.X < frame.X || d.X+d.Width > frame.X+frame.Width { + t.Errorf("device %q: x=%v width=%v outside frame [%v..%v]", d.Name, d.X, d.Width, frame.X, frame.X+frame.Width) + } + if d.Y < frame.Y || d.Y+d.Height > frame.Y+frame.Height { + t.Errorf("device %q: y=%v height=%v outside frame [%v..%v]", d.Name, d.Y, d.Height, frame.Y, frame.Y+frame.Height) + } + } + // No two devices share the same (X, Y) — the grid layout spreads them out. + seen := map[[2]float64]string{} + for _, d := range res.DevicesAdded { + key := [2]float64{d.X, d.Y} + if prev, ok := seen[key]; ok { + t.Errorf("devices %q and %q share grid cell (%v, %v)", prev, d.Name, d.X, d.Y) + } + seen[key] = d.Name + } +} + +func TestApplyTemplate_FrameNameSuffixOnCollision(t *testing.T) { + s := newTestStore(t) + p, _ := s.CreateProject("LOFT", "", "") + // Pre-create a frame called "Living Room" so the template's frame name collides. + _, _ = s.CreateFrame(p.ID, FrameCreate{Name: "Living Room", X: 0, Y: 0, Width: 100, Height: 100}) + tmpls, _ := s.ListSetupTemplates() + var lr SetupTemplate + for _, tm := range tmpls { + if tm.Name == "Living Room" { + lr = tm + break + } + } + res, err := s.ApplyTemplate(p.ID, lr.ID, ApplyTemplateOptions{}) + if err != nil { + t.Fatalf("apply: %v", err) + } + if len(res.FramesAdded) != 1 { + t.Fatalf("frames added = %d, want 1", len(res.FramesAdded)) + } + if res.FramesAdded[0].Name != "Living Room 2" { + t.Errorf("frame name = %q, want %q (suffixed)", res.FramesAdded[0].Name, "Living Room 2") + } +} + func TestApplyTemplate_NameCollisionSkipped(t *testing.T) { s := newTestStore(t) p, _ := s.CreateProject("LOFT", "", "")