From 6c6dd6d938d0123395173f076a23e3ce95e867c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 16:46:46 +0000 Subject: [PATCH 1/3] 40-editor-sketch: plan sketch mode, constraints, Extrude/Revolve (proposal, design, specs, tasks) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_0182QWEHEoWzZux4ViurCaKy --- openspec/ROADMAP.md | 2 +- .../changes/40-editor-sketch/.openspec.yaml | 2 + openspec/changes/40-editor-sketch/design.md | 56 +++++++++++ openspec/changes/40-editor-sketch/proposal.md | 39 ++++++++ .../specs/editor-sketch-constraints/spec.md | 73 +++++++++++++++ .../specs/editor-sketch-features/spec.md | 92 +++++++++++++++++++ .../specs/editor-sketch-import/spec.md | 40 ++++++++ .../specs/editor-sketch/spec.md | 76 +++++++++++++++ openspec/changes/40-editor-sketch/tasks.md | 59 ++++++++++++ 9 files changed, 438 insertions(+), 1 deletion(-) create mode 100644 openspec/changes/40-editor-sketch/.openspec.yaml create mode 100644 openspec/changes/40-editor-sketch/design.md create mode 100644 openspec/changes/40-editor-sketch/proposal.md create mode 100644 openspec/changes/40-editor-sketch/specs/editor-sketch-constraints/spec.md create mode 100644 openspec/changes/40-editor-sketch/specs/editor-sketch-features/spec.md create mode 100644 openspec/changes/40-editor-sketch/specs/editor-sketch-import/spec.md create mode 100644 openspec/changes/40-editor-sketch/specs/editor-sketch/spec.md create mode 100644 openspec/changes/40-editor-sketch/tasks.md diff --git a/openspec/ROADMAP.md b/openspec/ROADMAP.md index 2884986..d42d85f 100644 --- a/openspec/ROADMAP.md +++ b/openspec/ROADMAP.md @@ -50,7 +50,7 @@ | 37 | `37-resource-monitor` | 31a, 32, 29 | v1.8.0. Вкладка «Ресурсы» в панели лога: честные показатели CPU/GPU/памяти для ресурсоёмких случаев (перестроение и отрисовка модели, ИИ на WASM и WebGPU) с пометкой «измерено / оценка / недоступно», отчёт по каждой задаче, режим диагностики (профилирование GPU); без FPS | | 38 | `38-webmcp` | 31a, 29 | v1.9.0. Инструменты WebMCP для браузерных агентов: чтение и запись параметров активного режима (baseplate, box, rack, keychain shape) с валидацией, сводка модели, экспорт и сброс только с подтверждением пользователя, журнал и отмена действий агента, переключатель; без эффекта, если браузер не поддерживает WebMCP | | 39 | `39-editor-foundation` | 29, 31a | v1.10.0. **Свободный редактор** (главный режим, см. `openspec/EDITOR.md`): продукт `/editor`, оболочка (outliner, свойства, панель инструментов), сцена из объектов, импорт STL, примитивы, выбор, гизмо и числовые трансформации, привязка, undo/redo, экспорт сцены; Viewport переиспользуется и расширяется | -| 40 | `40-editor-sketch` | 39 | v1.11.0 (план). Эскиз на плоскости: инструменты (прямоугольник с радиусом углов), Fillet/Chamfer 2D, ограничения и размеры, степени свободы, импорт SVG, Extrude/Revolve в тела; тело = эскиз + параметры, сетка производная (`EDITOR.md` 2a) | +| 40 | `40-editor-sketch` | 39 | v1.11.0. Эскиз на плоскости: инструменты (прямоугольник с радиусом углов), Fillet/Chamfer 2D, ограничения и размеры, степени свободы, импорт SVG, Extrude/Revolve в тела (join/cut/intersect, фаска/скругление краёв); тело = эскиз + список операций, сетка производная (`EDITOR.md` 2a) | | 41 | `41-editor-op-stack` | 40 | v1.12.0 (план). Неразрушающий стек операций (пересчёт от источников, `EDITOR.md` 2a), дерево/timeline, булевы, Mirror, Pattern, Hollow, Offset | | 42 | `42-editor-mesh-repair` | 39 | v1.13.0 (план). Диагностика и ремонт STL, Decimate, Smooth, Lay on Face, измерения, анализ нависаний | | 43 | `43-editor-cut-split` | 41, 42 | v1.14.0 (план). Plane Cut, Split с соединителями, отверстия под магниты/винты, текст на грани | diff --git a/openspec/changes/40-editor-sketch/.openspec.yaml b/openspec/changes/40-editor-sketch/.openspec.yaml new file mode 100644 index 0000000..97e3642 --- /dev/null +++ b/openspec/changes/40-editor-sketch/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-27 diff --git a/openspec/changes/40-editor-sketch/design.md b/openspec/changes/40-editor-sketch/design.md new file mode 100644 index 0000000..a4cb4e8 --- /dev/null +++ b/openspec/changes/40-editor-sketch/design.md @@ -0,0 +1,56 @@ +# Design + +## Context + +`39` gives a scene of objects (imported meshes and primitives), selection, transforms, history, and a viewport with a scene API. `40` adds the CAD loop: sketch → constraints → feature. Rule 2a of `openspec/EDITOR.md` applies: the sketch and feature parameters are the source of truth, the mesh is derived and can be rebuilt (also at a higher resolution for export). Geometry stays React-free and free of three.js; the worker builds meshes with `manifold-3d`. + +## Goals / Non-Goals + +**Goals**: a sketcher that is pleasant for the common cases (brackets, plates with holes and slots, rounded rectangles, revolved knobs), predictable constraints, exact profiles, parametric recompute. + +**Non-Goals**: everything listed as out of scope in the proposal; matching Fusion's UI; a general-purpose CAD constraint solver. + +## Decisions + +1. **Data model.** `Sketch = {id, name, plane, visible, points: Record, curves: Record, constraints: Constraint[]}` in sketch millimetres, X right and Y up in the plane. `Curve` is `line {a, b}` | `circle {c, r}` | `arc {c, a, b}` (counter-clockwise from `a` to `b`; the radius is the distance to `a`, an internal "equal radius" constraint keeps `|cb| = |ca|`); every curve has `construction: boolean`. Points are shared by id, so "coincident" of endpoints is structural (merging) and the constraint kind remains for point-on-point cases made by hand. `Plane = {origin, normal, xDir}` derived from `{base: 'XY'|'XZ'|'YZ', offset}` (a face reference is `44`). Stored positions are the solved ones. +2. **Constraints** are plain data `{id, kind, refs, value?}`: `coincident`, `pointOnLine`, `pointOnCircle` (circle or arc), `horizontal`, `vertical` (line or two points), `parallel`, `perpendicular`, `tangent` (line–circle/arc, circle–circle/arc), `equal` (lengths or radii), `symmetric` (two points about a line), `midpoint`, `fixed`; dimensions `distance` (point–point, point–line, plus `dx` / `dy` variants), `radius`, `diameter` (the same constraint, a display flag), `angle` (two lines). Each kind is one residual function with an analytic Jacobian row. Dimensions are driving; reference (measured) dimensions are out of scope. +3. **Solver: own Levenberg–Marquardt, zero dependencies** (decision to be confirmed by the spike in task 1.1). The variables are the free point coordinates and circle radii; residuals are the constraints; a Jacobian is assembled sparsely; the damped normal equations are solved with Cholesky/LDLᵀ for ≤ 400 variables (a sketch is small). Dragging adds a weak "stay close to the mouse" residual and a stronger "stay close to the previous positions" one so the solution is the nearest one and does not jump to the mirrored solution. Degrees of freedom = variables − rank(J), rank from a QR with column pivoting; an entity is *fully defined* when its variables have no component in the null space; conflicts/redundancy come from the rank of J restricted to constraint rows (a minimal conflicting set by removing constraints one by one until the rank grows). Convergence tolerance 1e-9 mm, max 50 iterations; a non-converging solve leaves positions unchanged and reports `sketch.error.noSolution` with the last residuals. Candidates if the spike favours it: `planegcs` (FreeCAD, WASM) — only if its licence is compatible with a GitHub Pages static site and the size is below 300 KB; otherwise the own solver stays. +4. **Macros, not new entity types.** Rectangle = 4 lines + 4 shared points + 2 horizontal + 2 vertical (+ width/height dimensions when typed); with a corner radius it is followed by four `Fillet` operations with an `equal` radius constraint and one radius dimension, so changing the radius changes all four. Slot = 2 lines + 2 arcs + tangents + equal. Regular polygon = n lines + `equal` + points on a construction circle. Fillet(corner) = trim the two lines at the tangent points, add an arc, add `tangent` constraints and a `radius`; Chamfer = trim and a line with a distance dimension. The macros are pure functions `(sketch, args) → patch` and are individually tested. +5. **Inference while drawing.** Cursor snaps (priority order): existing point (coincident), curve end tangent direction, midpoint, point on curve, horizontal/vertical alignment with the previous point (within 3° or 6 px), grid. The matched rule is shown as a glyph next to the cursor and becomes a constraint on click. `Ctrl` disables inference, `Shift` locks the axis. +6. **Profile extraction** (`geometry/features/profile.ts`, pure): take non-construction curves, build a graph on shared points (points closer than 1e-5 mm are merged), find simple cycles, report dangling ends (`sketch.error.openProfile` with the point) and crossings (`sketch.error.selfIntersect` with the position), orient (outer counter-clockwise, holes clockwise), build nesting by point-in-polygon tests of one vertex, and discretise to polygons with an adaptive chord tolerance (default 0.01 mm, at least 16 segments per circle, 96 for the full circle at default quality; the quality is a parameter of the build, not stored in the sketch). Overlapping loops that are not nested are an error in v1 (a planar-arrangement regions step is future work); this keeps the region list simple: one region per outer loop with its holes. +7. **Features** (`geometry/features/*`): `Extrude {sketch, regions: 'all'|id[], side: 'one'|'symmetric'|'two', d1, d2?, op: 'new'|'join'|'cut'|'intersect', top?: EdgeBreak, bottom?: EdgeBreak}` and `Revolve {sketch, regions, axis: {kind:'line', curve} | {kind:'planeAxis', axis:'x'|'y'}, angle, op}`. Extrude = `CrossSection.ofPolygons(..., EvenOdd)` → `Manifold.extrude`; Revolve = profile moved to the axis frame → `Manifold.revolve(cs, segments, angle)`; a profile that crosses the axis is `sketch.error.revolveCrossesAxis`. A body is `source: {kind:'feature', features: Feature[]}`; `evaluateFeatures(features, sketches, quality)` applies them in order; the first feature must be `new`, later ones act on the body so far (`join` unions, `cut` subtracts, `intersect` intersects); a failed feature stops the evaluation, returns the previous solid and reports which feature failed (the UI marks it). A feature whose sketch is missing or empty is a failure, not a crash. +8. **Edge chamfer/fillet of the extrusion caps (`EdgeBreak {kind:'chamfer'|'fillet', size}`).** Built without B-rep edges: the cap is lofted from the profile to its mitre-offset (vertex-to-vertex correspondence, so the topology is the same), one sloped band for a chamfer and N bands following a quarter circle for a fillet (N from the 0.02 mm sagitta, 4–16), as a triangle mesh handed to Manifold. Allowed only while the offset does not collapse any vertex or edge (size < half of the smallest feature length / sharpest-corner limit); otherwise `sketch.warning.edgeBreakTooLarge` and the break is skipped. Concave corners use the mitre as well. The spike (1.2) builds this on a rounded rectangle with a hole and a slot and records the limits; if it is not robust enough, the field stays in the model but the UI offers only chamfer for convex-only profiles. +9. **Viewport.** `Viewport` gets an optional `sketch` prop `{plane, sketch, selection, preview, tool, grid}` and callbacks `onSketchPointer(kind, x, y, mods)`. The layer draws curves (line segments with a screen-constant width, arcs as polylines), points, constraint glyphs (sprites at curve midpoints), dimension labels (CSS2D-style DOM labels positioned from plane coordinates, editable on click) and the plane grid. In sketch mode the camera is orthographic looking along the plane normal; wheel and middle-drag pan/zoom; rotation is locked until the sketch is finished (the ViewCube is disabled but "Look at plane" resets the view). Picking in the sketch layer uses screen-space distances to curves and points (6 px), independent of the scene raycaster. +10. **History and persistence.** Sketch commands (`addCurve`, `moveFromDrag`, `addConstraint`, `setDimension`, macros, `deleteSelection`) go through the editor's `dispatch(label, patch, mergeKey)` with merging for drags and dimension typing. A solver run is part of the command: it returns the new positions together with the patch, so undo restores positions without solving. `doc.sketches` is added to `SceneDoc` (optional field, schema stays 1; `migrateScene` normalises every number and drops constraints that refer to missing ids, logging them). Features reference sketches by id; deleting a used sketch asks for confirmation and marks the dependent features as failed. +11. **Recompute.** Changing a dimension → solve (main thread, ≤ 16 ms target for 200 entities) → patch → the worker rebuilds only the bodies that use that sketch (cache key `(feature list, sketch versions, quality)`); the preview while an Extrude dialog is open uses a coarser quality and the same pipeline. +12. **SVG import** (`geometry/svg.ts`, pure, no DOM parser dependency beyond `DOMParser` in the main thread): supported elements `path` (M L H V C S Q T A Z, absolute and relative), `rect` (rx, ry), `circle`, `ellipse`, `line`, `polyline`, `polygon`, `g` transforms (matrix, translate, scale, rotate, skew); `use`, text, images, CSS and clipping are ignored with a notice. Units: user units to mm by a dialog (default 96 dpi, i.e. 0.2646 mm per unit, or "fit width to N mm"); curves (Bézier, ellipse) are flattened to the chord tolerance; arcs and circles stay arcs and circles when the transform is a similarity. Y is flipped (SVG down → sketch up). Limits: 50 000 segments, file ≤ 5 MB, otherwise refused with a message. +13. **UI structure.** Entering sketch mode swaps the tool bar to: Select, Line, Rectangle▾ (2 corner, centre), Circle, Arc▾, Slot, Polygon, Fillet, Chamfer, Trim, Mirror, Construction, Dimension, Constraints▾, Finish. Properties show the selected curve, its constraints and an editable list of dimensions (each a `LengthField`). The status bar shows `Sketch 1 · 3 DoF` / `Fully defined` / `Conflict`. Feature creation is in the Add menu of the scene mode (Extrude, Revolve) and opens a side dialog (not a modal) with live preview; the body's features are listed in the outliner under the body and in its properties, each editable, deletable (with re-evaluation) and marked when failed. No artboard exists; screens are built from `ui/` components and tokens, compared with the wireframes below. +14. **Hotkeys** extend the registry of `39` (active only in sketch mode): `L` line, `R` rectangle, `C` circle, `A` arc, `D` dimension, `X` construction, `T` trim, `F` fillet, `Enter` finish, `Esc` cancels the current tool, then clears selection, `Delete` removes the selection, `H`/`V` add horizontal/vertical to the selected line. + +## Wireframes (reference for the browser comparison) + +``` +Sketch mode +┌ Toolbar: Select Line Rect▾ Circle Arc▾ Slot Poly │ Fillet Chamfer Trim Mirror │ Constr. Dim │ Finish ┐ +├ Outliner ─────────┬ View (ortho, 2D grid) ─────────────┬ Properties ───────────────┤ +│ Sketches │ ┌────────┐ ⟂ glyphs, dims │ Line 3 │ +│ ▸ Sketch 1 👁 │ │ ○ │ 42.0 │ Constraints ⟂ ═ H ✕ │ +│ Bodies │ └────────┘ │ Dimensions Length 42.0 │ +│ ▸ Body 1 │ status: Sketch 1 · 3 DoF │ Radius 4.0 │ +└───────────────────┴────────────────────────────────────┴───────────────────────────┘ +Extrude dialog (side panel over Properties): Sketch ▾ · Regions (click in view) · Side ▾ · Distance · Operation ▾ · Top edge: none/chamfer/fillet · Bottom edge · OK / Cancel +``` + +## Risks / Trade-offs + +- **Solver quality is the product.** A weak solver (jumping to mirrored solutions, slow drag, vague errors) ruins the sketcher. Mitigations: nearest-solution damping, a regression set of 30 reference sketches (rounded rectangle, slot, gear-like polygon, bracket with holes, tangent chains) with expected DoF, and the spike comparing with `planegcs` before committing to the own solver. +- **Overlapping profiles are rejected.** Fusion users draw overlapping shapes and pick regions. v1 asks for non-overlapping loops (clear error with the crossing point). A planar-arrangement step can be added later via `CrossSection` booleans without changing the stored data. +- **Edge chamfer/fillet via offset loft** is the least certain geometry here (collapse, concave corners, tiny holes). It is isolated (`edgeBreak.ts`), optional in the UI, and the spike decides how much of it ships. +- **No topology naming.** Features reference sketches and sketch curves by stable ids, never mesh faces, so sketch edits cannot break references; face-based sketches (`44`) will add a face reference that must survive edits and are left out here on purpose. +- **Discretisation.** Curves become polygons at build time. Exports at the default quality are visibly smooth (96 segments per circle); the quality setting for export is a later option, the data already allows it. + +## Open Questions + +- Own solver or `planegcs` (spike 1.1: licence, size, speed on 200 entities, quality of DoF/conflict reporting). +- Whether the Slot and Polygon macros stay in v1 if the schedule is tight (they are independent tasks and can be dropped without changing the model). +- Whether the sketch plane offset needs a plane object of its own now (kept as two numbers on the sketch). diff --git a/openspec/changes/40-editor-sketch/proposal.md b/openspec/changes/40-editor-sketch/proposal.md new file mode 100644 index 0000000..d6312ea --- /dev/null +++ b/openspec/changes/40-editor-sketch/proposal.md @@ -0,0 +1,39 @@ +# Proposal + +## Why + +The foundation (`39`) lets the user import STL and combine primitives, but nothing can be created from scratch with real dimensions. In every desktop CAD the answer is the same loop: draw a profile on a plane, pin it down with constraints and dimensions, then extrude or revolve it into a solid. This is what turns the editor from a mesh viewer into a modelling tool: brackets, adapters, enclosures, clips, anything with exact holes and slots. + +Most printed parts are an extruded profile with a few cuts, so a small, solid sketcher gives the largest gain. The plan and the research are in `openspec/EDITOR.md`; this change implements section 1 ("Эскиз") and the rule of section 2a: **the sketch and the feature parameters are the source, the mesh is derived.** Rounded rectangle corners are in from the start (rectangle with corner radius and a 2D Fillet/Chamfer tool), as the owner asked. + +## What Changes + +- **Sketch mode.** New sketches on the XY, XZ or YZ plane (with offset); entering a sketch aligns the camera orthogonally to the plane, shows a 2D grid, swaps the tool bar to sketch tools and the properties to the constraint/dimension list; *Finish sketch* (`Enter`) returns to the scene. Sketches are listed in the outliner under "Sketches" (rename, show/hide, edit, delete) and drawn in the view as curves when visible. +- **Drawing tools (v1):** Line / polyline, Rectangle (two corners or centre, with an optional **corner radius**), Circle (centre + radius), Arc (3 points, or centre-start-end), Slot (obround), regular Polygon, Construction toggle. Slot, Rectangle and Polygon are *macros* that create lines and arcs plus constraints, not new solver entities. +- **Editing tools:** **Fillet** and **Chamfer** of a 2D corner (arc tangent to both lines with a radius dimension), Trim, Mirror, Move by drag with the solver keeping constraints, Delete. +- **Constraints and dimensions.** Coincident, point on line / circle, horizontal, vertical, parallel, perpendicular, tangent, equal, symmetric, midpoint, fixed; dimensions: length, distance (also horizontal and vertical), radius / diameter, angle. Dimensions are editable numbers (`LengthField`, mm stored). Constraints are inferred while drawing (snap to endpoints, horizontal/vertical, tangent to an arc end) and can be added by hand. +- **Solver and status.** A small own solver (Levenberg–Marquardt on constraint residuals, see design); degrees of freedom shown in the status bar; under-defined geometry blue, fully defined black, conflicting or redundant constraints red with the offending constraints highlighted. Dragging a point re-solves live. +- **Profiles.** Closed loops are found from the visible, non-construction geometry; nested loops become holes; open loops, gaps and crossing curves are reported with their location. Regions are picked for a feature (default: all). +- **Features.** **Extrude** (distance, one side / symmetric / two distances, operation New / Join / Cut / Intersect, optional chamfer or fillet of the top and bottom edges) and **Revolve** (axis = a sketch line or a plane axis, angle 1–360°). A body built from sketches stores an ordered **feature list** `[Extrude, Revolve, …]` evaluated in order; each feature keeps its sketch reference and parameters, so editing a dimension of any sketch recomputes the body. A live preview is shown while the dialog is open. +- **SVG import** into a sketch: paths (lines, arcs, curves flattened to a tolerance), rect, circle, ellipse, polygon, with a scale dialog; the imported geometry is unconstrained and can be dimensioned or fitted later. +- **Document and history.** Sketches live in the scene document (`sketches`), bodies get the source kind `feature`; all sketch commands use the editor's undo/redo; stored positions are the solved ones, so the worker needs no solver. +- **Viewport extension** (generators untouched): a sketch layer (curves, points, constraint glyphs, dimension labels), pointer events converted to plane coordinates, orbit locked to pan/zoom while sketching. + +## Capabilities + +### New Capabilities +- `editor-sketch`: sketch planes, mode, drawing and editing tools, outliner entries. +- `editor-sketch-constraints`: constraints, dimensions, inference, solver, degrees of freedom and conflict reporting. +- `editor-sketch-features`: profiles, Extrude, Revolve, edge chamfer/fillet, feature list, preview. +- `editor-sketch-import`: SVG import into a sketch. + +### Modified Capabilities + +## Impact + +- New `src/editor/sketch/` (React-free: `model.ts` entities and constraints, `solver.ts`, `dof.ts`, `infer.ts`, `tools/*` state machines and macros, `plane.ts`, `commands.ts`, `migrate.ts`) and `src/geometry/features/` (`profile.ts` loops/regions, `extrude.ts`, `revolve.ts`, `edgeBreak.ts`, `evaluate.ts`) and `src/geometry/svg.ts`; `src/features/editor/sketch/` (UI: `SketchToolbar`, `ConstraintPanel`, `DimensionTool`, `FeatureDialog`, `SketchList`, status line). `geometry/model.ts`: source kind `feature` in `generate('editor')`. `Viewport.tsx`: optional sketch layer. Outliner, properties, tool bar, hotkeys and strings (en, ru) of `39` are extended. Tests for every layer. +- No new runtime dependency is planned (own solver, own SVG parser); the spike (section 1) may replace the solver with a permissively licensed library if it is clearly better, and records the decision in `design.md`. +- Existing generators, scenes of `39` and stored keys keep working: the new fields are optional and the scene schema stays 1. +- Out of scope: sketch on a body face and projecting edges (`44`); spline, ellipse, text, offset curve, patterns and sketch blocks (later); named parameters and formulas; reference dimensions; Sweep and Loft; draft (taper) angle on Extrude; "extrude up to" a body; operations between bodies, feature reordering, suppression and a timeline UI (`41`, which generalises the feature list built here); DXF import; true B-rep fillets; 3D sketches. +- Version 1.11.0 (minor: a new capability). +- Depends on: `39-editor-foundation`. diff --git a/openspec/changes/40-editor-sketch/specs/editor-sketch-constraints/spec.md b/openspec/changes/40-editor-sketch/specs/editor-sketch-constraints/spec.md new file mode 100644 index 0000000..5ebe8de --- /dev/null +++ b/openspec/changes/40-editor-sketch/specs/editor-sketch-constraints/spec.md @@ -0,0 +1,73 @@ +# Spec Delta + +## Purpose +Defines constraints, dimensions, inference, the solver and the reporting of degrees of freedom and conflicts. + +## ADDED Requirements + +### Requirement: Constraints +The sketch SHALL support the constraints coincident, point on line, point on circle or arc, horizontal, vertical, parallel, perpendicular, tangent, equal, symmetric, midpoint and fixed, added from a menu for the current selection, listed in the properties and removable one by one. + +#### Scenario: Add parallel +- **WHEN** two lines are selected and the user applies Parallel +- **THEN** the second line turns to be parallel to the first, a glyph is shown, and the constraint is listed + +#### Scenario: Not applicable +- **WHEN** the selection does not fit a constraint +- **THEN** that menu item is disabled with a reason + +### Requirement: Dimensions +The sketch SHALL support driving dimensions: length or distance (point–point, point–line, horizontal and vertical), radius or diameter, and angle between two lines. A dimension SHALL be editable in place; lengths are entered with the length field in the shown unit and stored in millimetres. + +#### Scenario: Dimension a line +- **WHEN** the user picks Dimension, clicks a line and types 42 +- **THEN** the line becomes 42 mm long, the dimension label shows 42, and moving a connected point keeps 42 + +#### Scenario: Inches +- **WHEN** the unit is inches and the user types 1.5 into a dimension +- **THEN** the stored value is 38.1 mm + +#### Scenario: Change later +- **WHEN** the user edits the dimension to 50 +- **THEN** the geometry is solved again and dependent features are recomputed + +### Requirement: Inference while drawing +While drawing, the Editor SHALL snap to existing points, midpoints, curves, horizontal and vertical alignment, tangent directions and the grid, show which rule matched, and add the matching constraint on click. Ctrl SHALL disable inference. + +#### Scenario: Horizontal line +- **WHEN** the user draws a line within 3° of horizontal +- **THEN** it snaps horizontal, a horizontal glyph is shown, and a horizontal constraint is added + +#### Scenario: Connected +- **WHEN** a line ends on an existing endpoint +- **THEN** the endpoints are the same point + +### Requirement: Solver +The sketch SHALL be solved after every change with all constraints; the solution SHALL be the one nearest to the previous positions, and dragging SHALL update live. If no solution exists the previous positions SHALL be kept and the reason shown. + +#### Scenario: Nearest solution +- **WHEN** a dimension is changed on a constrained triangle +- **THEN** the triangle keeps its orientation and does not flip to the mirror image + +#### Scenario: No solution +- **WHEN** a dimension makes the sketch impossible (a side longer than the sum of the others) +- **THEN** the geometry stays unchanged, the dimension is marked in red and a message explains + +### Requirement: Degrees of freedom and conflicts +The sketch SHALL show the number of degrees of freedom in the status bar, colour under-defined entities blue and fully defined entities black, and mark conflicting or redundant constraints in red naming them. + +#### Scenario: Under-defined +- **WHEN** a rectangle has only a width dimension +- **THEN** the status shows the remaining degrees of freedom and the unconstrained corner points are blue + +#### Scenario: Fully defined +- **WHEN** all freedoms are removed +- **THEN** the status shows "Fully defined" and all geometry is black + +#### Scenario: Redundant +- **WHEN** the user adds a dimension that repeats what constraints already fix +- **THEN** it is marked redundant and the sketch is still usable + +#### Scenario: Conflict +- **WHEN** two constraints contradict each other +- **THEN** the conflicting constraints are highlighted red and a message names them; the geometry is not changed diff --git a/openspec/changes/40-editor-sketch/specs/editor-sketch-features/spec.md b/openspec/changes/40-editor-sketch/specs/editor-sketch-features/spec.md new file mode 100644 index 0000000..9efba45 --- /dev/null +++ b/openspec/changes/40-editor-sketch/specs/editor-sketch-features/spec.md @@ -0,0 +1,92 @@ +# Spec Delta + +## Purpose +Defines profiles, Extrude, Revolve, edge chamfer and fillet of extrusions, the feature list of a body and recomputation. + +## ADDED Requirements + +### Requirement: Profiles +The Editor SHALL find closed profiles in the visible, non-construction geometry of a sketch, treat loops inside loops as holes, and report open ends and crossing curves with their position. + +#### Scenario: Plate with a hole +- **WHEN** a sketch has a rectangle with a circle inside +- **THEN** one profile with one hole is found + +#### Scenario: Open profile +- **WHEN** two lines of a rectangle do not meet +- **THEN** extruding is refused, the message says "profile is not closed" and the gap is marked in the view + +#### Scenario: Crossing curves +- **WHEN** two loops overlap without being nested +- **THEN** the message names the crossing point and nothing is extruded + +### Requirement: Extrude +The Editor SHALL extrude selected profiles of a sketch by a distance on one side, symmetrically, or by two distances, with the operation New body, Join, Cut or Intersect, showing a live preview while the dialog is open. + +#### Scenario: New body +- **WHEN** the user extrudes a 40 × 20 rectangle by 10 mm +- **THEN** a body of 40 × 20 × 10 mm with volume 8000 mm³ is created on the sketch plane, and the sketch is hidden + +#### Scenario: Symmetric +- **WHEN** the side is symmetric with distance 10 +- **THEN** the body extends 5 mm to each side of the plane + +#### Scenario: Cut +- **WHEN** the user extrudes a circle with the operation Cut through a body +- **THEN** a hole is made in that body and no new body is created + +#### Scenario: Pick regions +- **WHEN** a sketch has two separate profiles and the user clicks only one in the view +- **THEN** only that profile is extruded + +#### Scenario: Zero distance +- **WHEN** the distance is zero or negative +- **THEN** the field shows the limit and the preview is not updated + +### Requirement: Revolve +The Editor SHALL revolve a profile around a sketch line or a plane axis by an angle from 1° to 360°. + +#### Scenario: Full revolution +- **WHEN** a rectangle beside the axis is revolved 360° +- **THEN** the result is a ring or cylinder whose volume equals the Pappus value within the polygon approximation + +#### Scenario: Crossing the axis +- **WHEN** the profile crosses the axis +- **THEN** the revolve is refused with a message + +### Requirement: Edge chamfer and fillet of extrusions +An Extrude SHALL allow a chamfer or a fillet of the top and bottom edges with a size, built from the profile so that holes and rounded corners follow it. + +#### Scenario: Chamfer +- **WHEN** the top chamfer is 1 mm on a 40 × 20 × 10 extrusion +- **THEN** the top face is 38 × 18 mm and the wall ring is a 45° slope + +#### Scenario: Too large +- **WHEN** the size is larger than half the thinnest part of the profile +- **THEN** the edge break is skipped and a warning says the maximum size + +### Requirement: Feature list and recompute +A body built from sketches SHALL keep an ordered list of features (the first is New body), shown under the body in the outliner, each editable and deletable. Changing any dimension of a used sketch or any feature parameter SHALL recompute the body. + +#### Scenario: Edit dimension +- **WHEN** the user edits the width of the sketch used by a plate and its hole features +- **THEN** the plate and the holes follow, and the position of the hole relative to the sketch constraints is kept + +#### Scenario: Failed feature +- **WHEN** a feature fails (its sketch lost its closed profile) +- **THEN** the body shows the result before that feature, the feature is marked failed in the list with the reason, and later features are skipped + +#### Scenario: Delete a feature +- **WHEN** the user deletes the middle feature +- **THEN** the body is recomputed without it + +#### Scenario: Reload +- **WHEN** the page is reloaded +- **THEN** the sketches, features and bodies are restored and rebuilt identically + +### Requirement: Derived mesh +The mesh of a sketch-built body SHALL be derived from its sketches and features and SHALL NOT be the only stored copy. + +#### Scenario: Missing mesh +- **WHEN** the mesh cache is cleared +- **THEN** the body is rebuilt from the stored description without data loss diff --git a/openspec/changes/40-editor-sketch/specs/editor-sketch-import/spec.md b/openspec/changes/40-editor-sketch/specs/editor-sketch-import/spec.md new file mode 100644 index 0000000..c2ce157 --- /dev/null +++ b/openspec/changes/40-editor-sketch/specs/editor-sketch-import/spec.md @@ -0,0 +1,40 @@ +# Spec Delta + +## Purpose +Defines importing vector outlines from SVG into a sketch. + +## ADDED Requirements + +### Requirement: SVG import +The Editor SHALL import an SVG file into a sketch (a new one on a chosen plane or the open one), supporting paths, rectangles, circles, ellipses, lines, polylines, polygons and group transforms, and ignoring other content with a notice. + +#### Scenario: Import a logo outline +- **WHEN** the user imports an SVG with a closed path and a circular hole +- **THEN** a sketch with the outline and the hole is created, with Y pointing up, and it can be extruded into a body with a hole + +#### Scenario: Scale +- **WHEN** the user chooses "fit width to 60 mm" +- **THEN** the imported geometry is scaled proportionally to that width + +#### Scenario: Curves +- **WHEN** the file has Bézier curves +- **THEN** they are flattened to a 0.02 mm tolerance and the number of segments is reported + +#### Scenario: Unsupported content +- **WHEN** the file contains text, images or filters +- **THEN** they are skipped and the notice lists what was ignored + +#### Scenario: Large or invalid file +- **WHEN** the file is over 5 MB, has more than 50 000 segments, or is not valid SVG +- **THEN** it is refused with a message and nothing is added + +### Requirement: Imported geometry +Imported curves SHALL be ordinary sketch geometry without constraints, joined into shared points where endpoints coincide within 0.001 mm, and can be dimensioned, constrained, trimmed and extruded. + +#### Scenario: Joined endpoints +- **WHEN** a path is closed by matching endpoints +- **THEN** the profile is detected as closed + +#### Scenario: Log +- **WHEN** an import finishes +- **THEN** the file name, size and number of curves are logged, not its content diff --git a/openspec/changes/40-editor-sketch/specs/editor-sketch/spec.md b/openspec/changes/40-editor-sketch/specs/editor-sketch/spec.md new file mode 100644 index 0000000..3173cf9 --- /dev/null +++ b/openspec/changes/40-editor-sketch/specs/editor-sketch/spec.md @@ -0,0 +1,76 @@ +# Spec Delta + +## Purpose +Defines sketches on planes, the sketch mode, the drawing and editing tools and how sketches appear in the editor. + +## ADDED Requirements + +### Requirement: Sketch planes and mode +The Editor SHALL create a sketch on the XY, XZ or YZ plane with an offset along the normal, enter a sketch mode that looks at the plane orthogonally with a 2D grid, and leave it with Finish sketch. + +#### Scenario: New sketch +- **WHEN** the user chooses Add → Sketch → XZ plane +- **THEN** a sketch "Sketch 1" is created, the camera looks along the plane normal, the grid is 2D, and the sketch tools replace the scene tools + +#### Scenario: Camera lock +- **WHEN** the user drags with the left button on empty space in sketch mode +- **THEN** the camera does not rotate; wheel and middle-drag still zoom and pan + +#### Scenario: Finish +- **WHEN** the user presses Enter or Finish +- **THEN** the scene mode returns, the sketch stays visible as curves, and one history step "Edit sketch" is kept + +### Requirement: Sketches in the outliner +Sketches SHALL be listed under "Sketches" with rename, show/hide, edit and delete, separately from bodies. + +#### Scenario: Delete a used sketch +- **WHEN** the user deletes a sketch used by a feature +- **THEN** a confirmation names the dependent bodies; if confirmed the sketch is removed and those features are marked failed + +### Requirement: Drawing tools +The sketch mode SHALL provide Line (polyline), Rectangle (two corners or centre, with an optional corner radius), Circle (centre and radius), Arc (three points, or centre-start-end), Slot and regular Polygon, each accepting typed values for the main dimensions while drawing. + +#### Scenario: Rounded rectangle +- **WHEN** the user draws a rectangle with corner radius 5 mm +- **THEN** four lines and four tangent arcs are created, the radii are tied by an equal constraint, and one radius dimension of 5 mm drives all four + +#### Scenario: Typed values +- **WHEN** the user starts a circle and types 12 and Enter +- **THEN** the circle has radius 12 mm and a radius dimension + +#### Scenario: Slot +- **WHEN** the user draws a slot of length 30 and width 8 +- **THEN** two lines and two arcs are created, tangent to each other, with the width as a dimension + +### Requirement: Editing tools +The sketch mode SHALL provide Fillet and Chamfer of a corner between two lines, Trim to the nearest intersections, Mirror about a line, Construction toggle and Delete, and dragging of points and curves that respects the constraints. + +#### Scenario: Fillet +- **WHEN** the user picks a corner of two lines and enters radius 3 +- **THEN** both lines are shortened to the tangent points, a tangent arc of radius 3 is added with a radius dimension + +#### Scenario: Chamfer +- **WHEN** the user chamfers a corner with distance 2 +- **THEN** a line replaces the corner with the distance as a dimension on each side + +#### Scenario: Fillet impossible +- **WHEN** the radius is larger than the adjacent lines allow +- **THEN** nothing changes and the message says the maximum allowed radius + +#### Scenario: Drag +- **WHEN** the user drags a corner of a fully constrained rectangle +- **THEN** it does not move; dragging a free corner moves it with all constraints still satisfied + +### Requirement: Construction geometry +Curves marked as construction SHALL be drawn dashed, SHALL take part in constraints and snapping, and SHALL NOT form profiles. + +#### Scenario: Construction circle +- **WHEN** a circle is toggled to construction +- **THEN** it is dashed and an extrude of the sketch ignores it + +### Requirement: Undo in sketch mode +All sketch edits SHALL be undoable with the editor's undo and redo; a drag or a typed value SHALL be one step. + +#### Scenario: Undo a dimension +- **WHEN** the user changes a dimension from 40 to 50 and presses Ctrl+Z +- **THEN** the dimension and the geometry return to 40 without a visible solver run diff --git a/openspec/changes/40-editor-sketch/tasks.md b/openspec/changes/40-editor-sketch/tasks.md new file mode 100644 index 0000000..770a31c --- /dev/null +++ b/openspec/changes/40-editor-sketch/tasks.md @@ -0,0 +1,59 @@ +# Tasks + +## 1. Spikes (decide before building) + +- [ ] 1.1 Solver: in a throwaway test file, implement a Levenberg–Marquardt solver for 6 constraint kinds (coincident, horizontal, distance, radius, tangent line–circle, angle) and compare it with `planegcs` on three reference sketches (rounded rectangle, slot, bracket with 4 holes, 200 entities): solve time, nearest-solution behaviour when a dimension changes, DoF and conflict reporting, licence, WASM size. Record the numbers and the decision in `design.md` (Decision 3, Open Questions). If neither is acceptable, stop for `/opsx:update`. +- [ ] 1.2 Edge break: build the mitre-offset loft (chamfer and fillet) on a rounded rectangle with a hole and a slot, check `expectValidSolid`, volume against the analytic value and collapse limits; record in `design.md` (Decision 8) how much ships (chamfer and fillet, or chamfer on convex profiles only). + +## 2. Sketch data and solver (React-free, `src/editor/sketch/`) + +- [ ] 2.1 `model.ts`: `Sketch`, `Curve`, `Constraint` types, `Plane` and `planeFrame` (to world and back), id generation, point merging, helpers (`addLine`, `addCircle`, `addArc`, `removeCurve` cleaning orphans and constraints). Test: plane frames for XY/XZ/YZ with offset (round trip), merging, removal leaves no dangling refs. +- [ ] 2.2 `solver.ts` per the spike: residuals and Jacobians for every constraint kind of Decision 2, nearest-solution damping, drag residual, tolerance and iteration limits, `solve(sketch, {drag?}) → {positions, ok, reason, residuals}`. Test per constraint kind (a minimal sketch that must satisfy it), a rounded rectangle, a triangle that must not flip, an impossible dimension keeping positions, and a 200-entity sketch within the time budget of the spike. +- [ ] 2.3 `dof.ts`: DoF count, per-entity "fully defined" flags, conflicting and redundant constraint sets. Test: rectangle with 0/1/2/4 dimensions (DoF 5/4/…/fully defined), two contradicting constraints named, a redundant dimension flagged but still solved. +- [ ] 2.4 `infer.ts`: snap candidates and the constraints they imply (priority order of Decision 5), thresholds in screen pixels converted by the view scale. Test each rule and the priority between them, Ctrl off. +- [ ] 2.5 Macros in `tools/`: `rectangle` (with corner radius), `slot`, `polygon`, `fillet`, `chamfer`, `trim`, `mirror` as pure `(sketch, args) → patch`. Test: constraint counts and DoF of each result, fillet radius limits and the message value, changing the rounded-rectangle radius updates all four arcs, trim at intersections of line/line, line/circle. +- [ ] 2.6 `migrate.ts` and the `SceneDoc.sketches` field (optional, schema 1): normalise every number, drop constraints with missing refs with a log entry, re-solve on load when positions disagree with constraints by > 1e-6. Test: corrupt sketch, partial sketch, missing refs, round trip. + +## 3. Profiles and features (geometry, no React or three.js) + +- [ ] 3.1 `geometry/features/profile.ts`: graph of non-construction curves, cycles, open ends and crossings with positions, orientation, nesting (holes), adaptive discretisation, region ids. Test: square, square with hole, two separate squares, nested three levels, open gap reported with its position, crossing loops reported, circle segment counts, arcs sweep direction. +- [ ] 3.2 `extrude.ts` and `revolve.ts`: one, symmetric and two-sided extrusion; revolve with sketch line or plane axis; errors `sketch.error.*`. Test with `expectValidSolid`: 40 × 20 × 10 volume 8000 and bbox, symmetric bbox, plate with hole volume, rounded rectangle area, revolve ring volume against Pappus within tolerance, axis crossing refused, zero distance refused. +- [ ] 3.3 `edgeBreak.ts` per the spike: chamfer and fillet of top and bottom caps with collapse limits. Test: volume against the analytic value for a box and a rounded rectangle, a hole and a slot follow the profile, too-large size gives the warning and no break, result is a valid solid. +- [ ] 3.4 `evaluate.ts`: `evaluateFeatures(features, sketches, quality)` (New, Join, Cut, Intersect in order, stop at the first failure and return the previous solid with the failed index and reason); `geometry/model.ts`: source kind `feature` in `generate('editor')`, cache key by description. Test: plate + cut hole volume, a failing second feature keeps the first result, missing sketch is a failure not a crash, cache hit on identical input, quality changes the segment count only. + +## 4. Viewport sketch layer + +- [ ] 4.1 `Viewport` optional `sketch` prop and `onSketchPointer`: plane grid, curves, points, dashed construction, selection and hover highlight, preview geometry, plane coordinates from pointer rays, orthographic look-at, rotation locked. Generator modes and the scene mode of `39` unchanged. Test (three mocked in jsdom): pointer to plane coordinates for each base plane and offset; pure helpers `screenPick` (6 px) and `lookAtPlane`. +- [ ] 4.2 Constraint glyphs and dimension labels (DOM labels positioned from plane coordinates, in-place editing with `LengthField`), colours by DoF status from theme tokens (blue, black, red). Browser check in both languages and both themes. + +## 5. Sketch UI (no artboard exists: built from `ui/` components and tokens; compare with the wireframes in `design.md`) + +- [ ] 5.1 Sketch mode shell: Add → Sketch with plane choice, enter/finish, tool bar swap, status line (`Sketch 1 · n DoF`), "Sketches" group in the outliner (rename, show/hide, edit, delete with the dependency confirmation). Component tests for each action; compare at 1280 px and 390 px, en and ru. +- [ ] 5.2 Tool state machines and `SketchToolbar`: Select, Line, Rectangle▾, Circle, Arc▾, Slot, Polygon, Fillet, Chamfer, Trim, Mirror, Construction, Dimension, Constraints▾; typed values while drawing; hotkeys of Decision 14 through the registry of `39`; inference glyph next to the cursor. Test the state machines as pure reducers (event sequences → patches) and the toolbar enabling rules. +- [ ] 5.3 `ConstraintPanel` in Properties: selected curve, constraint list with delete and hover highlight, dimension list with `LengthField` / `NumberField` (angle in degrees), conflict and redundancy marks, "Fix" actions. `stepperAssert` passes. Component test: editing a dimension solves and is one history step; conflict is named. +- [ ] 5.4 All sketch commands through `dispatch(label, patch, mergeKey)`; drag and typed values merge; undo restores positions without solving. Test: each command undoable, merge, redo cleared. + +## 6. Feature UI + +- [ ] 6.1 `FeatureDialog` for Extrude and Revolve (side panel, not a modal): sketch picker, region picking by clicking in the view, distance fields (`LengthField`), side, operation, top/bottom edge break, axis for Revolve, live preview at coarse quality through the same pipeline, OK/Cancel as one history step. Component tests and a browser check of preview vs result. +- [ ] 6.2 Feature list under the body (outliner and properties): edit, delete, failed marking with the reason, plain-language error messages from `sketch.error.*` / `sketch.warning.*`. Test: delete recomputes, failed feature marked, later features skipped. +- [ ] 6.3 Export and measurements: the bodies from features are ordinary editor objects for transform, selection and export (`39`); check bbox and volume from `generate` against the STL of the export. + +## 7. SVG import + +- [ ] 7.1 `geometry/svg.ts`: path commands (absolute and relative M L H V C S Q T A Z), basic shapes, group transforms, units and Y flip, flattening tolerance, limits, notices for ignored content, endpoint joining. Test with inline SVG fixtures: closed path with hole, arcs staying arcs under similarity, rotated group, relative commands, 50 000-segment limit, text ignored with a notice. +- [ ] 7.2 Import dialog (file, drop on the sketch, plane choice for a new sketch, scale: units or "fit width to N mm"), log entries (names and sizes only). Component test of the dialog and an end-to-end test: SVG → sketch → extrude → valid solid. + +## 8. Strings and persistence + +- [ ] 8.1 Strings for everything above in `en.json` and `ru.json`, including `sketch.error.*`, `sketch.warning.*`, constraint and tool names, `log.editor.sketch.*` (key-parity test must pass). +- [ ] 8.2 Persistence: sketches and features in `editor.scene`, old scenes of `39` load unchanged, deleted sketch handling, reload test with a plate, a hole and a failed feature. + +## 9. Version + +- [ ] 9.1 Bump `package.json` to 1.11.0 and add `1.11.0 — 40-editor-sketch` at the top of `CHANGELOG.md`. Test: `site.test.ts` passes. + +## 10. Verification + +- [ ] 10.1 `npm test` and `npm run build` are green, the dev server starts, the console has no errors; generators and the foundation of `39` work as before. +- [ ] 10.2 In the browser, en and ru, desktop and 390 px: draw a rounded rectangle with a radius dimension, add a slot and four holes, constrain it to fully defined, extrude it, cut the holes, change the width and the radius and watch the body follow, add a revolved knob from a profile, import an SVG outline and extrude it, undo and redo through all of it, reload and compare, export STL and check bbox and volume against the numbers typed. From 7ba1a53f7d86c7da484c7427c0214df03b6469eb Mon Sep 17 00:00:00 2001 From: skarakovski Date: Sun, 1 Nov 2026 13:59:31 +0700 Subject: [PATCH 2/3] AGENTS.md --- AGENTS.md | 81 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 82 +------------------------------------------------------ 2 files changed, 82 insertions(+), 81 deletions(-) create mode 100644 AGENTS.md mode change 100644 => 120000 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..18c77b6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,81 @@ +# AGENTS.md + +This file provides guidance to AI Agents when working with code in this repository. + +## Commands + +- `npm run dev` — Vite dev server +- `npm run build` — `tsc --noEmit` (type check) and then `vite build` into `dist/` +- `npm test` — `vitest run` (all tests) +- Single test: `npx vitest run src/baseplate.test.ts -t ""` + +There is no linter configured. + +## Workflow + +Work is planned as numbered OpenSpec changes in `openspec/changes/NN-name/` (order and dependencies: `openspec/ROADMAP.md`). Take them strictly in numeric order, one change at a time. + +1. **Pick the change.** The lowest-numbered active change (`openspec list`). Every change under its "Depends on" line must already be archived; otherwise stop. +2. **Apply.** `/opsx:apply NN-name`. Read proposal, specs, design and tasks before coding. Do the tasks in order, keep each one minimal and focused, and tick its checkbox (`- [x]`) right after it is truly done, with the behaviour from the spec fully implemented, not partly or deferred. If a task is unclear, needs extra scope or contradicts the design, stop and update the artifacts (`/opsx:update`) instead of guessing. +3. **UI tasks follow the design.** Each cites an artboard in `design/artboards/` (map in `design/README.md`). Build with the `ui/` components and Tailwind tokens from `theme.css`, no ad-hoc colours or sizes. Compare the result with the artboard in the browser, in both en and ru, and at 390 px if the layout changes. If no artboard exists, build from the component library and say so in the task. +4. **Rules for every task.** + - All UI text goes through i18n: add keys to both `en.json` and `ru.json`. + - Lengths are stored in mm; use `LengthField` for any length. + - Geometry stays React-free in `geometry/`; add or update a vitest test with the task (geometry: validity and bounding box; UI: component test). + - Verify what the task names (browser check, STL bbox/volume), not only that tests pass. +5. **Before closing a task section.** `npm test` and `npm run build` are green; the dev server starts and the console has no errors. Commit after each completed task section with a message that names the change, for example `04-fastener-placement: add FastenerSection`. +6. **Version.** Every change raises the semantic version (`package.json` is the single source, shown on the site as `v`; the footer and the beta notice read it through `APP_VERSION` in `app/site.ts`): minor for a new feature, mode, pattern or option, patch for a fix or rework without a new capability, major for an incompatible change (stored settings that cannot be migrated, removed or moved routes). The target version is stated in the proposal; the bump (`package.json` plus a line in `CHANGELOG.md` naming the change) is the last implementation task, before verification. 1.0.0 is the release of `29-beta-label`; changes before it do not bump. +7. **Finish.** When all tasks are `[x]`, run `/opsx:archive NN-name` (sync specs, then archive) and commit. Archive in numeric order so the main specs stay consistent. Update the Architecture section below if the structure or conventions changed. + +Changing the plan itself (new requirement, reorder, split a change) goes through `/opsx:update` or `/opsx:propose`, not by editing code first. Keep `openspec/ROADMAP.md` and the "Depends on" lines in sync when numbers change. + +## Architecture + +PrintKit is a client-only React 19 + TypeScript app (Tailwind CSS v4, Tabler Icons, react-router hash routes, i18next) with parametric tools for 3D printing: Gridfinity parts, keychains and (planned) rack parts. It previews them in 3D and exports binary STL and 3MF. There is no backend. Planned work lives in `openspec/changes/NN-*` (build order in `openspec/ROADMAP.md`); the visual design is in `design/` (artboard → task map in `design/README.md`). + +Layout of `src/`: +- `app/` — entry (`main.tsx`, `App.tsx`), `products.ts` + `modes.tsx` (registry of product sections, all with modes: `gridfinity` box/baseplate/cutout, `keychain` image/shape, `rack` panel + case + shelf; a mode is `{id, path, Icon, labelKey, startKey}`; a new product = one entry + routes), `routes.tsx` (hash router: `/` product chooser `StartPage`, `/gridfinity`, `/keychain` and `/rack` mode chooser `ModeChooser(product)`, `/gridfinity/{box,baseplate,cutout}`, `/keychain/{image,shape}`, `/rack/{panel,case,shelf}`, unknown subpaths go back to the product's chooser; legacy `/box|/baseplate|/cutout` redirect to the new paths, unknown → `/`), `StartLayout` (shared start-page frame, `ChoiceCard`, number hotkeys), `AppShell`, `Header` (product switcher, mode switcher for products that have modes, mm/in, EN/RU; logo text = active product), `StartPage`, `Workspace` (parameter panel + sticky footer + viewport; bottom drawer below 768 px), `usePersistedState`, `lastSession` (`gridfinity.last` stores product and mode as a string; old records without product read as gridfinity, a keychain record without mode leads to the chooser), `theme.css` (Tailwind `@theme` tokens from the design). +- `ui/` — generic controls with no domain knowledge: `Section` (always starts collapsed, no `defaultOpen`; `modified` colours its summary with the `--color-changed` token and adds "(changed)" to the toggle's accessible name; compute it with `isChanged(slice, defaults)` from `ui/isChanged.ts`, using the same slice that the section's Reset writes, in mm so units never matter; sections whose summary is not a setting — file name, cutout list/selection/placement — never pass it), `ConfirmDialog`, `Toggle`, `SegmentedControl`, `NumberField`, `LengthField`, `Banner`, `Button`, and units (`units.ts`, `UnitProvider`). +- `features/{baseplate,box,cutout,viewport}/` — a mode owns its panel, params and persistence key. `ComingSoon` (app/) is the placeholder for modes or products that are not built yet; nothing uses it now. +- `geometry/` — React-free CSG kernel, see "Geometry package" below. +- `i18n/` — `en.json`, `ru.json`, init with language detection. + +Geometry package (`src/geometry/`, no React or three.js imports): +- `spec.ts` — Gridfinity constants (pitch 42, layer stacks `SOCKET_LAYERS`/`FOOT_LAYERS`, clearance, radii, fastener sizes). Pitch is a parameter everywhere, never read from a constant inside builders. +- `profiles.ts` — `layeredHull` is the one profile builder (low chamfer / wall / up chamfer); `socketPocket`, `binFoot`, `lipProfile(style)` are data-driven wrappers. Profile slabs overshoot the nominal top by `SLAB_T` (0.001 mm) on purpose, for clean cuts. +- `thickness.ts` — `Thickness` defaults and `validateThickness` (returns i18n keys `thickness.*`, never text). `sizing.ts` — `resolveAxis(size, pitch, align)` → cells, padding, offset, truncated. +- `footprint.ts` — `Footprint` (cells, size, offsets; origin = footprint centre), `footprintFromCells/Size`, `cellCenters`. `fasteners.ts` — `FastenerConfig` (per kind `{mode: none|corner|full|manual, slots: Slot[]}` + sizes/presets/tolerance/`earWall`). Holes only exist at the standard cell-corner positions (13 mm from the cell centre); Manual is a selection of `Slot`s (cell i,j + corner), stored per slot so it survives grid resizing, and switching to Manual always starts empty. `resolvePositions`, `fastenerCuts(w, config, footprint, floor)` (hole cutters + the position list the 2D editor shows), `cellWindows` (per-cell open windows below the pocket minus the ears), `fastenerEar`, `checkFasteners` (warning keys). Fasteners never add a full floor: the baseplate keeps material only in ears (hull of a disc around the hole and the window corner) of height `FLOOR`, so total height is 4.65 + 4 mm. Old stored coordinates snap to slots within 3 mm (`slotsFromPoints`, `features/baseplate/migrate.ts`). Any mode that supports fasteners reuses these and `features/fasteners/FastenerSection`. +- `split.ts` — `SplitSettings` (persisted: bed source/size, margin, auto or manual cuts, connector, exploded view) → `splitGeometry` → `SplitGeometry`; `autoCuts` (fewest parts, cells spread evenly, remainder to the first parts, rim/padding stay in the edge parts), `resolveCuts`, `splitParts` (labels A1, B1…: column letter + row number; each part has its rectangle, so the union of parts is the plate). `connectors.ts` — `ConnectorConfig` (none | holes | dovetail, per-cut overrides), `cutFaces` (faces between parts, with `ribs` = cell boundaries strictly inside the face), `planFace`, `connectorExtras`. Holes (presets: filament 1.75 → Ø1.9, pins 2/3 mm, magnets 3×2 and 2.8×0.75; default filament, depth 6 mm) are drilled **along the rib that meets the cut, into its end face**, in both parts, at the rib ends nearest to the evenly spaced count/inset positions; they are blind (depth = min(configured, part length − 2 mm)), never reach a socket, and a face with no rib end (one cell wide) reports `connector.noRib`. The dovetail lies flat on the bed: the key spans z = 0…`dovetailHeight` (2 mm) on part A, the socket in part B is open at the underside and closed `clearance` above the key, so parts join by lowering one onto the other; its depth is checked against the wall width (`dovetailFits`, ≈ ≤ 1.2 mm). Collisions with fastener holes move/skip connectors and issues come back as `connector.*` keys; stored removed presets (m3, m6x2) load as `custom`. +- `wasm.ts` — `getWasm(wasmUrl?)`, one WASM module per thread; the url is optional so tests run in Node. `model.ts` — `generate(mode, params)` dispatches by mode and returns a `ModelResult` (`parts: PartMesh[]` = label, col, row, oversize, `MeshData`; one part `A1` when there is no split); new modes are added to `ModeParams` there. `buildBaseplate` builds the whole plate (tests, comparisons), `buildBaseplateParts` builds each part directly from its cell range (`buildPlate(w, g, part, extras)`), so all parts share the same height and edge parts keep rim, padding and rounded corners. `worker.ts` + `modelClient.ts` — the worker owns WASM; the client makes the latest request win and drops stale replies. UI side: `app/useModel(mode, params)` (debounce 150 ms, `busy` after 200 ms, `pending` while the mesh is stale, previous mesh kept on error). +- Tests: use `expectValidSolid` from `testing.ts` (status, non-empty, volume > 0, optional bbox). When refactoring geometry, add a volume + bbox regression case first and keep it green. + +Conventions: +- **Geometry is built with `manifold-3d` (WASM), not three.js**, and runs in a Web Worker. The rest of the app only sees `ModelResult`/`MeshData` (typed arrays + bbox + volume), never `Manifold` objects. `Viewport` takes a `ModelResult` plus the exploded `gap` (parts are moved in the scene only; geometry stays in assembled coordinates) and `toBinaryStl` takes one `MeshData`. `Viewport.tsx` is imperative: the three.js scene is created once in a mount effect and driven through an `api` ref. Z is up. The camera orbits freely, also below the XY plane (to inspect magnet pockets and ears on the underside): the ground grid and the build plate overlay fade out with `groundOpacity(cameraZ, distance)` (`features/viewport/ground.ts`) and a second light from below keeps down-facing faces readable. The camera is fitted only on the first model. In jsdom tests mock the Viewport (no WebGL). +- **Units:** everything is stored and computed in mm. Only `LengthField` converts to/from the display unit (`UnitProvider`, key `gridfinity.unit`); switching units never touches params. Use `LengthField` for any length, `NumberField` for unitless numbers. Both show − / + buttons by default (`stepper={false}` opts out): click = ±step, hold repeats (400 ms, then 10/s), Shift = ×10, limits disable the button; in inches the step is applied in the display unit and the stored mm stays exact. `ui/stepperAssert.ts` is the test helper that checks every numeric input of a panel has both buttons. +- **i18n:** no hard-coded UI text; add keys to both `en.json` and `ru.json` (a test fails if they diverge). Language key `gridfinity.lang`; `` follows the active language. +- **Persistence:** `usePersistedState(key, defaults, migrate?)` merges stored JSON over defaults and falls back to defaults on corrupt data; pass a `migrate` function when a stored shape changes (always normalise nested objects such as `fasteners` with `features/fasteners/normalize.ts`, a partially stored value must never crash the mode) (see `features/baseplate/migrate.ts`: old `magnets`/`screws` booleans → `fasteners`, old manual coordinates → slots). One key per mode (`gridfinity.baseplate`). Storage keys keep the `gridfinity.*` prefix (and `keychain.*`) although the site is called PrintKit: renaming them would reset every visitor's settings, language and last session. The site name, GitHub URL, donation address and Metrika counter live in `app/site.ts` and so does `APP_VERSION` (= `package.json` `version`, injected as `__APP_VERSION__` by `define` in `vite.config.ts`, which Vitest shares; never write the number anywhere else). `BetaBadge` (header, next to the logo) opens the disclaimer popover; the same text and `v` are in the start-page `Footer`; `CHANGELOG.md`'s top heading must carry the current version (`site.test.ts`). +- **Baseplate model** (`geometry/baseplate.ts`): `BaseplateParams = BaseplateGeometry + preview`; geometry is `{pitch, x: AxisSize, y: AxisSize, thickness, fasteners}` where `AxisSize = {mode: 'units'|'mm', value, align}` (units scale with the pitch, mm do not). `baseplateLayout` resolves cells, padding, offsets (rim included) and truncation; `baseplateHeights` gives the total height and the pocket bottom = `(baseHeight − 4.65) + max(floor, ears)`; `validateBaseplate` returns `baseplate.error.*` keys and `BaseplateMode` skips generation while invalid (`useModel(..., {enabled})`, the last valid mesh stays, export is disabled). Thickness: `baseHeight` (≥ 4.65), `floor` (0 = open frame), `rim` (outer wall around the whole plate), `paddingDrop` (how far the padding sits below the rim, 0 = flush), `cornerRadius` (default 4). Splitting is `params.split` (UI settings) → `toGeometry(p)`; the geometry's `SplitGeometry` is normalised to the default when disabled so toggling the exploded view or the gap never rebuilds. Preview-only values (build plate) live in `preview` and never trigger a rebuild. UI: `SizeSection`, `ThicknessSection`, `PlateSection`, `FastenerSection`; build-plate presets in `features/baseplate/bedPresets.ts`. In geometry tests use `sharpParams`/`units`/`mm` from `geometry/testing.ts` (square corners match the prototype's regression volumes). +- **Box model** (`geometry/box.ts`): `BoxParams = {pitch, x, y (AxisSize), height {units|mm}, thickness {outerWall, innerWall, floor}, lip, scoop {front,back,left,right,radius}, compartments, fasteners}`; persisted under `gridfinity.box` via `features/box/migrate.ts`. `boxLayout` gives the outer size (size − 0.5 mm clearance), cavity, wall top, total height (wall top + lip height), cavity bottom (foot 4.75 + floor) and the clamped scoop radius; `validateBox` (keys `box.error.*`, generation is skipped while invalid) and `boxNotices` (`box.notice.scoopClamped`). Build order in `buildBoxSolid`: feet (`binFoot`, one per full cell) ∪ body (rounded rect from foot top to total height) − cavity (prism, 45° chamfer under the lip, lip profile from `layeredHullRect`) + dividers + scoops − bottom fasteners (magnet pockets open at the underside, blind screw holes). Compartments: `xDividers`/`yDividers` are centre positions as fractions of the cavity length; `removed` holds merged segments `x:k:j` / `y:k:i`; `equalDividers` generates equal division. Not implemented (not in the spec): the artboard's separate "ledge" shelf, label tabs, lids, per-compartment heights. +- **Cutout model** (`geometry/cutout.ts`, `bin.ts`, `stl-import.ts`, `cutout-placement.ts`): `bin.ts` is the shared base of Box and Cutout (`binLayout`, `buildBinBody` = feet + body, `lipOpening`, `bottomFasteners`); Cutout leaves the interior solid, cuts the lip opening above the top face and subtracts pockets from the top face (`wallTop`) down by `depth` (limited to `wallTop − 4.75 − floor`). A `Cutout` is `{id, name, source, x, y, rotation, depth, clearance, visible, color}` with x/y = centre of its footprint box relative to the bin centre; `color` is a palette index (0–6, theme tokens `--color-cutout-N`, `features/cutout/palette.ts`) assigned by `addCutout`/`duplicateCutout` as the lowest index no other cutout uses (wrapping when all are used), kept for the cutout's life, carried to the 3D tool by `ToolMesh.color` and filled for old sessions by `fillCutoutColors` in `migrateCutout`; `source` is a rounded rectangle, a circle (`{kind: 'circle', diameter}`: 96-gon cross-section, exact bounds independent of rotation) or a mesh (`meshId`, `unit`, `orientation {faceDown, rx, ry, rz}`, `mode` projected | 3d, plus `size` and a convex `hull` kept in the params for the UI). Projected mode = `Manifold.project()` outline offset by the clearance and extruded; 3D mode = the part as it is, standing on the pocket floor (clearance = Minkowski offset with a small sphere, trimmed at the floor; scaling above 40 000 triangles), opening extended up to the top by its silhouette. Meshes that are not valid solids are healed first (`Mesh.merge()`, `fillHoles`); only if that fails a stepped pocket from cross-sections is built (`cutout-profile.ts`, cached), then the convex hull. `ModelResult.tools` carries translucent preview bodies of the cutouts (rectangle extrusion or the part's own mesh), never part of the model, volume or export; the viewport draws them in cyan with a persisted toggle (`gridfinity.viewport`). STL: `parseStl` (binary by file length, else ASCII) → `repairMesh` (weld, drop degenerate, consistent orientation, flip if the volume is negative) → `toManifold`; `orientVertices` applies units, lay-flat (`rotationToDown`), 90° steps, then centres XY and puts the lowest point at z = 0. Meshes are too big for localStorage: `features/cutout/meshStore.ts` keeps them in IndexedDB (memory fallback, `persistent` flag), the worker gets them with `ModelClient.registerMesh` and the registry `meshRegistry` in `geometry/model.ts`; params reference them by id and a missing mesh only skips that pocket. Placement helpers are pure (`centerCutout`, `alignEdge`, `distribute`, `fitFootprint`, `snapMove`); `TopView` shows live drag via a draft position and commits on release, so the model is rebuilt only then. +- **Photo cutouts** (`geometry/photo/`, `features/cutout/photo/`): the "From photo" button in the Cutout list opens `PhotoDialog` (steps: photo and paper, corners, silhouettes); its state lives in `usePhotoTrace` held by `CutoutPanel`, so closing and reopening keeps photo, corners and settings, nothing is persisted and the photo never leaves the browser. Pure pipeline: `paper.ts` (Letter 215.9 × 279.4, A4 210 × 297, custom 50–1000 mm, orientation), `homography.ts` (`homography` 8 × 8 solve, `checkQuad` = ok / notConvex / mirrored / tooSmall with corners clockwise from the top left, `rectify` samples the photo through paper → photo so there are no holes; raster 4 px/mm, longest side ≤ 2000 px), `segment.ts` (`paperSurface` fits a quadratic surface to the paper pixels with iterative exclusion of objects, so gradients and big dark objects work; ratio to that surface, `sensitivity` 0–1 mapped to a 50 %…8 % brightness drop with an Otsu-based automatic value; close, specks, `fillHoles`, edge margin, 8-connected components, min 3 mm), `outline.ts` (`traceSilhouette`: blurred mask → `traceAlpha` sub-pixel contour → largest outer loop → `simplifyLoop` 0.15 mm → mm, y up, counter-clockwise, relative to the bbox centre; `center` is paper-centred). `synthetic.ts` builds test photos through a known perspective (tests and browser checks only). `OutlineSource` (`kind: 'outline'`, polygon + size) is a cutout source like rect, circle and mesh: `baseOutline` returns its points, the pocket is `CrossSection.ofPolygons` grown by the clearance, `validOutline` guards loading. `createCutouts.ts`: `tracedCutouts` makes one cutout per kept silhouette at its paper position, `withTraced` adds them (colours by the usual rule, existing cutouts kept) and calls `fitFootprintUnits` (smallest whole units that hold all visible cutouts with wall, margin and bin clearance). **Colour, shadows and automatic corners (25a):** the photo is decoded in colour (`decodeColorImage`, `ColorImage`), `rectify` warps the colour planes too (`Rectified.rgb`), and `segment` (with rgb and `shadows` on) uses `colour.ts`: a quadratic paper surface per channel, an object is a pixel whose colour deviates from "paper colour times a factor" (`colourDeviation`, so yellow handles under flash are found) or is darker than the sensitivity drop, and the soft-shadow region is removed: `shadowRegion` grows from clean paper through pixels that get no brighter, are nearly colourless (`κ ≤ 0.12`), not darker than a drop of 0.8 and not on a *wall* (fine-scale steepness above `shadowEdge` = 0.3 per mm: edges and thin dark lines of tools, which the penumbra never reaches), and the existing hole filling restores enclosed steel parts (grey pliers head); no opening (`open` = 0), silhouettes under 60 mm² are dropped. Without rgb or with `shadows: false` the old brightness-only rule runs. Regression fixtures are the owner's two photos (`geometry/photo/fixtures/{flash,noflash}.jpg`, 480 × 640, metadata stripped, decoded in tests with the dev dependency `jpeg-js` through `fixtures/load.ts`): both give exactly three silhouettes agreeing within ~1 mm. `detect.ts` (`detectPaper`: bright, unsaturated area on a more saturated or darker table via Otsu of V and S, largest component with holes filled, convex hull, best four vertices, per-side line fits intersected for corners, clockwise from the top left, rejected when it touches the frame on many sides) and `aspect.ts` (`estimateAspect`: Zhang–He true proportions from the quad; `guessPaper` → A4 / Letter within 3 % and portrait / landscape) run when the photo is loaded (`usePhotoTrace`, `Detection`), proposing corners, paper and orientation. Dialog flow: step 1 only the photo and `PhotoTips` (seven points), then automatically step 2 with paper size, orientation and `CornerEditor` (transparent 22 px rings, `cornerGrab.ts`: grab from the area near a corner inside the sheet, relative drag, no crossing; `Loupe` 4× from the full-resolution photo, placed in the farthest corner of the visible editor with `placeLoupe`), a Tips popover on steps 2–3, step 3 silhouettes. Luminance-only limits remain only when no colour is available. +- **Export** (`export/`, `data/printers.ts`, `features/export/`): `ExportSettings` (format stl|3mf, printer id or `custom`, custom bed, edge `margin`, plate `gap`, nozzle/process/filament, `multi` one|separate, orientation, file name) is shared by all modes: a module store with `useExportSettings()` (key `gridfinity.export`, normalised by `normalizeExportSettings`). The printer bed and margin override the baseplate's stored plate size and split margin (`BaseplateMode`); `usableBed` = bed − 2·margin. `data/printers.ts` holds Bambu presets (hand-written from vendor specs, with `bambu: {model, suffix}` for Studio profile names; never copy third-party AGPL preset dumps). `buildExport(parts, settings, name)` orients each part (`orient.ts`: flip about X, rotate 90°, origin at the bbox corner, z = 0), arranges them on plates (`arrange.ts`: shelf packing, several plates, `overflow` for parts bigger than the bed) and returns STL or 3MF files (`threemf.ts` writes the ZIP with fflate: one object per part, mm, translation-only build transform). `bambu.ts` adds `Metadata/project_settings.config` + `model_settings.config` only for a Bambu printer in 3MF; the key names are version dependent and must be verified by opening a file in Bambu Studio. `ExportPanel` is the sticky block under the parameters (format, printer, download, plate status, per-part buttons), `ExportOptionsSection` is appended to every mode's panel by `Workspace`. +- **Start page art** (`src/geometry/art/`, `scripts/render-art.ts`, `src/app/art/*.svg`): every card (product cards on `/`, mode cards on `/gridfinity` and `/keychain`) shows a pre-rendered vector image keyed by `artId` (`Product.artId`, `ProductMode.artId`; `ART`/`ModeArt` in `app/ModeArt.tsx`: box, baseplate, cutout, keychainImage, keychainShape; Gridfinity card = box, Keychain card = keychainImage). Gridfinity images are the real 2×2 parts (baseplate with corner magnets, 2×2×3 box with two crossing dividers, 2×2×3 cutout block with a pocket from a sample STL). `npm run art` builds them with `generate()` in two groups (Gridfinity three with one shared scale, Keychain two, so the Gridfinity files stay byte-identical). In the Keychain group the keychain-image drawing gets the scale at which it is as tall as the box drawing (`BOX_ART_HEIGHT` ≈ 149 of 165 viewBox units, keeping the cards on `/` level — update that constant if box.svg changes) and every other picture gets the largest scale that does not exceed it and fits the field width (the shape card is wider than the paw). The keychain ones need no file: the image-mode bitmap is a paw print drawn by `geometry/art/paw.ts` (palm pad and four toes as ellipses joined by an exponential smooth minimum, anti-aliased 1000 px bitmap, symmetric and one piece), meshed at a 0.12 mm cell, and the script exits with an error after writing them if `tmp/cutout.stl` is missing. `ArtOptions.smooth` (σ in viewBox units; `smooth.ts` blurs every tone and silhouette mask and thresholds at 0.5 before tracing) is used only by the Keychain group (`KEYCHAIN_SMOOTH`) to remove the pixel stairs of the 3× raster; the Gridfinity group leaves it off. It uses `generate()`, isometric projection, a z-buffer tone image (7 tones, nested "tone ≥ k" regions) and boundary tracing; the SVG files are committed, carry only classes (`art-t0…6`, `art-edge`, colours from `theme.css` tokens) and are inlined by `ModeArt` (`?raw`), so the start page needs no WebGL, worker or WASM. Rerun `npm run art` and commit when default geometry changes. The cutout sample is `tmp/cutout.stl` (git-ignored; the script stops with a message when it is missing; CI never runs it and no test reads it). Size budget 60 KB per image is checked by `ModeArt.test.tsx`. +- **Keychain model** (`geometry/keychain.ts`, `geometry/raster/`, `features/keychain/`): the image is decoded on the main thread (`decodeImage`: PNG/JPEG/WebP/SVG → `GrayImage {gray, alpha}`, cap 4096 px, SVG rasterised at 1024) and stored in IndexedDB (`imageStore`, own DB `gridfinity-keychain`, memory fallback); the worker gets it once (`ModelClient.registerImage`, registry `imageRegistry` in `model.ts`) and params reference it by `imageId`. `KeychainParams = {imageId, silhouette, size, relief, base, quality}`, persisted under `keychain.relief` with `imageName` (`migrateKeychain` normalises nested objects). Pipeline in the worker (`generate('keychain')`, result `ModelResult.keychain` = `KeychainInfo` with threshold, mask preview, holes, size, effective cell, error key): `buildSilhouette` (Otsu/threshold, alpha wins when present, close, holes by stable scan-order ids with `keepOpen`, specks, grow) → crop to the bbox and resample to a grid (`buildGrid`, step = quality cell or larger to fit the triangle budget) → EDT → `reliefHeight` (plateau + smoothstep bevel, ≥ heightFloor, port of `02_heightfield.py`) → `reliefMesh` (block topology, flat back at z = 0, walls on shared vertices) ∪ plate outline extruded by `base.thickness` (`plateOutline`: silhouette offset, rect, rounded, circle; too small → `keychain.error.plateTooSmall`). Errors (empty mask, plate too small) return no parts; `useKeychainModel` keeps the last good mesh and flags `pending`. The raster toolkit (`otsu`, `dilate/erode/close`, `labelComponents`, `findHoles`, `fillHoles`, `edt`, `resampleMask`) is pure and unit-tested. `MaskPreview` is the collapsible overlay in the viewport corner (`Workspace overlay` prop) with an Enlarge button that opens a large dialog (~70 % of the window, Esc / outside click / close button, focus returns to the button). `PlacementEditor` draws its rulers in a margin band inside the viewBox; `Workspace` pads the panel scroller by the sticky footer height (`app/useElementHeight`). The viewport axes gizmo sits above the measurements bar (`GIZMO_BOTTOM`) and its axes are thin cylinders, not 1 px lines. +- **Keychain patterns** (`geometry/patterns/{profile,fields}.ts`, `raster/thin.ts`, `features/keychain/{ProfileSection,DetailSection,PatternSection}`): the heightfield in `buildGrid` runs source (distance, or image brightness with contrast/gamma/invert, capped by a bevel rim ceiling) → profile (`profileValue`: bevel, dome, terrace, flat, engraved; presets are data in `PRESETS`, `presetOf` returns `custom` when values differ) → detail lines (spine = Zhang–Suen skeleton + Gaussian, groove = band at a distance from the rim) → surface pattern (`coverage`: lines, crosshatch, dots, hex, ripples; crisp 0/1 masks in keychain coordinates, subtracting `depth`, never below the floor; scope relief | plateau | base) → clamp. `Grid.top` holds absolute heights including the plate, so `reliefMesh` has no base parameter. Plate patterns put the plate into the grid (`plateInGrid`); otherwise the plate is an extruded `CrossSection` union. `patternResolution` warns when the pitch is below 4 cells and offers the budget-limited step (`KeychainInfo.minCell`). `migrateKeychain` fills source/detail/pattern/profile defaults for old records. With everything off the output equals change 11 (regression volumes in `keychain-patterns.test.ts`). +- **Keychain mounts and split** (`geometry/anchors.ts`, `keychainFit.ts`, `keychainMounts.ts`, `features/keychain/{PlacementEditor,MountSection,SplitSection,contour}`): `MountParams` (none | hole | eyelet | channel; positions are fractions of the silhouette bbox, Y up) and `SplitParams` (two halves, pins, clearance, depth, optional manual `pinPositions`). `buildKeychainParts` builds the relief, then (when a mount or pins exist) an `AnchorField` from the grid (`solid`, `top`, `dist`, `distOut`, `exterior`), `findAnchors` (min height over the whole footprint, rim, spacing; returns rejected spots with a reason), `resolveMounts` (Suggest candidates incl. the nearest channel exit, auto/manual pins, `fitChecks` issues keyed `keychain.warning.`), then cuts: through hole (vertical cylinder), eyelet lug (hull + extrusion, added before the split), channel (horizontal cylinder in the mating plane, breach verified against the *exterior* background, not interior cavities), pins (cylinders symmetric about z = 0). Two halves = top part + `top.mirror(z).rotate(180° about X)` + loose pins, laid out as a sheet (`layoutSheet`); `ModelResult.sheetLayout` makes `buildExport(..., fixedLayout)` keep that layout. `MountInfo` carries resolved positions for the editor; `MaskPreview.box` maps its pixels to millimetres. **Assembled view** (`geometry/keychainAssembly.ts`, `features/viewport/{layout,AssembledControls}`): `buildKeychainParts` also returns `assembly` (label → column-major 4×4, `ModelResult.assembly`) computed from the same shift and pin centres that build the sheet: `top` identity, `bottom` = rotation 180° about X with the sheet shift undone (mirror of the top across z = 0), `pinN` = translation into the hole, centred on the mating plane. `Viewport` takes `view: 'sheet' | 'assembled'` and `openGap` and only sets group matrices (`assembledMatrix`, `layoutBounds` for Fit); labels and the build plate hide in the assembled view, no regeneration, export always uses the sheet. `Workspace` shows `AssembledControls` only when `model.assembly` exists; `keychainView` and `openGap` (0–15 mm, a length, so `LengthField`) live in `gridfinity.viewport`. +- **Rack panels** (`geometry/rack/`, `features/rack/`): `spec.ts` holds the 10-inch constants (254 wide, unit 44.45, ear holes 236.525 apart at 6.35 / 22.225 / 38.1 per unit, window 222.25, tolerance 0.45) and `rackLayout(units, tolerance, margin)` (height, ear holes of one ear — the builder mirrors them —, pattern region; X from the panel centre, Y from the lower edge; the tolerance is taken off the top so holes stay on the standard grid). `panel.ts`: `RackPanelParams = {units, thickness, cornerRadius, tolerance, ears {positions all|outer|none, shape round|slot, size, slotLength}, pattern, back {enabled, thickness}}`, `validateRackPanel` (`rack.error.*` stop generation, `rack.warning.thin` with the offending `setting`, from `patternIssues`), `buildRackPanel` = rounded outline − ear holes − pattern cut, extruded 0…thickness (front face up); the back wall is the same outline and holes at z = −t…0; a result with several components returns `error: 'rack.error.disconnected'` and no panel. `patterns/` are pure `(box) → CrossSection` in a centred box (`placePattern` puts it in the region and turns it 90° by swapping the box axes, `centredRun` centres whole elements only): `skadis` (slot 5 × 15, pitch 40, row step 20, odd rows shifted), `hex` (flat-to-flat width + wall, pitch width + wall so the wall is exact), `pills` (horizontal, brick-like rows shifted by half a pitch, `width` across and `length` along, optional shortened end pills), `lines`, `passthrough`; each pattern keeps its own settings in `params.pattern[kind]`. `generate('rackPanel')` returns parts `panel` (+ `back`) and `ModelResult.rack {error?, warnings}`; `useModel` treats `rack.error` like a failed build (last mesh kept, export stale). UI: `RackPanelMode` (state `rack.panel`, `migrateRackPanel` normalises every field) with `SizeSection`, `EarsSection`, `PatternSection`, `BackSection`; no artboard exists, built from `ui/` and tokens. The Rack card art is `rackPanel.svg` (2 U Skadis panel, its own group in `npm run art`). **Kumiko (27):** both patterns are through cuts. `asanoha.ts`: the triangular lattice with step R = width/√3 (`asanohaSegments`: main = lattice edges once each, detail = centroid-to-corner Y in every triangle), strokes are rectangles with square caps and stay material, the cut is the region minus the united strokes reduced to the openings that lie completely inside the region (strokes form one network joined to the frame; `detailWidth` 0 = no detail strokes). `hicks.ts`: honeycomb cells (`hicksCells`: one lattice, origin = the first complete cell, `edgeCells` adds the cells that only touch the region, clipped by it) each with nested hexagonal cut rings (`hicksRings`: stroke at the cell edge, then gap, stroke, gap… the core stays solid when it is ≥ stroke / 2), every ring interrupted by bridges of `stroke` width on alternating vertices (`bridgeVertices`: 0, 2, 4 / 1, 3, 5) so the panel stays one piece. Defaults were tuned by eye (asanoha width 30, strokes 1.6 / 1.2; Hicks 24 / 1.2 / 1.2, edge cells on). +- **Rack enclosure** (`geometry/rack/case.ts`, `features/rack/Case*`, `migrateCase.ts`): `RackCaseParams = {device {width, depth, height}, gap, wall, plateThickness, units {auto, value}, cornerRadius, tolerance, ears (shared `EarParams`), window {enabled (default true), lip}, openings (`OpeningGroup[]`, ≤ 6), vents {enabled, width, wall, margin}}`, persisted under `rack.case`. `caseLayout`: outer = device + 2 × (gap + wall), `autoUnits = ceil((outerH + tolerance) / 44.45)`, a manual value below it is raised (`raised`), `excess` = outerW − 222.25 (`rack.error.tooWide`, the UI shows it; generation is skipped like in other modes). `buildRackCase`: `plateBase` (outline + ear holes, shared with the panel) minus window and round holes, extruded 0…plate thickness; the shell is the outer prism (z −(depth + gap)…0, centred on the plate) minus the cavity (device + 2 gap, open at the back), unioned with the plate, and the whole body is then mirrored in z so the front face lies on z = 0 (on the bed as printed, the shell grows upward); vents are `hexCut` patterns turned into cutters through the wall thickness only (`ventCutters`). `openings.ts`: `OpeningGroup = {kind keystone|hole, count, perRow, zone left|right|above|below, diameter}` (`perRow` 1 = column, ≥ count = row, between = grid; rows centred); `layoutOpenings(groups, frame)` fills the free zones beside / above / below the shell (above and below exist only when the plate is taller than the shell), groups of one zone go outward from the shell, a group that leaves its zone or touches an earlier one is not cut and reported as `rack.warning.fit` with `setting: 'group'`. `keystone.ts` + `KEYSTONE` in `spec.ts` (measured from the owner's outlet sample, see README references): `keystoneSocket` gives a collar (19.8 × 27.85, 10.4 mm from the face, behind the plate) and a through pocket (opening 14.8 × 16.6 + clearance, ramp on the upper edge, latch notch on the lower); the collar is unioned with the plate and shell, then the pockets are subtracted. `migrateRackCase` turns old `keystones`/`cable` into groups. The viewport draws outlines with `creaseEdges` (own algorithm, see `features/viewport/degenerate.ts`). Card art `rackCase.svg`. +- **Rack shelf** (`geometry/rack/shelf.ts`, `features/rack/{ShelfMode,ShelfPanel}`, `migrateShelf.ts`): `RackShelfParams = {units (default 1), thickness, cornerRadius, tolerance, ears (shared `EarParams`), shelf {depth, thickness (floor), clearance}, sides {height (0 = no walls, default 35), wall}, closedFront (default false = open above the floor between the walls), floorPattern, platePattern (both `PatternParams`; floor default pills across the depth, plate pattern only for a closed front, default none)}`, persisted under `rack.shelf` (`migrateRackShelf` uses `normalizePattern` from `migrate.ts`; the stored `lips` of the first draft are dropped). `shelfLayout`: plate height, width = 222.25 − 2 × clearance, pattern regions (floor: x between the walls, second axis = distance behind the plate; plate: above the floor between the walls). `buildRackShelf` builds in print orientation like the enclosure: plate flat (front face z = 0, back z = thickness), the floor slab y 0…thickness standing on the plate's back face and growing up in z by the depth, two walls (polygon in (z, y) from the plate to the back, straight slope from `sides.height` to the floor top) on the floor edges, 0.01 mm overlaps, the floor pattern cut through the floor (`cutPattern`, turned about X), the plate pattern cut in the plate outline; a pattern that splits the part gives `rack.error.disconnected`. `PatternFields` (`PatternSection.tsx`) edits any `PatternParams` and is shared by the panel, the floor and the plate. `generate('rackShelf')` returns one part `shelf`. Card art `rackShelf.svg`. +- **Photo AI engine** (`geometry/photo/ai*.ts`, `outlineSmooth.ts`, `compare.ts`, `src/ml/`, `features/ml/`, `features/cutout/photo/{EngineSwitch,AiControls,AiStatus,MaskTuning,CompareOverlay}`): an optional neural silhouette engine for photo cutouts; the classic `segment` stays the default and is frozen by `photoBaseline.test.ts` (`segment.ts`, `colour.ts`, `outline.ts` must not change). `aiSegmentation(rectified, alpha, {threshold, smooth, refine, band, ...})` turns a soft alpha into the same `Segmentation` (classic cleanup, numbering top to bottom); `aiHybrid.ts` is the hybrid edge (mask eroded by `band` ∪ band ∩ classic colour test, network edge kept where the classic test is uninformative or the classic object leaks past the band); `aiRefine.ts` is the second pass per object (crop + 12 mm, blended at the crop border, the network is injected as a `Matte` callback); `aiOutline.ts` + `outlineSmooth.ts` trace an AI silhouette with corner-preserving vector smoothing and `roughness`; `aiResample.ts` stretches the network output (bicubic, measured no smoother than bilinear). `src/ml/` is React-free: `models.ts` (registry: pinned commit URL, bytes, SHA-256, SPDX, backends, `maskDefaults`; `registryIssues` refuses non-permissive licences), `modelStore.ts` (download with progress/cancel, size + SHA-256 check, Cache API under the revision URL with memory fallback, active model per kind in `gridfinity.ml`; the only caller of `download` is `service.downloadModel` from the manager after the consent dialog, a test greps for it), `capabilities.ts` (WebGPU, `shader-f16`), `runtime.ts` + `ml.worker.ts` + `mlClient.ts` (worker created lazily, one ort build per backend: `onnxruntime-web/wasm` (`ort-wasm-simd-threaded.wasm`) or `/webgpu` (its glue wants the `…asyncify.wasm`, not jsep: a mismatch hangs the session silently), wasm files bundled via `?url`, latest `matte` wins, cancel recreates the worker, worker errors reject pending requests), `service.ts` (the one place that runs the network and logs jobs of source `ai` for downloads and runs). Heavy photo steps (classic `segment`, `aiSegmentation`) run in `geometry/photo/photo.worker.ts` through `PhotoClient` (`photoClient.ts`: the sheet and the alpha are registered once and then referred to by id, lanes `segment`/`ai`/`base` each with latest-wins, inline fallback without `Worker` so tests need no worker); `useDebounced` compares objects by fields, never by reference, otherwise it re-renders in a loop. `usePhotoTrace` keeps `settings.engine`, caches the alpha of the last run (`ai.alpha`) so threshold/smoothness only recompute silhouettes, exposes `active` (AI result unless it failed or is missing, else classic). `ModelManager` (opened from the `Header` button via `features/ml/managerState`) lists models by kind with consent, progress, delete, active radio. A classic-only session loads no ML code beyond the small client modules (checked in the network panel). +- **Keychain AI silhouette** (`geometry/keychain.ts`, `geometry/raster/matte.ts`, `features/keychain/{matte,AiSilhouette,imageStore,MaskPreview}`): `SilhouetteParams.source` is `brightness` (default, output unchanged) or `ai`; `ai {model, threshold, smooth (mm of the finished keychain), keepLargest, matteId}`. The soft alpha ("matte", `Matte {width, height, data 0…255}` at image resolution) is registered in the worker (`ModelClient.registerMatte`, `matteRegistry`, params carry only `matteId`), so the sliders re-evaluate the mask (`smoothThreshold`, `keepLargest`) without running the network; the pixel size of the smoothing and the mm² of areas come from the rough mask bbox and `SizeParams` (`pxPerMmOf`). A missing or wrong-size matte falls back to brightness with `aiMissing`. `autoOpen` (mm², `null` = 150 for AI and 0 for brightness) opens enclosed gaps at least that big and fills smaller ones unless the user set the area's own mode. `imageStore` (IndexedDB `gridfinity-keychain` v2) keeps the grey image, a colour copy ≤ 2048 px (`colors`, the network needs RGB and the file is gone after import) and mattes keyed `imageId|model|revision`; removing the image removes all of them. `useMatte` runs `MlService.matteAlpha` (whole image, aspect kept, no second pass), saves, registers in the worker and only then writes `source/ai.model/ai.matteId` into the params; after a reload it restores the stored matte without the network, and `ImageMode` holds generation (`hold`) until that is done. `AiSilhouette` reuses `MaskTuning` and `AiStatus` of the photo flow; SVG has no AI; a logo on a clean background (`looksLikeLogo`) gets a hint. `MaskPreview` takes the matte for a grey soft-mask view. +- **Keychain photo relief** (`geometry/{photoRelief,photoPresets,subjects,imageOutline}.ts`, `geometry/raster/{tone,guided}.ts`, `features/keychain/{depth,cropMath,CropEditor,PhotoReliefBlock,OutlineControls}`): `SourceParams.kind = 'photo'` builds the height field in `photoHeights` (nodes of the grid): contour (`reliefHeight` normalised) + depth (plane from the worker `depthRegistry`, `normaliseDepth` over the mask, `guidedFilter` snap) + detail (`localContrast` with the radius in mm of the part, `detailTerm` Engrave/Relief/Both with a dark threshold, `quantise` levels) mixed by shares (proportions; missing depth = share zero, reported as `info.depthMissing`; sum zero = contour), then contrast/gamma/invert, `limitSlope`, final Gaussian; the bevel ceiling of the brightness path still applies. With distance or brightness the new fields are ignored (regression volumes kept). `KeychainParams.crop` (fractions, `cropImage`/`cropPlane` make the cropped view of gray, alpha, matte and depth in the core, no model re-run) and `silhouette.source: 'frame'` (every pixel of the crop is material). Image silhouettes get an exact outline: the grid is built on the mask dilated by `inflate` (like Shape mode), the outline is traced from the blurred mask (`traceImageOutline`: marching squares, corner-preserving smoothing with `restoreTips`, simplification 0.03 mm, vertex step), and the relief is intersected with that polygon (`CrossSection`, `EvenOdd` so open areas stay holes); a silhouette touching the image edge has no margin and falls back to the stair outline (`outline` undefined also keeps the old path for regression tests). Depth: registry kind `depth` with a second file (`ModelSpec.data`, external data for ORT, both pinned with SHA-256, cached together, ready only when both are present), `useDepth` runs it on the cropped colour copy, places it on the full image (`placeDepth`), stores it in `imageStore` (`depths`, keyed image|model|revision, with the crop it was computed for; a different crop only marks it stale). Subject presets (Face, Whole animal, Drawing / logo; `chooseSubject` from `pictureStats`) and strength presets (Subtle, Portrait, Deep) are data with `subjectOf`/`intensityOf` returning null for Custom; the automatic subject choice runs once per image and turns itself off on any edit (`State.subjectAuto`). `KeychainInfo.height` is a grey plane of the grid heights for the preview toggle. The local-contrast deviation floor is 0.08 (a smaller floor amplified fur into noise). +- **Activity log** (`app/activityLog.ts`, `logText.ts`, `modelLog.ts`, `ActivityLogPanel.tsx`, `features/viewport/StatusBar.tsx`): a memory-only module store (`useSyncExternalStore`, ring buffer of 500) of entries `{level, source, key, params, detail?, job?}`: keys, never sentences (`log..` in en/ru; `logText` renders them and translates the `setting`/`what` params), so a language switch re-renders old lines. `log.info/warn/error/once`, `memoryFallback(what)` (storage warning once per session) and `startJob({source,key,cancel?})` → `update/done/fail/cancelled/discard`; a job running > 200 ms is what shows the loader (`isLoading`), there is no `busy` prop any more. `useModel` runs one `model` job per real generation (debounced requests never start one; a superseded job is `discard`ed with its start line), reports duration/triangles, `ModelResult` errors and warnings (`modelLog.reportResult`) and the modes' validation keys (`invalid` option). `MeasurementsBar` keeps X/Y/Z and renders `StatusBar` (current job line, last message for 4 s, then "Updated"; warning/error counters; one button opens the log; `role=status` announces at most once a second). `ActivityLogPanel` (mounted in `AppShell`, opened with `openLog({source?})`, bottom sheet below 768 px) has level/source filters, follow + "Jump to latest", Details, Copy, Clear, Esc with focus return. Producers: image/mesh/font import, the three stores' memory fallback, `usePhotoTrace` and the cutout creation, `ExportPanel`. Entries hold names and sizes only, never pixels or file contents; AI changes (32–34) report through the same interface. +- **Tests:** vitest (`.claude/**` is excluded: other sessions' worktrees live there); component tests start with `// @vitest-environment jsdom` (setup in `src/test-setup.ts`, RTL cleanup included). + +UI strings are English/Russian; code comments are in Russian. + +- **Keychain modes** (`features/keychain/`): `KeychainWorkspace` (model hook, `Workspace`, export footer, session record) is shared; `ImageMode` (state `keychain.relief`, image store, drop zone, `KeychainPanel` = image/silhouette/size + `SharedSections`) and `ShapeMode` (state `keychain.shape`, `ShapePanel` = `ShapeSection` + `RimSection` + `SharedSections imageOnly={false}`, i.e. no Relief or Detail lines sections). `SharedSections` is relief, detail (both only with `imageOnly`), pattern, base, mount, split, resolution. Shape state has no image fields (`shapeState.ts`: `ShapeState = SharedParams & {shape}`, `migrateShape` normalises every number, `shapeParams` builds full `KeychainParams`). Geometry: `KeychainParams.origin = {kind:'image'} | {kind:'shape', shape}`; `geometry/shapes.ts` has `ShapeParams` (rect, circle, ellipse, capsule, polygon with rounding/rotation, `rim`), analytic `shapeInside`, `shapeBounds` (exact bbox in mm) and `shapeMask` (2048 px, bbox fills the raster exactly) which feeds the same `buildGrid` as an image mask, bypassing threshold, areas and brightness. Polygon `width` is the circumscribed diameter; the corner radius is a true fillet (Minkowski of a smaller polygon with a disc) so the extreme width does not change. Outlines are exact, not grid-stepped: the shape is rasterised with a 1.75-cell margin, the relief is intersected with `shapeCrossSection` (`geometry/shapeOutline.ts`, 256 segments per circle) and a silhouette plate is that outline offset, with `edgeShift` keeping edge distances true; the mount field uses the unpadded mask. The rim is an exact ring (outline minus its inward offset by `rim.width`, extruded to plateau + `rim.height`) unioned after the clip, so its inner corners are true arcs too; only in the stepped fallback (pattern on the base) it is added in `buildGrid` to nodes within `rim.width` of the edge. Shape origin only; patterns no longer cut the exact ring. The placement editor traces its outline from the shape's preview mask, the mask card is only rendered by `ImageMode`. +- **Smoothing** (`geometry/raster/blur.ts`, `ProfileSection`): `SourceParams.smooth/despeckle` (brightness plane: 3×3 median over silhouette nodes, then masked Gaussian, before contrast/gamma; the UI sets 0.4 mm when brightness is chosen, stored default and old records are 0) and `ReliefParams.smooth` (0–3 mm; blurs the finished height `raw` before the bevel ceiling of the brightness source, detail lines and patterns, then clamps to `heightFloor`). The filters are normalised convolutions (`G*(v·m)/G*m`) so nothing leaks across the silhouette or from `fixed` nodes; σ = radius/cell clamped to 0.5–40 cells (`sigmaCells`). Fields are optional in the types (missing = 0) and `migrateKeychain` coerces them. `smoothResolution` drives the "radius below one grid cell" warning with the raise-resolution button. Smoothing controls live in the Relief section, which only Image mode has; Shape mode is a flat plateau and has none. +- **Areas** (Image mode only): enclosed background regions are *areas* (never "holes" in UI/params). `SilhouetteParams.areas: Record` (migrated from `keepOpen`; `fillHoles: false` = all open). `buildSilhouette` returns `areas` (stable ids in scan order, effective mode), `areaId` (id per pixel), `goneAreas` (ids with stored non-follow settings that no longer exist; shown as a warning). `MaskPreview` carries `labels` (id per preview pixel, for click hit-testing via `areaAt`) and `areas`; plane value 4 = level area. In `buildGrid` nodes of level areas (via `GridInput.levels` + `resampleIds`) get a flat `base + max(level, heightFloor)` and are `fixed` for the brightness/surface smoothing, skipping profile, detail lines and patterns. Selection (`selectedArea`) lives in `ImageMode` and is shared by the area list (`SilhouetteSection`) and the small/enlarged mask. +- **Text** (`geometry/text.ts`, `features/keychain/{TextSection,textRaster,fonts,fontList,fontStore,useTextRaster}`): `KeychainParams.text` (`TextParams`: content, `font {id,bold,italic,name?}`, size = cap height mm, letter/line spacing, align, rotation, emboss|engrave with height/depth, x/y = fractions of the silhouette box, Y up) is shared by both modes (`SharedParams`). The main thread rasterises the text with canvas at 12 px/mm (`rasterizeText`: pixel size from the measured height of "H", `layoutLines` is the pure layout) and registers the alpha mask once in the worker (`ModelClient.registerText`, `textRegistry`); params sent to the worker carry `text.maskId` = `textRasterKey` (content, font, size, spacings, align only), so moving or rotating text never re-rasterises (the worker applies the inverse transform in `textSampler`). `useTextRaster` (in `KeychainWorkspace`) keeps the previous raster until the new one is ready and exposes `TextRasterContext` (font lost, busy). Letters are built as exact solids, not from grid nodes: `textOutline.ts` traces the alpha raster (marching squares with sub-pixel interpolation, Douglas–Peucker 0.02 mm) into polygons, and `applyExactText` extrudes them from just under the surface to surface + height (emboss) or cuts them from surface − depth, never below `base + heightFloor` (engrave); the part over the silhouette and the part over the plate outside it use their own levels and are clipped to the silhouette/plate outlines. This path is taken when the relief under the letters is flat (spread ≤ `TEXT_FLAT_TOL` = 0.15 mm: plateau, plate, level areas); otherwise (letters across a bevel, dome, brightness) the fallback samples the text in `buildGrid` (emboss `+height`, engrave `max(t − depth, min(t, lo))`) with stepped edges and, when text leaves the silhouette and a plate exists, `plateInGrid` is switched on (stepped plate, like base patterns); `textRegion` samples where text lies (`info.text.clipped` = part outside silhouette and plate, not built). Two halves: `buildKeychainParts` = `buildKeychainCore` for the top plus a second core call without text for the mirrored bottom. Fonts: 25 fontsource families (latin + cyrillic only) listed in `fontList.ts`, woff2 files are lazy `import.meta.glob(..., {query:'?url'})` loaders registered with `FontFace` on first use (no third-party hosts; variable packages for most, static for Fira Sans, PT Sans/Serif, Russo One, Press Start 2P, Pacifico, Lobster, Marck Script); generic families and a user TTF/OTF/WOFF (validated by magic bytes, stored in IndexedDB db `gridfinity-keychain-fonts`, restored by `ensureFont`) complete the list. `PlacementEditor` has a `text` target (box, baselines and the text drawn to scale, drag, X/Y) and a text-only mode (`only="text"`) embedded in `TextSection`. `textResolution` drives the fine-strokes warning (stroke ≈ size/8 < 4 cells). +- **Panel chrome** (`app/Workspace.tsx`): the optional `onStartOver` (+ `modeName`) prop renders a "Start over" strip above the panel scroller (so it is reachable with every section collapsed) and a `ConfirmDialog`; each mode passes `() => setState(defaults)` with the same defaults `usePersistedState` falls back to (Cutout also clears the selection, Image mode also drops the in-memory image; stored blobs are left to the existing cleanup, export settings, units and language are never touched). In Image mode without an image the sections are hidden, so the empty-state banner carries its own "Choose image" button (`image-input-empty`) and shows the import error. Tests: `ui/renderOpen.tsx` has `openAll`, `openSection(title)` (tolerates the "(changed)" suffix) and `renderOpen` for tests that need collapsed sections expanded; `ui/modifiedSections.test.tsx` checks collapsed-by-default and exactly-one-highlight per mode. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index bae03b3..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,81 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Commands - -- `npm run dev` — Vite dev server -- `npm run build` — `tsc --noEmit` (type check) and then `vite build` into `dist/` -- `npm test` — `vitest run` (all tests) -- Single test: `npx vitest run src/baseplate.test.ts -t ""` - -There is no linter configured. - -## Workflow - -Work is planned as numbered OpenSpec changes in `openspec/changes/NN-name/` (order and dependencies: `openspec/ROADMAP.md`). Take them strictly in numeric order, one change at a time. - -1. **Pick the change.** The lowest-numbered active change (`openspec list`). Every change under its "Depends on" line must already be archived; otherwise stop. -2. **Apply.** `/opsx:apply NN-name`. Read proposal, specs, design and tasks before coding. Do the tasks in order, keep each one minimal and focused, and tick its checkbox (`- [x]`) right after it is truly done, with the behaviour from the spec fully implemented, not partly or deferred. If a task is unclear, needs extra scope or contradicts the design, stop and update the artifacts (`/opsx:update`) instead of guessing. -3. **UI tasks follow the design.** Each cites an artboard in `design/artboards/` (map in `design/README.md`). Build with the `ui/` components and Tailwind tokens from `theme.css`, no ad-hoc colours or sizes. Compare the result with the artboard in the browser, in both en and ru, and at 390 px if the layout changes. If no artboard exists, build from the component library and say so in the task. -4. **Rules for every task.** - - All UI text goes through i18n: add keys to both `en.json` and `ru.json`. - - Lengths are stored in mm; use `LengthField` for any length. - - Geometry stays React-free in `geometry/`; add or update a vitest test with the task (geometry: validity and bounding box; UI: component test). - - Verify what the task names (browser check, STL bbox/volume), not only that tests pass. -5. **Before closing a task section.** `npm test` and `npm run build` are green; the dev server starts and the console has no errors. Commit after each completed task section with a message that names the change, for example `04-fastener-placement: add FastenerSection`. -6. **Version.** Every change raises the semantic version (`package.json` is the single source, shown on the site as `v`; the footer and the beta notice read it through `APP_VERSION` in `app/site.ts`): minor for a new feature, mode, pattern or option, patch for a fix or rework without a new capability, major for an incompatible change (stored settings that cannot be migrated, removed or moved routes). The target version is stated in the proposal; the bump (`package.json` plus a line in `CHANGELOG.md` naming the change) is the last implementation task, before verification. 1.0.0 is the release of `29-beta-label`; changes before it do not bump. -7. **Finish.** When all tasks are `[x]`, run `/opsx:archive NN-name` (sync specs, then archive) and commit. Archive in numeric order so the main specs stay consistent. Update the Architecture section below if the structure or conventions changed. - -Changing the plan itself (new requirement, reorder, split a change) goes through `/opsx:update` or `/opsx:propose`, not by editing code first. Keep `openspec/ROADMAP.md` and the "Depends on" lines in sync when numbers change. - -## Architecture - -PrintKit is a client-only React 19 + TypeScript app (Tailwind CSS v4, Tabler Icons, react-router hash routes, i18next) with parametric tools for 3D printing: Gridfinity parts, keychains and (planned) rack parts. It previews them in 3D and exports binary STL and 3MF. There is no backend. Planned work lives in `openspec/changes/NN-*` (build order in `openspec/ROADMAP.md`); the visual design is in `design/` (artboard → task map in `design/README.md`). - -Layout of `src/`: -- `app/` — entry (`main.tsx`, `App.tsx`), `products.ts` + `modes.tsx` (registry of product sections, all with modes: `gridfinity` box/baseplate/cutout, `keychain` image/shape, `rack` panel + case + shelf; a mode is `{id, path, Icon, labelKey, startKey}`; a new product = one entry + routes), `routes.tsx` (hash router: `/` product chooser `StartPage`, `/gridfinity`, `/keychain` and `/rack` mode chooser `ModeChooser(product)`, `/gridfinity/{box,baseplate,cutout}`, `/keychain/{image,shape}`, `/rack/{panel,case,shelf}`, unknown subpaths go back to the product's chooser; legacy `/box|/baseplate|/cutout` redirect to the new paths, unknown → `/`), `StartLayout` (shared start-page frame, `ChoiceCard`, number hotkeys), `AppShell`, `Header` (product switcher, mode switcher for products that have modes, mm/in, EN/RU; logo text = active product), `StartPage`, `Workspace` (parameter panel + sticky footer + viewport; bottom drawer below 768 px), `usePersistedState`, `lastSession` (`gridfinity.last` stores product and mode as a string; old records without product read as gridfinity, a keychain record without mode leads to the chooser), `theme.css` (Tailwind `@theme` tokens from the design). -- `ui/` — generic controls with no domain knowledge: `Section` (always starts collapsed, no `defaultOpen`; `modified` colours its summary with the `--color-changed` token and adds "(changed)" to the toggle's accessible name; compute it with `isChanged(slice, defaults)` from `ui/isChanged.ts`, using the same slice that the section's Reset writes, in mm so units never matter; sections whose summary is not a setting — file name, cutout list/selection/placement — never pass it), `ConfirmDialog`, `Toggle`, `SegmentedControl`, `NumberField`, `LengthField`, `Banner`, `Button`, and units (`units.ts`, `UnitProvider`). -- `features/{baseplate,box,cutout,viewport}/` — a mode owns its panel, params and persistence key. `ComingSoon` (app/) is the placeholder for modes or products that are not built yet; nothing uses it now. -- `geometry/` — React-free CSG kernel, see "Geometry package" below. -- `i18n/` — `en.json`, `ru.json`, init with language detection. - -Geometry package (`src/geometry/`, no React or three.js imports): -- `spec.ts` — Gridfinity constants (pitch 42, layer stacks `SOCKET_LAYERS`/`FOOT_LAYERS`, clearance, radii, fastener sizes). Pitch is a parameter everywhere, never read from a constant inside builders. -- `profiles.ts` — `layeredHull` is the one profile builder (low chamfer / wall / up chamfer); `socketPocket`, `binFoot`, `lipProfile(style)` are data-driven wrappers. Profile slabs overshoot the nominal top by `SLAB_T` (0.001 mm) on purpose, for clean cuts. -- `thickness.ts` — `Thickness` defaults and `validateThickness` (returns i18n keys `thickness.*`, never text). `sizing.ts` — `resolveAxis(size, pitch, align)` → cells, padding, offset, truncated. -- `footprint.ts` — `Footprint` (cells, size, offsets; origin = footprint centre), `footprintFromCells/Size`, `cellCenters`. `fasteners.ts` — `FastenerConfig` (per kind `{mode: none|corner|full|manual, slots: Slot[]}` + sizes/presets/tolerance/`earWall`). Holes only exist at the standard cell-corner positions (13 mm from the cell centre); Manual is a selection of `Slot`s (cell i,j + corner), stored per slot so it survives grid resizing, and switching to Manual always starts empty. `resolvePositions`, `fastenerCuts(w, config, footprint, floor)` (hole cutters + the position list the 2D editor shows), `cellWindows` (per-cell open windows below the pocket minus the ears), `fastenerEar`, `checkFasteners` (warning keys). Fasteners never add a full floor: the baseplate keeps material only in ears (hull of a disc around the hole and the window corner) of height `FLOOR`, so total height is 4.65 + 4 mm. Old stored coordinates snap to slots within 3 mm (`slotsFromPoints`, `features/baseplate/migrate.ts`). Any mode that supports fasteners reuses these and `features/fasteners/FastenerSection`. -- `split.ts` — `SplitSettings` (persisted: bed source/size, margin, auto or manual cuts, connector, exploded view) → `splitGeometry` → `SplitGeometry`; `autoCuts` (fewest parts, cells spread evenly, remainder to the first parts, rim/padding stay in the edge parts), `resolveCuts`, `splitParts` (labels A1, B1…: column letter + row number; each part has its rectangle, so the union of parts is the plate). `connectors.ts` — `ConnectorConfig` (none | holes | dovetail, per-cut overrides), `cutFaces` (faces between parts, with `ribs` = cell boundaries strictly inside the face), `planFace`, `connectorExtras`. Holes (presets: filament 1.75 → Ø1.9, pins 2/3 mm, magnets 3×2 and 2.8×0.75; default filament, depth 6 mm) are drilled **along the rib that meets the cut, into its end face**, in both parts, at the rib ends nearest to the evenly spaced count/inset positions; they are blind (depth = min(configured, part length − 2 mm)), never reach a socket, and a face with no rib end (one cell wide) reports `connector.noRib`. The dovetail lies flat on the bed: the key spans z = 0…`dovetailHeight` (2 mm) on part A, the socket in part B is open at the underside and closed `clearance` above the key, so parts join by lowering one onto the other; its depth is checked against the wall width (`dovetailFits`, ≈ ≤ 1.2 mm). Collisions with fastener holes move/skip connectors and issues come back as `connector.*` keys; stored removed presets (m3, m6x2) load as `custom`. -- `wasm.ts` — `getWasm(wasmUrl?)`, one WASM module per thread; the url is optional so tests run in Node. `model.ts` — `generate(mode, params)` dispatches by mode and returns a `ModelResult` (`parts: PartMesh[]` = label, col, row, oversize, `MeshData`; one part `A1` when there is no split); new modes are added to `ModeParams` there. `buildBaseplate` builds the whole plate (tests, comparisons), `buildBaseplateParts` builds each part directly from its cell range (`buildPlate(w, g, part, extras)`), so all parts share the same height and edge parts keep rim, padding and rounded corners. `worker.ts` + `modelClient.ts` — the worker owns WASM; the client makes the latest request win and drops stale replies. UI side: `app/useModel(mode, params)` (debounce 150 ms, `busy` after 200 ms, `pending` while the mesh is stale, previous mesh kept on error). -- Tests: use `expectValidSolid` from `testing.ts` (status, non-empty, volume > 0, optional bbox). When refactoring geometry, add a volume + bbox regression case first and keep it green. - -Conventions: -- **Geometry is built with `manifold-3d` (WASM), not three.js**, and runs in a Web Worker. The rest of the app only sees `ModelResult`/`MeshData` (typed arrays + bbox + volume), never `Manifold` objects. `Viewport` takes a `ModelResult` plus the exploded `gap` (parts are moved in the scene only; geometry stays in assembled coordinates) and `toBinaryStl` takes one `MeshData`. `Viewport.tsx` is imperative: the three.js scene is created once in a mount effect and driven through an `api` ref. Z is up. The camera orbits freely, also below the XY plane (to inspect magnet pockets and ears on the underside): the ground grid and the build plate overlay fade out with `groundOpacity(cameraZ, distance)` (`features/viewport/ground.ts`) and a second light from below keeps down-facing faces readable. The camera is fitted only on the first model. In jsdom tests mock the Viewport (no WebGL). -- **Units:** everything is stored and computed in mm. Only `LengthField` converts to/from the display unit (`UnitProvider`, key `gridfinity.unit`); switching units never touches params. Use `LengthField` for any length, `NumberField` for unitless numbers. Both show − / + buttons by default (`stepper={false}` opts out): click = ±step, hold repeats (400 ms, then 10/s), Shift = ×10, limits disable the button; in inches the step is applied in the display unit and the stored mm stays exact. `ui/stepperAssert.ts` is the test helper that checks every numeric input of a panel has both buttons. -- **i18n:** no hard-coded UI text; add keys to both `en.json` and `ru.json` (a test fails if they diverge). Language key `gridfinity.lang`; `` follows the active language. -- **Persistence:** `usePersistedState(key, defaults, migrate?)` merges stored JSON over defaults and falls back to defaults on corrupt data; pass a `migrate` function when a stored shape changes (always normalise nested objects such as `fasteners` with `features/fasteners/normalize.ts`, a partially stored value must never crash the mode) (see `features/baseplate/migrate.ts`: old `magnets`/`screws` booleans → `fasteners`, old manual coordinates → slots). One key per mode (`gridfinity.baseplate`). Storage keys keep the `gridfinity.*` prefix (and `keychain.*`) although the site is called PrintKit: renaming them would reset every visitor's settings, language and last session. The site name, GitHub URL, donation address and Metrika counter live in `app/site.ts` and so does `APP_VERSION` (= `package.json` `version`, injected as `__APP_VERSION__` by `define` in `vite.config.ts`, which Vitest shares; never write the number anywhere else). `BetaBadge` (header, next to the logo) opens the disclaimer popover; the same text and `v` are in the start-page `Footer`; `CHANGELOG.md`'s top heading must carry the current version (`site.test.ts`). -- **Baseplate model** (`geometry/baseplate.ts`): `BaseplateParams = BaseplateGeometry + preview`; geometry is `{pitch, x: AxisSize, y: AxisSize, thickness, fasteners}` where `AxisSize = {mode: 'units'|'mm', value, align}` (units scale with the pitch, mm do not). `baseplateLayout` resolves cells, padding, offsets (rim included) and truncation; `baseplateHeights` gives the total height and the pocket bottom = `(baseHeight − 4.65) + max(floor, ears)`; `validateBaseplate` returns `baseplate.error.*` keys and `BaseplateMode` skips generation while invalid (`useModel(..., {enabled})`, the last valid mesh stays, export is disabled). Thickness: `baseHeight` (≥ 4.65), `floor` (0 = open frame), `rim` (outer wall around the whole plate), `paddingDrop` (how far the padding sits below the rim, 0 = flush), `cornerRadius` (default 4). Splitting is `params.split` (UI settings) → `toGeometry(p)`; the geometry's `SplitGeometry` is normalised to the default when disabled so toggling the exploded view or the gap never rebuilds. Preview-only values (build plate) live in `preview` and never trigger a rebuild. UI: `SizeSection`, `ThicknessSection`, `PlateSection`, `FastenerSection`; build-plate presets in `features/baseplate/bedPresets.ts`. In geometry tests use `sharpParams`/`units`/`mm` from `geometry/testing.ts` (square corners match the prototype's regression volumes). -- **Box model** (`geometry/box.ts`): `BoxParams = {pitch, x, y (AxisSize), height {units|mm}, thickness {outerWall, innerWall, floor}, lip, scoop {front,back,left,right,radius}, compartments, fasteners}`; persisted under `gridfinity.box` via `features/box/migrate.ts`. `boxLayout` gives the outer size (size − 0.5 mm clearance), cavity, wall top, total height (wall top + lip height), cavity bottom (foot 4.75 + floor) and the clamped scoop radius; `validateBox` (keys `box.error.*`, generation is skipped while invalid) and `boxNotices` (`box.notice.scoopClamped`). Build order in `buildBoxSolid`: feet (`binFoot`, one per full cell) ∪ body (rounded rect from foot top to total height) − cavity (prism, 45° chamfer under the lip, lip profile from `layeredHullRect`) + dividers + scoops − bottom fasteners (magnet pockets open at the underside, blind screw holes). Compartments: `xDividers`/`yDividers` are centre positions as fractions of the cavity length; `removed` holds merged segments `x:k:j` / `y:k:i`; `equalDividers` generates equal division. Not implemented (not in the spec): the artboard's separate "ledge" shelf, label tabs, lids, per-compartment heights. -- **Cutout model** (`geometry/cutout.ts`, `bin.ts`, `stl-import.ts`, `cutout-placement.ts`): `bin.ts` is the shared base of Box and Cutout (`binLayout`, `buildBinBody` = feet + body, `lipOpening`, `bottomFasteners`); Cutout leaves the interior solid, cuts the lip opening above the top face and subtracts pockets from the top face (`wallTop`) down by `depth` (limited to `wallTop − 4.75 − floor`). A `Cutout` is `{id, name, source, x, y, rotation, depth, clearance, visible, color}` with x/y = centre of its footprint box relative to the bin centre; `color` is a palette index (0–6, theme tokens `--color-cutout-N`, `features/cutout/palette.ts`) assigned by `addCutout`/`duplicateCutout` as the lowest index no other cutout uses (wrapping when all are used), kept for the cutout's life, carried to the 3D tool by `ToolMesh.color` and filled for old sessions by `fillCutoutColors` in `migrateCutout`; `source` is a rounded rectangle, a circle (`{kind: 'circle', diameter}`: 96-gon cross-section, exact bounds independent of rotation) or a mesh (`meshId`, `unit`, `orientation {faceDown, rx, ry, rz}`, `mode` projected | 3d, plus `size` and a convex `hull` kept in the params for the UI). Projected mode = `Manifold.project()` outline offset by the clearance and extruded; 3D mode = the part as it is, standing on the pocket floor (clearance = Minkowski offset with a small sphere, trimmed at the floor; scaling above 40 000 triangles), opening extended up to the top by its silhouette. Meshes that are not valid solids are healed first (`Mesh.merge()`, `fillHoles`); only if that fails a stepped pocket from cross-sections is built (`cutout-profile.ts`, cached), then the convex hull. `ModelResult.tools` carries translucent preview bodies of the cutouts (rectangle extrusion or the part's own mesh), never part of the model, volume or export; the viewport draws them in cyan with a persisted toggle (`gridfinity.viewport`). STL: `parseStl` (binary by file length, else ASCII) → `repairMesh` (weld, drop degenerate, consistent orientation, flip if the volume is negative) → `toManifold`; `orientVertices` applies units, lay-flat (`rotationToDown`), 90° steps, then centres XY and puts the lowest point at z = 0. Meshes are too big for localStorage: `features/cutout/meshStore.ts` keeps them in IndexedDB (memory fallback, `persistent` flag), the worker gets them with `ModelClient.registerMesh` and the registry `meshRegistry` in `geometry/model.ts`; params reference them by id and a missing mesh only skips that pocket. Placement helpers are pure (`centerCutout`, `alignEdge`, `distribute`, `fitFootprint`, `snapMove`); `TopView` shows live drag via a draft position and commits on release, so the model is rebuilt only then. -- **Photo cutouts** (`geometry/photo/`, `features/cutout/photo/`): the "From photo" button in the Cutout list opens `PhotoDialog` (steps: photo and paper, corners, silhouettes); its state lives in `usePhotoTrace` held by `CutoutPanel`, so closing and reopening keeps photo, corners and settings, nothing is persisted and the photo never leaves the browser. Pure pipeline: `paper.ts` (Letter 215.9 × 279.4, A4 210 × 297, custom 50–1000 mm, orientation), `homography.ts` (`homography` 8 × 8 solve, `checkQuad` = ok / notConvex / mirrored / tooSmall with corners clockwise from the top left, `rectify` samples the photo through paper → photo so there are no holes; raster 4 px/mm, longest side ≤ 2000 px), `segment.ts` (`paperSurface` fits a quadratic surface to the paper pixels with iterative exclusion of objects, so gradients and big dark objects work; ratio to that surface, `sensitivity` 0–1 mapped to a 50 %…8 % brightness drop with an Otsu-based automatic value; close, specks, `fillHoles`, edge margin, 8-connected components, min 3 mm), `outline.ts` (`traceSilhouette`: blurred mask → `traceAlpha` sub-pixel contour → largest outer loop → `simplifyLoop` 0.15 mm → mm, y up, counter-clockwise, relative to the bbox centre; `center` is paper-centred). `synthetic.ts` builds test photos through a known perspective (tests and browser checks only). `OutlineSource` (`kind: 'outline'`, polygon + size) is a cutout source like rect, circle and mesh: `baseOutline` returns its points, the pocket is `CrossSection.ofPolygons` grown by the clearance, `validOutline` guards loading. `createCutouts.ts`: `tracedCutouts` makes one cutout per kept silhouette at its paper position, `withTraced` adds them (colours by the usual rule, existing cutouts kept) and calls `fitFootprintUnits` (smallest whole units that hold all visible cutouts with wall, margin and bin clearance). **Colour, shadows and automatic corners (25a):** the photo is decoded in colour (`decodeColorImage`, `ColorImage`), `rectify` warps the colour planes too (`Rectified.rgb`), and `segment` (with rgb and `shadows` on) uses `colour.ts`: a quadratic paper surface per channel, an object is a pixel whose colour deviates from "paper colour times a factor" (`colourDeviation`, so yellow handles under flash are found) or is darker than the sensitivity drop, and the soft-shadow region is removed: `shadowRegion` grows from clean paper through pixels that get no brighter, are nearly colourless (`κ ≤ 0.12`), not darker than a drop of 0.8 and not on a *wall* (fine-scale steepness above `shadowEdge` = 0.3 per mm: edges and thin dark lines of tools, which the penumbra never reaches), and the existing hole filling restores enclosed steel parts (grey pliers head); no opening (`open` = 0), silhouettes under 60 mm² are dropped. Without rgb or with `shadows: false` the old brightness-only rule runs. Regression fixtures are the owner's two photos (`geometry/photo/fixtures/{flash,noflash}.jpg`, 480 × 640, metadata stripped, decoded in tests with the dev dependency `jpeg-js` through `fixtures/load.ts`): both give exactly three silhouettes agreeing within ~1 mm. `detect.ts` (`detectPaper`: bright, unsaturated area on a more saturated or darker table via Otsu of V and S, largest component with holes filled, convex hull, best four vertices, per-side line fits intersected for corners, clockwise from the top left, rejected when it touches the frame on many sides) and `aspect.ts` (`estimateAspect`: Zhang–He true proportions from the quad; `guessPaper` → A4 / Letter within 3 % and portrait / landscape) run when the photo is loaded (`usePhotoTrace`, `Detection`), proposing corners, paper and orientation. Dialog flow: step 1 only the photo and `PhotoTips` (seven points), then automatically step 2 with paper size, orientation and `CornerEditor` (transparent 22 px rings, `cornerGrab.ts`: grab from the area near a corner inside the sheet, relative drag, no crossing; `Loupe` 4× from the full-resolution photo, placed in the farthest corner of the visible editor with `placeLoupe`), a Tips popover on steps 2–3, step 3 silhouettes. Luminance-only limits remain only when no colour is available. -- **Export** (`export/`, `data/printers.ts`, `features/export/`): `ExportSettings` (format stl|3mf, printer id or `custom`, custom bed, edge `margin`, plate `gap`, nozzle/process/filament, `multi` one|separate, orientation, file name) is shared by all modes: a module store with `useExportSettings()` (key `gridfinity.export`, normalised by `normalizeExportSettings`). The printer bed and margin override the baseplate's stored plate size and split margin (`BaseplateMode`); `usableBed` = bed − 2·margin. `data/printers.ts` holds Bambu presets (hand-written from vendor specs, with `bambu: {model, suffix}` for Studio profile names; never copy third-party AGPL preset dumps). `buildExport(parts, settings, name)` orients each part (`orient.ts`: flip about X, rotate 90°, origin at the bbox corner, z = 0), arranges them on plates (`arrange.ts`: shelf packing, several plates, `overflow` for parts bigger than the bed) and returns STL or 3MF files (`threemf.ts` writes the ZIP with fflate: one object per part, mm, translation-only build transform). `bambu.ts` adds `Metadata/project_settings.config` + `model_settings.config` only for a Bambu printer in 3MF; the key names are version dependent and must be verified by opening a file in Bambu Studio. `ExportPanel` is the sticky block under the parameters (format, printer, download, plate status, per-part buttons), `ExportOptionsSection` is appended to every mode's panel by `Workspace`. -- **Start page art** (`src/geometry/art/`, `scripts/render-art.ts`, `src/app/art/*.svg`): every card (product cards on `/`, mode cards on `/gridfinity` and `/keychain`) shows a pre-rendered vector image keyed by `artId` (`Product.artId`, `ProductMode.artId`; `ART`/`ModeArt` in `app/ModeArt.tsx`: box, baseplate, cutout, keychainImage, keychainShape; Gridfinity card = box, Keychain card = keychainImage). Gridfinity images are the real 2×2 parts (baseplate with corner magnets, 2×2×3 box with two crossing dividers, 2×2×3 cutout block with a pocket from a sample STL). `npm run art` builds them with `generate()` in two groups (Gridfinity three with one shared scale, Keychain two, so the Gridfinity files stay byte-identical). In the Keychain group the keychain-image drawing gets the scale at which it is as tall as the box drawing (`BOX_ART_HEIGHT` ≈ 149 of 165 viewBox units, keeping the cards on `/` level — update that constant if box.svg changes) and every other picture gets the largest scale that does not exceed it and fits the field width (the shape card is wider than the paw). The keychain ones need no file: the image-mode bitmap is a paw print drawn by `geometry/art/paw.ts` (palm pad and four toes as ellipses joined by an exponential smooth minimum, anti-aliased 1000 px bitmap, symmetric and one piece), meshed at a 0.12 mm cell, and the script exits with an error after writing them if `tmp/cutout.stl` is missing. `ArtOptions.smooth` (σ in viewBox units; `smooth.ts` blurs every tone and silhouette mask and thresholds at 0.5 before tracing) is used only by the Keychain group (`KEYCHAIN_SMOOTH`) to remove the pixel stairs of the 3× raster; the Gridfinity group leaves it off. It uses `generate()`, isometric projection, a z-buffer tone image (7 tones, nested "tone ≥ k" regions) and boundary tracing; the SVG files are committed, carry only classes (`art-t0…6`, `art-edge`, colours from `theme.css` tokens) and are inlined by `ModeArt` (`?raw`), so the start page needs no WebGL, worker or WASM. Rerun `npm run art` and commit when default geometry changes. The cutout sample is `tmp/cutout.stl` (git-ignored; the script stops with a message when it is missing; CI never runs it and no test reads it). Size budget 60 KB per image is checked by `ModeArt.test.tsx`. -- **Keychain model** (`geometry/keychain.ts`, `geometry/raster/`, `features/keychain/`): the image is decoded on the main thread (`decodeImage`: PNG/JPEG/WebP/SVG → `GrayImage {gray, alpha}`, cap 4096 px, SVG rasterised at 1024) and stored in IndexedDB (`imageStore`, own DB `gridfinity-keychain`, memory fallback); the worker gets it once (`ModelClient.registerImage`, registry `imageRegistry` in `model.ts`) and params reference it by `imageId`. `KeychainParams = {imageId, silhouette, size, relief, base, quality}`, persisted under `keychain.relief` with `imageName` (`migrateKeychain` normalises nested objects). Pipeline in the worker (`generate('keychain')`, result `ModelResult.keychain` = `KeychainInfo` with threshold, mask preview, holes, size, effective cell, error key): `buildSilhouette` (Otsu/threshold, alpha wins when present, close, holes by stable scan-order ids with `keepOpen`, specks, grow) → crop to the bbox and resample to a grid (`buildGrid`, step = quality cell or larger to fit the triangle budget) → EDT → `reliefHeight` (plateau + smoothstep bevel, ≥ heightFloor, port of `02_heightfield.py`) → `reliefMesh` (block topology, flat back at z = 0, walls on shared vertices) ∪ plate outline extruded by `base.thickness` (`plateOutline`: silhouette offset, rect, rounded, circle; too small → `keychain.error.plateTooSmall`). Errors (empty mask, plate too small) return no parts; `useKeychainModel` keeps the last good mesh and flags `pending`. The raster toolkit (`otsu`, `dilate/erode/close`, `labelComponents`, `findHoles`, `fillHoles`, `edt`, `resampleMask`) is pure and unit-tested. `MaskPreview` is the collapsible overlay in the viewport corner (`Workspace overlay` prop) with an Enlarge button that opens a large dialog (~70 % of the window, Esc / outside click / close button, focus returns to the button). `PlacementEditor` draws its rulers in a margin band inside the viewBox; `Workspace` pads the panel scroller by the sticky footer height (`app/useElementHeight`). The viewport axes gizmo sits above the measurements bar (`GIZMO_BOTTOM`) and its axes are thin cylinders, not 1 px lines. -- **Keychain patterns** (`geometry/patterns/{profile,fields}.ts`, `raster/thin.ts`, `features/keychain/{ProfileSection,DetailSection,PatternSection}`): the heightfield in `buildGrid` runs source (distance, or image brightness with contrast/gamma/invert, capped by a bevel rim ceiling) → profile (`profileValue`: bevel, dome, terrace, flat, engraved; presets are data in `PRESETS`, `presetOf` returns `custom` when values differ) → detail lines (spine = Zhang–Suen skeleton + Gaussian, groove = band at a distance from the rim) → surface pattern (`coverage`: lines, crosshatch, dots, hex, ripples; crisp 0/1 masks in keychain coordinates, subtracting `depth`, never below the floor; scope relief | plateau | base) → clamp. `Grid.top` holds absolute heights including the plate, so `reliefMesh` has no base parameter. Plate patterns put the plate into the grid (`plateInGrid`); otherwise the plate is an extruded `CrossSection` union. `patternResolution` warns when the pitch is below 4 cells and offers the budget-limited step (`KeychainInfo.minCell`). `migrateKeychain` fills source/detail/pattern/profile defaults for old records. With everything off the output equals change 11 (regression volumes in `keychain-patterns.test.ts`). -- **Keychain mounts and split** (`geometry/anchors.ts`, `keychainFit.ts`, `keychainMounts.ts`, `features/keychain/{PlacementEditor,MountSection,SplitSection,contour}`): `MountParams` (none | hole | eyelet | channel; positions are fractions of the silhouette bbox, Y up) and `SplitParams` (two halves, pins, clearance, depth, optional manual `pinPositions`). `buildKeychainParts` builds the relief, then (when a mount or pins exist) an `AnchorField` from the grid (`solid`, `top`, `dist`, `distOut`, `exterior`), `findAnchors` (min height over the whole footprint, rim, spacing; returns rejected spots with a reason), `resolveMounts` (Suggest candidates incl. the nearest channel exit, auto/manual pins, `fitChecks` issues keyed `keychain.warning.`), then cuts: through hole (vertical cylinder), eyelet lug (hull + extrusion, added before the split), channel (horizontal cylinder in the mating plane, breach verified against the *exterior* background, not interior cavities), pins (cylinders symmetric about z = 0). Two halves = top part + `top.mirror(z).rotate(180° about X)` + loose pins, laid out as a sheet (`layoutSheet`); `ModelResult.sheetLayout` makes `buildExport(..., fixedLayout)` keep that layout. `MountInfo` carries resolved positions for the editor; `MaskPreview.box` maps its pixels to millimetres. **Assembled view** (`geometry/keychainAssembly.ts`, `features/viewport/{layout,AssembledControls}`): `buildKeychainParts` also returns `assembly` (label → column-major 4×4, `ModelResult.assembly`) computed from the same shift and pin centres that build the sheet: `top` identity, `bottom` = rotation 180° about X with the sheet shift undone (mirror of the top across z = 0), `pinN` = translation into the hole, centred on the mating plane. `Viewport` takes `view: 'sheet' | 'assembled'` and `openGap` and only sets group matrices (`assembledMatrix`, `layoutBounds` for Fit); labels and the build plate hide in the assembled view, no regeneration, export always uses the sheet. `Workspace` shows `AssembledControls` only when `model.assembly` exists; `keychainView` and `openGap` (0–15 mm, a length, so `LengthField`) live in `gridfinity.viewport`. -- **Rack panels** (`geometry/rack/`, `features/rack/`): `spec.ts` holds the 10-inch constants (254 wide, unit 44.45, ear holes 236.525 apart at 6.35 / 22.225 / 38.1 per unit, window 222.25, tolerance 0.45) and `rackLayout(units, tolerance, margin)` (height, ear holes of one ear — the builder mirrors them —, pattern region; X from the panel centre, Y from the lower edge; the tolerance is taken off the top so holes stay on the standard grid). `panel.ts`: `RackPanelParams = {units, thickness, cornerRadius, tolerance, ears {positions all|outer|none, shape round|slot, size, slotLength}, pattern, back {enabled, thickness}}`, `validateRackPanel` (`rack.error.*` stop generation, `rack.warning.thin` with the offending `setting`, from `patternIssues`), `buildRackPanel` = rounded outline − ear holes − pattern cut, extruded 0…thickness (front face up); the back wall is the same outline and holes at z = −t…0; a result with several components returns `error: 'rack.error.disconnected'` and no panel. `patterns/` are pure `(box) → CrossSection` in a centred box (`placePattern` puts it in the region and turns it 90° by swapping the box axes, `centredRun` centres whole elements only): `skadis` (slot 5 × 15, pitch 40, row step 20, odd rows shifted), `hex` (flat-to-flat width + wall, pitch width + wall so the wall is exact), `pills` (horizontal, brick-like rows shifted by half a pitch, `width` across and `length` along, optional shortened end pills), `lines`, `passthrough`; each pattern keeps its own settings in `params.pattern[kind]`. `generate('rackPanel')` returns parts `panel` (+ `back`) and `ModelResult.rack {error?, warnings}`; `useModel` treats `rack.error` like a failed build (last mesh kept, export stale). UI: `RackPanelMode` (state `rack.panel`, `migrateRackPanel` normalises every field) with `SizeSection`, `EarsSection`, `PatternSection`, `BackSection`; no artboard exists, built from `ui/` and tokens. The Rack card art is `rackPanel.svg` (2 U Skadis panel, its own group in `npm run art`). **Kumiko (27):** both patterns are through cuts. `asanoha.ts`: the triangular lattice with step R = width/√3 (`asanohaSegments`: main = lattice edges once each, detail = centroid-to-corner Y in every triangle), strokes are rectangles with square caps and stay material, the cut is the region minus the united strokes reduced to the openings that lie completely inside the region (strokes form one network joined to the frame; `detailWidth` 0 = no detail strokes). `hicks.ts`: honeycomb cells (`hicksCells`: one lattice, origin = the first complete cell, `edgeCells` adds the cells that only touch the region, clipped by it) each with nested hexagonal cut rings (`hicksRings`: stroke at the cell edge, then gap, stroke, gap… the core stays solid when it is ≥ stroke / 2), every ring interrupted by bridges of `stroke` width on alternating vertices (`bridgeVertices`: 0, 2, 4 / 1, 3, 5) so the panel stays one piece. Defaults were tuned by eye (asanoha width 30, strokes 1.6 / 1.2; Hicks 24 / 1.2 / 1.2, edge cells on). -- **Rack enclosure** (`geometry/rack/case.ts`, `features/rack/Case*`, `migrateCase.ts`): `RackCaseParams = {device {width, depth, height}, gap, wall, plateThickness, units {auto, value}, cornerRadius, tolerance, ears (shared `EarParams`), window {enabled (default true), lip}, openings (`OpeningGroup[]`, ≤ 6), vents {enabled, width, wall, margin}}`, persisted under `rack.case`. `caseLayout`: outer = device + 2 × (gap + wall), `autoUnits = ceil((outerH + tolerance) / 44.45)`, a manual value below it is raised (`raised`), `excess` = outerW − 222.25 (`rack.error.tooWide`, the UI shows it; generation is skipped like in other modes). `buildRackCase`: `plateBase` (outline + ear holes, shared with the panel) minus window and round holes, extruded 0…plate thickness; the shell is the outer prism (z −(depth + gap)…0, centred on the plate) minus the cavity (device + 2 gap, open at the back), unioned with the plate, and the whole body is then mirrored in z so the front face lies on z = 0 (on the bed as printed, the shell grows upward); vents are `hexCut` patterns turned into cutters through the wall thickness only (`ventCutters`). `openings.ts`: `OpeningGroup = {kind keystone|hole, count, perRow, zone left|right|above|below, diameter}` (`perRow` 1 = column, ≥ count = row, between = grid; rows centred); `layoutOpenings(groups, frame)` fills the free zones beside / above / below the shell (above and below exist only when the plate is taller than the shell), groups of one zone go outward from the shell, a group that leaves its zone or touches an earlier one is not cut and reported as `rack.warning.fit` with `setting: 'group'`. `keystone.ts` + `KEYSTONE` in `spec.ts` (measured from the owner's outlet sample, see README references): `keystoneSocket` gives a collar (19.8 × 27.85, 10.4 mm from the face, behind the plate) and a through pocket (opening 14.8 × 16.6 + clearance, ramp on the upper edge, latch notch on the lower); the collar is unioned with the plate and shell, then the pockets are subtracted. `migrateRackCase` turns old `keystones`/`cable` into groups. The viewport draws outlines with `creaseEdges` (own algorithm, see `features/viewport/degenerate.ts`). Card art `rackCase.svg`. -- **Rack shelf** (`geometry/rack/shelf.ts`, `features/rack/{ShelfMode,ShelfPanel}`, `migrateShelf.ts`): `RackShelfParams = {units (default 1), thickness, cornerRadius, tolerance, ears (shared `EarParams`), shelf {depth, thickness (floor), clearance}, sides {height (0 = no walls, default 35), wall}, closedFront (default false = open above the floor between the walls), floorPattern, platePattern (both `PatternParams`; floor default pills across the depth, plate pattern only for a closed front, default none)}`, persisted under `rack.shelf` (`migrateRackShelf` uses `normalizePattern` from `migrate.ts`; the stored `lips` of the first draft are dropped). `shelfLayout`: plate height, width = 222.25 − 2 × clearance, pattern regions (floor: x between the walls, second axis = distance behind the plate; plate: above the floor between the walls). `buildRackShelf` builds in print orientation like the enclosure: plate flat (front face z = 0, back z = thickness), the floor slab y 0…thickness standing on the plate's back face and growing up in z by the depth, two walls (polygon in (z, y) from the plate to the back, straight slope from `sides.height` to the floor top) on the floor edges, 0.01 mm overlaps, the floor pattern cut through the floor (`cutPattern`, turned about X), the plate pattern cut in the plate outline; a pattern that splits the part gives `rack.error.disconnected`. `PatternFields` (`PatternSection.tsx`) edits any `PatternParams` and is shared by the panel, the floor and the plate. `generate('rackShelf')` returns one part `shelf`. Card art `rackShelf.svg`. -- **Photo AI engine** (`geometry/photo/ai*.ts`, `outlineSmooth.ts`, `compare.ts`, `src/ml/`, `features/ml/`, `features/cutout/photo/{EngineSwitch,AiControls,AiStatus,MaskTuning,CompareOverlay}`): an optional neural silhouette engine for photo cutouts; the classic `segment` stays the default and is frozen by `photoBaseline.test.ts` (`segment.ts`, `colour.ts`, `outline.ts` must not change). `aiSegmentation(rectified, alpha, {threshold, smooth, refine, band, ...})` turns a soft alpha into the same `Segmentation` (classic cleanup, numbering top to bottom); `aiHybrid.ts` is the hybrid edge (mask eroded by `band` ∪ band ∩ classic colour test, network edge kept where the classic test is uninformative or the classic object leaks past the band); `aiRefine.ts` is the second pass per object (crop + 12 mm, blended at the crop border, the network is injected as a `Matte` callback); `aiOutline.ts` + `outlineSmooth.ts` trace an AI silhouette with corner-preserving vector smoothing and `roughness`; `aiResample.ts` stretches the network output (bicubic, measured no smoother than bilinear). `src/ml/` is React-free: `models.ts` (registry: pinned commit URL, bytes, SHA-256, SPDX, backends, `maskDefaults`; `registryIssues` refuses non-permissive licences), `modelStore.ts` (download with progress/cancel, size + SHA-256 check, Cache API under the revision URL with memory fallback, active model per kind in `gridfinity.ml`; the only caller of `download` is `service.downloadModel` from the manager after the consent dialog, a test greps for it), `capabilities.ts` (WebGPU, `shader-f16`), `runtime.ts` + `ml.worker.ts` + `mlClient.ts` (worker created lazily, one ort build per backend: `onnxruntime-web/wasm` (`ort-wasm-simd-threaded.wasm`) or `/webgpu` (its glue wants the `…asyncify.wasm`, not jsep: a mismatch hangs the session silently), wasm files bundled via `?url`, latest `matte` wins, cancel recreates the worker, worker errors reject pending requests), `service.ts` (the one place that runs the network and logs jobs of source `ai` for downloads and runs). Heavy photo steps (classic `segment`, `aiSegmentation`) run in `geometry/photo/photo.worker.ts` through `PhotoClient` (`photoClient.ts`: the sheet and the alpha are registered once and then referred to by id, lanes `segment`/`ai`/`base` each with latest-wins, inline fallback without `Worker` so tests need no worker); `useDebounced` compares objects by fields, never by reference, otherwise it re-renders in a loop. `usePhotoTrace` keeps `settings.engine`, caches the alpha of the last run (`ai.alpha`) so threshold/smoothness only recompute silhouettes, exposes `active` (AI result unless it failed or is missing, else classic). `ModelManager` (opened from the `Header` button via `features/ml/managerState`) lists models by kind with consent, progress, delete, active radio. A classic-only session loads no ML code beyond the small client modules (checked in the network panel). -- **Keychain AI silhouette** (`geometry/keychain.ts`, `geometry/raster/matte.ts`, `features/keychain/{matte,AiSilhouette,imageStore,MaskPreview}`): `SilhouetteParams.source` is `brightness` (default, output unchanged) or `ai`; `ai {model, threshold, smooth (mm of the finished keychain), keepLargest, matteId}`. The soft alpha ("matte", `Matte {width, height, data 0…255}` at image resolution) is registered in the worker (`ModelClient.registerMatte`, `matteRegistry`, params carry only `matteId`), so the sliders re-evaluate the mask (`smoothThreshold`, `keepLargest`) without running the network; the pixel size of the smoothing and the mm² of areas come from the rough mask bbox and `SizeParams` (`pxPerMmOf`). A missing or wrong-size matte falls back to brightness with `aiMissing`. `autoOpen` (mm², `null` = 150 for AI and 0 for brightness) opens enclosed gaps at least that big and fills smaller ones unless the user set the area's own mode. `imageStore` (IndexedDB `gridfinity-keychain` v2) keeps the grey image, a colour copy ≤ 2048 px (`colors`, the network needs RGB and the file is gone after import) and mattes keyed `imageId|model|revision`; removing the image removes all of them. `useMatte` runs `MlService.matteAlpha` (whole image, aspect kept, no second pass), saves, registers in the worker and only then writes `source/ai.model/ai.matteId` into the params; after a reload it restores the stored matte without the network, and `ImageMode` holds generation (`hold`) until that is done. `AiSilhouette` reuses `MaskTuning` and `AiStatus` of the photo flow; SVG has no AI; a logo on a clean background (`looksLikeLogo`) gets a hint. `MaskPreview` takes the matte for a grey soft-mask view. -- **Keychain photo relief** (`geometry/{photoRelief,photoPresets,subjects,imageOutline}.ts`, `geometry/raster/{tone,guided}.ts`, `features/keychain/{depth,cropMath,CropEditor,PhotoReliefBlock,OutlineControls}`): `SourceParams.kind = 'photo'` builds the height field in `photoHeights` (nodes of the grid): contour (`reliefHeight` normalised) + depth (plane from the worker `depthRegistry`, `normaliseDepth` over the mask, `guidedFilter` snap) + detail (`localContrast` with the radius in mm of the part, `detailTerm` Engrave/Relief/Both with a dark threshold, `quantise` levels) mixed by shares (proportions; missing depth = share zero, reported as `info.depthMissing`; sum zero = contour), then contrast/gamma/invert, `limitSlope`, final Gaussian; the bevel ceiling of the brightness path still applies. With distance or brightness the new fields are ignored (regression volumes kept). `KeychainParams.crop` (fractions, `cropImage`/`cropPlane` make the cropped view of gray, alpha, matte and depth in the core, no model re-run) and `silhouette.source: 'frame'` (every pixel of the crop is material). Image silhouettes get an exact outline: the grid is built on the mask dilated by `inflate` (like Shape mode), the outline is traced from the blurred mask (`traceImageOutline`: marching squares, corner-preserving smoothing with `restoreTips`, simplification 0.03 mm, vertex step), and the relief is intersected with that polygon (`CrossSection`, `EvenOdd` so open areas stay holes); a silhouette touching the image edge has no margin and falls back to the stair outline (`outline` undefined also keeps the old path for regression tests). Depth: registry kind `depth` with a second file (`ModelSpec.data`, external data for ORT, both pinned with SHA-256, cached together, ready only when both are present), `useDepth` runs it on the cropped colour copy, places it on the full image (`placeDepth`), stores it in `imageStore` (`depths`, keyed image|model|revision, with the crop it was computed for; a different crop only marks it stale). Subject presets (Face, Whole animal, Drawing / logo; `chooseSubject` from `pictureStats`) and strength presets (Subtle, Portrait, Deep) are data with `subjectOf`/`intensityOf` returning null for Custom; the automatic subject choice runs once per image and turns itself off on any edit (`State.subjectAuto`). `KeychainInfo.height` is a grey plane of the grid heights for the preview toggle. The local-contrast deviation floor is 0.08 (a smaller floor amplified fur into noise). -- **Activity log** (`app/activityLog.ts`, `logText.ts`, `modelLog.ts`, `ActivityLogPanel.tsx`, `features/viewport/StatusBar.tsx`): a memory-only module store (`useSyncExternalStore`, ring buffer of 500) of entries `{level, source, key, params, detail?, job?}`: keys, never sentences (`log..` in en/ru; `logText` renders them and translates the `setting`/`what` params), so a language switch re-renders old lines. `log.info/warn/error/once`, `memoryFallback(what)` (storage warning once per session) and `startJob({source,key,cancel?})` → `update/done/fail/cancelled/discard`; a job running > 200 ms is what shows the loader (`isLoading`), there is no `busy` prop any more. `useModel` runs one `model` job per real generation (debounced requests never start one; a superseded job is `discard`ed with its start line), reports duration/triangles, `ModelResult` errors and warnings (`modelLog.reportResult`) and the modes' validation keys (`invalid` option). `MeasurementsBar` keeps X/Y/Z and renders `StatusBar` (current job line, last message for 4 s, then "Updated"; warning/error counters; one button opens the log; `role=status` announces at most once a second). `ActivityLogPanel` (mounted in `AppShell`, opened with `openLog({source?})`, bottom sheet below 768 px) has level/source filters, follow + "Jump to latest", Details, Copy, Clear, Esc with focus return. Producers: image/mesh/font import, the three stores' memory fallback, `usePhotoTrace` and the cutout creation, `ExportPanel`. Entries hold names and sizes only, never pixels or file contents; AI changes (32–34) report through the same interface. -- **Tests:** vitest (`.claude/**` is excluded: other sessions' worktrees live there); component tests start with `// @vitest-environment jsdom` (setup in `src/test-setup.ts`, RTL cleanup included). - -UI strings are English/Russian; code comments are in Russian. - -- **Keychain modes** (`features/keychain/`): `KeychainWorkspace` (model hook, `Workspace`, export footer, session record) is shared; `ImageMode` (state `keychain.relief`, image store, drop zone, `KeychainPanel` = image/silhouette/size + `SharedSections`) and `ShapeMode` (state `keychain.shape`, `ShapePanel` = `ShapeSection` + `RimSection` + `SharedSections imageOnly={false}`, i.e. no Relief or Detail lines sections). `SharedSections` is relief, detail (both only with `imageOnly`), pattern, base, mount, split, resolution. Shape state has no image fields (`shapeState.ts`: `ShapeState = SharedParams & {shape}`, `migrateShape` normalises every number, `shapeParams` builds full `KeychainParams`). Geometry: `KeychainParams.origin = {kind:'image'} | {kind:'shape', shape}`; `geometry/shapes.ts` has `ShapeParams` (rect, circle, ellipse, capsule, polygon with rounding/rotation, `rim`), analytic `shapeInside`, `shapeBounds` (exact bbox in mm) and `shapeMask` (2048 px, bbox fills the raster exactly) which feeds the same `buildGrid` as an image mask, bypassing threshold, areas and brightness. Polygon `width` is the circumscribed diameter; the corner radius is a true fillet (Minkowski of a smaller polygon with a disc) so the extreme width does not change. Outlines are exact, not grid-stepped: the shape is rasterised with a 1.75-cell margin, the relief is intersected with `shapeCrossSection` (`geometry/shapeOutline.ts`, 256 segments per circle) and a silhouette plate is that outline offset, with `edgeShift` keeping edge distances true; the mount field uses the unpadded mask. The rim is an exact ring (outline minus its inward offset by `rim.width`, extruded to plateau + `rim.height`) unioned after the clip, so its inner corners are true arcs too; only in the stepped fallback (pattern on the base) it is added in `buildGrid` to nodes within `rim.width` of the edge. Shape origin only; patterns no longer cut the exact ring. The placement editor traces its outline from the shape's preview mask, the mask card is only rendered by `ImageMode`. -- **Smoothing** (`geometry/raster/blur.ts`, `ProfileSection`): `SourceParams.smooth/despeckle` (brightness plane: 3×3 median over silhouette nodes, then masked Gaussian, before contrast/gamma; the UI sets 0.4 mm when brightness is chosen, stored default and old records are 0) and `ReliefParams.smooth` (0–3 mm; blurs the finished height `raw` before the bevel ceiling of the brightness source, detail lines and patterns, then clamps to `heightFloor`). The filters are normalised convolutions (`G*(v·m)/G*m`) so nothing leaks across the silhouette or from `fixed` nodes; σ = radius/cell clamped to 0.5–40 cells (`sigmaCells`). Fields are optional in the types (missing = 0) and `migrateKeychain` coerces them. `smoothResolution` drives the "radius below one grid cell" warning with the raise-resolution button. Smoothing controls live in the Relief section, which only Image mode has; Shape mode is a flat plateau and has none. -- **Areas** (Image mode only): enclosed background regions are *areas* (never "holes" in UI/params). `SilhouetteParams.areas: Record` (migrated from `keepOpen`; `fillHoles: false` = all open). `buildSilhouette` returns `areas` (stable ids in scan order, effective mode), `areaId` (id per pixel), `goneAreas` (ids with stored non-follow settings that no longer exist; shown as a warning). `MaskPreview` carries `labels` (id per preview pixel, for click hit-testing via `areaAt`) and `areas`; plane value 4 = level area. In `buildGrid` nodes of level areas (via `GridInput.levels` + `resampleIds`) get a flat `base + max(level, heightFloor)` and are `fixed` for the brightness/surface smoothing, skipping profile, detail lines and patterns. Selection (`selectedArea`) lives in `ImageMode` and is shared by the area list (`SilhouetteSection`) and the small/enlarged mask. -- **Text** (`geometry/text.ts`, `features/keychain/{TextSection,textRaster,fonts,fontList,fontStore,useTextRaster}`): `KeychainParams.text` (`TextParams`: content, `font {id,bold,italic,name?}`, size = cap height mm, letter/line spacing, align, rotation, emboss|engrave with height/depth, x/y = fractions of the silhouette box, Y up) is shared by both modes (`SharedParams`). The main thread rasterises the text with canvas at 12 px/mm (`rasterizeText`: pixel size from the measured height of "H", `layoutLines` is the pure layout) and registers the alpha mask once in the worker (`ModelClient.registerText`, `textRegistry`); params sent to the worker carry `text.maskId` = `textRasterKey` (content, font, size, spacings, align only), so moving or rotating text never re-rasterises (the worker applies the inverse transform in `textSampler`). `useTextRaster` (in `KeychainWorkspace`) keeps the previous raster until the new one is ready and exposes `TextRasterContext` (font lost, busy). Letters are built as exact solids, not from grid nodes: `textOutline.ts` traces the alpha raster (marching squares with sub-pixel interpolation, Douglas–Peucker 0.02 mm) into polygons, and `applyExactText` extrudes them from just under the surface to surface + height (emboss) or cuts them from surface − depth, never below `base + heightFloor` (engrave); the part over the silhouette and the part over the plate outside it use their own levels and are clipped to the silhouette/plate outlines. This path is taken when the relief under the letters is flat (spread ≤ `TEXT_FLAT_TOL` = 0.15 mm: plateau, plate, level areas); otherwise (letters across a bevel, dome, brightness) the fallback samples the text in `buildGrid` (emboss `+height`, engrave `max(t − depth, min(t, lo))`) with stepped edges and, when text leaves the silhouette and a plate exists, `plateInGrid` is switched on (stepped plate, like base patterns); `textRegion` samples where text lies (`info.text.clipped` = part outside silhouette and plate, not built). Two halves: `buildKeychainParts` = `buildKeychainCore` for the top plus a second core call without text for the mirrored bottom. Fonts: 25 fontsource families (latin + cyrillic only) listed in `fontList.ts`, woff2 files are lazy `import.meta.glob(..., {query:'?url'})` loaders registered with `FontFace` on first use (no third-party hosts; variable packages for most, static for Fira Sans, PT Sans/Serif, Russo One, Press Start 2P, Pacifico, Lobster, Marck Script); generic families and a user TTF/OTF/WOFF (validated by magic bytes, stored in IndexedDB db `gridfinity-keychain-fonts`, restored by `ensureFont`) complete the list. `PlacementEditor` has a `text` target (box, baselines and the text drawn to scale, drag, X/Y) and a text-only mode (`only="text"`) embedded in `TextSection`. `textResolution` drives the fine-strokes warning (stroke ≈ size/8 < 4 cells). -- **Panel chrome** (`app/Workspace.tsx`): the optional `onStartOver` (+ `modeName`) prop renders a "Start over" strip above the panel scroller (so it is reachable with every section collapsed) and a `ConfirmDialog`; each mode passes `() => setState(defaults)` with the same defaults `usePersistedState` falls back to (Cutout also clears the selection, Image mode also drops the in-memory image; stored blobs are left to the existing cleanup, export settings, units and language are never touched). In Image mode without an image the sections are hidden, so the empty-state banner carries its own "Choose image" button (`image-input-empty`) and shows the import error. Tests: `ui/renderOpen.tsx` has `openAll`, `openSection(title)` (tolerates the "(changed)" suffix) and `renderOpen` for tests that need collapsed sections expanded; `ui/modifiedSections.test.tsx` checks collapsed-by-default and exactly-one-highlight per mode. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file From ffc7d2036bc989f0b8e3ec72f91ffb98d44cd411 Mon Sep 17 00:00:00 2001 From: skarakovski Date: Sun, 1 Nov 2026 14:06:53 +0700 Subject: [PATCH 3/3] Add Skills for OpenSpec commands & ponytail --- .agents/skills/.openspec-target | 1 + .agents/skills/openspec-apply-change/SKILL.md | 205 ++++++++++ .../skills/openspec-archive-change/SKILL.md | 221 +++++++++++ .agents/skills/openspec-explore/SKILL.md | 354 ++++++++++++++++++ .agents/skills/openspec-propose/SKILL.md | 174 +++++++++ .agents/skills/openspec-sync-specs/SKILL.md | 290 ++++++++++++++ .../skills/openspec-update-change/SKILL.md | 109 ++++++ .gemini/commands/opsx/apply.toml | 197 ++++++++++ .gemini/commands/opsx/archive.toml | 260 +++++++++++++ .gemini/commands/opsx/explore.toml | 235 ++++++++++++ .gemini/commands/opsx/propose.toml | 166 ++++++++ .gemini/commands/opsx/sync.toml | 282 ++++++++++++++ .gemini/commands/opsx/update.toml | 101 +++++ .gemini/skills/openspec-apply-change/SKILL.md | 205 ++++++++++ .../skills/openspec-archive-change/SKILL.md | 221 +++++++++++ .gemini/skills/openspec-explore/SKILL.md | 354 ++++++++++++++++++ .gemini/skills/openspec-propose/SKILL.md | 174 +++++++++ .gemini/skills/openspec-sync-specs/SKILL.md | 290 ++++++++++++++ .../skills/openspec-update-change/SKILL.md | 109 ++++++ .../skills/openspec-apply-change/SKILL.md | 205 ++++++++++ .../skills/openspec-archive-change/SKILL.md | 221 +++++++++++ .kimi-code/skills/openspec-explore/SKILL.md | 354 ++++++++++++++++++ .kimi-code/skills/openspec-propose/SKILL.md | 174 +++++++++ .../skills/openspec-sync-specs/SKILL.md | 290 ++++++++++++++ .../skills/openspec-update-change/SKILL.md | 109 ++++++ 25 files changed, 5301 insertions(+) create mode 100644 .agents/skills/.openspec-target create mode 100644 .agents/skills/openspec-apply-change/SKILL.md create mode 100644 .agents/skills/openspec-archive-change/SKILL.md create mode 100644 .agents/skills/openspec-explore/SKILL.md create mode 100644 .agents/skills/openspec-propose/SKILL.md create mode 100644 .agents/skills/openspec-sync-specs/SKILL.md create mode 100644 .agents/skills/openspec-update-change/SKILL.md create mode 100644 .gemini/commands/opsx/apply.toml create mode 100644 .gemini/commands/opsx/archive.toml create mode 100644 .gemini/commands/opsx/explore.toml create mode 100644 .gemini/commands/opsx/propose.toml create mode 100644 .gemini/commands/opsx/sync.toml create mode 100644 .gemini/commands/opsx/update.toml create mode 100644 .gemini/skills/openspec-apply-change/SKILL.md create mode 100644 .gemini/skills/openspec-archive-change/SKILL.md create mode 100644 .gemini/skills/openspec-explore/SKILL.md create mode 100644 .gemini/skills/openspec-propose/SKILL.md create mode 100644 .gemini/skills/openspec-sync-specs/SKILL.md create mode 100644 .gemini/skills/openspec-update-change/SKILL.md create mode 100644 .kimi-code/skills/openspec-apply-change/SKILL.md create mode 100644 .kimi-code/skills/openspec-archive-change/SKILL.md create mode 100644 .kimi-code/skills/openspec-explore/SKILL.md create mode 100644 .kimi-code/skills/openspec-propose/SKILL.md create mode 100644 .kimi-code/skills/openspec-sync-specs/SKILL.md create mode 100644 .kimi-code/skills/openspec-update-change/SKILL.md diff --git a/.agents/skills/.openspec-target b/.agents/skills/.openspec-target new file mode 100644 index 0000000..4a33ff6 --- /dev/null +++ b/.agents/skills/.openspec-target @@ -0,0 +1 @@ +agents diff --git a/.agents/skills/openspec-apply-change/SKILL.md b/.agents/skills/openspec-apply-change/SKILL.md new file mode 100644 index 0000000..a0ee82e --- /dev/null +++ b/.agents/skills/openspec-apply-change/SKILL.md @@ -0,0 +1,205 @@ +--- +name: openspec-apply-change +description: Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks. Also use when the user says "openspec apply", "opsx apply", or "openspec implement". +allowed-tools: Bash(openspec:*) +license: MIT +compatibility: Requires openspec CLI. +metadata: + author: openspec + version: "1.0" + generatedBy: "1.14.0" +--- + +Implement tasks from an OpenSpec change. + +**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store ` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "" --json --store ""`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. + +**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store ` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it. + +One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`. + +Otherwise, with no root, what happens next depends on how this workflow was reached: + +- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup. +- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store `), or continue without OpenSpec for this request. Wait for their answer. + +In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it. + +**Input**: Optionally specify a change name (e.g., `/openspec-apply-change add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. + +**Steps** + +1. **Select the change** + + If a name is provided, use it. Otherwise: + - Infer from conversation context if the user mentioned a change + - Auto-select if only one active change exists + - If ambiguous, run `openspec list --json` to get available changes and ask the user to select one + + Always announce: "Using change: " and how to override (e.g., `/openspec-apply-change `). + +2. **Check status to understand the schema** + ```bash + openspec status --change "" --json + ``` + Parse the JSON to understand: + - `schemaName`: The workflow being used (e.g., "spec-driven") + - `planningHome`, `changeRoot`, and `actionContext`: planning scope and edit constraints + - Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others) + +3. **Get apply instructions** + + ```bash + openspec instructions apply --change "" --json + ``` + + This returns: + - `contextFiles`: artifact ID -> array of concrete file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs) + - Progress (total, complete, remaining) + - Task list with status, source path, and source line + - Dynamic instruction based on current state + - Optional `context`: current required project instruction input from the selected root + - Optional `operationGuidance`: current advisory guidance for apply + - `missingArtifacts` (when present): required artifact ids with no output + + **Handle states:** + - If `state: "blocked"`: show the message and pause implementation. + - If `missingArtifacts` is non-empty: suggest completing the missing artifacts. Run `openspec status --change "" --json`, select the next `ready` artifact (not `skipped` or `blocked`), and use `openspec instructions "" --change "" --json` for its rules and template. Keep the selected `--store ` on both commands. + - Otherwise, follow the CLI instruction to create or repair the schema-configured tracking file from existing planning artifacts. Do not assume another artifact is ready or start implementation while blocked. + - If `state: "all_done"`: congratulate, suggest archive + - Otherwise: proceed to implementation + + Treat `context` as a required prompt-level input. Read and consider it, and + apply relevant project facts, conventions, and constraints while implementing. + Treat `operationGuidance` as optional additive advice. Read and consider every + entry, and follow entries that are applicable and compatible with the built-in + workflow. + + Keep both fields separate from CLI-returned state, missing artifacts, tasks, + progress, `contextFiles`, and the built-in `instruction`. They are not + evidence of task completion, do not replace the built-in instruction, and do + not permit bypassing a blocked state. If context conflicts with the built-in + instruction, an explicit user choice, or a CLI-controlled value, report the + conflict and preserve the controlling value. If guidance is inapplicable or + conflicts with those controlling inputs, do not follow it and explain why. + These are prompt-level behavior contracts, not enforceable checks. + +4. **Read context files** + + Read every file path listed under `contextFiles` from the apply instructions output. + The files depend on the schema being used: + - **spec-driven**: proposal, specs, design, tasks + - Other schemas: follow the contextFiles from CLI output + + Do not copy `context` or `operationGuidance` verbatim into implementation + files or planning artifacts unless the user separately asks for that content. + +5. **Show current progress** + + Display: + - Schema being used + - Progress: "N/M tasks complete" + - Remaining tasks overview + - Dynamic instruction from CLI + +6. **Implement tasks (loop until done or blocked)** + + For each pending task: + - Show which task is being worked on + - Make the code changes required + - Keep changes minimal and focused + - Before editing, confirm the checkbox at the returned `sourcePath` and `line` still matches the task description; if it does not, rerun the apply instructions and use the refreshed location + - Mark the task complete at its returned `sourcePath` and `line`: `- [ ]` → `- [x]` + - Rerun the apply instructions and confirm that task is now done and progress changed + - Continue to next task + + **Pause if:** + - Task is unclear → ask for clarification + - Implementation reveals a design issue → suggest updating artifacts + - A task needs work beyond what the spec and tasks describe, or you are tempted to drop, narrow, defer, or accept exceptions to specified behavior to make it fit → surface the added scope and ask; do not absorb it silently + - Error or blocker encountered → report and wait for guidance + - User interrupts + +7. **On completion or pause, show status** + + Display: + - Tasks completed this session + - Overall progress: "N/M tasks complete" + - If all done: suggest archive + - If paused: explain why and wait for guidance + +**Output During Implementation** + +``` +## Implementing: (schema: ) + +Working on task 3/7: +[...implementation happening...] +✓ Task complete + +Working on task 4/7: +[...implementation happening...] +✓ Task complete +``` + +**Output On Completion** + +``` +## Implementation Complete + +**Change:** +**Schema:** +**Progress:** 7/7 tasks complete ✓ + +### Completed This Session +- [x] Task 1 +- [x] Task 2 +... + +All tasks complete! You can archive this change with `/openspec-archive-change`. +``` + +**Output On Pause (Issue Encountered)** + +``` +## Implementation Paused + +**Change:** +**Schema:** +**Progress:** 4/7 tasks complete + +### Issue Encountered + + +**Options:** +1.