Configurable grid + snap-to-grid in the layout system (#45)
Build image / build-and-push (push) Successful in 7s

Closes #44. Two independent grids (garden + per-bed), each with a size and a snap toggle; snapping defaults off. See PR #45 for details.

Co-authored-by: Steve Dudenhoeffer <[email protected]>
This commit was merged in pull request #45.
This commit is contained in:
2026-07-19 07:07:14 +00:00
committed by steve
parent 9968c06243
commit e74fb308c1
23 changed files with 581 additions and 88 deletions
+70 -10
View File
@@ -1,5 +1,5 @@
import { useEffect, useId, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react'
import { screenToWorld, worldToLocal, type Rect, type Size } from '@/lib/geometry'
import { screenToWorld, snapLocalToBedGrid, snapPoint, worldToLocal, type Rect, type Size } from '@/lib/geometry'
import { useCreateObject, useCreatePlanting } from '@/lib/objects'
import type { Plant } from '@/lib/plants'
import type { EditorPlanting } from '@/lib/plantings'
@@ -13,15 +13,26 @@ import { useEditorStore } from './store'
import { useViewport } from './useViewport'
import type { EditorGarden, EditorObject } from './types'
const GRID_CM = 100 // 1 m grid
const GRID_MIN_CELL_PX = 6
const GRID_MAX_OPACITY = 0.18
const BED_GRID_MAX_LINES = 200 // safety cap on lines drawn inside one bed
/** The world-space bounds rect of an object (center + size → top-left rect). */
function objectRect(o: EditorObject): Rect {
return { x: o.xCm - o.widthCm / 2, y: o.yCm - o.heightCm / 2, w: o.widthCm, h: o.heightCm }
}
/** Grid-line offsets (from a bed's top-left corner, in the object-local frame)
* for a bed of the given half-extents and grid step. Starts at the corner so the
* lines match snapLocalToBedGrid, and is capped so a tiny grid on a big bed can't
* emit thousands of lines. Returns cm offsets along one axis. */
function bedGridLines(half: number, step: number): number[] {
if (!(step > 0) || (2 * half) / step > BED_GRID_MAX_LINES) return []
const lines: number[] = []
for (let v = -half; v <= half + 1e-6; v += step) lines.push(v)
return lines
}
/**
* The editor canvas. Pan/zoom/pinch, tap-to-place objects, select+move/resize/
* rotate an object, and — the #15 core — focus into a plantable object to place,
@@ -88,7 +99,8 @@ export function GardenCanvas({
fitToRect(target ? objectRect(target) : gardenRect, size)
}, [size, garden.id, garden.widthCm, garden.heightCm, focusedObjectId, objects, gardenRect, fitToRect])
const cellPx = GRID_CM * viewport.scale
const gridCm = garden.gridSizeCm
const cellPx = gridCm * viewport.scale
const gridOpacity = Math.max(0, Math.min(GRID_MAX_OPACITY, ((cellPx - GRID_MIN_CELL_PX) / GRID_MIN_CELL_PX) * GRID_MAX_OPACITY))
const sorted = useMemo(() => [...objects].sort((a, b) => a.zIndex - b.zIndex), [objects])
@@ -117,7 +129,9 @@ export function GardenCanvas({
const def = kindDef(armed)
const rect = svgRef.current?.getBoundingClientRect()
if (!def || !rect) return
const world = screenToWorld({ x: e.clientX - rect.left, y: e.clientY - rect.top }, viewport)
let world = screenToWorld({ x: e.clientX - rect.left, y: e.clientY - rect.top }, viewport)
// Snap the new object's center to the garden grid when the garden opts in.
if (garden.snapToGrid) world = snapPoint(world, gridCm)
createObject.mutate(
{ kind: def.kind, shape: def.shape, xCm: world.x, yCm: world.y, widthCm: def.widthCm, heightCm: def.heightCm, zIndex: def.defaultZ },
{ onSuccess: (o) => select(o.id) },
@@ -143,8 +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 x = Math.max(-focusedObject.widthCm / 2, Math.min(focusedObject.widthCm / 2, local.x))
const y = Math.max(-focusedObject.heightCm / 2, Math.min(focusedObject.heightCm / 2, local.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).
@@ -154,6 +174,20 @@ export function GardenCanvas({
const halfFW = focusedObject ? focusedObject.widthCm / 2 : 0
const halfFH = focusedObject ? focusedObject.heightCm / 2 : 0
// 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.
// 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 (
<div ref={containerRef} className="relative h-full w-full overflow-hidden rounded-xl border border-border bg-bg">
<svg
@@ -166,8 +200,8 @@ export function GardenCanvas({
{gridOpacity > 0.005 && (
<>
<defs>
<pattern id={gridId} width={GRID_CM} height={GRID_CM} patternUnits="userSpaceOnUse">
<path d={`M ${GRID_CM} 0 L 0 0 0 ${GRID_CM}`} fill="none" stroke="#808080" strokeOpacity={gridOpacity} strokeWidth={1} vectorEffect="non-scaling-stroke" />
<pattern id={gridId} width={gridCm} height={gridCm} patternUnits="userSpaceOnUse">
<path d={`M ${gridCm} 0 L 0 0 0 ${gridCm}`} fill="none" stroke="#808080" strokeOpacity={gridOpacity} strokeWidth={1} vectorEffect="non-scaling-stroke" />
</pattern>
</defs>
<rect x={0} y={0} width={garden.widthCm} height={garden.heightCm} fill={`url(#${gridId})`} />
@@ -182,6 +216,17 @@ export function GardenCanvas({
</g>
))}
{focusedObject && bedGrid && (
<g transform={objectTransform(focusedObject)} pointerEvents="none">
{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" />
))}
{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" />
))}
</g>
)}
<PlopLayer
objects={rendered}
plantings={renderedPlops}
@@ -194,9 +239,24 @@ export function GardenCanvas({
{/* Edit handles are mounted only for editors/owners; viewers can still
select to inspect (read-only), but never move/resize. */}
{canEdit && selectedObject && <SelectionOverlay object={selectedObject} gardenId={garden.id} svgRef={svgRef} />}
{canEdit && selectedObject && (
<SelectionOverlay
object={selectedObject}
gardenId={garden.id}
svgRef={svgRef}
snap={garden.snapToGrid}
gridCm={gridCm}
/>
)}
{canEdit && selectedPlop && selectedPlopObject && (
<PlopOverlay plop={selectedPlop} object={selectedPlopObject} gardenId={garden.id} svgRef={svgRef} />
<PlopOverlay
plop={selectedPlop}
object={selectedPlopObject}
gardenId={garden.id}
svgRef={svgRef}
snap={selectedPlopObject.snapToGrid}
gridCm={selectedPlopObject.gridSizeCm}
/>
)}
{/* Placement capture: a transparent sheet over the focused object while a