Add canvas foundation: SVG viewport, pan/zoom/pinch, geometry lib (#9)
The editor's technically-riskiest slice, built against mock data so #11 only adds interactions on top. - lib/geometry.ts (+ vitest): world<->screen and object-local<->world transforms, clampScale, zoomViewportAt (the wheel/pinch "keep the point under the cursor stationary" math), zoomToFitRect. 11 geometry tests (round-trips, rotation, cursor-anchored zoom, fit) + 6 units tests. - editor/store.ts: Zustand store for ephemeral editor state — viewport, selection, focusedObjectId (selection interactions grow in #11). - editor/useViewport.ts: @use-gesture wiring — wheel + pinch zoom toward the pointer, drag-pan, scale clamped to [0.05, 20] px/cm, and an animated fitToRect (eased rAF tween). touch-action:none so the browser doesn't fight gestures. - editor/ObjectShape.tsx: rect/circle centered + rotated, kind default colors, name label; non-scaling strokes so outlines stay 1px at any zoom. - editor/GardenCanvas.tsx: full-size svg, single world <g>, fading 1m grid, garden boundary, z-ordered objects; ResizeObserver-driven auto-fit, a zoom readout, and a Fit control. Deps: zustand, @use-gesture/react, vitest (+ test scripts). - GardenEditorPage renders the canvas with a mock scene (#11 swaps in /full). Verified in a real browser: wheel zoom keeps the world point under the cursor stationary (sub-cm drift on a 12m garden), a real drag pans, and Fit animates to frame the garden. tsc + vitest green. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
clampScale,
|
||||
localToWorld,
|
||||
screenToWorld,
|
||||
worldToLocal,
|
||||
worldToScreen,
|
||||
zoomToFitRect,
|
||||
zoomViewportAt,
|
||||
type Viewport,
|
||||
} from './geometry'
|
||||
|
||||
const vp: Viewport = { tx: 100, ty: 50, scale: 2 }
|
||||
|
||||
describe('world/screen transforms', () => {
|
||||
it('round-trips a point', () => {
|
||||
const p = { x: 37.5, y: -12.25 }
|
||||
const back = screenToWorld(worldToScreen(p, vp), vp)
|
||||
expect(back.x).toBeCloseTo(p.x, 9)
|
||||
expect(back.y).toBeCloseTo(p.y, 9)
|
||||
})
|
||||
|
||||
it('applies scale then translate', () => {
|
||||
expect(worldToScreen({ x: 10, y: 10 }, vp)).toEqual({ x: 120, y: 70 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('object-local/world transforms', () => {
|
||||
const center = { x: 500, y: 300 }
|
||||
|
||||
it('round-trips through any rotation', () => {
|
||||
for (const deg of [0, 30, 90, 180, 270, 45, -60, 123.4]) {
|
||||
const local = { x: 40, y: -25 }
|
||||
const back = worldToLocal(localToWorld(local, center, deg), center, deg)
|
||||
expect(back.x).toBeCloseTo(local.x, 6)
|
||||
expect(back.y).toBeCloseTo(local.y, 6)
|
||||
}
|
||||
})
|
||||
|
||||
it('rotates 90° clockwise on screen (y down)', () => {
|
||||
// A point 10cm to the object's right, rotated 90°, lands 10cm below center.
|
||||
const w = localToWorld({ x: 10, y: 0 }, center, 90)
|
||||
expect(w.x).toBeCloseTo(center.x, 6)
|
||||
expect(w.y).toBeCloseTo(center.y + 10, 6)
|
||||
})
|
||||
|
||||
it('local origin maps to the object center', () => {
|
||||
expect(localToWorld({ x: 0, y: 0 }, center, 37)).toEqual(center)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clampScale', () => {
|
||||
it('bounds the value', () => {
|
||||
expect(clampScale(0.001, 0.05, 20)).toBe(0.05)
|
||||
expect(clampScale(100, 0.05, 20)).toBe(20)
|
||||
expect(clampScale(3, 0.05, 20)).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('zoomViewportAt', () => {
|
||||
it('keeps the point under the cursor stationary', () => {
|
||||
const cursor = { x: 640, y: 360 }
|
||||
const before = screenToWorld(cursor, vp)
|
||||
const next = zoomViewportAt(vp, cursor, vp.scale * 1.25, 0.05, 20)
|
||||
const after = screenToWorld(cursor, next)
|
||||
expect(after.x).toBeCloseTo(before.x, 6)
|
||||
expect(after.y).toBeCloseTo(before.y, 6)
|
||||
expect(next.scale).toBeCloseTo(2.5, 9)
|
||||
})
|
||||
|
||||
it('clamps the scale', () => {
|
||||
expect(zoomViewportAt(vp, { x: 0, y: 0 }, 1000, 0.05, 20).scale).toBe(20)
|
||||
})
|
||||
})
|
||||
|
||||
describe('zoomToFitRect', () => {
|
||||
it('centers the rect and fits it within padding', () => {
|
||||
const viewport = { w: 800, h: 600 }
|
||||
const rect = { x: 0, y: 0, w: 1000, h: 1000 }
|
||||
const fit = zoomToFitRect(rect, viewport, 20, 0.05, 20)
|
||||
// Limiting dimension is height: (600-40)/1000 = 0.56.
|
||||
expect(fit.scale).toBeCloseTo(0.56, 9)
|
||||
// The rect's center (500,500) should map to the viewport center (400,300).
|
||||
const c = worldToScreen({ x: 500, y: 500 }, fit)
|
||||
expect(c.x).toBeCloseTo(400, 6)
|
||||
expect(c.y).toBeCloseTo(300, 6)
|
||||
})
|
||||
|
||||
it('respects the min/max scale clamp', () => {
|
||||
const fit = zoomToFitRect({ x: 0, y: 0, w: 1, h: 1 }, { w: 800, h: 600 }, 0, 0.05, 20)
|
||||
expect(fit.scale).toBe(20)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,117 @@
|
||||
// Pure geometry for the editor. Everything is centimeters in "world" space
|
||||
// (garden origin top-left, x→right, y→down) except the viewport transform, which
|
||||
// maps world → screen pixels. Kept dependency-free and unit-tested; the canvas
|
||||
// and gesture code build on top.
|
||||
|
||||
export interface Point {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export interface Size {
|
||||
w: number
|
||||
h: number
|
||||
}
|
||||
|
||||
/** Axis-aligned rectangle by top-left corner + size, in world cm. */
|
||||
export interface Rect {
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
}
|
||||
|
||||
/** The viewport: world→screen is `screen = world * scale + t`. scale is px/cm. */
|
||||
export interface Viewport {
|
||||
tx: number
|
||||
ty: number
|
||||
scale: number
|
||||
}
|
||||
|
||||
export function worldToScreen(p: Point, vp: Viewport): Point {
|
||||
return { x: p.x * vp.scale + vp.tx, y: p.y * vp.scale + vp.ty }
|
||||
}
|
||||
|
||||
export function screenToWorld(p: Point, vp: Viewport): Point {
|
||||
return { x: (p.x - vp.tx) / vp.scale, y: (p.y - vp.ty) / vp.scale }
|
||||
}
|
||||
|
||||
/**
|
||||
* A point in an object's local frame (origin at the object's center, axes
|
||||
* aligned to the unrotated object) → garden world coordinates. rotationDeg is
|
||||
* clockwise (screen y grows downward, so a positive angle rotates clockwise on
|
||||
* screen). Plops live in this local frame, so moving/rotating the parent object
|
||||
* moves them for free.
|
||||
*/
|
||||
export function localToWorld(local: Point, center: Point, rotationDeg: number): Point {
|
||||
const r = (rotationDeg * Math.PI) / 180
|
||||
const cos = Math.cos(r)
|
||||
const sin = Math.sin(r)
|
||||
return {
|
||||
x: center.x + local.x * cos - local.y * sin,
|
||||
y: center.y + local.x * sin + local.y * cos,
|
||||
}
|
||||
}
|
||||
|
||||
/** Inverse of localToWorld: garden world point → the object's local frame. */
|
||||
export function worldToLocal(world: Point, center: Point, rotationDeg: number): Point {
|
||||
const r = (rotationDeg * Math.PI) / 180
|
||||
const cos = Math.cos(r)
|
||||
const sin = Math.sin(r)
|
||||
const dx = world.x - center.x
|
||||
const dy = world.y - center.y
|
||||
return { x: dx * cos + dy * sin, y: -dx * sin + dy * cos }
|
||||
}
|
||||
|
||||
/** Clamp a scale (px/cm) into [min, max]. */
|
||||
export function clampScale(scale: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, scale))
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-scale the viewport while keeping the world point currently under
|
||||
* `screenPoint` stationary — the math behind wheel-zoom-to-cursor and pinch. The
|
||||
* new scale is clamped to [minScale, maxScale].
|
||||
*/
|
||||
export function zoomViewportAt(
|
||||
vp: Viewport,
|
||||
screenPoint: Point,
|
||||
nextScale: number,
|
||||
minScale: number,
|
||||
maxScale: number,
|
||||
): Viewport {
|
||||
const scale = clampScale(nextScale, minScale, maxScale)
|
||||
const world = screenToWorld(screenPoint, vp)
|
||||
return {
|
||||
scale,
|
||||
tx: screenPoint.x - world.x * scale,
|
||||
ty: screenPoint.y - world.y * scale,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The viewport that frames `rect` centered in a viewport of `viewport` pixels,
|
||||
* with `padding` px of margin on every side. Scale is clamped to [minScale,
|
||||
* maxScale] so a tiny/huge rect still lands in a sane zoom.
|
||||
*/
|
||||
export function zoomToFitRect(
|
||||
rect: Rect,
|
||||
viewport: Size,
|
||||
padding: number,
|
||||
minScale: number,
|
||||
maxScale: number,
|
||||
): Viewport {
|
||||
const availW = Math.max(1, viewport.w - padding * 2)
|
||||
const availH = Math.max(1, viewport.h - padding * 2)
|
||||
const rectW = Math.max(rect.w, 1e-6)
|
||||
const rectH = Math.max(rect.h, 1e-6)
|
||||
const scale = clampScale(Math.min(availW / rectW, availH / rectH), minScale, maxScale)
|
||||
|
||||
const centerX = rect.x + rect.w / 2
|
||||
const centerY = rect.y + rect.h / 2
|
||||
return {
|
||||
scale,
|
||||
tx: viewport.w / 2 - centerX * scale,
|
||||
ty: viewport.h / 2 - centerY * scale,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { cmFromFtIn, cmFromMeters, displayFromCm, formatCm, formatDimensions } from './units'
|
||||
|
||||
describe('cm conversions', () => {
|
||||
it('feet+inches → whole cm', () => {
|
||||
expect(cmFromFtIn(4)).toBe(122) // 4ft = 121.92 → 122
|
||||
expect(cmFromFtIn(8)).toBe(244)
|
||||
expect(cmFromFtIn(4, 6)).toBe(137) // 4'6" = 137.16 → 137
|
||||
})
|
||||
|
||||
it('meters → whole cm', () => {
|
||||
expect(cmFromMeters(10)).toBe(1000)
|
||||
expect(cmFromMeters(1.22)).toBe(122)
|
||||
})
|
||||
})
|
||||
|
||||
describe('displayFromCm', () => {
|
||||
it('metric keeps cm precision', () => {
|
||||
expect(displayFromCm(1000, 'metric')).toBe(10)
|
||||
expect(displayFromCm(122, 'metric')).toBe(1.22)
|
||||
})
|
||||
|
||||
it('imperial rounds to 0.1 ft so integers stay clean', () => {
|
||||
expect(displayFromCm(122, 'imperial')).toBe(4) // not 4.0026
|
||||
expect(displayFromCm(244, 'imperial')).toBe(8) // not 8.01
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatCm', () => {
|
||||
it('metric shows meters', () => {
|
||||
expect(formatCm(122, 'metric')).toBe('1.22 m')
|
||||
expect(formatCm(1000, 'metric')).toBe('10 m')
|
||||
})
|
||||
|
||||
it('imperial shows feet and inches, carrying 12″ up', () => {
|
||||
expect(formatCm(122, 'imperial')).toBe('4′ 0″')
|
||||
expect(formatCm(244, 'imperial')).toBe('8′ 0″')
|
||||
expect(formatCm(137, 'imperial')).toBe('4′ 6″')
|
||||
})
|
||||
|
||||
it('formats width × height', () => {
|
||||
expect(formatDimensions(122, 244, 'imperial')).toBe('4′ 0″ × 8′ 0″')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user