From 7cddf31bcb022af73f8084a6b4ab6fd6e23eaad1 Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Wed, 9 Sep 2026 13:50:08 +0200 Subject: [PATCH] Add named parameters, UI scale setting, OPTIONS/OP dispatch, and help manual MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Named parameters (docs/named_parameters_design.md, stages 3-4): - ParameterTable (src/scene/named_parameters.rs): a document-wide table of named formulas, each resolved through a shared expression evaluator with cross-parameter references, cycle detection, and division-by-zero/parse error reporting. SketchConstraint::driving_param becomes DrivingValue (Literal | Named) instead of a bare f64, resolved at solve time by sketch_solve::build_constraint via Scene::named_parameters. - Persistence (src/scene/named_parameters_persist.rs): same lazy XRecord model sketch_persist.rs already established, synced around open/save. Bumps the sketch-constraint XRecord's own format version (1 -> 2) since DrivingValue's shape doesn't line up with a version-1 blob's bytes. - PARAMETERS command and its editor (src/ui/window/named_parameters.rs): add/remove/edit rows, live validation, duplicate-name detection: Apply defines the table and re-solves every constraint scope with a named-parameter-driven constraint (command_driver.rs). Distance/Angle constraint prompts (modules/draw/constrain/value.rs) now accept either a literal or an existing parameter's name, mirroring how the constraint's own value prompt already worked. - Undo scope-down carried over from this project's own established pattern (constraint-system stage 9): the geometry an Apply moves is one undo step, but the parameter table edit itself has no undo entry yet — a documented follow-up, not a silent gap. UI scale setting (src/app/settings.rs, app/mod.rs, ui/window/options.rs): a whole-interface scale percentage (50-200%), wired directly into iced's own per-window scale_factor hook rather than per-panel font-size plumbing, so it applies immediately to every panel, dialog, and the ribbon at once. Persisted alongside the existing lineweight-display-scale setting. OPTIONS/OP command dispatch (app/commands/view.rs, app/commands/mod.rs): OPTIONS was registered for autocomplete but had no dispatch arm — the Start-tab welcome screen's own button was the only way to reach it. Found live-testing the UI-scale setting, unrelated to that feature itself. "OP" is AutoCAD's own short alias. help/: a new self-contained static user-help manual (manual.md as the authored source, tools/build_help.py splits it into the committed static HTML pages plus search index) — not yet wired into the application. 64 new/updated tests across the named-parameters and constraint-prompt changes. Co-Authored-By: Claude Sonnet 5 --- docs/named_parameters_design.md | 347 ++++++- help/3d-modeling.html | 55 ++ help/README.md | 43 + help/annotation-dimensions.html | 55 ++ help/assets/css/core.css | 190 ++++ help/assets/css/opencad.css | 24 + help/assets/images/README.md | 9 + help/assets/js/help.js | 120 +++ help/automation-plugins.html | 55 ++ help/blocks-references.html | 55 ++ help/commands-shortcuts.html | 55 ++ help/context-map.json | 45 + help/create-edit-geometry.html | 57 ++ help/files-drawings.html | 57 ++ help/getting-started.html | 64 ++ help/index.html | 61 ++ help/layers-properties.html | 55 ++ help/layouts-plotting.html | 55 ++ help/manual.md | 305 ++++++ help/parametric-constraints.html | 55 ++ help/search-index.js | 1 + help/tools/build_help.py | 256 +++++ help/tools/check_help.py | 100 ++ help/troubleshooting-recovery.html | 59 ++ help/workspace-navigation.html | 57 ++ src/app/automation.rs | 3 + src/app/command_driver.rs | 162 ++++ src/app/commands/mod.rs | 3 + src/app/commands/view.rs | 19 + src/app/mod.rs | 40 + src/app/settings.rs | 9 + src/app/update/file.rs | 9 + src/app/update/mod.rs | 43 + src/app/view/modal.rs | 15 + src/command.rs | 11 +- src/modules/draw/constrain/value.rs | 80 +- src/scene/mod.rs | 14 + src/scene/named_parameters.rs | 898 ++++++++++++++++++ src/scene/named_parameters_persist.rs | 201 ++++ src/scene/sketch_constraints.rs | 42 +- src/scene/sketch_persist.rs | 20 +- src/scene/sketch_solve.rs | 31 +- src/ui/window/mod.rs | 1 + src/ui/window/named_parameters.rs | 258 +++++ src/ui/window/options.rs | 19 + tests/sketch_constraints_solve.rs | 64 +- tests/sketch_constraints_xrecord_roundtrip.rs | 11 +- 47 files changed, 4136 insertions(+), 52 deletions(-) create mode 100644 help/3d-modeling.html create mode 100644 help/README.md create mode 100644 help/annotation-dimensions.html create mode 100644 help/assets/css/core.css create mode 100644 help/assets/css/opencad.css create mode 100644 help/assets/images/README.md create mode 100644 help/assets/js/help.js create mode 100644 help/automation-plugins.html create mode 100644 help/blocks-references.html create mode 100644 help/commands-shortcuts.html create mode 100644 help/context-map.json create mode 100644 help/create-edit-geometry.html create mode 100644 help/files-drawings.html create mode 100644 help/getting-started.html create mode 100644 help/index.html create mode 100644 help/layers-properties.html create mode 100644 help/layouts-plotting.html create mode 100644 help/manual.md create mode 100644 help/parametric-constraints.html create mode 100644 help/search-index.js create mode 100644 help/tools/build_help.py create mode 100644 help/tools/check_help.py create mode 100644 help/troubleshooting-recovery.html create mode 100644 help/workspace-navigation.html create mode 100644 src/scene/named_parameters.rs create mode 100644 src/scene/named_parameters_persist.rs create mode 100644 src/ui/window/named_parameters.rs diff --git a/docs/named_parameters_design.md b/docs/named_parameters_design.md index 68a5ee72a..a654545e3 100644 --- a/docs/named_parameters_design.md +++ b/docs/named_parameters_design.md @@ -131,6 +131,347 @@ verification where the stage has new UI surface. ## 6. Progress -Nothing built yet. Update this section (not a separate file) after each -stage, matching the reporting pattern used throughout -`docs/parametric_system_design.md`'s own "Progress" section. +**Stage 1 done and tested.** `src/scene/named_parameters.rs`, new module +(registered `pub mod named_parameters;` in `src/scene/mod.rs`, alongside +`sketch_constraints`/`sketch_persist`/`sketch_solve`). Pure data + parser, +no document/UI integration yet, per this stage's own scope. + +- **`Expr`**: a small AST (`Number`, `Ref`, `Neg`, `Add`/`Sub`/`Mul`/`Div`/ + `Pow`, `Call`) plus a hand-rolled tokenizer/recursive-descent parser + (`parse`) — ordinary precedence (`+`/`-` < `*`/`/` < unary `-` < `^`, + right-associative `^`), parens, and scientific-notation numeric literals + (`1.5e-2`). **Deviation from this doc's own §2 suggestion** to check + `evalexpr`/`meval` first: hand-rolled anyway, because cycle detection + needs the set of names an expression references *before* it's evaluated + (`Expr::refs`), and owning the parse tree gives that for free — treating + evaluation as a black box the way a generic crate is normally used would + just mean re-deriving the same dependency information some other way. No + new external dependency added. +- **§5 open question 2 (expression language surface)**: resolved for v1 as + arithmetic + a small, deliberately conservative builtin function set + (`sqrt`, `abs`, `pow`, `min`, `max` — `call_builtin`), chosen as low-risk, + commonly-needed CAD primitives rather than a considered product decision + on the full surface. The grammar's `Call(String, Vec)` node means + adding more later needs no parser rewrite, matching this doc's own + call-out. Builtin names are reserved (`is_reserved_name`) so a parameter + can never collide with one — resolves part of open question 5 (naming + collisions) alongside `is_valid_name`'s identifier rule (leading + letter/underscore, then alphanumeric/underscore). +- **Cycle detection happens at `ParameterTable::set` time, not at + resolve/evaluate time** — the design doc's "must be rejected" read as + real-time rejection (matching a spreadsheet's usual UX), not a + resolve-time error. `set` walks the dependency graph from the candidate + formula's own references, through every already-stored formula, looking + for a path back to the name being defined; a reference to a name that + doesn't exist *yet* just terminates that branch rather than counting as a + cycle (order of definition doesn't matter, same as a spreadsheet formula + referencing an empty cell) — it only becomes + `ParamError::UndefinedReference` if `resolve`/`resolve_all` actually needs + that value later and it's still missing. A `resolve`-time cycle guard + exists too (`resolve_inner`'s `stack` check) but is defense-in-depth only + — unreachable through the public API, since `set` already refuses to + create one; the code comment is explicit that this is a safety net (e.g. + against a foreign/hand-edited file deserialized straight into a table + without going through `set`), not a path the design expects to hit. +- **`resolve_all` isolates a failure to just the parameters it actually + affects**, not the whole table — matching this codebase's established + don't-let-one-bad-item-take-down-everything-else pattern from the + constraint system's own dangling-reference handling. +- `ParameterTable::set` is a single upsert (define-or-redefine), not two + separate "must not exist" / "must already exist" entry points — a later UI + stage that wants add-vs-rename semantics can check `contains` itself + first; not a decision this data-layer stage needed to make. +- 17 unit tests: precedence/associativity, scientific notation, malformed + formulas, builtin arity/unknown-function errors, division by zero, name + validation, literal and chained-reference resolution, undefined-reference + reporting, direct and indirect (closes-later) cycle rejection, redefining + a parameter to *break* a would-be cycle, `resolve_all` isolation, + remove-then-resolve leaving a dependent gracefully undefined (not + panicking), upsert semantics, insertion-order stability across + redefinitions, and a `bincode` round-trip (both `Expr` and `Parameter` + derive `Serialize`/`Deserialize` already, ready for stage 2's XRecord + persistence — not exercised through the real save/load path yet, just + in-memory `bincode::serialize`/`deserialize`, the same distinction stage 4 + of the sketch-constraint project drew between its own in-memory-agreement + test and its real-save-path tests). + +Full regression check: `cargo test --workspace`, 686 passed / 1 failed (the +same pre-existing, unrelated Turkish-glyph-fallback font test noted +throughout `docs/parametric_system_design.md`) / 13 ignored. `cargo clippy +--lib` shows no new warnings in `named_parameters.rs` (pre-existing warnings +elsewhere untouched). + +**Still open after stage 1** (per §5): open question 1 (solid-history +scope), 3 (undo granularity), and 4 (per-document vs per-block-record table +scope) — none needed answering yet since nothing here touches the document +model or undo. Open question 2 and part of 5 got a working v1 answer above, +not a final product ruling. + +**Stage 2 done and tested.** `src/scene/named_parameters_persist.rs`, new +module (registered `mod named_parameters_persist;` in `src/scene/mod.rs`), +following `sketch_persist.rs`'s exact precedent: the "lazy" model, a +version-prefixed `bincode` blob in one `Chunk`-typed `XRecordEntry`, +materialized right before save and read back right after load. Also went +ahead with the "wire it into open/save" half of stage 2 (not just the +spike) since it's the same four call sites `sketch_persist` already touches +and there was no reason to leave it half-done: +`OpenCADStudio::prepare_native_save` and the wasm save path +(`src/app/update/file.rs`), `OpenCADStudio::on_file_opened`'s native/web +open path (`src/app/update/file.rs:1393`), and the automation `"open"`/ +`"new"` ops (`src/app/automation.rs`) — `named_parameters` lives on `Scene` +(`Scene::named_parameters: named_parameters::ParameterTable`, alongside +`sketch_constraints`, for the same co-location-with-`document` reason +stage 3 of the constraint-solver project gave), with `Scene:: +named_parameters`/`named_parameters_mut` accessors. + +- **A real round-trip gap found and worked around, not patched**: the + obvious owner handle for a document-wide (not per-scope) XRecord is + `document.header.named_objects_dict_handle` — the vendored `cadcodec` + source's own `extension_dictionary_handle` explicitly resolves an + extension dictionary for a `Dictionary`-type owner, and that crate's own + test suite (`tests/issue51.rs`) uses exactly that handle as an XRecord + owner. In practice this round-tripped through DWG but **silently dropped + the XRecord through DXF** — caught by this stage's own real-bytes save/ + load tests, not assumed to work from reading the source alone (the same + discipline `sketch_persist`'s stage 4 applied). Root cause, traced into + the vendored source: `CadDocument::ensure_xrecord` only writes the + *owner's own* `xdictionary_handle` field when `get_entity_mut(owner)` + succeeds (i.e. `owner` is a real "entity"); a `Dictionary` owner like the + root NOD isn't one, so `ensure_xrecord` falls back to a runtime-only side + map (`xdic_by_handle`) instead. The DWG writer consults that side map; + grepping `io/dxf/writer/` for it turns up nothing — the DXF writer only + ever reads the owner's own `xdictionary_handle` field, never the side map, + so a Dictionary-owned extension dictionary is simply invisible to a DXF + save. This is a gap in the vendored crate for this specific owner-type + combination. Per this project's own stated preference (§1.4 of the sibling + design doc: "requires zero changes to the external `acadrust`/`cadkernel` + crates") the fix taken was to pick a different owner rather than patch + the dependency: **`document.header.model_space_block_handle`** — a real + `BlockRecord` entity, the exact anchor `sketch_persist`'s own + `SketchScope::ModelSpace` case already proved clean through both formats. + Not a perfect semantic fit (a parameter table has nothing to do with model + space specifically) but it's guaranteed to exist in every `CadDocument` + and is a real entity, which is the property that actually matters. Worth + revisiting if a later need for a true document-root-anchored XRecord comes + up — the fix would be in `cadcodec` itself (teach `ensure_xrecord` to also + set a `Dictionary`/`Layout`/`XRecord`/`PlotSettings`/`VisualStyle`/ + `Material`/`ProxyObject` owner's own `xdictionary_handle` field, not just + entities'), not something to route around a second time. +- 5 new tests (`named_parameters_persist.rs`): in-memory materialize+reload + agreement, real `save_to_bytes`/`load_bytes` round trips for both DXF and + DWG (the two that caught the gap above), an empty table not persisting, + and format-version rejection — same shape as `sketch_persist`'s own test + suite. + +Full regression check: `cargo test --workspace`, 691 passed / 1 failed (same +pre-existing, unrelated font test) / 13 ignored. `cargo clippy --lib` shows +no new warnings in either `named_parameters.rs` or +`named_parameters_persist.rs`. + +**Stage 3 done and tested.** `SketchConstraint::driving_param` is now +`Option` (`sketch_constraints.rs`) instead +of `Option` — exactly this doc's own §2 suggested shape: + +```rust +pub enum DrivingValue { + Literal(f64), + Named(String), +} +``` + +with `DrivingValue::resolve(&self, table: &ParameterTable) -> Result` (`named_parameters.rs`) doing the lookup — a literal resolves +to itself, a named reference resolves through the table. + +- **Solve-side wiring**: `sketch_solve.rs`'s `build_constraint`/`solve_scope` + now take a `&ParameterTable` (threaded from `Scene::named_parameters` at + both call sites, `refresh_sketch_constraints` and + `solve_sketch_constraints_preview`) and resolve `driving_param` at the + three existing call sites (Distance/Angle/Radius) via `c.driving_param. + as_ref()?.resolve(params).ok()?` — a resolve failure (an undefined + reference, a division by zero; a cycle should be unreachable since `set` + already refuses to create one) is treated exactly like every other + unbuildable-constraint case `build_constraint`'s own doc comment already + documents: the constraint is skipped for this solve, not an error. +- **Judgment call, not dictated by this doc**: how a resolve failure should + surface wasn't an open question this doc raised explicitly, so the + existing precedent decided it — matching a dangling-reference / unbuildable + constraint rather than inventing a new "parameter error" state. A richer + surfacing (e.g. a distinct glyph color, a status-bar warning naming the + broken reference) is possible future work once stage 4's UI exists to + make it discoverable, not something this data/solver-layer stage needed + to add speculatively. +- **`CmdResult::AddSketchConstraint` intentionally left as `Option`** + (`src/command.rs`) rather than also switching it to `DrivingValue` — no UI + can author a `Named` reference yet (that's stage 4's picker), so every + existing call site (`DistanceConstraintCommand`/`AngleConstraintCommand` + in `src/modules/draw/constrain/value.rs`, the Horizontal/Vertical/etc. + dispatch in `src/app/commands/draw.rs`) needed no changes at all; the one + conversion point is `command_driver.rs`'s `CmdResult::AddSketchConstraint` + handler, which now wraps the literal into `DrivingValue::Literal` right + before calling `SketchConstraintSet::add`. Keeps this stage's diff to the + data/solver layer plus one narrow seam, not a ripple through every typed- + value UI command. +- **`glyph_label`** (`sketch_constraints.rs`) now matches on `&DrivingValue` + instead of a bare `f64`: a literal renders exactly as before, a named + reference renders its name (e.g. "↔ hole_dia") rather than a resolved + number — showing the resolved value too would need threading + `ParameterTable` into the glyph-render call site + (`src/ui/overlay.rs`), which is UI-layer work for stage 4, not this one; + this arm exists only so the match stays exhaustive ahead of that UI (no + command can produce a `Named` driving_param yet, so it isn't reachable + today). +- **`sketch_persist::FORMAT_VERSION` bumped 1 → 2**: `SketchConstraint`'s + serialized shape changed (a bare `f64` became an enum with its own + discriminant), so a version-1 blob's bytes no longer line up with the + current struct — exactly the "detected and gracefully skipped" case that + version byte exists for (§5.2's own call-out: no migration path exists, + bumping the version is the correct response, not a substitute for one). A + previously-saved file's sketch constraints will not reload after this + change; acceptable for personal-use, pre-release local work, not something + a real migration was owed here. +- 2 new tests (`tests/sketch_constraints_solve.rs`): a `Distance` + constraint driven by a named reference solves correctly and re-solves + when the parameter's own value is redefined; a `Distance` constraint + referencing an undefined name is skipped without panicking, leaving the + triggering edit untouched (the same contract the existing "unrelated edit + doesn't trigger a resolve" test already established for dangling + refs). Every existing `driving_param`-touching test (in + `sketch_constraints.rs`, `sketch_persist.rs`, + `tests/sketch_constraints_xrecord_roundtrip.rs`, + `tests/sketch_constraints_solve.rs`) updated to construct + `DrivingValue::Literal(...)` instead of a bare float; all still pass. +- **Testing-methodology note, not a code defect**: discovered while + re-verifying this stage that plain `cargo test --workspace` stops after + the first *crate's* test failures and silently skips the remaining + integration-test binaries in the workspace — the pre-existing, unrelated + font-fallback failure was silently truncating every regression check this + whole project has run with a bare `cargo test --workspace`. Re-ran with + `--no-fail-fast` for this stage's check and confirmed every binary (not + just the lib crate) passes except that one known-unrelated test. Worth + using `--no-fail-fast` for any future full-workspace check in this repo. + +Full regression check: `cargo test --workspace --no-fail-fast`, every test +binary passing except the same pre-existing, unrelated font test noted +throughout this document and its sibling. `cargo clippy --lib --tests` +shows no new warnings on any line this stage touched (the two warnings that +do appear in `sketch_solve.rs`/`scene/mod.rs` predate this stage and are on +code this stage didn't modify). + +**Stage 4 done and tested (not live-verified in the running app — see the +note below).** + +- **The parameters panel** (`src/ui/window/named_parameters.rs`, new): an + in-canvas modal opened by a new `PARAMETERS` command (AutoCAD's own name + for the equivalent feature — `src/app/commands/view.rs`, dispatched + alongside `ALIASEDIT`; registered in the autocomplete/MCP command list + from the start, unlike the six pre-existing constraint commands + `docs/backlog.md` flags as missing that registration). Built by mirroring + `alias_editor.rs`'s buffered-rows pattern as closely as possible rather + than inventing new UI conventions: rows are `(name, formula)`, edited in a + working buffer (`OpenCADStudio::named_parameter_editor_rows`, + `ModalKind::NamedParameters`) and only committed to `Scene:: + named_parameters` on Apply — a better fit here than for aliases, even: + `ParameterTable::set` validates (and can reject) a formula immediately, so + writing through to the live table on every keystroke would fight a user + mid-type. +- **Live preview column, beyond what the alias editor has**: each row also + shows its resolved value or its error, recomputed from a scratch + `ParameterTable` built from the *whole buffer's current text* on every + render (`preview`, private to the UI module, unit-tested directly) — not + cached, matching the "cheap enough to rebuild from scratch" philosophy + `ParameterTable`/`sketch_solve.rs` already use throughout. This means a + cross-row problem (a forward reference, a cycle that would close between + two draft rows) shows up immediately, before Apply, not just after a + failed commit. +- **A real gap found while building the preview, not by design**: two rows + typed with the same name would otherwise silently collapse — `set` + upserts by name, so the buffer's later row would just overwrite the + earlier one with no indication anything was discarded. Added + `duplicate_name_rows` (checked first, before any `set` call, by both the + preview and the real Apply) to flag every row sharing a duplicated name as + its own error instead of silently picking one arbitrarily. +- **The other half of stage 4 — "a way to pick a named parameter instead of + a literal"**: resolved as letting the *same* value prompt accept either, + rather than building a separate picker UI. `DistanceConstraintCommand`/ + `AngleConstraintCommand` (`src/modules/draw/constrain/value.rs`) now take + a one-time snapshot of the active tab's parameter names at construction + (`known_param_names` — the same `default_value`-is-a-one-time-snapshot + precedent these commands already relied on, since `on_text_input` has no + document access); `parse_driving_value` tries a numeric literal first, + falls back to an exact match against that snapshot, and returns neither if + the typed token is unrecognized (the prompt's existing "invalid input" + behavior, unchanged). This mirrors how AutoCAD's own dimensional- + constraint prompts already accept an expression in place of a bare value, + and needed no new interaction design. `CmdResult::AddSketchConstraint`'s + `driving_param` (left as a bare `Option` in stage 3, deliberately, + exactly so this stage could revisit it) is now `Option`. +- **Apply re-solves what it affects**: because editing a named parameter is + the entire point of the feature ("editing the named value ripples to + every constraint that references it," §1), Apply doesn't just rebuild the + table — it also gathers every entity handle touched by an enabled + constraint whose `driving_param` is `DrivingValue::Named` (across every + scope; the whole table changed, not one isolated value, so there's no + cheaper "which handles actually changed" test worth doing) and runs them + through the same `begin_undo`/`bump_entities`/`commit_undo_delta` bracket + `resolve_one_sketch_conflict` already established + (`OpenCADStudio::apply_named_parameter_editor_rows`, `command_driver.rs`). +- **Judgment call on undo granularity (§5 open question 3), not fully + resolved**: the geometry Apply moves is one undo step (bracketed as + above), matching how every other solve-triggering action in this codebase + behaves. The parameter *table* edit itself has no undo entry of its own + yet — that would need a new `HistorySnapshot` variant mirroring + `SketchConstraints` (the sibling project's own stage 9), which this pass + didn't build. Documented, not silently dropped, exactly like stage 9's own + "two presses, not one" scope-down: undoing after an Apply undoes the + geometry it moved, not the parameter values. +- 15 new tests: 3 for `parse_driving_value` (literal-first, name match, + unrecognized token), 6 for the panel's `preview` (cross-row reference, + forward reference regardless of buffer order, a cycle flagged on the row + that closes it, a blank row left unflagged, a malformed formula, the + duplicate-name fix), and 6 integration tests in `command_driver.rs` + (applying rows defines the parameter *and* re-solves a constraint that + already referenced it by name — including re-applying after redefining the + parameter; a malformed row is reported as an error and doesn't block a + good row alongside it; the duplicate-name fix exercised end to end through + `apply_named_parameter_editor_rows`, not just the UI-layer preview). +- **Testing-methodology note carried over from stage 3**: verified with + `cargo test --workspace --no-fail-fast` throughout this stage too, for the + same reason noted there. + +Full regression check: `cargo test --workspace --no-fail-fast`, 703 tests +passing, only the same pre-existing, unrelated font-fallback failure noted +throughout this document and its sibling. `cargo clippy --lib --tests` shows +no new warnings on any line this stage touched. + +**Not live-verified in the running app this pass** — a real gap, not a +formality skipped by habit: this is genuinely new UI surface (a whole modal +panel, plus a new branch in an existing text-input prompt), and static +review/unit tests can't catch every real-app integration issue (layout, +widget-API misuse that only manifests at runtime, focus/keyboard handling). +Per `docs/backlog.md`'s own live-testing notes, exercising it for real needs +a release build swapped into a scratch copy of the installed app bundle +(`open_application`/`request_access` always resolve to the `/Applications` +copy, and the debug binary doesn't reliably present a controllable window in +this environment) — a real time investment this pass didn't spend, flagged +explicitly rather than silently assumed to work. First things to check in a +future live-testing pass: the panel actually opens via `PARAMETERS`, the +value column updates live while typing, the duplicate/cycle/malformed- +formula error text renders legibly, Apply visibly moves geometry for a +constraint driven by a named parameter, and typing a parameter's name at a +`DCONSTRAINT`/`ACONSTRAINT` prompt is actually accepted. + +## Follow-up work not yet done + +- **Solid-history `PROP_*` values** (§2's own flagged scope decision, open + question 1) — named parameters currently only reach `SketchConstraint:: + driving_param` (Distance/Angle/Radius), not `solid_history.rs`'s box + length/cylinder radius/etc. Not started; a decision to make, not assumed. +- **Constraint-table-edit undo** (open question 3, only partially resolved — + see above). +- **Reserved-word/name-collision UX polish**: `ParameterTable::set` already + rejects an invalid or builtin-colliding name (stage 1); the panel surfaces + that rejection as the row's error text via `preview`/Apply, but there's no + live per-keystroke nudge beyond that (e.g. graying out `Apply` while any + row has an error) — a small, optional polish, not a functional gap. +- **Live UI verification**, per the note above. diff --git a/help/3d-modeling.html b/help/3d-modeling.html new file mode 100644 index 000000000..daa1f7702 --- /dev/null +++ b/help/3d-modeling.html @@ -0,0 +1,55 @@ + + + + + + + 3D Modeling and Visualization — OpenCAD Studio Help + + + + + + +
+ + OpenCAD Studio Help +
+ + + + +
+
+ +
+
+

