Configurable grid + snap-to-grid in the layout system #45
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|
|||||||
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. */
|
||||||
|
|||||||
Reference in New Issue
Block a user
🟡 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 ternarystep > 0 ? -halfW + Math.round((p.x + halfW) / step) * step : p.xinterleaves snapping and passthrough per axis, duplicating the anchored-snap formula across both axes.snapValue/snapPointalready exist for the origin-anchored case; asnapToCorner(v, origin, step)helper (orsnapValue(p.x + halfW, step) - halfW) would deduplicate and match the existing "anchor the snap, then clamp" shape. Verified by reading…🪰 Gadfly · advisory