merge: template-apply creates frame + grid-places devices inside

ApplyTemplate now creates a frame named after the template
('Living Room' etc, suffixed on collision), computes a uniform grid
(cols=min(ceil(sqrt(N)),4), rows=ceil(N/cols)), and places each
device inside the frame with frame_id set.

Frontend unchanged — activateProject re-hydrates the snapshot
including the new frame.

Tests cover frame creation + in-frame placement + name-collision
suffix. Verified on mDock: Living Room template → frame (200,200,
294×200) with TV/Soundbar at row 0 and ChromeCast wrapping to row 1.
This commit is contained in:
mAi
2026-05-16 11:35:25 +02:00
3 changed files with 181 additions and 12 deletions

View File

@@ -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 {

View File

@@ -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 "<name> 2", "<name> 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.

View File

@@ -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", "", "")