OpenCAD Studio User Manual

+

3D Modeling and Visualization

+

Solids and surfaces

+

OpenCAD Studio can create and display solid primitives and modeled features such as extrusions and revolutions. Imported ACIS-backed bodies are tessellated for display while exact body data is retained where supported.

+

Visual styles and lighting

+

Wireframe, shaded, hidden-line, materials, lights, and shadows affect display, not the underlying object geometry. Switch to wireframe when diagnosing missing faces or material problems.

+

Tessellation quality

+

Curves and analytic surfaces are converted into display geometry according to view and tolerance settings. A coarse display does not necessarily mean the stored curve or solid is coarse.

+ + +
+
+ + diff --git a/help/README.md b/help/README.md new file mode 100644 index 000000000..236cce877 --- /dev/null +++ b/help/README.md @@ -0,0 +1,43 @@ +# OpenCAD Studio Help + +This directory is a self-contained, static user-help system. It is intentionally +not wired into the application yet. + +## Authoring model + +- `manual.md` is the single source of truth for user-facing prose. +- `tools/build_help.py` splits the level-two chapters into static HTML pages. +- `assets/css/` and `assets/js/` provide the shared responsive presentation, + navigation, dark mode, print styling, and local search. +- `context-map.json` reserves stable help targets for future command/dialog + integration without changing application source now. +- `assets/images/` is for screenshots and workflow illustrations referenced by + `manual.md`. + +Generated HTML and `search-index.js` are committed so the manual works directly +from disk without a web server. Do not edit generated pages by hand. + +## Build and validate + +Requirements: Python 3 and Pandoc. + +```sh +python3 help/tools/build_help.py +python3 help/tools/build_help.py --check +python3 help/tools/check_help.py +``` + +Open `help/index.html` in a browser to review the generated manual. When future +application work is safe, package the complete `help/` directory and connect the +Help menu or contextual controls to the URLs in `context-map.json`. + +## Content rules + +1. Keep level-two headings unique; each one is a chapter boundary. +2. Keep level-three headings stable because their generated IDs are public help + targets. +3. Describe observable behavior, not implementation details. +4. Use task-first procedures and state prerequisites before numbered steps. +5. Mark safety, compatibility, data-loss, and known limitations explicitly. +6. Regenerate and run both checks before committing documentation changes. + diff --git a/help/annotation-dimensions.html b/help/annotation-dimensions.html new file mode 100644 index 000000000..d1f794516 --- /dev/null +++ b/help/annotation-dimensions.html @@ -0,0 +1,55 @@ + + + + + + + Annotation and Dimensions — OpenCAD Studio Help + + + + + + +
+ + OpenCAD Studio Help +
+ + + + +
+
+ +
+
+

OpenCAD Studio User Manual

+

Annotation and Dimensions

+

Text and annotation

+

Single-line text, multiline text, leaders, multileaders, tables, tolerances, center marks, and centerlines communicate design intent. Missing fonts can change layout or substitute glyphs, especially in the web build.

+

Dimensions

+

Dimensions combine measured geometry with a dimension style. Associative dimensions update when their source geometry changes; non-associative or partially associated dimensions require manual verification.

+

Annotation scale

+

Annotative objects can display differently at different viewport scales. Confirm the active annotation scale before creating annotations and inspect all paper-space viewports before plotting.

