diff --git a/CLAUDE.md b/CLAUDE.md index 82a9ae9..4d13884 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -98,6 +98,9 @@ Numbered for unambiguous reference; do not cite rule numbers in shipped source o want the same. A minimal local guard that duplicates existing logic is a defect, not a small change. Any non-trivial engine or cross-cutting change gets a short written design first (its canonical home, what it extends, what it must not duplicate) for owner approval before implementation. + Interim ("quick fix now, proper fix later") solutions are forbidden in all cases: deferred fixes + are forgotten and the interim state becomes permanent, so the correct structure is built + immediately, even when it costs a schema change or a larger diff. 11. **Subagent discipline.** Give every subagent a correct, specific title; never run more than 1 Fable agent at a time (hard budget limit). Sonnet is fine for parallel design/research work. diff --git a/README.md b/README.md index 92b01e4..307d472 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,11 @@ The stack is Vue 3, TypeScript, Vite, Vuetify, and Pinia. All geometry generatio Gridfinity bin geometry constants are ported from the MIT-licensed [kennetek/gridfinity-rebuilt-openscad](https://github.com/kennetek/gridfinity-rebuilt-openscad). +The 2D sketch workspace is powered by the FreeCAD PlaneGCS constraint solver, compiled to +WebAssembly by the LGPL-2.1-licensed +[Salusoft89/planegcs](https://github.com/Salusoft89/planegcs). The `planegcs.wasm` binary +ships as a separate, replaceable asset, as the LGPL requires. + ## Contributing Issues and pull requests are welcome at [github.com/jaak0b/StoreForge](https://github.com/jaak0b/StoreForge). diff --git a/docs/superpowers/plans/2026-07-24-sketch-workspace.md b/docs/superpowers/plans/2026-07-24-sketch-workspace.md new file mode 100644 index 0000000..daae39e --- /dev/null +++ b/docs/superpowers/plans/2026-07-24-sketch-workspace.md @@ -0,0 +1,3662 @@ +# Sketch Workspace Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A parametric 2D sketch workspace (lines, arcs, circles, constraints, dimensions) that produces the same millimeter outline a photo trace produces and feeds the existing tool pocket pipeline unchanged. + +**Architecture:** A new framework-agnostic engine module `web/src/engine/sketch/` (model, PlaneGCS solver adapter, profile extraction), a dedicated Comlink worker owning the PlaneGCS WASM, an origin discriminator on `TracedTool` (photo vs sketch, sketched tools embed their editable `Sketch`), plan file version 11, and an SVG sketch editor inside the Tool trace tab behind an upload-or-draw toggle. + +**Tech Stack:** Vue 3 + TypeScript + Vite + Pinia, Comlink workers, `@salusoft89/planegcs` (LGPL-2.1, WASM), Vitest. + +## Global Constraints + +- Engine code (`web/src/engine/`) must not import Vue or Pinia or touch the DOM (CLAUDE.md convention 3). Modules that need WASM take the loaded instance as a parameter. +- No silently swallowed errors; user-fixable problems are returned as user-worded messages, never raw exceptions (convention 2). +- Every branch on a discriminated union handles every member and ends in `assertNever` from `web/src/engine/plan/types.ts` (convention 13). +- Never use the em-dash character anywhere, including comments and UI text (convention 6). +- UI text is plain technical prose in complete sentences; diagnostic readouts are labeled rows (conventions 7, 8). +- Validation messages in `planFile.ts` follow the file's documented convention: optional lowercase subject prefix, then exactly one complete sentence ending in a full stop. +- Geometry math must be established methods, named as such; no hand-tuned fudge factors (conventions 1, 12). +- Arc flattening uses the trace pipeline's existing 0.2 mm tolerance (single source, convention 10). +- Never compute a value the codebase already derives elsewhere (convention 10). +- Commit messages: a single short sentence, `Co-Authored-By: Claude ` trailer allowed, no other AI attribution (convention 4). +- All commands run from `web/`. Verification bar: `npm run build` and `npm test` green. +- Splines and ellipses are out of scope (spec V1 scope). + +## Shared type reference + +These names are used across tasks; Task 2 defines them in `web/src/engine/sketch/model.ts`: + +- `SKETCH_SCHEMA_VERSION = 1` +- `Sketch { schemaVersion: number; entities: SketchEntity[]; constraints: SketchConstraint[] }` +- `SketchEntity = SketchPoint | SketchLine | SketchArc | SketchCircle` (discriminated on `kind`) +- `SketchConstraint` (discriminated on `kind`): `coincident`, `horizontal`, `vertical`, `parallel`, `perpendicular`, `tangent`, `symmetric`, `length`, `distance`, `radius`, `diameter`, `angle` +- `validateSketch(raw: unknown, subject: string): string | null` +- `deserializeSketch(raw: unknown): { ok: true; sketch: Sketch } | { ok: false; error: string }` +- `cloneSketch(sketch: Sketch): Sketch` +- `arcFromThreePoints(start, mid, end): { center: MmPoint; ccw: boolean } | null` + +Task 3 defines in `web/src/engine/sketch/solve.ts`: + +- `DragTarget { pointId: string; xMm: number; yMm: number }` +- `SketchSolveResult = { status: 'solved'; sketch: Sketch; dof: number } | { status: 'conflicting'; conflictingConstraintIds: string[] } | { status: 'failed'; message: string }` +- `solveSketch(wrapper: GcsWrapper, sketch: Sketch, drag?: DragTarget): SketchSolveResult` + +Task 4 defines in `web/src/engine/sketch/profile.ts`: + +- `ProfileResult = { ok: true; outline: TracedOutline } | { ok: false; error: string }` +- `extractProfile(sketch: Sketch): ProfileResult` + +Task 6 defines in `web/src/engine/trace/types.ts`: + +- `ToolSource = { kind: 'photo' } | { kind: 'sketch'; sketch: Sketch }` and a required `source: ToolSource` field on `TracedTool`. + +--- + +### Task 1: PlaneGCS dependency, node smoke test, LGPL attribution + +**Files:** +- Modify: `web/package.json` (add dependency) +- Create: `web/tests/helpers/planegcs.ts` +- Test: `web/tests/sketch/planegcsSmoke.spec.ts` +- Modify: `README.md` (attribution paragraph next to the existing kennetek attribution at lines 65-66) + +**Interfaces:** +- Consumes: nothing from earlier tasks. +- Produces: `loadGcsWrapper(): Promise` in `web/tests/helpers/planegcs.ts`, used by Task 3 tests. Confirms the exact import names `init_planegcs_module`, `GcsWrapper` resolve from the package root. + +- [ ] **Step 1: Install the dependency** + +Run from `web/`: + +```bash +npm install @salusoft89/planegcs +``` + +Expected: `package.json` gains `"@salusoft89/planegcs"` under `dependencies`. + +- [ ] **Step 2: Write the failing smoke test and its helper** + +Create `web/tests/helpers/planegcs.ts`: + +```typescript +import { fileURLToPath } from 'node:url'; +import { init_planegcs_module, GcsWrapper } from '@salusoft89/planegcs'; + +/** + * Loads the PlaneGCS WASM from node_modules for node-side tests, the same + * disk-loading pattern tests/vision/visionSmoke.spec.ts uses for the + * MobileSAM models. In the browser the sketch worker resolves the wasm with + * a Vite ?url import instead. + */ +export async function loadGcsWrapper(): Promise { + const wasmPath = fileURLToPath( + new URL( + '../../node_modules/@salusoft89/planegcs/dist/planegcs_dist/planegcs.wasm', + import.meta.url, + ), + ); + const mod = await init_planegcs_module({ locateFile: () => wasmPath }); + return new GcsWrapper(new mod.GcsSystem()); +} +``` + +Create `web/tests/sketch/planegcsSmoke.spec.ts`: + +```typescript +import { describe, expect, it } from 'vitest'; +import { loadGcsWrapper } from '../helpers/planegcs'; + +// The sketch worker itself cannot run under node (Comlink), so this smoke +// test exercises the same library it loads, following the vision smoke test +// pattern in tests/vision/visionSmoke.spec.ts. + +describe('planegcs wasm', () => { + it('loads the wasm and solves a one-constraint system', async () => { + const wrapper = await loadGcsWrapper(); + wrapper.push_primitives_and_params([ + { id: '1', type: 'point', x: 0, y: 0, fixed: true }, + { id: '2', type: 'point', x: 3, y: 4, fixed: false }, + { id: '3', type: 'p2p_distance', p1_id: '1', p2_id: '2', distance: 10 }, + ]); + const status = wrapper.solve(); + expect(status).toBeLessThanOrEqual(1); // Success (0) or Converged (1) + wrapper.apply_solution(); + const p2 = wrapper.sketch_index.get_primitive_or_fail('2') as { + x: number; + y: number; + }; + expect(Math.hypot(p2.x, p2.y)).toBeCloseTo(10, 6); + expect(wrapper.gcs.dof()).toBe(1); // a point on a circle has one dof left + wrapper.destroy_gcs_module(); + }); +}); +``` + +Note: if `init_planegcs_module` or `GcsWrapper` fail to resolve from the package root, import them from `@salusoft89/planegcs/dist/index` instead; check `node_modules/@salusoft89/planegcs/package.json` `exports` and use the documented entry. Do not vendor the files. + +- [ ] **Step 3: Run the test to verify current behavior** + +Run: `npx vitest run tests/sketch/planegcsSmoke.spec.ts` +Expected: PASS (the test fails only if the install or the API names are wrong; fix the import path per the note above until it passes). + +- [ ] **Step 4: Add the LGPL attribution** + +In `README.md`, directly after the kennetek attribution paragraph (currently lines 65-66), add: + +```markdown +The 2D sketch workspace is powered by the FreeCAD PlaneGCS constraint solver, compiled to +WebAssembly by the LGPL-2.1-licensed +[Salusoft89/planegcs](https://github.com/Salusoft89/planegcs). The `planegcs.wasm` binary +ships as a separate, replaceable asset, as the LGPL requires. +``` + +- [ ] **Step 5: Commit** + +```bash +git add package.json package-lock.json tests/helpers/planegcs.ts tests/sketch/planegcsSmoke.spec.ts ../README.md +git commit -m "Add the PlaneGCS solver dependency with a node smoke test and LGPL attribution." +``` + +--- + +### Task 2: Sketch datatype, validation, serialization + +**Files:** +- Create: `web/src/engine/sketch/model.ts` +- Test: `web/tests/sketch/model.spec.ts` + +**Interfaces:** +- Consumes: `MmPoint` from `web/src/engine/trace/types.ts`, `assertNever` from `web/src/engine/plan/types.ts`. +- Produces: everything in the "Shared type reference" block for model.ts, used by Tasks 3, 4, 5, 6, 7, 8. + +- [ ] **Step 1: Write the failing tests** + +Create `web/tests/sketch/model.spec.ts`: + +```typescript +import { describe, expect, it } from 'vitest'; +import { + SKETCH_SCHEMA_VERSION, + arcFromThreePoints, + cloneSketch, + deserializeSketch, + validateSketch, + type Sketch, +} from '../../src/engine/sketch/model'; + +/** A valid dimensioned unit square sketch used across the model tests. */ +export function squareSketch(): Sketch { + return { + schemaVersion: SKETCH_SCHEMA_VERSION, + entities: [ + { kind: 'point', id: 'pA', x: 0, y: 0, construction: false }, + { kind: 'point', id: 'pB', x: 10, y: 0, construction: false }, + { kind: 'point', id: 'pC', x: 10, y: 10, construction: false }, + { kind: 'point', id: 'pD', x: 0, y: 10, construction: false }, + { kind: 'line', id: 'lAB', p1Id: 'pA', p2Id: 'pB', construction: false }, + { kind: 'line', id: 'lBC', p1Id: 'pB', p2Id: 'pC', construction: false }, + { kind: 'line', id: 'lCD', p1Id: 'pC', p2Id: 'pD', construction: false }, + { kind: 'line', id: 'lDA', p1Id: 'pD', p2Id: 'pA', construction: false }, + ], + constraints: [ + { kind: 'horizontal', id: 'c1', lineId: 'lAB' }, + { kind: 'vertical', id: 'c2', lineId: 'lBC' }, + { kind: 'length', id: 'c3', lineId: 'lAB', mm: 10 }, + { kind: 'length', id: 'c4', lineId: 'lBC', mm: 10 }, + ], + }; +} + +describe('validateSketch', () => { + it('accepts a valid sketch', () => { + expect(validateSketch(squareSketch(), 'sketch')).toBeNull(); + }); + + it('rejects a duplicate entity id with a user-worded message', () => { + const sketch = squareSketch(); + sketch.entities.push({ kind: 'point', id: 'pA', x: 1, y: 1, construction: false }); + expect(validateSketch(sketch, 'sketch')).toBe( + 'sketch: The sketch id pA appears twice.', + ); + }); + + it('rejects a line whose endpoint is not a point', () => { + const sketch = squareSketch(); + sketch.entities.push({ kind: 'line', id: 'lX', p1Id: 'lAB', p2Id: 'pA', construction: false }); + expect(validateSketch(sketch, 'sketch')).toBe( + 'sketch: The line lX must connect two sketch points.', + ); + }); + + it('rejects a constraint referring to a missing entity', () => { + const sketch = squareSketch(); + sketch.constraints.push({ kind: 'horizontal', id: 'cX', lineId: 'nope' }); + expect(validateSketch(sketch, 'sketch')).toBe( + 'sketch: The constraint cX refers to geometry that is not in the sketch.', + ); + }); + + it('rejects a tangent constraint between two lines', () => { + const sketch = squareSketch(); + sketch.constraints.push({ kind: 'tangent', id: 'cT', aId: 'lAB', bId: 'lBC' }); + expect(validateSketch(sketch, 'sketch')).toBe( + 'sketch: The tangent constraint cT needs an arc or circle on at least one side.', + ); + }); + + it('rejects a non-positive dimension', () => { + const sketch = squareSketch(); + sketch.constraints.push({ kind: 'radius', id: 'cR', entityId: 'lAB', mm: -1 }); + expect(validateSketch(sketch, 'sketch')).toBe( + 'sketch: The radius constraint cR needs an arc or a circle.', + ); + }); + + it('rejects an unknown schema version', () => { + const sketch = { ...squareSketch(), schemaVersion: 999 }; + expect(validateSketch(sketch, 'sketch')).toBe( + `sketch: The sketch has schema version 999, but this app reads version ${SKETCH_SCHEMA_VERSION}.`, + ); + }); +}); + +describe('deserializeSketch', () => { + it('round-trips through JSON', () => { + const original = squareSketch(); + const result = deserializeSketch(JSON.parse(JSON.stringify(original))); + expect(result.ok).toBe(true); + if (result.ok) expect(result.sketch).toEqual(original); + }); + + it('returns the validation message for a broken value', () => { + const result = deserializeSketch({ schemaVersion: SKETCH_SCHEMA_VERSION }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toBe('sketch: The entities must be a list.'); + }); +}); + +describe('cloneSketch', () => { + it('produces an equal sketch sharing no objects', () => { + const original = squareSketch(); + const copy = cloneSketch(original); + expect(copy).toEqual(original); + expect(copy.entities[0]).not.toBe(original.entities[0]); + expect(copy.constraints[0]).not.toBe(original.constraints[0]); + }); +}); + +describe('arcFromThreePoints', () => { + it('finds the circumcenter and orientation of a counterclockwise arc', () => { + const arc = arcFromThreePoints({ x: 10, y: 0 }, { x: 0, y: -10 }, { x: -10, y: 0 }); + expect(arc).not.toBeNull(); + expect(arc!.center.x).toBeCloseTo(0, 9); + expect(arc!.center.y).toBeCloseTo(0, 9); + expect(arc!.ccw).toBe(false); + }); + + it('reports clockwise for the mirrored point order', () => { + const arc = arcFromThreePoints({ x: 10, y: 0 }, { x: 0, y: 10 }, { x: -10, y: 0 }); + expect(arc!.ccw).toBe(true); + }); + + it('returns null for collinear points', () => { + expect(arcFromThreePoints({ x: 0, y: 0 }, { x: 5, y: 0 }, { x: 10, y: 0 })).toBeNull(); + }); +}); +``` + +Note on orientation: sketch coordinates follow the trace convention (y increases downward, see `MmPoint` in `engine/trace/types.ts`), so a positive cross product is a clockwise turn on screen; `ccw` here means mathematically counterclockwise in the y-down frame, i.e. cross product of (mid-start) x (end-start) is negative. The test values above encode exactly that. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `npx vitest run tests/sketch/model.spec.ts` +Expected: FAIL with "Cannot find module '../../src/engine/sketch/model'". + +- [ ] **Step 3: Implement model.ts** + +Create `web/src/engine/sketch/model.ts`: + +```typescript +// The parametric 2D sketch datatype of the sketch workspace. Plain JSON +// throughout so a sketch serializes inside a plan file. All coordinates are +// millimeters in the trace frame (y increasing downward). The sketch carries +// its own schema version so the format can evolve without a plan version +// bump each time. +import type { MmPoint } from '../trace/types'; +import { assertNever } from '../plan/types'; + +export const SKETCH_SCHEMA_VERSION = 1; + +/** A sketch point, the only entity carrying coordinates. */ +export interface SketchPoint { + kind: 'point'; + id: string; + x: number; + y: number; + construction: boolean; +} + +/** A line segment between two sketch points. */ +export interface SketchLine { + kind: 'line'; + id: string; + p1Id: string; + p2Id: string; + construction: boolean; +} + +/** + * A circular arc running counterclockwise (in the y-down mm frame) from the + * start point to the end point about the center point. Radius and angles are + * deliberately not stored: they are derived from the three points when the + * solver needs them, so there is a single source for the arc's shape. + */ +export interface SketchArc { + kind: 'arc'; + id: string; + centerId: string; + startId: string; + endId: string; + construction: boolean; +} + +/** A full circle about a center point. */ +export interface SketchCircle { + kind: 'circle'; + id: string; + centerId: string; + radiusMm: number; + construction: boolean; +} + +export type SketchEntity = SketchPoint | SketchLine | SketchArc | SketchCircle; + +export interface CoincidentConstraint { + kind: 'coincident'; + id: string; + p1Id: string; + p2Id: string; +} +export interface HorizontalConstraint { + kind: 'horizontal'; + id: string; + lineId: string; +} +export interface VerticalConstraint { + kind: 'vertical'; + id: string; + lineId: string; +} +export interface ParallelConstraint { + kind: 'parallel'; + id: string; + l1Id: string; + l2Id: string; +} +export interface PerpendicularConstraint { + kind: 'perpendicular'; + id: string; + l1Id: string; + l2Id: string; +} +/** Tangency between a line, arc or circle pair; at most one side may be a line. */ +export interface TangentConstraint { + kind: 'tangent'; + id: string; + aId: string; + bId: string; +} +/** Two points mirrored across a line (usually a construction line). */ +export interface SymmetricConstraint { + kind: 'symmetric'; + id: string; + p1Id: string; + p2Id: string; + mirrorLineId: string; +} +export interface LengthDimension { + kind: 'length'; + id: string; + lineId: string; + mm: number; +} +export interface DistanceDimension { + kind: 'distance'; + id: string; + p1Id: string; + p2Id: string; + mm: number; +} +export interface RadiusDimension { + kind: 'radius'; + id: string; + entityId: string; + mm: number; +} +export interface DiameterDimension { + kind: 'diameter'; + id: string; + entityId: string; + mm: number; +} +export interface AngleDimension { + kind: 'angle'; + id: string; + l1Id: string; + l2Id: string; + degrees: number; +} + +export type SketchConstraint = + | CoincidentConstraint + | HorizontalConstraint + | VerticalConstraint + | ParallelConstraint + | PerpendicularConstraint + | TangentConstraint + | SymmetricConstraint + | LengthDimension + | DistanceDimension + | RadiusDimension + | DiameterDimension + | AngleDimension; + +/** The dimension subset of the constraints, for the click-to-edit labels. */ +export type SketchDimension = + | LengthDimension + | DistanceDimension + | RadiusDimension + | DiameterDimension + | AngleDimension; + +export interface Sketch { + schemaVersion: number; + entities: SketchEntity[]; + constraints: SketchConstraint[]; +} + +/** An empty sketch at the current schema version. */ +export function emptySketch(): Sketch { + return { schemaVersion: SKETCH_SCHEMA_VERSION, entities: [], constraints: [] }; +} + +/** Deep copy through JSON; a Sketch is plain JSON by construction. */ +export function cloneSketch(sketch: Sketch): Sketch { + return JSON.parse(JSON.stringify(sketch)) as Sketch; +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value); +} + +function isId(value: unknown): value is string { + return typeof value === 'string' && value.length > 0; +} + +/** + * Validates a raw value as a Sketch. Returns null when valid, otherwise one + * user-worded sentence prefixed with the given subject, following the plan + * file's validation message convention. + */ +export function validateSketch(raw: unknown, subject: string): string | null { + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { + return `${subject}: The sketch must be an object.`; + } + const sketch = raw as Record; + if (sketch.schemaVersion !== SKETCH_SCHEMA_VERSION) { + return ( + `${subject}: The sketch has schema version ${String(sketch.schemaVersion)}, ` + + `but this app reads version ${SKETCH_SCHEMA_VERSION}.` + ); + } + if (!Array.isArray(sketch.entities)) { + return `${subject}: The entities must be a list.`; + } + if (!Array.isArray(sketch.constraints)) { + return `${subject}: The constraints must be a list.`; + } + const kinds = new Map(); + for (const rawEntity of sketch.entities) { + if (typeof rawEntity !== 'object' || rawEntity === null || Array.isArray(rawEntity)) { + return `${subject}: A sketch entity is not an object.`; + } + const entity = rawEntity as Record; + if (!isId(entity.id)) { + return `${subject}: A sketch entity is missing its id.`; + } + if (kinds.has(entity.id)) { + return `${subject}: The sketch id ${entity.id} appears twice.`; + } + if (typeof entity.construction !== 'boolean') { + return `${subject}: The entity ${entity.id} is missing its construction flag.`; + } + const kind = entity.kind as SketchEntity['kind']; + switch (kind) { + case 'point': + if (!isFiniteNumber(entity.x) || !isFiniteNumber(entity.y)) { + return `${subject}: The point ${entity.id} needs finite x and y coordinates in mm.`; + } + break; + case 'line': + if (!isId(entity.p1Id) || !isId(entity.p2Id)) { + return `${subject}: The line ${entity.id} must connect two sketch points.`; + } + break; + case 'arc': + if (!isId(entity.centerId) || !isId(entity.startId) || !isId(entity.endId)) { + return `${subject}: The arc ${entity.id} needs a center, a start and an end point.`; + } + break; + case 'circle': + if (!isId(entity.centerId)) { + return `${subject}: The circle ${entity.id} needs a center point.`; + } + if (!isFiniteNumber(entity.radiusMm) || entity.radiusMm <= 0) { + return `${subject}: The circle ${entity.id} needs a radius above 0 mm.`; + } + break; + default: + return `${subject}: The entity kind must be point, line, arc or circle.`; + } + kinds.set(entity.id, kind); + } + const isPoint = (id: unknown): boolean => isId(id) && kinds.get(id) === 'point'; + const isLine = (id: unknown): boolean => isId(id) && kinds.get(id) === 'line'; + const isCurveOrCircle = (id: unknown): boolean => + isId(id) && (kinds.get(id) === 'arc' || kinds.get(id) === 'circle'); + // Second pass: entity references resolve to the right kinds. + for (const entity of sketch.entities as Record[]) { + const kind = entity.kind as SketchEntity['kind']; + switch (kind) { + case 'point': + break; + case 'line': + if (!isPoint(entity.p1Id) || !isPoint(entity.p2Id)) { + return `${subject}: The line ${entity.id} must connect two sketch points.`; + } + break; + case 'arc': + if (!isPoint(entity.centerId) || !isPoint(entity.startId) || !isPoint(entity.endId)) { + return `${subject}: The arc ${entity.id} needs a center, a start and an end point.`; + } + break; + case 'circle': + if (!isPoint(entity.centerId)) { + return `${subject}: The circle ${entity.id} needs a center point.`; + } + break; + default: + return assertNever(kind); + } + } + const constraintIds = new Set(); + for (const rawConstraint of sketch.constraints) { + if ( + typeof rawConstraint !== 'object' || + rawConstraint === null || + Array.isArray(rawConstraint) + ) { + return `${subject}: A sketch constraint is not an object.`; + } + const c = rawConstraint as Record; + if (!isId(c.id)) { + return `${subject}: A sketch constraint is missing its id.`; + } + if (constraintIds.has(c.id) || kinds.has(c.id)) { + return `${subject}: The sketch id ${c.id} appears twice.`; + } + constraintIds.add(c.id); + const missing = `${subject}: The constraint ${c.id} refers to geometry that is not in the sketch.`; + const kind = c.kind as SketchConstraint['kind']; + switch (kind) { + case 'coincident': + if (!kinds.has(c.p1Id as string) || !kinds.has(c.p2Id as string)) return missing; + if (!isPoint(c.p1Id) || !isPoint(c.p2Id)) { + return `${subject}: The coincident constraint ${c.id} needs two points.`; + } + break; + case 'horizontal': + case 'vertical': + if (!kinds.has(c.lineId as string)) return missing; + if (!isLine(c.lineId)) { + return `${subject}: The ${kind} constraint ${c.id} needs a line.`; + } + break; + case 'parallel': + case 'perpendicular': + if (!kinds.has(c.l1Id as string) || !kinds.has(c.l2Id as string)) return missing; + if (!isLine(c.l1Id) || !isLine(c.l2Id)) { + return `${subject}: The ${kind} constraint ${c.id} needs two lines.`; + } + break; + case 'tangent': { + if (!kinds.has(c.aId as string) || !kinds.has(c.bId as string)) return missing; + const aCurve = isCurveOrCircle(c.aId); + const bCurve = isCurveOrCircle(c.bId); + const aLine = isLine(c.aId); + const bLine = isLine(c.bId); + if (!((aCurve && (bCurve || bLine)) || (aLine && bCurve))) { + return `${subject}: The tangent constraint ${c.id} needs an arc or circle on at least one side.`; + } + break; + } + case 'symmetric': + if ( + !kinds.has(c.p1Id as string) || + !kinds.has(c.p2Id as string) || + !kinds.has(c.mirrorLineId as string) + ) { + return missing; + } + if (!isPoint(c.p1Id) || !isPoint(c.p2Id) || !isLine(c.mirrorLineId)) { + return `${subject}: The symmetric constraint ${c.id} needs two points and a mirror line.`; + } + break; + case 'length': + if (!kinds.has(c.lineId as string)) return missing; + if (!isLine(c.lineId)) { + return `${subject}: The length constraint ${c.id} needs a line.`; + } + if (!isFiniteNumber(c.mm) || c.mm <= 0) { + return `${subject}: The length constraint ${c.id} needs a value above 0 mm.`; + } + break; + case 'distance': + if (!kinds.has(c.p1Id as string) || !kinds.has(c.p2Id as string)) return missing; + if (!isPoint(c.p1Id) || !isPoint(c.p2Id)) { + return `${subject}: The distance constraint ${c.id} needs two points.`; + } + if (!isFiniteNumber(c.mm) || c.mm <= 0) { + return `${subject}: The distance constraint ${c.id} needs a value above 0 mm.`; + } + break; + case 'radius': + case 'diameter': + if (!kinds.has(c.entityId as string)) return missing; + if (!isCurveOrCircle(c.entityId)) { + return `${subject}: The ${kind} constraint ${c.id} needs an arc or a circle.`; + } + if (!isFiniteNumber(c.mm) || c.mm <= 0) { + return `${subject}: The ${kind} constraint ${c.id} needs a value above 0 mm.`; + } + break; + case 'angle': + if (!kinds.has(c.l1Id as string) || !kinds.has(c.l2Id as string)) return missing; + if (!isLine(c.l1Id) || !isLine(c.l2Id)) { + return `${subject}: The angle constraint ${c.id} needs two lines.`; + } + if (!isFiniteNumber(c.degrees)) { + return `${subject}: The angle constraint ${c.id} needs a finite angle in degrees.`; + } + break; + default: + return `${subject}: The constraint kind of ${c.id} is not one this app knows.`; + } + } + return null; +} + +/** Result of reading a sketch from untrusted JSON. */ +export type SketchParseResult = + | { ok: true; sketch: Sketch } + | { ok: false; error: string }; + +/** + * Validates and deep-copies a raw value into a Sketch, so an imported plan + * cannot smuggle extra fields into memory. The copy is field-by-field via the + * validated shape; validateSketch has already proven every field. + */ +export function deserializeSketch(raw: unknown): SketchParseResult { + const problem = validateSketch(raw, 'sketch'); + if (problem !== null) return { ok: false, error: problem }; + const source = raw as Sketch; + const entities: SketchEntity[] = source.entities.map((e) => { + switch (e.kind) { + case 'point': + return { kind: 'point', id: e.id, x: e.x, y: e.y, construction: e.construction }; + case 'line': + return { kind: 'line', id: e.id, p1Id: e.p1Id, p2Id: e.p2Id, construction: e.construction }; + case 'arc': + return { + kind: 'arc', + id: e.id, + centerId: e.centerId, + startId: e.startId, + endId: e.endId, + construction: e.construction, + }; + case 'circle': + return { + kind: 'circle', + id: e.id, + centerId: e.centerId, + radiusMm: e.radiusMm, + construction: e.construction, + }; + default: + return assertNever(e); + } + }); + const constraints: SketchConstraint[] = source.constraints.map((c) => { + switch (c.kind) { + case 'coincident': + return { kind: 'coincident', id: c.id, p1Id: c.p1Id, p2Id: c.p2Id }; + case 'horizontal': + return { kind: 'horizontal', id: c.id, lineId: c.lineId }; + case 'vertical': + return { kind: 'vertical', id: c.id, lineId: c.lineId }; + case 'parallel': + return { kind: 'parallel', id: c.id, l1Id: c.l1Id, l2Id: c.l2Id }; + case 'perpendicular': + return { kind: 'perpendicular', id: c.id, l1Id: c.l1Id, l2Id: c.l2Id }; + case 'tangent': + return { kind: 'tangent', id: c.id, aId: c.aId, bId: c.bId }; + case 'symmetric': + return { + kind: 'symmetric', + id: c.id, + p1Id: c.p1Id, + p2Id: c.p2Id, + mirrorLineId: c.mirrorLineId, + }; + case 'length': + return { kind: 'length', id: c.id, lineId: c.lineId, mm: c.mm }; + case 'distance': + return { kind: 'distance', id: c.id, p1Id: c.p1Id, p2Id: c.p2Id, mm: c.mm }; + case 'radius': + return { kind: 'radius', id: c.id, entityId: c.entityId, mm: c.mm }; + case 'diameter': + return { kind: 'diameter', id: c.id, entityId: c.entityId, mm: c.mm }; + case 'angle': + return { kind: 'angle', id: c.id, l1Id: c.l1Id, l2Id: c.l2Id, degrees: c.degrees }; + default: + return assertNever(c); + } + }); + return { + ok: true, + sketch: { schemaVersion: SKETCH_SCHEMA_VERSION, entities, constraints }, + }; +} + +/** + * Center and orientation of the circle through three points, by the standard + * circumcenter formula (perpendicular bisector intersection). Returns null + * for (near-)collinear points. ccw refers to the mathematical orientation in + * the y-down mm frame: the cross product (mid-start) x (end-start) negative. + */ +export function arcFromThreePoints( + start: MmPoint, + mid: MmPoint, + end: MmPoint, +): { center: MmPoint; ccw: boolean } | null { + const ax = start.x; + const ay = start.y; + const bx = mid.x; + const by = mid.y; + const cx = end.x; + const cy = end.y; + const d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by)); + if (Math.abs(d) < 1e-9) return null; + const ux = + ((ax * ax + ay * ay) * (by - cy) + + (bx * bx + by * by) * (cy - ay) + + (cx * cx + cy * cy) * (ay - by)) / + d; + const uy = + ((ax * ax + ay * ay) * (cx - bx) + + (bx * bx + by * by) * (ax - cx) + + (cx * cx + cy * cy) * (bx - ax)) / + d; + const cross = (bx - ax) * (cy - ay) - (by - ay) * (cx - ax); + return { center: { x: ux, y: uy }, ccw: cross < 0 }; +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `npx vitest run tests/sketch/model.spec.ts` +Expected: PASS (all tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/engine/sketch/model.ts tests/sketch/model.spec.ts +git commit -m "Add the sketch datatype with validation and serialization." +``` + +--- + +### Task 3: Solver adapter (solve.ts) + +**Files:** +- Create: `web/src/engine/sketch/solve.ts` +- Test: `web/tests/sketch/solve.spec.ts` + +**Interfaces:** +- Consumes: `Sketch`, `SketchEntity`, `SketchConstraint`, `cloneSketch` from Task 2; `GcsWrapper` type from `@salusoft89/planegcs`; `loadGcsWrapper` test helper from Task 1; `assertNever` from `web/src/engine/plan/types.ts`. +- Produces: `solveSketch(wrapper, sketch, drag?)`, `DragTarget`, `SketchSolveResult` (shapes in the shared reference), used by Tasks 5 and 7. + +- [ ] **Step 1: Write the failing tests** + +Create `web/tests/sketch/solve.spec.ts`: + +```typescript +import { beforeAll, describe, expect, it } from 'vitest'; +import type { GcsWrapper } from '@salusoft89/planegcs'; +import { loadGcsWrapper } from '../helpers/planegcs'; +import { SKETCH_SCHEMA_VERSION, type Sketch } from '../../src/engine/sketch/model'; +import { solveSketch } from '../../src/engine/sketch/solve'; + +let wrapper: GcsWrapper; +beforeAll(async () => { + wrapper = await loadGcsWrapper(); +}); + +function point(id: string, x: number, y: number, construction = false) { + return { kind: 'point' as const, id, x, y, construction }; +} +function line(id: string, p1Id: string, p2Id: string, construction = false) { + return { kind: 'line' as const, id, p1Id, p2Id, construction }; +} + +/** A 30 by 20 rectangle drawn slightly off so the solver has work to do. */ +function rectangleSketch(): Sketch { + return { + schemaVersion: SKETCH_SCHEMA_VERSION, + entities: [ + point('pA', 0.3, -0.2), + point('pB', 29, 1), + point('pC', 31, 21), + point('pD', -1, 19), + line('lAB', 'pA', 'pB'), + line('lBC', 'pB', 'pC'), + line('lCD', 'pC', 'pD'), + line('lDA', 'pD', 'pA'), + ], + constraints: [ + { kind: 'horizontal', id: 'cH1', lineId: 'lAB' }, + { kind: 'horizontal', id: 'cH2', lineId: 'lCD' }, + { kind: 'vertical', id: 'cV1', lineId: 'lBC' }, + { kind: 'vertical', id: 'cV2', lineId: 'lDA' }, + { kind: 'length', id: 'cLen', lineId: 'lAB', mm: 30 }, + { kind: 'distance', id: 'cDist', p1Id: 'pB', p2Id: 'pC', mm: 20 }, + ], + }; +} + +function solvedPoint(sketch: Sketch, id: string): { x: number; y: number } { + const p = sketch.entities.find((e) => e.id === id); + if (p === undefined || p.kind !== 'point') throw new Error(`missing point ${id}`); + return p; +} + +describe('solveSketch', () => { + it('solves the dimensioned rectangle and reports the free dof', () => { + const result = solveSketch(wrapper, rectangleSketch()); + expect(result.status).toBe('solved'); + if (result.status !== 'solved') return; + const a = solvedPoint(result.sketch, 'pA'); + const b = solvedPoint(result.sketch, 'pB'); + const c = solvedPoint(result.sketch, 'pC'); + expect(Math.hypot(b.x - a.x, b.y - a.y)).toBeCloseTo(30, 5); + expect(Math.hypot(c.x - b.x, c.y - b.y)).toBeCloseTo(20, 5); + expect(a.y).toBeCloseTo(b.y, 5); + // The rectangle can still translate freely: two degrees of freedom. + expect(result.dof).toBe(2); + }); + + it('solves a line with a tangent arc continuation', () => { + const sketch: Sketch = { + schemaVersion: SKETCH_SCHEMA_VERSION, + entities: [ + point('p1', 0, 0), + point('p2', 20, 0), + point('pc', 20, 10.5), + point('p3', 30.5, 10), + line('l1', 'p1', 'p2'), + { kind: 'arc', id: 'a1', centerId: 'pc', startId: 'p2', endId: 'p3', construction: false }, + ], + constraints: [ + { kind: 'horizontal', id: 'cH', lineId: 'l1' }, + { kind: 'tangent', id: 'cT', aId: 'l1', bId: 'a1' }, + { kind: 'radius', id: 'cR', entityId: 'a1', mm: 10 }, + { kind: 'distance', id: 'cD', p1Id: 'p1', p2Id: 'p2', mm: 20 }, + ], + }; + const result = solveSketch(wrapper, sketch); + expect(result.status).toBe('solved'); + if (result.status !== 'solved') return; + const p2 = solvedPoint(result.sketch, 'p2'); + const pc = solvedPoint(result.sketch, 'pc'); + // Tangency at p2: the center sits perpendicular to the horizontal line. + expect(Math.abs(pc.x - p2.x)).toBeLessThan(1e-4); + expect(Math.hypot(pc.x - p2.x, pc.y - p2.y)).toBeCloseTo(10, 4); + }); + + it('keeps two points symmetric about a construction mirror line', () => { + const sketch: Sketch = { + schemaVersion: SKETCH_SCHEMA_VERSION, + entities: [ + point('m1', 10, -5, true), + point('m2', 10, 25, true), + point('pl', 2, 8), + point('pr', 17, 9), + line('mirror', 'm1', 'm2', true), + line('span', 'pl', 'pr'), + ], + constraints: [ + { kind: 'vertical', id: 'cV', lineId: 'mirror' }, + { kind: 'symmetric', id: 'cS', p1Id: 'pl', p2Id: 'pr', mirrorLineId: 'mirror' }, + { kind: 'distance', id: 'cD', p1Id: 'pl', p2Id: 'pr', mm: 16 }, + ], + }; + const result = solveSketch(wrapper, sketch); + expect(result.status).toBe('solved'); + if (result.status !== 'solved') return; + const pl = solvedPoint(result.sketch, 'pl'); + const pr = solvedPoint(result.sketch, 'pr'); + const m1 = solvedPoint(result.sketch, 'm1'); + expect((pl.x + pr.x) / 2).toBeCloseTo(m1.x, 4); + expect(pr.x - pl.x).toBeCloseTo(16, 4); + }); + + it('reports the offending constraints of an over-constrained sketch', () => { + const sketch = rectangleSketch(); + sketch.constraints.push({ kind: 'length', id: 'cClash', lineId: 'lAB', mm: 40 }); + const result = solveSketch(wrapper, sketch); + expect(result.status).toBe('conflicting'); + if (result.status !== 'conflicting') return; + expect(result.conflictingConstraintIds.length).toBeGreaterThan(0); + for (const id of result.conflictingConstraintIds) { + expect(sketch.constraints.some((c) => c.id === id)).toBe(true); + } + }); + + it('moves a dragged point toward the target without breaking constraints', () => { + const result = solveSketch(wrapper, rectangleSketch(), { + pointId: 'pA', + xMm: 100, + yMm: 50, + }); + expect(result.status).toBe('solved'); + if (result.status !== 'solved') return; + const a = solvedPoint(result.sketch, 'pA'); + const b = solvedPoint(result.sketch, 'pB'); + expect(a.x).toBeCloseTo(100, 3); + expect(a.y).toBeCloseTo(50, 3); + expect(Math.hypot(b.x - a.x, b.y - a.y)).toBeCloseTo(30, 4); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `npx vitest run tests/sketch/solve.spec.ts` +Expected: FAIL with "Cannot find module '../../src/engine/sketch/solve'". + +- [ ] **Step 3: Implement solve.ts** + +Create `web/src/engine/sketch/solve.ts`: + +```typescript +// Adapter between the Sketch datatype and the FreeCAD PlaneGCS solver +// (@salusoft89/planegcs). Framework-agnostic: the caller (worker or test) +// passes in the loaded GcsWrapper, the same injection pattern the gridfinity +// engine uses for the manifold instance. The adapter maps entities and +// constraints onto PlaneGCS primitives, runs the solver, and writes solved +// coordinates back into a copy of the sketch. +import type { GcsWrapper } from '@salusoft89/planegcs'; +import { assertNever } from '../plan/types'; +import { cloneSketch, type Sketch, type SketchEntity } from './model'; + +/** The solver's driven-point drag: pull pointId toward the target. */ +export interface DragTarget { + pointId: string; + xMm: number; + yMm: number; +} + +export type SketchSolveResult = + | { status: 'solved'; sketch: Sketch; dof: number } + | { status: 'conflicting'; conflictingConstraintIds: string[] } + | { status: 'failed'; message: string }; + +// PlaneGCS SolveStatus values (planegcs_dist/enums): Success 0, Converged 1, +// Failed 2, SuccessfulSolutionInvalid 3. +const SOLVE_SUCCESS = 0; +const SOLVE_CONVERGED = 1; + +/** Internal ids the adapter adds; never reported back as user constraints. */ +const DRAG_POINT_ID = '__drag_target'; +const DRAG_CONSTRAINT_ID = '__drag_pin'; + +function arcAngles( + center: { x: number; y: number }, + start: { x: number; y: number }, + end: { x: number; y: number }, +): { radius: number; startAngle: number; endAngle: number } { + const radius = Math.hypot(start.x - center.x, start.y - center.y); + const startAngle = Math.atan2(start.y - center.y, start.x - center.x); + let endAngle = Math.atan2(end.y - center.y, end.x - center.x); + // The sketch arc runs counterclockwise from start to end; PlaneGCS expects + // end_angle >= start_angle along that direction. + if (endAngle <= startAngle) endAngle += 2 * Math.PI; + return { radius, startAngle, endAngle }; +} + +/** + * Runs the constraint solver over the sketch and returns the solved copy, + * the remaining degrees of freedom (0 means fully constrained), the + * conflicting constraint ids, or a user-worded failure. A drag is expressed + * as PlaneGCS's standard interactive workflow: a fixed target point plus a + * temporary coincidence, which the solver satisfies as well as the driving + * constraints allow without reducing the reported dof. + */ +export function solveSketch( + wrapper: GcsWrapper, + sketch: Sketch, + drag?: DragTarget, +): SketchSolveResult { + const byId = new Map(sketch.entities.map((e) => [e.id, e])); + const primitives: Record[] = []; + // Points first: PlaneGCS requires referenced primitives to be pushed + // before the primitives and constraints that use them. + for (const entity of sketch.entities) { + if (entity.kind === 'point') { + primitives.push({ + id: entity.id, + type: 'point', + x: entity.x, + y: entity.y, + fixed: false, + }); + } + } + for (const entity of sketch.entities) { + switch (entity.kind) { + case 'point': + break; + case 'line': + primitives.push({ id: entity.id, type: 'line', p1_id: entity.p1Id, p2_id: entity.p2Id }); + break; + case 'arc': { + const center = byId.get(entity.centerId) as { x: number; y: number }; + const start = byId.get(entity.startId) as { x: number; y: number }; + const end = byId.get(entity.endId) as { x: number; y: number }; + const derived = arcAngles(center, start, end); + primitives.push({ + id: entity.id, + type: 'arc', + c_id: entity.centerId, + start_id: entity.startId, + end_id: entity.endId, + radius: derived.radius, + start_angle: derived.startAngle, + end_angle: derived.endAngle, + }); + // arc_rules keeps the arc's endpoints, angles and radius consistent. + primitives.push({ id: `${entity.id}__rules`, type: 'arc_rules', a_id: entity.id }); + break; + } + case 'circle': + primitives.push({ + id: entity.id, + type: 'circle', + c_id: entity.centerId, + radius: entity.radiusMm, + }); + break; + default: + return assertNever(entity); + } + } + for (const c of sketch.constraints) { + switch (c.kind) { + case 'coincident': + primitives.push({ id: c.id, type: 'p2p_coincident', p1_id: c.p1Id, p2_id: c.p2Id }); + break; + case 'horizontal': + primitives.push({ id: c.id, type: 'horizontal_l', l_id: c.lineId }); + break; + case 'vertical': + primitives.push({ id: c.id, type: 'vertical_l', l_id: c.lineId }); + break; + case 'parallel': + primitives.push({ id: c.id, type: 'parallel', l1_id: c.l1Id, l2_id: c.l2Id }); + break; + case 'perpendicular': + primitives.push({ id: c.id, type: 'perpendicular_ll', l1_id: c.l1Id, l2_id: c.l2Id }); + break; + case 'tangent': { + const a = byId.get(c.aId); + const b = byId.get(c.bId); + if (a === undefined || b === undefined) { + return { + status: 'failed', + message: 'A tangent constraint refers to geometry that is not in the sketch.', + }; + } + // Normalize so a line, if present, is on the l side. Kind pairs map + // onto PlaneGCS's typed tangency constraints. + const [first, second] = a.kind === 'line' ? [a, b] : [b, a]; + if (first.kind === 'line' && second.kind === 'arc') { + primitives.push({ id: c.id, type: 'tangent_la', l_id: first.id, a_id: second.id }); + } else if (first.kind === 'line' && second.kind === 'circle') { + primitives.push({ id: c.id, type: 'tangent_lc', l_id: first.id, c_id: second.id }); + } else if (first.kind === 'arc' && second.kind === 'arc') { + primitives.push({ id: c.id, type: 'tangent_aa', a1_id: first.id, a2_id: second.id }); + } else if (first.kind === 'circle' && second.kind === 'circle') { + primitives.push({ id: c.id, type: 'tangent_cc', c1_id: first.id, c2_id: second.id }); + } else if (first.kind === 'circle' && second.kind === 'arc') { + primitives.push({ id: c.id, type: 'tangent_ca', c_id: first.id, a_id: second.id }); + } else if (first.kind === 'arc' && second.kind === 'circle') { + primitives.push({ id: c.id, type: 'tangent_ca', c_id: second.id, a_id: first.id }); + } else { + return { + status: 'failed', + message: 'A tangent constraint needs an arc or circle on at least one side.', + }; + } + break; + } + case 'symmetric': + primitives.push({ + id: c.id, + type: 'p2p_symmetric_ppl', + p1_id: c.p1Id, + p2_id: c.p2Id, + l_id: c.mirrorLineId, + }); + break; + case 'length': { + const lineEntity = byId.get(c.lineId); + if (lineEntity === undefined || lineEntity.kind !== 'line') { + return { + status: 'failed', + message: 'A length dimension refers to a line that is not in the sketch.', + }; + } + primitives.push({ + id: c.id, + type: 'p2p_distance', + p1_id: lineEntity.p1Id, + p2_id: lineEntity.p2Id, + distance: c.mm, + }); + break; + } + case 'distance': + primitives.push({ + id: c.id, + type: 'p2p_distance', + p1_id: c.p1Id, + p2_id: c.p2Id, + distance: c.mm, + }); + break; + case 'radius': + case 'diameter': { + const target = byId.get(c.entityId); + if (target === undefined || (target.kind !== 'arc' && target.kind !== 'circle')) { + return { + status: 'failed', + message: 'A radius or diameter dimension needs an arc or a circle.', + }; + } + const radiusMm = c.kind === 'diameter' ? c.mm / 2 : c.mm; + if (target.kind === 'arc') { + primitives.push({ id: c.id, type: 'arc_radius', a_id: target.id, radius: radiusMm }); + } else { + primitives.push({ id: c.id, type: 'circle_radius', c_id: target.id, radius: radiusMm }); + } + break; + } + case 'angle': + primitives.push({ + id: c.id, + type: 'l2l_angle_ll', + l1_id: c.l1Id, + l2_id: c.l2Id, + angle: (c.degrees * Math.PI) / 180, + }); + break; + default: + return assertNever(c); + } + } + if (drag !== undefined) { + primitives.push({ id: DRAG_POINT_ID, type: 'point', x: drag.xMm, y: drag.yMm, fixed: true }); + primitives.push({ + id: DRAG_CONSTRAINT_ID, + type: 'p2p_coincident', + p1_id: drag.pointId, + p2_id: DRAG_POINT_ID, + temporary: true, + }); + } + + wrapper.clear_data(); + wrapper.push_primitives_and_params( + primitives as Parameters[0], + ); + const status = wrapper.solve(); + const userConstraintIds = new Set(sketch.constraints.map((c) => c.id)); + if (wrapper.has_gcs_conflicting_constraints()) { + const offending = wrapper + .get_gcs_conflicting_constraints() + .filter((id) => userConstraintIds.has(id)); + if (offending.length > 0) { + return { status: 'conflicting', conflictingConstraintIds: offending }; + } + } + if (status !== SOLVE_SUCCESS && status !== SOLVE_CONVERGED) { + return { + status: 'failed', + message: + 'The sketch could not be solved from its current positions. Move the geometry closer to the intended shape and try again.', + }; + } + wrapper.apply_solution(); + const dof = wrapper.gcs.dof(); + const solved = cloneSketch(sketch); + for (const entity of solved.entities) { + switch (entity.kind) { + case 'point': { + const p = wrapper.sketch_index.get_primitive_or_fail(entity.id) as unknown as { + x: number; + y: number; + }; + entity.x = p.x; + entity.y = p.y; + break; + } + case 'circle': { + const circle = wrapper.sketch_index.get_primitive_or_fail(entity.id) as unknown as { + radius: number; + }; + entity.radiusMm = circle.radius; + break; + } + case 'line': + case 'arc': + // Lines and arcs are fully determined by their points; arcs also by + // arc_rules, which keeps endpoints authoritative. + break; + default: + return assertNever(entity); + } + } + return { status: 'solved', sketch: solved, dof }; +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `npx vitest run tests/sketch/solve.spec.ts` +Expected: PASS. If the conflict test reports `conflicting` ids that include the internal `__rules` ids, that is a bug in the filter; only ids present in `sketch.constraints` may be returned. If the dof assertion is off by the drag point's params, verify the drag point is pushed with `fixed: true` (fixed points add no params). + +- [ ] **Step 5: Commit** + +```bash +git add src/engine/sketch/solve.ts tests/sketch/solve.spec.ts +git commit -m "Add the PlaneGCS solver adapter with dof, conflict and drag support." +``` + +--- + +### Task 4: Profile extraction (profile.ts) and the shared outline tolerance + +**Files:** +- Modify: `web/src/engine/trace/contour.ts` (export the tolerance; currently the private `DEFAULT_TOLERANCE_MM = 0.2` at line 64) +- Create: `web/src/engine/sketch/profile.ts` +- Test: `web/tests/sketch/profile.spec.ts` + +**Interfaces:** +- Consumes: `Sketch`, `SketchEntity` from Task 2; `TracedOutline`, `MmPoint` from `web/src/engine/trace/types.ts`; `assertNever` from `web/src/engine/plan/types.ts`. +- Produces: `OUTLINE_TOLERANCE_MM` exported from `web/src/engine/trace/contour.ts`; `extractProfile(sketch): ProfileResult` and `ProfileResult` from `web/src/engine/sketch/profile.ts`, used by Tasks 7 and 10. + +- [ ] **Step 1: Export the trace tolerance from its existing home** + +In `web/src/engine/trace/contour.ts`, replace the private constant (line 64): + +```typescript +const DEFAULT_TOLERANCE_MM = 0.2; +``` + +with an exported one, keeping the existing default wiring: + +```typescript +/** + * Polygon simplification and arc flattening tolerance in mm, shared by the + * photo trace (approxPolyDP epsilon) and the sketch profile extraction, so a + * sketched outline and a traced outline are faithful to the same figure. + */ +export const OUTLINE_TOLERANCE_MM = 0.2; +``` + +and update the one usage at line 177 from `options.toleranceMm ?? DEFAULT_TOLERANCE_MM` to `options.toleranceMm ?? OUTLINE_TOLERANCE_MM`. + +- [ ] **Step 2: Write the failing tests** + +Create `web/tests/sketch/profile.spec.ts`: + +```typescript +import { describe, expect, it } from 'vitest'; +import { SKETCH_SCHEMA_VERSION, type Sketch } from '../../src/engine/sketch/model'; +import { extractProfile } from '../../src/engine/sketch/profile'; +import { OUTLINE_TOLERANCE_MM } from '../../src/engine/trace/contour'; +import type { MmPoint } from '../../src/engine/trace/types'; + +function point(id: string, x: number, y: number, construction = false) { + return { kind: 'point' as const, id, x, y, construction }; +} +function line(id: string, p1Id: string, p2Id: string, construction = false) { + return { kind: 'line' as const, id, p1Id, p2Id, construction }; +} +function sketchOf(entities: Sketch['entities'], constraints: Sketch['constraints'] = []): Sketch { + return { schemaVersion: SKETCH_SCHEMA_VERSION, entities, constraints }; +} + +function shoelace(loop: MmPoint[]): number { + let sum = 0; + for (let i = 0; i < loop.length; i += 1) { + const a = loop[i]; + const b = loop[(i + 1) % loop.length]; + sum += a.x * b.y - b.x * a.y; + } + return sum / 2; +} + +describe('extractProfile', () => { + it('extracts a closed rectangle as a positive-area outer loop', () => { + const result = extractProfile( + sketchOf([ + point('pA', 0, 0), + point('pB', 30, 0), + point('pC', 30, 20), + point('pD', 0, 20), + line('l1', 'pA', 'pB'), + line('l2', 'pB', 'pC'), + line('l3', 'pC', 'pD'), + line('l4', 'pD', 'pA'), + ]), + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.outline.holes).toEqual([]); + expect(result.outline.outer).toHaveLength(4); + expect(shoelace(result.outline.outer)).toBeGreaterThan(0); + expect(Math.abs(shoelace(result.outline.outer))).toBeCloseTo(600, 6); + }); + + it('closes a chain through coincident constraints between distinct points', () => { + const result = extractProfile( + sketchOf( + [ + point('pA', 0, 0), + point('pB', 30, 0), + point('pC', 30, 20), + point('pD', 0, 20), + point('pA2', 0, 0), + line('l1', 'pA', 'pB'), + line('l2', 'pB', 'pC'), + line('l3', 'pC', 'pD'), + line('l4', 'pD', 'pA2'), + ], + [{ kind: 'coincident', id: 'cW', p1Id: 'pA', p2Id: 'pA2' }], + ), + ); + expect(result.ok).toBe(true); + }); + + it('flattens a standalone circle within the shared tolerance', () => { + const result = extractProfile( + sketchOf([ + point('pc', 5, 5), + { kind: 'circle', id: 'c1', centerId: 'pc', radiusMm: 12, construction: false }, + ]), + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.outline.holes).toEqual([]); + for (const p of result.outline.outer) { + expect(Math.hypot(p.x - 5, p.y - 5)).toBeCloseTo(12, 9); + } + // Chord sagitta stays within the shared trace tolerance. + const pts = result.outline.outer; + for (let i = 0; i < pts.length; i += 1) { + const a = pts[i]; + const b = pts[(i + 1) % pts.length]; + const midDist = Math.hypot((a.x + b.x) / 2 - 5, (a.y + b.y) / 2 - 5); + expect(12 - midDist).toBeLessThanOrEqual(OUTLINE_TOLERANCE_MM + 1e-9); + } + }); + + it('flattens arcs in a chain', () => { + // A 20 wide stadium-ish profile: bottom line, right semicircular arc, + // top line, left semicircular arc (all counterclockwise in y-down mm). + const result = extractProfile( + sketchOf([ + point('p1', 0, 0), + point('p2', 20, 0), + point('cR', 20, 5), + point('p3', 20, 10), + point('p4', 0, 10), + point('cL', 0, 5), + line('lB', 'p1', 'p2'), + { kind: 'arc', id: 'aR', centerId: 'cR', startId: 'p2', endId: 'p3', construction: false }, + line('lT', 'p3', 'p4'), + { kind: 'arc', id: 'aL', centerId: 'cL', startId: 'p4', endId: 'p1', construction: false }, + ]), + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.outline.outer.length).toBeGreaterThan(8); + // Area of a 20x10 rectangle plus a radius-5 disc, within flattening error. + expect(Math.abs(shoelace(result.outline.outer))).toBeGreaterThan(270); + expect(Math.abs(shoelace(result.outline.outer))).toBeLessThan(280); + }); + + it('rejects an open chain with a user-worded message', () => { + const result = extractProfile( + sketchOf([ + point('pA', 0, 0), + point('pB', 30, 0), + point('pC', 30, 20), + line('l1', 'pA', 'pB'), + line('l2', 'pB', 'pC'), + ]), + ); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toBe( + 'The outline is not closed. Connect every line and arc end to end into one loop.', + ); + }); + + it('rejects a construction-only sketch', () => { + const result = extractProfile( + sketchOf([point('pA', 0, 0, true), point('pB', 10, 0, true), line('l1', 'pA', 'pB', true)]), + ); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toBe( + 'The sketch has only construction geometry. Draw the shape with regular lines, arcs or a circle.', + ); + }); + + it('rejects multiple disjoint loops', () => { + const result = extractProfile( + sketchOf([ + point('pA', 0, 0), + point('pB', 10, 0), + point('pC', 5, 8), + line('l1', 'pA', 'pB'), + line('l2', 'pB', 'pC'), + line('l3', 'pC', 'pA'), + point('qc', 40, 0), + { kind: 'circle', id: 'c1', centerId: 'qc', radiusMm: 5, construction: false }, + ]), + ); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toBe( + 'The sketch contains more than one separate shape. Keep exactly one closed outline.', + ); + }); + + it('rejects a self-intersecting outline', () => { + // A bowtie: the two diagonals cross. + const result = extractProfile( + sketchOf([ + point('pA', 0, 0), + point('pB', 10, 10), + point('pC', 10, 0), + point('pD', 0, 10), + line('l1', 'pA', 'pB'), + line('l2', 'pB', 'pC'), + line('l3', 'pC', 'pD'), + line('l4', 'pD', 'pA'), + ]), + ); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toBe( + 'The outline crosses itself. Adjust the shape so its boundary does not intersect.', + ); + }); +}); +``` + +- [ ] **Step 3: Run the tests to verify they fail** + +Run: `npx vitest run tests/sketch/profile.spec.ts` +Expected: FAIL with "Cannot find module '../../src/engine/sketch/profile'". + +- [ ] **Step 4: Implement profile.ts** + +Create `web/src/engine/sketch/profile.ts`: + +```typescript +// Extracts the closed outer loop of a solved sketch as the trace pipeline's +// outline type. Arcs and circles are flattened by the standard sagitta bound +// (segment angle chosen so the chord-to-arc deviation stays within the shared +// outline tolerance). Failures are user-worded messages the UI shows verbatim. +import type { MmPoint, TracedOutline } from '../trace/types'; +import { OUTLINE_TOLERANCE_MM } from '../trace/contour'; +import { assertNever } from '../plan/types'; +import type { Sketch, SketchArc, SketchCircle, SketchEntity, SketchPoint } from './model'; + +export type ProfileResult = + | { ok: true; outline: TracedOutline } + | { ok: false; error: string }; + +const OPEN_CHAIN = + 'The outline is not closed. Connect every line and arc end to end into one loop.'; +const SELF_INTERSECTING = + 'The outline crosses itself. Adjust the shape so its boundary does not intersect.'; +const CONSTRUCTION_ONLY = + 'The sketch has only construction geometry. Draw the shape with regular lines, arcs or a circle.'; +const MULTIPLE_LOOPS = + 'The sketch contains more than one separate shape. Keep exactly one closed outline.'; + +/** + * Union-find over point ids: points joined by coincident constraints count + * as the same chain node, matching what the solver enforces. + */ +class PointGroups { + private parent = new Map(); + + find(id: string): string { + const p = this.parent.get(id); + if (p === undefined || p === id) return p ?? id; + const root = this.find(p); + this.parent.set(id, root); + return root; + } + + union(a: string, b: string): void { + const ra = this.find(a); + const rb = this.find(b); + if (ra !== rb) this.parent.set(ra, rb); + } +} + +/** Number of segments flattening an arc of the given radius and sweep. */ +function segmentCount(radiusMm: number, sweepRad: number): number { + // Sagitta bound: a chord spanning angle t deviates r * (1 - cos(t / 2)), + // so the largest allowed step is 2 * acos(1 - tolerance / r). + const ratio = 1 - OUTLINE_TOLERANCE_MM / Math.max(radiusMm, OUTLINE_TOLERANCE_MM); + const maxStep = 2 * Math.acos(Math.max(-1, Math.min(1, ratio))); + return Math.max(2, Math.ceil(sweepRad / Math.max(maxStep, 1e-6))); +} + +/** Flattened arc points from start toward end, excluding the end point. */ +function flattenArc( + center: SketchPoint, + start: SketchPoint, + end: SketchPoint, + reversed: boolean, +): MmPoint[] { + const radius = Math.hypot(start.x - center.x, start.y - center.y); + const a0 = Math.atan2(start.y - center.y, start.x - center.x); + let a1 = Math.atan2(end.y - center.y, end.x - center.x); + if (a1 <= a0) a1 += 2 * Math.PI; // stored arcs run counterclockwise start to end + const from = reversed ? a1 : a0; + const to = reversed ? a0 : a1; + const n = segmentCount(radius, Math.abs(to - from)); + const points: MmPoint[] = []; + for (let i = 0; i < n; i += 1) { + const t = from + ((to - from) * i) / n; + points.push({ x: center.x + radius * Math.cos(t), y: center.y + radius * Math.sin(t) }); + } + return points; +} + +/** Full-circle flattening, counterclockwise, closed implicitly. */ +function flattenCircle(center: SketchPoint, radiusMm: number): MmPoint[] { + const n = Math.max(8, segmentCount(radiusMm, 2 * Math.PI)); + const points: MmPoint[] = []; + for (let i = 0; i < n; i += 1) { + const t = (2 * Math.PI * i) / n; + points.push({ x: center.x + radiusMm * Math.cos(t), y: center.y + radiusMm * Math.sin(t) }); + } + return points; +} + +function shoelaceArea(loop: MmPoint[]): number { + let sum = 0; + for (let i = 0; i < loop.length; i += 1) { + const a = loop[i]; + const b = loop[(i + 1) % loop.length]; + sum += a.x * b.y - b.x * a.y; + } + return sum / 2; +} + +/** Proper (interior) intersection test of two segments, standard orientation test. */ +function segmentsCross(a1: MmPoint, a2: MmPoint, b1: MmPoint, b2: MmPoint): boolean { + const orient = (p: MmPoint, q: MmPoint, r: MmPoint): number => + (q.x - p.x) * (r.y - p.y) - (q.y - p.y) * (r.x - p.x); + const d1 = orient(b1, b2, a1); + const d2 = orient(b1, b2, a2); + const d3 = orient(a1, a2, b1); + const d4 = orient(a1, a2, b2); + return d1 * d2 < 0 && d3 * d4 < 0; +} + +function selfIntersects(loop: MmPoint[]): boolean { + const n = loop.length; + for (let i = 0; i < n; i += 1) { + for (let j = i + 1; j < n; j += 1) { + // Skip adjacent segments (they share an endpoint by construction). + if (j === i || (j + 1) % n === i || (i + 1) % n === j) continue; + if (segmentsCross(loop[i], loop[(i + 1) % n], loop[j], loop[(j + 1) % n])) return true; + } + } + return false; +} + +/** + * Extracts the single closed outer loop of the sketch's non-construction + * geometry as a TracedOutline (positive shoelace area, no holes in v1). A + * lone non-construction circle stands alone as the whole outline. + */ +export function extractProfile(sketch: Sketch): ProfileResult { + const byId = new Map(sketch.entities.map((e) => [e.id, e])); + const pointOf = (id: string): SketchPoint => byId.get(id) as SketchPoint; + const curves = sketch.entities.filter( + (e): e is Extract => + (e.kind === 'line' || e.kind === 'arc') && !e.construction, + ); + const circles = sketch.entities.filter( + (e): e is SketchCircle => e.kind === 'circle' && !e.construction, + ); + if (curves.length === 0 && circles.length === 0) { + return { ok: false, error: CONSTRUCTION_ONLY }; + } + if (circles.length > 0) { + if (circles.length > 1 || curves.length > 0) { + return { ok: false, error: MULTIPLE_LOOPS }; + } + const circle = circles[0]; + return { + ok: true, + outline: { outer: orientPositive(flattenCircle(pointOf(circle.centerId), circle.radiusMm)), holes: [] }, + }; + } + // Merge endpoints joined by coincident constraints. + const groups = new PointGroups(); + for (const c of sketch.constraints) { + if (c.kind === 'coincident') groups.union(c.p1Id, c.p2Id); + } + const endsOf = (curve: (typeof curves)[number]): [string, string] => { + switch (curve.kind) { + case 'line': + return [groups.find(curve.p1Id), groups.find(curve.p2Id)]; + case 'arc': + return [groups.find(curve.startId), groups.find(curve.endId)]; + default: + return assertNever(curve); + } + }; + // Every merged endpoint must join exactly two curves for one closed loop. + const adjacency = new Map(); + for (const curve of curves) { + const [a, b] = endsOf(curve); + for (const [from, to] of [ + [a, b], + [b, a], + ] as const) { + const list = adjacency.get(from) ?? []; + list.push({ curve, other: to }); + adjacency.set(from, list); + } + } + for (const list of adjacency.values()) { + if (list.length !== 2) return { ok: false, error: OPEN_CHAIN }; + } + // Walk the loop from the first curve; every curve must be visited once. + const visited = new Set(); + const loop: MmPoint[] = []; + const startNode = endsOf(curves[0])[0]; + let node = startNode; + let previousCurveId: string | null = null; + for (;;) { + const nextEdge = (adjacency.get(node) ?? []).find( + (edge) => edge.curve.id !== previousCurveId && !visited.has(edge.curve.id), + ); + if (nextEdge === undefined) break; + const curve = nextEdge.curve; + visited.add(curve.id); + switch (curve.kind) { + case 'line': { + const from = + groups.find(curve.p1Id) === node ? pointOf(curve.p1Id) : pointOf(curve.p2Id); + loop.push({ x: from.x, y: from.y }); + break; + } + case 'arc': { + const reversed = groups.find(curve.startId) !== node; + loop.push( + ...flattenArc( + pointOf((curve as SketchArc).centerId), + pointOf((curve as SketchArc).startId), + pointOf((curve as SketchArc).endId), + reversed, + ), + ); + break; + } + default: + return assertNever(curve); + } + previousCurveId = curve.id; + node = nextEdge.other; + if (node === startNode) break; + } + if (visited.size !== curves.length) { + return { ok: false, error: MULTIPLE_LOOPS }; + } + if (node !== startNode || loop.length < 3) { + return { ok: false, error: OPEN_CHAIN }; + } + if (selfIntersects(loop)) { + return { ok: false, error: SELF_INTERSECTING }; + } + return { ok: true, outline: { outer: orientPositive(loop), holes: [] } }; +} + +/** Ensures positive shoelace area, the TracedOutline outer-loop convention. */ +function orientPositive(loop: MmPoint[]): MmPoint[] { + return shoelaceArea(loop) >= 0 ? loop : [...loop].reverse(); +} +``` + +Note: `flattenArc` reversed traversal emits points from the arc's end back toward its start (excluding the destination point), which is exactly what the loop walk needs since each step pushes the segment's departure points and the next curve supplies the arrival point. + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `npx vitest run tests/sketch/profile.spec.ts tests/trace/contour.spec.ts` +Expected: PASS, including the untouched contour tests (only the constant was renamed and exported). + +- [ ] **Step 6: Commit** + +```bash +git add src/engine/trace/contour.ts src/engine/sketch/profile.ts tests/sketch/profile.spec.ts +git commit -m "Add sketch profile extraction sharing the trace outline tolerance." +``` + +--- + +### Task 5: Sketch worker and client + +**Files:** +- Create: `web/src/worker/sketch.worker.ts` +- Create: `web/src/sketchClient.ts` + +**Interfaces:** +- Consumes: `solveSketch`, `DragTarget`, `SketchSolveResult` from Task 3; `Sketch` from Task 2; `sanitizeForWorker` from `web/src/workerSanitize.ts`; Comlink. +- Produces: `solveSketchInWorker(sketch: Sketch, drag?: DragTarget): Promise` from `web/src/sketchClient.ts`, used by Task 7. No unit test: the worker cannot run under node (Comlink), matching the vision worker; the WASM itself is smoke-tested in Task 1 and the adapter in Task 3. + +- [ ] **Step 1: Implement the worker** + +Create `web/src/worker/sketch.worker.ts`: + +```typescript +import * as Comlink from 'comlink'; +import { init_planegcs_module, GcsWrapper } from '@salusoft89/planegcs'; +import wasmUrl from '@salusoft89/planegcs/dist/planegcs_dist/planegcs.wasm?url'; +import type { Sketch } from '../engine/sketch/model'; +import { solveSketch, type DragTarget, type SketchSolveResult } from '../engine/sketch/solve'; + +// The sketch worker owns the PlaneGCS WASM so constraint solving never blocks +// the page or the geometry worker's carves, and the LGPL-licensed wasm ships +// as its own replaceable asset (the ?url import), following the manifold +// pattern in geometry.worker.ts. + +let wrapperPromise: Promise | null = null; + +function getWrapper(): Promise { + if (!wrapperPromise) { + wrapperPromise = init_planegcs_module({ locateFile: () => wasmUrl }).then( + (mod) => new GcsWrapper(new mod.GcsSystem()), + ); + } + return wrapperPromise; +} + +const api = { + /** Runs the constraint solver over a sketch; see solveSketch. */ + async solve(sketch: Sketch, drag?: DragTarget): Promise { + const wrapper = await getWrapper(); + return solveSketch(wrapper, sketch, drag); + }, +}; + +export type SketchWorkerApi = typeof api; + +Comlink.expose(api); +``` + +- [ ] **Step 2: Implement the client** + +Create `web/src/sketchClient.ts`: + +```typescript +import * as Comlink from 'comlink'; +import type { SketchWorkerApi } from './worker/sketch.worker'; +import type { Sketch } from './engine/sketch/model'; +import type { DragTarget, SketchSolveResult } from './engine/sketch/solve'; +import { sanitizeForWorker } from './workerSanitize'; + +// The only thing the UI calls for sketch solving, mirroring visionClient.ts. + +let remote: Comlink.Remote | null = null; + +function getWorker(): Comlink.Remote { + if (!remote) { + const worker = new Worker(new URL('./worker/sketch.worker.ts', import.meta.url), { + type: 'module', + }); + remote = Comlink.wrap(worker); + } + return remote; +} + +/** + * Solves a sketch in the sketch worker. Arguments cross the worker boundary, + * so they are sanitized into plain structured-cloneable values here. + */ +export async function solveSketchInWorker( + sketch: Sketch, + drag?: DragTarget, +): Promise { + const worker = getWorker(); + return worker.solve( + sanitizeForWorker(sketch), + drag === undefined ? undefined : sanitizeForWorker(drag), + ); +} +``` + +- [ ] **Step 3: Verify the build bundles the wasm as a separate asset** + +Run: `npm run build` +Expected: build succeeds; `dist/assets/` contains a `planegcs-*.wasm` file separate from the JS chunks (the LGPL replaceable-asset requirement). If `vue-tsc` cannot type the `?url` import, add the line `/// ` is already provided by the project's env types; check `web/src/vite-env.d.ts` exists (it does for the manifold wasm import) and mirror whatever declaration `geometry.worker.ts` relies on. + +- [ ] **Step 4: Commit** + +```bash +git add src/worker/sketch.worker.ts src/sketchClient.ts +git commit -m "Add the sketch worker and client owning the PlaneGCS wasm." +``` + +--- + +### Task 6: Tool origin discriminator and plan file version 11 + +**Files:** +- Modify: `web/src/engine/trace/types.ts` (add `ToolSource`, add `source` to `TracedTool`) +- Modify: `web/src/engine/trace/layoutModel.ts` (`addTool` gains a source parameter; `TracedTool` literals gain `source`) +- Modify: `web/src/engine/plan/types.ts` (`PLAN_FILE_VERSION` 10 to 11, line 675) +- Modify: `web/src/engine/plan/planFile.ts` (`validatePockets` validates `source`, `pickPockets` picks and defaults it, version comment in `parsePlanFile`) +- Test: `web/tests/plan/planFile.spec.ts` (add cases), `web/tests/trace/layoutModel.spec.ts` (add case) + +**Interfaces:** +- Consumes: `Sketch`, `validateSketch`, `deserializeSketch`, `cloneSketch` from Task 2. +- Produces: `ToolSource = { kind: 'photo' } | { kind: 'sketch'; sketch: Sketch }` and required `TracedTool.source`, used by Tasks 7, 8, 10. Plan files of version 11. + +- [ ] **Step 1: Write the failing tests** + +In `web/tests/plan/planFile.spec.ts`, add (adapt the existing helper the file uses for building a valid traced entry; the file already has fixtures for traced bins, follow its local naming): + +```typescript +import { SKETCH_SCHEMA_VERSION } from '../../src/engine/sketch/model'; + +describe('plan version 11: pocket tool source', () => { + it('defaults an absent source to photo on load', () => { + const plan = writeAndReparseTracedEntry((tool) => { + delete (tool as Record).source; + }); + const tool = firstPocketTool(plan); + expect(tool.source).toEqual({ kind: 'photo' }); + }); + + it('round-trips a sketch source', () => { + const sketch = { + schemaVersion: SKETCH_SCHEMA_VERSION, + entities: [ + { kind: 'point', id: 'pc', x: 0, y: 0, construction: false }, + { kind: 'circle', id: 'c1', centerId: 'pc', radiusMm: 12, construction: false }, + ], + constraints: [], + }; + const plan = writeAndReparseTracedEntry((tool) => { + (tool as Record).source = { kind: 'sketch', sketch }; + }); + const tool = firstPocketTool(plan); + expect(tool.source.kind).toBe('sketch'); + if (tool.source.kind === 'sketch') expect(tool.source.sketch).toEqual(sketch); + }); + + it('rejects a sketch source with a broken sketch', () => { + const result = parseTracedEntryWith((tool) => { + (tool as Record).source = { kind: 'sketch', sketch: { schemaVersion: 1 } }; + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain('entities must be a list'); + } + }); + + it('rejects an unknown source kind', () => { + const result = parseTracedEntryWith((tool) => { + (tool as Record).source = { kind: 'scan' }; + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain('source must be a photo trace or a sketch'); + } + }); +}); +``` + +Concrete instructions for the two helpers, since the implementer sees only this task: `writeAndReparseTracedEntry(mutate)` builds a valid plan with one traced entry the way the file's existing traced-bin tests do, runs `JSON.parse(JSON.stringify(plan))`, applies `mutate` to `raw.entries[0].product.bin.pockets.tools[0]`, calls `parsePlanFile` (the file's existing entry point, already imported at the top of the spec), asserts `ok` is true and returns the plan. `parseTracedEntryWith(mutate)` is the same but returns the raw `PlanParseResult` without asserting. `firstPocketTool(plan)` digs out `plan.entries[0]`'s bin pockets tool 0 through `binOf` as the existing tests do. Reuse the file's existing fixture builders rather than writing new ones if equivalents exist. + +In `web/tests/trace/layoutModel.spec.ts`, add: + +```typescript +it('stamps a new tool with the photo source by default', () => { + const state = freshState(); // the file's existing empty LayoutState helper + const tool = addTool(state, squareOutline(), 'Tool', 20); + expect(tool.source).toEqual({ kind: 'photo' }); +}); +``` + +(where `freshState` and `squareOutline` are the spec file's existing helpers; use their actual names, they exist because every layoutModel test builds the same state). + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `npx vitest run tests/plan/planFile.spec.ts tests/trace/layoutModel.spec.ts` +Expected: FAIL: the plan tests fail on the missing `source` handling (absent field is currently dropped silently, so `tool.source` is `undefined`) and the layoutModel test fails with `source` being `undefined`. Type errors also surface once types change; that is Step 3. + +- [ ] **Step 3: Implement the type and engine changes** + +In `web/src/engine/trace/types.ts`, after the `TracedOutline` interface, add: + +```typescript +import type { Sketch } from '../sketch/model'; + +/** + * Where a tool's outline came from. A photo-traced tool is re-editable + * through its stored clicks and photo; a sketched tool embeds its editable + * Sketch so it can be reopened and changed later. Discriminated on kind and + * always branched exhaustively (assertNever), mirroring Bin.origin. + */ +export type ToolSource = { kind: 'photo' } | { kind: 'sketch'; sketch: Sketch }; +``` + +(place the `import type` with the file's imports; the file currently has none, so it becomes the first line) and add to `TracedTool` (after `fingerHoles`): + +```typescript + /** Where the outline came from: a photo trace or an embedded sketch. */ + source: ToolSource; +``` + +In `web/src/engine/trace/layoutModel.ts`: + +- import the type: add `ToolSource` to the existing type import from `./types`; +- change `addTool`'s signature to append a parameter `source: ToolSource = { kind: 'photo' }` after `brushStrokes` and set `source` in the constructed `TracedTool` literal (after `fingerHoles: []`). `duplicateTool` needs no change: its JSON deep copy carries the source, including an embedded sketch. +- `replaceToolOutline` needs no change: re-tracing only applies to photo tools and does not alter the source. + +In `web/src/stores/toolTrace.ts`, thread the parameter through the store's `addTool` (append `source: ToolSource = { kind: 'photo' }` to its parameters and pass it to `layout.addTool`); import `ToolSource` in the type import from `../engine/trace/types`. + +Fix every remaining compile error where a `TracedTool` literal is constructed (search for `fingerHoles: []` and `fingerHoles: (` across `web/src`): each constructor site gains `source: { kind: 'photo' }` except `pickPockets`, handled next. + +In `web/src/engine/plan/planFile.ts`: + +- Import at the top: + +```typescript +import { validateSketch, deserializeSketch, type Sketch } from '../sketch/model'; +import type { ToolSource } from '../trace/types'; +``` + +- In `validatePockets`, after the `fingerHoles` loop (before the closing of the per-tool loop), add: + +```typescript + // source was added in plan version 11; older plans omit it, so undefined + // is accepted and defaulted to a photo trace on pick. + if (tool.source !== undefined) { + const source = tool.source as Record | null; + if (typeof source !== 'object' || source === null || Array.isArray(source)) { + return `${subject}: pocket tool ${tool.id}: The outline source must be an object.`; + } + if (source.kind === 'photo') { + // A photo source carries no further fields. + } else if (source.kind === 'sketch') { + const sketchProblem = validateSketch( + source.sketch, + `${subject}: pocket tool ${tool.id}`, + ); + if (sketchProblem !== null) return sketchProblem; + } else { + return `${subject}: pocket tool ${tool.id}: The outline source must be a photo trace or a sketch.`; + } + } +``` + +- In `pickPockets`, add to the returned tool literal (after `fingerHoles`): + +```typescript + source: pickToolSource(tool.source), +``` + +and add next to `pickPockets`: + +```typescript +/** + * Copies a validated tool source; absent (pre-version-11) means the tool was + * photo-traced, which is what every earlier plan's tools were. + */ +function pickToolSource(raw: unknown): ToolSource { + if (raw === undefined) return { kind: 'photo' }; + const source = raw as Record; + if (source.kind === 'sketch') { + const parsed = deserializeSketch(source.sketch); + if (!parsed.ok) { + // validatePockets already proved the sketch valid; reaching here is a + // programming error, not a user problem. + throw new Error(`A validated sketch failed to deserialize: ${parsed.error}`); + } + return { kind: 'sketch', sketch: parsed.sketch }; + } + return { kind: 'photo' }; +} +``` + +- In `web/src/engine/plan/types.ts` line 675, change `export const PLAN_FILE_VERSION = 10;` to `export const PLAN_FILE_VERSION = 11;`. +- In `parsePlanFile`'s version-history comment block (planFile.ts around lines 1875-1885), append one sentence: `Version 11 adds the outline source on pocket tools (photo trace or embedded sketch), absent in earlier versions and defaulted to a photo trace on pick.` + +- [ ] **Step 4: Run the tests and the typecheck** + +Run: `npx vitest run tests/plan tests/trace tests/stores && npx vue-tsc --noEmit` +Expected: PASS with no type errors. The typecheck is the enforcement that every `TracedTool` construction site got its `source`. + +- [ ] **Step 5: Commit** + +```bash +git add src/engine/trace/types.ts src/engine/trace/layoutModel.ts src/stores/toolTrace.ts src/engine/plan/types.ts src/engine/plan/planFile.ts tests/plan/planFile.spec.ts tests/trace/layoutModel.spec.ts +git commit -m "Add the tool outline source discriminator and plan version 11." +``` + +--- + +### Task 7: Sketch editor store + +**Files:** +- Create: `web/src/stores/sketchEditor.ts` +- Test: `web/tests/stores/sketchEditor.spec.ts` + +**Interfaces:** +- Consumes: `emptySketch`, `cloneSketch`, `arcFromThreePoints`, `Sketch`, `SketchEntity`, `SketchConstraint`, `SketchDimension` from Task 2; `solveSketchInWorker` from Task 5 (mocked in tests); `extractProfile` from Task 4; `SketchSolveResult`, `DragTarget` from Task 3. +- Produces: the `useSketchEditor` Pinia store used by Tasks 8, 9, 10 with the state and actions shown below. + +- [ ] **Step 1: Write the failing tests** + +Create `web/tests/stores/sketchEditor.spec.ts`: + +```typescript +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPinia, setActivePinia } from 'pinia'; + +const solveMock = vi.fn(); +vi.mock('../../src/sketchClient', () => ({ + solveSketchInWorker: (...args: unknown[]) => solveMock(...args), +})); + +import { useSketchEditor } from '../../src/stores/sketchEditor'; + +beforeEach(() => { + setActivePinia(createPinia()); + solveMock.mockReset(); + solveMock.mockImplementation(async (sketch) => ({ status: 'solved', sketch, dof: 4 })); +}); + +describe('useSketchEditor', () => { + it('starts empty and adds a line chain sharing intermediate points', () => { + const editor = useSketchEditor(); + editor.startNewSketch(); + const first = editor.appendChainPoint({ x: 0, y: 0 }); + const second = editor.appendChainPoint({ x: 30, y: 0 }); + const third = editor.appendChainPoint({ x: 30, y: 20 }); + expect(first).not.toBeNull(); + const lines = editor.sketch.entities.filter((e) => e.kind === 'line'); + const points = editor.sketch.entities.filter((e) => e.kind === 'point'); + expect(lines).toHaveLength(2); + expect(points).toHaveLength(3); + // Chained lines share the middle point instead of duplicating it. + expect((lines[0] as { p2Id: string }).p2Id).toBe((lines[1] as { p1Id: string }).p1Id); + expect(second).toBe((lines[1] as { p1Id: string }).p1Id); + expect(third).toBe((lines[1] as { p2Id: string }).p2Id); + }); + + it('closes the chain onto its first point when finishing at it', () => { + const editor = useSketchEditor(); + editor.startNewSketch(); + const first = editor.appendChainPoint({ x: 0, y: 0 })!; + editor.appendChainPoint({ x: 30, y: 0 }); + editor.appendChainPoint({ x: 30, y: 20 }); + editor.closeChainTo(first); + const lines = editor.sketch.entities.filter((e) => e.kind === 'line'); + expect(lines).toHaveLength(3); + expect((lines[2] as { p2Id: string }).p2Id).toBe(first); + }); + + it('adds a circle with center and radius', () => { + const editor = useSketchEditor(); + editor.startNewSketch(); + editor.addCircle({ x: 10, y: 10 }, 12.5); + const circle = editor.sketch.entities.find((e) => e.kind === 'circle'); + expect(circle).toBeDefined(); + expect((circle as { radiusMm: number }).radiusMm).toBeCloseTo(12.5); + }); + + it('adds a dimension and solves after the edit', async () => { + const editor = useSketchEditor(); + editor.startNewSketch(); + editor.appendChainPoint({ x: 0, y: 0 }); + editor.appendChainPoint({ x: 28, y: 3 }); + const line = editor.sketch.entities.find((e) => e.kind === 'line')!; + editor.addDimension({ kind: 'length', id: editor.nextId(), lineId: line.id, mm: 30 }); + await editor.solveNow(); + expect(solveMock).toHaveBeenCalled(); + expect(editor.solveState.status).toBe('solved'); + }); + + it('keeps the conflicting constraint ids for the diagnostics rows', async () => { + solveMock.mockResolvedValue({ status: 'conflicting', conflictingConstraintIds: ['cX'] }); + const editor = useSketchEditor(); + editor.startNewSketch(); + editor.appendChainPoint({ x: 0, y: 0 }); + editor.appendChainPoint({ x: 10, y: 0 }); + await editor.solveNow(); + expect(editor.solveState.status).toBe('conflicting'); + if (editor.solveState.status === 'conflicting') { + expect(editor.solveState.conflictingConstraintIds).toEqual(['cX']); + } + }); + + it('toggles the construction flag on a selected entity', () => { + const editor = useSketchEditor(); + editor.startNewSketch(); + editor.appendChainPoint({ x: 0, y: 0 }); + editor.appendChainPoint({ x: 10, y: 0 }); + const line = editor.sketch.entities.find((e) => e.kind === 'line')!; + editor.toggleConstruction(line.id); + expect(line.construction).toBe(true); + }); + + it('loads an existing sketch for editing a sketched tool', () => { + const editor = useSketchEditor(); + editor.startNewSketch(); + editor.addCircle({ x: 0, y: 0 }, 5); + const saved = JSON.parse(JSON.stringify(editor.sketch)); + editor.startNewSketch(); + expect(editor.sketch.entities).toHaveLength(0); + editor.loadSketch(saved, 'tool-1'); + expect(editor.sketch.entities).toHaveLength(2); + expect(editor.editingToolId).toBe('tool-1'); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `npx vitest run tests/stores/sketchEditor.spec.ts` +Expected: FAIL with "Cannot find module '../../src/stores/sketchEditor'". + +- [ ] **Step 3: Implement the store** + +Create `web/src/stores/sketchEditor.ts`: + +```typescript +import { defineStore } from 'pinia'; +import { ref, shallowRef } from 'vue'; +import { + arcFromThreePoints, + cloneSketch, + emptySketch, + type Sketch, + type SketchConstraint, + type SketchDimension, +} from '../engine/sketch/model'; +import type { DragTarget, SketchSolveResult } from '../engine/sketch/solve'; +import type { MmPoint } from '../engine/trace/types'; +import { solveSketchInWorker } from '../sketchClient'; + +/** The drawing tool active on the sketch canvas. */ +export type SketchTool = + | 'select' + | 'line' + | 'arcThreePoint' + | 'arcTangent' + | 'circle' + | 'mirror' + | 'dimension'; + +/** Solver state shown on the canvas; idle before the first run. */ +export type SolveState = + | { status: 'idle' } + | SketchSolveResult; + +/** + * State of the sketch workspace inside the Tool trace tab. All geometry + * mutations edit the Sketch (engine data); the canvas only renders it. Every + * mutation marks the sketch dirty and the workspace schedules a solve; the + * store never solves implicitly so tests stay deterministic. + */ +export const useSketchEditor = defineStore('sketchEditor', () => { + const sketch = ref(emptySketch()); + const activeTool = ref('select'); + const selectedIds = ref([]); + const solveState = shallowRef({ status: 'idle' }); + /** Id of the sketched tool being re-edited, or null for a new shape. */ + const editingToolId = ref(null); + /** The open line/arc chain's last point id, or null when no chain is open. */ + const chainTailId = ref(null); + /** Photo underlay: display only, never enters geometry. */ + const underlayUrl = ref(null); + const underlayOpacityPct = ref(40); + /** Millimeters per underlay image pixel from the calibration line, or null. */ + const underlayMmPerPixel = ref(null); + + let idCounter = 0; + /** Sketch-unique id; sequential so saved sketches diff readably. */ + function nextId(): string { + idCounter += 1; + return `s${idCounter}`; + } + + function startNewSketch(): void { + sketch.value = emptySketch(); + activeTool.value = 'select'; + selectedIds.value = []; + solveState.value = { status: 'idle' }; + editingToolId.value = null; + chainTailId.value = null; + underlayUrl.value = null; + underlayOpacityPct.value = 40; + underlayMmPerPixel.value = null; + idCounter = 0; + } + + /** Opens an existing sketch (deep-copied) for editing a sketched tool. */ + function loadSketch(source: Sketch, toolId: string): void { + startNewSketch(); + sketch.value = cloneSketch(source); + editingToolId.value = toolId; + // Continue id numbering above any existing s ids. + for (const entity of sketch.value.entities) { + const match = /^s(\d+)$/.exec(entity.id); + if (match) idCounter = Math.max(idCounter, Number(match[1])); + } + for (const constraint of sketch.value.constraints) { + const match = /^s(\d+)$/.exec(constraint.id); + if (match) idCounter = Math.max(idCounter, Number(match[1])); + } + } + + function addPoint(at: MmPoint, construction = false): string { + const id = nextId(); + sketch.value.entities.push({ kind: 'point', id, x: at.x, y: at.y, construction }); + return id; + } + + /** + * Appends a point to the open line chain, creating a line from the chain + * tail when one exists. Returns the new point id. + */ + function appendChainPoint(at: MmPoint): string | null { + const pointId = addPoint(at); + if (chainTailId.value !== null) { + sketch.value.entities.push({ + kind: 'line', + id: nextId(), + p1Id: chainTailId.value, + p2Id: pointId, + construction: false, + }); + } + chainTailId.value = pointId; + return pointId; + } + + /** Closes the open chain onto an existing point and ends the chain. */ + function closeChainTo(pointId: string): void { + if (chainTailId.value === null || chainTailId.value === pointId) return; + sketch.value.entities.push({ + kind: 'line', + id: nextId(), + p1Id: chainTailId.value, + p2Id: pointId, + construction: false, + }); + chainTailId.value = null; + } + + /** Ends the open chain without closing it. */ + function endChain(): void { + chainTailId.value = null; + } + + function addCircle(center: MmPoint, radiusMm: number): void { + const centerId = addPoint(center); + sketch.value.entities.push({ + kind: 'circle', + id: nextId(), + centerId, + radiusMm, + construction: false, + }); + } + + /** + * Adds a three-point arc. Point order start, end, then a point the arc + * passes through, matching the canvas tool. Returns false for collinear + * picks, which the workspace reports as a status row. + */ + function addThreePointArc(start: MmPoint, end: MmPoint, through: MmPoint): boolean { + const derived = arcFromThreePoints(start, through, end); + if (derived === null) return false; + const centerId = addPoint(derived.center); + const startId = addPoint(start); + const endId = addPoint(end); + // The stored arc always runs counterclockwise from start to end; a + // clockwise pick stores the endpoints swapped. + sketch.value.entities.push({ + kind: 'arc', + id: nextId(), + centerId, + startId: derived.ccw ? startId : endId, + endId: derived.ccw ? endId : startId, + construction: false, + }); + return true; + } + + /** + * Adds a mirror (construction) line plus a symmetric constraint between two + * selected points, the spec's mirror-line workflow. + */ + function addMirrorLine(a: MmPoint, b: MmPoint): string { + const p1 = addPoint(a, true); + const p2 = addPoint(b, true); + const lineId = nextId(); + sketch.value.entities.push({ + kind: 'line', + id: lineId, + p1Id: p1, + p2Id: p2, + construction: true, + }); + return lineId; + } + + function addConstraint(constraint: SketchConstraint): void { + sketch.value.constraints.push(constraint); + } + + function addDimension(dimension: SketchDimension): void { + sketch.value.constraints.push(dimension); + } + + /** Rewrites a dimension's value in place (click-to-edit label). */ + function setDimensionValue(constraintId: string, value: number): void { + const dimension = sketch.value.constraints.find((c) => c.id === constraintId); + if (dimension === undefined) return; + switch (dimension.kind) { + case 'length': + case 'distance': + case 'radius': + case 'diameter': + dimension.mm = value; + break; + case 'angle': + dimension.degrees = value; + break; + case 'coincident': + case 'horizontal': + case 'vertical': + case 'parallel': + case 'perpendicular': + case 'tangent': + case 'symmetric': + // Not dimensions; nothing to edit. + break; + default: { + const exhaustive: never = dimension; + throw new Error(`Unhandled constraint kind: ${String(exhaustive)}`); + } + } + } + + function removeConstraint(constraintId: string): void { + sketch.value.constraints = sketch.value.constraints.filter((c) => c.id !== constraintId); + } + + function toggleConstruction(entityId: string): void { + const entity = sketch.value.entities.find((e) => e.id === entityId); + if (entity === undefined) return; + entity.construction = !entity.construction; + } + + /** + * Runs the solver in the sketch worker over the current sketch, writing + * solved coordinates back on success. With a drag target this is the + * driven-point workflow used while a point is dragged. + */ + async function solveNow(drag?: DragTarget): Promise { + const result = await solveSketchInWorker( + JSON.parse(JSON.stringify(sketch.value)) as Sketch, + drag, + ); + solveState.value = result; + if (result.status === 'solved') { + sketch.value = result.sketch; + } + } + + return { + sketch, + activeTool, + selectedIds, + solveState, + editingToolId, + chainTailId, + underlayUrl, + underlayOpacityPct, + underlayMmPerPixel, + nextId, + startNewSketch, + loadSketch, + addPoint, + appendChainPoint, + closeChainTo, + endChain, + addCircle, + addThreePointArc, + addMirrorLine, + addConstraint, + addDimension, + setDimensionValue, + removeConstraint, + toggleConstruction, + solveNow, + }; +}); +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `npx vitest run tests/stores/sketchEditor.spec.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/stores/sketchEditor.ts tests/stores/sketchEditor.spec.ts +git commit -m "Add the sketch editor store." +``` + +--- + +### Task 8: Sketch canvas and workspace, input toggle + +The repo has no component tests (no `@vue/test-utils` anywhere under `web/tests`), so this UI task carries no new spec files; the engine behavior underneath is already tested. Verification is the typecheck plus the build. + +**Files:** +- Create: `web/src/components/trace/sketch/SketchCanvas.vue` +- Create: `web/src/components/trace/sketch/SketchWorkspace.vue` +- Modify: `web/src/components/trace/TraceTab.vue` (input toggle on stage 1) + +**Interfaces:** +- Consumes: `useSketchEditor` (Task 7), `Sketch` types (Task 2), `viewTransform` helpers from `web/src/components/trace/viewTransform.ts` (`ViewTransform`, `zoomToCursor`, `screenToImage`). +- Produces: `SketchWorkspace.vue` emitting `finish` (handled in Task 10) and `cancel`; a `traceInput` toggle (`'photo' | 'sketch'`) in `TraceTab.vue`. + +- [ ] **Step 1: Implement SketchCanvas.vue** + +Create `web/src/components/trace/sketch/SketchCanvas.vue`. The canvas is an SVG in mm coordinates: the `viewBox` derives from a pan/zoom `ViewTransform` reused from the trace canvas math (`zoomToCursor`, `screenToImage`), with a 10 mm grid, an entity layer, a dimension-label layer and an optional underlay image. All geometry lives in the store's `Sketch`; the canvas renders and forwards pointer events. + +```vue + + + + + +``` + +- [ ] **Step 2: Implement SketchWorkspace.vue** + +Create `web/src/components/trace/sketch/SketchWorkspace.vue`: the toolbar, the tool state machine over canvas clicks, the solver scheduling, and the finish/cancel buttons. The dimension input and status rows are extended in Task 9; this step wires the drawing tools. + +```vue + + + + + +``` + +- [ ] **Step 3: Add the input toggle to TraceTab.vue** + +In `web/src/components/trace/TraceTab.vue`: + +- Add to the script setup, near the existing `stage` ref (line 36): + +```typescript +import SketchWorkspace from './sketch/SketchWorkspace.vue'; +import { useSketchEditor } from '../../stores/sketchEditor'; + +/** How the tool outline is produced on stage 1: a photo trace or a drawn sketch. */ +const traceInput = ref<'photo' | 'sketch'>('photo'); +const sketchEditor = useSketchEditor(); + +function startSketch(): void { + traceInput.value = 'sketch'; + sketchEditor.startNewSketch(); +} +``` + +- In the template's stage-1 block (the part that renders `PhotoStage`), wrap it with the toggle so the user picks between uploading and drawing. The exact markup around `PhotoStage` stays; add above it: + +```html + + Upload a photo + Draw the shape + +``` + +and render `PhotoStage` only `v-if="traceInput === 'photo'"`, with: + +```html + +``` + +`finishSketch` is defined in Task 10; for this task add a stub that only closes the workspace so the file compiles: + +```typescript +function finishSketch(): void { + // Wired to profile extraction and the layout step in the finish task. + traceInput.value = 'photo'; +} +``` + +(The stub is replaced within this same feature branch by Task 10; it ships nowhere.) + +- [ ] **Step 4: Typecheck and build** + +Run: `npm run build` +Expected: success, no type errors. + +- [ ] **Step 5: Commit** + +```bash +git add src/components/trace/sketch/SketchCanvas.vue src/components/trace/sketch/SketchWorkspace.vue src/components/trace/TraceTab.vue +git commit -m "Add the sketch canvas and workspace behind an upload-or-draw toggle." +``` + +--- + +### Task 9: Dimensions UI, solver diagnostics, photo underlay + +**Files:** +- Modify: `web/src/components/trace/sketch/SketchWorkspace.vue` + +**Interfaces:** +- Consumes: `useSketchEditor` actions `addDimension`, `setDimensionValue`, `addConstraint`, `nextId`, store refs `solveState`, `selectedIds`, `underlayUrl`, `underlayOpacityPct`, `underlayMmPerPixel` (Task 7); `SketchDimension` kinds (Task 2). +- Produces: the complete editor UI Task 10 finishes from. + +- [ ] **Step 1: Add dimensioning (click entities, then type a value)** + +In `SketchWorkspace.vue` script, add: + +```typescript +import { computed } from 'vue'; +import type { SketchEntity } from '../../../engine/sketch/model'; + +/** The dimension entry field: which constraint is being typed, and its text. */ +const dimensionDraft = ref<{ constraintId: string | null; text: string } | null>(null); + +function entityById(id: string): SketchEntity | undefined { + return sketch.value.entities.find((e) => e.id === id); +} + +/** + * With the dimension tool active, a selection of one or two entities decides + * the dimension kind: one line is a length, one arc or circle is a radius + * (Shift for diameter is deliberately not offered; a diameter is typed by + * picking Diameter in the field's kind menu), two points are a distance, two + * lines are an angle. + */ +function beginDimensionFromSelection(): void { + const picked = editor.selectedIds.map(entityById).filter((e): e is SketchEntity => e !== undefined); + let created: string | null = null; + if (picked.length === 1 && picked[0].kind === 'line') { + const id = editor.nextId(); + editor.addDimension({ kind: 'length', id, lineId: picked[0].id, mm: 10 }); + created = id; + } else if (picked.length === 1 && (picked[0].kind === 'arc' || picked[0].kind === 'circle')) { + const id = editor.nextId(); + editor.addDimension({ kind: 'radius', id, entityId: picked[0].id, mm: 10 }); + created = id; + } else if (picked.length === 2 && picked.every((e) => e.kind === 'point')) { + const id = editor.nextId(); + editor.addDimension({ kind: 'distance', id, p1Id: picked[0].id, p2Id: picked[1].id, mm: 10 }); + created = id; + } else if (picked.length === 2 && picked.every((e) => e.kind === 'line')) { + const id = editor.nextId(); + editor.addDimension({ + kind: 'angle', + id, + l1Id: picked[0].id, + l2Id: picked[1].id, + degrees: 90, + }); + created = id; + } else { + toolHint.value = + 'Select one line for a length, an arc or circle for a radius, two points for a distance, or two lines for an angle.'; + return; + } + dimensionDraft.value = { constraintId: created, text: '' }; + editor.selectedIds = []; +} + +function commitDimensionDraft(): void { + if (dimensionDraft.value === null || dimensionDraft.value.constraintId === null) return; + const value = Number(dimensionDraft.value.text); + if (!Number.isFinite(value) || value <= 0) { + toolHint.value = 'The dimension value must be a number above 0.'; + return; + } + editor.setDimensionValue(dimensionDraft.value.constraintId, value); + dimensionDraft.value = null; + scheduleSolve(); +} + +/** Click-to-edit on an existing on-canvas dimension label. */ +function onDimensionClick(constraintId: string): void { + const c = sketch.value.constraints.find((k) => k.id === constraintId); + if (c === undefined) return; + const current = + c.kind === 'angle' ? c.degrees : 'mm' in c ? c.mm : null; + dimensionDraft.value = { constraintId, text: current === null ? '' : String(current) }; +} +``` + +Change the canvas `@dimension-click` binding from the Task 8 no-op to `@dimension-click="(id: string) => onDimensionClick(id)"`, and change the `entityClick` handler to toggle selection and, when the dimension tool is active, call `beginDimensionFromSelection()` once the selection suffices: + +```typescript +function onEntityClick(entityId: string): void { + const at = editor.selectedIds.indexOf(entityId); + if (at === -1) editor.selectedIds.push(entityId); + else editor.selectedIds.splice(at, 1); + if (activeTool.value === 'dimension' && editor.selectedIds.length > 0) { + beginDimensionFromSelection(); + } +} +``` + +Add the entry field to the template, under the hint line: + +```html + +``` + +- [ ] **Step 2: Add the constraint buttons and the construction toggle** + +Constraint application follows the same select-then-apply pattern. Add to the script: + +```typescript +/** Applies a constraint to the current selection; each row names its need. */ +function applyConstraint(kind: 'horizontal' | 'vertical' | 'parallel' | 'perpendicular' | 'tangent' | 'coincident' | 'symmetric'): void { + const picked = editor.selectedIds.map(entityById).filter((e): e is SketchEntity => e !== undefined); + const id = editor.nextId(); + switch (kind) { + case 'horizontal': + case 'vertical': + if (picked.length === 1 && picked[0].kind === 'line') { + editor.addConstraint({ kind, id, lineId: picked[0].id }); + } else { + toolHint.value = 'Select one line first.'; + return; + } + break; + case 'parallel': + case 'perpendicular': + if (picked.length === 2 && picked.every((e) => e.kind === 'line')) { + editor.addConstraint({ kind, id, l1Id: picked[0].id, l2Id: picked[1].id }); + } else { + toolHint.value = 'Select two lines first.'; + return; + } + break; + case 'tangent': + if (picked.length === 2) { + editor.addConstraint({ kind: 'tangent', id, aId: picked[0].id, bId: picked[1].id }); + } else { + toolHint.value = 'Select the two entities to make tangent first.'; + return; + } + break; + case 'coincident': + if (picked.length === 2 && picked.every((e) => e.kind === 'point')) { + editor.addConstraint({ kind: 'coincident', id, p1Id: picked[0].id, p2Id: picked[1].id }); + } else { + toolHint.value = 'Select two points first.'; + return; + } + break; + case 'symmetric': + if ( + picked.length === 3 && + picked.filter((e) => e.kind === 'point').length === 2 && + picked.filter((e) => e.kind === 'line').length === 1 + ) { + const pts = picked.filter((e) => e.kind === 'point'); + const mirror = picked.find((e) => e.kind === 'line')!; + editor.addConstraint({ + kind: 'symmetric', + id, + p1Id: pts[0].id, + p2Id: pts[1].id, + mirrorLineId: mirror.id, + }); + } else { + toolHint.value = 'Select two points and the mirror line first.'; + return; + } + break; + default: + assertNever(kind); + } + editor.selectedIds = []; + scheduleSolve(); +} + +function toggleConstructionOnSelection(): void { + for (const id of editor.selectedIds) editor.toggleConstruction(id); + scheduleSolve(); +} +``` + +Add a second toolbar row to the template: + +```html + + Horizontal + Vertical + Parallel + Perpendicular + Tangent + Coincident + Symmetric + Construction + +``` + +- [ ] **Step 3: Add the solver status rows** + +Diagnostic readouts are labeled rows (convention 8). Add to the script: + +```typescript +/** The solver readout as labeled rows, not prose. */ +const statusRows = computed<{ label: string; value: string }[]>(() => { + const state = solveState.value; + switch (state.status) { + case 'idle': + return [{ label: 'Solver', value: 'not yet run' }]; + case 'solved': + return [ + { label: 'Solver', value: state.dof === 0 ? 'fully constrained' : 'under-constrained' }, + { label: 'Degrees of freedom', value: String(state.dof) }, + ]; + case 'conflicting': + return [ + { label: 'Solver', value: 'conflicting constraints' }, + ...state.conflictingConstraintIds.map((id) => ({ label: 'Conflicting constraint', value: id })), + ]; + case 'failed': + return [{ label: 'Solver', value: state.message }]; + default: + return assertNever(state); + } +}); + +/** Removes one conflicting constraint from its diagnostics row. */ +function removeConflicting(constraintId: string): void { + editor.removeConstraint(constraintId); + scheduleSolve(); +} +``` + +Template, under the canvas: + +```html +
+
+ {{ row.label }} + {{ row.value }} + + Remove + +
+
+``` + +with styles: + +```css +.status-rows { + padding: 4px 12px; + font-size: 0.85rem; +} +.status-row { + display: flex; + gap: 8px; + align-items: center; +} +.status-label { + color: rgba(0, 0, 0, 0.6); + min-width: 170px; +} +``` + +- [ ] **Step 4: Add the photo underlay controls** + +Add to the script: + +```typescript +/** The two clicked ends of the calibration line over the underlay, in image px. */ +const calibrationClicks = ref([]); +const calibrationLengthText = ref(''); +const calibrating = ref(false); + +function onUnderlayFile(file: File | null): void { + if (editor.underlayUrl !== null) URL.revokeObjectURL(editor.underlayUrl); + editor.underlayUrl = file === null ? null : URL.createObjectURL(file); + editor.underlayMmPerPixel = file === null ? null : 1; + calibrationClicks.value = []; +} + +/** + * One-line scale calibration: the user draws one line over the photo and + * types its real length. Display only; the figure scales the underlay image + * and never enters the sketch geometry. + */ +function commitCalibration(): void { + const lengthMm = Number(calibrationLengthText.value); + if (calibrationClicks.value.length !== 2 || !Number.isFinite(lengthMm) || lengthMm <= 0) { + toolHint.value = 'Click the two ends of a known distance on the photo, then type its length in mm.'; + return; + } + const [a, b] = calibrationClicks.value; + const drawnMm = Math.hypot(b.x - a.x, b.y - a.y); + const currentScale = editor.underlayMmPerPixel ?? 1; + // The clicks are in current display mm; rescale so the drawn span reads lengthMm. + editor.underlayMmPerPixel = (currentScale * lengthMm) / drawnMm; + calibrating.value = false; + calibrationClicks.value = []; +} +``` + +In `onCanvasClick`, before the tool switch, intercept calibration clicks: + +```typescript + if (calibrating.value) { + pendingClicks.value = []; + calibrationClicks.value.push(at); + if (calibrationClicks.value.length > 2) calibrationClicks.value = [at]; + return; + } +``` + +Template, in the second toolbar row's right side: + +```html + + + + + Set photo scale + + +``` + +- [ ] **Step 5: Typecheck and build** + +Run: `npm run build` +Expected: success. + +- [ ] **Step 6: Commit** + +```bash +git add src/components/trace/sketch/SketchWorkspace.vue +git commit -m "Add dimensioning, constraints, solver diagnostics and the photo underlay." +``` + +--- + +### Task 10: Finish flow, reopening sketched tools, final verification + +**Files:** +- Modify: `web/src/components/trace/TraceTab.vue` (real `finishSketch`, reopen path) +- Modify: `web/src/components/trace/LayoutWorkspace.vue` (the tool rail's re-trace affordance branches on `tool.source`) +- Test: `web/tests/stores/toolTrace.spec.ts` (sketched tool lands in the layout with its sketch) + +**Interfaces:** +- Consumes: `extractProfile` (Task 4), `solveSketchInWorker` via `editor.solveNow` (Tasks 5, 7), `useToolTrace().addTool` with the `source` parameter (Task 6), `useSketchEditor().loadSketch` (Task 7). +- Produces: the finished feature; nothing downstream. + +- [ ] **Step 1: Write the failing store test** + +Add to `web/tests/stores/toolTrace.spec.ts` (following the file's existing setup helpers): + +```typescript +import { SKETCH_SCHEMA_VERSION } from '../../src/engine/sketch/model'; + +it('adds a sketched tool carrying its editable sketch', () => { + const trace = useToolTrace(); + const sketch = { + schemaVersion: SKETCH_SCHEMA_VERSION, + entities: [ + { kind: 'point' as const, id: 'pc', x: 0, y: 0, construction: false }, + { kind: 'circle' as const, id: 'c1', centerId: 'pc', radiusMm: 12, construction: false }, + ], + constraints: [], + }; + const outline = { + outer: [ + { x: -12, y: -12 }, + { x: 12, y: -12 }, + { x: 12, y: 12 }, + { x: -12, y: 12 }, + ], + holes: [], + }; + const tool = trace.addTool(outline, 'Sketched shape', [], false, [], { + kind: 'sketch', + sketch, + }); + expect(tool.source.kind).toBe('sketch'); + if (tool.source.kind === 'sketch') { + expect(tool.source.sketch).toEqual(sketch); + } + expect(trace.placements.some((p) => p.toolId === tool.id)).toBe(true); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest run tests/stores/toolTrace.spec.ts` +Expected: FAIL if Task 6 did not yet thread `source` through the store's `addTool` signature exactly as specified there (parameter after `brushStrokes`); otherwise it passes immediately, which is acceptable: it then pins the contract this task relies on. + +- [ ] **Step 3: Implement the finish flow in TraceTab.vue** + +Replace the Task 8 stub: + +```typescript +import { extractProfile } from '../../engine/sketch/profile'; +import { cloneSketch } from '../../engine/sketch/model'; + +/** Error from the last finish attempt, shown as an alert over the workspace. */ +const sketchFinishError = ref(null); + +/** + * Validates the sketch through the profile extractor and drops the resulting + * outline into the normal tool placement step. The sketch itself travels on + * the tool so it can be reopened and edited later. + */ +async function finishSketch(): Promise { + sketchFinishError.value = null; + // One final solve so the extracted profile is the solved geometry. + await sketchEditor.solveNow(); + const state = sketchEditor.solveState; + if (state.status === 'conflicting') { + sketchFinishError.value = + 'The sketch has conflicting constraints. Remove one of the constraints listed under the canvas.'; + return; + } + if (state.status === 'failed') { + sketchFinishError.value = state.message; + return; + } + const profile = extractProfile(sketchEditor.sketch); + if (!profile.ok) { + sketchFinishError.value = profile.error; + return; + } + const source = { kind: 'sketch' as const, sketch: cloneSketch(sketchEditor.sketch) }; + if (sketchEditor.editingToolId !== null) { + // Re-editing a sketched tool: replace its outline and sketch in place. + const tool = trace.tools.find((t) => t.id === sketchEditor.editingToolId); + if (tool !== undefined) { + trace.replaceToolOutline(tool.id, profile.outline, []); + tool.source = source; + } + } else { + trace.addTool(profile.outline, 'Sketched shape', [], false, [], source); + } + traceInput.value = 'photo'; + stage.value = 2; + trace.workspaceMode = 'layout'; +} +``` + +and surface the error above the workspace in the template: + +```html + + {{ sketchFinishError }} + +``` + +Also add the reopen path: when the layout rail asks to edit a sketched tool (Step 4 emits it), handle: + +```typescript +/** Opens a sketched tool's stored sketch back in the sketch workspace. */ +function editSketchedTool(toolId: string): void { + const tool = trace.tools.find((t) => t.id === toolId); + if (tool === undefined) return; + switch (tool.source.kind) { + case 'photo': + return; // photo tools re-trace through the existing path + case 'sketch': + sketchEditor.loadSketch(tool.source.sketch, toolId); + stage.value = 1; + traceInput.value = 'sketch'; + return; + default: + return assertNever(tool.source); + } +} +``` + +(import `assertNever` from `../../engine/plan/types`), and pass it to `LayoutWorkspace` as a prop or event handler per that component's existing pattern (it already emits or calls the re-trace request through `trace.retraceRequestId`; wire `edit-sketch` alongside). + +- [ ] **Step 4: Branch the tool rail on the tool source** + +In `web/src/components/trace/LayoutWorkspace.vue`, find the tool rail's re-trace button (it sets `trace.retraceRequestId`). Replace its unconditional rendering with an exhaustive branch on `tool.source.kind` computed per tool: + +```typescript +import { assertNever } from '../../engine/plan/types'; +import type { TracedTool } from '../../engine/trace/types'; + +/** The edit affordance a tool row shows, by outline source. */ +function editActionOf(tool: TracedTool): 'retrace' | 'editSketch' { + switch (tool.source.kind) { + case 'photo': + return 'retrace'; + case 'sketch': + return 'editSketch'; + default: + return assertNever(tool.source); + } +} +``` + +In the template, where the re-trace button renders, branch: + +```html + + Re-trace + + + Edit sketch + +``` + +adding `editSketch` to the component's `defineEmits` and `requestRetrace` being whatever the existing button already called (keep its current handler name). Wire `@edit-sketch="editSketchedTool"` where `TraceTab.vue` renders `LayoutWorkspace`. + +- [ ] **Step 5: Run the full verification** + +Run: `npx vitest run` then `npm run build` +Expected: every test green, build clean. + +Then run: `npm test` +Expected: green (same suite through the project's own script, the CI bar). + +- [ ] **Step 6: Commit** + +```bash +git add src/components/trace/TraceTab.vue src/components/trace/LayoutWorkspace.vue tests/stores/toolTrace.spec.ts +git commit -m "Wire the sketch finish flow into tool placement and sketch re-editing." +``` + +--- + +## Spec coverage self-review + +- Engine `model.ts` with schema version, entities (point/line/arc/circle, construction flag), all listed constraints and dimensions, exhaustive unions, validation, serialization round-trip: Task 2. +- `solve.ts` adapter with status fully constrained / under-constrained with DOF / conflicting with offending ids, driven-point drag: Task 3. +- `profile.ts` closed loop extraction, arc flattening at the shared 0.2 mm trace tolerance, returns `TracedOutline`, all four user-worded failures: Task 4. +- Dedicated `sketch.worker.ts` and `sketchClient.ts` on the Comlink pattern, WASM as a separate asset: Task 5; LGPL attribution: Task 1. +- Tool origin discriminator mirroring `Bin.origin`, embedded editable sketch, plan version 11 with validators and default-on-pick migration, exhaustive switches: Task 6 (data), Task 10 (UI branch). +- Upload-or-draw toggle at the input step, SVG mm-grid canvas with pan/zoom, select/drag, line chain, three-point arc, tangent continuation, center-plus-diameter circle (center plus rim click stores the radius; the diameter dimension types the exact figure), construction toggle, mirror line: Task 8. +- Click-then-type dimensions with click-to-edit on-canvas labels, solver runs after every edit, distinct colors for under-constrained vs fully constrained vs conflicting, conflicts as labeled rows, photo underlay with opacity and one-line display-only calibration: Task 9. +- Finish button validating through `profile.ts` into the normal placement and depth step, reopening sketched tools: Task 10. +- Testing section: model round-trips (Task 2), solve against dimensioned rectangle, tangent arc chain, symmetric profile, over-constrained conflict (Task 3), profile extraction with every failure (Task 4), plan version migration (Task 6), node WASM smoke test on the vision pattern (Task 1). UI carries no component tests because the repo has none. diff --git a/docs/superpowers/plans/2026-07-25-multi-source-tool-bin.md b/docs/superpowers/plans/2026-07-25-multi-source-tool-bin.md new file mode 100644 index 0000000..e2f8abb --- /dev/null +++ b/docs/superpowers/plans/2026-07-25-multi-source-tool-bin.md @@ -0,0 +1,1941 @@ +# Multi-Source Tool Bin Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Reshape the tool-bin data model around trace sessions so one bin can combine tools from several photographed sheets plus sketched and primitive tools, with each `ToolSource` variant owning its re-edit data, and rebuild the tab's input stage as a source list. + +**Architecture:** `TracedBin` gains `traceSessions: TraceSession[]`; `ToolSource` becomes a three-variant union (`photo` with `sessionId`/`clicks`/`brushStrokes`, `sketch` with its `Sketch`, `primitive` with nothing) and `clicks`/`brushStrokes` leave `TracedTool`. Plan file version 11 is reshaped in place (it is unshipped; per the owner's standing rule, unshipped-schema local test data is discarded, never migrated). Version 10 plans migrate on load: the bin-level `traceSourceId`/`paper` pair becomes one session, tools with clicks become photo tools referencing it, tools with empty clicks become primitive, sketch tools stay sketch. The `toolTrace` store's single-photo state becomes active-session state with one atomic `activateSession` action and `embedReady` keyed by session id. Sessions are reference-counted on save: only sessions some tool references are stored with the bin, and the photo-store sweep keeps only referenced session photos. + +**Tech Stack:** Vue 3 + TypeScript + Pinia + Vitest. Engine code stays framework-agnostic (`web/src/engine/` imports no Vue, no Pinia, no DOM). + +## Global Constraints + +- Never use the em-dash character, and never a hyphen as a substitute for it (CLAUDE.md convention 6). This applies to code comments, UI text, test names and commit messages. +- Every branch on `ToolSource` (and every other discriminated union) handles every member explicitly and ends in `assertNever` (convention 13). The new `primitive` member must appear in every switch. +- planFile.ts validation message convention (documented at the top of that file): return `null` when valid, otherwise an optional lowercase subject prefix followed by exactly one complete sentence, capital letter, user-facing field names, full stop. +- No silently swallowed errors (convention 2): a `catch` surfaces, rethrows, or returns a value the caller acts on. +- UI text is plain technical prose in complete sentences, 3D-printing-community terminology (convention 7). +- Interim fixes are forbidden: this is the final structure, no compatibility shims for the unshipped v11 shape. +- All commands run inside `web/`. Verification bar: `npm run build` and `npm test` green. +- Commits: single short sentence, optionally ending with `Co-Authored-By: Claude `. +- Working branch: `sketch-workspace`. Commit at will on the branch; do not push. + +--- + +### Task 1: ToolSource three-variant union and TraceSession type + +Move `clicks` and `brushStrokes` off `TracedTool` into the photo variant, add the `primitive` variant, add the `TraceSession` type, and swap `TracedBin`'s `traceSourceId`/`paper` for `traceSessions`. Then follow the compiler through every consumer. planFile.ts is only patched enough to compile here (it is rebuilt properly in Task 2). + +**Files:** +- Modify: `web/src/engine/trace/types.ts:87` (ToolSource), `:139-177` (TracedTool) +- Modify: `web/src/engine/plan/types.ts:129-138` (TracedBin) +- Modify: `web/src/engine/trace/layoutModel.ts:369-461` (addTool, replaceToolOutline) +- Modify: `web/src/stores/toolTrace.ts:161-191` (addTool, replaceToolOutline wrappers) +- Modify: `web/src/components/trace/toolEditAction.ts` +- Modify: `web/src/components/trace/TraceCanvas.vue:154-163, 829-840` +- Modify: `web/src/components/trace/LayoutWorkspace.vue:135` (primitive add), `:302-358` (save path, minimally, rebuilt in Task 4) +- Modify: `web/src/components/trace/LayoutToolbar.vue:192`, `web/src/components/trace/AdvancedDrawer.vue:202` +- Modify: `web/src/components/trace/TraceTab.vue:98-130` (sketch finish, edit-sketch switch) +- Modify: `web/src/engine/plan/planFile.ts` (compile-only stubs; full rebuild in Task 2) +- Modify: `web/src/engine/plan/storedAssets.ts:75-87` +- Test: `web/tests/stores/toolTrace.spec.ts` + +**Interfaces:** +- Produces (later tasks rely on these exact shapes): + +```ts +// web/src/engine/trace/types.ts +export interface TraceSession { + /** Stable id tools reference through their photo source's sessionId. */ + id: string; + /** Key of the session's photo in this device's photo store. */ + traceSourceId: string; + /** The reference-sheet setup the photo was rectified with. */ + paper: { corners: PaperCorners; kind: PaperKind }; +} + +export type ToolSource = + | { kind: 'photo'; sessionId: string; clicks: SamPoint[]; brushStrokes?: BrushStroke[] } + | { kind: 'sketch'; sketch: Sketch } + | { kind: 'primitive' }; +``` + +- Produces: `layoutModel.addTool(state, outline, name, pocketDepthMm, source: ToolSource, placeAtSheetPosition = false): TracedTool` and `layoutModel.replaceToolOutline(state, toolId, outline, source: ToolSource): void` +- Produces: store wrappers `trace.addTool(outline, name: string | undefined, source: ToolSource, placeAtSheetPosition = false)` and `trace.replaceToolOutline(toolId, outline, source: ToolSource)` +- Produces: `editActionOf(tool): 'retrace' | 'editSketch' | 'none'` + +- [ ] **Step 1: Update the store test to the new shapes (failing test first)** + +In `web/tests/stores/toolTrace.spec.ts`, change the sketched-tool test's `addTool` call and add a primitive and a photo case: + +```ts + it('adds a sketched tool carrying its editable sketch', () => { + const trace = useToolTrace(); + const sketch = { + schemaVersion: SKETCH_SCHEMA_VERSION, + entities: [ + { kind: 'point' as const, id: 'pc', x: 0, y: 0, construction: false }, + { kind: 'circle' as const, id: 'c1', centerId: 'pc', radiusMm: 12, construction: false }, + ], + constraints: [], + }; + const outline = { + outer: [ + { x: -12, y: -12 }, + { x: 12, y: -12 }, + { x: 12, y: 12 }, + { x: -12, y: 12 }, + ], + holes: [], + }; + const tool = trace.addTool(outline, 'Sketched shape', { kind: 'sketch', sketch }); + expect(tool.source.kind).toBe('sketch'); + if (tool.source.kind === 'sketch') { + expect(tool.source.sketch).toEqual(sketch); + } + expect(trace.placements.some((p) => p.toolId === tool.id)).toBe(true); + }); + + it('adds a primitive tool with no re-edit data', () => { + const trace = useToolTrace(); + const outline = { + outer: [ + { x: 0, y: 0 }, + { x: 10, y: 0 }, + { x: 10, y: 10 }, + { x: 0, y: 10 }, + ], + holes: [], + }; + const tool = trace.addTool(outline, 'Circle', { kind: 'primitive' }); + expect(tool.source).toEqual({ kind: 'primitive' }); + expect('clicks' in tool).toBe(false); + }); + + it('stores a photo tool\'s clicks and strokes inside its source', () => { + const trace = useToolTrace(); + const outline = { + outer: [ + { x: 0, y: 0 }, + { x: 10, y: 0 }, + { x: 10, y: 10 }, + { x: 0, y: 10 }, + ], + holes: [], + }; + const clicks = [{ x: 5, y: 5, label: 1 as const }]; + const tool = trace.addTool(outline, undefined, { + kind: 'photo', + sessionId: 's1', + clicks, + brushStrokes: [], + }); + expect(tool.source.kind).toBe('photo'); + if (tool.source.kind === 'photo') { + expect(tool.source.sessionId).toBe('s1'); + expect(tool.source.clicks).toEqual(clicks); + } + }); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest run tests/stores/toolTrace.spec.ts` +Expected: FAIL (type errors / wrong argument shapes against the current signatures). + +- [ ] **Step 3: Rewrite the types** + +In `web/src/engine/trace/types.ts`: + +Replace the `ToolSource` declaration (line 82-87) with: + +```ts +/** + * Where a tool's outline came from, each variant owning its own re-edit + * data. A photo-traced tool names the trace session it was traced in and + * carries the clicks and brush strokes (rectified-image pixels of that + * session's sheet) that reproduce its segmentation. A sketched tool embeds + * its editable Sketch. A primitive tool (basic circle or rectangle) has no + * re-edit data at all. Discriminated on kind and always branched + * exhaustively (assertNever), mirroring Bin.origin. + */ +export type ToolSource = + | { kind: 'photo'; sessionId: string; clicks: SamPoint[]; brushStrokes?: BrushStroke[] } + | { kind: 'sketch'; sketch: Sketch } + | { kind: 'primitive' }; + +/** + * One photographed reference sheet a tool bin's photo tools were traced on. + * The photo blob itself lives in this device's photo store under + * traceSourceId; the session carries what re-tracing needs to reproduce the + * exact rectified image the tools' clicks refer to. A session is saved with + * the bin iff at least one tool references its id. + */ +export interface TraceSession { + id: string; + traceSourceId: string; + paper: { corners: PaperCorners; kind: PaperKind }; +} +``` + +In the `TracedTool` interface, delete the `clicks` field (lines 143-149), the `brushStrokes` field (lines 150-155), and update the `source` doc comment to `/** Where the outline came from, owning that origin's re-edit data. */`. Update the `BrushStroke` doc comment's frame reference (line 14) from `TracedTool.clicks` to `the photo source's clicks`. + +In `web/src/engine/plan/types.ts`, replace `TracedBin`'s `traceSourceId?` and `paper?` fields (lines 130-137) with: + +```ts + /** + * The photographed sheets this bin's photo tools were traced on. Empty for + * bins with only sketched or primitive tools, and for plans imported from + * devices that no longer hold the photos (the photo store lookup then + * comes back empty and the bin is layout-only editable). + */ + traceSessions: TraceSession[]; +``` + +Import `TraceSession` from `../trace/types` in plan/types.ts. Keep the `TracePaper` interface (line 28-33) where it is: it is still the shape of `TraceSession.paper` as stored in the plan, and the v10 migration validator still reads the legacy bin-level field. Note that `TraceSession.paper` is structurally identical to `TracePaper`; do not declare a second corners-plus-kind type anywhere. + +- [ ] **Step 4: Follow the compiler** + +Run: `npx vue-tsc --noEmit` and fix every error site as follows. + +`web/src/engine/trace/layoutModel.ts`: change `addTool` (line 369) to + +```ts +export function addTool( + state: LayoutState, + outline: TracedOutline, + name: string, + pocketDepthMm: number, + source: ToolSource, + placeAtSheetPosition = false, +): TracedTool { + const tool: TracedTool = { + id: crypto.randomUUID(), + name, + outline: recentred(outline), + rotationDeg: 0, + offsetMm: DEFAULT_CLEARANCE_MM, + mirrored: false, + minHoleWidthMm: DEFAULT_MIN_HOLE_WIDTH_MM, + filledHoleIndices: [], + fingerHoles: [], + source: cloneSource(source), + }; + // ... rest unchanged +``` + +and `replaceToolOutline` (line 441) to + +```ts +export function replaceToolOutline( + state: LayoutState, + toolId: string, + outline: TracedOutline, + source: ToolSource, +): void { + const tool = state.tools.find((t) => t.id === toolId); + if (tool === undefined) return; + tool.outline = recentred(outline); + tool.source = cloneSource(source); + tool.filledHoleIndices = []; + // ... placement update and refit unchanged +``` + +Add next to `cloneStrokes` (and delete `cloneStrokes` if nothing else uses it): + +```ts +/** Deep-copies a tool source so the stored tool never aliases caller state. */ +function cloneSource(source: ToolSource): ToolSource { + return JSON.parse(JSON.stringify(source)) as ToolSource; +} +``` + +`web/src/stores/toolTrace.ts`: change the wrappers (lines 161-191) to + +```ts + function addTool( + outline: TracedOutline, + name: string | undefined, + source: ToolSource, + placeAtSheetPosition = false, + ): TracedTool { + toolCounter += 1; + const tool = layout.addTool( + layoutState, + outline, + name ?? `Tool ${toolCounter}`, + defaultDepthMm.value, + source, + placeAtSheetPosition, + ); + selectedToolId.value = tool.id; + return tool; + } + + function replaceToolOutline(toolId: string, outline: TracedOutline, source: ToolSource): void { + layout.replaceToolOutline(layoutState, toolId, outline, source); + } +``` + +Drop the now-unused `BrushStroke` and `SamPoint` imports if the compiler flags them. + +`web/src/components/trace/toolEditAction.ts`: extend to the third variant. + +```ts +import { assertNever } from '../../engine/plan/types'; +import type { TracedTool } from '../../engine/trace/types'; + +/** + * The edit affordance a tool row shows, by outline source: a photo-traced + * tool re-traces from its stored clicks, a sketched tool reopens its stored + * sketch for editing, and a primitive shape has nothing to reopen. Shared by + * every place a tool row renders its edit button (the advanced drawer's tool + * list, the selection toolbar's menu), so they never drift out of step. + */ +export function editActionOf(tool: TracedTool): 'retrace' | 'editSketch' | 'none' { + switch (tool.source.kind) { + case 'photo': + return 'retrace'; + case 'sketch': + return 'editSketch'; + case 'primitive': + return 'none'; + default: + return assertNever(tool.source); + } +} +``` + +`web/src/components/trace/LayoutToolbar.vue:192` and `web/src/components/trace/AdvancedDrawer.vue:202`: replace the condition `editActionOf(tool) === 'retrace' && tool.clicks.length > 0` with `editActionOf(tool) === 'retrace'` (a photo source always carries its clicks now). + +`web/src/components/trace/TraceCanvas.vue`: in the retrace watcher (lines 154-163), read from the source: + +```ts + const tool = store.tools.find((t) => t.id === toolId); + store.retraceRequestId = null; + if (tool === undefined || tool.source.kind !== 'photo') return; + points.value = JSON.parse(JSON.stringify(tool.source.clicks)) as SamPoint[]; + strokes.value = tool.source.brushStrokes + ? (JSON.parse(JSON.stringify(tool.source.brushStrokes)) as BrushStroke[]) + : []; +``` + +In the accept handler (lines 826-840), build the source. The active session id comes from the store (added in Task 5; until then use `store.sourceId ?? ''`, and Task 5's step replaces it, which is acceptable only because Task 5 hard-replaces this line; flag it with a comment `// Session id wiring lands with the active-session store state.`): + +```ts + const clicks = JSON.parse(JSON.stringify(points.value)) as SamPoint[]; + const brushStrokes = JSON.parse(JSON.stringify(strokes.value)) as BrushStroke[]; + const source: ToolSource = { + kind: 'photo', + // Session id wiring lands with the active-session store state. + sessionId: store.sourceId ?? '', + clicks, + brushStrokes, + }; + if (retracingToolId !== null) { + store.replaceToolOutline(retracingToolId, outline.value, source); + } else { + const tool = store.addTool(outline.value, undefined, source, true); + } +``` + +(Adapt the surrounding variable names to what the file actually uses at those lines; the shape of the call is what matters.) Import `ToolSource` in the file's type imports. + +`web/src/components/trace/LayoutWorkspace.vue:135`: the primitive-shape dialog's add call becomes `trace.addTool(outline, name, { kind: 'primitive' })` (keeping whatever outline and name expressions are already there; primitives were previously mislabeled as photo). + +`web/src/components/trace/TraceTab.vue`: in `finishSketch` (lines 103-107), the two calls become + +```ts + trace.replaceToolOutline(tool.id, profile.outline, source); +``` + +and + +```ts + trace.addTool(profile.outline, 'Sketched shape', source); +``` + +(delete the now-redundant `tool.source = source;` line 104). In `editSketchedTool` (lines 115-130), add the `primitive` case to the switch: + +```ts + case 'primitive': + return; // a primitive shape has nothing to reopen +``` + +`web/src/engine/plan/storedAssets.ts:75-78`: the traced branch of `referencedAssetIds` becomes + +```ts + case 'traced': + for (const session of bin.traceSessions) tracePhotos.add(session.traceSourceId); + return; +``` + +`web/src/engine/plan/planFile.ts`: patch only what the compiler forces (the `TracedBin` construction in `pickBin` and the `pickPockets` tool mapping). Give `pickBin`'s traced branch `traceSessions: []` and delete `assignTraceSource`'s call, and in `pickPockets` map `source: { kind: 'primitive' }` for every tool temporarily. This is throwaway scaffolding that Task 2 replaces wholesale; do not invest in it. If other spec files under `web/tests/` reference `tool.clicks`, `bin.traceSourceId` or `bin.paper` and fail to compile, update those fixtures to the new shapes minimally so the suite compiles (Task 2 rewrites the planFile fixtures properly). + +- [ ] **Step 5: Typecheck and run the store test** + +Run: `npx vue-tsc --noEmit` +Expected: clean. +Run: `npx vitest run tests/stores/toolTrace.spec.ts` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add -A +git commit -m "Make ToolSource a three-variant union owning its re-edit data." +``` + +--- + +### Task 2: Plan file v11 reshape and v10 migration + +Rebuild planFile.ts for the new shape: `traceSessions` validation, three-variant source validation with cross-checked session ids, migration of v10 bins (bin-level `traceSourceId`/`paper` to one session, tool classification), and the version-history comment. Version number stays 11 (unshipped, reshaped in place, never bumped to 12). + +**Files:** +- Modify: `web/src/engine/plan/planFile.ts:129-315` (validatePockets), `:317-387` (pickPockets, pickToolSource), `:633-691` (trace-source validation and pick), `:848-908` (validateBin traced branch, pickBin), `:1914-1928` (version-history comment) +- Test: `web/tests/plan/planFile.spec.ts` + +**Interfaces:** +- Consumes: `TraceSession`, three-variant `ToolSource` from Task 1. +- Produces: `validateTraceSessions(raw: unknown, subject: string): string | null`; `validatePockets(raw: unknown, subject: string, sessionIds: ReadonlySet | null): string | null` where `null` sessionIds means legacy (pre-session) mode; `pickTraceSessions(raw: Record): TraceSession[]`; `pickPockets(raw: Record, migratedSessionId: string | null): BinPockets`. + +- [ ] **Step 1: Write the failing tests** + +Add to `web/tests/plan/planFile.spec.ts` (follow the file's existing helper style for building a minimal valid plan envelope; the fixtures below spell out the traced-bin payloads in full). A minimal valid traced-bin entry for these tests: + +```ts +const squareOutline = { + outer: [ + { x: 0, y: 0 }, + { x: 20, y: 0 }, + { x: 20, y: 20 }, + { x: 0, y: 20 }, + ], + holes: [], +}; + +const paper = { + kind: 'a4', + corners: { + tl: { x: 0, y: 0 }, + tr: { x: 100, y: 0 }, + br: { x: 100, y: 140 }, + bl: { x: 0, y: 140 }, + }, +}; + +function tracedEntry(bin: Record): Record { + return { + id: 'e1', + createdAt: '2026-07-25T00:00:00.000Z', + quantity: 1, + product: { + kind: 'bin', + labelSlot: false, + bin: { + origin: 'traced', + gridX: 1, + gridY: 1, + heightUnits: 6, + magnetHoles: false, + ...bin, + }, + }, + }; +} + +function toolBase(id: string): Record { + return { + id, + name: 'Tool', + outline: squareOutline, + rotationDeg: 0, + offsetMm: 1.5, + mirrored: false, + minHoleWidthMm: 0, + filledHoleIndices: [], + fingerHoles: [], + }; +} +``` + +Tests: + +```ts +describe('plan v11 trace sessions', () => { + it('round-trips a multi-session bin with photo, sketch and primitive tools', () => { + const plan = { + version: 11, + entries: [ + tracedEntry({ + traceSessions: [ + { id: 's1', traceSourceId: 'p1', paper }, + { id: 's2', traceSourceId: 'p2', paper }, + ], + pockets: { + tools: [ + { + ...toolBase('t1'), + source: { kind: 'photo', sessionId: 's1', clicks: [{ x: 1, y: 2, label: 1 }] }, + }, + { + ...toolBase('t2'), + source: { kind: 'photo', sessionId: 's2', clicks: [{ x: 3, y: 4, label: 1 }] }, + }, + { ...toolBase('t3'), source: { kind: 'primitive' } }, + ], + placements: [ + { toolId: 't1', xMm: 10, yMm: 10, pocketDepthMm: 20, draftAngleDeg: 0 }, + { toolId: 't2', xMm: 25, yMm: 10, pocketDepthMm: 20, draftAngleDeg: 0 }, + { toolId: 't3', xMm: 10, yMm: 25, pocketDepthMm: 20, draftAngleDeg: 0 }, + ], + }, + }), + ], + batches: [], + }; + const result = parsePlanFile(JSON.stringify(plan)); + expect(result.ok).toBe(true); + if (!result.ok) return; + const bin = binOf(result.plan.entries[0].product); + if (bin === null || bin.origin !== 'traced') throw new Error('expected a traced bin'); + expect(bin.traceSessions.map((s) => s.id)).toEqual(['s1', 's2']); + const reparsed = parsePlanFile( + serializePlanFile(result.plan.entries, result.plan.batches, result.plan.groups), + ); + expect(reparsed).toEqual(result); + }); + + it('rejects a photo tool whose sessionId is not one of the bin sessions', () => { + const plan = { + version: 11, + entries: [ + tracedEntry({ + traceSessions: [{ id: 's1', traceSourceId: 'p1', paper }], + pockets: { + tools: [ + { + ...toolBase('t1'), + source: { kind: 'photo', sessionId: 'missing', clicks: [] }, + }, + ], + placements: [ + { toolId: 't1', xMm: 10, yMm: 10, pocketDepthMm: 20, draftAngleDeg: 0 }, + ], + }, + }), + ], + batches: [], + }; + const result = parsePlanFile(JSON.stringify(plan)); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toContain('names a photo sheet the bin does not have'); + }); + + it('rejects a duplicate session id', () => { + const plan = { + version: 11, + entries: [ + tracedEntry({ + traceSessions: [ + { id: 's1', traceSourceId: 'p1', paper }, + { id: 's1', traceSourceId: 'p2', paper }, + ], + pockets: { tools: [], placements: [] }, + }), + ], + batches: [], + }; + const result = parsePlanFile(JSON.stringify(plan)); + expect(result.ok).toBe(false); + }); + + it('migrates a v10 bin into one session and classifies its tools', () => { + const plan = { + version: 10, + entries: [ + tracedEntry({ + traceSourceId: 'photo-key', + paper, + pockets: { + tools: [ + { ...toolBase('t1'), clicks: [{ x: 1, y: 2, label: 1 }] }, + { ...toolBase('t2'), clicks: [] }, + { + ...toolBase('t3'), + clicks: [], + source: { + kind: 'sketch', + sketch: { + schemaVersion: SKETCH_SCHEMA_VERSION, + entities: [ + { kind: 'point', id: 'pc', x: 0, y: 0, construction: false }, + { kind: 'circle', id: 'c1', centerId: 'pc', radiusMm: 12, construction: false }, + ], + constraints: [], + }, + }, + }, + ], + placements: [ + { toolId: 't1', xMm: 10, yMm: 10, pocketDepthMm: 20, draftAngleDeg: 0 }, + { toolId: 't2', xMm: 25, yMm: 10, pocketDepthMm: 20, draftAngleDeg: 0 }, + { toolId: 't3', xMm: 10, yMm: 25, pocketDepthMm: 20, draftAngleDeg: 0 }, + ], + }, + }), + ], + batches: [], + }; + const result = parsePlanFile(JSON.stringify(plan)); + expect(result.ok).toBe(true); + if (!result.ok) return; + const bin = binOf(result.plan.entries[0].product); + if (bin === null || bin.origin !== 'traced') throw new Error('expected a traced bin'); + expect(bin.traceSessions).toHaveLength(1); + const session = bin.traceSessions[0]; + expect(session.traceSourceId).toBe('photo-key'); + expect(session.paper.kind).toBe('a4'); + const [t1, t2, t3] = bin.pockets.tools; + expect(t1.source.kind).toBe('photo'); + if (t1.source.kind === 'photo') { + expect(t1.source.sessionId).toBe(session.id); + expect(t1.source.clicks).toEqual([{ x: 1, y: 2, label: 1 }]); + } + expect(t2.source).toEqual({ kind: 'primitive' }); + expect(t3.source.kind).toBe('sketch'); + }); + + it('migrates a v10 bin without a stored photo to sessionless primitives', () => { + const plan = { + version: 10, + entries: [ + tracedEntry({ + pockets: { + tools: [{ ...toolBase('t1'), clicks: [{ x: 1, y: 2, label: 1 }] }], + placements: [ + { toolId: 't1', xMm: 10, yMm: 10, pocketDepthMm: 20, draftAngleDeg: 0 }, + ], + }, + }), + ], + batches: [], + }; + const result = parsePlanFile(JSON.stringify(plan)); + expect(result.ok).toBe(true); + if (!result.ok) return; + const bin = binOf(result.plan.entries[0].product); + if (bin === null || bin.origin !== 'traced') throw new Error('expected a traced bin'); + expect(bin.traceSessions).toEqual([]); + // Without a photo the clicks reference nothing re-traceable; the tool + // degrades to a primitive rather than a photo tool with a dangling session. + expect(bin.pockets.tools[0].source).toEqual({ kind: 'primitive' }); + }); +}); +``` + +Add the imports the file does not already have: `binOf` from `../../src/engine/plan/types`, `SKETCH_SCHEMA_VERSION` from `../../src/engine/sketch/model`, `serializePlanFile` and `parsePlanFile` from `../../src/engine/plan/planFile`. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `npx vitest run tests/plan/planFile.spec.ts` +Expected: FAIL (session validation and migration do not exist yet). + +- [ ] **Step 3: Implement the reshape in planFile.ts** + +3a. Add `validateTraceSessions` and `pickTraceSessions` next to the existing `validateTraceSource` (keep `validateTraceSource` and `pickTracePaper`; they now serve the v10 legacy fields). Extract the corner-checking loop shared with `validateTraceSource` into a helper so the paper shape is validated in one place: + +```ts +/** Validates a paper object (kind plus four corners); shared by the session and the legacy bin-level field. */ +function validatePaper(raw: unknown, subject: string): string | null { + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { + return `${subject}: The paper must be an object.`; + } + const paper = raw as Record; + if (paper.kind !== 'a4' && paper.kind !== 'letter') { + return `${subject}: The paper kind must be a4 or letter.`; + } + const corners = paper.corners as Record | null | undefined; + if (typeof corners !== 'object' || corners === null || Array.isArray(corners)) { + return `${subject}: The paper corners must be an object.`; + } + for (const key of CORNER_KEYS) { + const corner = corners[key] as Record | null | undefined; + if ( + typeof corner !== 'object' || + corner === null || + !isFiniteNumber(corner.x) || + !isFiniteNumber(corner.y) + ) { + return `${subject}: The paper corner ${key} needs an x and a y coordinate.`; + } + } + return null; +} + +/** + * Validates a traced bin's list of trace sessions (the photographed sheets + * its photo tools reference). Returns null when valid, otherwise a message + * naming the first offending session. + */ +export function validateTraceSessions(raw: unknown, subject: string): string | null { + if (!Array.isArray(raw)) { + return `${subject}: The photo sheets must be a list.`; + } + const ids = new Set(); + for (const rawSession of raw) { + if (typeof rawSession !== 'object' || rawSession === null || Array.isArray(rawSession)) { + return `${subject}: A photo sheet is not an object.`; + } + const session = rawSession as Record; + if (typeof session.id !== 'string' || session.id.length === 0) { + return `${subject}: A photo sheet is missing its id.`; + } + if (ids.has(session.id)) { + return `${subject}: The photo sheet id ${session.id} appears twice.`; + } + ids.add(session.id); + if (typeof session.traceSourceId !== 'string' || session.traceSourceId.length === 0) { + return `${subject}: photo sheet ${session.id}: The stored photo id must be text that is not empty.`; + } + const paperProblem = validatePaper(session.paper, `${subject}: photo sheet ${session.id}`); + if (paperProblem !== null) return paperProblem; + } + return null; +} + +/** Copies only the known TraceSession fields from a validated raw list. */ +export function pickTraceSessions(raw: Record): TraceSession[] { + if (!Array.isArray(raw.traceSessions)) return []; + return (raw.traceSessions as Record[]).map((session) => ({ + id: session.id as string, + traceSourceId: session.traceSourceId as string, + paper: pickTracePaper(session.paper as Record), + })); +} +``` + +Rewrite `validateTraceSource`'s paper branch to delegate: `if (raw.paper !== undefined) { return validatePaper(raw.paper, subject); }`. Import `TraceSession` from `../trace/types`. + +3b. Rework `validatePockets` to take the session ids and validate the source variants. Signature: `validatePockets(raw: unknown, subject: string, sessionIds: ReadonlySet | null)`, where `null` means a legacy (v10 or earlier) bin: tool-level `clicks`/`brushStrokes` are accepted for migration and `source` may be absent or `{ kind: 'photo' }` without a sessionId. Move the existing click-list and stroke-list checks (lines 196-238) into two helpers so both modes share them: + +```ts +function validateClickList(raw: unknown, subject: string): string | null { + if (!Array.isArray(raw)) { + return `${subject}: The clicks must be a list.`; + } + for (const rawClick of raw) { + const click = rawClick as Record | null; + if ( + typeof click !== 'object' || + click === null || + !isFiniteNumber(click.x) || + !isFiniteNumber(click.y) || + (click.label !== 0 && click.label !== 1) + ) { + return `${subject}: A click needs an x, a y and a label of 0 or 1.`; + } + } + return null; +} + +function validateStrokeList(raw: unknown, subject: string): string | null { + if (!Array.isArray(raw)) { + return `${subject}: The brush strokes must be a list.`; + } + for (const rawStroke of raw) { + const stroke = rawStroke as Record | null; + if ( + typeof stroke !== 'object' || + stroke === null || + (stroke.mode !== 'add' && stroke.mode !== 'erase' && stroke.mode !== 'smooth') || + !isFiniteNumber(stroke.radiusMm) || + (stroke.radiusMm as number) <= 0 || + !Array.isArray(stroke.points) + ) { + return `${subject}: A brush stroke needs a mode of add, erase or smooth, a radius above 0 mm and a list of points.`; + } + for (const rawPt of stroke.points as unknown[]) { + const pt = rawPt as Record | null; + if (typeof pt !== 'object' || pt === null || !isFiniteNumber(pt.x) || !isFiniteNumber(pt.y)) { + return `${subject}: A brush stroke point needs an x and a y.`; + } + } + } + return null; +} +``` + +Inside the per-tool loop, replace the old tool-level clicks/strokes blocks and the old source block with: + +```ts + if (sessionIds === null) { + // Legacy mode (plan version 10 or earlier): clicks and brush strokes + // sit on the tool itself and the source, when present at all, is a + // sketch or a bare photo marker. The pick step migrates them. + if (tool.clicks !== undefined) { + const clicksProblem = validateClickList(tool.clicks, `${subject}: pocket tool ${tool.id}`); + if (clicksProblem !== null) return clicksProblem; + } + if (tool.brushStrokes !== undefined) { + const strokesProblem = validateStrokeList( + tool.brushStrokes, + `${subject}: pocket tool ${tool.id}`, + ); + if (strokesProblem !== null) return strokesProblem; + } + if (tool.source !== undefined) { + const source = tool.source as Record | null; + if (typeof source !== 'object' || source === null || Array.isArray(source)) { + return `${subject}: pocket tool ${tool.id}: The outline source must be an object.`; + } + if (source.kind === 'sketch') { + const sketchProblem = validateSketch(source.sketch, `${subject}: pocket tool ${tool.id}`); + if (sketchProblem !== null) return sketchProblem; + } else if (source.kind !== 'photo') { + return `${subject}: pocket tool ${tool.id}: The outline source must be a photo trace or a sketch.`; + } + } + } else { + const source = tool.source as Record | null | undefined; + if (typeof source !== 'object' || source === null || Array.isArray(source)) { + return `${subject}: pocket tool ${tool.id}: The outline source must be an object.`; + } + if (source.kind === 'photo') { + if (typeof source.sessionId !== 'string' || !sessionIds.has(source.sessionId)) { + return `${subject}: pocket tool ${tool.id}: The outline source names a photo sheet the bin does not have.`; + } + const clicksProblem = validateClickList(source.clicks, `${subject}: pocket tool ${tool.id}`); + if (clicksProblem !== null) return clicksProblem; + if (source.brushStrokes !== undefined) { + const strokesProblem = validateStrokeList( + source.brushStrokes, + `${subject}: pocket tool ${tool.id}`, + ); + if (strokesProblem !== null) return strokesProblem; + } + } else if (source.kind === 'sketch') { + const sketchProblem = validateSketch(source.sketch, `${subject}: pocket tool ${tool.id}`); + if (sketchProblem !== null) return sketchProblem; + } else if (source.kind === 'primitive') { + // A primitive source carries no further fields. + } else { + return `${subject}: pocket tool ${tool.id}: The outline source must be a photo trace, a sketch or a basic shape.`; + } + } +``` + +3c. Rework `pickPockets` and `pickToolSource`. New signatures: + +```ts +export function pickPockets( + raw: Record, + migratedSessionId: string | null, +): BinPockets +``` + +The tool mapping drops the `clicks`/`brushStrokes` fields and calls `source: pickToolSource(tool, migratedSessionId)`. Replace `pickToolSource` with: + +```ts +/** + * Copies a validated tool source. A tool from a version 10 or earlier plan + * has no self-contained source: a sketch source is kept, a tool with stored + * clicks becomes a photo tool referencing the bin's one migrated session, + * and everything else (empty clicks, or clicks with no stored photo to + * re-trace against) is a primitive. + */ +function pickToolSource( + tool: Record, + migratedSessionId: string | null, +): ToolSource { + const raw = tool.source as Record | undefined; + if (raw !== undefined && raw.kind === 'sketch') { + const parsed = deserializeSketch(raw.sketch); + if (!parsed.ok) { + // validatePockets already proved the sketch valid; reaching here is a + // programming error, not a user problem. + throw new Error(`A validated sketch failed to deserialize: ${parsed.error}`); + } + return { kind: 'sketch', sketch: parsed.sketch }; + } + if (raw !== undefined && raw.kind === 'primitive') { + return { kind: 'primitive' }; + } + if (raw !== undefined && raw.kind === 'photo' && typeof raw.sessionId === 'string') { + return { + kind: 'photo', + sessionId: raw.sessionId, + clicks: (raw.clicks as SamPoint[]).map((p) => ({ x: p.x, y: p.y, label: p.label })), + ...(raw.brushStrokes !== undefined + ? { + brushStrokes: (raw.brushStrokes as BrushStroke[]).map((s) => ({ + mode: s.mode, + radiusMm: s.radiusMm, + points: s.points.map((p) => ({ x: p.x, y: p.y })), + })), + } + : {}), + }; + } + // Legacy tool: clicks live on the tool itself. + const clicks = (tool.clicks as SamPoint[] | undefined) ?? []; + if (clicks.length > 0 && migratedSessionId !== null) { + return { + kind: 'photo', + sessionId: migratedSessionId, + clicks: clicks.map((p) => ({ x: p.x, y: p.y, label: p.label })), + ...(tool.brushStrokes !== undefined + ? { + brushStrokes: (tool.brushStrokes as BrushStroke[]).map((s) => ({ + mode: s.mode, + radiusMm: s.radiusMm, + points: s.points.map((p) => ({ x: p.x, y: p.y })), + })), + } + : {}), + }; + } + return { kind: 'primitive' }; +} +``` + +3d. Rework `validateBin`'s traced branch and `pickBin`'s traced branch: + +```ts + if (bin.origin === 'traced') { + if ( + bin.walls !== undefined || + bin.dividerCountX !== undefined || + bin.dividerCountY !== undefined + ) { + return `${subject}: A traced bin cannot have divider walls.`; + } + let sessionIds: ReadonlySet | null = null; + if (bin.traceSessions !== undefined) { + const sessionsProblem = validateTraceSessions(bin.traceSessions, subject); + if (sessionsProblem !== null) return sessionsProblem; + sessionIds = new Set( + (bin.traceSessions as Record[]).map((s) => s.id as string), + ); + } else { + // A bin without a session list is a version 10 (or earlier) bin still + // carrying the single-photo fields; validate those for the migration. + const legacyProblem = validateTraceSource(bin, subject); + if (legacyProblem !== null) return legacyProblem; + } + const pocketsProblem = validatePockets(bin.pockets, subject, sessionIds); + if (pocketsProblem !== null) return pocketsProblem; + return validateCavityEdits(bin.edits, subject); + } +``` + +In `pickBin`: + +```ts + if (raw.origin === 'traced') { + let traceSessions: TraceSession[]; + let migratedSessionId: string | null = null; + if (raw.traceSessions !== undefined) { + traceSessions = pickTraceSessions(raw); + } else if (typeof raw.traceSourceId === 'string' && raw.paper !== undefined) { + // Version 10 migration: the bin-level photo becomes one session that + // every tool with stored clicks references. + const session: TraceSession = { + id: crypto.randomUUID(), + traceSourceId: raw.traceSourceId, + paper: pickTracePaper(raw.paper as Record), + }; + traceSessions = [session]; + migratedSessionId = session.id; + } else { + traceSessions = []; + } + return { + ...envelope, + origin: 'traced', + pockets: pickPockets(raw.pockets as Record, migratedSessionId), + edits: pickCavityEdits(raw), + traceSessions, + }; + } +``` + +Delete `assignTraceSource` (nothing calls it now). Keep `pickTracePaper` (both `pickTraceSessions` and the migration use it). A migrated bin whose session no tool ends up referencing (all tools had empty clicks) keeps the session on load; the reference-count rule applies on save (Task 4), not on read, so a photo the owner might still re-trace against is not dropped by merely opening the plan. + +3e. Update the version-history comment (planFile.ts lines 1926-1928). Replace the last sentence ("Version 11 adds the outline source ...") with: + +``` + // Version 11 reshapes traced bins around trace sessions: the bin carries a + // traceSessions list (each session one photographed sheet with its stored + // photo id and paper corners), and every pocket tool carries a + // self-contained source (a photo source naming its session and owning its + // clicks and brush strokes, an embedded sketch, or a primitive shape). + // Version 10 bins carry a single bin-level traceSourceId and paper and + // tool-level clicks; on load these become one session, tools with clicks + // become photo tools referencing it, and the rest become primitives. +``` + +3f. If `web/src/stores/binQueue.ts` or components call `pickPockets` or `validatePockets` directly (check with grep), pass the new arguments (`null` sessionIds / `null` migratedSessionId only where the value is genuinely legacy; a compile error here means the call site must decide, do not default silently). + +- [ ] **Step 4: Run the plan tests** + +Run: `npx vitest run tests/plan/planFile.spec.ts` +Expected: PASS, including all pre-existing tests (update any older fixture in that file still using tool-level `clicks` under `version: 11` to the new source shape, or to `version: 10` if it is deliberately exercising migration). + +- [ ] **Step 5: Typecheck** + +Run: `npx vue-tsc --noEmit` +Expected: clean. + +- [ ] **Step 6: Commit** + +```bash +git add -A +git commit -m "Reshape plan v11 around trace sessions and migrate v10 bins." +``` + +--- + +### Task 3: Photo sweep follows sessions + +`referencedAssetIds` already collects `session.traceSourceId` after Task 1; this task pins it with tests so orphaned session photos are provably swept and referenced ones kept. + +**Files:** +- Modify: `web/src/engine/plan/storedAssets.ts` (only if the test finds a gap) +- Test: `web/tests/plan/storedAssets.spec.ts` (extend the existing spec; if the file does not exist, create it following the fake-store pattern described in storedAssets.ts's header comment) + +**Interfaces:** +- Consumes: `TracedBin.traceSessions` from Task 1. + +- [ ] **Step 1: Write the failing test** + +In the storedAssets spec, add (reusing the file's existing fake-store helpers and entry builders where present; the traced bin fixture is spelled out here): + +```ts +it('keeps every session photo a traced bin references and sweeps the rest', async () => { + const bin = { + origin: 'traced' as const, + gridX: 1, + gridY: 1, + heightUnits: 6, + magnetHoles: false, + pockets: { tools: [], placements: [] }, + edits: [], + traceSessions: [ + { + id: 's1', + traceSourceId: 'photo-a', + paper: { + kind: 'a4' as const, + corners: { + tl: { x: 0, y: 0 }, + tr: { x: 100, y: 0 }, + br: { x: 100, y: 140 }, + bl: { x: 0, y: 140 }, + }, + }, + }, + { + id: 's2', + traceSourceId: 'photo-b', + paper: { + kind: 'a4' as const, + corners: { + tl: { x: 0, y: 0 }, + tr: { x: 100, y: 0 }, + br: { x: 100, y: 140 }, + bl: { x: 0, y: 140 }, + }, + }, + }, + ], + }; + const entry = { + id: 'e1', + createdAt: '2026-07-25T00:00:00.000Z', + quantity: 1, + product: { kind: 'bin' as const, bin, labelSlot: false }, + }; + const referenced = referencedAssetIds([entry], []); + expect(referenced.tracePhotos).toEqual(new Set(['photo-a', 'photo-b'])); +}); +``` + +- [ ] **Step 2: Run the test** + +Run: `npx vitest run tests/plan/storedAssets.spec.ts` +Expected: PASS already if Task 1's storedAssets edit landed; if it fails, fix the traced branch of `referencedAssetIds` to loop `bin.traceSessions` exactly as shown in Task 1 Step 4. + +- [ ] **Step 3: Commit** + +```bash +git add -A +git commit -m "Pin the photo sweep to trace session references." +``` + +--- + +### Task 4: Session reference-counting on save + +The save path stores only sessions at least one tool references, writes each referenced session's photo blob to the photo store, and lets the existing sweep drop everything else. A sketch-only bin therefore saves with `traceSessions: []` and zero photo data by construction. + +**Files:** +- Modify: `web/src/components/trace/LayoutWorkspace.vue:292-382` (storeTraceSource replaced, addToQueue) +- Modify: `web/src/engine/trace/layoutModel.ts` (new pure helper `referencedSessionIds`) +- Test: `web/tests/trace/layoutModel.spec.ts` (extend the existing layoutModel spec; create it in that location if absent) + +**Interfaces:** +- Consumes: store session state from Task 5 is NOT needed here in full; this task uses `trace.sessions` and `trace.sessionBlobs` which Task 5 introduces. **Execute Task 5 before Task 4 if working strictly in order matters to you; the tasks are written in spec order but Task 4's Vue steps compile only after Task 5.** The pure helper and its test (Steps 1-3) have no such dependency. +- Produces: `referencedSessionIds(tools: readonly TracedTool[]): Set` in layoutModel.ts. + +- [ ] **Step 1: Write the failing test for the pure helper** + +```ts +import { referencedSessionIds } from '../../src/engine/trace/layoutModel'; + +it('collects exactly the session ids photo tools reference', () => { + const base = { + id: '', + name: 'Tool', + outline: { outer: [{ x: 0, y: 0 }, { x: 1, y: 0 }, { x: 1, y: 1 }], holes: [] }, + rotationDeg: 0, + offsetMm: 0, + mirrored: false, + minHoleWidthMm: 0, + filledHoleIndices: [], + fingerHoles: [], + }; + const tools = [ + { ...base, id: 't1', source: { kind: 'photo' as const, sessionId: 's1', clicks: [] } }, + { ...base, id: 't2', source: { kind: 'photo' as const, sessionId: 's1', clicks: [] } }, + { ...base, id: 't3', source: { kind: 'primitive' as const } }, + { + ...base, + id: 't4', + source: { + kind: 'sketch' as const, + sketch: { schemaVersion: SKETCH_SCHEMA_VERSION, entities: [], constraints: [] }, + }, + }, + ]; + expect(referencedSessionIds(tools)).toEqual(new Set(['s1'])); +}); +``` + +(Import `SKETCH_SCHEMA_VERSION` from `../../src/engine/sketch/model`; if an empty-entity sketch fails a type check, reuse the point-plus-circle sketch fixture from Task 1 Step 1.) + +- [ ] **Step 2: Run it to verify it fails** + +Run: `npx vitest run tests/trace/layoutModel.spec.ts` +Expected: FAIL with `referencedSessionIds` not exported. + +- [ ] **Step 3: Implement the helper in layoutModel.ts** + +```ts +/** + * The trace-session ids the given tools still reference. A session absent + * from this set is an orphan: it is not saved with the bin and its stored + * photo is swept. Exhaustive over the source kinds so a future source that + * references a session must be named here. + */ +export function referencedSessionIds(tools: readonly TracedTool[]): Set { + const ids = new Set(); + for (const tool of tools) { + switch (tool.source.kind) { + case 'photo': + ids.add(tool.source.sessionId); + break; + case 'sketch': + case 'primitive': + break; + default: + assertNever(tool.source); + } + } + return ids; +} +``` + +(Import `assertNever` from `../plan/types` if layoutModel.ts does not already.) + +Run: `npx vitest run tests/trace/layoutModel.spec.ts` +Expected: PASS. + +- [ ] **Step 4: Rewire the save path in LayoutWorkspace.vue** (requires Task 5's store state) + +Replace `storeTraceSource` (lines 296-319) with: + +```ts +/** + * The sessions to save with the entry: exactly those some photo tool still + * references, with each one's photo bytes written to the photo store first. + * Orphaned sessions are simply not included; persisting the plan then sweeps + * their stored photos. A failed photo write keeps the session out of the + * saved list (its tools become layout-only editable later) and says so. + */ +async function storeReferencedSessions(): Promise { + const referenced = referencedSessionIds(trace.tools); + const saved: TraceSession[] = []; + for (const session of trace.sessions) { + if (!referenced.has(session.id)) continue; + const blob = trace.sessionBlobs.get(session.id); + if (blob !== undefined) { + try { + await putPhoto(session.traceSourceId, blob); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + photoNote.value = `Storing a trace photo failed (${detail}). The bin was saved, but tools traced on that sheet cannot be re-traced later without the photo.`; + continue; + } + } + saved.push(JSON.parse(JSON.stringify(session)) as TraceSession); + } + return saved; +} +``` + +In `addToQueue`, replace the `storeTraceSource` call and the `traceSourceId`/`paper` assignment block (lines 336, 355-358) with: + +```ts + const traceSessions = await storeReferencedSessions(); +``` + +and build the bin as: + +```ts + const bin: TracedBin = { + origin: 'traced', + gridX: params.gridX, + gridY: params.gridY, + heightUnits: params.heightUnits, + magnetHoles: params.magnetHoles, + pockets, + edits: trace.edits.map(cloneEdit), + traceSessions, + }; +``` + +Delete the `editingBin` fallback merge of `traceSourceId`/`paper` (the sessions list is rehydrated into the store when an edit opens, Task 6, so the store is the single source at save time). Update imports: `TraceSession` and `referencedSessionIds`; drop `TracePaper` and `PaperCorners` if now unused. + +- [ ] **Step 5: Typecheck** + +Run: `npx vue-tsc --noEmit` +Expected: clean (after Task 5 is in). + +- [ ] **Step 6: Commit** + +```bash +git add -A +git commit -m "Reference-count trace sessions on save and drop orphaned photos." +``` + +--- + +### Task 5: toolTrace store active-session state and atomic activation + +The store's single-photo fields become the state of the one active session. Activation is a single atomic action: clear everything, load the session's photo into the vision worker, apply its saved corners, rectify, embed. `embedReady` is derived from a session-id key so a re-trace can never run against a stale sheet's calibration. + +**Files:** +- Modify: `web/src/stores/toolTrace.ts` +- Modify: `web/src/components/trace/PhotoStage.vue:143` area (new-photo path registers a session) +- Modify: `web/src/components/trace/TraceCanvas.vue` (accept handler uses `trace.activeSessionId`, removing Task 1's placeholder) +- Test: `web/tests/stores/toolTrace.spec.ts` + +**Interfaces:** +- Consumes: `TraceSession` (Task 1), `loadPhoto`, `rectifyPaper`, `embedImage` from `web/src/visionClient.ts` (existing exports, used today by TraceTab.vue). +- Produces on the store: + - `sessions: Ref` (the bin's sheets, saved or pending) + - `sessionBlobs: Map` (photo bytes by session id, non-reactive) + - `activeSessionId: Ref` + - `embedReadySessionId: Ref` and computed `embedReady: boolean` (true iff `activeSessionId !== null && embedReadySessionId === activeSessionId`) + - `startPhotoSession(blob: Blob, url: string, size: {width: number; height: number}): string` (returns the new session id) + - `commitSessionPaper(): void` (upserts the active session's paper from the current calibration) + - `activateSession(sessionId: string, blob: Blob): Promise` (atomic; throws on worker failure, caller shows the message) + - `reset()` additionally clears sessions, blobs and the new refs. + +- [ ] **Step 1: Write the failing tests** + +Add to `web/tests/stores/toolTrace.spec.ts`. Mock the vision client at the top of the file (before imports of the store): + +```ts +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const rectifyCalls: unknown[] = []; +vi.mock('../../src/visionClient', () => ({ + loadPhoto: vi.fn(async () => ({ width: 400, height: 300 })), + rectifyPaper: vi.fn(async (corners: unknown, kind: unknown) => { + rectifyCalls.push([corners, kind]); + return { + calibration: { + corners, + kind, + mmPerPixel: 0.5, + rectifiedWidthPx: 420, + rectifiedHeightPx: 594, + }, + preview: null, + }; + }), + embedImage: vi.fn(async () => ({ encodeMs: 1 })), +})); +``` + +(If `rectifyPaper`'s real return type makes `preview: null` fail the typecheck, cast the mock module with `as unknown as typeof import('../../src/visionClient')`; ImageData does not exist in node.) + +```ts +const paper = { + kind: 'a4' as const, + corners: { + tl: { x: 0, y: 0 }, + tr: { x: 100, y: 0 }, + br: { x: 100, y: 140 }, + bl: { x: 0, y: 140 }, + }, +}; + +describe('toolTrace active session', () => { + beforeEach(() => { + setActivePinia(createPinia()); + }); + + it('keys embedReady by session so a stale embed never reads as ready', async () => { + const trace = useToolTrace(); + trace.sessions = [ + { id: 's1', traceSourceId: 'p1', paper }, + { id: 's2', traceSourceId: 'p2', paper }, + ]; + await trace.activateSession('s1', new Blob(['a'])); + expect(trace.activeSessionId).toBe('s1'); + expect(trace.embedReady).toBe(true); + // Activating the next session invalidates the old embed the instant the + // switch starts; wrong millimeters otherwise. + const activation = trace.activateSession('s2', new Blob(['b'])); + expect(trace.embedReady).toBe(false); + await activation; + expect(trace.embedReady).toBe(true); + expect(trace.activeSessionId).toBe('s2'); + }); + + it('applies the activated session saved corners without re-detection', async () => { + const trace = useToolTrace(); + trace.sessions = [{ id: 's1', traceSourceId: 'p1', paper }]; + rectifyCalls.length = 0; + await trace.activateSession('s1', new Blob(['a'])); + expect(rectifyCalls).toHaveLength(1); + expect(rectifyCalls[0]).toEqual([paper.corners, 'a4']); + expect(trace.calibration?.kind).toBe('a4'); + }); + + it('clears the prior session calibration before loading the next', async () => { + const trace = useToolTrace(); + trace.sessions = [ + { id: 's1', traceSourceId: 'p1', paper }, + { id: 's2', traceSourceId: 'p2', paper }, + ]; + await trace.activateSession('s1', new Blob(['a'])); + const first = trace.calibration; + await trace.activateSession('s2', new Blob(['b'])); + expect(trace.calibration).not.toBe(first); + expect(trace.corners).toEqual(paper.corners); + }); + + it('rejects activating a session the store does not hold', async () => { + const trace = useToolTrace(); + await expect(trace.activateSession('nope', new Blob(['a']))).rejects.toThrow(); + }); + + it('registers a fresh photo upload as a new pending session', () => { + const trace = useToolTrace(); + const id = trace.startPhotoSession(new Blob(['a']), 'blob:x', { width: 4, height: 3 }); + expect(trace.activeSessionId).toBe(id); + expect(trace.sessionBlobs.get(id)).toBeInstanceOf(Blob); + expect(trace.embedReady).toBe(false); + }); + + it('clears sessions and the active key on reset', async () => { + const trace = useToolTrace(); + trace.sessions = [{ id: 's1', traceSourceId: 'p1', paper }]; + await trace.activateSession('s1', new Blob(['a'])); + trace.reset(); + expect(trace.sessions).toEqual([]); + expect(trace.activeSessionId).toBeNull(); + expect(trace.embedReady).toBe(false); + expect(trace.sessionBlobs.size).toBe(0); + }); +}); +``` + +Note on jsdom: `URL.createObjectURL`/`revokeObjectURL` may be absent in the node test environment; guard the store's revoke calls with `typeof URL.revokeObjectURL === 'function'`, or stub them in the spec's `beforeEach` (`URL.createObjectURL ??= () => 'blob:test'; URL.revokeObjectURL ??= () => {};`). Prefer the spec-side stub; the store code stays clean. + +- [ ] **Step 2: Run to verify failure** + +Run: `npx vitest run tests/stores/toolTrace.spec.ts` +Expected: FAIL (`activateSession` is not a function). + +- [ ] **Step 3: Implement the store state** + +In `web/src/stores/toolTrace.ts`: + +Add imports: `import { embedImage, loadPhoto, rectifyPaper } from '../visionClient';` and `TraceSession` to the type imports. + +Replace the `sourceId` ref and the plain `embedReady` ref (keep `photoUrl`, `photoBlob`, `photoSize`, `corners`, `paperKind`, `calibration`, `rectifiedPreview`, `encodeMs`: they are now the active session's working state) with: + +```ts + /** The bin's trace sessions: every photographed sheet, saved or pending. */ + const sessions = ref([]); + /** + * Photo bytes by session id, for sessions whose photo is loaded on this + * page (a fresh upload, or a stored photo fetched for re-tracing). + * Deliberately a plain Map outside reactivity: blobs are multi-megabyte. + */ + const sessionBlobs = new Map(); + /** The session the single-photo working state below belongs to. */ + const activeSessionId = ref(null); + /** + * The session id the current MobileSAM embedding was computed for. Kept + * separately from activeSessionId so a half-finished activation reads as + * not ready: embedReady is true only when the two agree, which makes it + * impossible for a re-trace to run against a stale sheet's calibration. + */ + const embedReadySessionId = ref(null); + /** True once the embedding of the ACTIVE session's rectified sheet is ready. */ + const embedReady = computed( + () => activeSessionId.value !== null && embedReadySessionId.value === activeSessionId.value, + ); +``` + +Add a private helper and the three actions: + +```ts + /** Clears the single-photo working state; every activation path starts here. */ + function clearActivePhotoState(): void { + if (photoUrl.value !== null && typeof URL.revokeObjectURL === 'function') { + URL.revokeObjectURL(photoUrl.value); + } + photoUrl.value = null; + photoBlob.value = null; + photoSize.value = null; + corners.value = null; + calibration.value = null; + rectifiedPreview.value = null; + embedReadySessionId.value = null; + encodeMs.value = null; + activeSessionId.value = null; + } + + /** + * Registers a freshly uploaded photo as a new pending session and makes it + * active. The session enters the sessions list once its sheet corners are + * confirmed (commitSessionPaper); until then it exists only as the active + * working state plus its blob. + */ + function startPhotoSession( + blob: Blob, + url: string, + size: { width: number; height: number }, + ): string { + clearActivePhotoState(); + const id = crypto.randomUUID(); + sessionBlobs.set(id, blob); + activeSessionId.value = id; + photoBlob.value = blob; + photoUrl.value = url; + photoSize.value = size; + return id; + } + + /** + * Records the active session's confirmed paper setup from the current + * calibration, inserting the session into the list or updating it in place + * (corners re-confirmed after an adjustment). + */ + function commitSessionPaper(): void { + const id = activeSessionId.value; + const cal = calibration.value; + if (id === null || cal === null) return; + const paper = { + corners: JSON.parse(JSON.stringify(cal.corners)) as PaperCorners, + kind: cal.kind, + }; + const existing = sessions.value.find((s) => s.id === id); + if (existing !== undefined) { + existing.paper = paper; + } else { + sessions.value.push({ id, traceSourceId: crypto.randomUUID(), paper }); + } + } + + /** + * Atomically makes a session the active one: clears every piece of the + * prior sheet's state first (so nothing stale can be read mid-switch), + * then loads the session's photo into the vision worker, applies its saved + * corners without re-detection, rectifies and embeds. embedReady turns + * true only at the very end and only for this session. Worker failures + * propagate to the caller, which shows the message; the state is left + * cleared, never half-activated. + */ + async function activateSession(sessionId: string, blob: Blob): Promise { + const session = sessions.value.find((s) => s.id === sessionId); + if (session === undefined) { + throw new Error('The photo sheet to activate is not part of this bin.'); + } + clearActivePhotoState(); + activeSessionId.value = sessionId; + sessionBlobs.set(sessionId, blob); + const info = await loadPhoto(await blob.arrayBuffer()); + photoBlob.value = blob; + photoUrl.value = + typeof URL.createObjectURL === 'function' ? URL.createObjectURL(blob) : null; + photoSize.value = info; + corners.value = JSON.parse(JSON.stringify(session.paper.corners)) as PaperCorners; + paperKind.value = session.paper.kind; + const rectified = await rectifyPaper(session.paper.corners, session.paper.kind); + calibration.value = rectified.calibration; + rectifiedPreview.value = rectified.preview; + const embed = await embedImage(); + encodeMs.value = embed.encodeMs; + embedReadySessionId.value = sessionId; + } +``` + +Extend `reset()`: replace the old photo-field lines with a `clearActivePhotoState()` call plus `sessions.value = []; sessionBlobs.clear();`. Extend the returned object with `sessions, sessionBlobs, activeSessionId, embedReadySessionId, embedReady, startPhotoSession, commitSessionPaper, activateSession` and remove `sourceId`. Note `embedReady` is now a computed: any code that assigned `trace.embedReady = ...` must be rewritten (the compiler finds them: TraceTab.vue's `resumeTrace`, PhotoStage.vue's embed step). PhotoStage's embed completion becomes `trace.embedReadySessionId = trace.activeSessionId;` and its new-photo handler calls `trace.startPhotoSession(file, url, info)` instead of assigning `photoBlob`/`photoUrl`/`photoSize` piecemeal (adapt to the file's actual local variable names at line 143's surroundings; the sheet-confirm handler additionally calls `trace.commitSessionPaper()` after a successful rectify at line 363's surroundings). TraceTab.vue's `resumeTrace` is deleted outright in Task 6; to keep this task compiling, replace its body with a call to `trace.activateSession(...)` for the bin's single stored session or leave the file to Task 6 and run the two tasks in one worktree session if the intermediate typecheck bothers you. The plan's task boundary assumption: Task 5 and Task 6 land as consecutive commits and only the Task 6 commit needs the full app to typecheck; run the store spec here, the full `vue-tsc` gate after Task 6. + +In `web/src/components/trace/TraceCanvas.vue`, replace Task 1's placeholder line with: + +```ts + sessionId: store.activeSessionId ?? '', +``` + +and guard the accept handler's entry: if `store.activeSessionId === null`, return early (tracing is impossible without an active session; the canvas only renders when `embedReady`, which now implies an active session, so this is a type guard, not a reachable branch). + +- [ ] **Step 4: Run the store tests** + +Run: `npx vitest run tests/stores/toolTrace.spec.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "Turn the toolTrace store photo state into atomic active-session state." +``` + +--- + +### Task 6: Source list stage and workspace entry/exit rewiring + +The input stage becomes a source list (one card per session, one per sketched tool, plus "Add a photo sheet" and "Draw a shape"); the `traceInput` toggle and `sketchCancelStage` mechanism go away; the trace and sketch workspaces are modal work entered from a card with "Back to sources"; re-trace atomically activates the tool's session; the breadcrumb's first chip becomes "Sources". + +**Files:** +- Create: `web/src/components/trace/SourceListStage.vue` +- Modify: `web/src/components/trace/TraceTab.vue` (full rework of stage state, script and template) +- Modify: `web/src/components/trace/PhotoStage.vue` (emit unchanged; confirm handler already commits the session via Task 5) +- Modify: `web/src/components/trace/SketchWorkspace.vue` (only if its cancel button label needs the "Back to sources" wording; its `cancel`/`finish` emits stay) + +**Interfaces:** +- Consumes: store state and actions from Task 5, `editActionOf` from Task 1, `getPhoto` from `web/src/photoStore.ts`. +- Produces: `SourceListStage.vue` with props `{ sessions: TraceSession[]; sketchTools: TracedTool[]; busy: boolean }` and emits `{ openSheet: [sessionId: string]; openSketch: [toolId: string]; addPhoto: []; drawShape: [] }`. + +- [ ] **Step 1: Create SourceListStage.vue** + +```vue + + + + + +``` + +- [ ] **Step 2: Rework TraceTab.vue** + +Replace the stage model and the sketch/photo entry logic. Full new script for the changed regions (unchanged parts, such as `editingEntry`, `editingBin`, `finishSketch`'s validation body, `workspaceReady`, are kept as they are unless named): + +Stage state: + +```ts +/** Which screen the tab shows. Workspaces are modal work on one source. */ +const stage = ref<'sources' | 'photo' | 'sketch' | 'workspace'>('sources'); +``` + +Delete `traceInput` (line 43) and `sketchCancelStage` (line 51), `startSketch`, `cancelSketch` as written. New source-stage handlers: + +```ts +/** Sketched tools, one source card each. */ +const sketchTools = computed(() => trace.tools.filter((t) => t.source.kind === 'sketch')); + +/** Starts a fresh sketch from the Sources stage. */ +function drawShape(): void { + sketchEditor.startNewSketch(); + stage.value = 'sketch'; +} + +/** Opens the photo stage to add a new sheet. */ +function addPhotoSheet(): void { + stage.value = 'photo'; +} + +/** Every workspace's way back; also the breadcrumb's first chip. */ +function backToSources(): void { + stage.value = 'sources'; +} + +/** + * Loads a session's photo (from the page's blob map, or the photo store) + * and atomically activates it. Returns false with sourcesError set when the + * photo is not available on this device. + */ +const sourcesBusy = ref(false); +const sourcesError = ref(null); + +async function ensureSessionActive(sessionId: string): Promise { + if (trace.activeSessionId === sessionId && trace.embedReady) return true; + const session = trace.sessions.find((s) => s.id === sessionId); + if (session === undefined) { + sourcesError.value = 'That photo sheet is no longer part of this bin.'; + return false; + } + sourcesBusy.value = true; + sourcesError.value = null; + try { + let blob = trace.sessionBlobs.get(sessionId) ?? null; + if (blob === null) blob = await getPhoto(session.traceSourceId); + if (blob === null) { + sourcesError.value = + 'The photo of this sheet is not stored on this device, so its tools cannot be re-traced. You can still edit the layout.'; + return false; + } + await trace.activateSession(sessionId, blob); + return true; + } catch (error) { + sourcesError.value = + error instanceof Error ? error.message : 'Restoring the trace photo failed.'; + return false; + } finally { + sourcesBusy.value = false; + } +} + +/** A sheet card: activate its session and open the trace workspace. */ +async function openSheet(sessionId: string): Promise { + if (!(await ensureSessionActive(sessionId))) return; + stage.value = 'workspace'; + trace.workspaceMode = 'trace'; +} + +/** A sketch card: open the tool's stored sketch in the sketch workspace. */ +function editSketchedTool(toolId: string): void { + const tool = trace.tools.find((t) => t.id === toolId); + if (tool === undefined) return; + switch (tool.source.kind) { + case 'photo': + return; // photo tools re-trace through onRetrace instead + case 'sketch': + sketchEditor.loadSketch(tool.source.sketch, toolId); + stage.value = 'sketch'; + return; + case 'primitive': + return; // a primitive shape has nothing to reopen + default: + return assertNever(tool.source); + } +} +``` + +`finishSketch` keeps its validation body; its exit lines (109-111) become: + +```ts + stage.value = 'workspace'; + trace.workspaceMode = 'layout'; +``` + +and its re-edit branch uses `trace.replaceToolOutline(tool.id, profile.outline, source);` (already done in Task 1). The sketch workspace's cancel handler becomes: + +```ts +/** Cancelling the sketch returns to the stage that opened it: the source list. */ +function cancelSketch(): void { + stage.value = 'sources'; +} +``` + +Delete `storedPhoto`, `photoMissing`, `lookUpStoredPhoto` and `resumeTrace` (lines 148-206): the per-session lookup in `ensureSessionActive` replaces all of them. `resumeBusy`/`resumeError` are replaced by `sourcesBusy`/`sourcesError`. The photo-missing hint in the layout template keys off `sourcesError` now. + +The editing watch (lines 213-258) changes: replace `void lookUpStoredPhoto(bin);` with `trace.sessions = JSON.parse(JSON.stringify(bin.traceSessions)) as TraceSession[];` and keep the rest; its final lines become `stage.value = 'workspace'; trace.workspaceMode = 'layout';`. Import `TraceSession` in the type imports and `getPhoto` stays imported. + +`traceModeAvailable` (line 267) becomes: the selected tool's session can be activated, which is only knowable per tool; for the toolbar's generic "trace another" affordance use: + +```ts +/** True when some sheet exists to trace on (active now or restorable). */ +const traceModeAvailable = computed(() => trace.embedReady || trace.sessions.length > 0); +``` + +The zero-tools fallback watch (lines 282-289): replace `void setWorkspaceMode('trace')` with `backToSources()` and drop the `traceModeAvailable` condition: with no tools the workspace has nothing to lay out, and the source list is now the home that offers every way forward. Delete `setWorkspaceMode` entirely; the "trace another" toolbar event now routes: + +```ts +/** The toolbar's trace-another action: back to the source list to pick a sheet. */ +function onTraceAnother(): void { + if (trace.sessions.length === 1) { + // One sheet: skip the list and go straight back to tracing on it. + void openSheet(trace.sessions[0].id); + return; + } + backToSources(); +} +``` + +`onRetrace` becomes: + +```ts +/** Re-traces a photo tool: activate its own session, then open trace mode. */ +async function onRetrace(toolId: string): Promise { + const tool = trace.tools.find((t) => t.id === toolId); + if (tool === undefined || tool.source.kind !== 'photo') return; + if (!(await ensureSessionActive(tool.source.sessionId))) return; + trace.selectedToolId = toolId; + trace.retraceRequestId = toolId; + stage.value = 'workspace'; + trace.workspaceMode = 'trace'; +} +``` + +`openPhotoStage` is deleted (the breadcrumb's first chip now calls `backToSources`). `onPhotoReplaced` reduces to clearing `sourcesError`. `onSheetConfirmed` becomes: + +```ts +function onSheetConfirmed(): void { + // PhotoStage committed the session's paper on confirm; tracing starts now. + stage.value = 'workspace'; + trace.workspaceMode = 'trace'; +} +``` + +`restart` sets `stage.value = 'sources'` and keeps `trace.reset()`. + +New template: + +```vue + +``` + +Add `import SourceListStage from './SourceListStage.vue';` and drop the `v-btn-toggle` block, the old stage-1 template, and any now-unused imports (`shallowRef`, `TracedBin` if unused, `PaperCorners`, `loadPhoto`, `rectifyPaper`, `embedImage`: the store owns those calls now). + +Also check `web/src/components/trace/PhotoStage.vue` for a "start over with a new photo" path that previously reset the whole trace store; with multiple sheets, replacing the photo before confirm must only replace the pending session (call `trace.startPhotoSession` again; the superseded pending id is simply never committed and its blob entry is overwritten or left to `reset()`). Do not let it call `trace.reset()` when `trace.tools.length > 0`; if the current code does, gate it: `if (trace.tools.length === 0) trace.reset();` followed by the `startPhotoSession` call, and keep the existing "loading a new photo discards these tools" copy only for the true fresh-start case. + +Mobile check (owner's browser check will confirm): workspaces stay full-bleed as today; SourceListStage's one-column grid rule covers 375 px. + +- [ ] **Step 3: Typecheck and full test run** + +Run: `npx vue-tsc --noEmit` +Expected: clean, including the deferred Task 5 leftovers (no references to `resumeTrace`, `sourceId`, `traceInput` remain; `grep -rn "sourceId\|traceInput\|sketchCancelStage\|resumeTrace" web/src` returns nothing). +Run: `npx vitest run` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add -A +git commit -m "Replace the input stage with a source list and rewire workspace entry." +``` + +--- + +### Task 7: Final gate + +- [ ] **Step 1: Full build and tests** + +Run (inside `web/`): `npm run build` +Expected: vue-tsc clean, production build succeeds. +Run: `npm test` +Expected: all suites pass. + +- [ ] **Step 2: Spec sweep** + +Confirm each spec point maps to landed code: three-variant `ToolSource` with owned re-edit data (Task 1); `traceSessions` on the bin, v11 reshaped in place, v10 migration with classification (Task 2); persistence rule, orphan drop via reference counting plus sweep (Tasks 3, 4); atomic activation with session-keyed `embedReady` (Task 5); source list with first-run shortcut, "Sources" breadcrumb chip, modal workspaces with "Back to sources", re-trace activating the tool's own session, edit-sketch direct, `traceInput`/`sketchCancelStage` removed (Task 6). Confirm no em-dash characters were introduced (U+2014, written as an escape here so this plan itself stays clean): `grep -rnP '\x{2014}' web/src` returns nothing. + +- [ ] **Step 3: Commit anything outstanding** + +```bash +git add -A +git commit -m "Finish the multi-source tool bin data model and source list." +``` + +Owner-owed afterwards (out of scope for the executor): browser check of the source list flow at desktop and 375 px, and an Orca Slicer check of an exported multi-source bin. diff --git a/docs/superpowers/specs/2026-07-24-sketch-workspace-design.md b/docs/superpowers/specs/2026-07-24-sketch-workspace-design.md new file mode 100644 index 0000000..ba5f8e7 --- /dev/null +++ b/docs/superpowers/specs/2026-07-24-sketch-workspace-design.md @@ -0,0 +1,96 @@ +# Sketch workspace for tool outlines + +Date: 2026-07-24. Status: approved by owner in conversation; this document is the written record. + +## Problem + +Tool bins currently get their pocket outlines from a photo trace or from an uploaded STL. Some +objects cannot be photographed usefully (tall objects such as bottles cast shadows and distort), +and many users cannot produce an STL. They need a way to construct a precise 2D outline directly +in the app. + +## Decision summary + +A parametric 2D sketch workspace, in the spirit of the Fusion 360 sketcher, built on the FreeCAD +PlaneGCS constraint solver compiled to WASM (npm package `@salusoft89/planegcs`, LGPL-2.1). A +finished sketch produces the same millimeter outline a photo trace produces and enters the +existing tool pipeline unchanged (placement, outline offset, draft angle, pocket carve, cavity +edits, plate, export). No new bin type. The tool datatype gains an origin discriminator +(photo trace vs sketch), mirroring the established `Bin.origin` pattern, and sketched tools store +their editable `Sketch` so they can be reopened and changed later. + +### Solver choice rationale + +Researched options: JSketcher (copyright-assignment license, unusable), SolveSpace/libslvs +(GPLv3, dead JS port), hobby solvers (unmaintained), hand-rolled least-squares solver (viable +but we would own convergence bugs and diagnostics). PlaneGCS is the only maintained, +industrial-grade solver with a usable license. LGPL-2.1 obligations are met by shipping the +`.wasm` as a separate replaceable asset (the app already loads all WASM as separate worker +assets) and adding an attribution notice. + +## Architecture + +### New engine module `web/src/engine/sketch/` (framework-agnostic, convention 3) + +- **`model.ts`**: the `Sketch` datatype. Entities: point, line, arc, circle, all in mm, each + with a construction flag. Constraints: coincident, horizontal, vertical, parallel, + perpendicular, tangent, symmetric, and dimensions (length, distance, radius/diameter, angle). + Entities and constraints are discriminated unions handled with exhaustive switches and + `assertNever` (convention 13). The `Sketch` carries its own small schema version so the sketch + format can evolve without a full plan version bump each time. +- **`solve.ts`**: adapter mapping a `Sketch` onto PlaneGCS primitives and constraints, running + the solver, and writing solved coordinates back. Reports solver status to the UI: fully + constrained, under-constrained (with degrees of freedom), or conflicting (with the offending + constraints). Dragging a point is expressed as the solver's standard driven-point workflow. +- **`profile.ts`**: extracts the closed outer loop from the solved entities (chained line/arc + endpoints; a non-construction circle stands alone), flattens arcs at the same tolerance the + trace pipeline already uses, and returns the same mm outline type traced tools carry. + Failures are user-worded messages (convention 2): open chain, self-intersecting outline, + construction-only sketch, multiple disjoint loops. + +### Solver placement + +PlaneGCS runs in its own small worker (`sketch.worker.ts` beside the existing manifold and +vision workers) so sketch editing never blocks on a running carve and the WASM stays out of the +main bundle. A `sketchClient.ts` mirrors the existing worker client pattern. + +### Data model integration + +- The traced-tool datatype gains an origin discriminator: photo trace vs sketch. A sketched + tool embeds its `Sketch`; its outline feeds every downstream stage untouched. +- Plan file: new plan version with validators, following the existing migration pattern. +- Accepted cost: plans that embed sketches are larger than outline-only plans. Worth it, + because without the stored sketch a saved tool would no longer be editable. + +## Editor UI + +A sketch workspace inside the tool trace flow. The input step becomes a toggle: upload a photo +or draw the shape. Drawing opens the sketch canvas. + +- **Canvas**: SVG, mm coordinate system with grid, pan and zoom following the existing trace + canvas conventions. The canvas is preview only; all geometry lives in the engine. +- **Tools**: select and drag, line chain, arc (three-point and tangent continuation), circle + (center plus diameter, the one-click bottle case), construction toggle, mirror line for the + symmetric constraint. +- **Dimensions**: click one or two entities, type a value (length, distance, radius, diameter, + angle). Dimension labels render on the canvas and are click-to-edit. The solver runs after + every edit. Under-constrained geometry is drawn in a distinct color from fully constrained + geometry; conflicts surface the solver's diagnostics as labeled rows (convention 8). +- **Photo underlay**: optional image upload with an opacity slider, calibrated by drawing one + reference line over the photo and typing its real length. Display only; never enters + geometry. +- **Finish**: "Use this shape" validates through `profile.ts` and drops the user into the + normal tool placement and depth step. Pocket depth is the existing per-placement value. + +## V1 scope + +Lines and arcs, dimensions and core constraints, full-circle primitive, reference photo +underlay, symmetry and construction lines. Splines and ellipses are out of scope. + +## Testing + +Engine-level Vitest coverage: model validation and (de)serialization round-trips, solve.ts +against known sketches (dimensioned rectangle, tangent arc chain, symmetric profile, +over-constrained conflict), profile extraction including every user-worded failure, and the +plan version migration. Worker smoke test loading the PlaneGCS WASM in node, following the +existing vision smoke test pattern. diff --git a/docs/superpowers/specs/2026-07-25-multi-source-tool-bin-design.md b/docs/superpowers/specs/2026-07-25-multi-source-tool-bin-design.md new file mode 100644 index 0000000..0c81bee --- /dev/null +++ b/docs/superpowers/specs/2026-07-25-multi-source-tool-bin-design.md @@ -0,0 +1,77 @@ +# Multi-source tool bins: trace sessions, per-tool source data, source list UI + +Date: 2026-07-25. Status: approved by owner in conversation (data model and scope); UI structure +per expert consultation. This document is the written record. + +## Problem + +The sketch workspace made a tool's origin per-tool (`ToolSource`), but photo trace data stayed +bin-level from the single-photo era: `paper`, `traceSourceId`, and the stored photo blob are saved +with the bin regardless of whether any tool still uses them, while the photo-specific re-edit data +(`clicks`, `brushStrokes`) sits on every `TracedTool` including sketched ones. Consequence the +owner hit: a sketch-only bin silently saves and restores a photo it never used. The owner also +wants the capability this structure blocks: one bin combining tools traced from several +photographed sheets with any number of sketched and primitive tools. + +## Decision summary + +Reshape the data model around **trace sessions** and make each `ToolSource` variant fully own its +re-edit data. Interim fixes are forbidden (convention 10); this is the final structure. Plan file +version 11 is unshipped, so it is reshaped in place; no intermediate migration exists. + +### Data model + +- A tool bin carries `traceSessions: TraceSession[]` where + `TraceSession = { id: string; traceSourceId: string; paper: { corners; kind } }`. Zero or more. +- `ToolSource` becomes a three-variant union, each variant self-contained: + - `{ kind: 'photo'; sessionId: string; clicks: SamPoint[]; brushStrokes?: BrushStroke[] }` + - `{ kind: 'sketch'; sketch: Sketch }` + - `{ kind: 'primitive' }` (basic shapes, previously mislabeled as photo) +- `clicks` and `brushStrokes` leave `TracedTool`; they exist only inside the photo variant. +- Persistence rule: a session (and its photo blob in the photo store) is saved iff at least one + tool references its `sessionId`; orphaned sessions and their stored photos are dropped on save. + A sketch-only bin therefore carries zero photo data by construction. +- Plan file: v11 reshaped. v10 plans migrate: bin-level `paper`/`traceSourceId` become one + session; every tool with clicks becomes a photo tool referencing it; primitive-shaped tools + (empty clicks) become `primitive`; validation messages follow the planFile.ts convention. + +### Store and worker + +- The `toolTrace` store's single-photo state (`photoUrl`, `corners`, `calibration`, + `embedReady`, ...) becomes the **active session** state. Session activation is one atomic store + action that clears all of it first, then loads the session's photo, applies its saved corners, + rectifies and embeds. `embedReady` is keyed by session id so a re-trace can never run against a + stale sheet's calibration (expert-flagged failure mode; wrong millimeters otherwise). + +### UI (expert-consulted structure) + +- The input stage becomes a **source list**: one card per existing trace session (sheet) and per + sketched tool, plus two actions, "Add a photo sheet" and "Draw a shape". Clicking a sheet card + activates that session and opens the trace workspace; a sketch card opens the sketch workspace. +- First-run shortcut: with no sessions and no sketches, the stage shows only the two large + actions (today's screen without the toggle); the list appears once a source exists. The common + single-sheet or single-sketch flow stays as short as today. +- Breadcrumb: first chip renamed to "Sources"; second stays "Trace and lay out". The trace and + sketch workspaces are modal work on one source entered from a card, with a "Back to sources" + action, not a third chip. +- Tool rail: re-trace resolves the tool's `sessionId`, atomically activates that session, then + opens trace mode. Edit-sketch opens the sketch workspace directly. The `traceInput` toggle and + `sketchCancelStage` mechanism in TraceTab.vue are removed; workspaces return to whichever stage + opened them. +- Mobile: source cards stack full width in one column at 375 px; workspaces stay full-bleed; no + persistent rail. +- UI text plain technical prose; no em-dash characters; exhaustive `ToolSource` switches with + `assertNever` everywhere, including the new `primitive` member. + +## Out of scope + +Cross-bin session sharing, sheet thumbnails beyond what the photo store already yields cheaply, +and any change to the pocket/export pipeline (tools still resolve to outlines exactly as today). + +## Testing + +Engine: session reference-counting on save (orphan dropped, referenced kept), v10 to v11 +migration (tools gain photo sources referencing the migrated session; primitives detected), plan +round-trip with multi-session bins, ToolSource validation for all three variants. Store: atomic +session activation clears prior calibration and re-keys embedReady; re-trace against the correct +session. UI behavior beyond build/typecheck is covered by the owner's browser check. diff --git a/docs/superpowers/specs/2026-07-25-sketch-cad-review-backlog.md b/docs/superpowers/specs/2026-07-25-sketch-cad-review-backlog.md new file mode 100644 index 0000000..9571be2 --- /dev/null +++ b/docs/superpowers/specs/2026-07-25-sketch-cad-review-backlog.md @@ -0,0 +1,49 @@ +# Sketch workspace: senior CAD designer review, backlog + +Date: 2026-07-25. Source: consulted CAD-application expert review of the shipped sketch +workspace. Items marked BUILT are being implemented the same night under the owner's standing +directive; everything else awaits owner triage. + +## Defects in shipped features (BUILT tonight) + +- Dimension entry field: blur cancels the draft even when the text parses; blur should commit + a parseable value, Escape stays cancel. New dimensions should default to the measured current + value instead of a placeholder (Enter without editing = lock at current size), which also + stops the solver yanking geometry to a placeholder value mid-entry. +- Mirror tool hint promises "then the two points to keep symmetric" but the tool only places + the line; hint corrected to the real workflow. +- Escape during a multi-click tool (three-point arc, mirror) should clear the pending clicks, + not only end chains. +- Conflict rows name raw constraint ids; hovering a conflict row should highlight the offending + constraint's glyph/entities on canvas. + +## Quick wins (BUILT tonight, expert's build-next top 3) + +- Rectangle tool: two clicks, auto horizontal/vertical constraints on the four lines. +- Slot tool: two clicks plus a width, the canonical elongated tool pocket shape. +- Dimension ergonomics: measured-value defaults, commit-on-blur, and type-length-while-drawing + for line and circle (numeric input while the tool is active applies to the segment being + drawn). +- Auto horizontal/vertical inference: lines drawn within about 2 degrees of an axis get the + constraint automatically, with a pre-commit visual hint glyph so the user sees it coming + (and can suppress with a modifier key). +- Live length/angle readout beside the rubber-band cursor while drawing. + +## Awaiting owner triage + +- Novice-first presentation: replace "Degrees of freedom: N" phrasing with plain language + ("Shape fully defined" / "N measurements still free"), demote the constraint vocabulary + visually so the trace-then-dimension path leads. +- Underlay positioning: drag and rotate the reference photo, not only scale; draw the + calibration line's measured span before commit; calibration click flow hardening. +- Entity dragging: drag whole lines/circles, not only points; marquee selection with Shift + semantics. +- Sketch fillet (corner pick, radius). Note: the pocket pipeline already offsets outlines; + clarify overlap with the outline-offset stage before building (convention 10). +- Template shapes: parametric starter sketches (hex key L, wrench silhouette, cylinder+flat). +- Trim tool: low priority, region picking already yields trim's outcome. +- Offset curves in-sketch: skip, pipeline offsets already. +- DXF import: real hobbyist demand (Inkscape exports); an importer module, separate decision. +- Rectangle and slot corners do not snap onto existing sketch points (unlike line/circle/arc); + a compound profile built from these tools relies on region extraction to join adjacent + shapes rather than shared corner points. diff --git a/docs/superpowers/specs/2026-07-25-sketch-regions-design.md b/docs/superpowers/specs/2026-07-25-sketch-regions-design.md new file mode 100644 index 0000000..72a8b64 --- /dev/null +++ b/docs/superpowers/specs/2026-07-25-sketch-regions-design.md @@ -0,0 +1,67 @@ +# Sketch region extraction: CAD-style enclosed-area picking + +Date: 2026-07-25. Status: owner approved the feature and the shaded-region UI in conversation; +algorithm choices per expert consultation. Written record. + +## Problem + +Profile extraction only walks entities chained end to end through shared points, so overlapping +geometry yields nothing: a line crossing a circle cannot produce the flat-sided-bottle outline. +Real CAD sketchers compute curve intersections and let the user pick any enclosed region. + +## Decision summary + +New engine module `web/src/engine/sketch/regions.ts` implementing an arrangement of the sketch's +non-construction curves and enumeration of its faces; the UI shades every bounded face with a +light translucent fill (CAD blue), the user picks one (auto-pick when exactly one), and the +picked face becomes the tool outline with holes from its inner cycles. `profile.ts` becomes a +thin caller; its flattening helpers move to the shared location rather than being duplicated +(convention 10). + +## Algorithms (established, named; convention 12) + +1. **Intersections:** analytic closed forms per pair: line/line, line/circle-or-arc (quadratic), + circle/circle (radical line). O(n^2) pairwise is sufficient at sketch scale (tens of curves); + Bentley-Ottmann deliberately not needed, stated in code. +2. **Vertex welding:** epsilon vertex clustering via union-find (the tolerance model used by + CGAL Arrangement_2 style snap approaches). Epsilon is one shared constant with a reasoned + derivation documented in code (1e-6 mm: far below any manufacturable feature, far above + accumulated double-precision error at sketch scale). Not a tuned fudge. +3. **Splitting and flattening:** curves split at exact analytic intersection parameters, then + each sub-curve flattened at the shared OUTLINE_TOLERANCE_MM; the arrangement is built on + polyline edges that remember their source entity id for UI hit-testing. Exact-arc traversal + is rejected: the outline is flattened at 0.2 mm regardless, so exact split points are kept + and nothing further is gained. +4. **Face extraction:** doubly connected edge list with face traversal by most-counterclockwise + outgoing edge, including the full inner-cycle grouping step (cycles with negative signed + area grouped into containing faces via the leftmost-vertex ray-crossing containment test), + per de Berg, Cheong, van Kreveld, Overmars, Computational Geometry, chapter 2. Inner cycles + of the picked face become outline holes directly. +5. **Tangency defense:** a near-zero discriminant is treated as exactly one tangent point + (threshold expressed in the shared epsilon); post-split edges shorter than the epsilon are + dropped, so tangent contact cannot produce zero-length edges or broken turn decisions. + Tangent cases are tested explicitly. + +## UI + +- The canvas shades every bounded face translucently whenever the solver state is not + conflicting; hovering a face raises its opacity; clicking selects it (selected face visibly + distinct). With exactly one bounded face it is preselected. +- "Use this shape" consumes the selected face (outer cycle plus holes); with no face selected + and several available, the finish action asks the user to pick a region first (user-worded + message, no exception). +- Region shading lives in its own SVG layer under the geometry so entity/point/glyph hit + targets keep priority. Construction entities never contribute edges. + +## Out of scope + +Splines and ellipses (V1 scope holds); multi-region union pockets (one region per tool for +now, matching one outline per tool in the plan schema). + +## Testing + +Engine tests per the expert's list: line through circle (two faces, both extractable), two +overlapping circles (three faces), circle tangent to line and circle tangent to circle +(tangency defense), island in region (hole via inner cycle), construction-only geometry +(no faces, user-worded error), plus reuse of every existing profile.ts failure wording where +it still applies. Store/UI: face pick state, auto-pick single face, finish-with-no-pick message. diff --git a/web/package-lock.json b/web/package-lock.json index 88a191a..1a5669b 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,6 +8,7 @@ "name": "store-forge", "version": "0.1.0", "dependencies": { + "@salusoft89/planegcs": "^1.2.0", "@techstark/opencv-js": "^5.0.0-release.1", "comlink": "^4.4.2", "fflate": "^0.8.3", @@ -596,7 +597,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -619,7 +619,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -639,7 +638,6 @@ "version": "0.35.3", "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -662,7 +660,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -679,7 +676,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -696,7 +692,6 @@ "cpu": [ "arm" ], - "dev": true, "libc": [ "glibc" ], @@ -716,7 +711,6 @@ "cpu": [ "arm64" ], - "dev": true, "libc": [ "glibc" ], @@ -736,7 +730,6 @@ "cpu": [ "ppc64" ], - "dev": true, "libc": [ "glibc" ], @@ -756,7 +749,6 @@ "cpu": [ "riscv64" ], - "dev": true, "libc": [ "glibc" ], @@ -776,7 +768,6 @@ "cpu": [ "s390x" ], - "dev": true, "libc": [ "glibc" ], @@ -796,7 +787,6 @@ "cpu": [ "x64" ], - "dev": true, "libc": [ "glibc" ], @@ -816,7 +806,6 @@ "cpu": [ "arm64" ], - "dev": true, "libc": [ "musl" ], @@ -836,7 +825,6 @@ "cpu": [ "x64" ], - "dev": true, "libc": [ "musl" ], @@ -856,7 +844,6 @@ "cpu": [ "arm" ], - "dev": true, "libc": [ "glibc" ], @@ -882,7 +869,6 @@ "cpu": [ "arm64" ], - "dev": true, "libc": [ "glibc" ], @@ -908,7 +894,6 @@ "cpu": [ "ppc64" ], - "dev": true, "libc": [ "glibc" ], @@ -934,7 +919,6 @@ "cpu": [ "riscv64" ], - "dev": true, "libc": [ "glibc" ], @@ -960,7 +944,6 @@ "cpu": [ "s390x" ], - "dev": true, "libc": [ "glibc" ], @@ -986,7 +969,6 @@ "cpu": [ "x64" ], - "dev": true, "libc": [ "glibc" ], @@ -1012,7 +994,6 @@ "cpu": [ "arm64" ], - "dev": true, "libc": [ "musl" ], @@ -1038,7 +1019,6 @@ "cpu": [ "x64" ], - "dev": true, "libc": [ "musl" ], @@ -1061,7 +1041,6 @@ "version": "0.35.3", "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", - "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { @@ -1081,7 +1060,6 @@ "cpu": [ "wasm32" ], - "dev": true, "license": "Apache-2.0", "optional": true, "dependencies": { @@ -1101,7 +1079,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -1121,7 +1098,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -1141,7 +1117,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -1657,6 +1632,12 @@ "win32" ] }, + "node_modules/@salusoft89/planegcs": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@salusoft89/planegcs/-/planegcs-1.2.0.tgz", + "integrity": "sha512-NcdWJnJRCnIDvM9yJD98Jm8qaK//wRqMEbJ0WtibKnhbzI484TjIEbYD6EVUZejndXGC9yBkuKgqXg+9buzi6Q==", + "license": "LGPL-2.0-or-later" + }, "node_modules/@techstark/opencv-js": { "version": "5.0.0-release.1", "resolved": "https://registry.npmjs.org/@techstark/opencv-js/-/opencv-js-5.0.0-release.1.tgz", @@ -2868,7 +2849,6 @@ "version": "0.35.3", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@img/colour": "^1.1.0", diff --git a/web/package.json b/web/package.json index 5b7c510..1aa1c27 100644 --- a/web/package.json +++ b/web/package.json @@ -9,6 +9,7 @@ "test": "vitest run" }, "dependencies": { + "@salusoft89/planegcs": "^1.2.0", "@techstark/opencv-js": "^5.0.0-release.1", "comlink": "^4.4.2", "fflate": "^0.8.3", diff --git a/web/src/components/ConfirmDialog.vue b/web/src/components/ConfirmDialog.vue new file mode 100644 index 0000000..e64507f --- /dev/null +++ b/web/src/components/ConfirmDialog.vue @@ -0,0 +1,55 @@ + + + diff --git a/web/src/components/DeleteDrawerDialog.vue b/web/src/components/DeleteDrawerDialog.vue index 5b8c0f2..591a811 100644 --- a/web/src/components/DeleteDrawerDialog.vue +++ b/web/src/components/DeleteDrawerDialog.vue @@ -1,12 +1,14 @@ diff --git a/web/src/components/trace/AdvancedDrawer.vue b/web/src/components/trace/AdvancedDrawer.vue index 2e51e72..65357d3 100644 --- a/web/src/components/trace/AdvancedDrawer.vue +++ b/web/src/components/trace/AdvancedDrawer.vue @@ -6,11 +6,12 @@ import { CLEARANCE_CHOICES, HOLE_WIDTH_CHOICES, useToolTrace } from '../../store import { binPlacement } from '../../engine/trace/layoutModel'; import { maxPocketDepthMm } from '../../engine/trace/pocketBin'; import { DEFAULT_DRAFT_ANGLE_DEG, validateDraftAngleDeg } from '../../engine/carve/sweep'; -import type { FingerHole } from '../../engine/trace/types'; +import type { FingerHole, TracedTool } from '../../engine/trace/types'; import { overallHeightMm } from '../../heightHint'; import LabelIconField from '../LabelIconField.vue'; import ProductSelect from '../ProductSelect.vue'; import MoreOptions from '../MoreOptions.vue'; +import { editActionOf } from './toolEditAction'; /** * The advanced drawer of the layout workspace, opened by the Edit button in @@ -29,6 +30,8 @@ const props = defineProps<{ const emit = defineEmits<{ /** Asks the workspace to re-trace the tool from its stored clicks. */ retrace: [toolId: string]; + /** Asks the workspace to reopen a sketched tool's stored sketch. */ + editSketch: [toolId: string]; 'update:quantity': [value: number]; }>(); @@ -141,6 +144,11 @@ function applyDefaultDepth(value: number): void { } } +/** Total interior holes across every part of the tool's raw (unresolved) outline. */ +function toolHoleCount(tool: TracedTool): number { + return tool.parts.reduce((count, part) => count + part.holes.length, 0); +} + /** True when the hole is an elongated slot rather than a circle. */ function isSlot(hole: FingerHole): boolean { return hole.x2 !== undefined && hole.y2 !== undefined; @@ -196,7 +204,7 @@ function toolSummary(draftAngleDeg: number, offsetMm: number, minHoleWidthMm: nu