Configurable grid + snap-to-grid in the layout system (#45)
Build image / build-and-push (push) Successful in 7s
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:
@@ -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
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user