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
+55 -1
View File
@@ -9,13 +9,17 @@ import { errorMessage } from '@/lib/api'
import { conflictGarden, useCreateGarden, useUpdateGarden, type Garden } from '@/lib/gardens'
import {
cmFromDisplay,
cmFromSpacing,
dimensionUnitLabel,
displayFromCm,
isValidDimensionCm,
spacingFromCm,
spacingUnitLabel,
type UnitPref,
} from '@/lib/units'
const DEFAULT_METERS = 10 // matches the server's 10 m default
const DEFAULT_GRID_CM = 100 // matches the server's 1 m grid default
const unitOptions = [
{ value: 'metric', label: 'Metric (m)' },
@@ -42,6 +46,10 @@ export function GardenFormModal({ garden, onClose }: { garden?: Garden; onClose:
const [unit, setUnit] = useState<UnitPref>(garden?.unitPref ?? 'metric')
const [width, setWidth] = useState(() => dimString(garden?.widthCm, garden?.unitPref ?? 'metric'))
const [height, setHeight] = useState(() => dimString(garden?.heightCm, garden?.unitPref ?? 'metric'))
const [gridSize, setGridSize] = useState(() =>
String(spacingFromCm(garden?.gridSizeCm ?? DEFAULT_GRID_CM, garden?.unitPref ?? 'metric')),
)
const [snapToGrid, setSnapToGrid] = useState(garden?.snapToGrid ?? false)
const [notes, setNotes] = useState(garden?.notes ?? '')
const [version, setVersion] = useState(garden?.version ?? 0)
const [conflict, setConflict] = useState<string | null>(null)
@@ -52,8 +60,15 @@ export function GardenFormModal({ garden, onClose }: { garden?: Garden; onClose:
const v = parseFloat(s)
return Number.isFinite(v) ? String(displayFromCm(cmFromDisplay(v, unit), next)) : s
}
// The grid is entered at spacing scale (cm/in), so it converts with the
// spacing helpers, not the dimension ones.
const convertGrid = (s: string) => {
const v = parseFloat(s)
return Number.isFinite(v) ? String(spacingFromCm(cmFromSpacing(v, unit), next)) : s
}
setWidth(convert(width))
setHeight(convert(height))
setGridSize(convertGrid(gridSize))
setUnit(next)
}
@@ -75,8 +90,21 @@ export function GardenFormModal({ garden, onClose }: { garden?: Garden; onClose:
setFormError('Width and height must be between 1 cm and 100 m.')
return
}
const gridSizeCm = cmFromSpacing(parseFloat(gridSize), unit)
if (!isValidDimensionCm(gridSizeCm)) {
setFormError('Grid size must be between 1 cm and 100 m.')
return
}
const input = { name: name.trim(), widthCm, heightCm, unitPref: unit, notes: notes.trim() }
const input = {
name: name.trim(),
widthCm,
heightCm,
unitPref: unit,
notes: notes.trim(),
gridSizeCm,
snapToGrid,
}
try {
if (isEdit) {
@@ -95,6 +123,8 @@ export function GardenFormModal({ garden, onClose }: { garden?: Garden; onClose:
setUnit(current.unitPref)
setWidth(dimString(current.widthCm, current.unitPref))
setHeight(dimString(current.heightCm, current.unitPref))
setGridSize(String(spacingFromCm(current.gridSizeCm, current.unitPref)))
setSnapToGrid(current.snapToGrid)
setNotes(current.notes)
setConflict('This garden changed elsewhere. The latest values are shown — review and save again.')
return
@@ -145,6 +175,30 @@ export function GardenFormModal({ garden, onClose }: { garden?: Garden; onClose:
/>
</div>
<div className="flex items-end gap-3">
<div className="flex-1">
<TextField
label={`Grid size (${spacingUnitLabel(unit)})`}
name="gridSize"
type="number"
inputMode="decimal"
step="any"
min="0"
value={gridSize}
onChange={(e) => setGridSize(e.target.value)}
/>
</div>
<label className="flex h-9 items-center gap-2 whitespace-nowrap text-sm text-fg">
<input
type="checkbox"
checked={snapToGrid}
onChange={(e) => setSnapToGrid(e.target.checked)}
className="h-4 w-4 rounded border-border"
/>
Snap objects
</label>
</div>
<TextArea label="Notes" name="notes" rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} />
{formError && <Alert>{formError}</Alert>}
+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
+54 -9
View File
@@ -5,9 +5,12 @@ import { TextArea } from '@/components/ui/TextArea'
import { useDeleteObject, useUpdateObject } from '@/lib/objects'
import {
cmFromDisplay,
cmFromSpacing,
dimensionUnitLabel,
displayFromCm,
MIN_DIMENSION_CM,
spacingFromCm,
spacingUnitLabel,
type UnitPref,
} from '@/lib/units'
import { kindDef } from './kinds'
@@ -48,6 +51,7 @@ export function Inspector({
const [y, setY] = useState(String(displayFromCm(object.yCm, unit)))
const [rotation, setRotation] = useState(String(Math.round(object.rotationDeg)))
const [color, setColor] = useState(object.color ?? DEFAULT_COLOR)
const [gridSize, setGridSize] = useState(String(spacingFromCm(object.gridSizeCm, unit)))
const [confirmingDelete, setConfirmingDelete] = useState(false)
const rootRef = useRef<HTMLDivElement>(null)
@@ -64,7 +68,8 @@ export function Inspector({
setY(String(displayFromCm(object.yCm, unit)))
setRotation(String(Math.round(object.rotationDeg)))
setColor(object.color ?? DEFAULT_COLOR)
}, [object.version, object.widthCm, object.heightCm, object.xCm, object.yCm, object.rotationDeg, object.name, object.notes, object.color, unit])
setGridSize(String(spacingFromCm(object.gridSizeCm, unit)))
}, [object.version, object.widthCm, object.heightCm, object.xCm, object.yCm, object.rotationDeg, object.name, object.notes, object.color, object.gridSizeCm, unit])
const patch = (fields: Partial<Omit<EditorObject, 'id' | 'version'>>) => {
if (readOnly) return // a blur mustn't fire a mutation if the role changed mid-edit
@@ -84,6 +89,17 @@ export function Inspector({
if (cm !== current) apply(cm)
}
// Commit the bed grid size (entered at spacing scale, cm/in). Compare at display
// precision so a blur without an edit doesn't fire a spurious PATCH; ignore a
// sub-1cm value the server would reject.
const commitGrid = () => {
const v = parseFloat(gridSize)
if (!Number.isFinite(v)) return
if (v === spacingFromCm(object.gridSizeCm, unit)) return
const cm = cmFromSpacing(v, unit)
if (cm >= MIN_DIMENSION_CM && cm !== object.gridSizeCm) patch({ gridSizeCm: cm })
}
const u = dimensionUnitLabel(unit)
return (
@@ -218,14 +234,43 @@ export function Inspector({
Plantable
</label>
<TextArea
label="Notes"
name="notes"
rows={2}
value={notes}
onChange={(e) => setNotes(e.target.value)}
onBlur={() => notes !== object.notes && patch({ notes })}
/>
{/* Bed grid: only plantable beds place plants, so the plant-snapping grid
is shown just for them. */}
{object.plantable && (
<div className="flex items-end gap-2">
<div className="flex-1">
<TextField
label={`Bed grid (${spacingUnitLabel(unit)})`}
name="gridSize"
type="number"
inputMode="decimal"
step="any"
min="0"
value={gridSize}
onChange={(e) => setGridSize(e.target.value)}
onBlur={commitGrid}
/>
</div>
<label className="flex h-9 items-center gap-2 whitespace-nowrap text-sm text-fg">
<input
type="checkbox"
checked={object.snapToGrid}
onChange={(e) => patch({ snapToGrid: e.target.checked })}
className="h-4 w-4 rounded border-border"
/>
Snap plants
</label>
</div>
)}
<TextArea
label="Notes"
name="notes"
rows={2}
value={notes}
onChange={(e) => setNotes(e.target.value)}
onBlur={() => notes !== object.notes && patch({ notes })}
/>
</fieldset>
{!readOnly &&
+11 -7
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, type PointerEvent as ReactPointerEvent, type RefObject } from 'react'
import { screenToWorld, worldToLocal, type Point } from '@/lib/geometry'
import { screenToWorld, snapLocalToBedGrid, worldToLocal, type Point } from '@/lib/geometry'
import { useUpdatePlanting } from '@/lib/objects'
import type { EditorPlanting } from '@/lib/plantings'
import { HANDLE_PX, MIN_RADIUS_CM, SELECT_COLOR, objectTransform } from './shared'
@@ -20,11 +20,17 @@ export function PlopOverlay({
object,
gardenId,
svgRef,
snap,
gridCm,
}: {
plop: EditorPlanting
object: EditorObject
gardenId: number
svgRef: RefObject<SVGSVGElement | null>
// The parent bed's grid: when snap is true, moving the plop snaps it to the
// bed grid (radius stays free either way).
snap: boolean
gridCm: number
}) {
const setLivePlanting = useEditorStore((s) => s.setLivePlanting)
const setObjectDragging = useEditorStore((s) => s.setObjectDragging)
@@ -57,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 = (
e: ReactPointerEvent,
onMove: (e: PointerEvent) => EditorPlanting,
@@ -103,7 +104,10 @@ export function PlopOverlay({
e,
(ev) => {
const p = ptr(ev)
const next = clampLocal({ 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 either way; step 0 (snapping off) makes it a
// 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 }
},
(f) => ({ id: base.id, version: base.version, xCm: f.xCm, yCm: f.yCm }),
+19 -4
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, type PointerEvent as ReactPointerEvent, type RefObject } from 'react'
import { localToWorld, screenToWorld, worldToLocal, type Point } from '@/lib/geometry'
import { localToWorld, screenToWorld, snapPoint, snapValue, worldToLocal, type Point } from '@/lib/geometry'
import { useUpdateObject } from '@/lib/objects'
import { HANDLE_PX, SELECT_COLOR, objectTransform } from './shared'
import { useEditorStore } from './store'
@@ -27,10 +27,16 @@ export function SelectionOverlay({
object,
gardenId,
svgRef,
snap,
gridCm,
}: {
object: EditorObject
gardenId: number
svgRef: RefObject<SVGSVGElement | null>
// The garden grid: when snap is true, a move snaps the object's center to it and
// a resize snaps width/height to whole grid steps (the opposite corner stays put).
snap: boolean
gridCm: number
}) {
const setLiveObject = useEditorStore((s) => s.setLiveObject)
const setObjectDragging = useEditorStore((s) => s.setObjectDragging)
@@ -112,7 +118,9 @@ export function SelectionOverlay({
e,
(ev) => {
const world = pointerWorld(ev)
return { ...base, xCm: base.xCm + (world.x - start.x), yCm: base.yCm + (world.y - start.y) }
const next: Point = { x: base.xCm + (world.x - start.x), y: base.yCm + (world.y - start.y) }
const c = snap ? snapPoint(next, gridCm) : next
return { ...base, xCm: c.x, yCm: c.y }
},
(f) => ({ id: base.id, version: base.version, xCm: f.xCm, yCm: f.yCm }),
)
@@ -128,8 +136,15 @@ export function SelectionOverlay({
(ev) => {
const world = pointerWorld(ev)
const p = worldToLocal(world, center0, base.rotationDeg)
const newW = Math.max(MIN_OBJ_CM, Math.abs(p.x - oppositeLocal.x))
const newH = Math.max(MIN_OBJ_CM, Math.abs(p.y - oppositeLocal.y))
let newW = Math.max(MIN_OBJ_CM, Math.abs(p.x - oppositeLocal.x))
let newH = Math.max(MIN_OBJ_CM, Math.abs(p.y - oppositeLocal.y))
// Snap dimensions to whole grid steps (at least one cell). The opposite
// corner is the anchor, so it stays fixed while the dragged corner lands
// on a grid-multiple size.
if (snap) {
newW = Math.max(gridCm, snapValue(newW, gridCm))
newH = Math.max(gridCm, snapValue(newH, gridCm))
}
const draggedLocal: Point = { x: oppositeLocal.x + sx * newW, y: oppositeLocal.y + sy * newH }
const newCenterLocal: Point = {
x: (oppositeLocal.x + draggedLocal.x) / 2,
+7
View File
@@ -17,6 +17,10 @@ export interface EditorObject {
rotationDeg: number
zIndex: number
color?: string | null
// This object's local grid spacing (cm) and whether plants snap to it inside
// the bed. Only meaningful for plantable objects.
gridSizeCm: number
snapToGrid: boolean
notes: string
plantable: boolean
version: number
@@ -29,4 +33,7 @@ export interface EditorGarden {
widthCm: number
heightCm: number
unitPref: UnitPref
// The garden-scale grid the canvas draws, and whether objects snap to it.
gridSizeCm: number
snapToGrid: boolean
}
+4
View File
@@ -19,6 +19,8 @@ export const gardenSchema = z.object({
heightCm: z.number(),
unitPref: unitPrefSchema,
notes: z.string(),
gridSizeCm: z.number(),
snapToGrid: z.boolean(),
version: z.number(),
createdAt: z.string(),
updatedAt: z.string(),
@@ -55,6 +57,8 @@ export interface GardenInput {
heightCm: number
unitPref: UnitPref
notes: string
gridSizeCm: number
snapToGrid: boolean
}
export function useCreateGarden() {
+37
View File
@@ -3,6 +3,9 @@ import {
clampScale,
localToWorld,
screenToWorld,
snapLocalToBedGrid,
snapPoint,
snapValue,
worldToLocal,
worldToScreen,
zoomToFitRect,
@@ -91,3 +94,37 @@ describe('zoomToFitRect', () => {
expect(fit.scale).toBe(20)
})
})
describe('grid snapping', () => {
it('snaps a scalar to the nearest multiple of the step', () => {
expect(snapValue(37, 30)).toBe(30)
expect(snapValue(46, 30)).toBe(60)
expect(snapValue(-46, 30)).toBe(-60)
expect(snapValue(15, 30)).toBe(30) // .5 rounds up
})
it('leaves a scalar unchanged for a non-positive step', () => {
expect(snapValue(37, 0)).toBe(37)
expect(snapValue(37, -5)).toBe(37)
expect(snapValue(37, NaN)).toBe(37)
})
it('snaps a world point to the origin-anchored garden grid', () => {
expect(snapPoint({ x: 37, y: -46 }, 30)).toEqual({ x: 30, y: -60 })
})
it('snaps a local point to the bed grid anchored at the top-left corner', () => {
// A 120×80 bed (halfW=60, halfH=40), 30cm grid: lines at x=-60,-30,0,30,60
// and y=-40,-10,20 (from the corner, then +30 while ≤ halfH).
expect(snapLocalToBedGrid({ x: 5, y: 2 }, 30, 60, 40)).toEqual({ x: 0, y: -10 })
expect(snapLocalToBedGrid({ x: 22, y: 18 }, 30, 60, 40)).toEqual({ x: 30, y: 20 })
})
it('clamps a snapped bed point back inside the bed bounds', () => {
// Near the far corner, rounding would land at x=90 (> halfW=60); clamp to 60.
const p = snapLocalToBedGrid({ x: 100, y: 100 }, 30, 60, 40)
expect(p.x).toBeLessThanOrEqual(60)
expect(p.y).toBeLessThanOrEqual(40)
expect(p.x).toBeGreaterThanOrEqual(-60)
})
})
+31
View File
@@ -68,6 +68,37 @@ export function clampScale(scale: number, min: number, max: number): number {
return Math.min(max, Math.max(min, scale))
}
/** Snap a scalar to the nearest multiple of step (measured from 0). A step of 0
* or less (or a non-finite value) leaves v unchanged, so callers can pass the
* grid size straight through without guarding. */
export function snapValue(v: number, step: number): number {
if (!(step > 0)) return v
return Math.round(v / step) * step
}
/** Snap a world point to the nearest garden-grid intersection. The garden grid
* is anchored at the origin (0,0), which is exactly where the canvas draws it,
* so a snapped point always lands on a visible grid line. */
export function snapPoint(p: Point, step: number): Point {
return { x: snapValue(p.x, step), y: snapValue(p.y, step) }
}
/**
* Snap a point given in a bed's local frame (origin at the bed's center) to the
* bed's grid. The grid is anchored at the bed's top-left corner (-halfW,-halfH)
* — matching the grid lines the canvas draws inside the bed — and the result is
* clamped to the bed bounds so a plant never snaps outside it. step<=0 → only
* clamped, not snapped.
*/
export function snapLocalToBedGrid(p: Point, step: number, halfW: number, halfH: number): Point {
// Snap one axis to the grid anchored at -half (the bed's near edge), then clamp
// to [-half, half]. step<=0 skips the snap, so callers can pass step 0 to get a
// pure clamp — the non-snapping placement/move path — through the same helper.
const snapAxis = (v: number, half: number) =>
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. */
export function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t
+10
View File
@@ -30,6 +30,8 @@ export const serverObjectSchema = z.object({
plantable: z.boolean(),
color: z.string().nullable().optional(),
props: z.string().nullable().optional(),
gridSizeCm: z.number(),
snapToGrid: z.boolean(),
notes: z.string(),
version: z.number(),
})
@@ -59,6 +61,8 @@ export function toEditorObject(o: ServerObject): EditorObject {
rotationDeg: o.rotationDeg,
zIndex: o.zIndex,
color: o.color ?? null,
gridSizeCm: o.gridSizeCm,
snapToGrid: o.snapToGrid,
notes: o.notes,
plantable: o.plantable,
version: o.version,
@@ -110,6 +114,8 @@ export interface ObjectCreate {
zIndex?: number
plantable?: boolean
color?: string | null
gridSizeCm?: number
snapToGrid?: boolean
}
export function useCreateObject(gardenId: number) {
@@ -138,6 +144,8 @@ export interface ObjectPatch {
zIndex?: number
plantable?: boolean
color?: string | null
gridSizeCm?: number
snapToGrid?: boolean
notes?: string
}
@@ -392,6 +400,8 @@ function applyServerPatch(o: ServerObject, patch: ObjectPatch): ServerObject {
...(patch.zIndex !== undefined ? { zIndex: patch.zIndex } : {}),
...(patch.plantable !== undefined ? { plantable: patch.plantable } : {}),
...(patch.color !== undefined ? { color: patch.color } : {}),
...(patch.gridSizeCm !== undefined ? { gridSizeCm: patch.gridSizeCm } : {}),
...(patch.snapToGrid !== undefined ? { snapToGrid: patch.snapToGrid } : {}),
...(patch.notes !== undefined ? { notes: patch.notes } : {}),
}
}
+2
View File
@@ -228,6 +228,8 @@ export function GardenEditorPage() {
widthCm: g.widthCm,
heightCm: g.heightCm,
unitPref: g.unitPref,
gridSizeCm: g.gridSizeCm,
snapToGrid: g.snapToGrid,
}
const selectedObject = objects.find((o) => o.id === selectedId) ?? null
+2
View File
@@ -48,6 +48,8 @@ export function PublicGardenPage() {
widthCm: g.widthCm,
heightCm: g.heightCm,
unitPref: g.unitPref,
gridSizeCm: g.gridSizeCm,
snapToGrid: g.snapToGrid,
}
return (