Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion openspec/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
| 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 в тела (join/cut/intersect, фаска/скругление краёв); тело = эскиз + список операций, сетка производная (`EDITOR.md` 2a) |
| 41 | `41-editor-op-stack` | 40 | v1.12.0 (план). Неразрушающий стек операций (пересчёт от источников, `EDITOR.md` 2a), дерево/timeline, булевы, Mirror, Pattern, Hollow, Offset |
| 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 с соединителями, отверстия под магниты/винты, текст на грани |
| 44 | `44-editor-face-tools` | 41 | v1.15.0 (план). Выбор граней/рёбер, Push/Pull, Fillet/Chamfer, эскиз на грани, Hole wizard |
Expand Down
2 changes: 2 additions & 0 deletions openspec/changes/41-editor-op-stack/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-27
41 changes: 41 additions & 0 deletions openspec/changes/41-editor-op-stack/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Design

## Context

`39` stores objects `{source, transform}`; `40` adds sketch-built bodies with a feature list. Rule 2a (`openspec/EDITOR.md`): descriptions are the source, meshes are derived. A single mechanism must now cover sketch features, booleans with other objects and body operations. Blender's modifier stack and Fusion's timeline are the references; Blender's *order matters, non-destructive, apply when done* model is the simplest to explain and implement.

## Goals / Non-Goals

**Goals**: one stack model for everything; fast edits through caching; safe dependency handling; clear messages for open meshes and failures.
**Non-Goals**: parametric constraints between bodies, formulas, assembly joints, a separate timeline bar.

## Decisions

