Configurable grid + snap-to-grid in the layout system #45

Merged
steve merged 2 commits from feat/layout-grid into main 2026-07-19 07:07:15 +00:00
6 changed files with 51 additions and 47 deletions
Showing only changes of commit 677dbf0756 - Show all commits
+3 -1
View File
@@ -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 // gardenFromInput validates and normalizes input into a domain.Garden (without
// identity/ownership fields). With applyDefaults, absent dimensions/unit are // 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) { func gardenFromInput(in GardenInput, applyDefaults bool) (*domain.Garden, error) {
name := strings.TrimSpace(in.Name) name := strings.TrimSpace(in.Name)
if name == "" || len(name) > maxGardenNameLen { if name == "" || len(name) > maxGardenNameLen {
+21 -15
View File
1
@@ -157,15 +157,14 @@ export function GardenCanvas({
if (!rect) return if (!rect) return
const world = screenToWorld({ x: e.clientX - rect.left, y: e.clientY - rect.top }, viewport) 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 local = worldToLocal(world, { x: focusedObject.xCm, y: focusedObject.yCm }, focusedObject.rotationDeg)
const halfW = focusedObject.widthCm / 2 // snapLocalToBedGrid clamps to the bed either way; step 0 (snapping off) makes
const halfH = focusedObject.heightCm / 2 // it a pure clamp, so both paths go through one helper.
// Snap the plant to the bed's grid when the bed opts in; snapLocalToBedGrid const { x, y } = snapLocalToBedGrid(
// also clamps, so the plain clamp covers the non-snapping case. local,
const placed = focusedObject.snapToGrid focusedObject.snapToGrid ? focusedObject.gridSizeCm : 0,
? snapLocalToBedGrid(local, focusedObject.gridSizeCm, halfW, halfH) focusedObject.widthCm / 2,
: { x: Math.max(-halfW, Math.min(halfW, local.x)), y: Math.max(-halfH, Math.min(halfH, local.y)) } focusedObject.heightCm / 2,
const x = placed.x )
const y = placed.y
const radiusCm = Math.max(1.5 * armedPlant.spacingCm, 15) const radiusCm = Math.max(1.5 * armedPlant.spacingCm, 15)
// Stay armed for repeat-placement; don't select (the placement sheet covers // Stay armed for repeat-placement; don't select (the placement sheet covers
// the object, so a selection would be hidden until placement ends anyway). // 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, // 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 // 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. // frame (origin at center), anchored to the corner to match snapLocalToBedGrid.
const showBedGrid = !!focusedObject && focusedObject.plantable && focusedObject.snapToGrid // Memoized so a high-frequency plop drag (which re-renders the canvas on every
const bedVLines = showBedGrid ? bedGridLines(halfFW, focusedObject.gridSizeCm) : [] // pointermove) doesn't rebuild the line arrays each frame. null when the focused
const bedHLines = showBedGrid ? bedGridLines(halfFH, focusedObject.gridSizeCm) : [] // 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 ( return (
<div ref={containerRef} className="relative h-full w-full overflow-hidden rounded-xl border border-border bg-bg"> <div ref={containerRef} className="relative h-full w-full overflow-hidden rounded-xl border border-border bg-bg">
@@ -210,12 +216,12 @@ export function GardenCanvas({
</g> </g>
))} ))}
{showBedGrid && focusedObject && ( {focusedObject && bedGrid && (
<g transform={objectTransform(focusedObject)} pointerEvents="none"> <g transform={objectTransform(focusedObject)} pointerEvents="none">
{bedVLines.map((x) => ( {bedGrid.v.map((x) => (
<line key={`v${x}`} x1={x} y1={-halfFH} x2={x} y2={halfFH} stroke="#3f8f4f" strokeOpacity={0.3} strokeWidth={1} vectorEffect="non-scaling-stroke" /> <line key={`v${x}`} x1={x} y1={-halfFH} x2={x} y2={halfFH} stroke="#3f8f4f" strokeOpacity={0.3} strokeWidth={1} vectorEffect="non-scaling-stroke" />
))} ))}
{bedHLines.map((y) => ( {bedGrid.h.map((y) => (
<line key={`h${y}`} x1={-halfFW} y1={y} x2={halfFW} y2={y} stroke="#3f8f4f" strokeOpacity={0.3} strokeWidth={1} vectorEffect="non-scaling-stroke" /> <line key={`h${y}`} x1={-halfFW} y1={y} x2={halfFW} y2={y} stroke="#3f8f4f" strokeOpacity={0.3} strokeWidth={1} vectorEffect="non-scaling-stroke" />
))} ))}
</g> </g>
+3 -7
View File
@@ -63,11 +63,6 @@ export function PlopOverlay({
} }
} }
const clampLocal = (p: Point): Point => ({
x: Math.max(-halfW, Math.min(halfW, p.x)),
y: Math.max(-halfH, Math.min(halfH, p.y)),
})
const begin = ( const begin = (
e: ReactPointerEvent, e: ReactPointerEvent,
onMove: (e: PointerEvent) => EditorPlanting, onMove: (e: PointerEvent) => EditorPlanting,
@@ -110,8 +105,9 @@ export function PlopOverlay({
(ev) => { (ev) => {
const p = ptr(ev) const p = ptr(ev)
const moved: Point = { x: base.xCm + (p.x - start.x), y: base.yCm + (p.y - start.y) } const moved: Point = { x: base.xCm + (p.x - start.x), y: base.yCm + (p.y - start.y) }
// snapLocalToBedGrid clamps too, so the plain clamp covers the non-snap case. // snapLocalToBedGrid clamps either way; step 0 (snapping off) makes it a
const next = snap ? snapLocalToBedGrid(moved, gridCm, halfW, halfH) : clampLocal(moved) // pure clamp, so both paths go through one helper.
const next = snapLocalToBedGrid(moved, snap ? gridCm : 0, halfW, halfH)
return { ...base, xCm: next.x, yCm: next.y } return { ...base, xCm: next.x, yCm: next.y }
}, },
(f) => ({ id: base.id, version: base.version, xCm: f.xCm, yCm: f.yCm }), (f) => ({ id: base.id, version: base.version, xCm: f.xCm, yCm: f.yCm }),
+6 -6
View File
@@ -91,12 +91,12 @@ export function snapPoint(p: Point, step: number): Point {
* clamped, not snapped. * clamped, not snapped.
*/ */
export function snapLocalToBedGrid(p: Point, step: number, halfW: number, halfH: number): Point { export function snapLocalToBedGrid(p: Point, step: number, halfW: number, halfH: number): Point {
Review

🟡 snapLocalToBedGrid duplicates the anchor-snap formula per axis instead of reusing snapValue-style factoring

maintainability · flagged by 1 model

  • web/src/lib/geometry.ts:93 (snapLocalToBedGrid) — The inline ternary step > 0 ? -halfW + Math.round((p.x + halfW) / step) * step : p.x interleaves snapping and passthrough per axis, duplicating the anchored-snap formula across both axes. snapValue/snapPoint already exist for the origin-anchored case; a snapToCorner(v, origin, step) helper (or snapValue(p.x + halfW, step) - halfW) would deduplicate and match the existing "anchor the snap, then clamp" shape. Verified by reading…

🪰 Gadfly · advisory

🟡 **snapLocalToBedGrid duplicates the anchor-snap formula per axis instead of reusing snapValue-style factoring** _maintainability · flagged by 1 model_ - **`web/src/lib/geometry.ts:93` (`snapLocalToBedGrid`)** — The inline ternary `step > 0 ? -halfW + Math.round((p.x + halfW) / step) * step : p.x` interleaves snapping and passthrough per axis, duplicating the anchored-snap formula across both axes. `snapValue`/`snapPoint` already exist for the origin-anchored case; a `snapToCorner(v, origin, step)` helper (or `snapValue(p.x + halfW, step) - halfW`) would deduplicate and match the existing "anchor the snap, then clamp" shape. Verified by reading… <sub>🪰 Gadfly · advisory</sub>
const sx = step > 0 ? -halfW + Math.round((p.x + halfW) / step) * step : p.x // Snap one axis to the grid anchored at -half (the bed's near edge), then clamp
const sy = step > 0 ? -halfH + Math.round((p.y + halfH) / step) * step : p.y // to [-half, half]. step<=0 skips the snap, so callers can pass step 0 to get a
return { // pure clamp — the non-snapping placement/move path — through the same helper.
x: Math.max(-halfW, Math.min(halfW, sx)), const snapAxis = (v: number, half: number) =>
y: Math.max(-halfH, Math.min(halfH, sy)), Math.max(-half, Math.min(half, step > 0 ? -half + Math.round((v + half) / step) * step : v))
} return { x: snapAxis(p.x, halfW), y: snapAxis(p.y, halfH) }
} }
/** Linear interpolation. */ /** Linear interpolation. */