+ + +
+
+ + diff --git a/help/assets/css/core.css b/help/assets/css/core.css new file mode 100644 index 000000000..f1e77ce0c --- /dev/null +++ b/help/assets/css/core.css @@ -0,0 +1,190 @@ +:root { + color-scheme: light dark; + --accent: #1677d2; + --accent-strong: #075cae; + --accent-soft: #eaf4ff; + --canvas: #ffffff; + --chrome: rgba(20, 27, 37, 0.96); + --line: #d7dee7; + --muted: #617083; + --sidebar: #f4f7fa; + --text: #16202c; + --warning: #fff4cc; + --danger: #a62b2b; + --sidebar-width: 286px; + --topbar-height: 62px; +} + +* { box-sizing: border-box; } +html { scroll-padding-top: calc(var(--topbar-height) + 20px); } + +body { + margin: 0; + background: var(--canvas); + color: var(--text); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-size: 16px; + line-height: 1.62; +} + +a { color: var(--accent-strong); text-decoration-thickness: .08em; text-underline-offset: .16em; } +a:hover { color: var(--accent); } +a:focus-visible, button:focus-visible, input:focus-visible { + outline: 3px solid color-mix(in srgb, var(--accent) 45%, transparent); + outline-offset: 2px; +} + +.topbar { + position: fixed; + inset: 0 0 auto; + z-index: 30; + height: var(--topbar-height); + display: flex; + align-items: center; + gap: 14px; + padding: 0 18px; + background: var(--chrome); + border-bottom: 1px solid rgba(255, 255, 255, .13); + backdrop-filter: blur(16px); +} + +.brand { min-width: 190px; color: #fff; font-size: 18px; font-weight: 720; text-decoration: none; } +.brand:hover { color: #fff; } +.nav-toggle { + width: 38px; + height: 38px; + border: 0; + border-radius: 8px; + background: transparent; + color: #fff; + cursor: pointer; + font-size: 22px; +} +.nav-toggle:hover { background: rgba(255, 255, 255, .12); } + +.search-shell { position: relative; width: min(520px, 55vw); margin-left: auto; } +.help-search { + width: 100%; + height: 38px; + padding: 0 42px 0 13px; + border: 1px solid rgba(255, 255, 255, .24); + border-radius: 9px; + background: rgba(255, 255, 255, .11); + color: #fff; + font: inherit; +} +.help-search::placeholder { color: rgba(255, 255, 255, .68); } +.search-key { position: absolute; top: 8px; right: 10px; color: rgba(255, 255, 255, .64); font-size: 13px; } +.search-results { + position: absolute; + top: 44px; + right: 0; + width: min(620px, calc(100vw - 24px)); + max-height: min(70vh, 560px); + overflow: auto; + margin: 0; + padding: 7px; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--canvas); + box-shadow: 0 18px 50px rgba(0, 0, 0, .28); + list-style: none; +} +.search-results[hidden] { display: none; } +.search-results a { display: block; padding: 10px 11px; border-radius: 7px; color: var(--text); text-decoration: none; } +.search-results a:hover, .search-results a[aria-selected="true"] { background: var(--accent-soft); } +.search-results strong { display: block; color: var(--accent-strong); } +.search-results span { display: block; color: var(--muted); font-size: 13px; } +.search-empty { padding: 12px; color: var(--muted); } + +.sidebar { + position: fixed; + z-index: 20; + inset: var(--topbar-height) auto 0 0; + width: var(--sidebar-width); + padding: 16px 13px 36px; + overflow: auto; + background: var(--sidebar); + border-right: 1px solid var(--line); + transition: transform 180ms ease; +} +body.nav-collapsed .sidebar { transform: translateX(-100%); } +.sidebar details, .sidebar > div { margin: 3px 0; border-radius: 8px; } +.sidebar details.current-topic, .sidebar > div.current-topic { background: var(--canvas); box-shadow: 0 0 0 1px var(--line); } +.sidebar summary { padding: 7px 9px; font-weight: 680; cursor: pointer; } +.sidebar .chapter-link { display: block; padding: 7px 9px; font-weight: 680; } +.sidebar ul { margin: 0; padding: 0 9px 9px 27px; list-style: none; } +.sidebar li { margin: 2px 0; line-height: 1.35; } +.sidebar li a { display: block; padding: 3px 0; color: var(--text); font-size: 13px; text-decoration: none; } +.sidebar li a:hover { color: var(--accent); } + +#main-content { + min-height: 100vh; + margin-left: var(--sidebar-width); + padding: calc(var(--topbar-height) + 38px) 34px 70px; + transition: margin-left 180ms ease; +} +body.nav-collapsed #main-content { margin-left: 0; } +.content-wrap { max-width: 940px; margin: 0 auto; } +.page-kicker { margin: 0; color: var(--accent-strong); font-size: 12px; font-weight: 760; letter-spacing: .09em; text-transform: uppercase; } +.content-title { margin: 3px 0 30px; font-size: clamp(34px, 6vw, 52px); line-height: 1.06; letter-spacing: -.035em; } + +h2 { margin: 44px 0 17px; padding-bottom: 8px; border-bottom: 2px solid var(--accent); font-size: 23px; line-height: 1.3; } +h3 { margin: 34px 0 8px; font-size: 19px; line-height: 1.35; } +p { margin: 9px 0 16px; } +ul, ol { padding-left: 26px; } +li { margin: 5px 0; } +code, pre { font-family: ui-monospace, "SF Mono", Menlo, monospace; } +code { color: #a0440c; font-size: .91em; } +blockquote { + margin: 18px 0; + padding: 12px 15px; + border: 1px solid color-mix(in srgb, #d5a000 45%, var(--line)); + border-left: 4px solid #d5a000; + border-radius: 8px; + background: var(--warning); + color: #503f05; +} +blockquote p { margin: 0; } + +.page-navigation { display: flex; justify-content: space-between; gap: 20px; margin-top: 58px; padding-top: 18px; border-top: 1px solid var(--line); } +.page-navigation a:last-child { margin-left: auto; } +.date-footer { margin-top: 34px; color: var(--muted); font-size: 13px; text-align: center; } +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } + +@media (max-width: 760px) { + .brand { min-width: 0; font-size: 16px; } + .search-shell { width: min(48vw, 420px); } + .search-key { display: none; } + .sidebar { box-shadow: 12px 0 34px rgba(0, 0, 0, .22); } + body:not(.nav-open) .sidebar { transform: translateX(-100%); } + body.nav-open .sidebar { transform: translateX(0); } + #main-content, body.nav-collapsed #main-content { margin-left: 0; padding: calc(var(--topbar-height) + 28px) 18px 52px; } +} + +@media (prefers-color-scheme: dark) { + :root { + --accent: #66b6ff; + --accent-strong: #8bc7ff; + --accent-soft: #17344f; + --canvas: #161b22; + --chrome: rgba(14, 18, 24, .96); + --line: #374252; + --muted: #9eabba; + --sidebar: #1d242d; + --text: #eef3f8; + --warning: #423819; + } + code { color: #ffae79; } + blockquote { color: #f5e6ad; } +} + +@media print { + .topbar, .sidebar, .page-navigation { display: none !important; } + #main-content { margin: 0; padding: 0; } + body { color: #000; background: #fff; font-size: 11pt; } + a { color: inherit; } + h2, h3 { break-after: avoid; } + blockquote { break-inside: avoid; } +} + diff --git a/help/assets/css/opencad.css b/help/assets/css/opencad.css new file mode 100644 index 000000000..35f814a02 --- /dev/null +++ b/help/assets/css/opencad.css @@ -0,0 +1,24 @@ +.hero { + margin: 0 0 38px; + padding: clamp(25px, 6vw, 56px); + border: 1px solid color-mix(in srgb, var(--accent) 30%, var(--line)); + border-radius: 18px; + background: + linear-gradient(115deg, color-mix(in srgb, var(--accent-soft) 82%, var(--canvas)), var(--canvas)); +} +.hero .eyebrow { margin: 0; color: var(--accent-strong); font-size: 12px; font-weight: 760; letter-spacing: .1em; text-transform: uppercase; } +.hero h2 { margin: 8px 0 14px; padding: 0; border: 0; font-size: clamp(28px, 5vw, 44px); line-height: 1.08; letter-spacing: -.03em; } +.hero p:last-child { max-width: 720px; margin-bottom: 0; font-size: 17px; } +.chapter-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; } +.chapter-card { min-height: 130px; display: flex; flex-direction: column; gap: 7px; padding: 18px; border: 1px solid var(--line); border-radius: 10px; color: var(--text); text-decoration: none; } +.chapter-card:hover { border-color: var(--accent); background: var(--accent-soft); text-decoration: none; } +.chapter-card strong { color: var(--accent-strong); font-size: 18px; } +.chapter-card span { color: var(--muted); } +.quick-paths { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin: 18px 0 28px; } +.quick-paths a { padding: 12px; border: 1px solid var(--line); border-radius: 9px; text-align: center; text-decoration: none; } +.quick-paths a:hover { background: var(--accent-soft); } +@media (max-width: 640px) { + .chapter-grid, .quick-paths { grid-template-columns: 1fr; } + .chapter-card { min-height: 0; } +} + diff --git a/help/assets/images/README.md b/help/assets/images/README.md new file mode 100644 index 000000000..ebe08c420 --- /dev/null +++ b/help/assets/images/README.md @@ -0,0 +1,9 @@ +# Help images + +Place optimized screenshots and workflow illustrations in this directory. + +Use descriptive lowercase filenames, include meaningful alternative text in +`manual.md`, crop away unrelated desktop content, and avoid confidential drawing +names or paths. Prefer WebP or optimized PNG and keep each image at the smallest +resolution that remains legible on a high-density display. + diff --git a/help/assets/js/help.js b/help/assets/js/help.js new file mode 100644 index 000000000..336311a5c --- /dev/null +++ b/help/assets/js/help.js @@ -0,0 +1,120 @@ +(function () { + "use strict"; + + const body = document.body; + const toggle = document.querySelector(".nav-toggle"); + const sidebar = document.getElementById("help-sidebar"); + const search = document.getElementById("help-search"); + const results = document.getElementById("search-results"); + const mobile = window.matchMedia("(max-width: 760px)"); + let selected = -1; + + function syncToggle() { + const expanded = mobile.matches + ? body.classList.contains("nav-open") + : !body.classList.contains("nav-collapsed"); + toggle?.setAttribute("aria-expanded", String(expanded)); + } + + toggle?.addEventListener("click", function () { + body.classList.toggle(mobile.matches ? "nav-open" : "nav-collapsed"); + syncToggle(); + }); + + sidebar?.addEventListener("click", function (event) { + if (mobile.matches && event.target.closest("a")) { + body.classList.remove("nav-open"); + syncToggle(); + } + }); + + mobile.addEventListener("change", function () { + body.classList.remove("nav-open"); + syncToggle(); + }); + + function hideResults() { + results.hidden = true; + results.innerHTML = ""; + selected = -1; + search?.setAttribute("aria-expanded", "false"); + } + + function renderResults(query) { + const terms = query.toLocaleLowerCase().trim().split(/\s+/).filter(Boolean); + if (!terms.length) { + hideResults(); + return; + } + + const matches = (window.OCS_HELP_INDEX || []).map(function (entry) { + const title = entry.title.toLocaleLowerCase(); + const text = entry.text.toLocaleLowerCase(); + const score = terms.reduce(function (total, term) { + if (!text.includes(term) && !title.includes(term)) return -1000; + return total + (title.includes(term) ? 8 : 1); + }, 0); + return { entry: entry, score: score }; + }).filter(function (item) { return item.score >= 0; }) + .sort(function (a, b) { return b.score - a.score || a.entry.title.localeCompare(b.entry.title); }) + .slice(0, 12); + + results.innerHTML = ""; + if (!matches.length) { + const empty = document.createElement("li"); + empty.className = "search-empty"; + empty.textContent = "No matching help topics."; + results.appendChild(empty); + } else { + matches.forEach(function (item) { + const li = document.createElement("li"); + const link = document.createElement("a"); + link.href = item.entry.url; + const strong = document.createElement("strong"); + strong.textContent = item.entry.title; + const detail = document.createElement("span"); + detail.textContent = item.entry.chapter; + link.append(strong, detail); + li.appendChild(link); + results.appendChild(li); + }); + } + results.hidden = false; + search.setAttribute("aria-expanded", "true"); + selected = -1; + } + + search?.addEventListener("input", function () { renderResults(search.value); }); + search?.addEventListener("keydown", function (event) { + const links = Array.from(results.querySelectorAll("a")); + if (event.key === "Escape") { + search.value = ""; + hideResults(); + return; + } + if (!links.length || !["ArrowDown", "ArrowUp", "Enter"].includes(event.key)) return; + event.preventDefault(); + if (event.key === "Enter" && selected >= 0) { + links[selected].click(); + return; + } + if (event.key === "ArrowDown") selected = (selected + 1) % links.length; + if (event.key === "ArrowUp") selected = selected < 0 ? links.length - 1 : (selected - 1 + links.length) % links.length; + links.forEach(function (link, index) { link.setAttribute("aria-selected", String(index === selected)); }); + links[selected]?.scrollIntoView({ block: "nearest" }); + }); + + document.addEventListener("keydown", function (event) { + const shortcut = event.key === "/" || ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k"); + if (shortcut && document.activeElement !== search) { + event.preventDefault(); + search?.focus(); + } + }); + + document.addEventListener("click", function (event) { + if (!event.target.closest(".search-shell")) hideResults(); + }); + + syncToggle(); +}()); diff --git a/help/automation-plugins.html b/help/automation-plugins.html new file mode 100644 index 000000000..772d1d5c1 --- /dev/null +++ b/help/automation-plugins.html @@ -0,0 +1,55 @@ + + + + + + + Automation and Plugins — OpenCAD Studio Help + + + + + + +
+ + OpenCAD Studio Help +
+ + + + +
+
+ +
+
+

OpenCAD Studio User Manual

+

Automation and Plugins

+

Automation interface

+

The automation interface can inspect documents, run commands, query geometry, capture the viewport, and perform batches. Callers should use request IDs and document revisions to avoid repeating or applying work to stale state.

+

Plugins

+

Native plugins can contribute commands and ribbon surfaces through the plugin host. Install only trusted packages that match the host API, compiler, and CAD model compatibility requirements.

+

Native and web differences

+

The browser build uses in-memory file workflows and cannot load native plugin libraries. Some rendering, font, windowing, and parallel-processing behavior also differs from the desktop build.

+ + +
+
+ + diff --git a/help/blocks-references.html b/help/blocks-references.html new file mode 100644 index 000000000..8502f0729 --- /dev/null +++ b/help/blocks-references.html @@ -0,0 +1,55 @@ + + + + + + + Blocks and References — OpenCAD Studio Help + + + + + + +
+ + OpenCAD Studio Help +
+ + + + +
+
+ +
+
+

OpenCAD Studio User Manual

+

Blocks and References

+

Create and insert blocks

+

A block definition groups reusable drawing content; an INSERT places an instance with its own position, rotation, scale, attributes, and optional array settings. Editing a definition affects every reference to it.

+

Array inserts

+

Array inserts place multiple block instances with row and column counts and spacing. Large counts can generate substantial geometry. Validate scaled and rotated arrays when exchanging drawings with another CAD system.

+

Attributes and external references

+

Attributes attach editable values to block references. External references and underlays retain links to other files; preserve their paths when sharing or archiving a project.

+ + +
+
+ + diff --git a/help/commands-shortcuts.html b/help/commands-shortcuts.html new file mode 100644 index 000000000..8471c3caf --- /dev/null +++ b/help/commands-shortcuts.html @@ -0,0 +1,55 @@ + + + + + + + Commands and Shortcuts — OpenCAD Studio Help + + + + + + +
+ + OpenCAD Studio Help +
+ + + + +
+
+ +
+
+

OpenCAD Studio User Manual

+

Commands and Shortcuts

+

Command line

+

Enter a command name or alias, then respond to its prompts. Press Escape to cancel the active command and Return to accept an available default. Exact prompt options vary by command and current selection.

+

Discover commands

+

Use command autocomplete and the ribbon to discover available operations. Some commands are one-shot actions; others maintain state across several point, number, keyword, or object-selection inputs.

+

Shortcut conventions

+

Application and operating-system shortcuts can differ between native and web builds. Avoid assigning browser-reserved shortcuts to web-only workflows.

+ + +
+
+ + diff --git a/help/context-map.json b/help/context-map.json new file mode 100644 index 000000000..d6bf4d820 --- /dev/null +++ b/help/context-map.json @@ -0,0 +1,45 @@ +{ + "schema_version": 1, + "fallback": "index.html", + "commands": { + "NEW": "files-drawings.html#create-and-open-drawings", + "OPEN": "files-drawings.html#create-and-open-drawings", + "SAVE": "files-drawings.html#save-and-save-as", + "SAVEAS": "files-drawings.html#save-and-save-as", + "RECOVER": "files-drawings.html#recovery-and-backups", + "LINE": "create-edit-geometry.html#basic-drawing-commands", + "CIRCLE": "create-edit-geometry.html#basic-drawing-commands", + "PLINE": "create-edit-geometry.html#basic-drawing-commands", + "ERASE": "create-edit-geometry.html#modify-geometry", + "UNDO": "create-edit-geometry.html#undo-redo-and-oops", + "REDO": "create-edit-geometry.html#undo-redo-and-oops", + "OOPS": "create-edit-geometry.html#undo-redo-and-oops", + "LAYER": "layers-properties.html#layers", + "CHPROP": "layers-properties.html#object-properties", + "BLOCK": "blocks-references.html#create-and-insert-blocks", + "INSERT": "blocks-references.html#create-and-insert-blocks", + "MINSERT": "blocks-references.html#array-inserts", + "DIM": "annotation-dimensions.html#dimensions", + "PLOT": "layouts-plotting.html#page-setup-and-plot-preview", + "EXTRUDE": "3d-modeling.html#solids-and-surfaces", + "REVOLVE": "3d-modeling.html#solids-and-surfaces", + "HCONSTRAINT": "parametric-constraints.html#geometric-constraints", + "VCONSTRAINT": "parametric-constraints.html#geometric-constraints", + "PCONSTRAINT": "parametric-constraints.html#geometric-constraints", + "QCONSTRAINT": "parametric-constraints.html#geometric-constraints", + "ECONSTRAINT": "parametric-constraints.html#geometric-constraints", + "CCONSTRAINT": "parametric-constraints.html#geometric-constraints", + "TCONSTRAINT": "parametric-constraints.html#geometric-constraints" + }, + "surfaces": { + "file-open": "files-drawings.html#create-and-open-drawings", + "file-save": "files-drawings.html#save-and-save-as", + "layers": "layers-properties.html#layers", + "properties": "layers-properties.html#object-properties", + "plot": "layouts-plotting.html#page-setup-and-plot-preview", + "plugin-manager": "automation-plugins.html#plugins", + "constraint-conflicts": "parametric-constraints.html#editing-constrained-geometry", + "recovery": "troubleshooting-recovery.html#a-drawing-opens-incorrectly" + } +} + diff --git a/help/create-edit-geometry.html b/help/create-edit-geometry.html new file mode 100644 index 000000000..0e35516f7 --- /dev/null +++ b/help/create-edit-geometry.html @@ -0,0 +1,57 @@ + + + + + + + Create and Edit Geometry — OpenCAD Studio Help + + + + + + +
+ + OpenCAD Studio Help +
+ + + + +
+
+ +
+
+

OpenCAD Studio User Manual

+

Create and Edit Geometry

+

Basic drawing commands

+

Create lines, arcs, circles, ellipses, polylines, splines, points, text, hatches, and other supported entities from the ribbon or command line. Follow the active prompt: multi-step commands remain active until they complete or are cancelled.

+

Modify geometry

+

Common modification workflows include move, copy, rotate, scale, mirror, offset, trim, extend, fillet, chamfer, stretch, join, explode, and erase. Locked layers can prevent modification.

+

Grips and direct manipulation

+

Select an entity to expose its grips. Dragging a grip previews and then commits a geometric change. Constrained neighboring geometry may update with it.

+

Undo, redo, and OOPS

+

Undo reverses recorded document operations and Redo reapplies them. OOPS restores the most recently erased entities without reversing later commands. Save before a risky sequence and verify that associated dimensions, hatches, groups, and constraints were restored with their geometry.

+ + +
+
+ + diff --git a/help/files-drawings.html b/help/files-drawings.html new file mode 100644 index 000000000..643206a8d --- /dev/null +++ b/help/files-drawings.html @@ -0,0 +1,57 @@ + + + + + + + Files and Drawings — OpenCAD Studio Help + + + + + + +
+ + OpenCAD Studio Help +
+ + + + +
+
+ +
+
+

OpenCAD Studio User Manual

+

Files and Drawings

+

Create and open drawings

+

Use New for a blank document and Open for an existing DWG, DXF, backup, or autosave drawing. After opening a file from another CAD system, inspect its units, layers, blocks, fonts, raster references, layouts, and model extents.

+

Save and Save As

+

Save updates the current drawing. Save As changes its path, format, or version. Prefer Save As when validating interoperability or recovering an older drawing. A successful write does not replace visual inspection of a reopened copy.

+

Recovery and backups

+

OpenCAD Studio recognizes drawing backups and autosave-style files where the underlying data is DWG or DXF. If strict loading rejects malformed entities, use the recovery workflow and review what was discarded before continuing work.

+

External resources

+

Raster images, underlays, fonts, materials, and external references may depend on paths outside the drawing. Keep referenced files together when moving a project and verify that relative paths still resolve.

+ + +
+
+ + diff --git a/help/getting-started.html b/help/getting-started.html new file mode 100644 index 000000000..7a6d82d1d --- /dev/null +++ b/help/getting-started.html @@ -0,0 +1,64 @@ + + + + + + + Getting Started — OpenCAD Studio Help + + + + + + +
+ + OpenCAD Studio Help +
+ + + + +
+
+ +
+
+

OpenCAD Studio User Manual

+

Getting Started

+

What OpenCAD Studio does

+

OpenCAD Studio is a desktop and web CAD application for opening, creating, editing, visualizing, and saving two- and three-dimensional drawings. It works with DWG and DXF drawings and provides command-line, ribbon, automation, and plugin-oriented workflows.

+

A first drawing

+
    +
  1. Create a new drawing or open an existing DWG or DXF file.
  2. +
  3. Confirm the active workspace, units, layer, and object-snap settings.
  4. +
  5. Create geometry with a drawing command such as Line, Circle, or Polyline.
  6. +
  7. Edit the result with grips or a modification command.
  8. +
  9. Save the drawing under a new name until its compatibility has been verified.
  10. +
+
+

Data safety: Keep an untouched copy of important third-party drawings. Confirm geometry, layouts, external references, and plotted output before replacing the original file.

+
+

How to use this manual

+

Use the chapter navigation for broad workflows, the search box for a command or concept, and the previous/next links for sequential reading. Command names, system variables, keys, and file extensions appear in monospaced text.

+ + +
+
+ + diff --git a/help/index.html b/help/index.html new file mode 100644 index 000000000..9f4dbed96 --- /dev/null +++ b/help/index.html @@ -0,0 +1,61 @@ + + + + + + + Help overview — OpenCAD Studio Help + + + + + + +
+ + OpenCAD Studio Help +
+ + + + +
+
+ +
+
+

OpenCAD Studio User Manual

+

Help overview

+
+

OpenCAD Studio User Manual

+

Find the workflow, command, or concept you need

+

Learn how to create and edit drawings, exchange DWG and DXF files, prepare layouts, model in 3D, use constraints, automate work, and recover from common problems.

+
+ +

Manual chapters

+ + + +
+
+ + diff --git a/help/layers-properties.html b/help/layers-properties.html new file mode 100644 index 000000000..457bfe56b --- /dev/null +++ b/help/layers-properties.html @@ -0,0 +1,55 @@ + + + + + + + Layers and Properties — OpenCAD Studio Help + + + + + + +
+ + OpenCAD Studio Help +
+ + + + +
+
+ +
+
+

OpenCAD Studio User Manual

+

Layers and Properties

+

Layers

+

Layers organize geometry and control visibility, locking, color, line type, line weight, and plot behavior. Set the intended active layer before creating objects. Avoid editing objects on locked layers.

+

Object properties

+

Properties can be assigned explicitly or inherited ByLayer or ByBlock. Use the Properties surface or commands such as CHPROP to inspect and change selected objects.

+

Styles

+

Text, dimension, multiline, table, and visual styles centralize presentation. Changing a shared style can update many objects, so inspect affected layouts before saving.

+ + +
+
+ + diff --git a/help/layouts-plotting.html b/help/layouts-plotting.html new file mode 100644 index 000000000..9a1214662 --- /dev/null +++ b/help/layouts-plotting.html @@ -0,0 +1,55 @@ + + + + + + + Layouts and Plotting — OpenCAD Studio Help + + + + + + +
+ + OpenCAD Studio Help +
+ + + + +
+
+ +
+
+

OpenCAD Studio User Manual

+

Layouts and Plotting

+

Layouts and viewports

+

Layouts represent sheets. Paper-space viewports expose model-space views at controlled scales. Lock finished viewports to avoid accidental view changes.

+

Page setup and plot preview

+

Choose the target device or file format, paper size, orientation, plot area, scale, style behavior, and output quality. Use preview to catch clipping, incorrect line weights, hidden layers, or an unintended plot area.

+

Export checks

+

Before distributing a PDF or plotted file, compare it with the drawing at the intended sheet size and verify text, dimensions, raster content, transparency, line types, and viewport scales.

+ + +
+
+ + diff --git a/help/manual.md b/help/manual.md new file mode 100644 index 000000000..b366e7004 --- /dev/null +++ b/help/manual.md @@ -0,0 +1,305 @@ +# OpenCAD Studio User Manual + +This manual describes the user-facing workflows of OpenCAD Studio. The initial +version establishes the help structure and high-level guidance; individual +commands should be expanded and verified against released behavior before the +manual is published as complete. + +## Getting Started + +### What OpenCAD Studio does + +OpenCAD Studio is a desktop and web CAD application for opening, creating, +editing, visualizing, and saving two- and three-dimensional drawings. It works +with DWG and DXF drawings and provides command-line, ribbon, automation, and +plugin-oriented workflows. + +### A first drawing + +1. Create a new drawing or open an existing DWG or DXF file. +2. Confirm the active workspace, units, layer, and object-snap settings. +3. Create geometry with a drawing command such as **Line**, **Circle**, or + **Polyline**. +4. Edit the result with grips or a modification command. +5. Save the drawing under a new name until its compatibility has been verified. + +> **Data safety:** Keep an untouched copy of important third-party drawings. +> Confirm geometry, layouts, external references, and plotted output before +> replacing the original file. + +### How to use this manual + +Use the chapter navigation for broad workflows, the search box for a command or +concept, and the previous/next links for sequential reading. Command names, +system variables, keys, and file extensions appear in `monospaced text`. + +## Files and Drawings + +### Create and open drawings + +Use **New** for a blank document and **Open** for an existing DWG, DXF, backup, +or autosave drawing. After opening a file from another CAD system, inspect its +units, layers, blocks, fonts, raster references, layouts, and model extents. + +### Save and Save As + +**Save** updates the current drawing. **Save As** changes its path, format, or +version. Prefer Save As when validating interoperability or recovering an older +drawing. A successful write does not replace visual inspection of a reopened +copy. + +### Recovery and backups + +OpenCAD Studio recognizes drawing backups and autosave-style files where the +underlying data is DWG or DXF. If strict loading rejects malformed entities, use +the recovery workflow and review what was discarded before continuing work. + +### External resources + +Raster images, underlays, fonts, materials, and external references may depend +on paths outside the drawing. Keep referenced files together when moving a +project and verify that relative paths still resolve. + +## Workspace and Navigation + +### Model space and paper space + +Model space is the primary drawing environment. Paper-space layouts arrange +views for plotting through layout viewports. Always confirm the active space +before drawing or editing. + +### Pan, zoom, and orbit + +Use pan and zoom to navigate without changing drawing geometry. Use orbit and +view controls for three-dimensional inspection. **Zoom Extents** is useful when +the drawing appears empty or geometry is far from the expected location. + +### Multiple model viewports + +Model viewports can show different views of the same document. Grid, snap, +camera, and render mode may vary by viewport; confirm which viewport is active +before entering coordinates. + +### Selection and object snaps + +Selection identifies the objects a command will modify. Object snaps target +precise geometric locations such as endpoints, midpoints, centers, and +intersections. Zoom in and verify the snap marker when precision matters. + +## Create and Edit Geometry + +### Basic drawing commands + +Create lines, arcs, circles, ellipses, polylines, splines, points, text, hatches, +and other supported entities from the ribbon or command line. Follow the active +prompt: multi-step commands remain active until they complete or are cancelled. + +### Modify geometry + +Common modification workflows include move, copy, rotate, scale, mirror, +offset, trim, extend, fillet, chamfer, stretch, join, explode, and erase. Locked +layers can prevent modification. + +### Grips and direct manipulation + +Select an entity to expose its grips. Dragging a grip previews and then commits +a geometric change. Constrained neighboring geometry may update with it. + +### Undo, redo, and OOPS + +**Undo** reverses recorded document operations and **Redo** reapplies them. +**OOPS** restores the most recently erased entities without reversing later +commands. Save before a risky sequence and verify that associated dimensions, +hatches, groups, and constraints were restored with their geometry. + +## Layers and Properties + +### Layers + +Layers organize geometry and control visibility, locking, color, line type, +line weight, and plot behavior. Set the intended active layer before creating +objects. Avoid editing objects on locked layers. + +### Object properties + +Properties can be assigned explicitly or inherited **ByLayer** or **ByBlock**. +Use the Properties surface or commands such as `CHPROP` to inspect and change +selected objects. + +### Styles + +Text, dimension, multiline, table, and visual styles centralize presentation. +Changing a shared style can update many objects, so inspect affected layouts +before saving. + +## Blocks and References + +### Create and insert blocks + +A block definition groups reusable drawing content; an INSERT places an +instance with its own position, rotation, scale, attributes, and optional array +settings. Editing a definition affects every reference to it. + +### Array inserts + +Array inserts place multiple block instances with row and column counts and +spacing. Large counts can generate substantial geometry. Validate scaled and +rotated arrays when exchanging drawings with another CAD system. + +### Attributes and external references + +Attributes attach editable values to block references. External references and +underlays retain links to other files; preserve their paths when sharing or +archiving a project. + +## Annotation and Dimensions + +### Text and annotation + +Single-line text, multiline text, leaders, multileaders, tables, tolerances, +center marks, and centerlines communicate design intent. Missing fonts can +change layout or substitute glyphs, especially in the web build. + +### Dimensions + +Dimensions combine measured geometry with a dimension style. Associative +dimensions update when their source geometry changes; non-associative or +partially associated dimensions require manual verification. + +### Annotation scale + +Annotative objects can display differently at different viewport scales. +Confirm the active annotation scale before creating annotations and inspect all +paper-space viewports before plotting. + +## Layouts and Plotting + +### Layouts and viewports + +Layouts represent sheets. Paper-space viewports expose model-space views at +controlled scales. Lock finished viewports to avoid accidental view changes. + +### Page setup and plot preview + +Choose the target device or file format, paper size, orientation, plot area, +scale, style behavior, and output quality. Use preview to catch clipping, +incorrect line weights, hidden layers, or an unintended plot area. + +### Export checks + +Before distributing a PDF or plotted file, compare it with the drawing at the +intended sheet size and verify text, dimensions, raster content, transparency, +line types, and viewport scales. + +## 3D Modeling and Visualization + +### Solids and surfaces + +OpenCAD Studio can create and display solid primitives and modeled features such +as extrusions and revolutions. Imported ACIS-backed bodies are tessellated for +display while exact body data is retained where supported. + +### Visual styles and lighting + +Wireframe, shaded, hidden-line, materials, lights, and shadows affect display, +not the underlying object geometry. Switch to wireframe when diagnosing missing +faces or material problems. + +### Tessellation quality + +Curves and analytic surfaces are converted into display geometry according to +view and tolerance settings. A coarse display does not necessarily mean the +stored curve or solid is coarse. + +## Parametric Constraints + +### Geometric constraints + +Constraints capture relationships such as coincident, horizontal, vertical, +parallel, perpendicular, equal, tangent, distance, angle, and radius. Use the +degree-of-freedom and conflict indicators to understand the sketch state. + +### Editing constrained geometry + +Moving or grip-editing constrained geometry can move neighboring objects. If a +solve fails or becomes redundant, inspect the constraint glyphs and conflict +status before deleting geometry. + +### Persistence and history limitations + +Constraint data is stored as OpenCAD Studio extension data inside DWG and DXF. +Other applications may preserve it without interpreting it. Constraint edits +and geometry changes may currently occupy adjacent history entries; verify the +result after undo, redo, erase, copy, and reopen operations. + +## Commands and Shortcuts + +### Command line + +Enter a command name or alias, then respond to its prompts. Press `Escape` to +cancel the active command and `Return` to accept an available default. Exact +prompt options vary by command and current selection. + +### Discover commands + +Use command autocomplete and the ribbon to discover available operations. Some +commands are one-shot actions; others maintain state across several point, +number, keyword, or object-selection inputs. + +### Shortcut conventions + +Application and operating-system shortcuts can differ between native and web +builds. Avoid assigning browser-reserved shortcuts to web-only workflows. + +## Automation and Plugins + +### Automation interface + +The automation interface can inspect documents, run commands, query geometry, +capture the viewport, and perform batches. Callers should use request IDs and +document revisions to avoid repeating or applying work to stale state. + +### Plugins + +Native plugins can contribute commands and ribbon surfaces through the plugin +host. Install only trusted packages that match the host API, compiler, and CAD +model compatibility requirements. + +### Native and web differences + +The browser build uses in-memory file workflows and cannot load native plugin +libraries. Some rendering, font, windowing, and parallel-processing behavior +also differs from the desktop build. + +## Troubleshooting and Recovery + +### A drawing opens incorrectly + +Check units, extents, layers, layouts, block placement, missing references, and +fonts. Reopen a copy after saving to detect serialization problems early. Keep +the original file available for comparison. + +### Geometry is missing or distorted + +Try Zoom Extents, enable relevant layers, switch to wireframe, and inspect block +scale, array settings, clipping boundaries, object normals, and coordinates far +from the origin. + +### Rendering is incomplete + +Reduce unusually large images or material textures, simplify extreme hatch or +array inputs, and compare another visual style. Record the graphics backend and +the smallest drawing that reproduces the issue. + +### Undo did not restore the expected state + +Stop editing and save a diagnostic copy. Try the next adjacent undo entry only +when you understand what it represents. Compare the result with autosave or +backup data before overwriting the original drawing. + +### Report a reproducible problem + +Include the OpenCAD Studio version, platform, native or web build, exact steps, +expected and observed results, and a minimal sanitized drawing when possible. +Do not publish confidential drawings or external-reference paths. + diff --git a/help/parametric-constraints.html b/help/parametric-constraints.html new file mode 100644 index 000000000..3ba9227ee --- /dev/null +++ b/help/parametric-constraints.html @@ -0,0 +1,55 @@ + + + + + + + Parametric Constraints — OpenCAD Studio Help + + + + + + +
+ + OpenCAD Studio Help +
+ + + + +
+
+ +
+
+

OpenCAD Studio User Manual

+

Parametric Constraints

+

Geometric constraints

+

Constraints capture relationships such as coincident, horizontal, vertical, parallel, perpendicular, equal, tangent, distance, angle, and radius. Use the degree-of-freedom and conflict indicators to understand the sketch state.

+

Editing constrained geometry

+

Moving or grip-editing constrained geometry can move neighboring objects. If a solve fails or becomes redundant, inspect the constraint glyphs and conflict status before deleting geometry.

+

Persistence and history limitations

+

Constraint data is stored as OpenCAD Studio extension data inside DWG and DXF. Other applications may preserve it without interpreting it. Constraint edits and geometry changes may currently occupy adjacent history entries; verify the result after undo, redo, erase, copy, and reopen operations.

+ + +
+
+ + diff --git a/help/search-index.js b/help/search-index.js new file mode 100644 index 000000000..f6ef5f775 --- /dev/null +++ b/help/search-index.js @@ -0,0 +1 @@ +window.OCS_HELP_INDEX = [{"title":"What OpenCAD Studio does","chapter":"Getting Started","url":"getting-started.html#what-opencad-studio-does","text":"What OpenCAD Studio does OpenCAD Studio is a desktop and web CAD application for opening, creating, editing, visualizing, and saving two- and three-dimensional drawings. It works with DWG and DXF drawings and provides command-line, ribbon, automation, and plugin-oriented workflows."},{"title":"A first drawing","chapter":"Getting Started","url":"getting-started.html#a-first-drawing","text":"A first drawing Create a new drawing or open an existing DWG or DXF file. Confirm the active workspace, units, layer, and object-snap settings. Create geometry with a drawing command such as Line , Circle , or Polyline . Edit the result with grips or a modification command. Save the drawing under a new name until its compatibility has been verified. Data safety: Keep an untouched copy of important third-party drawings. Confirm geometry, layouts, external references, and plotted output before replacing the original file."},{"title":"How to use this manual","chapter":"Getting Started","url":"getting-started.html#how-to-use-this-manual","text":"How to use this manual Use the chapter navigation for broad workflows, the search box for a command or concept, and the previous/next links for sequential reading. Command names, system variables, keys, and file extensions appear in monospaced text ."},{"title":"Create and open drawings","chapter":"Files and Drawings","url":"files-drawings.html#create-and-open-drawings","text":"Create and open drawings Use New for a blank document and Open for an existing DWG, DXF, backup, or autosave drawing. After opening a file from another CAD system, inspect its units, layers, blocks, fonts, raster references, layouts, and model extents."},{"title":"Save and Save As","chapter":"Files and Drawings","url":"files-drawings.html#save-and-save-as","text":"Save and Save As Save updates the current drawing. Save As changes its path, format, or version. Prefer Save As when validating interoperability or recovering an older drawing. A successful write does not replace visual inspection of a reopened copy."},{"title":"Recovery and backups","chapter":"Files and Drawings","url":"files-drawings.html#recovery-and-backups","text":"Recovery and backups OpenCAD Studio recognizes drawing backups and autosave-style files where the underlying data is DWG or DXF. If strict loading rejects malformed entities, use the recovery workflow and review what was discarded before continuing work."},{"title":"External resources","chapter":"Files and Drawings","url":"files-drawings.html#external-resources","text":"External resources Raster images, underlays, fonts, materials, and external references may depend on paths outside the drawing. Keep referenced files together when moving a project and verify that relative paths still resolve."},{"title":"Model space and paper space","chapter":"Workspace and Navigation","url":"workspace-navigation.html#model-space-and-paper-space","text":"Model space and paper space Model space is the primary drawing environment. Paper-space layouts arrange views for plotting through layout viewports. Always confirm the active space before drawing or editing."},{"title":"Pan, zoom, and orbit","chapter":"Workspace and Navigation","url":"workspace-navigation.html#pan-zoom-and-orbit","text":"Pan, zoom, and orbit Use pan and zoom to navigate without changing drawing geometry. Use orbit and view controls for three-dimensional inspection. Zoom Extents is useful when the drawing appears empty or geometry is far from the expected location."},{"title":"Multiple model viewports","chapter":"Workspace and Navigation","url":"workspace-navigation.html#multiple-model-viewports","text":"Multiple model viewports Model viewports can show different views of the same document. Grid, snap, camera, and render mode may vary by viewport; confirm which viewport is active before entering coordinates."},{"title":"Selection and object snaps","chapter":"Workspace and Navigation","url":"workspace-navigation.html#selection-and-object-snaps","text":"Selection and object snaps Selection identifies the objects a command will modify. Object snaps target precise geometric locations such as endpoints, midpoints, centers, and intersections. Zoom in and verify the snap marker when precision matters."},{"title":"Basic drawing commands","chapter":"Create and Edit Geometry","url":"create-edit-geometry.html#basic-drawing-commands","text":"Basic drawing commands Create lines, arcs, circles, ellipses, polylines, splines, points, text, hatches, and other supported entities from the ribbon or command line. Follow the active prompt: multi-step commands remain active until they complete or are cancelled."},{"title":"Modify geometry","chapter":"Create and Edit Geometry","url":"create-edit-geometry.html#modify-geometry","text":"Modify geometry Common modification workflows include move, copy, rotate, scale, mirror, offset, trim, extend, fillet, chamfer, stretch, join, explode, and erase. Locked layers can prevent modification."},{"title":"Grips and direct manipulation","chapter":"Create and Edit Geometry","url":"create-edit-geometry.html#grips-and-direct-manipulation","text":"Grips and direct manipulation Select an entity to expose its grips. Dragging a grip previews and then commits a geometric change. Constrained neighboring geometry may update with it."},{"title":"Undo, redo, and OOPS","chapter":"Create and Edit Geometry","url":"create-edit-geometry.html#undo-redo-and-oops","text":"Undo, redo, and OOPS Undo reverses recorded document operations and Redo reapplies them. OOPS restores the most recently erased entities without reversing later commands. Save before a risky sequence and verify that associated dimensions, hatches, groups, and constraints were restored with their geometry."},{"title":"Layers","chapter":"Layers and Properties","url":"layers-properties.html#layers","text":"Layers Layers organize geometry and control visibility, locking, color, line type, line weight, and plot behavior. Set the intended active layer before creating objects. Avoid editing objects on locked layers."},{"title":"Object properties","chapter":"Layers and Properties","url":"layers-properties.html#object-properties","text":"Object properties Properties can be assigned explicitly or inherited ByLayer or ByBlock . Use the Properties surface or commands such as CHPROP to inspect and change selected objects."},{"title":"Styles","chapter":"Layers and Properties","url":"layers-properties.html#styles","text":"Styles Text, dimension, multiline, table, and visual styles centralize presentation. Changing a shared style can update many objects, so inspect affected layouts before saving."},{"title":"Create and insert blocks","chapter":"Blocks and References","url":"blocks-references.html#create-and-insert-blocks","text":"Create and insert blocks A block definition groups reusable drawing content; an INSERT places an instance with its own position, rotation, scale, attributes, and optional array settings. Editing a definition affects every reference to it."},{"title":"Array inserts","chapter":"Blocks and References","url":"blocks-references.html#array-inserts","text":"Array inserts Array inserts place multiple block instances with row and column counts and spacing. Large counts can generate substantial geometry. Validate scaled and rotated arrays when exchanging drawings with another CAD system."},{"title":"Attributes and external references","chapter":"Blocks and References","url":"blocks-references.html#attributes-and-external-references","text":"Attributes and external references Attributes attach editable values to block references. External references and underlays retain links to other files; preserve their paths when sharing or archiving a project."},{"title":"Text and annotation","chapter":"Annotation and Dimensions","url":"annotation-dimensions.html#text-and-annotation","text":"Text and annotation Single-line text, multiline text, leaders, multileaders, tables, tolerances, center marks, and centerlines communicate design intent. Missing fonts can change layout or substitute glyphs, especially in the web build."},{"title":"Dimensions","chapter":"Annotation and Dimensions","url":"annotation-dimensions.html#dimensions","text":"Dimensions Dimensions combine measured geometry with a dimension style. Associative dimensions update when their source geometry changes; non-associative or partially associated dimensions require manual verification."},{"title":"Annotation scale","chapter":"Annotation and Dimensions","url":"annotation-dimensions.html#annotation-scale","text":"Annotation scale Annotative objects can display differently at different viewport scales. Confirm the active annotation scale before creating annotations and inspect all paper-space viewports before plotting."},{"title":"Layouts and viewports","chapter":"Layouts and Plotting","url":"layouts-plotting.html#layouts-and-viewports","text":"Layouts and viewports Layouts represent sheets. Paper-space viewports expose model-space views at controlled scales. Lock finished viewports to avoid accidental view changes."},{"title":"Page setup and plot preview","chapter":"Layouts and Plotting","url":"layouts-plotting.html#page-setup-and-plot-preview","text":"Page setup and plot preview Choose the target device or file format, paper size, orientation, plot area, scale, style behavior, and output quality. Use preview to catch clipping, incorrect line weights, hidden layers, or an unintended plot area."},{"title":"Export checks","chapter":"Layouts and Plotting","url":"layouts-plotting.html#export-checks","text":"Export checks Before distributing a PDF or plotted file, compare it with the drawing at the intended sheet size and verify text, dimensions, raster content, transparency, line types, and viewport scales."},{"title":"Solids and surfaces","chapter":"3D Modeling and Visualization","url":"3d-modeling.html#solids-and-surfaces","text":"Solids and surfaces OpenCAD Studio can create and display solid primitives and modeled features such as extrusions and revolutions. Imported ACIS-backed bodies are tessellated for display while exact body data is retained where supported."},{"title":"Visual styles and lighting","chapter":"3D Modeling and Visualization","url":"3d-modeling.html#visual-styles-and-lighting","text":"Visual styles and lighting Wireframe, shaded, hidden-line, materials, lights, and shadows affect display, not the underlying object geometry. Switch to wireframe when diagnosing missing faces or material problems."},{"title":"Tessellation quality","chapter":"3D Modeling and Visualization","url":"3d-modeling.html#tessellation-quality","text":"Tessellation quality Curves and analytic surfaces are converted into display geometry according to view and tolerance settings. A coarse display does not necessarily mean the stored curve or solid is coarse."},{"title":"Geometric constraints","chapter":"Parametric Constraints","url":"parametric-constraints.html#geometric-constraints","text":"Geometric constraints Constraints capture relationships such as coincident, horizontal, vertical, parallel, perpendicular, equal, tangent, distance, angle, and radius. Use the degree-of-freedom and conflict indicators to understand the sketch state."},{"title":"Editing constrained geometry","chapter":"Parametric Constraints","url":"parametric-constraints.html#editing-constrained-geometry","text":"Editing constrained geometry Moving or grip-editing constrained geometry can move neighboring objects. If a solve fails or becomes redundant, inspect the constraint glyphs and conflict status before deleting geometry."},{"title":"Persistence and history limitations","chapter":"Parametric Constraints","url":"parametric-constraints.html#persistence-and-history-limitations","text":"Persistence and history limitations Constraint data is stored as OpenCAD Studio extension data inside DWG and DXF. Other applications may preserve it without interpreting it. Constraint edits and geometry changes may currently occupy adjacent history entries; verify the result after undo, redo, erase, copy, and reopen operations."},{"title":"Command line","chapter":"Commands and Shortcuts","url":"commands-shortcuts.html#command-line","text":"Command line Enter a command name or alias, then respond to its prompts. Press Escape to cancel the active command and Return to accept an available default. Exact prompt options vary by command and current selection."},{"title":"Discover commands","chapter":"Commands and Shortcuts","url":"commands-shortcuts.html#discover-commands","text":"Discover commands Use command autocomplete and the ribbon to discover available operations. Some commands are one-shot actions; others maintain state across several point, number, keyword, or object-selection inputs."},{"title":"Shortcut conventions","chapter":"Commands and Shortcuts","url":"commands-shortcuts.html#shortcut-conventions","text":"Shortcut conventions Application and operating-system shortcuts can differ between native and web builds. Avoid assigning browser-reserved shortcuts to web-only workflows."},{"title":"Automation interface","chapter":"Automation and Plugins","url":"automation-plugins.html#automation-interface","text":"Automation interface The automation interface can inspect documents, run commands, query geometry, capture the viewport, and perform batches. Callers should use request IDs and document revisions to avoid repeating or applying work to stale state."},{"title":"Plugins","chapter":"Automation and Plugins","url":"automation-plugins.html#plugins","text":"Plugins Native plugins can contribute commands and ribbon surfaces through the plugin host. Install only trusted packages that match the host API, compiler, and CAD model compatibility requirements."},{"title":"Native and web differences","chapter":"Automation and Plugins","url":"automation-plugins.html#native-and-web-differences","text":"Native and web differences The browser build uses in-memory file workflows and cannot load native plugin libraries. Some rendering, font, windowing, and parallel-processing behavior also differs from the desktop build."},{"title":"A drawing opens incorrectly","chapter":"Troubleshooting and Recovery","url":"troubleshooting-recovery.html#a-drawing-opens-incorrectly","text":"A drawing opens incorrectly Check units, extents, layers, layouts, block placement, missing references, and fonts. Reopen a copy after saving to detect serialization problems early. Keep the original file available for comparison."},{"title":"Geometry is missing or distorted","chapter":"Troubleshooting and Recovery","url":"troubleshooting-recovery.html#geometry-is-missing-or-distorted","text":"Geometry is missing or distorted Try Zoom Extents, enable relevant layers, switch to wireframe, and inspect block scale, array settings, clipping boundaries, object normals, and coordinates far from the origin."},{"title":"Rendering is incomplete","chapter":"Troubleshooting and Recovery","url":"troubleshooting-recovery.html#rendering-is-incomplete","text":"Rendering is incomplete Reduce unusually large images or material textures, simplify extreme hatch or array inputs, and compare another visual style. Record the graphics backend and the smallest drawing that reproduces the issue."},{"title":"Undo did not restore the expected state","chapter":"Troubleshooting and Recovery","url":"troubleshooting-recovery.html#undo-did-not-restore-the-expected-state","text":"Undo did not restore the expected state Stop editing and save a diagnostic copy. Try the next adjacent undo entry only when you understand what it represents. Compare the result with autosave or backup data before overwriting the original drawing."},{"title":"Report a reproducible problem","chapter":"Troubleshooting and Recovery","url":"troubleshooting-recovery.html#report-a-reproducible-problem","text":"Report a reproducible problem Include the OpenCAD Studio version, platform, native or web build, exact steps, expected and observed results, and a minimal sanitized drawing when possible. Do not publish confidential drawings or external-reference paths."}]; diff --git a/help/tools/build_help.py b/help/tools/build_help.py new file mode 100644 index 000000000..7a99dff2e --- /dev/null +++ b/help/tools/build_help.py @@ -0,0 +1,256 @@ +#!/usr/bin/env python3 +"""Build the static OpenCAD Studio HTML help system from help/manual.md.""" + +from __future__ import annotations + +import argparse +import html +import json +import re +import subprocess +import sys +from dataclasses import dataclass +from html.parser import HTMLParser +from pathlib import Path + + +HELP = Path(__file__).resolve().parents[1] +SOURCE = HELP / "manual.md" + + +@dataclass(frozen=True) +class Page: + filename: str + title: str + summary: str + + +PAGES = ( + Page("getting-started.html", "Getting Started", "Begin a drawing safely and learn how to use this manual."), + Page("files-drawings.html", "Files and Drawings", "Open, save, recover, and move drawings with their dependencies."), + Page("workspace-navigation.html", "Workspace and Navigation", "Work with spaces, views, selection, and object snaps."), + Page("create-edit-geometry.html", "Create and Edit Geometry", "Draw, modify, grip-edit, erase, and restore geometry."), + Page("layers-properties.html", "Layers and Properties", "Organize objects and control their inherited appearance."), + Page("blocks-references.html", "Blocks and References", "Create reusable content, arrays, attributes, and file references."), + Page("annotation-dimensions.html", "Annotation and Dimensions", "Add readable text, dimensions, leaders, and scaled annotation."), + Page("layouts-plotting.html", "Layouts and Plotting", "Prepare paper-space sheets and verify plotted output."), + Page("3d-modeling.html", "3D Modeling and Visualization", "Create and inspect solids, surfaces, materials, and visual styles."), + Page("parametric-constraints.html", "Parametric Constraints", "Capture geometric intent and diagnose solve or history issues."), + Page("commands-shortcuts.html", "Commands and Shortcuts", "Use prompts, autocomplete, aliases, and keyboard conventions."), + Page("automation-plugins.html", "Automation and Plugins", "Control drawings programmatically and extend the native app."), + Page("troubleshooting-recovery.html", "Troubleshooting and Recovery", "Diagnose file, geometry, rendering, and history problems."), +) + + +def slug(text: str) -> str: + value = re.sub(r"[^\w\s-]", "", text.lower(), flags=re.UNICODE) + value = re.sub(r"[\s_]+", "-", value) + return value.strip("-") + + +def split_chapters(markdown: str) -> dict[str, str]: + chapters: dict[str, list[str]] = {} + current: list[str] | None = None + for line in markdown.splitlines(): + match = re.match(r"^## (.+)$", line) + if match: + title = match.group(1).strip() + if title in chapters: + raise RuntimeError(f"Duplicate chapter: {title}") + current = [line] + chapters[title] = current + elif current is not None: + current.append(line) + return {title: "\n".join(lines) for title, lines in chapters.items()} + + +def pandoc(markdown: str) -> str: + process = subprocess.run( + ["pandoc", "--from=gfm", "--to=html5", "--wrap=none"], + input=markdown, + text=True, + capture_output=True, + check=True, + ) + return process.stdout.strip() + + +def headings(markdown: str) -> list[tuple[str, str]]: + output: list[tuple[str, str]] = [] + for line in markdown.splitlines(): + match = re.match(r"^### (.+)$", line) + if match: + title = match.group(1).strip() + output.append((slug(title), title)) + return output + + +def sidebar(current: Page | None, chapters: dict[str, str]) -> str: + parts = ['") + return "\n".join(parts) + + +def overview() -> str: + cards = "".join( + f'{html.escape(page.title)}' + f'{html.escape(page.summary)}' + for page in PAGES + ) + return f""" +
+

OpenCAD Studio User Manual

+

Find the workflow, command, or concept you need

+

Learn how to create and edit drawings, exchange DWG and DXF files, prepare layouts, model in 3D, use constraints, automate work, and recover from common problems.

+
+ +

Manual chapters

+
{cards}
+""".strip() + + +def shell(page: Page | None, title: str, body: str, chapters: dict[str, str]) -> str: + previous_link = "" + next_link = "" + if page is not None: + index = PAGES.index(page) + previous = PAGES[index - 1] if index else None + following = PAGES[index + 1] if index + 1 < len(PAGES) else None + previous_link = ( + f'' + if previous else '' + ) + next_link = ( + f'' + if following else "" + ) + elif PAGES: + next_link = f'' + + return f""" + + + + + + {html.escape(title)} — OpenCAD Studio Help + + + + + + +
+ + OpenCAD Studio Help +
+ + + + +
+
+ {sidebar(page, chapters)} +
+
+

OpenCAD Studio User Manual

+

{html.escape(title)}

+ {body} + + +
+
+ + +""" + + +class TextExtractor(HTMLParser): + def __init__(self) -> None: + super().__init__() + self.parts: list[str] = [] + + def handle_data(self, data: str) -> None: + if data.strip(): + self.parts.append(data.strip()) + + +def search_entries(chapters: dict[str, str]) -> list[dict[str, str]]: + entries: list[dict[str, str]] = [] + for page in PAGES: + chapter = chapters[page.title] + sections = re.split(r"(?=^### )", chapter, flags=re.MULTILINE) + for section in sections: + match = re.match(r"^### (.+)$", section.splitlines()[0] if section.splitlines() else "") + if not match: + continue + title = match.group(1).strip() + extractor = TextExtractor() + extractor.feed(pandoc(section)) + entries.append({ + "title": title, + "chapter": page.title, + "url": f"{page.filename}#{slug(title)}", + "text": " ".join(extractor.parts), + }) + return entries + + +def generated_outputs() -> dict[str, str]: + markdown = SOURCE.read_text(encoding="utf-8") + chapters = split_chapters(markdown) + expected = {page.title for page in PAGES} + if set(chapters) != expected: + raise RuntimeError( + f"Manual chapter mapping mismatch. Missing: {sorted(expected - set(chapters))}; " + f"extra: {sorted(set(chapters) - expected)}" + ) + + output = {"index.html": shell(None, "Help overview", overview(), chapters)} + for page in PAGES: + chapter_body = "\n".join(chapters[page.title].splitlines()[1:]).lstrip() + output[page.filename] = shell(page, page.title, pandoc(chapter_body), chapters) + index_json = json.dumps(search_entries(chapters), ensure_ascii=False, separators=(",", ":")) + output["search-index.js"] = f"window.OCS_HELP_INDEX = {index_json};\n" + return output + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--check", action="store_true", help="Fail if generated output is stale.") + args = parser.parse_args() + output = generated_outputs() + + if args.check: + stale = [name for name, content in output.items() if not (HELP / name).is_file() or (HELP / name).read_text(encoding="utf-8") != content] + if stale: + print("Stale generated help: " + ", ".join(stale), file=sys.stderr) + return 1 + print(f"Generated help is current ({len(output) - 1} pages plus search index).") + return 0 + + for name, content in output.items(): + (HELP / name).write_text(content, encoding="utf-8") + print(f"Generated {len(output) - 1} pages and search-index.js in {HELP}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/help/tools/check_help.py b/help/tools/check_help.py new file mode 100644 index 000000000..9d1eb42f2 --- /dev/null +++ b/help/tools/check_help.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Validate local links, anchors, assets, context targets, and basic HTML structure.""" + +from __future__ import annotations + +import json +import sys +from html.parser import HTMLParser +from pathlib import Path +from urllib.parse import unquote + + +HELP = Path(__file__).resolve().parents[1] + + +class Document(HTMLParser): + def __init__(self) -> None: + super().__init__() + self.ids: set[str] = set() + self.links: list[str] = [] + self.assets: list[str] = [] + self.has_main = False + self.has_title = False + self.images_without_alt = 0 + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + values = dict(attrs) + if values.get("id"): + self.ids.add(values["id"] or "") + if tag == "a" and values.get("href"): + self.links.append(values["href"] or "") + if tag in {"img", "script", "link"}: + target = values.get("src") or values.get("href") + if target: + self.assets.append(target) + if tag == "img" and "alt" not in values: + self.images_without_alt += 1 + self.has_main |= tag == "main" + self.has_title |= tag == "title" + + +def load_documents() -> dict[str, Document]: + documents: dict[str, Document] = {} + for path in sorted(HELP.glob("*.html")): + document = Document() + document.feed(path.read_text(encoding="utf-8")) + documents[path.name] = document + return documents + + +def validate_target(source: str, target: str, documents: dict[str, Document], errors: list[str]) -> None: + if target.startswith(("http:", "https:", "mailto:", "javascript:")): + return + filename, _, fragment = target.partition("#") + filename = unquote(filename) or source + destination = HELP / filename + if not destination.is_file(): + errors.append(f"{source}: missing target {target}") + return + if fragment and destination.suffix == ".html": + document = documents.get(destination.name) + if document is None or fragment not in document.ids: + errors.append(f"{source}: missing anchor {target}") + + +def main() -> int: + documents = load_documents() + errors: list[str] = [] + if not documents: + errors.append("No generated HTML pages found.") + + for name, document in documents.items(): + if not document.has_main: + errors.append(f"{name}: missing
") + if not document.has_title: + errors.append(f"{name}: missing ") + if document.images_without_alt: + errors.append(f"{name}: {document.images_without_alt} image(s) missing alt") + for target in document.links + document.assets: + validate_target(name, target, documents, errors) + + context = json.loads((HELP / "context-map.json").read_text(encoding="utf-8")) + for group in ("commands", "surfaces"): + for key, target in context.get(group, {}).items(): + validate_target(f"context-map.json:{group}.{key}", target, documents, errors) + + if errors: + print("Help validation failed:", file=sys.stderr) + for error in errors: + print(f"- {error}", file=sys.stderr) + return 1 + + links = sum(len(document.links) for document in documents.values()) + print(f"Validated {len(documents)} pages, {links} links, and {sum(len(context.get(group, {})) for group in ('commands', 'surfaces'))} context targets.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) + diff --git a/help/troubleshooting-recovery.html b/help/troubleshooting-recovery.html new file mode 100644 index 000000000..ea998bcfd --- /dev/null +++ b/help/troubleshooting-recovery.html @@ -0,0 +1,59 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <meta name="description" content="OpenCAD Studio user manual — Troubleshooting and Recovery"> + <title>Troubleshooting and Recovery — OpenCAD Studio Help + + + + + + +
+ + OpenCAD Studio Help +
+ + + + +
+
+ +
+
+

OpenCAD Studio User Manual

+

Troubleshooting and Recovery

+

A drawing opens incorrectly

+

Check units, extents, layers, layouts, block placement, missing references, and fonts. Reopen a copy after saving to detect serialization problems early. Keep the original file available for comparison.

+

Geometry is missing or distorted

+

Try Zoom Extents, enable relevant layers, switch to wireframe, and inspect block scale, array settings, clipping boundaries, object normals, and coordinates far from the origin.

+

Rendering is incomplete

+

Reduce unusually large images or material textures, simplify extreme hatch or array inputs, and compare another visual style. Record the graphics backend and the smallest drawing that reproduces the issue.

+

Undo did not restore the expected state

+

Stop editing and save a diagnostic copy. Try the next adjacent undo entry only when you understand what it represents. Compare the result with autosave or backup data before overwriting the original drawing.

+

Report a reproducible problem

+

Include the OpenCAD Studio version, platform, native or web build, exact steps, expected and observed results, and a minimal sanitized drawing when possible. Do not publish confidential drawings or external-reference paths.

+ + +
+
+ + diff --git a/help/workspace-navigation.html b/help/workspace-navigation.html new file mode 100644 index 000000000..ff8e00628 --- /dev/null +++ b/help/workspace-navigation.html @@ -0,0 +1,57 @@ + + + + + + + Workspace and Navigation — OpenCAD Studio Help + + + + + + +
+ + OpenCAD Studio Help +
+ + + + +
+
+ +
+
+

OpenCAD Studio User Manual

+

Workspace and Navigation

+

Model space and paper space

+

Model space is the primary drawing environment. Paper-space layouts arrange views for plotting through layout viewports. Always confirm the active space before drawing or editing.

+

Pan, zoom, and orbit

+

Use pan and zoom to navigate without changing drawing geometry. Use orbit and view controls for three-dimensional inspection. Zoom Extents is useful when the drawing appears empty or geometry is far from the expected location.

+

Multiple model viewports

+

Model viewports can show different views of the same document. Grid, snap, camera, and render mode may vary by viewport; confirm which viewport is active before entering coordinates.

+

Selection and object snaps

+

Selection identifies the objects a command will modify. Object snaps target precise geometric locations such as endpoints, midpoints, centers, and intersections. Zoom in and verify the snap marker when precision matters.

+ + +
+
+ + diff --git a/src/app/automation.rs b/src/app/automation.rs index 743f05770..1343081c4 100644 --- a/src/app/automation.rs +++ b/src/app/automation.rs @@ -302,6 +302,7 @@ impl OpenCADStudio { let i = self.active_tab; self.tabs[i].scene.document = acadrust::CadDocument::new(); self.tabs[i].scene.sketch_constraints.clear(); + self.tabs[i].scene.named_parameters = crate::scene::named_parameters::ParameterTable::new(); self.tabs[i].scene.deselect_all(); self.tabs[i].current_path = None; // The headless session starts on the welcome (Start) tab, which @@ -331,6 +332,7 @@ impl OpenCADStudio { let i = self.active_tab; self.tabs[i].scene.document = doc; self.tabs[i].scene.load_sketch_constraints_from_document(); + self.tabs[i].scene.load_named_parameters_from_document(); self.tabs[i].scene.deselect_all(); crate::app::style_ops::ensure_standard_styles( &mut self.tabs[i].scene.document, @@ -1063,6 +1065,7 @@ mod tests { "CHANGELOG", "CUI", "ALIASEDIT", + "OPTIONS", ]; for cmd in standalone { assert!( diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index eee1345cd..a455b7a0f 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -951,6 +951,81 @@ impl OpenCADStudio { self.push_sketch_constraints_history(i, label, scope, constraints_before); } + /// PARAMETERS' Apply button (`docs/named_parameters_design.md` stage 4): + /// rebuilds the active tab's `Scene::named_parameters` from the + /// editor's working buffer, reports any row that failed to validate via + /// the command line, and re-solves every scope with a constraint driven + /// by a named parameter — the entire point of a *named* parameter is + /// that editing it ripples to whatever references it, the same way + /// editing a constraint's literal value already does. + /// + /// **Deliberate scope-down, matching this project's established + /// treatment of undo-granularity questions** (design doc §5 open + /// question 3, and the sibling constraint-system project's stage 9): the + /// geometry this ripple moves is bracketed into one undo step below + /// (same `begin_undo`/`bump_entities`/`commit_undo_delta` pattern + /// `resolve_one_sketch_conflict` above uses), but the parameter *table* + /// edit itself has no undo entry of its own yet — that would need a new + /// `HistorySnapshot` variant mirroring `SketchConstraints` + /// (`push_sketch_constraints_history`). Left for a follow-up, not + /// silently dropped: undoing after an Apply undoes the geometry it + /// moved, not the parameter values themselves. + pub(super) fn apply_named_parameter_editor_rows(&mut self) { + let i = self.active_tab; + // A name shared by more than one row can't be resolved by picking + // one arbitrarily (`ParameterTable::set` would just let the last + // one silently win) -- refuse every row sharing that name up front, + // same as `named_parameters::preview`'s live check. + let duplicates = crate::ui::window::named_parameters::duplicate_name_rows(&self.named_parameter_editor_rows); + let mut table = crate::scene::named_parameters::ParameterTable::new(); + let mut failed = 0usize; + for (idx, row) in self.named_parameter_editor_rows.iter().enumerate() { + let name = row.name.trim(); + if name.is_empty() || duplicates.contains(&idx) { + continue; + } + if let Err(e) = table.set(name, row.formula.trim()) { + self.command_line.push_error(crate::tf!("Parameter '{}': {}", name, e).as_ref()); + failed += 1; + } + } + if !duplicates.is_empty() { + self.command_line.push_error(crate::tf!("{} row(s) skipped: duplicate parameter name.", duplicates.len()).as_ref()); + failed += duplicates.len(); + } + let param_count = table.len(); + self.tabs[i].scene.named_parameters = table; + + // The whole table just changed, not one isolated value, so there is + // no cheaper "which handles actually moved" test worth doing here — + // re-solve every scope with a constraint driven by *any* named + // parameter (mirrors `solve_scope`'s own "full rebuild, not + // incremental" philosophy). + let touched: Vec = self.tabs[i] + .scene + .sketch_constraints + .iter() + .flat_map(|set| set.constraints.iter()) + .filter(|c| c.enabled && matches!(c.driving_param, Some(crate::scene::named_parameters::DrivingValue::Named(_)))) + .flat_map(|c| c.refs.iter().map(|r| r.entity)) + .collect(); + + if !touched.is_empty() { + let pending = self.begin_undo(i, "Apply named parameters", touched.len(), true); + let changes: Vec<(Handle, crate::scene::ChangeKind)> = + touched.into_iter().map(|h| (h, crate::scene::ChangeKind::Modified)).collect(); + self.tabs[i].scene.bump_entities(&changes); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + } + + self.tabs[i].dirty = true; + if failed == 0 { + self.command_line.push_info(crate::tf!("{} parameter(s) applied.", param_count).as_ref()); + } + } + fn apply_cmd_result_inner(&mut self, result: CmdResult) -> Task { let i = self.active_tab; let preserve_commit_layer = self.tabs[i] @@ -6075,6 +6150,93 @@ mod sketch_constraint_undo_tests { assert_eq!(set.constraints.len(), 2, "undo should restore the removed constraint"); } + /// `named_parameters_design.md` stage 4: applying the PARAMETERS editor's + /// working buffer both defines the parameter and re-solves whatever + /// constraint already references it by name — the entire point of a + /// *named* parameter, not just data plumbing. + #[test] + fn applying_named_parameter_rows_defines_and_resolves_referencing_constraints() { + let mut app = OpenCADStudio::new_for_test(); + let _ = app.automation_op(r#"{"op":"new"}"#); + let line = add_line(&mut app, 0.0, 0.0, 10.0, 0.0); + + // The constraint is added *before* the parameter exists -- allowed, + // same as `ParameterTable::set`'s own forward-reference tolerance; + // it just doesn't resolve to anything until the parameter is defined. + let _ = app.apply_cmd_result(CmdResult::AddSketchConstraint { + kind: ConstraintKind::Distance, + refs: vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], + driving_param: Some(crate::scene::named_parameters::DrivingValue::Named("target_len".to_string())), + label: "Distance constraint", + }); + + app.named_parameter_editor_rows = + vec![crate::ui::window::named_parameters::ParamEditorRow { name: "target_len".to_string(), formula: "8".to_string() }]; + app.apply_named_parameter_editor_rows(); + + assert_eq!(app.tabs[app.active_tab].scene.named_parameters().resolve("target_len"), Ok(8.0)); + let (start, end) = match app.tabs[app.active_tab].scene.document.get_entity(line) { + Some(acadrust::EntityType::Line(l)) => (l.start, l.end), + other => panic!("expected a Line, got {other:?}"), + }; + let len = ((end.x - start.x).powi(2) + (end.y - start.y).powi(2)).sqrt(); + assert!((len - 8.0).abs() < 1e-6, "length should track the applied parameter, got {len}"); + + // Redefining the parameter and re-applying should ripple again. + app.named_parameter_editor_rows[0].formula = "3".to_string(); + app.apply_named_parameter_editor_rows(); + let (start, end) = match app.tabs[app.active_tab].scene.document.get_entity(line) { + Some(acadrust::EntityType::Line(l)) => (l.start, l.end), + other => panic!("expected a Line, got {other:?}"), + }; + let len = ((end.x - start.x).powi(2) + (end.y - start.y).powi(2)).sqrt(); + assert!((len - 3.0).abs() < 1e-6, "length should track the redefined parameter, got {len}"); + } + + /// A row that fails to validate (here, a formula that doesn't parse) + /// must not silently vanish or corrupt the table: it's reported as an + /// error and simply isn't added, while a valid row alongside it still + /// commits — the same isolate-the-failure treatment + /// `ParameterTable::resolve_all` already gives a bad reference. + #[test] + fn applying_a_row_with_a_malformed_formula_reports_an_error_and_keeps_the_good_row() { + let mut app = OpenCADStudio::new_for_test(); + let _ = app.automation_op(r#"{"op":"new"}"#); + + app.named_parameter_editor_rows = vec![ + crate::ui::window::named_parameters::ParamEditorRow { name: "good".to_string(), formula: "10".to_string() }, + crate::ui::window::named_parameters::ParamEditorRow { name: "bad".to_string(), formula: "1 +".to_string() }, + ]; + app.apply_named_parameter_editor_rows(); + + assert_eq!(app.tabs[app.active_tab].scene.named_parameters().resolve("good"), Ok(10.0)); + assert!(!app.tabs[app.active_tab].scene.named_parameters().contains("bad"), "a malformed row must not be added"); + assert!( + app.command_line.history.iter().any(|e| e.kind == crate::ui::command_line::EntryKind::Error), + "a malformed row must be reported as an error, not silently dropped" + ); + } + + /// Two rows typed with the same name must not silently collapse into + /// "whichever `set` call happens last wins" — both are refused, and an + /// unrelated row still commits normally. + #[test] + fn applying_rows_with_a_duplicate_name_refuses_both_and_keeps_the_unrelated_row() { + let mut app = OpenCADStudio::new_for_test(); + let _ = app.automation_op(r#"{"op":"new"}"#); + + app.named_parameter_editor_rows = vec![ + crate::ui::window::named_parameters::ParamEditorRow { name: "x".to_string(), formula: "1".to_string() }, + crate::ui::window::named_parameters::ParamEditorRow { name: "x".to_string(), formula: "2".to_string() }, + crate::ui::window::named_parameters::ParamEditorRow { name: "y".to_string(), formula: "3".to_string() }, + ]; + app.apply_named_parameter_editor_rows(); + + assert!(!app.tabs[app.active_tab].scene.named_parameters().contains("x"), "a duplicated name must not be added at all"); + assert_eq!(app.tabs[app.active_tab].scene.named_parameters().resolve("y"), Ok(3.0)); + assert!(app.command_line.history.iter().any(|e| e.kind == crate::ui::command_line::EntryKind::Error)); + } + /// Design doc §6.1's manual Coincident UI: two picks landing on two /// different lines' endpoints should resolve to a real `SketchRef` pair /// and add a Coincident constraint — the same `AddCoincidentConstraint` diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index b69daec39..a5ba30972 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -292,6 +292,8 @@ pub fn start_allowed(cmd: &str) -> bool { | "PERF" | "CUI" | "ALIASEDIT" + | "OPTIONS" + | "OP" | "CUILOAD" | "CUIIMPORT" ) @@ -312,6 +314,7 @@ inventory::submit!(crate::command::CommandRegistration { "CLEANSCREEN", "CUI", "DSETTINGS", + "PARAMETERS", "GRID", "ISODRAFT", "ISOPLANE", diff --git a/src/app/commands/view.rs b/src/app/commands/view.rs index a181b677b..6ceb6d67a 100644 --- a/src/app/commands/view.rs +++ b/src/app/commands/view.rs @@ -240,6 +240,25 @@ impl OpenCADStudio { return Some(Task::done(Message::AliasEditorOpen)); } + // ── OPTIONS — app preferences dialog ──────────────────────────── + // Registered for autocomplete (`commands/mod.rs`'s "one-shot + // commands" list) but had no actual dispatch arm anywhere — the + // Start-tab welcome screen's "Options" button emits + // `Message::OptionsOpen` directly and was the only way to reach + // it. Found while live-testing the new UI-scale setting (nothing + // to do with that feature itself, just discovered along the + // way). "OP" is AutoCAD's own short alias for this command. + "OPTIONS" | "OP" => { + return Some(Task::done(Message::OptionsOpen)); + } + + // ── PARAMETERS — named-parameter/expression table editor ─────── + // `docs/named_parameters_design.md` stage 4. AutoCAD's own + // command name for the equivalent feature (Parameters Manager). + "PARAMETERS" => { + return Some(Task::done(Message::NamedParametersOpen)); + } + // ── CUI — keyboard shortcut / key-binding editor ─────────────── "CUI" => { return Some(Task::done(Message::ShortcutsPanelOpen)); diff --git a/src/app/mod.rs b/src/app/mod.rs index 8dbe6b889..b7723545f 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -504,6 +504,12 @@ pub(super) struct OpenCADStudio { crosshair_color_input: String, /// Model-space lineweight preview scale, in percent (25..=200). lineweight_display_scale: i32, + /// Whole-interface scale, in percent (50..=200) — drives iced's own + /// per-window `scale_factor` (`run`'s `.scale_factor(...)` hook), so it + /// resizes every panel, dialog, the ribbon, and their text uniformly + /// rather than needing a separate font-size knob threaded through each + /// panel's own view code. + ui_scale: i32, /// Isometric drafting state and active axis pair. isometric_drafting: bool, iso_plane: settings::IsoPlane, @@ -1018,6 +1024,15 @@ pub(super) struct OpenCADStudio { /// edited. Seeded from `command_aliases` on open, committed back on close. alias_editor_rows: Vec<(String, String)>, + // ── Named Parameters (docs/named_parameters_design.md, stage 4) ──────── + /// Working buffer for the PARAMETERS modal. Unlike `alias_editor_rows`, + /// this isn't a copy of a separate app-level store — the real state + /// lives per-document at `Scene::named_parameters`; this buffer is + /// seeded from the active tab's table on open and only written back to + /// it on Apply (`apply_named_parameter_editor_rows`, + /// `src/app/named_parameters.rs`). + named_parameter_editor_rows: Vec, + // ── Layout Manager Panel ────────────────────────────────────────────── layout_manager_selected: String, layout_manager_rename_buf: String, @@ -1695,6 +1710,7 @@ pub enum ModalKind { AttributeEditor, LayerDeleteWarning, Aliases, + NamedParameters, ScaleManager, /// Add / remove the annotation scales a single selected object has a /// per-object representation for. @@ -1937,6 +1953,8 @@ pub enum Message { CursorTypeChanged(settings::CursorType), /// Set the model-space lineweight preview scale from Options. LineweightDisplayScaleChanged(i32), + /// Set the whole-interface scale (`ui_scale`) from Options. + UiScaleChanged(i32), /// Set the ribbon's large-button icon-label font size from Options. RibbonLabelFontSizeChanged(i32), /// Set the ribbon's panel-title ("Draw", "Modify", …) font size from Options. @@ -2706,6 +2724,24 @@ pub enum Message { AliasEditorRemove(usize), /// Commit the edited rows to the alias table (Apply button); stays open. AliasEditorApply, + // ── Named Parameters (PARAMETERS) ─────────────────────────────────── + /// Open the named-parameter editor, seeding rows from the active tab's + /// `Scene::named_parameters`. + NamedParametersOpen, + /// Live edit of the name or formula in row `idx`. + NamedParametersInput { + idx: usize, + field: crate::ui::window::named_parameters::ParamField, + value: String, + }, + /// Append a blank parameter row. + NamedParametersAdd, + /// Remove parameter row `idx`. + NamedParametersRemove(usize), + /// Commit the edited rows to `Scene::named_parameters` (Apply button) + /// and re-solve every constraint that reads a named parameter; stays + /// open. + NamedParametersApply, // ── About window ──────────────────────────────────────────────────── AboutOpen, /// Close whatever in-canvas modal dialog is open (Plan B). @@ -3378,6 +3414,7 @@ impl OpenCADStudio { crosshair_color: None, crosshair_color_input: String::new(), lineweight_display_scale: 100, + ui_scale: 100, isometric_drafting: false, iso_plane: settings::IsoPlane::Left, snap_angle_deg: 0.0, @@ -3579,6 +3616,7 @@ impl OpenCADStudio { // Command aliases (populated from ocad.pgp just after construction) command_aliases: rustc_hash::FxHashMap::default(), alias_editor_rows: Vec::new(), + named_parameter_editor_rows: Vec::new(), // Layout Manager layout_manager_selected: "Model".to_string(), layer_state_selected: None, @@ -3978,6 +4016,7 @@ pub fn run() -> iced::Result { ..iced::Settings::default() }) .subscription(OpenCADStudio::subscription) + .scale_factor(|state: &OpenCADStudio, _window: window::Id| state.ui_scale as f32 / 100.0) .title(|state: &OpenCADStudio, window_id: window::Id| { let _ = window_id; // all dialogs are in-canvas modals now if let Some(tab) = state.tabs.get(state.active_tab) { @@ -4022,6 +4061,7 @@ pub fn run_web() -> iced::Result { OpenCADStudio::view_main, ) .subscription(OpenCADStudio::subscription) + .scale_factor(|state: &OpenCADStudio| state.ui_scale as f32 / 100.0) .title(|_state: &OpenCADStudio| { concat!("Open CAD Studio ", env!("OCS_APP_VERSION")).to_string() }) diff --git a/src/app/settings.rs b/src/app/settings.rs index a9ca0cb55..589555127 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -166,6 +166,14 @@ pub struct UserSettings { pub crosshair_color: Option<[u8; 3]>, /// Model-space lineweight preview scale as a percentage. pub lineweight_display_scale: i32, + /// Whole-interface scale, as a percentage — every panel, dialog, the + /// ribbon, and their text, not just one widget's font size. Wired + /// straight into iced's own per-window `scale_factor` hook + /// (`OpenCADStudio::run`), so a change takes effect immediately with no + /// per-panel plumbing needed. Relies on the struct-level `#[serde(default)]` + /// (via `impl Default` below) to backfill this field for a config file + /// saved before this setting existed, same as every other field here. + pub ui_scale: i32, /// Isometric drafting changes the grid and crosshair to the active axis pair. pub isometric_drafting: bool, pub iso_plane: IsoPlane, @@ -310,6 +318,7 @@ impl Default for UserSettings { cursor_type: CursorType::Crosshair, crosshair_color: None, lineweight_display_scale: 100, + ui_scale: 100, isometric_drafting: false, iso_plane: IsoPlane::Left, snap_angle_deg: 0.0, diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 9b2dd763b..8a9640335 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -368,6 +368,7 @@ impl OpenCADStudio { cursor_type: self.cursor_type, crosshair_color: self.crosshair_color, lineweight_display_scale: self.lineweight_display_scale, + ui_scale: self.ui_scale, isometric_drafting: self.isometric_drafting, iso_plane: self.iso_plane, snap_angle_deg: self.snap_angle_deg, @@ -425,6 +426,7 @@ impl OpenCADStudio { .map(crate::app::config::rgb_to_hex) .unwrap_or_default(); self.lineweight_display_scale = s.lineweight_display_scale.clamp(25, 200); + self.ui_scale = s.ui_scale.clamp(50, 200); self.isometric_drafting = s.isometric_drafting; self.iso_plane = s.iso_plane; self.snap_angle_deg = if s.snap_angle_deg.is_finite() { @@ -1394,6 +1396,9 @@ pub(super) fn on_open_file(&mut self) -> Task { // Design doc §8 stage 4: read back any persisted sketch // constraint sets right after the document is installed. self.tabs[i].scene.load_sketch_constraints_from_document(); + // named_parameters_design.md stage 2: same load-time hook, + // for the document-wide parameter table. + self.tabs[i].scene.load_named_parameters_from_document(); self.tabs[i].active_layer = self.tabs[i] .scene .document @@ -1660,6 +1665,9 @@ pub(super) fn on_open_file(&mut self) -> Task { // Design doc §8 stage 4: write any sketch constraint sets into the // document right before it's serialized. self.tabs[i].scene.materialize_sketch_constraints_for_save(); + // named_parameters_design.md stage 2: same save-time hook, for the + // document-wide parameter table. + self.tabs[i].scene.materialize_named_parameters_for_save(); } #[cfg(not(target_arch = "wasm32"))] @@ -2611,6 +2619,7 @@ pub(super) fn on_open_file(&mut self) -> Task { self.stamp_header_sysvars(i); self.sync_solid_models_for_save(i); self.tabs[i].scene.materialize_sketch_constraints_for_save(); + self.tabs[i].scene.materialize_named_parameters_for_save(); let tab_id = self.tabs[i].id; let bounds = crate::ui::wrap_bar::dropdown_bounds( crate::app::view::VIEWPORT_CAPTURE_BOUNDS_ID, diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index b076c18a0..76cca25dc 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -5861,6 +5861,43 @@ impl OpenCADStudio { Task::none() } + // ── Named Parameters (PARAMETERS) ───────────────────────────────── + Message::NamedParametersOpen => { + let i = self.active_tab; + self.named_parameter_editor_rows = self.tabs[i] + .scene + .named_parameters() + .iter() + .map(|p| crate::ui::window::named_parameters::ParamEditorRow { name: p.name.clone(), formula: p.source.clone() }) + .collect(); + self.active_modal = Some(super::ModalKind::NamedParameters); + Task::none() + } + Message::NamedParametersInput { idx, field, value } => { + use crate::ui::window::named_parameters::ParamField; + if let Some(row) = self.named_parameter_editor_rows.get_mut(idx) { + match field { + ParamField::Name => row.name = value, + ParamField::Formula => row.formula = value, + } + } + Task::none() + } + Message::NamedParametersAdd => { + self.named_parameter_editor_rows.push(crate::ui::window::named_parameters::ParamEditorRow::default()); + Task::none() + } + Message::NamedParametersRemove(idx) => { + if idx < self.named_parameter_editor_rows.len() { + self.named_parameter_editor_rows.remove(idx); + } + Task::none() + } + Message::NamedParametersApply => { + self.apply_named_parameter_editor_rows(); + Task::none() + } + // ── Options / About windows ─────────────────────────────────── Message::OptionsOpen => { self.active_modal = Some(super::ModalKind::Options); @@ -5900,6 +5937,12 @@ impl OpenCADStudio { Task::none() } + Message::UiScaleChanged(value) => { + self.ui_scale = value.clamp(50, 200); + self.persist_settings_if_changed(); + Task::none() + } + Message::RibbonLabelFontSizeChanged(value) => { self.ribbon.set_label_font_size(value); self.save_config(); diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index bc0c3bdf7..0d921a8c7 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -13,6 +13,7 @@ impl OpenCADStudio { Some(K::About) => crate::tr!("modal", "about"), Some(K::Shortcuts) => crate::tr!("modal", "keyboard-shortcuts"), Some(K::Aliases) => crate::tr!("modal", "command-aliases"), + Some(K::NamedParameters) => crate::t!("Named Parameters").into_owned(), Some(K::Options) => crate::tr!("action", "options"), Some(K::FindReplace) => crate::tr!("modal", "find-replace"), Some(K::PluginManager) => crate::tr!("modal", "plugin-manager"), @@ -173,6 +174,19 @@ impl OpenCADStudio { }, ) } + super::super::ModalKind::NamedParameters => { + sized_flow( + ex, + 620, + 520, + |flow| { + crate::ui::window::named_parameters::view_window( + &self.named_parameter_editor_rows, + flow, + ) + }, + ) + } super::super::ModalKind::Options => sized_flow( ex, 540, @@ -191,6 +205,7 @@ impl OpenCADStudio { self.crosshair_color, &self.crosshair_color_input, self.lineweight_display_scale, + self.ui_scale, self.ribbon.label_font_size(), self.ribbon.group_title_font_size(), &self.model_space, diff --git a/src/command.rs b/src/command.rs index 771a0b58a..13a109e59 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1336,9 +1336,14 @@ pub enum CmdResult { AddSketchConstraint { kind: crate::scene::sketch_constraints::ConstraintKind, refs: Vec, - /// The typed target for a dimensional kind (Distance/Angle/Radius); - /// `None` for a purely geometric one. - driving_param: Option, + /// The typed target for a dimensional kind (Distance/Angle/Radius) — + /// a literal number, or a named-parameter reference + /// (`docs/named_parameters_design.md` stage 4, recognized by + /// `DistanceConstraintCommand`/`AngleConstraintCommand:: + /// on_text_input` when the typed token matches a known parameter + /// name instead of parsing as a number); `None` for a purely + /// geometric kind. + driving_param: Option, /// Undo-history label, e.g. `"Horizontal constraint"`. label: &'static str, }, diff --git a/src/modules/draw/constrain/value.rs b/src/modules/draw/constrain/value.rs index 78d328659..fa0afa064 100644 --- a/src/modules/draw/constrain/value.rs +++ b/src/modules/draw/constrain/value.rs @@ -19,9 +19,30 @@ use glam::DVec3; use crate::command::{CadCommand, CmdResult, InputKind}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; +use crate::scene::named_parameters::DrivingValue; use crate::scene::sketch_constraints::{ConstraintKind, SketchRef}; use crate::scene::Scene; +/// Recognizes a typed prompt token as either a numeric literal or a +/// reference to an existing named parameter (`docs/ +/// named_parameters_design.md` stage 4's "a way to pick a named parameter +/// instead of typing a literal" — the same prompt takes either, mirroring +/// how AutoCAD's own dimensional-constraint prompts accept an expression in +/// place of a bare value). `known_names` is a one-time snapshot taken at +/// command construction (`DistanceConstraintCommand`/`AngleConstraintCommand +/// ::new`, which already has `&Scene`) since `on_text_input` itself has no +/// document access — the same constraint `default_value` already works +/// around. Only *existence* is checked here; the actual value is resolved +/// fresh at solve time (`sketch_solve::build_constraint`), consistent with +/// this project's "no incremental/cached resolution" approach throughout. +fn parse_driving_value(text: &str, known_names: &[String]) -> Option { + let text = text.trim(); + if let Ok(value) = text.parse::() { + return Some(DrivingValue::Literal(value)); + } + known_names.iter().find(|n| n.as_str() == text).map(|n| DrivingValue::Named(n.clone())) +} + pub mod distance_tool { use super::*; pub fn tool() -> ToolDef { @@ -55,6 +76,9 @@ pub struct DistanceConstraintCommand { /// split between line-length (`Distance`) and circle-radius (`Radius`). is_circle: bool, default_value: f64, + /// Snapshot of `Scene::named_parameters`' names at construction time — + /// see `parse_driving_value`'s doc comment for why a snapshot. + known_param_names: Vec, } impl DistanceConstraintCommand { @@ -70,12 +94,15 @@ impl DistanceConstraintCommand { EntityType::Circle(c) => (true, c.radius), _ => return None, }; - Some(Self { handle, is_circle, default_value }) + let known_param_names = scene.named_parameters().iter().map(|p| p.name.clone()).collect(); + Some(Self { handle, is_circle, default_value, known_param_names }) } - fn build(&self, target: f64) -> Option { - if target <= 0.0 { - return None; + fn build(&self, target: DrivingValue) -> Option { + if let DrivingValue::Literal(v) = target { + if v <= 0.0 { + return None; + } } let (kind, refs, label) = if self.is_circle { (ConstraintKind::Radius, vec![SketchRef::whole(self.handle)], "Radius constraint") @@ -108,11 +135,11 @@ impl CadCommand for DistanceConstraintCommand { } fn on_enter(&mut self) -> CmdResult { - self.build(self.default_value).unwrap_or(CmdResult::Cancel) + self.build(DrivingValue::Literal(self.default_value)).unwrap_or(CmdResult::Cancel) } fn on_text_input(&mut self, text: &str) -> Option { - let value: f64 = text.trim().parse().ok()?; + let value = parse_driving_value(text, &self.known_param_names)?; self.build(value) } @@ -126,6 +153,9 @@ pub struct AngleConstraintCommand { fixed_handle: Handle, moving_handle: Handle, default_value: f64, + /// Snapshot of `Scene::named_parameters`' names at construction time — + /// see `parse_driving_value`'s doc comment for why a snapshot. + known_param_names: Vec, } impl AngleConstraintCommand { @@ -139,14 +169,15 @@ impl AngleConstraintCommand { let a1 = (f.end.y - f.start.y).atan2(f.end.x - f.start.x); let a2 = (m.end.y - m.start.y).atan2(m.end.x - m.start.x); let default_value = (a2 - a1).to_degrees(); - Some(Self { fixed_handle: fixed, moving_handle: moving, default_value }) + let known_param_names = scene.named_parameters().iter().map(|p| p.name.clone()).collect(); + Some(Self { fixed_handle: fixed, moving_handle: moving, default_value, known_param_names }) } - fn build(&self, target_degrees: f64) -> Option { + fn build(&self, target: DrivingValue) -> Option { Some(CmdResult::AddSketchConstraint { kind: ConstraintKind::Angle, refs: vec![SketchRef::whole(self.fixed_handle), SketchRef::whole(self.moving_handle)], - driving_param: Some(target_degrees), + driving_param: Some(target), label: "Angle constraint", }) } @@ -170,11 +201,11 @@ impl CadCommand for AngleConstraintCommand { } fn on_enter(&mut self) -> CmdResult { - self.build(self.default_value).unwrap_or(CmdResult::Cancel) + self.build(DrivingValue::Literal(self.default_value)).unwrap_or(CmdResult::Cancel) } fn on_text_input(&mut self, text: &str) -> Option { - let value: f64 = text.trim().parse().ok()?; + let value = parse_driving_value(text, &self.known_param_names)?; self.build(value) } @@ -182,3 +213,30 @@ impl CadCommand for AngleConstraintCommand { CmdResult::Cancel } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_driving_value_prefers_a_numeric_literal() { + let known = vec!["hole_dia".to_string()]; + assert_eq!(parse_driving_value("12.5", &known), Some(DrivingValue::Literal(12.5))); + // Distance's own `build` rejects a non-positive target later; the + // parse step itself accepts any number, negative included. + assert_eq!(parse_driving_value("-3", &known), Some(DrivingValue::Literal(-3.0))); + } + + #[test] + fn parse_driving_value_recognizes_a_known_parameter_name() { + let known = vec!["hole_dia".to_string(), "plate_len".to_string()]; + assert_eq!(parse_driving_value("hole_dia", &known), Some(DrivingValue::Named("hole_dia".to_string()))); + } + + #[test] + fn parse_driving_value_rejects_an_unknown_token() { + let known = vec!["hole_dia".to_string()]; + assert_eq!(parse_driving_value("bogus", &known), None); + assert_eq!(parse_driving_value("", &known), None); + } +} diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 5d86346ee..8fbbfac1e 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -36,6 +36,8 @@ mod group_layer; mod layout; mod limits; mod modify; +pub mod named_parameters; +mod named_parameters_persist; mod mspace; mod page_setup; mod paper; @@ -1934,6 +1936,17 @@ pub struct Scene { /// touches directly, materialized into `document.objects` only right /// before a save and read back right after a load. pub(crate) sketch_constraints: Vec, + /// Document-wide named-parameter/expression table (`docs/ + /// named_parameters_design.md`). One table per document (not per-scope, + /// unlike `sketch_constraints` — a parameter is meant to be shared + /// across the whole drawing), lives on `Scene` for the same reason + /// `sketch_constraints` does: co-located with the `document: CadDocument` + /// it's synced against. Synced with a single document-level + /// `OCS_NAMED_PARAMETERS` XRecord (hung off `document.header. + /// named_objects_dict_handle`, not a per-`BlockRecord` one) around + /// open/save (`named_parameters_persist.rs`, same "lazy" model as + /// `sketch_persist.rs`). + pub(crate) named_parameters: named_parameters::ParameterTable, /// Tessellated block definitions in block-local coords, keyed by render /// background and block epoch. Model and Paper adapt black/white colours /// differently; retaining both variants prevents a full block rebuild on @@ -2167,6 +2180,7 @@ impl Scene { dependency_index_cache: RefCell::new(None), associative_hatch_source_cache: RefCell::new(None), sketch_constraints: Vec::new(), + named_parameters: named_parameters::ParameterTable::new(), has_associative_centers: std::cell::Cell::new(None), block_defn_cache: RefCell::new(HashMap::default()), entity_index_cache: RefCell::new(None), diff --git a/src/scene/named_parameters.rs b/src/scene/named_parameters.rs new file mode 100644 index 000000000..3168b81cd --- /dev/null +++ b/src/scene/named_parameters.rs @@ -0,0 +1,898 @@ +//! Named, formula-capable document parameters — data model. +//! +//! Design: `docs/named_parameters_design.md`. This is stage 1 of that doc's +//! §4 staged plan: the parameter table type plus a small expression +//! parser/evaluator, unit-tested in isolation — no document/UI integration +//! yet (that's stage 2's XRecord persistence, mirroring +//! [`super::sketch_persist`], and stage 3's +//! `SketchConstraint::driving_param` wiring). +//! +//! A [`Parameter`] is a name plus a formula: either a bare literal (`12.5`) +//! or an expression referencing other parameters by name (`2 * hole_dia + +//! 1.5`). A [`ParameterTable`] holds every parameter for one document (per +//! the design doc's §2: one table per document, not per-[`super:: +//! sketch_constraints::SketchScope`]) and resolves each one's numeric value, +//! rejecting a circular reference at the moment it would be introduced +//! rather than at evaluation time — the design doc's explicit requirement +//! ("A referencing B referencing A must be rejected, not silently +//! infinite-loop or panic"), and the same real-time-rejection behavior a +//! spreadsheet gives for a circular formula. +//! +//! No existing workspace dependency parses expressions (the design doc's §2 +//! flags checking `evalexpr`/`meval` before hand-rolling one). Hand-rolled +//! here instead: cycle detection needs the set of names an expression +//! references *before* it's evaluated, which means owning the parse tree — +//! treating evaluation as a black box, the way a generic expression-eval +//! crate is normally used, would just mean re-deriving that same dependency +//! information by other means. [`Expr::refs`] *is* that dependency +//! information, not extra work spent duplicating what such a crate computes +//! internally anyway. + +use super::Scene; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fmt; + +/// A parsed formula. Ordinary arithmetic plus [`Expr::Ref`] (a reference to +/// another parameter by name — what makes this a *named*-parameter system +/// rather than a plain calculator) and [`Expr::Call`] (a function call). +/// `Call` is in the grammar now so it doesn't need a rewrite if/when more +/// functions are wanted later (design doc §5 open question 2 leaves the +/// exact v1 function set undecided); only a small, conservative builtin set +/// is actually wired up for now (see `call_builtin`). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum Expr { + Number(f64), + Ref(String), + Neg(Box), + Add(Box, Box), + Sub(Box, Box), + Mul(Box, Box), + Div(Box, Box), + Pow(Box, Box), + Call(String, Vec), +} + +impl Expr { + /// Every parameter name this expression reads, deduplicated in + /// first-seen order. This is the entire basis for cycle detection + /// (`ParameterTable::set`): one dependency edge per name returned here. + fn refs(&self, out: &mut Vec) { + match self { + Expr::Number(_) => {} + Expr::Ref(name) => { + if !out.contains(name) { + out.push(name.clone()); + } + } + Expr::Neg(a) => a.refs(out), + Expr::Add(a, b) | Expr::Sub(a, b) | Expr::Mul(a, b) | Expr::Div(a, b) | Expr::Pow(a, b) => { + a.refs(out); + b.refs(out); + } + Expr::Call(_, args) => { + for arg in args { + arg.refs(out); + } + } + } + } +} + +/// Everything that can go wrong parsing, defining, or resolving a +/// parameter. Every case is reported back to the caller (and, in later +/// stages, the UI) rather than panicking — matching this codebase's +/// established pattern of isolating a bad derived value instead of letting +/// it take down an unrelated computation (e.g. dangling-constraint handling +/// in `sketch_solve.rs`). +#[derive(Debug, Clone, PartialEq)] +pub enum ParamError { + /// A formula's text doesn't parse. The `String` is a short, human- + /// readable reason, not a machine-parseable code. + Parse(String), + /// Not a valid identifier, or collides with a builtin function name + /// (`is_reserved_name`). + InvalidName(String), + /// Accepting this formula would close a circular reference right now. + /// The path reads start-to-finish, e.g. `["a", "b", "a"]` for `a = b`, + /// `b = a`. + Cycle(Vec), + UnknownFunction(String), + WrongArgCount { function: String, expected: usize, found: usize }, + /// Raised by `resolve`/`resolve_all`, not `set` — a formula may + /// legitimately reference a parameter that doesn't exist *yet* (order of + /// definition shouldn't matter, mirroring a spreadsheet); this only + /// surfaces once something actually tries to read the missing value. + UndefinedReference(String), + DivisionByZero, +} + +impl fmt::Display for ParamError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ParamError::Parse(msg) => write!(f, "formula error: {msg}"), + ParamError::InvalidName(name) => write!(f, "'{name}' is not a valid parameter name"), + ParamError::Cycle(path) => write!(f, "circular reference: {}", path.join(" -> ")), + ParamError::UnknownFunction(name) => write!(f, "unknown function '{name}'"), + ParamError::WrongArgCount { function, expected, found } => { + write!(f, "{function}() expects {expected} argument(s), found {found}") + } + ParamError::UndefinedReference(name) => write!(f, "'{name}' is not defined"), + ParamError::DivisionByZero => write!(f, "division by zero"), + } + } +} + +impl std::error::Error for ParamError {} + +/// The v1 builtin function set — deliberately small (design doc §5 open +/// question 2 defers the full surface). Also doubles as the reserved-word +/// list `is_reserved_name` checks a parameter name against, so a formula +/// like `sqrt(x)` is never ambiguous between "call the builtin" and +/// "multiply by a parameter named sqrt". +const BUILTIN_FUNCTIONS: &[&str] = &["sqrt", "abs", "pow", "min", "max"]; + +pub fn is_reserved_name(name: &str) -> bool { + BUILTIN_FUNCTIONS.contains(&name) +} + +/// A valid parameter name: starts with a letter or underscore, followed by +/// letters/digits/underscores. (Design doc §5 open question 5.) +pub fn is_valid_name(name: &str) -> bool { + let mut chars = name.chars(); + match chars.next() { + Some(c) if c.is_alphabetic() || c == '_' => {} + _ => return false, + } + chars.all(|c| c.is_alphanumeric() || c == '_') +} + +/// A dimensional constraint's driving value (design doc §2's own suggested +/// shape for `SketchConstraint::driving_param`, stage 3 of the named- +/// parameters staged plan): either a literal number typed directly, or a +/// reference to a named parameter resolved through a [`ParameterTable`] at +/// solve time. Lives here rather than in `sketch_constraints.rs` since +/// resolving `Named` needs `ParameterTable`, which this module owns. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum DrivingValue { + Literal(f64), + Named(String), +} + +impl DrivingValue { + /// Resolves to a number: a literal resolves to itself (never fails); a + /// named reference resolves through `table`, which can fail the same + /// ways any [`ParameterTable::resolve`] call can (undefined reference, + /// division by zero — a cycle should be unreachable here since `set` + /// already refuses to create one). + pub fn resolve(&self, table: &ParameterTable) -> Result { + match self { + DrivingValue::Literal(value) => Ok(*value), + DrivingValue::Named(name) => table.resolve(name), + } + } +} + +impl From for DrivingValue { + fn from(value: f64) -> Self { + DrivingValue::Literal(value) + } +} + +fn call_builtin(name: &str, args: &[f64]) -> Result { + fn check_arity(name: &str, args: &[f64], expected: usize) -> Result<(), ParamError> { + if args.len() != expected { + return Err(ParamError::WrongArgCount { function: name.to_string(), expected, found: args.len() }); + } + Ok(()) + } + match name { + "sqrt" => { + check_arity(name, args, 1)?; + Ok(args[0].sqrt()) + } + "abs" => { + check_arity(name, args, 1)?; + Ok(args[0].abs()) + } + "pow" => { + check_arity(name, args, 2)?; + Ok(args[0].powf(args[1])) + } + "min" => { + check_arity(name, args, 2)?; + Ok(args[0].min(args[1])) + } + "max" => { + check_arity(name, args, 2)?; + Ok(args[0].max(args[1])) + } + _ => Err(ParamError::UnknownFunction(name.to_string())), + } +} + +#[derive(Debug, Clone, PartialEq)] +enum Token { + Number(f64), + Ident(String), + Plus, + Minus, + Star, + Slash, + Caret, + LParen, + RParen, + Comma, +} + +fn tokenize(src: &str) -> Result, ParamError> { + let chars: Vec = src.chars().collect(); + let mut tokens = Vec::new(); + let mut i = 0; + while i < chars.len() { + let c = chars[i]; + if c.is_whitespace() { + i += 1; + continue; + } + match c { + '+' => { + tokens.push(Token::Plus); + i += 1; + } + '-' => { + tokens.push(Token::Minus); + i += 1; + } + '*' => { + tokens.push(Token::Star); + i += 1; + } + '/' => { + tokens.push(Token::Slash); + i += 1; + } + '^' => { + tokens.push(Token::Caret); + i += 1; + } + '(' => { + tokens.push(Token::LParen); + i += 1; + } + ')' => { + tokens.push(Token::RParen); + i += 1; + } + ',' => { + tokens.push(Token::Comma); + i += 1; + } + _ if c.is_ascii_digit() || c == '.' => { + let start = i; + let mut seen_dot = c == '.'; + i += 1; + while i < chars.len() { + let d = chars[i]; + if d.is_ascii_digit() { + i += 1; + } else if d == '.' && !seen_dot { + seen_dot = true; + i += 1; + } else { + break; + } + } + // Scientific notation: 1e-3, 2.5E10. Only consumed when a + // digit (optionally signed) actually follows the e/E, so a + // bare trailing "e" is left for the identifier lexer instead + // (not reachable today since no builtin/constant is named + // "e", but keeps this branch from eating an identifier that + // happens to start with it, e.g. a formula like `2 * ecc`). + if i < chars.len() && (chars[i] == 'e' || chars[i] == 'E') { + let mut j = i + 1; + if j < chars.len() && (chars[j] == '+' || chars[j] == '-') { + j += 1; + } + if j < chars.len() && chars[j].is_ascii_digit() { + while j < chars.len() && chars[j].is_ascii_digit() { + j += 1; + } + i = j; + } + } + let text: String = chars[start..i].iter().collect(); + let value: f64 = text.parse().map_err(|_| ParamError::Parse(format!("invalid number '{text}'")))?; + tokens.push(Token::Number(value)); + } + _ if c.is_alphabetic() || c == '_' => { + let start = i; + i += 1; + while i < chars.len() && (chars[i].is_alphanumeric() || chars[i] == '_') { + i += 1; + } + tokens.push(Token::Ident(chars[start..i].iter().collect())); + } + other => return Err(ParamError::Parse(format!("unexpected character '{other}'"))), + } + } + Ok(tokens) +} + +struct Parser<'a> { + tokens: &'a [Token], + pos: usize, +} + +impl<'a> Parser<'a> { + fn peek(&self) -> Option<&Token> { + self.tokens.get(self.pos) + } + + fn bump(&mut self) -> Option { + let t = self.tokens.get(self.pos).cloned(); + if t.is_some() { + self.pos += 1; + } + t + } + + fn expect(&mut self, expected: Token) -> Result<(), ParamError> { + match self.bump() { + Some(t) if t == expected => Ok(()), + Some(t) => Err(ParamError::Parse(format!("expected {expected:?}, found {t:?}"))), + None => Err(ParamError::Parse(format!("expected {expected:?}, found end of formula"))), + } + } + + // Precedence, low to high: expr (+ -) > term (* /) > unary (-) > power (^) > primary. + // `^` binds tighter than unary minus on its left operand but is + // right-associative on its own exponent (`parse_unary` recursing back + // into `parse_power` covers `2^-2` == `2^(-2)`, and `2^3^2` == + // `2^(3^2)`). + fn parse_expr(&mut self) -> Result { + let mut lhs = self.parse_term()?; + loop { + match self.peek() { + Some(Token::Plus) => { + self.pos += 1; + lhs = Expr::Add(Box::new(lhs), Box::new(self.parse_term()?)); + } + Some(Token::Minus) => { + self.pos += 1; + lhs = Expr::Sub(Box::new(lhs), Box::new(self.parse_term()?)); + } + _ => break, + } + } + Ok(lhs) + } + + fn parse_term(&mut self) -> Result { + let mut lhs = self.parse_unary()?; + loop { + match self.peek() { + Some(Token::Star) => { + self.pos += 1; + lhs = Expr::Mul(Box::new(lhs), Box::new(self.parse_unary()?)); + } + Some(Token::Slash) => { + self.pos += 1; + lhs = Expr::Div(Box::new(lhs), Box::new(self.parse_unary()?)); + } + _ => break, + } + } + Ok(lhs) + } + + fn parse_unary(&mut self) -> Result { + match self.peek() { + Some(Token::Minus) => { + self.pos += 1; + Ok(Expr::Neg(Box::new(self.parse_unary()?))) + } + Some(Token::Plus) => { + self.pos += 1; + self.parse_unary() + } + _ => self.parse_power(), + } + } + + fn parse_power(&mut self) -> Result { + let base = self.parse_primary()?; + if let Some(Token::Caret) = self.peek() { + self.pos += 1; + let exponent = self.parse_unary()?; + return Ok(Expr::Pow(Box::new(base), Box::new(exponent))); + } + Ok(base) + } + + fn parse_primary(&mut self) -> Result { + match self.bump() { + Some(Token::Number(n)) => Ok(Expr::Number(n)), + Some(Token::Ident(name)) => { + if matches!(self.peek(), Some(Token::LParen)) { + self.pos += 1; + let mut args = Vec::new(); + if !matches!(self.peek(), Some(Token::RParen)) { + loop { + args.push(self.parse_expr()?); + if matches!(self.peek(), Some(Token::Comma)) { + self.pos += 1; + } else { + break; + } + } + } + self.expect(Token::RParen)?; + Ok(Expr::Call(name, args)) + } else { + Ok(Expr::Ref(name)) + } + } + Some(Token::LParen) => { + let inner = self.parse_expr()?; + self.expect(Token::RParen)?; + Ok(inner) + } + Some(other) => Err(ParamError::Parse(format!("unexpected token {other:?}"))), + None => Err(ParamError::Parse("unexpected end of formula".to_string())), + } + } +} + +/// Parses a formula's text into an [`Expr`], without evaluating or +/// resolving any reference. `ParameterTable::set` is the usual entry point; +/// exposed standalone so a UI stage can validate-as-you-type without +/// touching the table. +pub fn parse(source: &str) -> Result { + let tokens = tokenize(source)?; + if tokens.is_empty() { + return Err(ParamError::Parse("empty formula".to_string())); + } + let mut parser = Parser { tokens: &tokens, pos: 0 }; + let expr = parser.parse_expr()?; + if parser.pos != tokens.len() { + return Err(ParamError::Parse(format!("unexpected trailing token {:?}", tokens[parser.pos]))); + } + Ok(expr) +} + +/// One named parameter: a name plus its formula, both the original typed +/// text (`source`, for redisplay/re-editing) and the parsed [`Expr`] +/// (`expr`, for evaluation). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Parameter { + pub name: String, + pub source: String, + pub expr: Expr, +} + +/// Every named parameter for one document (design doc §2: per-document, not +/// per-[`super::sketch_constraints::SketchScope`] — a block/xref does not +/// get its own independent table; open question 4 leaves whether that's the +/// final answer, but it's this stage's working assumption). +/// +/// `parameters` is intentionally not `pub` (unlike the sibling +/// `SketchConstraintSet::constraints`): this table's entire value is that +/// every stored formula is already known to be parse-valid and cycle-free, +/// an invariant only `set`/`remove` maintain. A direct external push could +/// silently violate it. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ParameterTable { + parameters: Vec, +} + +impl ParameterTable { + pub fn new() -> Self { + Self::default() + } + + pub fn is_empty(&self) -> bool { + self.parameters.is_empty() + } + + pub fn len(&self) -> usize { + self.parameters.len() + } + + /// Insertion order — the order parameters were first defined in, stable + /// across edits to existing ones. The natural listing order for a + /// future parameters panel. + pub fn iter(&self) -> impl Iterator { + self.parameters.iter() + } + + pub fn get(&self, name: &str) -> Option<&Parameter> { + self.parameters.iter().find(|p| p.name == name) + } + + pub fn contains(&self, name: &str) -> bool { + self.get(name).is_some() + } + + fn index_of(&self, name: &str) -> Option { + self.parameters.iter().position(|p| p.name == name) + } + + /// Defines a new parameter or redefines an existing one's formula (one + /// upsert operation — there is no separate "must not already exist" + /// entry point; a future UI stage that wants add-vs-rename semantics can + /// check `contains` itself first). + /// + /// Rejects: an invalid identifier or one colliding with a builtin + /// function name (`is_valid_name`/`is_reserved_name`), a formula that + /// fails to parse, and — the design doc's explicit requirement — a + /// formula that would close a circular reference right now. A reference + /// to a parameter that doesn't exist *yet* is not rejected (order of + /// definition shouldn't matter, mirroring a spreadsheet); it only + /// surfaces as [`ParamError::UndefinedReference`] when [`Self::resolve`] + /// actually needs that value. + pub fn set(&mut self, name: &str, source: &str) -> Result<(), ParamError> { + if !is_valid_name(name) || is_reserved_name(name) { + return Err(ParamError::InvalidName(name.to_string())); + } + let expr = parse(source)?; + self.check_no_cycle(name, &expr)?; + match self.index_of(name) { + Some(i) => { + self.parameters[i].source = source.to_string(); + self.parameters[i].expr = expr; + } + None => self.parameters.push(Parameter { name: name.to_string(), source: source.to_string(), expr }), + } + Ok(()) + } + + /// Removes a parameter by name. Returns whether one was actually + /// removed. Deliberately does not check whether another parameter's + /// formula still references `name` — that becomes an + /// [`ParamError::UndefinedReference`] for the referencing parameter the + /// next time it's resolved, the same graceful-degradation treatment an + /// unresolvable reference gets everywhere else in this module, rather + /// than a special "can't delete, still in use" rule this stage doesn't + /// need to invent (a future UI stage may still want to warn before + /// deleting — that's a UI-layer decision, not a data-layer one). + pub fn remove(&mut self, name: &str) -> bool { + match self.index_of(name) { + Some(i) => { + self.parameters.remove(i); + true + } + None => false, + } + } + + /// Would accepting `new_expr` as `name`'s formula close a circular + /// reference right now? Walks the dependency graph from each of + /// `new_expr`'s direct references, following each visited parameter's + /// *existing* stored formula, looking for a path back to `name`. A + /// reference to a name not currently in the table just terminates that + /// branch — no cycle through something that doesn't exist yet. + fn check_no_cycle(&self, name: &str, new_expr: &Expr) -> Result<(), ParamError> { + let mut direct_refs = Vec::new(); + new_expr.refs(&mut direct_refs); + for start in &direct_refs { + let mut visiting = Vec::new(); + if let Some(mut path) = self.find_path(start, name, &mut visiting) { + let mut full = vec![name.to_string()]; + full.append(&mut path); + return Err(ParamError::Cycle(full)); + } + } + Ok(()) + } + + /// Depth-first search from `current` to `target`, following each + /// visited parameter's existing formula's references. Returns the path + /// (starting with `current`) if `target` is reachable. + fn find_path(&self, current: &str, target: &str, visiting: &mut Vec) -> Option> { + if current == target { + return Some(vec![current.to_string()]); + } + if visiting.iter().any(|v| v == current) { + // A cycle that doesn't involve `target` shouldn't be reachable + // (`set` refuses to create one), but bail out rather than loop + // forever if that invariant is ever violated some other way + // (e.g. a foreign/hand-edited file deserialized straight into a + // table without going through `set`). + return None; + } + let param = self.get(current)?; + visiting.push(current.to_string()); + let mut refs = Vec::new(); + param.expr.refs(&mut refs); + for r in &refs { + if let Some(mut rest) = self.find_path(r, target, visiting) { + let mut path = vec![current.to_string()]; + path.append(&mut rest); + visiting.pop(); + return Some(path); + } + } + visiting.pop(); + None + } + + /// Resolves one parameter's numeric value, evaluating its dependency + /// chain as needed. No state is kept between calls (a fresh memo cache + /// every call) — mirrors `refresh_sketch_constraints`'s + /// rebuild-from-scratch-per-trigger model (design doc §2): this stage + /// has no document/UI integration yet to decide when a cached value + /// would go stale, so it simply never caches across calls. + pub fn resolve(&self, name: &str) -> Result { + let mut cache = HashMap::new(); + let mut stack = Vec::new(); + self.resolve_inner(name, &mut cache, &mut stack) + } + + /// Resolves every parameter, isolating a failure to just the parameters + /// it actually affects rather than aborting the whole table — the same + /// don't-let-one-bad-item-take-down-everything-else treatment this + /// codebase already gives dangling constraints and similar derived-data + /// failures elsewhere. + pub fn resolve_all(&self) -> HashMap> { + let mut out = HashMap::with_capacity(self.parameters.len()); + for param in &self.parameters { + out.insert(param.name.clone(), self.resolve(¶m.name)); + } + out + } + + fn resolve_inner(&self, name: &str, cache: &mut HashMap, stack: &mut Vec) -> Result { + if let Some(v) = cache.get(name) { + return Ok(*v); + } + if stack.iter().any(|s| s == name) { + // Defense in depth only — `set` already refuses to create a + // cycle; see `find_path`'s doc comment for how one could still + // end up here. + let mut cyc = stack.clone(); + cyc.push(name.to_string()); + return Err(ParamError::Cycle(cyc)); + } + let param = self.get(name).ok_or_else(|| ParamError::UndefinedReference(name.to_string()))?; + stack.push(name.to_string()); + let value = self.eval(¶m.expr, cache, stack)?; + stack.pop(); + cache.insert(name.to_string(), value); + Ok(value) + } + + fn eval(&self, expr: &Expr, cache: &mut HashMap, stack: &mut Vec) -> Result { + Ok(match expr { + Expr::Number(n) => *n, + Expr::Ref(name) => self.resolve_inner(name, cache, stack)?, + Expr::Neg(a) => -self.eval(a, cache, stack)?, + Expr::Add(a, b) => self.eval(a, cache, stack)? + self.eval(b, cache, stack)?, + Expr::Sub(a, b) => self.eval(a, cache, stack)? - self.eval(b, cache, stack)?, + Expr::Mul(a, b) => self.eval(a, cache, stack)? * self.eval(b, cache, stack)?, + Expr::Div(a, b) => { + let denom = self.eval(b, cache, stack)?; + if denom == 0.0 { + return Err(ParamError::DivisionByZero); + } + self.eval(a, cache, stack)? / denom + } + Expr::Pow(a, b) => self.eval(a, cache, stack)?.powf(self.eval(b, cache, stack)?), + Expr::Call(fname, args) => { + let mut values = Vec::with_capacity(args.len()); + for arg in args { + values.push(self.eval(arg, cache, stack)?); + } + call_builtin(fname, &values)? + } + }) + } +} + +impl Scene { + /// The document's named-parameter table (design doc §2: one per + /// document). Read-only accessor; use [`Scene::named_parameters_mut`] to + /// define/redefine/remove a parameter. + pub fn named_parameters(&self) -> &ParameterTable { + &self.named_parameters + } + + pub fn named_parameters_mut(&mut self) -> &mut ParameterTable { + &mut self.named_parameters + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_arithmetic_with_ordinary_precedence() { + // 2 + 3 * 4 == 14, not 20 -- proves * binds tighter than +. + assert_eq!(eval_literal("2 + 3 * 4"), 14.0); + // (2 + 3) * 4 == 20 -- parens override precedence. + assert_eq!(eval_literal("(2 + 3) * 4"), 20.0); + // 2 ^ 3 ^ 2 == 2 ^ (3 ^ 2) == 512 -- right-associative power. + assert_eq!(eval_literal("2 ^ 3 ^ 2"), 512.0); + // Unary minus binds tighter than power's left operand is *read*, + // but -2^2 must still equal -(2^2) = -4 under this grammar (unary + // parses before power), matching common calculator convention. + assert_eq!(eval_literal("-2^2"), -4.0); + assert_eq!(eval_literal("2^-2"), 0.25); + assert_eq!(eval_literal("10 / 4"), 2.5); + assert_eq!(eval_literal("-(3 + 4)"), -7.0); + } + + fn eval_literal(src: &str) -> f64 { + let table = ParameterTable::new(); + let expr = parse(src).unwrap_or_else(|e| panic!("parse '{src}': {e}")); + table.eval(&expr, &mut HashMap::new(), &mut Vec::new()).unwrap_or_else(|e| panic!("eval '{src}': {e}")) + } + + #[test] + fn parses_scientific_notation() { + assert_eq!(eval_literal("1e3"), 1000.0); + assert_eq!(eval_literal("1.5e-2"), 0.015); + } + + #[test] + fn rejects_malformed_formulas() { + assert!(matches!(parse(""), Err(ParamError::Parse(_)))); + assert!(matches!(parse("1 +"), Err(ParamError::Parse(_)))); + assert!(matches!(parse("(1 + 2"), Err(ParamError::Parse(_)))); + assert!(matches!(parse("1 2"), Err(ParamError::Parse(_)))); + assert!(matches!(parse("1 @ 2"), Err(ParamError::Parse(_)))); + } + + #[test] + fn builtin_functions_evaluate_and_check_arity() { + assert_eq!(eval_literal("sqrt(16)"), 4.0); + assert_eq!(eval_literal("abs(-5)"), 5.0); + assert_eq!(eval_literal("min(3, 7)"), 3.0); + assert_eq!(eval_literal("max(3, 7)"), 7.0); + assert_eq!(eval_literal("pow(2, 10)"), 1024.0); + + let table = ParameterTable::new(); + let expr = parse("sqrt(1, 2)").unwrap(); + let err = table.eval(&expr, &mut HashMap::new(), &mut Vec::new()).unwrap_err(); + assert_eq!(err, ParamError::WrongArgCount { function: "sqrt".to_string(), expected: 1, found: 2 }); + + let expr = parse("bogus(1)").unwrap(); + let err = table.eval(&expr, &mut HashMap::new(), &mut Vec::new()).unwrap_err(); + assert_eq!(err, ParamError::UnknownFunction("bogus".to_string())); + } + + #[test] + fn division_by_zero_is_reported_not_panicked() { + let table = ParameterTable::new(); + let expr = parse("1 / 0").unwrap(); + assert_eq!(table.eval(&expr, &mut HashMap::new(), &mut Vec::new()), Err(ParamError::DivisionByZero)); + } + + #[test] + fn set_rejects_invalid_and_reserved_names() { + let mut table = ParameterTable::new(); + assert!(matches!(table.set("2bad", "1"), Err(ParamError::InvalidName(_)))); + assert!(matches!(table.set("has space", "1"), Err(ParamError::InvalidName(_)))); + assert!(matches!(table.set("", "1"), Err(ParamError::InvalidName(_)))); + assert!(matches!(table.set("sqrt", "1"), Err(ParamError::InvalidName(_)))); + assert!(table.set("_valid1", "1").is_ok()); + } + + #[test] + fn a_literal_parameter_resolves_to_its_number() { + let mut table = ParameterTable::new(); + table.set("plate_len", "42.5").unwrap(); + assert_eq!(table.resolve("plate_len"), Ok(42.5)); + } + + #[test] + fn a_formula_resolves_through_a_reference_chain() { + let mut table = ParameterTable::new(); + table.set("hole_dia", "5").unwrap(); + table.set("hole_spacing", "2 * hole_dia + 1.5").unwrap(); + table.set("row_span", "3 * hole_spacing").unwrap(); + assert_eq!(table.resolve("hole_spacing"), Ok(11.5)); + assert_eq!(table.resolve("row_span"), Ok(34.5)); + } + + #[test] + fn resolving_an_undefined_reference_reports_which_name_is_missing() { + let mut table = ParameterTable::new(); + table.set("a", "b + 1").unwrap(); + assert_eq!(table.resolve("a"), Err(ParamError::UndefinedReference("b".to_string()))); + } + + #[test] + fn set_rejects_a_direct_self_reference() { + let mut table = ParameterTable::new(); + let err = table.set("a", "a + 1").unwrap_err(); + assert!(matches!(err, ParamError::Cycle(_))); + assert!(!table.contains("a"), "a rejected formula must not be stored"); + } + + #[test] + fn set_rejects_an_indirect_cycle_at_the_definition_that_closes_it() { + let mut table = ParameterTable::new(); + table.set("a", "b").unwrap(); + // b doesn't exist yet -- not a cycle, a is just unresolved for now. + assert_eq!(table.resolve("a"), Err(ParamError::UndefinedReference("b".to_string()))); + // Defining b = a closes the loop right here and must be rejected, + // leaving b undefined rather than silently accepted. + let err = table.set("b", "a").unwrap_err(); + assert!(matches!(err, ParamError::Cycle(_))); + assert!(!table.contains("b")); + // a's formula (still "b") is untouched -- it stays unresolved, not + // corrupted by the rejected attempt. + assert_eq!(table.resolve("a"), Err(ParamError::UndefinedReference("b".to_string()))); + } + + #[test] + fn set_allows_redefining_an_existing_parameter_to_break_a_would_be_cycle() { + let mut table = ParameterTable::new(); + table.set("a", "1").unwrap(); + table.set("b", "a + 1").unwrap(); + // Redefining a in terms of b must be rejected (a <- b <- a)... + assert!(table.set("a", "b + 1").is_err()); + // ...but redefining a to something unrelated is always fine. + table.set("a", "5").unwrap(); + assert_eq!(table.resolve("a"), Ok(5.0)); + assert_eq!(table.resolve("b"), Ok(6.0)); + } + + #[test] + fn resolve_all_isolates_a_failure_to_the_affected_parameters_only() { + let mut table = ParameterTable::new(); + table.set("good", "10").unwrap(); + table.set("bad", "missing + 1").unwrap(); + table.set("depends_on_bad", "bad * 2").unwrap(); + + let results = table.resolve_all(); + assert_eq!(results.get("good"), Some(&Ok(10.0))); + assert_eq!(results.get("bad"), Some(&Err(ParamError::UndefinedReference("missing".to_string())))); + assert_eq!(results.get("depends_on_bad"), Some(&Err(ParamError::UndefinedReference("missing".to_string())))); + } + + #[test] + fn remove_drops_a_parameter_and_leaves_dependents_undefined_not_panicking() { + let mut table = ParameterTable::new(); + table.set("a", "1").unwrap(); + table.set("b", "a + 1").unwrap(); + assert!(table.remove("a")); + assert!(!table.remove("a"), "removing an already-removed name reports false"); + assert_eq!(table.resolve("b"), Err(ParamError::UndefinedReference("a".to_string()))); + } + + #[test] + fn set_upserts_an_existing_parameters_formula() { + let mut table = ParameterTable::new(); + table.set("a", "1").unwrap(); + table.set("a", "2").unwrap(); + assert_eq!(table.len(), 1, "redefining must not create a second entry"); + assert_eq!(table.resolve("a"), Ok(2.0)); + } + + #[test] + fn table_round_trips_through_bincode() { + let mut table = ParameterTable::new(); + table.set("hole_dia", "5").unwrap(); + table.set("hole_spacing", "2 * hole_dia + 1.5").unwrap(); + + let bytes = bincode::serialize(&table).expect("serialize"); + let restored: ParameterTable = bincode::deserialize(&bytes).expect("deserialize"); + + assert_eq!(restored.len(), 2); + assert_eq!(restored.resolve("hole_spacing"), Ok(11.5)); + assert_eq!(restored.get("hole_dia").unwrap().source, "5"); + } + + #[test] + fn iter_preserves_insertion_order_across_redefinitions() { + let mut table = ParameterTable::new(); + table.set("z", "1").unwrap(); + table.set("a", "2").unwrap(); + table.set("z", "3").unwrap(); // redefine, should not move to the end + let names: Vec<&str> = table.iter().map(|p| p.name.as_str()).collect(); + assert_eq!(names, vec!["z", "a"]); + } +} diff --git a/src/scene/named_parameters_persist.rs b/src/scene/named_parameters_persist.rs new file mode 100644 index 000000000..ff09b7085 --- /dev/null +++ b/src/scene/named_parameters_persist.rs @@ -0,0 +1,201 @@ +//! Save/load integration for [`super::named_parameters::ParameterTable`] — +//! design doc §4 stage 2 (the XRecord persistence spike) plus enough of +//! stage 2's "wire it into open/save" to actually be useful, following +//! [`super::sketch_persist`]'s exact precedent (itself the sketch-constraint +//! project's stage 4/§5.2): the "lazy" model, where the table lives only in +//! `Scene::named_parameters` in-memory during editing and is materialized +//! into `document.objects` as one XRecord right before a save, then read +//! back right after a load. +//! +//! The one structural difference from `sketch_persist`: a +//! [`super::named_parameters::ParameterTable`] is document-wide, not +//! per-[`super::sketch_constraints::SketchScope`], so there is exactly one +//! XRecord for the whole document rather than one per scope owner. +//! +//! **Owner handle, and a real round-trip gap found while building this**: +//! the obvious choice was `document.header.named_objects_dict_handle` (the +//! document's own root Named Objects Dictionary) — `extension_dictionary_ +//! handle` in the vendored `cadcodec` source explicitly resolves an +//! extension dictionary for a `Dictionary`-type owner (`ObjectType:: +//! Dictionary(value) => value.xdictionary_handle`), and that crate's own +//! tests (e.g. `tests/issue51.rs`) use `named_objects_dict_handle` as an +//! XRecord owner directly. In practice this round-tripped through DWG but +//! **silently lost the XRecord through DXF** (caught by this module's own +//! save/load tests, not assumed): `CadDocument::ensure_xrecord` only writes +//! the owner's *own* `xdictionary_handle` field when `get_entity_mut(owner)` +//! succeeds (a real "entity"); a `Dictionary` owner like the root NOD isn't +//! one, so `ensure_xrecord` falls back to a runtime-only side map +//! (`xdic_by_handle`) instead. The DWG writer consults that side map +//! (`io/dwg/dwg_document_builder.rs` et al.); the DXF writer never does (no +//! `xdic_by_handle` reference anywhere under `io/dxf/writer/`) — so a +//! Dictionary-owned extension dictionary is invisible to a DXF save. This +//! is a gap in the vendored crate for this specific owner-type combination, +//! not something introduced here; per this project's own stated preference +//! to avoid needing changes to `acadrust`/`cadkernel`, the fix is to pick a +//! different, already-proven owner rather than patch it. +//! +//! **Owner used instead**: `document.header.model_space_block_handle` — a +//! real `BlockRecord` entity, the exact anchor `sketch_persist`'s own +//! `SketchScope::ModelSpace` case already proved round-trips cleanly +//! through both DWG and DXF. Model space is not a semantically perfect fit +//! for "the whole document" (a parameter table has nothing to do with model +//! space specifically), but it is guaranteed to exist in every +//! `CadDocument` and is a real entity, which is the property that actually +//! matters here. + +use super::named_parameters::ParameterTable; +use super::Scene; + +const XRECORD_KEY: &str = "OCS_NAMED_PARAMETERS"; + +/// Prefixed onto the serialized blob so a future schema change can be +/// detected and gracefully skipped rather than silently misreading bytes — +/// same call-out as `sketch_persist::FORMAT_VERSION`, same caveat that the +/// migration path itself is still undesigned. +const FORMAT_VERSION: u8 = 1; + +fn encode(table: &ParameterTable) -> Option> { + let mut bytes = vec![FORMAT_VERSION]; + bytes.extend(bincode::serialize(table).ok()?); + Some(bytes) +} + +fn decode(bytes: &[u8]) -> Option { + let (&version, body) = bytes.split_first()?; + if version != FORMAT_VERSION { + return None; + } + bincode::deserialize(body).ok() +} + +impl Scene { + /// Writes `named_parameters` into the document's `OCS_NAMED_PARAMETERS` + /// XRecord, ready for whatever save call happens next to serialize + /// `self.document` as-is. Called from the same save entry points as + /// `materialize_sketch_constraints_for_save` + /// (`OpenCADStudio::prepare_native_save`, the wasm save path). + /// + /// An empty table is skipped rather than writing an empty XRecord — an + /// unconstrained-by-parameters drawing (the common case, at least until + /// this feature is used) should not gain persisted-but-empty + /// bookkeeping on every save. + pub(crate) fn materialize_named_parameters_for_save(&mut self) { + if self.named_parameters.is_empty() { + return; + } + let Some(bytes) = encode(&self.named_parameters) else { return }; + let owner = self.document.header.model_space_block_handle; + if owner.is_null() { + return; + } + self.document.ensure_xrecord(owner, XRECORD_KEY); + if let Some(record) = self.document.xrecord_mut(owner, XRECORD_KEY) { + // Overwrite, not append: a resave must replace the prior blob, + // not accumulate one more Chunk entry every time. + record.entries.clear(); + record + .entries + .push(acadrust::objects::XRecordEntry::new(310, acadrust::objects::XRecordValue::Chunk(bytes))); + } + } + + /// Populates `named_parameters` from the just-loaded `self.document`'s + /// `OCS_NAMED_PARAMETERS` XRecord, if any — called right after a + /// document open installs its `CadDocument` into this `Scene`, the same + /// call sites `load_sketch_constraints_from_document` already has + /// (`OpenCADStudio::on_file_opened`, the automation `"open"`/`"new"` + /// ops). Replaces whatever was already in `named_parameters` (a fresh + /// table for a real open; `"new"`'s blank document has nothing to find + /// anyway). + pub(crate) fn load_named_parameters_from_document(&mut self) { + self.named_parameters = ParameterTable::new(); + let owner = self.document.header.model_space_block_handle; + if owner.is_null() { + return; + } + let Some(record) = self.document.xrecord(owner, XRECORD_KEY) else { return }; + let Some(bytes) = record.entries.iter().find_map(|entry| match &entry.value { + acadrust::objects::XRecordValue::Chunk(bytes) => Some(bytes.clone()), + _ => None, + }) else { + return; + }; + if let Some(table) = decode(&bytes) { + self.named_parameters = table; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_parameter_table_survives_materialize_and_reload() { + let mut scene = Scene::new(); + scene.named_parameters_mut().set("hole_dia", "5").unwrap(); + scene.named_parameters_mut().set("hole_spacing", "2 * hole_dia + 1.5").unwrap(); + + scene.materialize_named_parameters_for_save(); + // Simulate the load-time side of a save/reload round trip: drop the + // in-memory table (a fresh open starts empty) and repopulate purely + // from what materialize just wrote into `document.objects`. + scene.named_parameters = ParameterTable::new(); + scene.load_named_parameters_from_document(); + + assert_eq!(scene.named_parameters().len(), 2); + assert_eq!(scene.named_parameters().resolve("hole_spacing"), Ok(11.5)); + assert_eq!(scene.named_parameters().get("hole_dia").unwrap().source, "5"); + } + + /// The above test only proves `materialize`/`load` agree with each + /// other on the same in-memory `CadDocument` — this drives a real + /// `save_to_bytes`/`load_bytes` round trip (the same primitives + /// `OpenCADStudio::on_file_opened`'s native save/open path uses), + /// through both supported formats, mirroring + /// `sketch_persist`'s own real-bytes tests. + fn full_bytes_roundtrip(ext: &str) -> ParameterTable { + let mut scene = Scene::new(); + scene.named_parameters_mut().set("plate_len", "42.5").unwrap(); + scene.named_parameters_mut().set("plate_width", "plate_len / 2").unwrap(); + + scene.materialize_named_parameters_for_save(); + let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version) + .unwrap_or_else(|e| panic!("save to {ext} bytes: {e}")); + let mut reloaded_scene = Scene::new(); + reloaded_scene.document = crate::io::load_bytes(&format!("named_params_roundtrip.{ext}"), bytes) + .unwrap_or_else(|e| panic!("reload {ext} bytes: {e}")); + reloaded_scene.load_named_parameters_from_document(); + + reloaded_scene.named_parameters + } + + #[test] + fn a_parameter_table_survives_a_real_dxf_save_and_load_round_trip() { + let restored = full_bytes_roundtrip("dxf"); + assert_eq!(restored.len(), 2); + assert_eq!(restored.resolve("plate_width"), Ok(21.25)); + } + + #[test] + fn a_parameter_table_survives_a_real_dwg_save_and_load_round_trip() { + let restored = full_bytes_roundtrip("dwg"); + assert_eq!(restored.len(), 2); + assert_eq!(restored.resolve("plate_width"), Ok(21.25)); + } + + #[test] + fn an_empty_table_is_not_materialized() { + let mut scene = Scene::new(); + scene.materialize_named_parameters_for_save(); + let owner = scene.document.header.model_space_block_handle; + assert!(scene.document.xrecord(owner, XRECORD_KEY).is_none(), "an empty table should not create an XRecord"); + } + + #[test] + fn decode_rejects_a_mismatched_format_version() { + let mut bytes = vec![FORMAT_VERSION.wrapping_add(1)]; + bytes.extend(bincode::serialize(&ParameterTable::new()).unwrap()); + assert!(decode(&bytes).is_none(), "a future/unknown format version must be rejected, not misread"); + } +} diff --git a/src/scene/sketch_constraints.rs b/src/scene/sketch_constraints.rs index a5bd58246..a3ece49ce 100644 --- a/src/scene/sketch_constraints.rs +++ b/src/scene/sketch_constraints.rs @@ -11,6 +11,7 @@ //! entities and is meant to be re-solved every time referenced geometry //! changes, not applied once. +use super::named_parameters::DrivingValue; use acadrust::types::{Handle, Vector3}; use serde::{Deserialize, Serialize}; @@ -90,11 +91,14 @@ pub struct SketchConstraint { pub id: ConstraintId, pub kind: ConstraintKind, pub refs: Vec, - /// The typed target for a dimensional constraint (a `Distance`'s - /// length, an `Angle`'s degrees, a `Radius`'s radius). `None` for every - /// purely-geometric kind (Coincident, Horizontal, Vertical, Parallel, - /// Perpendicular, Equal, Tangent). - pub driving_param: Option, + /// The target for a dimensional constraint (a `Distance`'s length, an + /// `Angle`'s degrees, a `Radius`'s radius) — a literal number or a + /// named-parameter reference (`docs/named_parameters_design.md`, + /// resolved through `Scene::named_parameters` at solve time by + /// `sketch_solve::build_constraint`). `None` for every purely-geometric + /// kind (Coincident, Horizontal, Vertical, Parallel, Perpendicular, + /// Equal, Tangent). + pub driving_param: Option, /// Lets a user suppress a constraint without losing it — a re-solve /// skips a disabled constraint entirely. pub enabled: bool, @@ -160,7 +164,7 @@ impl SketchConstraintSet { } /// Appends a constraint, assigning it a fresh id unique within this set. - pub fn add(&mut self, kind: ConstraintKind, refs: Vec, driving_param: Option) -> ConstraintId { + pub fn add(&mut self, kind: ConstraintKind, refs: Vec, driving_param: Option) -> ConstraintId { let id = self.next_id; self.next_id += 1; self.constraints.push(SketchConstraint { id, kind, refs, driving_param, enabled: true }); @@ -348,9 +352,17 @@ impl ConstraintKind { /// The full glyph text for one constraint: its symbol, plus the driving /// value for a dimensional kind (Distance/Angle/Radius). pub(crate) fn glyph_label(constraint: &SketchConstraint) -> String { - match (constraint.kind, constraint.driving_param) { - (ConstraintKind::Angle, Some(value)) => format!("{} {value:.1}°", constraint.kind.glyph_symbol()), - (_, Some(value)) => format!("{} {value:.2}", constraint.kind.glyph_symbol()), + match (constraint.kind, &constraint.driving_param) { + (ConstraintKind::Angle, Some(DrivingValue::Literal(value))) => format!("{} {value:.1}°", constraint.kind.glyph_symbol()), + (_, Some(DrivingValue::Literal(value))) => format!("{} {value:.2}", constraint.kind.glyph_symbol()), + // A named reference has no single resolved number to show without + // threading `ParameterTable` into every glyph-render call site + // (`src/ui/overlay.rs`) — showing the name itself is enough for now; + // stage 4's parameters panel is the natural place to reconsider this + // once a named `driving_param` can actually be authored through the + // UI (nothing can yet — this arm exists so the match is exhaustive + // and correct ahead of that UI, not because it's reachable today). + (_, Some(DrivingValue::Named(name))) => format!("{} {name}", constraint.kind.glyph_symbol()), (_, None) => constraint.kind.glyph_symbol().to_string(), } } @@ -415,7 +427,7 @@ impl super::Scene { if handle_map.is_empty() { return; } - let mut to_add: Vec<(usize, ConstraintKind, Vec, Option)> = Vec::new(); + let mut to_add: Vec<(usize, ConstraintKind, Vec, Option)> = Vec::new(); for (scope_index, set) in self.sketch_constraints.iter().enumerate() { for c in &set.constraints { if !c.enabled || !c.refs.iter().all(|r| handle_map.contains_key(&r.entity)) { @@ -423,7 +435,7 @@ impl super::Scene { } let new_refs: Vec = c.refs.iter().map(|r| SketchRef { entity: handle_map[&r.entity], marker: r.marker }).collect(); - to_add.push((scope_index, c.kind, new_refs, c.driving_param)); + to_add.push((scope_index, c.kind, new_refs, c.driving_param.clone())); } } if to_add.is_empty() { @@ -453,10 +465,10 @@ mod tests { fn add_assigns_increasing_ids_and_get_finds_them() { let mut set = SketchConstraintSet::new(SketchScope::ModelSpace); let a = set.add(ConstraintKind::Horizontal, vec![SketchRef::whole(h(1))], None); - let b = set.add(ConstraintKind::Distance, vec![SketchRef::whole(h(1))], Some(25.0)); + let b = set.add(ConstraintKind::Distance, vec![SketchRef::whole(h(1))], Some(DrivingValue::Literal(25.0))); assert_ne!(a, b); assert_eq!(set.get(a).unwrap().kind, ConstraintKind::Horizontal); - assert_eq!(set.get(b).unwrap().driving_param, Some(25.0)); + assert_eq!(set.get(b).unwrap().driving_param, Some(DrivingValue::Literal(25.0))); } #[test] @@ -521,14 +533,14 @@ mod tests { fn sketch_constraint_set_round_trips_through_bincode() { let mut set = SketchConstraintSet::new(SketchScope::Block(h(5))); set.add(ConstraintKind::Coincident, vec![SketchRef::point(h(1), 0), SketchRef::point(h(2), 1)], None); - set.add(ConstraintKind::Distance, vec![SketchRef::whole(h(3))], Some(12.5)); + set.add(ConstraintKind::Distance, vec![SketchRef::whole(h(3))], Some(DrivingValue::Literal(12.5))); let bytes = bincode::serialize(&set).expect("serialize"); let restored: SketchConstraintSet = bincode::deserialize(&bytes).expect("deserialize"); assert_eq!(restored.scope, set.scope); assert_eq!(restored.constraints.len(), set.constraints.len()); - assert_eq!(restored.constraints[1].driving_param, Some(12.5)); + assert_eq!(restored.constraints[1].driving_param, Some(DrivingValue::Literal(12.5))); assert_eq!(restored.constraints[0].refs, set.constraints[0].refs); } } diff --git a/src/scene/sketch_persist.rs b/src/scene/sketch_persist.rs index 4cfe0f7bd..b0f224540 100644 --- a/src/scene/sketch_persist.rs +++ b/src/scene/sketch_persist.rs @@ -48,7 +48,15 @@ fn remove_xrecord(document: &mut CadDocument, owner: Handle, key: &str) { /// or panicking — design doc §5.2's explicit call-out, and §7's flagged /// "no migration path designed yet": bumping this is the signal a real /// migration is needed, not a substitute for one. -const FORMAT_VERSION: u8 = 1; +/// +/// Bumped 1 -> 2 by `named_parameters_design.md` stage 3: +/// `SketchConstraint::driving_param` changed from a bare `Option` to +/// `Option` (an enum, with its own +/// discriminant), so a version-1 blob's bytes no longer line up with the +/// current struct shape — exactly the "detected and gracefully skipped" +/// case this version byte exists for, not something to migrate (no +/// migration path exists yet, per the call-out above). +const FORMAT_VERSION: u8 = 2; fn encode(set: &SketchConstraintSet) -> Option> { let mut bytes = vec![FORMAT_VERSION]; @@ -192,7 +200,11 @@ mod tests { vec![SketchRef::point(a, 1), SketchRef::point(b, 0)], None, ); - scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add(ConstraintKind::Distance, vec![SketchRef::whole(b)], Some(12.5)); + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( + ConstraintKind::Distance, + vec![SketchRef::whole(b)], + Some(crate::scene::named_parameters::DrivingValue::Literal(12.5)), + ); scene.materialize_sketch_constraints_for_save(); let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version) @@ -213,7 +225,7 @@ mod tests { assert_eq!(restored.constraints.len(), 2); assert_eq!(restored.constraints[0].kind, ConstraintKind::Coincident); assert_eq!(restored.constraints[1].kind, ConstraintKind::Distance); - assert_eq!(restored.constraints[1].driving_param, Some(12.5)); + assert_eq!(restored.constraints[1].driving_param, Some(crate::scene::named_parameters::DrivingValue::Literal(12.5))); } #[test] @@ -222,7 +234,7 @@ mod tests { assert_eq!(restored.constraints.len(), 2); assert_eq!(restored.constraints[0].kind, ConstraintKind::Coincident); assert_eq!(restored.constraints[1].kind, ConstraintKind::Distance); - assert_eq!(restored.constraints[1].driving_param, Some(12.5)); + assert_eq!(restored.constraints[1].driving_param, Some(crate::scene::named_parameters::DrivingValue::Literal(12.5))); } #[test] diff --git a/src/scene/sketch_solve.rs b/src/scene/sketch_solve.rs index c807d2443..cd9d3a755 100644 --- a/src/scene/sketch_solve.rs +++ b/src/scene/sketch_solve.rs @@ -38,6 +38,7 @@ use ocs_gcs::geo::{Circle as GCircle, Line as GLine, Point as GPoint}; use ocs_gcs::solvers::dogleg::solve_dl; use ocs_gcs::system::System; +use super::named_parameters::ParameterTable; use super::sketch_constraints::{ConstraintId, ConstraintKind, SketchConstraint, SketchConstraintSet, SketchRef}; use super::{ChangeKind, Scene}; @@ -113,14 +114,21 @@ fn resolve_ref( /// One [`SketchConstraint`]'s `ocs_gcs` construction, or `None` if it can't /// be built (an unsupported/not-yet-mapped kind, a dangling ref, a ref /// shape the kind doesn't expect — e.g. `Parallel` needs two whole-line -/// refs). A constraint that can't be built is simply skipped for this -/// solve, not an error: geometry it would have constrained is left alone, -/// matching how a dangling associative-dimension reference degrades today -/// rather than aborting the whole recompute. +/// refs — or, for a dimensional kind, a `driving_param` that fails to +/// resolve: an undefined named-parameter reference or a division by zero in +/// its formula, design doc `named_parameters_design.md` stage 3). A +/// constraint that can't be built is simply skipped for this solve, not an +/// error: geometry it would have constrained is left alone, matching how a +/// dangling associative-dimension reference degrades today rather than +/// aborting the whole recompute. (A named-parameter *cycle* should be +/// unreachable here — `ParameterTable::set` already refuses to create one — +/// so it gets the same treatment as any other resolve failure rather than a +/// special case.) fn build_constraint( document: &acadrust::CadDocument, sys: &mut System, cache: &mut HashMap, + params: &ParameterTable, c: &SketchConstraint, ) -> Option> { if !c.enabled { @@ -181,18 +189,18 @@ fn build_constraint( ConstraintKind::Distance => { let [a, b] = c.refs.as_slice() else { return None }; let (pa, pb) = (point_ref(sys, cache, *a)?, point_ref(sys, cache, *b)?); - let target = sys.add_param(c.driving_param?, true); + let target = sys.add_param(c.driving_param.as_ref()?.resolve(params).ok()?, true); Some(Rc::new(P2PDistance::new(pa, pb, target))) } ConstraintKind::Angle => { let [a, b] = c.refs.as_slice() else { return None }; let (fixed, moving) = (whole_line(sys, cache, *a)?, whole_line(sys, cache, *b)?); - let angle = sys.add_param(c.driving_param?.to_radians(), true); + let angle = sys.add_param(c.driving_param.as_ref()?.resolve(params).ok()?.to_radians(), true); Some(Rc::new(L2LAngle::new(fixed, moving, angle))) } ConstraintKind::Radius => { let circle = whole_circle(sys, cache, *c.refs.first()?)?; - let target = sys.add_param(c.driving_param?, true); + let target = sys.add_param(c.driving_param.as_ref()?.resolve(params).ok()?, true); Some(Rc::new(Equal::new(circle.rad, target, 1.0))) } ConstraintKind::Tangent => { @@ -252,6 +260,7 @@ const MOVE_EPS: f64 = 1e-9; /// on a successful solve. fn solve_scope( document: &acadrust::CadDocument, + params: &ParameterTable, set: &SketchConstraintSet, ) -> Option<(Vec<(Handle, EntityType)>, usize, Vec<(ConstraintId, ocs_gcs::diagnosis::RedundancyKind)>)> { let mut sys = System::new(); @@ -266,7 +275,7 @@ fn solve_scope( let mut owner: Vec<(Rc, ConstraintId)> = Vec::new(); for c in &set.constraints { - let Some(constraint) = build_constraint(document, &mut sys, &mut cache, c) else { continue }; + let Some(constraint) = build_constraint(document, &mut sys, &mut cache, params, c) else { continue }; owner.push((constraint.clone(), c.id)); sys.add_constraint(constraint); // Coincident needs both X and Y equal; `build_constraint` returns @@ -412,7 +421,9 @@ impl Scene { if !touched { continue; } - let Some((solved, dof, conflicts)) = solve_scope(&self.document, &self.sketch_constraints[i]) else { continue }; + let Some((solved, dof, conflicts)) = solve_scope(&self.document, &self.named_parameters, &self.sketch_constraints[i]) else { + continue; + }; self.sketch_constraints[i].dof = Some(dof); self.sketch_constraints[i].conflicts = conflicts; for (handle, new_entity) in solved { @@ -470,7 +481,7 @@ impl Scene { if !is_touched { continue; } - let Some((solved, _dof, _conflicts)) = solve_scope(&self.document, set) else { continue }; + let Some((solved, _dof, _conflicts)) = solve_scope(&self.document, &self.named_parameters, set) else { continue }; result.extend(solved); } result diff --git a/src/ui/window/mod.rs b/src/ui/window/mod.rs index c4e345c8d..c43b80b7c 100644 --- a/src/ui/window/mod.rs +++ b/src/ui/window/mod.rs @@ -18,3 +18,4 @@ pub mod options; pub mod attribute_editor; pub mod alias_editor; pub mod find_replace; +pub mod named_parameters; diff --git a/src/ui/window/named_parameters.rs b/src/ui/window/named_parameters.rs new file mode 100644 index 000000000..cbcb97667 --- /dev/null +++ b/src/ui/window/named_parameters.rs @@ -0,0 +1,258 @@ +//! Named-parameter table editor — an in-canvas modal (Plan B), mirroring +//! `alias_editor.rs`'s buffered-rows pattern exactly. Opened by PARAMETERS +//! (`docs/named_parameters_design.md` stage 4). Rows are `(name, formula)`, +//! edited in a working buffer (`OpenCADStudio::named_parameter_editor_rows`) +//! and committed to `Scene::named_parameters` only on Apply — matching the +//! alias editor's "closing discards unapplied edits" convention, which +//! suits a formula table even better: `ParameterTable::set` validates (and +//! can reject) a formula immediately, so typing a not-yet-finished name or +//! formula must not touch the live table on every keystroke. +//! +//! Unlike the alias editor, each row also shows a live-computed resolved +//! value or error (`preview`, below) — built fresh from the whole buffer on +//! every render, not cached: the same "cheap enough to recompute from +//! scratch" philosophy `ParameterTable`/`sketch_solve.rs` already use +//! throughout this project, and it means the preview reflects circular/ +//! undefined-reference problems across rows immediately, before Apply. + +use crate::app::Message; +use crate::scene::named_parameters::ParameterTable; +use crate::t; +use iced::widget::{button, column, container, row, scrollable, text, text_input, Space}; +use iced::{Background, Element, Length, Theme}; + +/// Which column of a parameter row a text edit targets. +#[derive(Clone, Copy, Debug)] +pub enum ParamField { + Name, + Formula, +} + +/// One working-buffer row. Free text until Apply — `ParameterTable::set` +/// does the real validation then, not on every keystroke. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ParamEditorRow { + pub name: String, + pub formula: String, +} + +/// Right-hand lane reserved for the scrollbar so it never overlaps the ✕ +/// column — same convention and value as `alias_editor::GUTTER`. +const GUTTER: f32 = 16.0; + +fn muted_style(theme: &Theme) -> iced::widget::text::Style { + iced::widget::text::Style { color: Some(theme.palette().background.base.text.scale_alpha(0.68)) } +} + +fn danger_style(theme: &Theme) -> iced::widget::text::Style { + iced::widget::text::Style { color: Some(theme.palette().danger.base.color) } +} + +/// Every buffer index sharing a (trimmed) name with at least one other +/// non-blank row — `ParameterTable::set` upserts by name, so two rows typed +/// with the same name would otherwise silently collapse into "whichever one +/// happens to `set` last wins," discarding the other's formula with no +/// indication anything was lost. Both `preview` and the real Apply +/// (`OpenCADStudio::apply_named_parameter_editor_rows`) check this first and +/// refuse every row in a name collision rather than guessing which one the +/// user meant. +pub(crate) fn duplicate_name_rows(rows: &[ParamEditorRow]) -> std::collections::HashSet { + let mut by_name: std::collections::HashMap<&str, Vec> = std::collections::HashMap::new(); + for (idx, row) in rows.iter().enumerate() { + let name = row.name.trim(); + if !name.is_empty() { + by_name.entry(name).or_default().push(idx); + } + } + by_name.into_values().filter(|idxs| idxs.len() > 1).flatten().collect() +} + +/// Builds a scratch table from every row's *current* text and resolves each +/// row's own value against it, so the preview reflects cross-row references +/// (including a row referencing one defined further down — order in the +/// buffer doesn't matter, same as it doesn't for the real table). Returns +/// one `Result` per row, in row order: +/// - `Err` from a blank name is suppressed (`Ok` with no text shown instead, +/// handled by the caller) — a fresh row from "+ Add parameter" before the +/// user has typed a name yet isn't an error to flag. +/// - A name shared with another row is its own error (`duplicate_name_rows`) +/// before anything is even attempted. +/// - `Err` from `ParameterTable::set` itself (bad name, parse error, a +/// formula that would close a cycle right now) is that row's own problem. +/// - `Err(UndefinedReference)`/other resolve failure means the row's own +/// formula is fine but something *it* depends on isn't. +fn preview(rows: &[ParamEditorRow]) -> Vec>> { + let duplicates = duplicate_name_rows(rows); + let mut table = ParameterTable::new(); + let mut set_errors: Vec> = vec![None; rows.len()]; + for (idx, row) in rows.iter().enumerate() { + let name = row.name.trim(); + if name.is_empty() || duplicates.contains(&idx) { + continue; + } + if let Err(e) = table.set(name, row.formula.trim()) { + set_errors[idx] = Some(e.to_string()); + } + } + rows.iter() + .enumerate() + .map(|(idx, row)| { + let name = row.name.trim(); + if name.is_empty() { + return None; + } + if duplicates.contains(&idx) { + return Some(Err(format!("duplicate name '{name}'"))); + } + if let Some(err) = &set_errors[idx] { + return Some(Err(err.clone())); + } + Some(table.resolve(name).map_err(|e| e.to_string())) + }) + .collect() +} + +/// Build the named-parameter editor content. `rows` is the live working +/// buffer. +pub fn view_window(rows: &[ParamEditorRow], sizing: crate::ui::modal::ModalSizing) -> Element<'_, Message> { + let title = text(t!("Named Parameters")).size(15); + let hint = text(t!( + "Type a name and a formula (e.g. hole_dia = 12, hole_spacing = 2 * hole_dia + 1.5). Apply to save and re-solve; closing discards unapplied edits." + )) + .size(11) + .style(muted_style); + + let gutter = iced::Padding { top: 0.0, right: GUTTER, bottom: 0.0, left: 0.0 }; + const NAME_WIDTH: f32 = 120.0; + const VALUE_WIDTH: f32 = 140.0; + + let head = container( + row![ + container(text(t!("Name")).size(11).style(muted_style)).width(Length::Fixed(NAME_WIDTH)), + container(text(t!("Formula")).size(11).style(muted_style)).width(sizing.width), + container(text(t!("Value")).size(11).style(muted_style)).width(Length::Fixed(VALUE_WIDTH)), + Space::new().width(Length::Fixed(30.0)), + ] + .spacing(8), + ) + .padding(gutter); + + let results = preview(rows); + let mut list = column![].spacing(3); + for (idx, row) in rows.iter().enumerate() { + let name_box = text_input(t!("name").as_ref(), &row.name) + .on_input(move |v| Message::NamedParametersInput { idx, field: ParamField::Name, value: v }) + .size(13) + .padding([3, 6]) + .width(Length::Fixed(NAME_WIDTH)); + let formula_box = text_input(t!("formula").as_ref(), &row.formula) + .on_input(move |v| Message::NamedParametersInput { idx, field: ParamField::Formula, value: v }) + .size(13) + .padding([3, 6]) + .width(sizing.width); + let value_cell: Element<'_, Message> = match results.get(idx).cloned().flatten() { + Some(Ok(value)) => container(text(format!("= {value:.4}")).size(12).style(muted_style)).into(), + Some(Err(msg)) => container(text(msg).size(11).style(danger_style)).into(), + None => Space::new().into(), + }; + let del = button(crate::ui::icons::themed_danger_text(crate::ui::icons::CLOSE, 12.0)) + .on_press(Message::NamedParametersRemove(idx)) + .padding([2, 6]) + .style(button::danger); + list = list.push( + row![name_box, formula_box, container(value_cell).width(Length::Fixed(VALUE_WIDTH)), del] + .spacing(8) + .align_y(iced::Center), + ); + } + + let add = button(text(t!("+ Add parameter")).size(12)) + .on_press(Message::NamedParametersAdd) + .padding([4, 10]) + .style(button::secondary); + + let apply = button(text(t!("Apply")).size(12)) + .on_press(Message::NamedParametersApply) + .padding([4, 16]) + .style(button::primary); + + container( + column![ + title, + hint, + Space::new().height(6), + head, + scrollable(container(list).padding(gutter)).height(sizing.height), + Space::new().height(6), + row![add, Space::new().width(sizing.width), apply].align_y(iced::Center), + ] + .spacing(6) + .width(sizing.width) + .height(sizing.height), + ) + .padding(12) + .width(sizing.width) + .height(sizing.height) + .style(|theme: &Theme| container::Style { background: Some(Background::Color(theme.palette().background.base.color)), ..Default::default() }) + .into() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn row(name: &str, formula: &str) -> ParamEditorRow { + ParamEditorRow { name: name.to_string(), formula: formula.to_string() } + } + + #[test] + fn preview_resolves_a_literal_and_a_cross_row_reference() { + let rows = vec![row("hole_dia", "5"), row("hole_spacing", "2 * hole_dia + 1.5")]; + let results = preview(&rows); + assert_eq!(results[0], Some(Ok(5.0))); + assert_eq!(results[1], Some(Ok(11.5))); + } + + #[test] + fn preview_resolves_a_forward_reference_regardless_of_row_order() { + // `plate_width` (row 0) references `plate_len` (row 1) -- defined + // *after* it in the buffer. Order in the editor must not matter, same + // as it doesn't for the real `ParameterTable`. + let rows = vec![row("plate_width", "plate_len / 2"), row("plate_len", "20")]; + let results = preview(&rows); + assert_eq!(results[0], Some(Ok(10.0))); + assert_eq!(results[1], Some(Ok(20.0))); + } + + #[test] + fn preview_reports_a_cycle_on_the_row_that_closes_it() { + let rows = vec![row("a", "b"), row("b", "a")]; + let results = preview(&rows); + // Row 0 (a = b) is accepted first (b doesn't exist yet, no cycle); + // row 1 (b = a) is the one that would close the loop and is rejected. + assert!(matches!(results[0], Some(Ok(_))) || matches!(&results[0], Some(Err(e)) if e.contains("not defined"))); + assert!(matches!(&results[1], Some(Err(e)) if e.to_lowercase().contains("circular"))); + } + + #[test] + fn preview_leaves_a_blank_row_without_a_name_unflagged() { + let rows = vec![row("", "")]; + assert_eq!(preview(&rows), vec![None]); + } + + #[test] + fn preview_reports_a_malformed_formula_on_its_own_row() { + let rows = vec![row("bad", "1 +")]; + let results = preview(&rows); + assert!(matches!(&results[0], Some(Err(e)) if e.contains("formula error"))); + } + + #[test] + fn preview_flags_every_row_sharing_a_duplicate_name_instead_of_picking_one() { + let rows = vec![row("x", "1"), row("x", "2"), row("y", "3")]; + let results = preview(&rows); + assert!(matches!(&results[0], Some(Err(e)) if e.contains("duplicate"))); + assert!(matches!(&results[1], Some(Err(e)) if e.contains("duplicate"))); + assert_eq!(results[2], Some(Ok(3.0)), "an unrelated row's name must not be affected"); + } +} diff --git a/src/ui/window/options.rs b/src/ui/window/options.rs index 391974260..0e3957701 100644 --- a/src/ui/window/options.rs +++ b/src/ui/window/options.rs @@ -41,6 +41,7 @@ pub fn view_window<'a>( crosshair_color: Option<[u8; 3]>, crosshair_color_input: &'a str, lineweight_display_scale: i32, + ui_scale: i32, ribbon_label_font_size: i32, ribbon_group_title_font_size: i32, model_space: &'a crate::app::config::ModelSpaceThemeConfig, @@ -349,6 +350,24 @@ pub fn view_window<'a>( }); let mut display = column![ + text(crate::t!("UI Scale")).size(15), + Space::new().height(10), + row![ + text(crate::t!("Overall size")).size(12).width(150), + slider(50..=200, ui_scale.clamp(50, 200), Message::UiScaleChanged) + .step(5) + .width(Fill), + text(format!("{}%", ui_scale.clamp(50, 200))).size(11).width(44), + ] + .spacing(12) + .align_y(iced::Center), + Space::new().height(8), + text(crate::t!( + "Scales the whole interface at once — every panel, dialog, the ribbon, and their text — rather than one widget's font size. Takes effect immediately." + )) + .size(11) + .width(sizing.width), + Space::new().height(24), text(crate::tr!("options", "theme-section")).size(15), Space::new().height(10), row![ diff --git a/tests/sketch_constraints_solve.rs b/tests/sketch_constraints_solve.rs index a7c474ea8..2c9f025d2 100644 --- a/tests/sketch_constraints_solve.rs +++ b/tests/sketch_constraints_solve.rs @@ -5,6 +5,7 @@ // constrained neighbors, produces the expected change list, and an // unrelated edit doesn't touch anything (§4.3's "what does NOT trigger"). +use OpenCADStudio::scene::named_parameters::DrivingValue; use OpenCADStudio::scene::sketch_constraints::{ConstraintKind, SketchRef, SketchScope}; use OpenCADStudio::scene::{ChangeKind, Scene}; use acadrust::entities::EntityType; @@ -96,7 +97,7 @@ fn distance_constraint_holds_the_target_length_after_an_unrelated_endpoint_edit( scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( ConstraintKind::Distance, vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], - Some(20.0), + Some(DrivingValue::Literal(20.0)), ); set_line_end(&mut scene, line, Vector3::new(5.0, 5.0, 0.0)); // arbitrary edit, not length=20 @@ -107,6 +108,63 @@ fn distance_constraint_holds_the_target_length_after_an_unrelated_endpoint_edit( assert!((len - 20.0).abs() < 1e-6, "length should have been solved to the driving value, got {len}"); } +/// `named_parameters_design.md` stage 3: a `Distance` constraint's driving +/// value can be a named-parameter reference instead of a literal, resolved +/// through `Scene::named_parameters` at solve time. +#[test] +fn distance_constraint_resolves_a_named_parameter_reference() { + let mut scene = Scene::new(); + let line = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0); + scene.named_parameters_mut().set("target_len", "20").unwrap(); + + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( + ConstraintKind::Distance, + vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], + Some(DrivingValue::Named("target_len".to_string())), + ); + + set_line_end(&mut scene, line, Vector3::new(5.0, 5.0, 0.0)); // arbitrary edit, not length=20 + scene.bump_entities(&[(line, ChangeKind::Modified)]); + + let (start, end) = line_endpoints(&scene, line); + let len = ((end.x - start.x).powi(2) + (end.y - start.y).powi(2)).sqrt(); + assert!((len - 20.0).abs() < 1e-6, "length should have resolved the named parameter, got {len}"); + + // Editing the parameter itself and touching the scope again should + // ripple through, same as editing a literal driving value would. + scene.named_parameters_mut().set("target_len", "8").unwrap(); + scene.bump_entities(&[(line, ChangeKind::Modified)]); + let (start, end) = line_endpoints(&scene, line); + let len = ((end.x - start.x).powi(2) + (end.y - start.y).powi(2)).sqrt(); + assert!((len - 8.0).abs() < 1e-6, "length should track the redefined parameter value, got {len}"); +} + +/// A `driving_param` referencing a named parameter that doesn't (or no +/// longer) exists must not panic the solve — same "skip, don't panic" +/// contract `build_constraint`'s doc comment gives every other unbuildable +/// constraint. The referenced line's two endpoint coordinates are still +/// registered (via `point_ref`) even though the constraint itself can't be +/// built, so they're just left free — the edit that triggered this solve is +/// not undone or altered. +#[test] +fn distance_constraint_with_an_undefined_named_reference_is_skipped_not_panicked() { + let mut scene = Scene::new(); + let line = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0); + + scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add( + ConstraintKind::Distance, + vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], + Some(DrivingValue::Named("does_not_exist".to_string())), + ); + + let edited_end = Vector3::new(5.0, 5.0, 0.0); + set_line_end(&mut scene, line, edited_end); + scene.bump_entities(&[(line, ChangeKind::Modified)]); // must not panic + + let (_, end) = line_endpoints(&scene, line); + assert_eq!(end, edited_end, "an unresolvable driving reference must leave the edit alone, not move it toward a phantom target"); +} + #[test] fn tangent_constraint_between_a_line_and_a_circle_solves_to_touching() { let mut scene = Scene::new(); @@ -285,8 +343,8 @@ fn two_conflicting_distance_targets_are_reported_as_conflicting() { let mut scene = Scene::new(); let a = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0); let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); - let first = set.add(ConstraintKind::Distance, vec![SketchRef::point(a, 0), SketchRef::point(a, 1)], Some(20.0)); - let second = set.add(ConstraintKind::Distance, vec![SketchRef::point(a, 0), SketchRef::point(a, 1)], Some(50.0)); + let first = set.add(ConstraintKind::Distance, vec![SketchRef::point(a, 0), SketchRef::point(a, 1)], Some(DrivingValue::Literal(20.0))); + let second = set.add(ConstraintKind::Distance, vec![SketchRef::point(a, 0), SketchRef::point(a, 1)], Some(DrivingValue::Literal(50.0))); scene.bump_entities(&[(a, ChangeKind::Modified)]); diff --git a/tests/sketch_constraints_xrecord_roundtrip.rs b/tests/sketch_constraints_xrecord_roundtrip.rs index 9c22ce9a9..5446a8959 100644 --- a/tests/sketch_constraints_xrecord_roundtrip.rs +++ b/tests/sketch_constraints_xrecord_roundtrip.rs @@ -5,6 +5,7 @@ // (save/load integration, the "lazy" persistence model) is gated on this // test passing — see the design doc §8 stage 2 and §7 open question 1. +use OpenCADStudio::scene::named_parameters::DrivingValue; use OpenCADStudio::scene::sketch_constraints::{ConstraintKind, SketchConstraintSet, SketchRef, SketchScope}; use OpenCADStudio::scene::Scene; use acadrust::entities::EntityType; @@ -20,9 +21,13 @@ fn sample_set(scope: SketchScope) -> SketchConstraintSet { vec![SketchRef::point(Handle::new(101), 0), SketchRef::point(Handle::new(102), 1)], None, ); - set.add(ConstraintKind::Distance, vec![SketchRef::whole(Handle::new(103))], Some(25.0)); - set.add(ConstraintKind::Angle, vec![SketchRef::whole(Handle::new(104)), SketchRef::whole(Handle::new(105))], Some(45.0)); - set.add(ConstraintKind::Radius, vec![SketchRef::whole(Handle::new(106))], Some(3.5)); + set.add(ConstraintKind::Distance, vec![SketchRef::whole(Handle::new(103))], Some(DrivingValue::Literal(25.0))); + set.add( + ConstraintKind::Angle, + vec![SketchRef::whole(Handle::new(104)), SketchRef::whole(Handle::new(105))], + Some(DrivingValue::Literal(45.0)), + ); + set.add(ConstraintKind::Radius, vec![SketchRef::whole(Handle::new(106))], Some(DrivingValue::Literal(3.5))); set }