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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user