1. **Model.** `EditorObject = {…, source: Source, stack: Op[]}`; `Source` = `mesh | primitive | empty` (an empty source is a sketch-built body whose first op is Extrude/Revolve with `op: 'new'`). `Op = {id, kind, params, enabled, label?}` with kinds: `extrude`, `revolve` (from `40`), `boolean`, `mirror`, `linearPattern`, `circularPattern`, `offset`, `hollow`. Later changes add `planeCut`, `hole`, `text` (43), `pushPull`, `edgeBreak` (44), `decimate`, `smooth` (42, as ops on a mesh source).
2. **Evaluation.** `evaluateObject(obj, ctx)` folds the enabled ops over the source result. Each op is a pure function `(solid | null, op, ctx) → solid` where `ctx` gives sketches, other objects' evaluated results (for tools), the quality and the abort signal. A failed op returns `{solid: previous, failed: {index, reason}}` and evaluation stops for that object (as in `40`). The rollback marker is `obj.rollback` (index or `null`); ops after it are not evaluated and the result is the state at that step.
3. **Cache.** Key of step *k* = hash of `(source id/params, ops[0..k] params + enabled, ids and versions of tools used up to k, quality)`. The worker keeps a map from key to `Manifold` with an LRU bound by triangle count (default 6 M) and deletes evicted manifolds (`.delete()`); editing op *k* reuses result *k−1*. Only the final solid of each visible object is turned into a mesh and sent; intermediate solids never leave the worker.
4. **Graph.** A `boolean` op references tool objects by id. `buildGraph(objects)` gives dependencies; evaluation order is a topological order; a cycle is detected when the op is created or edited and refused with the cycle named (`stack.error.cycle`). The tool is evaluated *with its own stack and its own transform*, then brought into the target's local space by `inverse(targetMatrix) · toolMatrix` (the worker receives matrices for this reason; `39` already carries them). Deleting a tool object marks the dependent ops failed (reason `toolMissing`); undo restores both.
5. **Closed-solid guard.** Booleans, offset and hollow need valid Manifold input. The source/tool mesh is converted with `toManifold`; on failure the op fails with `stack.error.notSolid` naming the object and the health summary of `39` (`openEdges`, …), with an action link to repair once `42` exists (until then the message is plain).
6. **Combine command.** For objects A, B, C selected with A active: Union → A gets `boolean(union, [B, C])`; Subtract → `boolean(subtract, [B, C])`; Intersect likewise. B and C become tools: hidden by default, `toolOf` shown in the outliner. This is one history step. The reverse (Release tool) removes the op and shows the tool again.
7. **Tools in the view and export.** An object that is a tool of an enabled op is a *tool object*: if visible it is drawn as a ghost (translucent, the existing tool colour tokens, no outline pick priority), otherwise hidden. Export skips tool objects unless they are explicitly selected with "Selection only". The 3D measurements bar uses the result bodies only.
8. **Mirror.** `mirror {plane: 'XY'|'YZ'|'XZ' (object local), origin: 'object'|'bbox'|number, merge}`: `merge` unions the body with its mirror image (default), otherwise both shells are kept in one solid (disjoint union); Manifold's `mirror(normal)` plus a translation.
9. **Patterns.** `linearPattern {axis, count (2–200), spacing | total, symmetric, merge}` and `circularPattern {axis, count, angle (full or partial), merge}` copy the body so far with Manifold transforms; `merge` unions, otherwise disjoint union (with an overlap warning). Axis: `x|y|z` of the object, a sketch line (plane frame), or two points later. Counts above a limit (triangles × count > 6 M) are refused. Pattern of the body is the only mode; patterns of a single earlier feature are out of scope, and hole arrays are done by patterning the *tool* object and subtracting it.
10. **Offset and Hollow.** `offset {distance}` (positive grows, negative shrinks) and `hollow {wall, drains: [{point, normal, diameter}] }`. Manifold Minkowski operations with a sphere approximation: the sphere is built as a polyhedron whose *circumscribed* radius is the requested distance for the minimum wall guarantee (shrink) and *inscribed* for the maximum size guarantee (grow), segment count from the tolerance (16–48). Hollow = `body − offset(body, −wall)` with drains as cylinders from the cavity to the outside along the given normal; a body that cannot hold a cavity of that wall (empty offset) fails with `stack.error.wallTooThick`. The spike (1.1) measures Minkowski time and robustness on real meshes; the limit on triangles and the fallback message are set from its results.
11. **Apply and Flatten.** *Apply* replaces ops[0..k] with the baked mesh as a new `mesh` source only if k is the first op; applying an op in the middle is not allowed (the UI offers Flatten). *Flatten* evaluates the stack, stores the mesh as a new immutable mesh (content hash) and resets the source to `mesh` with an empty stack; keeps an undo step. Flatten is the bridge to destructive mesh edits of `42`, which operate on mesh sources.
12. **Migration.** `migrateScene` turns `source: {kind:'feature', features}` of `40` into `source: {kind:'empty'}` with `stack: features.map(toOp)`; unknown op kinds are kept but disabled and shown as "unknown operation" so newer files do not lose data.
13. **UI.** The properties panel of an object shows a "Stack" section: a list of rows (drag handle, eye, kind icon, summary, failed mark) with rollback marker between rows, an Add menu (Boolean, Mirror, Pattern, Offset, Hollow, Extrude, Revolve), edit-in-place forms per op using `LengthField`/`NumberField`, and Flatten. The outliner shows chips for the number of ops and the tool relation. No artboard exists: built from `ui/` components and tokens.
14. **History.** Stack edits go through `dispatch(label, patch, mergeKey)`; drag reordering is one step; typing merges.

## Risks / Trade-offs

