diff --git a/internal/domain/domain.go b/internal/domain/domain.go index 2c2a327..1183185 100644 --- a/internal/domain/domain.go +++ b/internal/domain/domain.go @@ -120,13 +120,13 @@ type Session struct { // Garden is a planning surface owned by one user, at real-world scale (cm). type Garden struct { - ID int64 `json:"id"` - OwnerID int64 `json:"ownerId"` - Name string `json:"name"` - WidthCM float64 `json:"widthCm"` - HeightCM float64 `json:"heightCm"` - UnitPref string `json:"unitPref"` - Notes string `json:"notes"` + ID int64 `json:"id"` + OwnerID int64 `json:"ownerId"` + Name string `json:"name"` + WidthCM float64 `json:"widthCm"` + HeightCM float64 `json:"heightCm"` + UnitPref string `json:"unitPref"` + Notes string `json:"notes"` // GridSizeCM is the garden-scale grid spacing (cm) the editor draws its grid // from; SnapToGrid, when true, snaps objects to that grid on place/move/resize. GridSizeCM float64 `json:"gridSizeCm"` @@ -183,9 +183,9 @@ type GardenObject struct { GridSizeCM float64 `json:"gridSizeCm"` SnapToGrid bool `json:"snapToGrid"` Notes string `json:"notes"` - Version int64 `json:"version"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` + Version int64 `json:"version"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` } // Plant is a catalog entry. OwnerID nil means a read-only seeded built-in. diff --git a/internal/service/gardens.go b/internal/service/gardens.go index d062889..460b3d7 100644 --- a/internal/service/gardens.go +++ b/internal/service/gardens.go @@ -166,7 +166,9 @@ func (s *Service) DeleteGarden(ctx context.Context, actorID, gardenID int64) err // gardenFromInput validates and normalizes input into a domain.Garden (without // identity/ownership fields). With applyDefaults, absent dimensions/unit are -// filled in (create); without it, every field must be supplied (update). +// filled in (create); without it, dimensions and unit must be supplied (update). +// The grid size is the exception: 0 always means "unset" and is defaulted on both +// paths, so an older client that omits it can't wipe the column to an invalid 0. func gardenFromInput(in GardenInput, applyDefaults bool) (*domain.Garden, error) { name := strings.TrimSpace(in.Name) if name == "" || len(name) > maxGardenNameLen { diff --git a/web/src/editor/GardenCanvas.tsx b/web/src/editor/GardenCanvas.tsx index d7ae5ac..90be8f6 100644 --- a/web/src/editor/GardenCanvas.tsx +++ b/web/src/editor/GardenCanvas.tsx @@ -157,15 +157,14 @@ export function GardenCanvas({ if (!rect) return const world = screenToWorld({ x: e.clientX - rect.left, y: e.clientY - rect.top }, viewport) const local = worldToLocal(world, { x: focusedObject.xCm, y: focusedObject.yCm }, focusedObject.rotationDeg) - const halfW = focusedObject.widthCm / 2 - const halfH = focusedObject.heightCm / 2 - // Snap the plant to the bed's grid when the bed opts in; snapLocalToBedGrid - // also clamps, so the plain clamp covers the non-snapping case. - const placed = focusedObject.snapToGrid - ? snapLocalToBedGrid(local, focusedObject.gridSizeCm, halfW, halfH) - : { x: Math.max(-halfW, Math.min(halfW, local.x)), y: Math.max(-halfH, Math.min(halfH, local.y)) } - const x = placed.x - const y = placed.y + // snapLocalToBedGrid clamps to the bed either way; step 0 (snapping off) makes + // it a pure clamp, so both paths go through one helper. + const { x, y } = snapLocalToBedGrid( + local, + focusedObject.snapToGrid ? focusedObject.gridSizeCm : 0, + focusedObject.widthCm / 2, + focusedObject.heightCm / 2, + ) const radiusCm = Math.max(1.5 * armedPlant.spacingCm, 15) // Stay armed for repeat-placement; don't select (the placement sheet covers // the object, so a selection would be hidden until placement ends anyway). @@ -178,9 +177,16 @@ export function GardenCanvas({ // Draw the bed's own grid inside a focused plantable bed that has snapping on, // so the user sees exactly where plants will land. Lines are in the bed's local // frame (origin at center), anchored to the corner to match snapLocalToBedGrid. - const showBedGrid = !!focusedObject && focusedObject.plantable && focusedObject.snapToGrid - const bedVLines = showBedGrid ? bedGridLines(halfFW, focusedObject.gridSizeCm) : [] - const bedHLines = showBedGrid ? bedGridLines(halfFH, focusedObject.gridSizeCm) : [] + // Memoized so a high-frequency plop drag (which re-renders the canvas on every + // pointermove) doesn't rebuild the line arrays each frame. null when the focused + // object isn't a snapping plantable bed. + const bedGrid = useMemo(() => { + if (!focusedObject || !focusedObject.plantable || !focusedObject.snapToGrid) return null + return { + v: bedGridLines(focusedObject.widthCm / 2, focusedObject.gridSizeCm), + h: bedGridLines(focusedObject.heightCm / 2, focusedObject.gridSizeCm), + } + }, [focusedObject]) return (