- **Minkowski cost.** Offsetting a non-convex 500k-triangle mesh with a sphere may take many seconds or memory. Mitigations: triangle limit with a message, progress/abort in the log, coarse sphere in the preview, cached result. The spike decides the limits.
- **Cache memory.** Many cached solids on large meshes; LRU by triangles plus explicit `delete()`; tested for leaks (counting live manifolds).
- **Order sensitivity.** Reordering ops can make later ones fail; the UI shows the failed row and keeps the state valid instead of blocking.
- **Floating point.** Booleans of nearly coplanar faces produce slivers; tools are not auto-grown (a user-facing "overlap" is the user's choice), and the Offset op exists to adjust.
- **Scope creep toward a full CAD timeline.** Parameters, formulas and groups are left out deliberately.

## Open Questions

- Spike 1.1: Minkowski limits and sphere resolution.
- Whether Mirror should default to `merge` for symmetric printing parts (assumed yes).
- Whether the rollback marker should also pause the display of dependent objects (assumed no: dependants see the full result).
32 changes: 32 additions & 0 deletions openspec/changes/41-editor-op-stack/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Proposal

## Why

After `40` a body built from sketches keeps a short list of features, and `39` objects are independent of each other. Real parts need operations *between* bodies and *on* bodies: subtract a hole pattern, add a boss, mirror a half, hollow a model to save resin or filament, grow a part by 0.2 mm for clearance. Desktop tools solve this with a non-destructive history (Fusion's timeline, Blender's modifier stack) where each operation stays editable. `openspec/EDITOR.md` (section 2) fixes the model: objects with a source and a stack of operations evaluated in the worker. This change turns the feature list of `40` into that general stack and adds the first body operations.

## What Changes

- **Operation stack.** Every object gets an ordered `stack` of operations applied to its source (imported mesh, primitive, or a sketch-built base). Each operation can be edited, reordered by drag, suppressed (eye), deleted, applied (baked into the result) or the whole stack flattened. A rollback marker shows the object as it was after a chosen step. The sketch features Extrude and Revolve of `40` become stack operations; stored scenes of `40` are migrated automatically.
- **Booleans between objects.** Union, Subtract and Intersect with one or more *tool* objects, in the target's local space. Tools stay in the scene (hidden or shown as translucent ghosts) and keep their own transform and stack, so moving the tool moves the cut. A **Combine** command (select several objects → Union / Subtract / Intersect) creates the operation in one step. Requires closed solids; open meshes are refused with the reason and a pointer to repair (`42`).
- **Mirror** (about a plane through the object origin, a bounding-box centre plane or a plane along an axis; merged or kept as separate shells).
- **Linear and circular patterns** of the body so far, with count, spacing or total length, axis from a world axis, a sketch line or the object origin. A pattern on a *tool* object followed by a Subtract gives hole arrays.
- **Offset** (grow or shrink by a distance) for clearances and wall changes, and **Hollow** (shell with a wall thickness and optional drain holes), both built with Minkowski offsets of Manifold.
- **Dependency graph.** Objects can depend on other objects as tools; cycles are refused; a deleted tool turns the operation into a failed one without losing the rest; the worker evaluates the graph in order with a per-prefix cache so editing step *k* recomputes only steps *k…n*.
- **Display of tools and export.** Objects used as tools are drawn as ghosts when visible, are not exported unless selected explicitly, and the outliner shows the relation (tool icon, "used by").

## Capabilities

### New Capabilities
- `editor-op-stack`: stack model, editing, reordering, suppression, rollback, apply and flatten, migration of `40` features, dependency graph and evaluation.
- `editor-booleans`: Union, Subtract, Intersect, Combine, tools and ghosts.
- `editor-body-ops`: Mirror, patterns, Offset, Hollow.

### Modified Capabilities

## Impact

- `src/editor/stack.ts` (types, validation, dependency graph, migration `migrateStack`), `geometry/features/ops/{boolean,mirror,pattern,offset,hollow}.ts`, `geometry/features/evaluate.ts` generalised (prefix cache, graph order), `geometry/model.ts` (`generate('editor')` takes the whole graph), outliner and properties get the stack panel (`features/editor/stack/*`), tool ghosts in `Viewport` (existing tool colour tokens), strings in en and ru, tests.
- Scenes saved by `39`/`40` load unchanged; `source: {kind:'feature'}` is rewritten to an empty source plus stack entries by `migrateScene`.
- Out of scope: plane cut, split, connectors, holes, text on faces (`43`); face and edge based operations, fillet and chamfer of body edges, push/pull (`44`); mesh repair, decimate, smooth (`42`); project files (`45`); parameters with formulas; suppress by group/configuration; feature patterns of a single earlier operation (only whole-body patterns and tool patterns); true timeline UI as a separate bottom bar (the stack panel with a rollback marker covers it).
- Version 1.12.0 (minor: a new capability).
- Depends on: `40-editor-sketch`.
62 changes: 62 additions & 0 deletions openspec/changes/41-editor-op-stack/specs/editor-body-ops/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Spec Delta

## Purpose
Defines mirror, patterns, offset and hollow as stack operations.

## ADDED Requirements

### Requirement: Mirror
The Editor SHALL mirror the body about a plane through the object origin, the bounding-box centre or a given offset, merged into one solid or as separate shells.

#### Scenario: Merge
- **WHEN** a half of a symmetric part touching the plane is mirrored with merge on
- **THEN** the result is one solid whose volume is twice the half

#### Scenario: Separate shells
- **WHEN** merge is off
- **THEN** both shells are in the object and no union is computed

### Requirement: Patterns
The Editor SHALL repeat the body linearly (count, spacing or total length, optionally symmetric about the origin) or circularly (count, axis, full or partial angle), merged or as separate shells.

#### Scenario: Linear
- **WHEN** a 10 mm cube is repeated 4 times with spacing 20 mm along X
- **THEN** the bounding box is 70 mm long and the volume is 4000 mm³

#### Scenario: Circular
- **WHEN** a peg is repeated 6 times over 360° about Z
- **THEN** the pegs are 60° apart

#### Scenario: Hole array
- **WHEN** a cylinder tool has a linear pattern and is subtracted from a plate
- **THEN** the plate has one hole per copy

#### Scenario: Too many
- **WHEN** the repeat would exceed the triangle limit
- **THEN** the operation fails with the limit and the reached count

### Requirement: Offset
The Editor SHALL grow or shrink the body by a distance in millimetres, guaranteeing that a shrink does not leave less than the requested wall and a grow does not exceed the requested size.

#### Scenario: Grow for clearance
- **WHEN** a 20 mm cube is offset by +0.2 mm
- **THEN** its bounding box is 20.4 mm on each axis

#### Scenario: Shrink to nothing
- **WHEN** the shrink distance removes the whole body
- **THEN** the operation fails with a message that the distance is too large

### Requirement: Hollow
The Editor SHALL hollow a body with a wall thickness and optional drain holes with a position, direction and diameter.

#### Scenario: Hollow cube
- **WHEN** a 30 mm cube is hollowed with a 2 mm wall
- **THEN** the cavity is a 26 mm cube and the volume is 27000 − 17576 mm³ within tolerance

#### Scenario: Drain hole
- **WHEN** a drain of 3 mm is added on the bottom face
- **THEN** the cavity is open to the outside through that hole

#### Scenario: Wall too thick
- **WHEN** the wall is larger than half the thinnest part
- **THEN** the operation fails with the largest allowed wall
54 changes: 54 additions & 0 deletions openspec/changes/41-editor-op-stack/specs/editor-booleans/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Spec Delta

## Purpose
Defines boolean operations between objects, tool objects and the Combine command.

## ADDED Requirements

### Requirement: Boolean operations
The Editor SHALL provide Union, Subtract and Intersect of an object with one or more tool objects, evaluated in the target's local space with the tools' own stacks and transforms applied.

#### Scenario: Subtract
- **WHEN** a cylinder tool is subtracted from a box
- **THEN** the box has a hole where the cylinder was and the volume decreases by the overlap volume

#### Scenario: Move the tool
- **WHEN** the user moves the tool cylinder afterwards
- **THEN** the hole moves with it

#### Scenario: Transformed target
- **WHEN** the target box is rotated 30° and the tool is not
- **THEN** the cut stays in the same place in the scene

#### Scenario: Intersect
- **WHEN** two overlapping boxes are intersected
- **THEN** the result is their common volume

### Requirement: Closed solids only
Boolean operations SHALL require closed solid operands and SHALL explain what is wrong otherwise.

#### Scenario: Open mesh
- **WHEN** a tool mesh has open edges
- **THEN** the operation fails with the object name and the number of open edges

### Requirement: Combine command
The Editor SHALL provide Combine for the selection (Union, Subtract, Intersect) using the active object as the target, as one history step.

#### Scenario: Combine three
- **WHEN** three objects are selected with the first active and Union is chosen
- **THEN** the first object gets a Union operation with the other two as tools, and they are hidden as tools

#### Scenario: Release
- **WHEN** the user releases a tool
- **THEN** the operation is removed and the tool object is visible again at its place

### Requirement: Tool display and export
Tool objects SHALL be drawn as translucent ghosts when visible, marked in the outliner, and left out of exports unless explicitly selected.

#### Scenario: Export
- **WHEN** the scene is exported with all visible objects
- **THEN** the result bodies are exported and the tool objects are not

#### Scenario: Ghost
- **WHEN** a tool object is visible
- **THEN** it is translucent and clicking through it selects the body behind it first
Loading
Loading