diff --git a/Cargo.lock b/Cargo.lock index 6e030559..d4f35623 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -452,6 +452,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.5.1" @@ -731,6 +740,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -771,6 +789,16 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "cursor-icon" version = "1.2.0" @@ -801,6 +829,16 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -1254,6 +1292,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "gethostname" version = "1.1.0" @@ -2905,6 +2953,17 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "2.0.1" @@ -3085,6 +3144,7 @@ dependencies = [ "log", "serde", "serde_json", + "sha2", "solvecraft-geom", "solvecraft-kernel", "solvecraft-sketch", @@ -3567,6 +3627,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "uds_windows" version = "1.2.1" diff --git a/Cargo.toml b/Cargo.toml index 192451fd..37bf16ee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ thiserror = "2" serde = { version = "1", features = ["derive", "rc"] } serde_json = { version = "1", features = ["float_roundtrip"] } log = "0.4" +sha2 = "0.10" # geometry kernel (Apache-2.0; see plan/adr/0001-geometry-kernel.md). Only crates/kernel uses it. truck-modeling = "0.6" diff --git a/README.md b/README.md index 2d25820d..2ed7066a 100644 --- a/README.md +++ b/README.md @@ -39,9 +39,12 @@ and help is welcome: see [CONTRIBUTING.md](CONTRIBUTING.md). - **Parameters and configurations** with unit-aware expressions (`2 * width + 5 mm`, `angle / 2`), ordered rules, feature suppression and per-row body materials/appearances. - **Assemblies** with nested components and occurrences, joints, motion studies, contact sets, - interference and material-aware mass properties. + interference and material-aware mass properties. Recorded body transfers preserve modelling + history and world placement; 3D sketch wires retain their coordinate frames through edits. - **Sheet metal** with flanges, hems, fold/unfold/refold, material cutouts, flat patterns, - exact line/arc/circle DXF outlines and bend tables; plastic enclosure features and rules. + retained shallow prismatic pockets, exact line/arc/circle DXF outlines and bend tables; + plastic enclosure features and rules. Pocket depth and side survive unfold/refold and appear + separately from through-hole outlines in the flat preview and DXF. - **Files**: designs as JSON (`.solvecraft`), export to STEP (AP242 with body names, colours and components as assemblies; revolved faces as native planes, cylinders, cones, spheres and tori), IGES 5.3 (manifold solid B-reps with names and colours), 3MF, STL (binary/ASCII) and OBJ; diff --git a/ROADMAP.md b/ROADMAP.md index 5b1565d0..208d9f77 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -46,20 +46,20 @@ headline counts in-scope tabs only and lists the deferred ones separately. | # | Milestone | Status | Hours left | |---|---|---|---| | M0 | Vertical slice | done | — | -| M1 | Sketch depth: command coverage implemented — projection/intersect/include as linked geometry (auto-project on faces, lost-reference warnings, break link), full SKETCH create/modify set (slots, polygons, tangent arcs/circles, ellipse, fit/control splines, conic, text, trim/extend/break, fillet/chamfer, offset, mirror, patterns, move/scale, blend curve, centerline), constraints incl. curvature and polygon with over-constraint refusal, driven/arc-length/linear-diameter dimensions, AutoConstrain, DXF/SVG insert, 3D sketch curves (Project To Surface, Intersection Curve, Isoparametric Curve, Spun Profile), exact free-form profiles (B-spline/NURBS edges, any spline degree, curves cut by crossings), sweeps and pipes along 3D sketch curves, canvases and decals, curvature combs. Exact free-form Extend and certified associative offsets now preserve geometry and IDs through edits, deletion and reopen. Remaining workflow repairs: mixed offset junctions, periodic closed spline seams, and consistent occurrence-frame 3D wire authoring | in progress | 4 | +| M1 | Sketch depth: command coverage implemented — projection/intersect/include as linked geometry (auto-project on faces, lost-reference warnings, break link), full SKETCH create/modify set (slots, polygons, tangent arcs/circles, ellipse, fit/control splines, conic, text, trim/extend/break, fillet/chamfer, offset, mirror, patterns, move/scale, blend curve, centerline), constraints incl. curvature and polygon with over-constraint refusal, driven/arc-length/linear-diameter dimensions, AutoConstrain, DXF/SVG insert, 3D sketch curves (Project To Surface, Intersection Curve, Isoparametric Curve, Spun Profile), exact free-form profiles (B-spline/NURBS edges, any spline degree, curves cut by crossings), sweeps and pipes along 3D sketch curves, canvases and decals, curvature combs. Exact free-form Extend and certified associative offsets now preserve geometry and IDs through edits, deletion and reopen. Occurrence-frame 3D wires now store explicit coordinate spaces and retain points and analytic fits through moves, refresh, undo and reopen. Remaining workflow repairs: mixed offset junctions, periodic closed spline seams, and damaged legacy wire links lacking frame provenance | in progress | 4 | | M2 | Robust modelling: coplanar booleans in the kernel, holes (placed on faces and on sketch points), threads (cosmetic, then modelled), patterns, mirror, shell, draft, split, construction geometry | in progress | 16 | | M3 | General fillets and chamfers (curved edges, chains, vertex blends); saddle rolling-ball surfaces now use continuous seam sections and arc-length fitting with shared contact boundaries and bounded accuracy checks | in progress | 30 | | M4 | Files: STEP import (done: all 29 Fusion STEP files — [docs/step-import.md](docs/step-import.md)), 3MF export and 3MF/STL import as mesh bodies (done), DXF sketches, OBJ import | in progress | 6 | | M5 | Persistent naming of faces and edges (plan/adr/0002): faces named from their history (sketch curve sides, caps, revolve sections, primitive sides, blends, pattern/mirror copies; split pieces numbered), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. sheet metal edges by base sketch line and flange side, shell inner faces, sweep and loft roles: done. Left: kernel-reported face history (exact through coincident surfaces) | in progress | 20 | | M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, reorder with dependency checks, suppress, rename, delete with dependents, re-resolved references; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearance controls now preserve body/face/component targeting, named transparent looks, reuse/search and override removal; preferences | in progress | 6 | -| M7 | Sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces and exact radial emboss/deboss of bounded orthogonal profiles on complete exterior cylinders/sleeves), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Left: general curved-face emboss, automatic cylinder target inference, exact (unfaceted) modelled threads | in progress | 12 | +| M7 | Sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces and exact radial emboss/deboss of bounded orthogonal profiles on complete exterior cylinders/sleeves), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Unique eligible cylindrical targets are inferred for new Emboss features. Left: general curved-face emboss and exact (unfaceted) modelled threads | in progress | 12 | | M8 | Surface workspace | deferred | — | | M9 | Components and assemblies (Fusion-style): New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure. Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 | | M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done (oracle 67-76). PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, no Fusion oracle (licence), tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line) | in progress | 36 | | M11 | 2D drawings | deferred | — | | M12 | Release: installers, signing, docs, performance | planned | 16 | -| M14 | Configurations: a table of rows (parameter expressions and feature suppressions per row) switched with config.activate (undoable, all or nothing), kept through parameter renames and feature deletes (config.configure, config.table, config.column/row/activate). Per-row body materials and appearances (library looks, custom colour/opacity and clearing overrides), undoable activation and save/reopen: done. Ordered typed configuration rules, atomic validation and structured rule editing: done. Component appearance and certified semantic face style columns, atomic preview/apply and independent-copy remapping: done. Left: certified sketch-side/imported face lineage and ambiguous shifted/two-sided caps | in progress | 4 | -| M13 | Never lose work: atomic saves (temporary file, flush, rename; a crash at any moment leaves the old file or the new one) with the replaced version kept as `.bak`; autosave of unsaved changes to the recovery folder every 5 minutes (preference) and after big operations, only when something changed; "Recover unsaved design?" on the next launch (`doc.recovery_list`, `doc.recover`, `doc.recovery_discard`); a kill-mid-save test (process killed while saving a 0.5 MB design 25 times). File format: `"format": "solvecraft/9"`, older formats upgraded step by step (`doc::format`), newer ones refused with a message, out-of-range numbers and broken placements refused; a corpus of designs saved by M0, components-era, joints-era and current builds (apps/solvecraft-cli/tests/corpus) that must open and evaluate as saved; damaged and hostile variants (truncation, bit flips, hostile values, removed keys, repeated features, deep nesting) never panic | done | 6 | +| M14 | Configurations: a table of rows (parameter expressions and feature suppressions per row) switched with config.activate (undoable, all or nothing), kept through parameter renames and feature deletes (config.configure, config.table, config.column/row/activate). Per-row body materials and appearances (library looks, custom colour/opacity and clearing overrides), undoable activation and save/reopen: done. Ordered typed configuration rules, atomic validation and structured rule editing: done. Component appearance and certified semantic face style columns, atomic preview/apply and independent-copy remapping: done. Exact constructed sketch-side and immutable imported-face lineage now preserve legacy names and reject retargeting. Left: ambiguous shifted/two-sided caps | in progress | 4 | +| M13 | Never lose work: atomic saves (temporary file, flush, rename; a crash at any moment leaves the old file or the new one) with the replaced version kept as `.bak`; autosave of unsaved changes to the recovery folder every 5 minutes (preference) and after big operations, only when something changed; "Recover unsaved design?" on the next launch (`doc.recovery_list`, `doc.recover`, `doc.recovery_discard`); a kill-mid-save test (process killed while saving a 0.5 MB design 25 times). File format: `"format": "solvecraft/10"`, older formats upgraded step by step (`doc::format`), newer ones refused with a message, out-of-range numbers and broken placements refused; a corpus of designs saved by M0, components-era, joints-era and current builds (apps/solvecraft-cli/tests/corpus) that must open and evaluate as saved; damaged and hostile variants (truncation, bit flips, hostile values, removed keys, repeated features, deep nesting) never panic | done | 6 | ## Known limitations @@ -116,10 +116,13 @@ headline counts in-scope tabs only and lists the deferred ones separately. and selected occurrence frames; cancel and successful edits preserve one-step undo history. Placed feature edits preview through the edited timeline entry, and cylindrical Emboss guides use the sketch's actual target, author or uniquely placed third-component frame. - Revolve rotator direction and To Object value interaction remain follow-on usability repairs. + Revolve rotators follow the actual signed axis and support symmetric and two-sided extents; + dragging a To Object extent converts its derived value to an explicit angle. Legacy reversed-axis + symmetric geometry is preserved; correcting that saved behavior needs versioned semantics. - Cylindrical emboss/deboss wraps bounded orthogonal line profiles, including holes, concave outlines and nested islands, onto complete exterior cylinders or coaxial sleeves with exact - radial walls. Select the face explicitly and capture pending positions first. Saved target + radial walls. New features infer a unique eligible cylinder when the face field is empty; + ambiguous targets require an explicit face. Saved target and author occurrence paths retain the chosen placements; missing/split faces refuse rather than retarget. General free-form profiles, seams, stepped cylinders and other curved surfaces remain unsupported. STEP round-trip and independent radial/volume checks cover this subset. @@ -148,9 +151,11 @@ headline counts in-scope tabs only and lists the deferred ones separately. independent feature/input histories, styles and internal motion records, with bounded geometry checks and explicit external-link notices. Paste New selects the copy while preserving the active authoring context. Imported periodic STEP seams survive placement and reexport with shared - topology and appearances intact. Creating a component from a body can change its historical - implicit Join membership; preserving that material through ownership transfers remains open. - Legacy 3D wire authoring needs further work. See [occurrence workflows](docs/occurrence-workflows.md). + topology and appearances intact. Recorded body transfers preserve historical Join/Cut membership, + material, placements and machining data through edits, copying, undo and reopen. New 3D wires + record their coordinate frame; legacy wires migrate from known provenance without changing + coordinates. Broken legacy links with missing provenance can require explicit refresh or relink. + See [occurrence workflows](docs/occurrence-workflows.md). - Configuration rules: ordered typed conditions can drive parameters and suppressions or restrict values. Preview explains effective values without modifying the document; applying rules validates rows atomically. Length expressions retain their authoring units across @@ -161,8 +166,10 @@ headline counts in-scope tabs only and lists the deferred ones separately. islands share the same boundaries in flat preview, DXF and unfolded solids. Detached material pieces survive unfold/refold as one logical sheet group, including split flanged sheets. Circular cuts crossing bends remain unsupported; free-form bases use bounded sampling. - Partial-depth Cut and Intersect still lack retained machining metadata; later sheet rebuilds - can restore removed material. Their continuity repair remains open. + Parallel prismatic partial-depth cuts retain pocket side and depth through transfer and + unfold/refold, with separate pocket contours in preview and DXF. Planar Intersect preserves + exact holes and retained islands, including the union of multiple selected profiles. Unsupported + tapered or bend-crossing machining refuses atomically instead of restoring material later. - Rolling-ball fillet accuracy: independent offset-cylinder tee references check surface distance, actual trimmed tessellation, supporting-cylinder contacts and tangency at two scales. The measured trimmed-part error is below 0.0001 mm; this is an analytic geometry diff --git a/THIRD-PARTY-LICENSES.txt b/THIRD-PARTY-LICENSES.txt index e6f6dcaa..b520e18b 100644 --- a/THIRD-PARTY-LICENSES.txt +++ b/THIRD-PARTY-LICENSES.txt @@ -1,6 +1,6 @@ Third-party software in SolveCraft -SolveCraft (the desktop app, solvecraft-cli and the web app) is built from the 419 crates below. +SolveCraft (the desktop app, solvecraft-cli and the web app) is built from the 426 crates below. Generated by `cargo xtask licences` from Cargo.lock and the crates' own licence files. SolveCraft's own licence is in LICENSE-MIT and LICENSE-APACHE, and NOTICE. @@ -44,6 +44,7 @@ bit-set 0.10.0 (Apache-2.0 OR MIT) bit-vec 0.9.1 (Apache-2.0 OR MIT) bitflags 1.3.2 (MIT/Apache-2.0) bitflags 2.13.2 (MIT OR Apache-2.0) +block-buffer 0.10.4 (MIT OR Apache-2.0) block2 0.5.1 (MIT) block2 0.6.2 (MIT) blocking 1.7.0 (Apache-2.0 OR MIT) @@ -71,14 +72,17 @@ core-foundation 0.9.4 (MIT OR Apache-2.0) core-foundation-sys 0.8.7 (MIT OR Apache-2.0) core-graphics 0.23.2 (MIT OR Apache-2.0) core-graphics-types 0.1.3 (MIT OR Apache-2.0) +cpufeatures 0.2.17 (MIT OR Apache-2.0) crc32fast 1.5.2 (MIT OR Apache-2.0) crossbeam-deque 0.8.8 (MIT OR Apache-2.0) crossbeam-epoch 0.9.21 (MIT OR Apache-2.0) crossbeam-utils 0.8.23 (MIT OR Apache-2.0) crunchy 0.2.4 (MIT) +crypto-common 0.1.7 (MIT OR Apache-2.0) cursor-icon 1.2.0 (MIT OR Apache-2.0 OR Zlib) derive_arbitrary 1.5.0 (MIT OR Apache-2.0) derive_more 0.99.20 (MIT) +digest 0.10.7 (MIT OR Apache-2.0) dispatch 0.2.0 (MIT) dispatch2 0.3.1 (Zlib OR Apache-2.0 OR MIT) dlib 0.5.3 (MIT) @@ -121,6 +125,7 @@ futures-lite 2.6.1 (Apache-2.0 OR MIT) futures-macro 0.3.34 (MIT OR Apache-2.0) futures-task 0.3.34 (MIT OR Apache-2.0) futures-util 0.3.34 (MIT OR Apache-2.0) +generic-array 0.14.7 (MIT) gethostname 1.1.0 (Apache-2.0) getrandom 0.2.17 (MIT OR Apache-2.0) getrandom 0.3.4 (MIT OR Apache-2.0) @@ -278,6 +283,7 @@ serde_core 1.0.229 (MIT OR Apache-2.0) serde_derive 1.0.229 (MIT OR Apache-2.0) serde_json 1.0.151 (MIT OR Apache-2.0) serde_repr 0.1.21 (MIT OR Apache-2.0) +sha2 0.10.9 (MIT OR Apache-2.0) signal-hook-registry 1.4.8 (MIT OR Apache-2.0) simd-adler32 0.3.10 (MIT) simd_cesu8 1.2.0 (Apache-2.0 OR MIT) @@ -324,6 +330,7 @@ truck-topology 0.6.0 (Apache-2.0) ttf-parser 0.25.1 (MIT OR Apache-2.0) type-map 0.5.1 (MIT/Apache-2.0) typeid 1.0.3 (MIT OR Apache-2.0) +typenum 1.20.1 (MIT OR Apache-2.0) uds_windows 1.2.1 (MIT) unicode-general-category 1.1.0 (Apache-2.0) unicode-ident 1.0.26 ((MIT OR Apache-2.0) AND Unicode-3.0) @@ -2457,6 +2464,242 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +================================================================================ +block-buffer 0.10.4 +================================================================================ + +Copyright (c) 2018-2019 The RustCrypto Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +================================================================================ +block-buffer 0.10.4, cpufeatures 0.2.17, crypto-common 0.1.7, digest 0.10.7, sha2 0.10.9 +================================================================================ + +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + ================================================================================ branches 0.3.0, rclite 0.2.8 ================================================================================ @@ -3085,6 +3328,36 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +================================================================================ +cpufeatures 0.2.17 +================================================================================ + +Copyright (c) 2020-2025 The RustCrypto Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + ================================================================================ crc32fast 1.5.2 ================================================================================ @@ -3378,6 +3651,36 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +================================================================================ +crypto-common 0.1.7 +================================================================================ + +Copyright (c) 2021 RustCrypto Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + ================================================================================ cursor-icon 1.2.0 ================================================================================ @@ -3650,6 +3953,36 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +================================================================================ +digest 0.10.7 +================================================================================ + +Copyright (c) 2017 Artyom Pavlov + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + ================================================================================ dlib 0.5.3 ================================================================================ @@ -4678,6 +5011,32 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +================================================================================ +generic-array 0.14.7 +================================================================================ + +The MIT License (MIT) + +Copyright (c) 2015 Bartłomiej Kamiński + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + ================================================================================ getrandom 0.2.17 ================================================================================ @@ -9293,6 +9652,38 @@ consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. +================================================================================ +sha2 0.10.9 +================================================================================ + +Copyright (c) 2006-2009 Graydon Hoare +Copyright (c) 2009-2013 Mozilla Foundation +Copyright (c) 2016 Artyom Pavlov + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + ================================================================================ signal-hook-registry 1.4.8 ================================================================================ @@ -10179,6 +10570,244 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +================================================================================ +typenum 1.20.1 +================================================================================ + +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2014 Paho Lurie-Gregg + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +================================================================================ +typenum 1.20.1 +================================================================================ + +MIT OR Apache-2.0 + +================================================================================ +typenum 1.20.1 +================================================================================ + +The MIT License (MIT) + +Copyright (c) 2014 Paho Lurie-Gregg + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + ================================================================================ uds_windows 1.2.1 ================================================================================ diff --git a/crates/doc/Cargo.toml b/crates/doc/Cargo.toml index 1ebce064..084aa785 100644 --- a/crates/doc/Cargo.toml +++ b/crates/doc/Cargo.toml @@ -16,4 +16,5 @@ serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } log = { workspace = true } +sha2 = { workspace = true } web-time = { workspace = true } diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index 7fefc281..12a577d1 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -48,6 +48,8 @@ pub struct FaceLook { /// Strict semantic assignments never transfer to a nearby replacement face. #[serde(default, skip_serializing_if = "is_false")] pub strict_name: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub certificate: Option, } fn is_false(value: &bool) -> bool { @@ -139,7 +141,13 @@ pub fn material_look(material: &str) -> Option { impl Document { /// Shared definition appearance for a computed occurrence body. pub fn model_body_look(&self, body: &crate::ModelBody) -> Option { - self.body_look(body.definition_name(), body.feature) + let name = body.definition_name(); + self.appearances + .bodies + .get(name) + .cloned() + .or_else(|| self.appearances.components.get(&self.model_body_component(body)).cloned()) + .or_else(|| self.materials.get(name).and_then(|material| material_look(material))) } /// Effective body opacity, including paint carried from an imported body. pub fn model_body_opacity(&self, body: &crate::ModelBody) -> f64 { @@ -198,6 +206,7 @@ impl Document { origin_feature, name: crate::naming::strip_piece(name).into(), point: f.point, + certificate: f.certificate.clone(), }; let Ok(indices) = self.configuration_face_indices(b, &target) else { continue }; for index in indices { diff --git a/crates/doc/src/assembly.rs b/crates/doc/src/assembly.rs index ece7c690..f284d5fa 100644 --- a/crates/doc/src/assembly.rs +++ b/crates/doc/src/assembly.rs @@ -328,7 +328,8 @@ impl FeatureKind { | FeatureKind::Stitch { .. } | FeatureKind::Thicken { .. } | FeatureKind::Import { .. } - | FeatureKind::MeshImport { .. } => {} + | FeatureKind::MeshImport { .. } + | FeatureKind::BodyTransfer { .. } => {} FeatureKind::Patch { edges, .. } | FeatureKind::SurfaceExtend { edges, .. } => edges.iter_mut().for_each(pt), FeatureKind::SurfaceTrim { plane, tool, keep, .. } => { map_plane_ref(m, plane); @@ -392,9 +393,9 @@ impl Document { pub fn body_frames_with_source(&self, st: &crate::ModelState) -> Vec<(String, String, u64, Vec, Mat)> { let mut out = Vec::new(); for (comp, path, m) in self.placements() { - for b in st.bodies.iter().filter(|b| self.body_component(&b.name, b.feature) == comp) { + for b in st.bodies.iter().filter(|b| self.model_body_component(b) == comp) { let name = self.world_body_name(st, &b.name, comp, &path); - let w = match self.body_offsets.get(&b.name) { + let w = match self.model_body_offset(b) { Some(o) => mat_mul(&m, o), None => m, }; diff --git a/crates/doc/src/body_transfer.rs b/crates/doc/src/body_transfer.rs new file mode 100644 index 00000000..38b80cbe --- /dev/null +++ b/crates/doc/src/body_transfer.rs @@ -0,0 +1,80 @@ +//! Recorded ownership changes are evaluated at their timeline position. +use crate::{DocError, Document, Mat, ModelBody, ModelState, Result}; +use solvecraft_geom::Vec3; +use std::collections::BTreeSet; + +pub fn valid_transfer_matrix(m: &Mat) -> bool { + let axis = |i: usize| Vec3::new(m[i][0], m[i][1], m[i][2]); + let (x, y, z) = (axis(0), axis(1), axis(2)); + m.iter().flatten().all(|v| v.is_finite()) + && m[3][..3].iter().all(|v| v.abs() <= 1e9) + && [x, y, z].iter().all(|v| (v.dot(*v) - 1.0).abs() < 1e-6) + && x.dot(y).abs() < 1e-6 + && x.dot(z).abs() < 1e-6 + && y.dot(z).abs() < 1e-6 + && x.cross(y).dot(z) > 0.0 + && [m[0][3], m[1][3], m[2][3]].iter().all(|v| v.abs() < 1e-9) + && (m[3][3] - 1.0).abs() < 1e-9 +} + +/// Validate the stored boundary before allocation, loading, or evaluation. +pub fn validate_body_transfer(doc: &Document, bodies: &[String], from: u64, to: u64, transform: &Mat) -> Result<()> { + if bodies.is_empty() + || bodies.len() > 1000 + || bodies.iter().any(|name| name.trim().is_empty() || name.len() > 512 || name.chars().any(char::is_control)) + || bodies.iter().collect::>().len() != bodies.len() + || !valid_transfer_matrix(transform) + || [from, to].iter().any(|id| *id != 0 && !doc.components.iter().any(|c| c.id == *id)) + { + return Err(DocError::Invalid("invalid body transfer boundary".into())); + } + Ok(()) +} + +pub(crate) fn evaluate(doc: &Document, st: &mut ModelState, bodies: &[String], from: u64, to: u64, transform: Mat) -> Result<()> { + validate_body_transfer(doc, bodies, from, to, &transform)?; + let mut next = st.clone(); + for name in bodies { + let old = st.body(name).ok_or_else(|| DocError::Unknown(format!("transferred body `{name}`")))?; + if doc.model_body_component(old) != from { + return Err(DocError::Invalid(format!("body `{name}` no longer belongs to transfer source component {from}"))); + } + let shape = if crate::is_identity(&transform) { old.body.clone() } else { solvecraft_kernel::transform_matrix(&old.body, transform)? }; + let mut body = ModelBody::new(old.name.clone(), shape, old.feature); + body.component = to; + body.transferred = true; + body.source_name = old.source_name.clone(); + body.source_transform = crate::mat_mul(&transform, &old.source_transform); + body.imported = old.imported.clone(); + body.names = crate::naming::NameCell::known(crate::naming::face_names(old)); + let slot = next.bodies.iter_mut().find(|b| b.name == *name).ok_or_else(|| DocError::Unknown(format!("transferred body `{name}`")))?; + *slot = body; + } + crate::body_transfer_sheet::remap_sheet_metadata(&mut next, bodies, &transform)?; + *st = next; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn invalid_transfer_constructor_preserves_document_and_allocator() { + let mut doc = Document::default(); + let before = doc.to_json(); + let mut scaled = crate::IDENTITY; + scaled[0][0] = 2.0; + for (bodies, from, transform) in [ + (vec!["Block".into()], 42, crate::IDENTITY), + (vec!["Block".into(), "Block".into()], 0, crate::IDENTITY), + (vec![String::new()], 0, crate::IDENTITY), + (vec![" ".into()], 0, crate::IDENTITY), + (vec!["bad\nname".into()], 0, crate::IDENTITY), + (vec!["Block".into()], 0, scaled), + ] { + assert!(doc.add_feature(crate::FeatureKind::BodyTransfer { bodies, from, to: 0, transform }, None).is_err()); + assert_eq!(doc.to_json(), before); + } + } +} diff --git a/crates/doc/src/body_transfer_sheet.rs b/crates/doc/src/body_transfer_sheet.rs new file mode 100644 index 00000000..10f9297e --- /dev/null +++ b/crates/doc/src/body_transfer_sheet.rs @@ -0,0 +1,54 @@ +//! World-frame metadata at a timeline body ownership boundary. +use solvecraft_geom::Plane; + +use crate::{DocError, Mat, ModelState, Result, apply_point, apply_vector}; + +pub(crate) fn remap_sheet_metadata(st: &mut ModelState, names: &[String], transform: &Mat) -> Result<()> { + if names.len() > 1000 || st.sheets.len().saturating_add(st.threads.len()) > 100_000 || !transform.iter().flatten().all(|v| v.is_finite()) { + return Err(DocError::Invalid("body transfer metadata exceeds its finite processing budget".into())); + } + let names: std::collections::BTreeSet<&str> = names.iter().map(String::as_str).collect(); + if !st.sheets.iter().any(|s| names.contains(s.body.as_str())) && !st.threads.iter().any(|t| names.contains(t.body.as_str())) { + return Ok(()); + } + if !crate::valid_transfer_matrix(transform) { + return Err(DocError::Invalid("sheet and thread transfer requires a rigid placement".into())); + } + let frames: Vec<_> = st + .sheets + .iter() + .enumerate() + .filter(|(_, s)| names.contains(s.body.as_str())) + .map(|(i, s)| { + let frame = Plane::new(apply_point(transform, s.frame.origin), apply_vector(transform, s.frame.x), apply_vector(transform, s.frame.y)) + .ok_or_else(|| DocError::Invalid("invalid transferred sheet frame".into()))?; + Ok((i, frame)) + }) + .collect::>()?; + let threads: Vec<_> = st + .threads + .iter() + .enumerate() + .filter(|(_, t)| names.contains(t.body.as_str())) + .map(|(i, t)| { + let point = apply_point(transform, t.axis_point); + let axis = apply_vector(transform, t.axis).normalized().ok_or_else(|| DocError::Invalid("invalid transferred thread axis".into()))?; + if !point.is_finite() { + return Err(DocError::Invalid("invalid transferred thread placement".into())); + } + Ok((i, point, axis)) + }) + .collect::>()?; + for (i, frame) in frames { + if let Some(s) = st.sheets.get_mut(i) { + s.frame = frame; + } + } + for (i, point, axis) in threads { + if let Some(t) = st.threads.get_mut(i) { + t.axis_point = point; + t.axis = axis; + } + } + Ok(()) +} diff --git a/crates/doc/src/clipboard.rs b/crates/doc/src/clipboard.rs index add9db0a..d0b89bfd 100644 --- a/crates/doc/src/clipboard.rs +++ b/crates/doc/src/clipboard.rs @@ -304,7 +304,8 @@ impl crate::Feature { | K::Align { bodies: list, .. } | K::Remove { bodies: list } | K::Stitch { bodies: list, .. } - | K::Move { bodies: list, .. } => { + | K::Move { bodies: list, .. } + | K::BodyTransfer { bodies: list, .. } => { for n in list { mapped_name(n, bodies); } diff --git a/crates/doc/src/config.rs b/crates/doc/src/config.rs index e0e2bc47..db242353 100644 --- a/crates/doc/src/config.rs +++ b/crates/doc/src/config.rs @@ -12,6 +12,9 @@ pub use rules::*; #[path = "config_styles.rs"] mod styles; pub use styles::*; +#[path = "config_certificates.rs"] +mod certificates; +pub use certificates::*; #[path = "config_clone.rs"] mod clone; pub use clone::*; @@ -235,6 +238,7 @@ impl Document { look, name: Some(target.name.clone()), strict_name: true, + certificate: target.certificate.clone(), }); } } diff --git a/crates/doc/src/config_certificates.rs b/crates/doc/src/config_certificates.rs new file mode 100644 index 00000000..4b51cadf --- /dev/null +++ b/crates/doc/src/config_certificates.rs @@ -0,0 +1,232 @@ +//! Certificates are producer identities, never a nearest-surface selection. +use crate::{DocError, Document, FeatureKind, ModelBody, Result, SolvedSketch}; +use serde::{Deserialize, Serialize}; +use solvecraft_geom::Seg2; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum FaceCertificate { + ExtrudeSide { sketch: u64, curve: String }, + Imported { payload_sha256: String, item: u64, face: u64 }, +} + +impl FaceCertificate { + pub fn remap_features(&mut self, features: &std::collections::BTreeMap) { + if let Self::ExtrudeSide { sketch, .. } = self + && let Some(mapped) = features.get(sketch) + { + *sketch = *mapped; + } + } + pub fn validate(&self) -> Result<()> { + let valid = match self { + Self::ExtrudeSide { sketch, curve } => *sketch != 0 && !curve.is_empty() && curve.len() <= 128, + Self::Imported { payload_sha256, item, face } => { + *item != 0 + && *face != 0 + && payload_sha256.len() == 64 + && payload_sha256.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + } + }; + if valid { Ok(()) } else { Err(DocError::Invalid("invalid face source certificate".into())) } + } +} + +pub(crate) fn validate_certificate_json(value: &serde_json::Value) -> Result<()> { + if value.is_null() { + return Ok(()); + } + let fields = value.as_object().ok_or_else(|| DocError::Invalid("face certificate must be an object".into()))?; + let nonzero = |field: &str| fields.get(field).and_then(serde_json::Value::as_u64).is_some_and(|id| id != 0); + let valid = match fields.get("kind").and_then(serde_json::Value::as_str) { + Some("extrude_side") => { + fields.len() == 3 + && fields.keys().all(|key| ["kind", "sketch", "curve"].contains(&key.as_str())) + && nonzero("sketch") + && fields.get("curve").and_then(serde_json::Value::as_str).is_some_and(|curve| !curve.is_empty() && curve.len() <= 128) + } + Some("imported") => { + fields.len() == 4 + && fields.keys().all(|key| ["kind", "payload_sha256", "item", "face"].contains(&key.as_str())) + && nonzero("item") + && nonzero("face") + && fields + .get("payload_sha256") + .and_then(serde_json::Value::as_str) + .is_some_and(|digest| digest.len() == 64 && digest.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))) + } + _ => false, + }; + if valid { Ok(()) } else { Err(DocError::Invalid("malformed or oversized face source certificate".into())) } +} + +/// Match a generated segment to one exact analytic sketch curve. Coincident +/// candidates are ambiguous and never resolved by distance or candidate order. +pub(crate) fn exact_sketch_curve(sketch: &SolvedSketch, generated: &Seg2) -> Option { + if sketch.sketch.curves.len() > 2000 { + return None; + } + let mut found = None; + for (index, curve) in sketch.sketch.curves.iter().enumerate() { + if curve.construction + || !matches!( + curve.kind, + solvecraft_sketch::CurveKind::Line { .. } | solvecraft_sketch::CurveKind::Arc { .. } | solvecraft_sketch::CurveKind::Circle { .. } + ) + { + continue; + } + let matches = if let solvecraft_sketch::CurveKind::Circle { c, r } = curve.kind { + sketch + .sketch + .point(c) + .is_some_and(|center| exact_segment_contains(&Seg2::Arc { center, radius: r, start: 0., sweep: std::f64::consts::TAU }, generated)) + } else { + sketch.sketch.segs(index).iter().any(|original| exact_segment_contains(original, generated)) + }; + if matches { + if found.is_some() { + return None; + } + found = Some(curve.id.clone()); + } + } + found +} + +fn exact_segment_contains(original: &Seg2, generated: &Seg2) -> bool { + match (original, generated) { + (Seg2::Line { a, b }, Seg2::Line { a: c, b: d }) => { + let direction = *b - *a; + let scale = direction.len().max((*d - *c).len()).max(1.); + let interval = |point: solvecraft_geom::Vec2| { + let t = (point - *a).dot(direction) / direction.len2(); + (-1e-10..=1. + 1e-10).contains(&t) + }; + direction.len() > 1e-12 + && direction.cross(*c - *a).abs() <= 1e-10 * scale * scale + && direction.cross(*d - *a).abs() <= 1e-10 * scale * scale + && interval(*c) + && interval(*d) + } + (Seg2::Arc { center: a, radius: ar, start: os, sweep: ow }, Seg2::Arc { center: b, radius: br, start: gs, sweep: gw }) => { + let tau = std::f64::consts::TAU; + let tolerance = 1e-10; + let length = ow.abs(); + let generated_length = gw.abs(); + if a.dist(*b) > 1e-10 * ar.max(*br).max(1.) + || (ar - br).abs() > 1e-10 * ar.max(*br).max(1.) + || length > tau + tolerance + || generated_length > length + tolerance + { + return false; + } + if length >= tau - tolerance { + return true; + } + let original_low = if *ow >= 0. { *os } else { os + ow }; + let generated_low = if *gw >= 0. { *gs } else { gs + gw }; + let offset = (generated_low - original_low).rem_euclid(tau); + let offset = if offset > tau - tolerance { 0. } else { offset }; + offset + generated_length <= length + tolerance + } + _ => false, + } +} + +impl Document { + pub fn configuration_face_target(&self, body: &ModelBody, index: usize, point: solvecraft_geom::Vec3) -> Result { + let names = crate::naming::face_names(body); + let mut name = names + .get(index) + .map(|name| crate::naming::strip_piece(name).to_string()) + .ok_or_else(|| DocError::Unknown("face source name missing".into()))?; + let origin = match body.body.face_sources().and_then(|sources| sources.get(index)).and_then(Option::as_ref) { + Some(solvecraft_kernel::FaceSource::Imported { producer: Some(producer), .. }) + | Some(solvecraft_kernel::FaceSource::CertifiedExtrudeSide { producer, .. }) => *producer, + _ => name + .strip_prefix('F') + .and_then(|name| name.split_once(':')) + .and_then(|(id, _)| id.parse().ok()) + .ok_or_else(|| DocError::Invalid("face source producer missing".into()))?, + }; + let certificate = self.configuration_face_certificate(body, index)?; + if let Some(FaceCertificate::ExtrudeSide { curve, .. }) = &certificate { + name = format!("F{origin}:side:{curve}"); + } else if !name.starts_with(&format!("F{origin}:")) { + name = format!("F{origin}:face:{index}"); + } + let inverse = crate::mat_inverse(&body.source_transform).ok_or_else(|| DocError::Invalid("invalid definition placement".into()))?; + Ok(crate::config::ConfigFaceTarget { + body: body.definition_name().into(), + origin_feature: origin, + name, + point: crate::apply_point(&inverse, point), + certificate, + }) + } + pub fn configuration_face_certificate(&self, body: &ModelBody, index: usize) -> Result> { + let Some(source) = body.body.face_sources().and_then(|sources| sources.get(index)).and_then(Option::as_ref) else { + return Ok(None); + }; + if let solvecraft_kernel::FaceSource::Imported { item, face, payload_sha256: Some(digest), producer: Some(producer) } = source { + if !self.feature(*producer).is_some_and(|feature| matches!(feature.kind, FeatureKind::Import { .. })) { + return Err(DocError::Invalid("import source producer missing".into())); + } + let certificate = FaceCertificate::Imported { payload_sha256: digest.clone(), item: *item, face: *face }; + certificate.validate()?; + return Ok(Some(certificate)); + } + let certificate = match source { + solvecraft_kernel::FaceSource::CertifiedExtrudeSide { producer, sketch, curve } => { + if !self.feature(*producer).is_some_and(|feature| matches!(feature.kind, FeatureKind::Extrude { .. })) { + return Err(DocError::Invalid("side source producer missing".into())); + } + FaceCertificate::ExtrudeSide { sketch: *sketch, curve: curve.clone() } + } + _ => return Err(DocError::Invalid("face source does not match producer".into())), + }; + certificate.validate()?; + Ok(Some(certificate)) + } +} + +pub(crate) fn payload_digest(payload: &str) -> String { + use sha2::{Digest, Sha256}; + format!("{:x}", Sha256::digest(payload.as_bytes())) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + #[test] + fn certificate_budgets_apply_before_saved_target_and_paint_decode() { + for certificate in [ + json!({"kind":"extrude_side","sketch":1,"curve":"x".repeat(1_000_000)}), + json!({"kind":"imported","payload_sha256":"0".repeat(64),"item":0,"face":1}), + json!({"kind":"imported","payload_sha256":"g".repeat(64),"item":1,"face":1}), + json!({"kind":"extrude_side","sketch":1,"curve":"c1","extra":[[[[[]]]]]}), + ] { + assert!(validate_certificate_json(&certificate).is_err()); + let target = json!({"configs":{"columns":[{"face_appearance":{"body":"B","origin_feature":1,"name":"F1:side:c1","point":[0,0,0],"certificate":certificate}}]}}); + assert!(crate::config::validate_styles_json(&target).is_err()); + let paint = json!({"appearances":{"faces":[{"certificate":certificate}]}}); + assert!(crate::config::validate_styles_json(&paint).is_err()); + } + assert!(validate_certificate_json(&json!({"kind":"imported","payload_sha256":"a".repeat(64),"item":1,"face":2})).is_ok()); + assert!(validate_certificate_json(&json!({"kind":"extrude_side","sketch":1,"curve":"c1"})).is_ok()); + } + #[test] + fn analytic_arc_interval_refuses_unsampled_gaps_and_accepts_wrap_reversal() { + let radians = |degrees: f64| degrees.to_radians(); + let arc = |start, sweep| Seg2::Arc { center: solvecraft_geom::Vec2::default(), radius: 10., start: radians(start), sweep: radians(sweep) }; + assert!(!exact_segment_contains(&arc(10., 350.), &arc(20., 360.)), "five point probes missed the missing ten-degree gap"); + assert!(!exact_segment_contains(&arc(10., 350.), &arc(20., -360.))); + assert!(exact_segment_contains(&arc(350., 40.), &arc(355., 20.))); + assert!(exact_segment_contains(&arc(350., 40.), &arc(15., -20.))); + assert!(exact_segment_contains(&arc(30., -40.), &arc(15., -20.))); + assert!(!exact_segment_contains(&arc(350., 40.), &arc(345., 20.))); + assert!(exact_segment_contains(&arc(0., 360.), &arc(20., -360.))); + } +} diff --git a/crates/doc/src/config_clone.rs b/crates/doc/src/config_clone.rs index 7ccb8eba..da1aac92 100644 --- a/crates/doc/src/config_clone.rs +++ b/crates/doc/src/config_clone.rs @@ -75,6 +75,9 @@ impl ConfigTable { .map(|role| format!("F{origin}:{role}")) .unwrap_or_else(|| target.name.clone()); target.origin_feature = *origin; + if let Some(certificate) = &mut target.certificate { + certificate.remap_features(&maps.features); + } Column::FaceAppearance(target) }) } @@ -140,6 +143,7 @@ mod tests { origin_feature: 3, name: "F3:+z".into(), point: solvecraft_geom::Vec3::new(1., 1., 2.), + certificate: None, }), ], rows: vec![ConfigRow { @@ -196,6 +200,7 @@ mod tests { origin_feature: 3, name: "F3:+z".into(), point: solvecraft_geom::Vec3::ZERO, + certificate: None, }), ], rows: vec![ConfigRow { name: "Default".into(), cells: vec!["2 mm".into(), String::new()] }], diff --git a/crates/doc/src/config_styles.rs b/crates/doc/src/config_styles.rs index 577e5c38..f9b4d949 100644 --- a/crates/doc/src/config_styles.rs +++ b/crates/doc/src/config_styles.rs @@ -13,6 +13,8 @@ pub struct ConfigFaceTarget { pub name: String, /// Definition-frame point for diagnostics and rendering; never a resolver fallback. pub point: Vec3, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub certificate: Option, } impl ConfigFaceTarget { pub fn key(&self) -> String { @@ -23,6 +25,9 @@ impl ConfigFaceTarget { face.body == self.body && face.name.as_ref().is_some_and(|name| crate::naming::strip_piece(name) == self.name) } pub fn validate(&self) -> Result<()> { + if let Some(certificate) = &self.certificate { + certificate.validate()?; + } if self.body.is_empty() || self.body.len() > 128 || self.name.is_empty() @@ -36,6 +41,25 @@ impl ConfigFaceTarget { } } +pub fn validate_face_target_json(target: &Value) -> Result<()> { + let fields = target.as_object().ok_or_else(|| DocError::Invalid("face target must be an object".into()))?; + if !(4..=5).contains(&fields.len()) + || fields.keys().any(|key| !["body", "origin_feature", "name", "point", "certificate"].contains(&key.as_str())) + || fields.get("body").and_then(Value::as_str).is_none_or(|body| body.is_empty() || body.len() > 128) + || fields.get("name").and_then(Value::as_str).is_none_or(|name| name.is_empty() || name.len() > 512) + || fields.get("origin_feature").and_then(Value::as_u64).is_none_or(|id| id == 0) + || fields.get("point").and_then(Value::as_array).is_none_or(|point| { + point.len() != 3 || point.iter().any(|value| value.as_f64().is_none_or(|value| !value.is_finite() || value.abs() > 1e9)) + }) + { + return Err(DocError::Invalid("invalid or oversized configuration face target".into())); + } + if let Some(certificate) = fields.get("certificate") { + super::validate_certificate_json(certificate)?; + } + Ok(()) +} + /// Budget malformed style nodes before decoding their typed representation. pub fn validate_styles_json(value: &Value) -> Result<()> { if let Some(columns) = value.get("configs").and_then(|v| v.get("columns")).and_then(Value::as_array) { @@ -44,18 +68,7 @@ pub fn validate_styles_json(value: &Value) -> Result<()> { } for column in columns { if let Some(target) = column.get("face_appearance") { - let fields = target.as_object().ok_or_else(|| DocError::Invalid("face target must be an object".into()))?; - if fields.len() != 4 - || fields.keys().any(|key| !["body", "origin_feature", "name", "point"].contains(&key.as_str())) - || fields.get("body").and_then(Value::as_str).is_none_or(|body| body.is_empty() || body.len() > 128) - || fields.get("name").and_then(Value::as_str).is_none_or(|name| name.is_empty() || name.len() > 512) - || fields.get("origin_feature").and_then(Value::as_u64).is_none_or(|id| id == 0) - || fields.get("point").and_then(Value::as_array).is_none_or(|point| { - point.len() != 3 || point.iter().any(|value| value.as_f64().is_none_or(|value| !value.is_finite() || value.abs() > 1e9)) - }) - { - return Err(DocError::Invalid("invalid or oversized configuration face target".into())); - } + validate_face_target_json(target)?; } if let Some(id) = column.get("component_appearance") && id.as_u64().is_none_or(|id| id == 0) @@ -64,6 +77,23 @@ pub fn validate_styles_json(value: &Value) -> Result<()> { } } } + if let Some(faces) = value.get("appearances").and_then(|value| value.get("faces")).and_then(Value::as_array) { + for face in faces { + if let Some(certificate) = face.get("certificate") { + super::validate_certificate_json(certificate)?; + if !certificate.is_null() + && (face.get("strict_name").and_then(Value::as_bool) != Some(true) + || face.get("body").and_then(Value::as_str).is_none_or(|body| body.is_empty() || body.len() > 128) + || face.get("name").and_then(Value::as_str).is_none_or(|name| name.is_empty() || name.len() > 512) + || face.get("point").and_then(Value::as_array).is_none_or(|point| { + point.len() != 3 || point.iter().any(|value| value.as_f64().is_none_or(|value| !value.is_finite() || value.abs() > 1e9)) + })) + { + return Err(DocError::Invalid("invalid certified face appearance".into())); + } + } + } + } Ok(()) } @@ -77,6 +107,17 @@ impl Document { return Err(DocError::Invalid("configure a face lineage, not a numbered split piece".into())); } let feature = self.feature(target.origin_feature).ok_or_else(|| DocError::Unknown("face origin feature is gone".into()))?; + if let Some(certificate) = &target.certificate { + let valid = match (certificate, &feature.kind) { + (super::FaceCertificate::ExtrudeSide { sketch, curve }, FeatureKind::Extrude { sketch: producer_sketch, extent, operation: crate::Operation::NewBody, .. }) => { + sketch == producer_sketch && extent.taper.is_none() && role == format!("side:{curve}") + && self.feature(*sketch).is_some_and(|feature| matches!(&feature.kind, FeatureKind::Sketch { sketch, .. } if sketch.curves.iter().any(|candidate| candidate.id == *curve && !candidate.construction && matches!(candidate.kind, solvecraft_sketch::CurveKind::Line { .. } | solvecraft_sketch::CurveKind::Circle { .. } | solvecraft_sketch::CurveKind::Arc { .. })))) + } + (super::FaceCertificate::Imported { .. }, FeatureKind::Import { .. }) => role.strip_prefix("face:").is_some_and(|index| index.parse::().is_ok()), + _ => false, + }; + return if valid { Ok(()) } else { Err(DocError::Invalid("face certificate no longer matches its producer".into())) }; + } let valid = match &feature.kind { FeatureKind::Box { .. } => ["+x", "-x", "+y", "-y", "+z", "-z"].contains(&role), FeatureKind::Cylinder { .. } => ["top", "bottom", "side"].contains(&role), @@ -108,12 +149,38 @@ impl Document { if body.body.is_mesh() || body.definition_name() != target.body { return Err(DocError::Invalid("configured face definition does not match".into())); } + if let Some(super::FaceCertificate::ExtrudeSide { sketch, curve }) = &target.certificate { + let indices: Vec<_> = body.body.face_sources().into_iter().flatten().enumerate().filter_map(|(index, source)| { + matches!(source, Some(solvecraft_kernel::FaceSource::CertifiedExtrudeSide { producer, sketch: current_sketch, curve: current_curve }) if *producer == target.origin_feature && current_sketch == sketch && current_curve == curve).then_some(index) + }).collect(); + if indices.is_empty() { + return Err(DocError::Unknown("constructed side certificate no longer exists".into())); + } + return Ok(indices); + } + if let Some(super::FaceCertificate::Imported { payload_sha256, item, face }) = &target.certificate { + let indices: Vec<_> = body.body.face_sources().into_iter().flatten().enumerate().filter_map(|(index, source)| { + matches!(source, Some(solvecraft_kernel::FaceSource::Imported { item: current_item, face: current_face, payload_sha256: Some(digest), producer: Some(producer) }) if current_item == item && current_face == face && digest == payload_sha256 && *producer == target.origin_feature).then_some(index) + }).collect(); + if indices.is_empty() { + return Err(DocError::Unknown("immutable import face certificate no longer exists".into())); + } + return Ok(indices); + } let names = crate::naming::face_names(body); let indices: Vec = names.iter().enumerate().filter(|(_, name)| crate::naming::strip_piece(name) == target.name).map(|(index, _)| index).collect(); if indices.is_empty() { return Err(DocError::Unknown(format!("configured face `{}` on `{}` no longer exists", target.name, target.body))); } + if let Some(certificate) = &target.certificate { + for index in &indices { + if self.configuration_face_certificate(body, *index)?.as_ref() != Some(certificate) { + return Err(DocError::Invalid("face pieces do not share the exact certified source".into())); + } + } + return Ok(indices); + } if target.name.ends_with(":side") { if indices.len() != 1 { return Err(DocError::Invalid("split curved face lineage is not yet certified".into())); diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 3ed7742a..0f16df14 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -174,8 +174,8 @@ pub struct EmbossFace { impl EmbossFace { /// The selected sketch placement, shared by evaluation and interactive guides. - pub fn sketch_frame(&self, doc: &Document, author: u64, sketch_owner: u64, body_feature: u64) -> Result { - let owner = doc.body_component(&self.body, body_feature); + pub fn sketch_frame(&self, doc: &Document, author: u64, sketch_owner: u64, body: &crate::ModelBody) -> Result { + let owner = doc.model_body_component(body); if sketch_owner == owner { return Self::component_frame(doc, owner, &self.path); } @@ -202,10 +202,10 @@ impl EmbossFace { } /// Resolve this exact placement without expanding every assembly instance. - pub fn body_frame(&self, doc: &Document, feature: u64) -> Result { - let owner = doc.body_component(&self.body, feature); + pub fn body_frame(&self, doc: &Document, body: &crate::ModelBody) -> Result { + let owner = doc.model_body_component(body); let mut frame = Self::component_frame(doc, owner, &self.path)?; - if let Some(offset) = doc.body_offsets.get(&self.body) { + if let Some(offset) = doc.model_body_offset(body) { frame = crate::mat_mul(&frame, offset); } Ok(frame) @@ -244,6 +244,14 @@ impl EmbossFace { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum FeatureKind { + /// A recorded timeline ownership boundary. Geometry is converted from its + /// current local frame into the destination component frame exactly once. + BodyTransfer { + bodies: Vec, + from: u64, + to: u64, + transform: crate::Mat, + }, Sketch { plane: PlaneRef, sketch: Sketch, @@ -1121,7 +1129,8 @@ impl FeatureKind { | K::SurfaceTrim { .. } | K::Move { .. } | K::Import { .. } - | K::MeshImport { .. } => Vec::new(), + | K::MeshImport { .. } + | K::BodyTransfer { .. } => Vec::new(), } } @@ -1146,6 +1155,7 @@ impl FeatureKind { /// Fusion-like type name (timeline tooltip, `inspect`). pub fn type_name(&self) -> &'static str { match self { + FeatureKind::BodyTransfer { .. } => "BodyTransferFeature", FeatureKind::Sketch { .. } => "Sketch", FeatureKind::Extrude { .. } => "ExtrudeFeature", FeatureKind::Revolve { .. } => "RevolveFeature", @@ -1210,6 +1220,7 @@ impl FeatureKind { /// Default name prefix (`Extrude` → `Extrude1`). pub fn base_name(&self) -> &'static str { match self { + FeatureKind::BodyTransfer { .. } => "BodyTransfer", FeatureKind::Sketch { .. } => "Sketch", FeatureKind::Extrude { .. } => "Extrude", FeatureKind::Revolve { .. } => "Revolve", @@ -1465,7 +1476,7 @@ impl FeatureKind { FeatureKind::SurfaceTrim { plane, .. } => plane_exprs(plane, &mut v), FeatureKind::SurfaceExtend { distance, .. } => v.push(distance), FeatureKind::Patch { .. } => {} - FeatureKind::Import { .. } | FeatureKind::MeshImport { .. } => {} + FeatureKind::Import { .. } | FeatureKind::MeshImport { .. } | FeatureKind::BodyTransfer { .. } => {} } v } @@ -1701,9 +1712,36 @@ impl Document { /// The component a body belongs to: where it was moved, else its feature's. pub fn body_component(&self, body: &str, feature: u64) -> u64 { + self.features + .iter() + .take(self.marker.unwrap_or(usize::MAX)) + .rev() + .find_map(|f| { + if f.suppressed { + return None; + } + match &f.kind { + FeatureKind::BodyTransfer { bodies, to, .. } if bodies.iter().any(|b| b == body) => Some(*to), + _ => None, + } + }) + .unwrap_or_else(|| self.legacy_body_component(body, feature)) + } + + pub fn legacy_body_component(&self, body: &str, feature: u64) -> u64 { self.body_components.get(body).copied().unwrap_or_else(|| self.feature(feature).map(|f| f.component).unwrap_or(0)) } + /// Ownership in this evaluated timeline state, rather than a later transfer. + pub fn model_body_component(&self, body: &crate::ModelBody) -> u64 { + if body.transferred || body.source_name.is_some() { body.component } else { self.legacy_body_component(&body.name, body.feature) } + } + + /// Recorded transfers already converted geometry into the new local frame. + pub fn model_body_offset(&self, body: &crate::ModelBody) -> Option<&crate::Mat> { + if body.transferred { None } else { self.body_offsets.get(&body.name) } + } + /// Add a component under `parent` and return its id. pub fn add_component(&mut self, name: Option<&str>, parent: u64) -> Result { if parent != 0 && !self.components.iter().any(|c| c.id == parent) { @@ -1784,6 +1822,12 @@ impl Document { if self.features.len() >= MAX_FEATURES { return Err(DocError::Invalid("too many features".into())); } + let component = if let FeatureKind::BodyTransfer { bodies, from, to, transform } = &kind { + crate::validate_body_transfer(self, bodies, *from, *to, transform)?; + *to + } else { + 0 + }; let id = self.next_id.max(1); self.next_id = id + 1; let name = match name { @@ -1796,7 +1840,7 @@ impl Document { suppressed: false, body_names: Vec::new(), param_names: Vec::new(), - component: 0, + component, edge_refs: Vec::new(), edge_names: Vec::new(), face_names: Vec::new(), diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 626b7229..693fd349 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -33,6 +33,8 @@ pub struct ModelBody { pub(crate) names: crate::naming::NameCell, /// The component the body belongs to (set after each feature). pub component: u64, + /// A timeline transfer has converted this body into its evaluated owner's frame. + pub transferred: bool, /// The look it was imported with, carried through later features. pub imported: Option>, } @@ -49,6 +51,7 @@ impl ModelBody { key: 0, names: Default::default(), component: 0, + transferred: false, imported: None, } } @@ -274,6 +277,8 @@ impl ModelState { b.name.hash(&mut h); b.key.hash(&mut h); b.feature.hash(&mut h); + b.component.hash(&mut h); + b.transferred.hash(&mut h); } for s in &self.sketches { s.feature.hash(&mut h); @@ -292,6 +297,13 @@ impl ModelState { let depth = before.bodies.iter().filter_map(|b| b.names.origin.as_ref().map(|o| o.depth)).max().unwrap_or(0) + 1; let origin = Arc::new(crate::naming::NamingOrigin { feature: f.clone(), before: before.clone(), depth, mats }); for b in &mut self.bodies { + if !matches!(f.kind, FeatureKind::BodyTransfer { .. }) + && let Some(old) = before.bodies.iter().find(|old| old.name == b.name && old.transferred) + { + b.transferred = true; + b.component = old.component; + b.source_transform = old.source_transform; + } let kept = before.bodies.iter().find(|x| x.name == b.name && x.body.same(&b.body)); match kept { Some(x) => { @@ -302,7 +314,9 @@ impl ModelState { let mut h = std::collections::hash_map::DefaultHasher::new(); (key, &b.name).hash(&mut h); b.key = h.finish(); - b.names = crate::naming::NameCell::new(Some(origin.clone())); + if !matches!(f.kind, FeatureKind::BodyTransfer { .. }) { + b.names = crate::naming::NameCell::new(Some(origin.clone())); + } // Faces split on purpose stay split through later features (for export). if b.body.split_lines().is_none() && let Some(lines) = before.bodies.iter().find(|x| x.name == b.name).and_then(|x| x.body.split_lines()) @@ -494,6 +508,26 @@ fn cache_key(f: &Feature, chain_fp: u64, local: u64, ctx: u64, state: &ModelStat /// Where components are placed, for a sketch that sees other components' geometry (on a face, /// or with projections): it is resolved in its own component's view of them. fn placement_digest(doc: &Document, f: &Feature) -> u64 { + let path = match &f.kind { + FeatureKind::Sweep { path_sketch, .. } + | FeatureKind::Pipe { path_sketch, .. } + | FeatureKind::Pattern { pattern: crate::PatternKind::Path { path_sketch, .. }, .. } => Some(*path_sketch), + _ => None, + }; + if let Some(path) = path + && doc.sketch(path).is_ok_and(|sketch| !sketch.wires.is_empty()) + { + let mut hash = std::collections::hash_map::DefaultHasher::new(); + let owner = doc.feature(path).map(|feature| feature.component).unwrap_or(0); + for frame in [doc.component_transform(f.component), doc.component_transform(owner)] { + frame.iter().flatten().for_each(|value| value.to_bits().hash(&mut hash)); + } + if let FeatureKind::Sweep { sketch, .. } = &f.kind { + let owner = doc.feature(*sketch).map(|feature| feature.component).unwrap_or(0); + doc.component_transform(owner).iter().flatten().for_each(|value| value.to_bits().hash(&mut hash)); + } + return hash.finish(); + } let FeatureKind::Sketch { plane, sketch } = &f.kind else { return 0 }; if doc.occurrences.is_empty() || (!matches!(plane, PlaneRef::Face { .. }) && sketch.links.is_empty()) { return 0; @@ -673,7 +707,7 @@ impl Model { Ok(()) => { // Copies are named after their sources through the instance transforms. let mats = match &f.kind { - FeatureKind::Pattern { pattern, .. } => pattern_transforms(&vals, &state, pattern).unwrap_or_default(), + FeatureKind::Pattern { pattern, .. } => pattern_transforms(doc, &vals, &state, f.component, pattern).unwrap_or_default(), FeatureKind::Mirror { plane, .. } => { doc.resolve_plane_in(&vals, plane, 0, Some(&state)).map(|pl| vec![mirror_matrix(&pl)]).unwrap_or_default() } @@ -681,7 +715,9 @@ impl Model { }; next.stamp(&state, f, key, mats); for b in &mut next.bodies { - b.component = doc.body_component(&b.name, b.feature); + if !b.transferred { + b.component = doc.legacy_body_component(&b.name, b.feature); + } } state = Arc::new(next); None @@ -723,7 +759,7 @@ fn val(vals: &BTreeMap, e: &str, k: Kind) -> Result { Document::eval_in(vals, e, k) } -fn select_profiles(ss: &SolvedSketch, sel: &ProfileSel) -> Result> { +pub fn select_profiles(ss: &SolvedSketch, sel: &ProfileSel) -> Result> { let ps = &ss.profiles; if ps.is_empty() { return Err(DocError::Invalid(format!("{} has no closed profiles", ss.name))); @@ -794,7 +830,55 @@ fn copy_body_name(state: &ModelState, f: &Feature, k: usize, source: &str) -> St } /// Add tool bodies to the model according to the operation. +fn sheet_boolean_unchanged(body: &Body, tools: &[Body], op: Operation) -> Result<()> { + if sheet_boolean_changes(body, tools, op)? { + return Err(DocError::Invalid("this sheet machining cannot be preserved in its flat material: use a parallel, untapered extrude pocket/cut, or unfold before intersecting; unsupported 3D sheet machining is refused to prevent material restoration".into())); + } + Ok(()) +} + +fn sheet_boolean_changes(body: &Body, tools: &[Body], op: Operation) -> Result { + let united; + let tools: &[Body] = if op == Operation::Intersect { + united = intersection_tool_union(tools)?; + &united + } else { + tools + }; + for tool in tools { + let test = if op == Operation::Cut { BoolOp::Intersect } else { BoolOp::Cut }; + if kernel::boolean(body, tool, test)?.is_some() { + return Ok(true); + } + } + Ok(false) +} + +/// Selected profiles form one machining tool, including disconnected tool material. +/// Balanced unions avoid repeatedly rebuilding a growing aggregate boundary. +fn intersection_tool_union(tools: &[Body]) -> Result> { + if tools.len() > 1000 || tools.iter().fold(0usize, |n, b| n.saturating_add(b.face_count())) > 16_384 { + return Err(DocError::Invalid("intersection tools exceed the bounded geometry budget".into())); + } + let mut groups = tools.to_vec(); + while groups.len() > 1 { + let mut next = Vec::new(); + let mut iter = groups.into_iter(); + while let Some(a) = iter.next() { + match iter.next() { + Some(b) => { + next.push(kernel::boolean(&a, &b, BoolOp::Union)?.ok_or_else(|| DocError::Invalid("intersection tool union is empty".into()))?) + } + None => next.push(a), + } + } + groups = next; + } + Ok(groups) +} + fn apply_op(state: &mut ModelState, f: &Feature, tools: Vec, op: Operation, targets: &[String]) -> Result<()> { + let tools = if op == Operation::Intersect { intersection_tool_union(&tools)? } else { tools }; // Participants: the feature's own targets, else its explicit participants, else the bodies // of its component (and what it made itself): other components are left alone. let targets: &[String] = if targets.is_empty() { &f.participants } else { targets }; @@ -820,6 +904,13 @@ fn apply_op(state: &mut ModelState, f: &Feature, tools: Vec, op: Operation } return Ok(()); } + if matches!(op, Operation::Cut | Operation::Intersect) { + for &i in &target_idx { + if let Some(mb) = state.bodies.get(i).filter(|b| state.sheets.iter().any(|s| s.body == b.name)) { + sheet_boolean_unchanged(&mb.body, &tools, op)?; + } + } + } match op { Operation::Join => { let Some(&first) = target_idx.first() else { return Ok(()) }; @@ -859,6 +950,8 @@ fn apply_op(state: &mut ModelState, f: &Feature, tools: Vec, op: Operation i += 1; keep }); + let live: std::collections::BTreeSet<&str> = state.bodies.iter().map(|b| b.name.as_str()).collect(); + state.threads.retain(|t| live.contains(t.body.as_str())); // A cut can leave a body in pieces: each piece becomes its own body ("Bar", "Bar (1)"). let mut split: Vec = Vec::new(); for mb in std::mem::take(&mut state.bodies) { @@ -1382,8 +1475,57 @@ fn hole_tools( Ok(tools) } +/// The angular interval used by both the solid and its interactive extent handles. +#[derive(Clone, Copy, Debug)] +pub struct RevolveExtent { + pub start: f64, + pub sweep: f64, + pub rotation_axis: Vec3, + pub display_start: f64, + /// A curved target trims the swept solid and has no single angular boundary. + pub curved_to: bool, +} + +pub fn revolve_extent( + st: &ModelState, + plane: &Plane, + regions: &[Region2], + (origin, axis): (Vec3, Vec3), + (angle, angle2): (f64, Option), + symmetric: bool, + to: Option, +) -> Result { + let (start, sweep) = match (to, angle2, symmetric) { + (Some(point), _, _) => { + let points: Vec = regions.iter().flat_map(|r| r.outer.polyline(1e-2)).collect(); + let centre = plane.to_world(points.iter().fold(Vec2::default(), |sum, point| sum + *point) * (1.0 / points.len().max(1) as f64)); + let flat = |point: Vec3| { + let v = point - origin; + v - axis * v.dot(axis) + }; + let (u, v) = (flat(centre), flat(point)); + let sweep = (-axis).dot(u.cross(v)).atan2(u.dot(v)); + (0.0, if sweep <= 1e-9 { sweep + std::f64::consts::TAU } else { sweep }) + } + (None, Some(other), _) => (-other, angle + other), + (None, None, true) => (-angle / 2.0, angle), + (None, None, false) => (0.0, angle), + }; + if !(sweep.is_finite() && start.is_finite() && sweep.abs() > 1e-9 && sweep.abs() <= std::f64::consts::TAU + 1e-9) { + return Err(DocError::Invalid("the revolve angle must be more than 0 and at most 360°".into())); + } + let points: Vec = regions.iter().flat_map(|r| r.outer.polyline(1e-2)).collect(); + let centre = plane.to_world(points.iter().fold(Vec2::default(), |sum, point| sum + *point) * (1.0 / points.len().max(1) as f64)); + let unit_axis = axis.normalized().ok_or_else(|| DocError::Invalid("revolve axis has no direction".into()))?; + let radial_centre = regions.first().map(|region| plane.to_world(region.centroid())).unwrap_or(centre); + let radial = radial_centre - origin - unit_axis * (radial_centre - origin).dot(unit_axis); + let rotation_axis = if unit_axis.cross(radial).dot(plane.normal()) < 0.0 { -unit_axis } else { unit_axis }; + let display_start = start * -unit_axis.dot(rotation_axis); + Ok(RevolveExtent { start, sweep, rotation_axis, display_start, curved_to: to.is_some_and(|point| curved_target_any(st, point).is_some()) }) +} + /// The tool bodies a feature adds or removes (extrude, revolve, primitives), in the current state. -fn feature_tools(vals: &BTreeMap, f: &Feature, st: &ModelState) -> Result> { +fn feature_tools(doc: &Document, vals: &BTreeMap, f: &Feature, st: &ModelState) -> Result> { match &f.kind { FeatureKind::Extrude { sketch, profiles, extent, operation, targets } => { let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?.clone(); @@ -1486,41 +1628,36 @@ fn feature_tools(vals: &BTreeMap, f: &Feature, st: &ModelState) - let start = if hi > 0.0 { lo + off } else { hi + off }; tools = trim_to_body(tools, &tb.body, |q: Vec3| ((q - ss.plane.origin).dot(n) - start).abs())?; } + if *operation == Operation::NewBody && extent.taper.is_none() { + tools = tools + .into_iter() + .map(|body| { + let curves: Vec<_> = body + .face_sources() + .into_iter() + .flatten() + .map(|source| match source { + Some(kernel::FaceSource::ExtrudeSide { segment }) => crate::config::exact_sketch_curve(&ss, segment), + _ => None, + }) + .collect(); + body.with_extrude_certificates(f.id, *sketch, &curves).map_err(DocError::from) + }) + .collect::>>()?; + } Ok(tools) } FeatureKind::Revolve { sketch, profiles, axis, angle, angle2, symmetric, to, .. } => { let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?.clone(); let regions = solvecraft_sketch::merge_regions(&select_profiles(&ss, profiles)?); let (o, d) = revolve_axis(&ss, axis)?; - // Where the turn starts (from the profile) and how far it goes. - let (start, ang) = match (to, angle2, symmetric) { - (Some(p), _, _) => { - let (ow, aw) = (ss.plane.to_world(o), ss.plane.dir_to_world(d)); - let pts: Vec = regions.iter().flat_map(|r| r.outer.polyline(1e-2)).collect(); - let c = ss.plane.to_world(pts.iter().fold(Vec2::default(), |a, q| a + *q) * (1.0 / pts.len().max(1) as f64)); - let flat = |q: Vec3| { - let v = q - ow; - v - aw * v.dot(aw) - }; - let (u, v) = (flat(c), flat(*p)); - // The revolve turns clockwise about its axis direction (the kernel's sense). - let a = (aw * -1.0).dot(u.cross(v)).atan2(u.dot(v)); - let a = if a <= 1e-9 { a + std::f64::consts::TAU } else { a }; - (0.0, a) - } - (None, Some(a2), _) => { - let (a, b) = (val(vals, angle, Kind::Angle)?, val(vals, a2, Kind::Angle)?); - (-b, a + b) - } - (None, None, true) => { - let a = val(vals, angle, Kind::Angle)?; - (-a / 2.0, a) - } - (None, None, false) => (0.0, val(vals, angle, Kind::Angle)?), + let angles = if to.is_some() { + (0.0, None) + } else { + (val(vals, angle, Kind::Angle)?, angle2.as_ref().map(|a| val(vals, a, Kind::Angle)).transpose()?) }; - if !(ang.abs() > 1e-9 && ang.abs() <= std::f64::consts::TAU + 1e-9) { - return Err(DocError::Invalid("the revolve angle must be more than 0 and at most 360°".into())); - } + let extent = revolve_extent(st, &ss.plane, ®ions, (ss.plane.to_world(o), ss.plane.dir_to_world(d)), angles, *symmetric, *to)?; + let (start, ang) = (extent.start, extent.sweep); let (operation, targets) = feature_op(f); let axis_w = ss.plane.dir_to_world(d); let origin_w = ss.plane.to_world(o); @@ -1633,6 +1770,15 @@ fn feature_tools(vals: &BTreeMap, f: &Feature, st: &ModelState) - let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?; let ps = st.sketch(*path_sketch).ok_or_else(|| DocError::Unknown(format!("sketch {path_sketch} (it must come earlier in the timeline)")))?; + let profile; + let ss = if !ps.sketch.wires.is_empty() { + profile = profile_in_frame(doc, ss, f.component)?; + &profile + } else { + ss + }; + let mapped = path_in_frame(doc, ps, f.component)?; + let ps = mapped.as_ref(); let regions = solvecraft_sketch::merge_regions(&select_profiles(ss, profiles)?); let start = regions.first().map(|r| ss.plane.to_world(r.centroid())).unwrap_or_default(); // A path through 3D sketch curves follows them as points. @@ -1645,6 +1791,8 @@ fn feature_tools(vals: &BTreeMap, f: &Feature, st: &ModelState) - FeatureKind::Pipe { path_sketch, path, diameter, wall, .. } => { let ps = st.sketch(*path_sketch).ok_or_else(|| DocError::Unknown(format!("sketch {path_sketch} (it must come earlier in the timeline)")))?; + let mapped = path_in_frame(doc, ps, f.component)?; + let ps = mapped.as_ref(); let r = val(vals, diameter, Kind::Length)? / 2.0; if !(r > 1e-6) { return Err(DocError::Invalid("pipe diameter must be positive".into())); @@ -1709,6 +1857,51 @@ fn pipe_region(vals: &BTreeMap, r: f64, wall: &Option) -> Ok(Region2 { outer: solvecraft_geom::Loop2::circle(Vec2::ZERO, r), holes }) } +fn map_wire(wire: &mut solvecraft_sketch::Wire, matrix: &Mat) { + for point in wire.pts.iter_mut().chain(&mut wire.fit) { + *point = crate::apply_point(matrix, *point); + } +} + +fn profile_in_frame(doc: &Document, sketch: &SolvedSketch, consumer: u64) -> Result { + let owner = doc.feature(sketch.feature).map(|feature| feature.component).unwrap_or(0); + let inverse = + crate::mat_inverse(&doc.component_transform(consumer)).ok_or_else(|| DocError::Invalid("profile consumer frame is invalid".into()))?; + let relative = crate::mat_mul(&inverse, &doc.component_transform(owner)); + let mut mapped = sketch.clone(); + mapped.plane = Plane::new( + crate::apply_point(&relative, sketch.plane.origin), + crate::apply_vector(&relative, sketch.plane.x), + crate::apply_vector(&relative, sketch.plane.y), + ) + .ok_or_else(|| DocError::Invalid("profile sketch frame is invalid".into()))?; + Ok(mapped) +} + +/// Stored wire coordinates are converted once into the feature consumer frame. +fn path_in_frame<'a>(doc: &Document, sketch: &'a SolvedSketch, consumer: u64) -> Result> { + use std::borrow::Cow; + if sketch.sketch.wires.is_empty() { + return Ok(Cow::Borrowed(sketch)); + } + let owner = doc.feature(sketch.feature).map(|f| f.component).unwrap_or(0); + let inverse = + crate::mat_inverse(&doc.component_transform(consumer)).ok_or_else(|| DocError::Invalid("path consumer frame is not invertible".into()))?; + let relative = crate::mat_mul(&inverse, &doc.component_transform(owner)); + let mut mapped = sketch.clone(); + mapped.plane = Plane::new( + crate::apply_point(&relative, sketch.plane.origin), + crate::apply_vector(&relative, sketch.plane.x), + crate::apply_vector(&relative, sketch.plane.y), + ) + .ok_or_else(|| DocError::Invalid("path sketch frame is invalid".into()))?; + for wire in &mut mapped.sketch.wires { + let matrix = if wire.space == solvecraft_sketch::WireSpace::Component { &relative } else { &inverse }; + map_wire(wire, matrix); + } + Ok(Cow::Owned(mapped)) +} + /// A path that uses 3D sketch curves (wires), as one chain of world points from the end nearest /// `start` (no start: the first curve runs toward the second). `None` when the path is all /// planar curves. @@ -1890,10 +2083,10 @@ const MAX_INSTANCES: usize = 1000; /// The placements (column-major matrices) of a pattern's copies, the original excluded. pub fn pattern_matrices(doc: &Document, st: &ModelState, p: &crate::PatternKind) -> Result> { let (vals, _) = doc.param_values(); - pattern_transforms(&vals, st, p) + pattern_transforms(doc, &vals, st, 0, p) } -fn pattern_transforms(vals: &BTreeMap, st: &ModelState, p: &crate::PatternKind) -> Result> { +fn pattern_transforms(doc: &Document, vals: &BTreeMap, st: &ModelState, component: u64, p: &crate::PatternKind) -> Result> { let count = |e: &str| -> Result { let n = val(vals, e, Kind::Unitless)?.round(); if !(1.0..=MAX_INSTANCES as f64).contains(&n) { @@ -1928,6 +2121,8 @@ fn pattern_transforms(vals: &BTreeMap, st: &ModelState, p: &crate let n = count(c)?; let d = val(vals, spacing, Kind::Length)?; let ps = st.sketch(*path_sketch).ok_or_else(|| DocError::Unknown(format!("path sketch {path_sketch}")))?; + let mapped = path_in_frame(doc, ps, component)?; + let ps = mapped.as_ref(); let mut segs = path_segments(ps, path, Vec3::new(f64::NAN, f64::NAN, f64::NAN))?; if *flip { segs = segs.iter().rev().map(reverse_seg).collect(); @@ -2093,7 +2288,7 @@ fn replay( } } } else { - let tools = feature_tools(vals, src, st)?; + let tools = feature_tools(doc, vals, src, st)?; for m in mats { for t in &tools { copies.push(kernel::transform_matrix(t, *m)?); @@ -2348,6 +2543,7 @@ fn chamfer_with( fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: &mut ModelState, warning: &mut Option) -> Result<()> { match &f.kind { + FeatureKind::BodyTransfer { bodies, from, to, transform } => crate::body_transfer::evaluate(doc, st, bodies, *from, *to, *transform), FeatureKind::SheetBase { .. } | FeatureKind::SheetContour { .. } | FeatureKind::SheetFlange { .. } @@ -2405,9 +2601,9 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: | FeatureKind::Loft { operation, targets, .. } | FeatureKind::Coil { operation, targets, .. } | FeatureKind::Sweep { operation, targets, .. } => { - let tools = feature_tools(vals, f, st)?; + let tools = feature_tools(doc, vals, f, st)?; // Cuts through sheet metal become cut-outs of its flat pattern. - if *operation == Operation::Cut && matches!(f.kind, FeatureKind::Extrude { .. }) && !st.sheets.is_empty() { + if matches!(operation, Operation::Cut | Operation::Intersect) && matches!(f.kind, FeatureKind::Extrude { .. }) && !st.sheets.is_empty() { let handled = more::sheet_cut(f, st, &tools, targets)?; if !handled.is_empty() { let effective = if targets.is_empty() { &f.participants } else { targets }; @@ -2436,7 +2632,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: } FeatureKind::Hole { thread, diameter, .. } => { let (spots, dir) = hole_spots(st, f)?; - let tools = feature_tools(vals, f, st)?; + let tools = feature_tools(doc, vals, f, st)?; apply_op(st, f, tools, Operation::Cut, &[])?; if let Some(d) = thread { // A point on each hole's wall, a little below the entry. @@ -2637,7 +2833,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: Ok(()) } FeatureKind::Pattern { features, pattern, bodies } => { - let mats = pattern_transforms(vals, st, pattern)?; + let mats = pattern_transforms(doc, vals, st, f.component, pattern)?; if bodies.is_empty() { return replay(doc, vals, f, st, features, &mats, warning); } @@ -2886,10 +3082,11 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: } FeatureKind::Import { step, .. } => { let imp = kernel::step_import_shared(step)?; + let digest = crate::config::payload_digest(step); for (k, b) in imp.bodies.iter().enumerate() { let base = f.body_names.get(k).filter(|n| !n.trim().is_empty()).unwrap_or(&b.name); let name = unique_body_name(st, base); - st.bodies.push(ModelBody::new(name, b.body.clone(), f.id)); + st.bodies.push(ModelBody::new(name, b.body.clone().with_import_certificate(&digest, f.id)?, f.id)); } if !imp.warnings.is_empty() { let n = imp.warnings.len(); @@ -2946,9 +3143,18 @@ pub fn state_in_frame_at<'a>(doc: &Document, st: &'a ModelState, comp: u64, fram None => m, } }; - let body_m: Vec = st.bodies.iter().map(|b| rel(doc.body_component(&b.name, b.feature), Some(&b.name))).collect(); + let body_m: Vec = st + .bodies + .iter() + .map(|b| { + let m = rel(doc.model_body_component(b), None); + doc.model_body_offset(b).map(|offset| crate::mat_mul(&m, offset)).unwrap_or(m) + }) + .collect(); let sketch_m: Vec = st.sketches.iter().map(|s| rel(comp_of_feature(s.feature), None)).collect(); - if body_m.iter().chain(&sketch_m).all(crate::is_identity) { + let world_wires_need_mapping = !crate::is_identity(&inv) + && st.sketches.iter().any(|sketch| sketch.sketch.wires.iter().any(|wire| wire.space == solvecraft_sketch::WireSpace::World)); + if body_m.iter().chain(&sketch_m).all(crate::is_identity) && !world_wires_need_mapping { return Cow::Borrowed(st); } let mut out = st.clone(); @@ -2960,6 +3166,7 @@ pub fn state_in_frame_at<'a>(doc: &Document, st: &'a ModelState, comp: u64, fram let names = crate::naming::face_names(b); let mut nb = ModelBody::new(b.name.clone(), t, b.feature); nb.component = b.component; + nb.transferred = b.transferred; nb.source_name = b.source_name.clone(); nb.source_transform = crate::mat_mul(m, &b.source_transform); nb.imported = b.imported.clone(); @@ -2973,6 +3180,10 @@ pub fn state_in_frame_at<'a>(doc: &Document, st: &'a ModelState, comp: u64, fram { s.plane = np; } + for wire in &mut s.sketch.wires { + let matrix = if wire.space == solvecraft_sketch::WireSpace::Component { m } else { &inv }; + map_wire(wire, matrix); + } } Cow::Owned(out) } @@ -3002,16 +3213,17 @@ pub fn world_state(doc: &Document, st: &ModelState) -> ModelState { for (comp, path, m) in doc.placements() { let primary = path == first_path(comp); let ident = crate::is_identity(&m); - for b in st.bodies.iter().filter(|b| doc.body_component(&b.name, b.feature) == comp) { + for b in st.bodies.iter().filter(|b| doc.model_body_component(b) == comp) { let name = doc.world_body_name(st, &b.name, comp, &path); // A body moved in from another component keeps its place (its offset). - let bm = doc.body_offsets.get(&b.name).map(|o| crate::mat_mul(&m, o)).unwrap_or(m); + let bm = doc.model_body_offset(b).map(|o| crate::mat_mul(&m, o)).unwrap_or(m); let body = if crate::is_identity(&bm) { b.body.clone() } else { kernel::transform_matrix(&b.body, bm).unwrap_or_else(|_| b.body.clone()) }; let mut placed = ModelBody::new(name, body, b.feature); placed.source_name = Some(b.definition_name().to_string()); placed.source_transform = crate::mat_mul(&bm, &b.source_transform); placed.component = comp; + placed.transferred = b.transferred; placed.imported = b.imported.clone(); // Placement preserves topology: face ownership remains that of the // source model, including modifiers on component instances. @@ -3027,12 +3239,22 @@ pub fn world_state(doc: &Document, st: &ModelState) -> ModelState { s2.plane = np; } } + for wire in &mut s2.sketch.wires { + if wire.space == solvecraft_sketch::WireSpace::Component { + map_wire(wire, &m); + } + } out.sketches.push(s2); } - for t in st.threads.iter().filter(|t| comp_of_feature(t.feature) == comp) { + for t in st + .threads + .iter() + .filter(|t| st.body(&t.body).map(|body| doc.model_body_component(body)).unwrap_or_else(|| comp_of_feature(t.feature)) == comp) + { let mut t2 = t.clone(); - t2.axis_point = crate::apply_point(&m, t.axis_point); - t2.axis = crate::apply_vector(&m, t.axis); + let frame = st.body(&t.body).and_then(|body| doc.model_body_offset(body)).map(|offset| crate::mat_mul(&m, offset)).unwrap_or(m); + t2.axis_point = crate::apply_point(&frame, t.axis_point); + t2.axis = crate::apply_vector(&frame, t.axis); out.threads.push(t2); } } diff --git a/crates/doc/src/eval_more.rs b/crates/doc/src/eval_more.rs index 3c8e5f73..e6d6add3 100644 --- a/crates/doc/src/eval_more.rs +++ b/crates/doc/src/eval_more.rs @@ -4,6 +4,7 @@ use super::*; use crate::EmbossFace; use crate::apply_point; +use crate::sheet; use solvecraft_geom::{Loop2, Seg2}; pub(super) fn eval(doc: &Document, vals: &BTreeMap, f: &Feature, st: &mut ModelState) -> Result<()> { @@ -22,11 +23,11 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap, f: &Feature, let i = st.bodies.iter().position(|b| b.name == face.body).ok_or_else(|| DocError::Unknown(format!("emboss body `{}`", face.body)))?; let mb = st.bodies.get(i).cloned().ok_or_else(|| DocError::Invalid("emboss body".into()))?; - let body_frame = face.body_frame(doc, mb.feature)?; + let body_frame = face.body_frame(doc, &mb)?; let inverse = crate::mat_inverse(&body_frame).ok_or_else(|| DocError::Invalid("invalid cylinder body frame".into()))?; let sketch_owner = doc.feature(ss.feature).map(|f| f.component).unwrap_or(0); let author_frame = EmbossFace::component_frame(doc, f.component, &face.author_path)?; - let sketch_frame = face.sketch_frame(doc, f.component, sketch_owner, mb.feature)?; + let sketch_frame = face.sketch_frame(doc, f.component, sketch_owner, &mb)?; let sketch_transform = crate::mat_mul(&inverse, &sketch_frame); let plane = Plane::new( crate::apply_point(&sketch_transform, ss.plane.origin), @@ -289,12 +290,22 @@ fn point_on_polyline(pts: &[Vec2], d: f64) -> Vec2 { } /// Rebuild a sheet body's solid from its model. +fn replace_sheet_body(mb: &mut ModelBody, body: Body) { + let mut next = ModelBody::new(mb.name.clone(), body, mb.feature); + next.component = mb.component; + next.transferred = mb.transferred; + next.source_name = mb.source_name.clone(); + next.source_transform = mb.source_transform; + next.imported = mb.imported.clone(); + *mb = next; +} + fn rebuild_sheet(st: &mut ModelState, idx: usize) -> Result<()> { let sheet = st.sheets.get(idx).cloned().ok_or_else(|| DocError::Invalid("sheet".into()))?; let solid = sheet.solid()?; let i = st.bodies.iter().position(|b| b.name == sheet.body).ok_or_else(|| DocError::Unknown(format!("body `{}`", sheet.body)))?; if let Some(mb) = st.bodies.get_mut(i) { - *mb = ModelBody::new(mb.name.clone(), solid, mb.feature); + replace_sheet_body(mb, solid); } Ok(()) } @@ -513,7 +524,7 @@ pub(super) fn sheet_eval( sh.holes = region.holes.iter().map(|h| Region2 { outer: h.ccw(), holes: Vec::new() }).collect(); let solid = sh.solid()?; if let Some(b) = st.bodies.iter_mut().find(|b| &b.name == body) { - *b = ModelBody::new(b.name.clone(), solid, b.feature); + replace_sheet_body(b, solid); } st.sheets.retain(|s| &s.body != body); st.sheets.push(sh); @@ -528,14 +539,15 @@ fn poly_area(p: &[Vec2]) -> f64 { (0..m).filter_map(|i| Some(p.get(i)?.cross(*p.get((i + 1) % m)?))).sum::() / 2.0 } -/// An extrude cut through sheet metal: the profiles become cut-outs of the flat pattern -/// (parallel to a panel, through its thickness). Returns the sheet bodies handled. +/// Exact prismatic machining, retained in sheet material/depth metadata. pub(super) fn sheet_cut(f: &Feature, st: &mut ModelState, tools: &[Body], targets: &[String]) -> Result> { - let FeatureKind::Extrude { sketch, profiles, .. } = &f.kind else { return Ok(Vec::new()) }; + let FeatureKind::Extrude { sketch, profiles, operation, extent, .. } = &f.kind else { return Ok(Vec::new()) }; + if extent.taper.is_some() { + return Ok(Vec::new()); + } let Some(ss) = st.sketch(*sketch).cloned() else { return Ok(Vec::new()) }; let regions = solvecraft_sketch::merge_regions(&select_profiles(&ss, profiles)?); let n = ss.plane.normal(); - // How far the tools reach along the sketch normal. let mut span = (f64::MAX, f64::MIN); for t in tools { for p in t.tessellate((t.size() * 1e-3).max(1e-3))?.positions { @@ -543,51 +555,123 @@ pub(super) fn sheet_cut(f: &Feature, st: &mut ModelState, tools: &[Body], target span = (span.0.min(d), span.1.max(d)); } } - let mut handled = Vec::new(); - let mut overlap_work = crate::sheet::FlatWork::new(); let targets = if targets.is_empty() { &f.participants } else { targets }; let all = targets.len() == 1 && targets.first().is_some_and(|t| t == "*"); - for i in 0..st.sheets.len() { - let Some(sh) = st.sheets.get(i).cloned() else { continue }; + let mut plans = Vec::new(); + let mut work = sheet::FlatWork::new(); + for original in &st.sheets { let eligible = all || if targets.is_empty() { - st.bodies.iter().any(|b| b.name == sh.body && (b.component == f.component || b.feature == f.id)) + st.bodies.iter().any(|b| b.name == original.body && (b.component == f.component || b.feature == f.id)) } else { - targets.contains(&sh.body) + targets.contains(&original.body) }; if !eligible { continue; } - let candidates: Vec<(Option, Region2)> = if sh.flat { - if sh.frame.normal().cross(n).len() > 1e-6 { Vec::new() } else { sh.flat_regions()?.into_iter().map(|region| (None, region)).collect() } - } else { - sh.parallel_panels(&ss.plane) + let Some(mb) = st.body(&original.body) else { + continue; }; - let mut added = Vec::new(); - for (pi, panel) in candidates { - // The panel's faces along the sketch normal must both be inside the cut. - let w = sh.world(pi); - let z0 = (apply_point(&w, Vec3::ZERO) - ss.plane.origin).dot(n); - let z1 = (apply_point(&w, Vec3::Z * sh.t) - ss.plane.origin).dot(n); - if z0.min(z1) < span.0 - 1e-6 || z0.max(z1) > span.1 + 1e-6 { - continue; - } - for r in ®ions { - let flat = sh.region_to_flat(pi, &ss.plane, r)?; - let overlaps = crate::sheet::flat_regions_overlap(&panel, &flat, &mut overlap_work)?; - if overlaps && !added.iter().any(|x: &Region2| x == &flat) { - added.push(flat); + let mut sh = original.clone(); + if *operation == Operation::Intersect && (!sh.flat && !sh.flanges.is_empty()) { + super::sheet_boolean_unchanged(&mb.body, tools, *operation)?; + plans.push((sh.body.clone(), Some((sh, mb.body.clone())))); + continue; + } + if !sh.flat && !sh.flanges.is_empty() && !super::sheet_boolean_changes(&mb.body, tools, *operation)? { + plans.push((sh.body.clone(), Some((sh, mb.body.clone())))); + continue; + } + if let Some(bends) = sh.unmodeled_solid(&ss.plane)? { + for tool in tools { + if kernel::boolean(&bends, tool, kernel::BoolOp::Intersect)?.is_some() { + return Err(DocError::Invalid( + "this machining intersects a folded bend or a nonparallel panel; unfold the sheet before machining that material".into(), + )); } } } - if added.is_empty() { - continue; + let candidates = if sh.flat { + if sh.frame.normal().cross(n).len() > 1e-6 { Vec::new() } else { sh.flat_regions()?.into_iter().map(|r| (None, r)).collect() } + } else { + sh.material_panels(Some(&ss.plane), &mut work)? + }; + let mut modeled = false; + for (pi, panel) in candidates { + let w = if sh.flat { sh.world(None) } else { sh.world(pi) }; + let zorigin = (apply_point(&w, Vec3::ZERO) - ss.plane.origin).dot(n); + let sign = crate::apply_vector(&w, Vec3::Z).dot(n); + if sign.abs() < 1e-6 { + continue; + } + let (a, b) = ((span.0 - zorigin) / sign, (span.1 - zorigin) / sign); + let (z0, z1) = (a.min(b).max(0.0), a.max(b).min(sh.t)); + if *operation == Operation::Intersect { + if z1 <= z0 { + sh.holes.push(panel); + modeled = true; + continue; + } + if z0 > 1e-7 || z1 < sh.t - 1e-7 { + super::sheet_boolean_unchanged(&mb.body, tools, *operation)?; + continue; + } + let mapped = regions.iter().map(|r| sh.region_to_flat(pi, &ss.plane, r)).collect::>>()?; + let tool = sheet::material_union(mapped, &mut work)?; + let retained = sheet::material_boolean(std::slice::from_ref(&panel), &tool, kernel::BoolOp::Intersect, &mut work)?; + let removed = sheet::material_boolean(&[panel], &retained, kernel::BoolOp::Cut, &mut work)?; + sh.holes.extend(removed); + modeled = true; + } else if z1 > z0 { + for r in ®ions { + let flat = sh.region_to_flat(pi, &ss.plane, r)?; + let clipped = + sheet::material_boolean(std::slice::from_ref(&panel), std::slice::from_ref(&flat), kernel::BoolOp::Intersect, &mut work)?; + if !clipped.is_empty() && z0 <= 1e-7 && z1 >= sh.t - 1e-7 { + // Keep a single original tool boundary across structural seams. + // Re-clipping notch polygons creates coincident seam endpoints. + if !sh.holes.contains(&flat) { + sh.holes.push(flat); + } + modeled = true; + } else { + for region in clipped { + sh.pockets.push(sheet::SheetPocket { region, z0, z1 }); + modeled = true; + } + } + } + } } - if let Some(s) = st.sheets.get_mut(i) { - s.holes.extend(added); + if !modeled { + super::sheet_boolean_unchanged(&mb.body, tools, *operation)?; + plans.push((sh.body.clone(), Some((sh, mb.body.clone())))); + } else if sh.flat_material()?.is_empty() { + // Empty exact material removes geometry and metadata together. Budget or + // topology errors propagate; they are never mistaken for an empty blank. + plans.push((sh.body.clone(), None)); + } else { + let body = sh.solid()?; + plans.push((sh.body.clone(), Some((sh, body)))); + } + } + let handled = plans.iter().map(|(name, _)| name.clone()).collect(); + for (name, result) in plans { + match result { + Some((sh, body)) => { + if let Some(mb) = st.bodies.iter_mut().find(|b| b.name == name) { + replace_sheet_body(mb, body); + } + if let Some(target) = st.sheets.iter_mut().find(|s| s.body == name) { + *target = sh; + } + } + None => { + st.bodies.retain(|b| b.name != name); + st.sheets.retain(|s| s.body != name); + st.threads.retain(|t| t.body != name); + } } - rebuild_sheet(st, i)?; - handled.push(sh.body.clone()); } Ok(handled) } diff --git a/crates/doc/src/format.rs b/crates/doc/src/format.rs index 9017fb7e..6e9bf80b 100644 --- a/crates/doc/src/format.rs +++ b/crates/doc/src/format.rs @@ -28,13 +28,16 @@ //! - 9: configured component/semantic-face styles, associative free-form offsets and //! explicit cylindrical emboss targets. Existing version 8 geometry and rules are //! preserved; new fields use defaults rather than guessing a target or an association. +//! - 10: timeline body transfers, explicit 3D wire coordinate spaces and certified face +//! bindings. Legacy ownership maps remain unchanged. Retained wire associations determine +//! their space; saved coordinates are never rewritten during migration. use serde_json::Value; use crate::{DocError, Document, Result}; /// The format this build writes. -pub const FORMAT: u32 = 9; +pub const FORMAT: u32 = 10; pub fn format_string(n: u32) -> String { format!("solvecraft/{n}") @@ -47,7 +50,7 @@ pub fn version_of(s: &str) -> Option { /// JSON-level upgrades: entry `i` turns format `i + 1` into `i + 2`. type JsonStep = fn(&mut Value) -> Result<()>; -const JSON_STEPS: [JsonStep; 8] = [|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6, |_| Ok(()), |_| Ok(()), |_| Ok(())]; +const JSON_STEPS: [JsonStep; 9] = [|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6, |_| Ok(()), |_| Ok(()), |_| Ok(()), |_| Ok(())]; /// Old XZ references retain their exact frame, including nested offset and angled planes. /// New origin references use +Y; freezing the old frame avoids changing sketch coordinates, @@ -142,10 +145,29 @@ pub fn read(s: &str) -> Result { crate::config::validate_styles_json(&v)?; validate_emboss_targets(&v)?; validate_freeform_offsets(&v)?; + let legacy_wires = validate_wires(&v, version)?; + validate_body_transfers(&v)?; for step in JSON_STEPS.iter().skip(version as usize - 1).take((FORMAT - version) as usize) { step(&mut v)?; } let mut d: Document = serde_json::from_value(v).map_err(|e| DocError::Invalid(format!("design file: {e}")))?; + let mut legacy_by_feature = std::collections::BTreeMap::>::new(); + for (feature, wire) in legacy_wires { + legacy_by_feature.entry(feature).or_default().push(wire); + } + for (feature_index, wire_indices) in legacy_by_feature { + if let Some(crate::Feature { kind: crate::FeatureKind::Sketch { sketch, .. }, .. }) = d.features.get_mut(feature_index) { + let mut spaces = std::collections::BTreeMap::new(); + for link in &sketch.links { + spaces.entry(link.id.as_str()).or_insert_with(|| solvecraft_sketch::WireSpace::from_legacy_link(link)); + } + for wire_index in wire_indices { + if let Some(wire) = sketch.wires.get_mut(wire_index) { + wire.space = wire.link.as_deref().and_then(|id| spaces.get(id)).copied().unwrap_or_default(); + } + } + } + } if d.features.len() > crate::document::MAX_FEATURES || d.params.len() > crate::document::MAX_PARAMS || d.components.len() > crate::document::MAX_FEATURES @@ -157,9 +179,21 @@ pub fn read(s: &str) -> Result { return Err(DocError::Invalid(format!("damaged design: occurrence `{}` has an invalid placement", o.name))); } } + let component_ids: std::collections::BTreeSet<_> = d.components.iter().map(|component| component.id).chain(std::iter::once(0)).collect(); for feature in &d.features { if let crate::FeatureKind::Sketch { sketch, .. } = &feature.kind { sketch.validate_freeform_offsets().map_err(|e| DocError::Invalid(format!("damaged design: {e}")))?; + sketch.validate_wires().map_err(|e| DocError::Invalid(format!("damaged design: {e}")))?; + } + if let crate::FeatureKind::BodyTransfer { from, to, .. } = &feature.kind + && [from, to].into_iter().any(|id| !component_ids.contains(id)) + { + return Err(DocError::Invalid("damaged design: body transfer references an unknown component".into())); + } + if let crate::FeatureKind::BodyTransfer { to, .. } = &feature.kind + && feature.component != *to + { + return Err(DocError::Invalid("damaged design: body transfer owner must match its destination".into())); } } // Fill in what older designs (and hand-edited files) may lack; a no-op otherwise. @@ -168,6 +202,112 @@ pub fn read(s: &str) -> Result { Ok(d) } +/// Bound arrays before typed allocation; only old files with absent markers need migration. +fn validate_wires(value: &Value, version: u32) -> Result> { + let invalid = || DocError::Invalid("damaged design: 3D curve arrays, ids or coordinate spaces are invalid".into()); + let mut legacy = Vec::new(); + let Some(features) = value.get("features").and_then(Value::as_array) else { return Ok(legacy) }; + if features.len() > crate::document::MAX_FEATURES { + return Err(invalid()); + } + for (feature_index, feature) in features.iter().enumerate() { + if feature.get("type").and_then(Value::as_str) != Some("sketch") { + continue; + } + let Some(wires) = feature.get("sketch").and_then(|sketch| sketch.get("wires")) else { continue }; + let wires = wires.as_array().ok_or_else(invalid)?; + if wires.len() > 20_000 { + return Err(invalid()); + } + let mut ids = std::collections::BTreeSet::new(); + let mut work = 0usize; + for (wire_index, wire) in wires.iter().enumerate() { + let id = wire.get("id").and_then(Value::as_str).ok_or_else(invalid)?; + if id.is_empty() || id.len() > 512 || !ids.insert(id) { + return Err(invalid()); + } + if let Some(link) = wire.get("link").filter(|link| !link.is_null()) + && link.as_str().is_none_or(|link| link.is_empty() || link.len() > 512) + { + return Err(invalid()); + } + match wire.get("space") { + None if version < 10 => legacy.push((feature_index, wire_index)), + None => {} + Some(Value::String(space)) if matches!(space.as_str(), "world" | "component") => {} + _ => return Err(invalid()), + } + let pts = wire.get("pts").and_then(Value::as_array).ok_or_else(invalid)?; + let fit = match wire.get("fit") { + None => &[][..], + Some(fit) => fit.as_array().ok_or_else(invalid)?.as_slice(), + }; + work = work.saturating_add(pts.len()).saturating_add(fit.len()); + if work > 1_000_000 || pts.is_empty() || pts.len() > 100_000 || fit.len() > 500 || (pts.len() == 1 && fit.len() != 1) { + return Err(invalid()); + } + for point in pts.iter().chain(fit) { + let point = point.as_array().ok_or_else(invalid)?; + if point.len() != 3 || point.iter().any(|coordinate| coordinate.as_f64().is_none_or(|n| !n.is_finite() || n.abs() >= MAX_REAL)) { + return Err(invalid()); + } + } + } + } + Ok(legacy) +} + +fn validate_body_transfers(value: &Value) -> Result<()> { + let invalid = || DocError::Invalid("damaged design: body transfer needs bounded unique bodies, components and a rigid placement".into()); + let Some(features) = value.get("features").and_then(Value::as_array) else { return Ok(()) }; + for feature in features { + if feature.get("type").and_then(Value::as_str) != Some("body_transfer") { + continue; + } + let bodies = feature.get("bodies").and_then(Value::as_array).ok_or_else(invalid)?; + if bodies.is_empty() || bodies.len() > 1000 { + return Err(invalid()); + } + let mut names = std::collections::BTreeSet::new(); + for body in bodies { + let body = body.as_str().ok_or_else(invalid)?; + if body.trim().is_empty() || body.len() > 512 || body.chars().any(char::is_control) || !names.insert(body) { + return Err(invalid()); + } + } + for key in ["from", "to"] { + if feature.get(key).and_then(Value::as_u64).is_none() { + return Err(invalid()); + } + } + let owner = match feature.get("component") { + None => 0, + Some(value) => value.as_u64().ok_or_else(invalid)?, + }; + if feature.get("to").and_then(Value::as_u64) != Some(owner) { + return Err(DocError::Invalid("damaged design: body transfer owner must match its destination".into())); + } + let matrix = feature.get("transform").and_then(Value::as_array).ok_or_else(invalid)?; + if matrix.len() != 4 { + return Err(invalid()); + } + let mut placement = [[0.0; 4]; 4]; + for (out, column) in placement.iter_mut().zip(matrix) { + let column = column.as_array().ok_or_else(invalid)?; + if column.len() != 4 { + return Err(invalid()); + } + for (out, number) in out.iter_mut().zip(column) { + *out = number.as_f64().ok_or_else(invalid)?; + } + } + if !crate::body_transfer::valid_transfer_matrix(&placement) { + return Err(invalid()); + } + } + Ok(()) +} + /// Validate new explicit targets before typed decoding. Older planar features have none. fn validate_emboss_targets(value: &Value) -> Result<()> { let Some(features) = value.get("features").and_then(Value::as_array) else { return Ok(()) }; @@ -335,6 +475,99 @@ fn normalise(d: &mut Document) -> Result<()> { mod tests { use super::*; + #[test] + fn version_nine_wire_spaces_preserve_saved_points_and_explicit_markers() { + use solvecraft_geom::Vec3; + use solvecraft_sketch::{LinkKind, LinkSource, Sketch, WireSpace}; + let mut sketch = Sketch::new(); + sketch.add_drawn_wire(&[Vec3::new(12.0, 3.0, 4.0)]).unwrap(); + let link = sketch + .add_link_with_wires( + LinkKind::Include, + LinkSource::Edge { body: "Missing".into(), at: Vec3::ZERO }, + &[], + &[vec![Vec3::new(2.0, 7.0, 9.0), Vec3::new(2.0, 8.0, 9.0)]], + ) + .unwrap(); + sketch.set_link_lost(&link, true); + let mut doc = Document::new("legacy wire spaces"); + doc.add_feature(crate::FeatureKind::Sketch { plane: crate::PlaneRef::Origin { name: "XY".into() }, sketch }, None).unwrap(); + let mut json: Value = serde_json::from_str(&doc.to_json()).unwrap(); + json["format"] = Value::from("solvecraft/9"); + for wire in json["features"][0]["sketch"]["wires"].as_array_mut().unwrap() { + wire.as_object_mut().unwrap().remove("space"); + } + let old = json["features"][0]["sketch"]["wires"].clone(); + let migrated = read(&json.to_string()).unwrap(); + let crate::FeatureKind::Sketch { sketch, .. } = &migrated.features[0].kind else { panic!("sketch") }; + assert_eq!(sketch.wires[0].space, WireSpace::World); + assert_eq!(sketch.wires[1].space, WireSpace::Component); + let saved: Value = serde_json::from_str(&migrated.to_json()).unwrap(); + for (a, b) in old.as_array().unwrap().iter().zip(saved["features"][0]["sketch"]["wires"].as_array().unwrap()) { + assert_eq!(a["pts"], b["pts"]); + assert_eq!(a["fit"], b["fit"]); + assert_eq!(a["link"], b["link"]); + } + assert_eq!(read(&migrated.to_json()).unwrap(), migrated); + json["features"][0]["sketch"]["wires"][1]["space"] = Value::from("world"); + let explicit = read(&json.to_string()).unwrap(); + let crate::FeatureKind::Sketch { sketch, .. } = &explicit.features[0].kind else { panic!("sketch") }; + assert_eq!(sketch.wires[1].space, WireSpace::World); + } + + #[test] + fn hostile_wire_and_transfer_shapes_are_refused_before_typed_decode() { + let base: Value = serde_json::from_str(&Document::new("bounds").to_json()).unwrap(); + let wire = serde_json::json!({"id":"w1", "pts":[[1,2,3],[4,5,6]],"space":"world"}); + for damaged in [ + serde_json::json!({"space":"guess"}), + serde_json::json!({"pts":[[1,2]]}), + serde_json::json!({"pts":[[1e9,2,3],[4,5,6]]}), + serde_json::json!({"fit": vec![[1,2,3];501]}), + ] { + let mut changed = wire.clone(); + for (key, value) in damaged.as_object().unwrap() { + changed[key] = value.clone(); + } + let mut file = base.clone(); + file["features"] = serde_json::json!([{"type":"sketch","sketch":{"wires":[changed]}}]); + assert!(validate_wires(&file, FORMAT).is_err()); + } + let transfer = serde_json::json!({"type":"body_transfer","component":1,"bodies":["Body1"],"from":0,"to":1,"transform":crate::IDENTITY}); + for damaged in [ + serde_json::json!({"bodies":["Body1","Body1"]}), + serde_json::json!({"from":-1}), + serde_json::json!({"component":0}), + serde_json::json!({"transform":[[2,0,0,0],[0,1,0,0],[0,0,1,0],[0,0,0,1]]}), + serde_json::json!({"bodies":["\n"]}), + ] { + let mut changed = transfer.clone(); + for (key, value) in damaged.as_object().unwrap() { + changed[key] = value.clone(); + } + let mut file = base.clone(); + file["features"] = serde_json::json!([changed]); + assert!(validate_body_transfers(&file).is_err()); + } + } + + #[test] + fn recorded_transfer_roundtrips_and_unknown_component_is_refused() { + let mut doc = Document::new("recorded ownership boundary"); + doc.add_feature(crate::FeatureKind::BodyTransfer { bodies: vec!["Body1".into()], from: 0, to: 0, transform: crate::IDENTITY }, None).unwrap(); + assert_eq!(read(&doc.to_json()).unwrap(), doc); + let mut json: Value = serde_json::from_str(&doc.to_json()).unwrap(); + assert_eq!(json["features"][0]["type"], "body_transfer"); + json["features"][0]["to"] = Value::from(12345); + json["features"][0]["component"] = Value::from(12345); + assert!(read(&json.to_string()).unwrap_err().to_string().contains("unknown component")); + json["features"][0]["to"] = Value::from(0); + assert!(read(&json.to_string()).unwrap_err().to_string().contains("owner must match")); + json["features"][0]["component"] = Value::from(0); + json["features"][0]["transform"][0][0] = Value::from(2); + assert!(read(&json.to_string()).unwrap_err().to_string().contains("rigid placement")); + } + #[test] fn version_eight_to_object_offset_keeps_existing_model_parameter_names() { use crate::{Direction, Extent, FeatureKind, Operation, PlaneRef, ProfileSel}; diff --git a/crates/doc/src/joints.rs b/crates/doc/src/joints.rs index c3bfa8a6..ead63f33 100644 --- a/crates/doc/src/joints.rs +++ b/crates/doc/src/joints.rs @@ -223,7 +223,7 @@ pub fn motion(kind: JointKind, q: &[f64]) -> Mat { /// Bodies of a component in the model (its own frame). fn component_bodies<'a>(doc: &Document, st: &'a ModelState, component: u64) -> Vec<&'a crate::ModelBody> { - st.bodies.iter().filter(|b| doc.body_component(&b.name, b.feature) == component).collect() + st.bodies.iter().filter(|b| doc.model_body_component(b) == component).collect() } /// The component an occurrence places (0 for the root). diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 248942da..4e8d6557 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -7,6 +7,9 @@ pub mod appearance; mod assembly; +mod body_transfer; +mod body_transfer_sheet; +pub use body_transfer::{valid_transfer_matrix, validate_body_transfer}; pub mod canvas; mod clipboard; pub use clipboard::{clone_expression, remap_topology}; @@ -29,8 +32,8 @@ pub mod sheet; pub use assembly::{IDENTITY, Mat, Occurrence, apply_point, apply_vector, is_identity, map_point_expr, mat_inverse, mat_mul, rigid}; pub use document::*; pub use eval::{ - EdgeRef, FeatureResult, Model, ModelBody, ModelState, SolvedSketch, ThreadInfo, edge_names_for, edge_refs, object_height, parse_metric_thread, - pattern_matrices, state_in_frame, state_in_frame_at, through_all_distance, world_state, + EdgeRef, FeatureResult, Model, ModelBody, ModelState, RevolveExtent, SolvedSketch, ThreadInfo, edge_names_for, edge_refs, object_height, + parse_metric_thread, pattern_matrices, revolve_extent, select_profiles, state_in_frame, state_in_frame_at, through_all_distance, world_state, }; pub use params::{ParamRow, ParamUser, cycles}; pub use solvecraft_kernel as kernel; diff --git a/crates/doc/src/params.rs b/crates/doc/src/params.rs index fde2cebe..c1b54110 100644 --- a/crates/doc/src/params.rs +++ b/crates/doc/src/params.rs @@ -274,7 +274,8 @@ impl FeatureKind { | FeatureKind::Loft { .. } | FeatureKind::Sweep { .. } | FeatureKind::Import { .. } - | FeatureKind::MeshImport { .. } => {} + | FeatureKind::MeshImport { .. } + | FeatureKind::BodyTransfer { .. } => {} } v } diff --git a/crates/doc/src/sheet.rs b/crates/doc/src/sheet.rs index 1d109626..eb06fe07 100644 --- a/crates/doc/src/sheet.rs +++ b/crates/doc/src/sheet.rs @@ -83,24 +83,32 @@ fn planar_region(region: &Region2) -> Result { } fn region_complexity(regions: &[Region2]) -> Result<(usize, usize)> { - if regions.len() > MAX_FLAT_PIECES { - return Err(flat_budget_error()); - } + regions_complexity(regions.iter()) +} + +fn regions_complexity<'a>(regions: impl Iterator) -> Result<(usize, usize)> { let (mut edges, mut points) = (0usize, 0usize); - for lp in regions.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)) { - edges = edges.saturating_add(lp.segs.len()); - if edges > MAX_FLAT_POINTS { + let mut pieces = 0usize; + for region in regions { + pieces += 1; + if pieces > MAX_FLAT_PIECES || region.holes.len() > MAX_FLAT_PIECES { return Err(flat_budget_error()); } - points = points.saturating_add(flat_polyline(lp)?.len()); - if points > MAX_FLAT_POINTS { - return Err(flat_budget_error()); + for lp in std::iter::once(®ion.outer).chain(®ion.holes) { + edges = edges.saturating_add(lp.segs.len()); + if edges > MAX_FLAT_POINTS { + return Err(flat_budget_error()); + } + points = points.saturating_add(flat_polyline(lp)?.len()); + if points > MAX_FLAT_POINTS { + return Err(flat_budget_error()); + } } } Ok((edges, points)) } -fn material_boolean(a: &[Region2], b: &[Region2], op: kernel::BoolOp, work: &mut FlatWork) -> Result> { +pub(crate) fn material_boolean(a: &[Region2], b: &[Region2], op: kernel::BoolOp, work: &mut FlatWork) -> Result> { let (ae, ap) = region_complexity(a)?; let (be, bp) = region_complexity(b)?; let edges = ae.saturating_add(be); @@ -112,7 +120,7 @@ fn material_boolean(a: &[Region2], b: &[Region2], op: kernel::BoolOp, work: &mut // Merge in balanced rounds so hundreds of disjoint holes or adjoining panels do not // repeatedly arrange a growing accumulated boundary against just one small region. -fn material_union(regions: Vec, work: &mut FlatWork) -> Result> { +pub(crate) fn material_union(regions: Vec, work: &mut FlatWork) -> Result> { region_complexity(®ions)?; let mut groups: Vec> = regions.into_iter().map(|r| vec![r]).collect(); while groups.len() > 1 { @@ -290,6 +298,10 @@ pub struct SheetBody { pub flanges: Vec, /// Cut-outs (flat coordinates) through the sheet. pub holes: Vec, + /// Planar machining in flat coordinates, with depths measured from the flat bottom. + /// Derived from feature history; these records are not a document schema change. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub pockets: Vec, /// Unfolded. pub flat: bool, /// Gap between flanges that meet at a corner. @@ -299,6 +311,13 @@ pub struct SheetBody { pub base_curves: Vec<(String, Vec2, Vec2)>, } +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct SheetPocket { + pub region: Region2, + pub z0: f64, + pub z1: f64, +} + /// A piece of the sheet: a panel (None = the base) or a flange's bend zone. #[derive(Clone, Copy, Debug, PartialEq)] enum Piece { @@ -564,6 +583,7 @@ impl SheetBody { } cuts.push(region); } + cuts.extend(self.through_pocket_regions(work)?); let cuts = material_union(cuts, work)?; let mut out = Vec::new(); let mut points = 0usize; @@ -734,20 +754,148 @@ impl SheetBody { } let mut work = FlatWork::new(); let cells = self.cells_with_work(&mut work)?; - Ok(kernel::sew(&self.faces(&cells, 0.0, self.t, &mut work)?)?) + let body = kernel::sew(&self.faces(&cells, 0.0, self.t, &mut work)?)?; + self.machine_pockets(body, &mut work) } /// The flat pattern as one extrusion (outline with its cut-outs). pub fn flat_solid(&self) -> Result { let regions = self.flat_regions()?; let pl = plane_of(&self.frame_mat(), Vec3::ZERO, Vec3::X, Vec3::Y)?; - Ok(kernel::extrude_planar_regions(&pl, ®ions, 0.0, self.t)?) + let body = kernel::extrude_planar_regions(&pl, ®ions, 0.0, self.t)?; + self.machine_pockets(body, &mut FlatWork::new()) + } + + /// Replay exact planar machining after forming the full-thickness material. + fn machine_pockets(&self, mut body: Body, work: &mut FlatWork) -> Result { + if self.pockets.len() > 1000 { + return Err(flat_budget_error()); + } + regions_complexity(self.pockets.iter().map(|p| &p.region))?; + let panels = if self.flat { + vec![(None, self.flat_regions()?)] + } else { + self.material_panels(None, work)?.into_iter().map(|(pi, r)| (pi, vec![r])).collect() + }; + for pocket in &self.pockets { + if !pocket.z0.is_finite() || !pocket.z1.is_finite() || pocket.z0 < 0.0 || pocket.z1 > self.t || pocket.z0 >= pocket.z1 { + return Err(DocError::Invalid("invalid sheet pocket depth".into())); + } + if !self.flat { + for flange in &self.flanges { + if flat_regions_overlap(&pocket.region, &self.zone(flange), work)? { + return Err(DocError::Invalid( + "a sheet pocket crosses a bend zone; planar pocket machining cannot yet preserve curved bend material".into(), + )); + } + } + } + for (pi, panel) in &panels { + let clipped = material_boolean(panel, std::slice::from_ref(&pocket.region), kernel::BoolOp::Intersect, work)?; + if clipped.is_empty() { + continue; + } + let w = if self.flat { self.frame_mat() } else { self.world(*pi) }; + let plane = plane_of(&w, Vec3::ZERO, Vec3::X, Vec3::Y)?; + // BRep replay is costlier than planar clipping; share the command budget. + work.charge(100_000)?; + let tool = kernel::extrude_planar_regions(&plane, &clipped, pocket.z0, pocket.z1)?; + body = kernel::boolean(&body, &tool, kernel::BoolOp::Cut)? + .ok_or_else(|| DocError::Invalid("sheet pocket removes all sheet material".into()))?; + } + } + Ok(body) + } + + /// Actual curved material, used to certify unsupported folded machining before mutation. + pub(crate) fn unmodeled_solid(&self, plane: &Plane) -> Result> { + if self.flat || self.flanges.is_empty() { + return Ok(None); + } + let mut work = FlatWork::new(); + let cells: Vec<_> = self + .cells_with_work(&mut work)? + .into_iter() + .filter(|c| match c.piece { + Piece::Zone(_) => true, + Piece::Panel(pi) => apply_vector(&self.world(pi), Vec3::Z).cross(plane.normal()).len() > 1e-6, + }) + .collect(); + if cells.is_empty() { + return Ok(None); + } + Ok(Some(kernel::sew(&self.faces(&cells, 0.0, self.t, &mut work)?)?)) + } + + fn through_pocket_regions(&self, work: &mut FlatWork) -> Result> { + if self.pockets.is_empty() { + return Ok(Vec::new()); + } + if self.pockets.len() > 1000 { + return Err(flat_budget_error()); + } + regions_complexity(self.pockets.iter().map(|p| &p.region))?; + let mut levels = vec![0.0, self.t]; + for p in &self.pockets { + if !p.z0.is_finite() || !p.z1.is_finite() || p.z0 < 0.0 || p.z1 > self.t || p.z0 >= p.z1 { + return Err(DocError::Invalid("invalid sheet pocket depth".into())); + } + levels.extend([p.z0, p.z1]); + } + levels.sort_by(f64::total_cmp); + levels.dedup(); + work.charge(levels.len().saturating_mul(self.pockets.len()))?; + // Most pockets leave an untouched thickness layer. Certify that cheap case + // before arranging hundreds of unrelated profiles at every depth level. + if levels.windows(2).any(|zs| !self.pockets.iter().any(|p| p.z0 <= zs[0] && p.z1 >= zs[1])) { + return Ok(Vec::new()); + } + let mut through: Option> = None; + for zs in levels.windows(2) { + let active: Vec<_> = self.pockets.iter().filter(|p| p.z0 <= zs[0] && p.z1 >= zs[1]).map(|p| p.region.clone()).collect(); + if active.is_empty() { + return Ok(Vec::new()); + } + let removed = material_union(active, work)?; + through = Some(match through { + None => removed, + Some(previous) => material_boolean(&previous, &removed, kernel::BoolOp::Intersect, work)?, + }); + if through.as_ref().is_some_and(Vec::is_empty) { + return Ok(Vec::new()); + } + } + Ok(through.unwrap_or_default()) + } + + /// Pocket operations clipped to the surviving flat material, with exact depth intervals. + pub fn machining(&self) -> Result> { + let material = self.flat_regions()?; + let mut work = FlatWork::new(); + let mut out = Vec::new(); + for pocket in &self.pockets { + for region in material_boolean(&material, std::slice::from_ref(&pocket.region), kernel::BoolOp::Intersect, &mut work)? { + if out.len() >= 1000 { + return Err(flat_budget_error()); + } + out.push(SheetPocket { region, z0: pocket.z0, z1: pocket.z1 }); + } + } + Ok(out) } /// Actual flat material boundaries after cut-outs. The same panel and cut regions build /// the folded solid; planar unions remove panel/bend seams and turn edge-crossing cuts /// into notches. Lines and circular arcs remain exact through clipping and merging. pub fn flat_regions(&self) -> Result> { + let regions = self.flat_material()?; + if regions.is_empty() { + return Err(DocError::Invalid("empty flat pattern".into())); + } + Ok(regions) + } + + pub(crate) fn flat_material(&self) -> Result> { let mut work = FlatWork::new(); // Merge structural pieces before cutting: clipping each panel first introduces // coincident notch endpoints at seams, while a single material cut has no seams. @@ -783,11 +931,9 @@ impl SheetBody { } cuts.push(region); } + cuts.extend(self.through_pocket_regions(&mut work)?); let cuts = material_union(cuts, &mut work)?; let regions = if cuts.is_empty() { regions } else { material_boolean(®ions, &cuts, kernel::BoolOp::Cut, &mut work)? }; - if regions.is_empty() { - return Err(DocError::Invalid("empty flat pattern".into())); - } Ok(regions) } /// Outline loops of the flat pattern (flat coordinates): the pieces' edges, shared ones left out. @@ -1332,6 +1478,22 @@ impl SheetBody { }) .collect() } + + pub(crate) fn material_panels(&self, plane: Option<&Plane>, work: &mut FlatWork) -> Result, Region2)>> { + Ok(self + .cells_with_work(work)? + .into_iter() + .filter_map(|c| { + let Piece::Panel(pi) = c.piece else { + return None; + }; + if plane.is_some_and(|p| apply_vector(&self.world(pi), Vec3::Z).cross(p.normal()).len() > 1e-6) { + return None; + } + Some((pi, Region2 { outer: Loop2 { segs: c.outer }, holes: c.holes.into_iter().map(|segs| Loop2 { segs }).collect() })) + }) + .collect()) + } } fn point_in(poly: &[Vec2], p: Vec2) -> bool { @@ -1465,6 +1627,7 @@ pub fn base_flange(body: &str, rule: &str, rv: &RuleValues, plane: Plane, region base: Region2 { outer: region.outer, holes: Vec::new() }, flanges: Vec::new(), holes, + pockets: Vec::new(), flat: false, gap: rv.gap, base_curves: Vec::new(), @@ -1516,6 +1679,7 @@ pub fn contour_flange(body: &str, rule: &str, rv: &RuleValues, plane: &Plane, pt base: Region2 { outer: Loop2 { segs: Vec::new() }, holes: Vec::new() }, flanges: Vec::new(), holes: Vec::new(), + pockets: Vec::new(), flat: false, gap: rv.gap, base_curves: Vec::new(), @@ -1780,4 +1944,23 @@ mod tests { assert!(s.faces(&cells, 0.0, 2.5, &mut FlatWork::new()).unwrap_err().to_string().contains("budget")); assert!(s.faces(&cells[..1], 0.0, 2.5, &mut FlatWork(0)).unwrap_err().to_string().contains("budget")); } + + #[test] + fn pocket_geometry_and_depth_budgets_are_checked_before_replay() { + let pocket = SheetPocket { + region: rect(Vec2::new(20.0, 20.0), Vec2::new(30.0, 20.0), Vec2::new(30.0, 30.0), Vec2::new(20.0, 30.0)), + z0: 0.0, + z1: 1.0, + }; + let mut s = plate(100.0, 60.0); + s.pockets = vec![pocket.clone(); 1001]; + assert!(s.flat_solid().unwrap_err().to_string().contains("budget")); + s.pockets = vec![pocket.clone()]; + s.pockets[0].region.outer.segs = vec![Seg2::Line { a: Vec2::ZERO, b: Vec2::X }; MAX_FLAT_POINTS + 1]; + assert!(s.flat_solid().unwrap_err().to_string().contains("budget")); + for (z0, z1) in [(f64::NAN, 1.0), (-1.0, 1.0), (0.0, 3.0), (1.0, 1.0)] { + s.pockets = vec![SheetPocket { z0, z1, ..pocket.clone() }]; + assert!(s.flat_solid().unwrap_err().to_string().contains("depth")); + } + } } diff --git a/crates/engine/src/cmd/browser.rs b/crates/engine/src/cmd/browser.rs index 13da3a96..36a4f6ec 100644 --- a/crates/engine/src/cmd/browser.rs +++ b/crates/engine/src/cmd/browser.rs @@ -235,7 +235,7 @@ fn visibility(s: &mut Session, p: &Value) -> Result { } } let mut root_bodies: Vec = - s.model.state().bodies.iter().filter(|b| s.doc.body_component(&b.name, b.feature) == 0).map(|b| b.name.clone()).collect(); + s.model.state().bodies.iter().filter(|b| s.doc.model_body_component(b) == 0).map(|b| b.name.clone()).collect(); root_bodies.extend(ancestor_bodies); s.visibility.hidden_occurrences = hidden; s.visibility.hidden_bodies = root_bodies; diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs index cbeaabd7..79616264 100644 --- a/crates/engine/src/cmd/component.rs +++ b/crates/engine/src/cmd/component.rs @@ -13,6 +13,8 @@ use crate::{Result, Session}; #[path = "component_properties.rs"] mod properties; +#[path = "component_transfer.rs"] +mod transfer; #[cfg(test)] #[path = "occurrence_tests.rs"] @@ -133,25 +135,7 @@ fn new_component(s: &mut Session, p: &Value) -> Result { } fn from_bodies(s: &mut Session, p: &Value) -> Result { - let cmd = "component.from_bodies"; - let bodies = string_list(p, "bodies"); - if bodies.is_empty() || bodies.len() > 1000 { - return Err(bad(cmd, "`bodies` must list 1…1000 body names")); - } - let st = s.model.state(); - for b in &bodies { - if st.body(b).is_none() { - return Err(bad(cmd, format!("no body `{b}`"))); - } - } - let parent = s.active_component; - let mut made = Vec::new(); - for b in bodies { - let id = s.doc_mut().add_component(Some(&b), parent)?; - s.doc_mut().body_components.insert(b.clone(), id); - made.push(json!({"component": id, "body": b})); - } - Ok(json!({ "components": made })) + transfer::from_bodies(s, p) } fn activate(s: &mut Session, p: &Value) -> Result { @@ -176,7 +160,7 @@ fn list(s: &mut Session, _p: &Value) -> Result { let st = s.model.state(); let doc = &s.doc; let node = |id: u64, name: &str, parent: Option| { - let bodies: Vec<&str> = st.bodies.iter().filter(|b| doc.body_component(&b.name, b.feature) == id).map(|b| b.name.as_str()).collect(); + let bodies: Vec<&str> = st.bodies.iter().filter(|b| doc.model_body_component(b) == id).map(|b| b.name.as_str()).collect(); let features: Vec<&str> = doc .features .iter() @@ -223,6 +207,15 @@ fn delete(s: &mut Session, p: &Value) -> Result { return Err(bad(cmd, "the design itself can't be deleted")); } let gone: Vec = s.doc.components.iter().filter(|c| s.doc.component_within(c.id, id)).map(|c| c.id).collect(); + if s.doc.features.iter().any(|feature| { + !gone.contains(&feature.component) + && matches!(&feature.kind, FeatureKind::BodyTransfer { from, to, .. } if gone.contains(from) || gone.contains(to)) + }) { + return Err(bad( + cmd, + "component supplies a recorded body transfer outside this subtree; remove that transfer history before deleting its source", + )); + } let feats: Vec = s.doc.features.iter().filter(|f| gone.contains(&f.component)).map(|f| f.id).collect(); let d = s.doc_mut(); for f in &feats { @@ -241,36 +234,11 @@ fn delete(s: &mut Session, p: &Value) -> Result { } fn move_bodies(s: &mut Session, p: &Value) -> Result { - let cmd = "component.move_bodies"; - let to = component(s, p, "component", cmd)?; - let bodies = string_list(p, "bodies"); - let st = s.model.state(); - if bodies.is_empty() || bodies.iter().any(|b| st.body(b).is_none()) { - return Err(bad(cmd, "`bodies` must list existing bodies")); - } - for b in &bodies { - let feature = st.body(b).map(|x| x.feature).unwrap_or(0); - let own = s.doc.feature(feature).map(|f| f.component).unwrap_or(0); - // The body keeps its place in the world: where it is shown now (its component's frame and - // any earlier offset), seen from the new component's frame. - let from = s.doc.body_component(b, feature); - let now = solvecraft_doc::mat_mul(&s.doc.component_transform(from), s.doc.body_offsets.get(b).unwrap_or(&solvecraft_doc::IDENTITY)); - let offset = solvecraft_doc::mat_inverse(&s.doc.component_transform(to)) - .map(|inv| solvecraft_doc::mat_mul(&inv, &now)) - .unwrap_or(solvecraft_doc::IDENTITY); - let doc = s.doc_mut(); - if own == to { - doc.body_components.remove(b); - } else { - doc.body_components.insert(b.clone(), to); - } - if solvecraft_doc::is_identity(&offset) { - doc.body_offsets.remove(b); - } else { - doc.body_offsets.insert(b.clone(), offset); - } - } - Ok(json!({"bodies": bodies, "component": to})) + transfer::move_bodies(s, p) +} + +pub(crate) fn transfer_constructor_result(s: &mut Session, p: &Value) -> Result { + transfer::move_result(s, p) } fn move_sketches(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/component_clone.rs b/crates/engine/src/cmd/component_clone.rs index 5acf8d5d..08f3065c 100644 --- a/crates/engine/src/cmd/component_clone.rs +++ b/crates/engine/src/cmd/component_clone.rs @@ -232,20 +232,29 @@ pub(super) fn paste( } // Body assignment may put externally authored geometry into this graph. Freeze it explicitly. let owned_features: BTreeSet<_> = feats.iter().map(|f| f.id).collect(); - for body in state.bodies.iter().filter(|b| selected.contains(&source.body_component(&b.name, b.feature)) && !owned_features.contains(&b.feature)) - { + for body in state.bodies.iter().filter(|b| selected.contains(&source.model_body_component(b)) && !owned_features.contains(&b.feature)) { if body.body.is_mesh() { return Err(bad(CMD, "Paste New of a reassigned mesh body is not supported yet")); } - let comp = source.body_component(&body.name, body.feature); - let offset = source.body_offsets.get(&body.name).copied().unwrap_or(solvecraft_doc::IDENTITY); + let offset = source.model_body_offset(body).copied().unwrap_or(solvecraft_doc::IDENTITY); + // An external transfer establishes the local baseline in this graph. + // Freeze AFTER that boundary, then replay it as an identity ownership event. + let entry = feats.iter().find(|f| { + !f.suppressed + && matches!(&f.kind, FeatureKind::BodyTransfer { bodies, from, to, .. } + if !selected.contains(from) && bodies.contains(&body.name) + && s.model.result(f.id).is_some_and(|result| !result.skipped && result.error.is_none() + && result.state.body(&body.name).is_some_and(|entered| source.model_body_component(entered) == *to))) + }); let before = feats.iter().find_map(|f| { let before = s.model.state_before(f.id); let prior = before.body(&body.name)?; let after = s.model.result(f.id)?.state.body(&body.name); if after.is_none_or(|after| !prior.body.same(&after.body)) { Some(before) } else { None } }); - let base = before.as_ref().and_then(|st| st.body(&body.name)).unwrap_or(body); + let entered = entry.and_then(|f| s.model.result(f.id)).map(|r| r.state.clone()); + let base = entered.as_ref().and_then(|st| st.body(&body.name)).or_else(|| before.as_ref().and_then(|st| st.body(&body.name))).unwrap_or(body); + let baseline_component = source.model_body_component(base); let geometry = solvecraft_kernel::transform_matrix(&base.body, offset)?; let step = solvecraft_kernel::step_export(&[&geometry], "SolveCraft independent reassigned body")?; let new_name = unique(&body.name, &mut used); @@ -256,7 +265,7 @@ pub(super) fn paste( Some(&feature_name), )?; if let Some(f) = scratch.doc_mut().feature_mut(id) { - f.component = *components.get(&comp).ok_or_else(|| bad(CMD, "missing assigned component"))?; + f.component = *components.get(&baseline_component).ok_or_else(|| bad(CMD, "reassigned baseline is outside copied component graph"))?; f.body_names = vec![new_name]; } new_ids.push(id); @@ -316,6 +325,15 @@ pub(super) fn paste( f.body_names = outputs.get(&old.id).cloned().unwrap_or_default(); } f.remap_clone(&features, &names, &bodies, ¶meters)?; + if let FeatureKind::BodyTransfer { from, to, transform, .. } = &mut f.kind { + *to = *components.get(to).ok_or_else(|| bad(CMD, "transfer destination is outside the copied graph"))?; + if let Some(mapped) = components.get(from) { + *from = *mapped; + } else { + *from = *to; + *transform = solvecraft_doc::IDENTITY; + } + } if let FeatureKind::Emboss { face: Some(face), .. } = &mut f.kind && !face.path.is_empty() && let FeatureKind::Emboss { face: Some(old_face), .. } = &old.kind @@ -361,7 +379,7 @@ pub(super) fn paste( let moved_out: Vec<_> = state .bodies .iter() - .filter(|b| owned_features.contains(&b.feature) && !selected.contains(&source.body_component(&b.name, b.feature))) + .filter(|b| owned_features.contains(&b.feature) && !selected.contains(&source.model_body_component(b))) .filter_map(|b| bodies.get(&b.name).cloned()) .collect(); if !moved_out.is_empty() { @@ -376,17 +394,24 @@ pub(super) fn paste( if let Some(new) = bodies.get(&face.body) { let mut face = face.clone(); if frozen.contains(&face.body) { + if face.strict_name { + return Err(bad(CMD, "cannot copy strict face paint through uncertified foreign re-export")); + } if let Some(name) = &face.name { face.point = solvecraft_doc::naming::point_on_face(&state, name, face.point) .map(|(point, _)| point) .ok_or_else(|| bad(CMD, "source frozen face appearance lost its identity"))?; } - face.point = solvecraft_doc::apply_point(source.body_offsets.get(&face.body).unwrap_or(&solvecraft_doc::IDENTITY), face.point); + let source_body = state.body(&face.body).ok_or_else(|| bad(CMD, "source frozen face appearance lost its body"))?; + face.point = solvecraft_doc::apply_point(source.model_body_offset(source_body).unwrap_or(&solvecraft_doc::IDENTITY), face.point); face.name = None; } else if let Some(name) = &mut face.name { *name = solvecraft_doc::remap_topology(name, &features, &bodies); } face.body = new.clone(); + if let Some(certificate) = &mut face.certificate { + certificate.remap_features(&features); + } scratch.doc_mut().appearances.faces.push(face); } } @@ -519,9 +544,9 @@ pub(super) fn paste( scratch.doc_mut().configs.columns = all_columns; style_validation?; let mut proof_work = 0usize; - let expected_count = state.bodies.iter().filter(|b| selected.contains(&source.body_component(&b.name, b.feature))).count(); + let expected_count = state.bodies.iter().filter(|b| selected.contains(&source.model_body_component(b))).count(); let copied_components: BTreeSet<_> = components.values().copied().collect(); - let actual_count = copied.bodies.iter().filter(|b| copied_components.contains(&scratch.doc.body_component(&b.name, b.feature))).count(); + let actual_count = copied.bodies.iter().filter(|b| copied_components.contains(&scratch.doc.model_body_component(b))).count(); if actual_count != expected_count { return Err(bad(CMD, "copy produced an incomplete or unexpected set of bodies")); } @@ -533,11 +558,11 @@ pub(super) fn paste( return Err(bad(CMD, "copy would modify a source body; external destructive dependencies are not supported")); } } - for body in state.bodies.iter().filter(|b| selected.contains(&source.body_component(&b.name, b.feature))) { + for body in state.bodies.iter().filter(|b| selected.contains(&source.model_body_component(b))) { let new = bodies.get(&body.name).ok_or_else(|| bad(CMD, format!("body {} has no clone identity", body.name)))?; let cloned = copied_bodies.get(new.as_str()).ok_or_else(|| bad(CMD, format!("copy did not reproduce body {}", body.name)))?; - let a = solvecraft_kernel::transform_matrix(&body.body, *source.body_offsets.get(&body.name).unwrap_or(&solvecraft_doc::IDENTITY))?; - let b = solvecraft_kernel::transform_matrix(&cloned.body, *scratch.doc.body_offsets.get(new).unwrap_or(&solvecraft_doc::IDENTITY))?; + let a = solvecraft_kernel::transform_matrix(&body.body, *source.model_body_offset(body).unwrap_or(&solvecraft_doc::IDENTITY))?; + let b = solvecraft_kernel::transform_matrix(&cloned.body, *scratch.doc.model_body_offset(cloned).unwrap_or(&solvecraft_doc::IDENTITY))?; let ma = solvecraft_kernel::measure(&a)?; let mb = solvecraft_kernel::measure(&b)?; let tolerance = 1e-5 * (1.0 + (ma.bbox.max - ma.bbox.min).len()) + 64.0 * f64::EPSILON * (ma.bbox.min.len() + ma.bbox.max.len()); diff --git a/crates/engine/src/cmd/component_clone_tests.rs b/crates/engine/src/cmd/component_clone_tests.rs index 1273710e..b0c59425 100644 --- a/crates/engine/src/cmd/component_clone_tests.rs +++ b/crates/engine/src/cmd/component_clone_tests.rs @@ -238,6 +238,7 @@ fn configuration_rows_follow_copied_parameters_material_and_strict_face_lineage( origin_feature: source, name: format!("F{source}:+z"), point: solvecraft_geom::Vec3::new(1.0, 1.0, 2.0), + certificate: None, }; run(&mut s, "config.configure", json!({"params":[length],"materials":["Cube"],"component_appearances":[part],"face_appearances":[target]})); let face_key = s.doc.config_key(&solvecraft_doc::config::Column::FaceAppearance(target.clone())); @@ -275,6 +276,7 @@ fn configured_extrude_cap_copy_keeps_independent_semantic_lineage() { origin_feature: feature, name: format!("F{feature}:top"), point: solvecraft_geom::Vec3::new(1.0, 1.0, 2.0), + certificate: None, }; run(&mut s, "config.configure", json!({"face_appearances":[target]})); let key = s.doc.config_key(&solvecraft_doc::config::Column::FaceAppearance(target)); @@ -391,3 +393,75 @@ fn generated_copy_parameters_cannot_heal_an_unrelated_missing_reference() { assert_eq!(s.selection, selection); assert_eq!(s.pending_moves, pending); } + +#[test] +fn external_transfer_entry_and_internal_rotated_transfer_clone_once() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":4,"width":3,"height":2,"body_name":"Foreign"})); + let parent = component(&mut s, "Assembly"); + let a = component(&mut s, "A"); + let ao = s.doc.occurrence_of(a).unwrap().id; + run(&mut s, "occurrence.move", json!({"occurrence":ao,"translate":[10,0,0],"angle":90,"axis":[0,0,1],"capture":true})); + run(&mut s, "component.move_bodies", json!({"component":a,"bodies":["Foreign"]})); + run(&mut s, "component.activate", json!({"component":parent})); + let b = component(&mut s, "B"); + let bo = s.doc.occurrence_of(b).unwrap().id; + run(&mut s, "occurrence.move", json!({"occurrence":bo,"translate":[0,20,0],"angle":90,"axis":[1,0,0],"capture":true})); + run(&mut s, "component.move_bodies", json!({"component":b,"bodies":["Foreign"]})); + run(&mut s, "solid.move", json!({"bodies":["Foreign"],"translate":[0,0,5]})); + let before = s.model.state().body("Foreign").unwrap().mesh().bounds(); + let copied = copy(&mut s, parent); + assert_eq!(copied["frozen_bodies"], json!(["Foreign"])); + let state = s.model.state(); + let body = state.body("Foreign (copy)").unwrap(); + let after = body.mesh().bounds(); + assert!(before.min.dist(after.min) < 1e-6); + assert!(before.max.dist(after.max) < 1e-6); + assert!(body.transferred); + let mut reopened = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() }; + reopened.refresh(); + assert!(reopened.model.state().body("Foreign (copy)").unwrap().mesh().bounds().min.dist(before.min) < 1e-6); +} + +#[test] +fn suppressed_external_entry_does_not_supply_clone_baseline() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":4,"width":3,"height":2,"body_name":"Foreign"})); + let part = component(&mut s, "Part"); + let occurrence = s.doc.occurrence_of(part).unwrap().id; + run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[25,0,0],"angle":90,"axis":[0,0,1],"capture":true})); + let skipped = s + .doc_mut() + .add_feature( + solvecraft_doc::FeatureKind::BodyTransfer { bodies: vec!["Foreign".into()], from: 0, to: part, transform: solvecraft_doc::IDENTITY }, + None, + ) + .unwrap(); + s.doc_mut().feature_mut(skipped).unwrap().suppressed = true; + s.refresh(); + run(&mut s, "component.move_bodies", json!({"component":part,"bodies":["Foreign"]})); + let before = s.model.state().body("Foreign").unwrap().mesh().bounds(); + copy(&mut s, part); + let after = s.model.state().body("Foreign (copy)").unwrap().mesh().bounds(); + assert!(before.min.dist(after.min) < 1e-6); + assert!(before.max.dist(after.max) < 1e-6); +} + +#[test] +fn transferred_frozen_face_paint_ignores_consumed_legacy_offset() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"body_name":"Foreign"})); + run(&mut s, "appearance.assign", json!({"body":"Foreign","faces":[[1,1,2]],"color":"#00ff00"})); + s.doc_mut().body_offsets.insert( + "Foreign".into(), + solvecraft_doc::rigid(solvecraft_geom::Vec3::new(7.0, 0.0, 0.0), solvecraft_geom::Vec3::ZERO, solvecraft_geom::Vec3::Z, 0.0), + ); + s.refresh(); + let part = component(&mut s, "Part"); + run(&mut s, "component.move_bodies", json!({"component":part,"bodies":["Foreign"]})); + let original = s.doc.appearances.faces.iter().find(|face| face.body == "Foreign").unwrap(); + let expected = solvecraft_doc::naming::point_on_face(&s.model.state(), original.name.as_ref().unwrap(), original.point).unwrap().0; + copy(&mut s, part); + let paint = s.doc.appearances.faces.iter().find(|face| face.body == "Foreign (copy)").unwrap(); + assert!(paint.point.dist(expected) < 1e-6, "{:?} versus {expected:?}", paint.point); +} diff --git a/crates/engine/src/cmd/component_properties.rs b/crates/engine/src/cmd/component_properties.rs index c642e85c..195d565e 100644 --- a/crates/engine/src/cmd/component_properties.rs +++ b/crates/engine/src/cmd/component_properties.rs @@ -26,7 +26,7 @@ fn placed_doc(s: &Session) -> Document { /// A property total must cover the entire assembly. The display traversal intentionally /// truncates hostile designs, so use an explicit error instead of accepting its prefix. -fn placements(doc: &Document, cmd: &str) -> Result, Mat)>> { +pub(super) fn placements(doc: &Document, cmd: &str) -> Result, Mat)>> { let mut out = vec![(0, Vec::new(), solvecraft_doc::IDENTITY)]; let mut stack = vec![(0, Vec::::new(), solvecraft_doc::IDENTITY)]; while let Some((component, path, transform)) = stack.pop() { @@ -112,8 +112,8 @@ fn properties(s: &Session, doc: &Document, selected: &[u64], descendants: bool, }; for (component, path, transform) in selected_placements { placement_count += 1; - for body in state.bodies.iter().filter(|b| doc.body_component(&b.name, b.feature) == component) { - let transform: Mat = doc.body_offsets.get(&body.name).map(|offset| solvecraft_doc::mat_mul(&transform, offset)).unwrap_or(transform); + for body in state.bodies.iter().filter(|b| doc.model_body_component(b) == component) { + let transform: Mat = doc.model_body_offset(body).map(|offset| solvecraft_doc::mat_mul(&transform, offset)).unwrap_or(transform); let placed = solvecraft_kernel::transform_matrix(&body.body, transform)?; let measured = solvecraft_kernel::measure(&placed)?; let density = doc.density(&body.name); diff --git a/crates/engine/src/cmd/component_transfer.rs b/crates/engine/src/cmd/component_transfer.rs new file mode 100644 index 00000000..be5ae106 --- /dev/null +++ b/crates/engine/src/cmd/component_transfer.rs @@ -0,0 +1,153 @@ +//! Atomic timeline ownership changes, preserving the selected world geometry. +use super::*; +use std::collections::{BTreeMap, BTreeSet}; + +type UniqueFrames = BTreeMap, Mat)>>; + +fn unique_frames(s: &Session, cmd: &str) -> Result { + let doc = s.placed_document(); + let mut children = BTreeMap::>::new(); + for occurrence in &doc.occurrences { + children.entry(occurrence.parent).or_default().push(occurrence); + } + let mut frames = BTreeMap::from([(0, Some((Vec::new(), solvecraft_doc::IDENTITY)))]); + let mut stack = vec![(0, Vec::new(), solvecraft_doc::IDENTITY)]; + let mut work = 0usize; + while let Some((component, path, matrix)) = stack.pop() { + for occurrence in children.get(&component).into_iter().flatten() { + work += 1; + if path.len() >= 64 || work > 100_000 { + return Err(bad(cmd, "transfer assembly exceeds 64 levels or 100000 placements")); + } + let mut child_path = path.clone(); + child_path.push(occurrence.id); + let frame = solvecraft_doc::mat_mul(&matrix, &occurrence.transform); + frames.entry(occurrence.component).and_modify(|entry| *entry = None).or_insert_with(|| Some((child_path.clone(), frame))); + stack.push((occurrence.component, child_path, frame)); + } + } + Ok(frames) +} + +fn body_names(s: &Session, p: &Value, cmd: &str, allow_boundary: bool) -> Result> { + if p.get("bodies").and_then(Value::as_array).is_none_or(|names| names.iter().any(|name| !name.is_string())) { + return Err(bad(cmd, "bodies must be an array of body names")); + } + let names = string_list(p, "bodies"); + if names.is_empty() || names.len() > 1000 || names.iter().collect::>().len() != names.len() { + return Err(bad(cmd, "bodies must list 1..1000 distinct existing definition bodies")); + } + if s.doc.marker.is_some() && !allow_boundary { + return Err(bad(cmd, "finish timeline rollback before transferring bodies")); + } + let state = s.model.state(); + for name in &names { + if state.body(name).is_none() { + return Err(bad(cmd, format!("no body `{name}`"))); + } + } + Ok(names) +} + +fn transfer(s: &mut Session, name: &str, to: u64, frames: &UniqueFrames, cmd: &str) -> Result { + let state = s.model.state(); + let body = state.body(name).ok_or_else(|| bad(cmd, "body is missing"))?; + let from = s.doc.model_body_component(body); + let doc = s.placed_document(); + let unique = |component| { + frames.get(&component).and_then(Option::as_ref).ok_or_else(|| { + bad(cmd, "body transfer requires one source and destination placement; shared instances cannot preserve a unique world pose") + }) + }; + let source = unique(from)?; + let destination = unique(to)?; + let old = doc.model_body_offset(body).map(|offset| solvecraft_doc::mat_mul(&source.1, offset)).unwrap_or(source.1); + let inv = solvecraft_doc::mat_inverse(&destination.1).ok_or_else(|| bad(cmd, "destination frame is not invertible"))?; + let transform = solvecraft_doc::mat_mul(&inv, &old); + if !solvecraft_doc::valid_transfer_matrix(&transform) { + return Err(bad(cmd, "body transfer exceeds supported rigid coordinate range")); + } + let inherited = (!s.doc.appearances.bodies.contains_key(name)).then(|| s.doc.appearances.components.get(&from).cloned()).flatten(); + let paths: BTreeSet = source.0.iter().chain(&destination.0).copied().collect(); + let captures: Vec<_> = paths.into_iter().filter_map(|id| s.pending_moves.get(&id).map(|transform| (id, *transform))).collect(); + if captures.iter().any(|(_, transform)| !solvecraft_doc::valid_transfer_matrix(transform)) { + return Err(bad(cmd, "pending transfer position is not a supported rigid transform")); + } + drop(doc); + for (id, transform) in captures { + if let Some(occurrence) = s.doc_mut().occurrences.iter_mut().find(|occurrence| occurrence.id == id) { + occurrence.transform = transform; + } + s.pending_moves.remove(&id); + } + if let Some(look) = inherited { + s.doc_mut().appearances.bodies.insert(name.to_string(), look); + } + let id = s.doc_mut().add_feature(FeatureKind::BodyTransfer { bodies: vec![name.to_string()], from, to, transform }, None)?; + if let Some(feature) = s.doc_mut().feature_mut(id) { + feature.component = to; + } + Ok(id) +} + +fn finish(s: &mut Session, scratch: &mut Session, features: &[u64], cmd: &str) -> Result<()> { + scratch.refresh(); + for id in features { + if let Some(error) = scratch.model.result(*id).and_then(|result| result.error.as_ref()) { + return Err(bad(cmd, error)); + } + } + s.accept_computed(std::mem::take(scratch)); + Ok(()) +} + +pub(super) fn from_bodies(s: &mut Session, p: &Value) -> Result { + let cmd = "component.from_bodies"; + let bodies = body_names(s, p, cmd, false)?; + let mut scratch = s.scratch(); + let parent = s.active_component; + let mut made = Vec::new(); + let mut targets = Vec::new(); + for name in &bodies { + let component = scratch.doc_mut().add_component(Some(name), parent)?; + targets.push(component); + } + let frames = unique_frames(&scratch, cmd)?; + let mut features = Vec::new(); + for (name, component) in bodies.into_iter().zip(targets) { + let feature = transfer(&mut scratch, &name, component, &frames, cmd)?; + features.push(feature); + made.push(json!({"component":component,"body":name,"feature":feature})); + } + finish(s, &mut scratch, &features, cmd)?; + Ok(json!({"components":made})) +} + +pub(super) fn move_bodies(s: &mut Session, p: &Value) -> Result { + move_at_boundary(s, p, false) +} + +/// A solid constructor can attach its result immediately after the new feature, +/// including inside the scratch timeline used by Edit Feature. +pub(super) fn move_result(s: &mut Session, p: &Value) -> Result { + move_at_boundary(s, p, true) +} + +fn move_at_boundary(s: &mut Session, p: &Value, allow_boundary: bool) -> Result { + let cmd = "component.move_bodies"; + let to = super::component(s, p, "component", cmd)?; + let bodies = body_names(s, p, cmd, allow_boundary)?; + let mut scratch = s.scratch(); + let frames = unique_frames(&scratch, cmd)?; + let mut features = Vec::new(); + for name in &bodies { + features.push(transfer(&mut scratch, name, to, &frames, cmd)?); + } + finish(s, &mut scratch, &features, cmd)?; + Ok(json!({"bodies":bodies,"component":to,"features":features})) +} + +#[cfg(test)] +#[path = "component_transfer_tests.rs"] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests; diff --git a/crates/engine/src/cmd/component_transfer_tests.rs b/crates/engine/src/cmd/component_transfer_tests.rs new file mode 100644 index 00000000..9a6fd2e8 --- /dev/null +++ b/crates/engine/src/cmd/component_transfer_tests.rs @@ -0,0 +1,287 @@ +use super::*; + +fn run(s: &mut Session, command: &str, params: Value) -> Value { + s.execute(command, ¶ms).unwrap() +} + +#[test] +fn recorded_assignment_preserves_sample_joined_material_and_timeline() { + let mut s = Session::default(); + s.run_script(&crate::sample::script()).unwrap(); + let before = s.model.state(); + assert_eq!(before.bodies.len(), 1); + let volume = solvecraft_kernel::measure(&before.body("Plate").unwrap().body).unwrap().volume; + let created = run(&mut s, "component.from_bodies", json!({"bodies":["Plate"]})); + let to = created["components"][0]["component"].as_u64().unwrap(); + let feature = created["components"][0]["feature"].as_u64().unwrap(); + let after = s.model.state(); + assert_eq!(after.bodies.len(), 1); + let body = after.body("Plate").unwrap(); + assert_eq!(body.component, to); + assert!(body.transferred); + assert!((solvecraft_kernel::measure(&body.body).unwrap().volume - volume).abs() < 1e-6); + assert!(body.mesh().contains(Vec3::new(40.0, 35.0, 12.0))); + let index = s.doc.feature_index(feature).unwrap(); + s.doc_mut().marker = Some(index); + s.refresh(); + assert_eq!(s.world_state().body("Plate").unwrap().component, 0); + s.doc_mut().marker = None; + s.refresh(); + assert_eq!(s.world_state().body("Plate").unwrap().component, to); +} + +#[test] +fn transformed_pending_destination_repeated_transfer_preserves_world_pose() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Block"})); + let created = run(&mut s, "component.create", json!({"name":"Destination"})); + let to = created["component"].as_u64().unwrap(); + let occurrence = created["occurrence"].as_u64().unwrap(); + run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[30,20,0],"angle":90,"axis":[0,0,1],"capture":false})); + run(&mut s, "component.move_bodies", json!({"component":to,"bodies":["Block"]})); + let state = s.world_state(); + let bounds = state.body("Block").unwrap().mesh().bounds(); + assert!(bounds.min.dist(Vec3::ZERO) < 1e-6); + assert!(bounds.max.dist(Vec3::new(10.0, 10.0, 10.0)) < 1e-6); + assert!(!s.pending_moves.contains_key(&occurrence)); + let mut destination_reopened = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() }; + destination_reopened.refresh(); + assert!(destination_reopened.world_state().body("Block").unwrap().mesh().bounds().min.dist(Vec3::ZERO) < 1e-6); + run(&mut s, "component.move_bodies", json!({"component":0,"bodies":["Block"]})); + assert!(s.world_state().body("Block").unwrap().mesh().bounds().min.dist(Vec3::ZERO) < 1e-6); + let mut reopened = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() }; + reopened.refresh(); + assert!(reopened.world_state().body("Block").unwrap().mesh().bounds().min.dist(Vec3::ZERO) < 1e-6); +} + +#[test] +fn shared_destination_refuses_without_mutating_document_or_session() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Block"})); + let c = run(&mut s, "component.create", json!({"name":"Shared"}))["component"].as_u64().unwrap(); + run(&mut s, "occurrence.copy", json!({"component":c,"translate":[20,0,0]})); + let document = s.doc.to_json(); + let active = s.active_component; + assert!(s.execute("component.move_bodies", &json!({"component":c,"bodies":["Block"]})).is_err()); + assert_eq!(s.doc.to_json(), document); + assert_eq!(s.active_component, active); +} + +#[test] +fn historical_join_cut_and_new_destination_modeling_keep_material() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Block"})); + run(&mut s, "solid.box", json!({"corner":[8,0,0],"length":10,"width":10,"height":10,"operation":"join"})); + run(&mut s, "solid.box", json!({"corner":[2,2,0],"length":2,"width":2,"height":10,"operation":"cut"})); + let original = s.model.state(); + assert_eq!(original.bodies.len(), 1); + let v = solvecraft_kernel::measure(&original.body("Block").unwrap().body).unwrap().volume; + assert!((v - 1760.0).abs() < 1e-6); + run(&mut s, "material.assign", json!({"bodies":["Block"],"material":"Brass"})); + run(&mut s, "appearance.assign", json!({"bodies":["Block"],"color":"#bc8537"})); + let created = run(&mut s, "component.from_bodies", json!({"bodies":["Block"]})); + let c = created["components"][0]["component"].as_u64().unwrap(); + assert_eq!(s.doc.materials["Block"], "Brass"); + assert_eq!(s.doc.appearances.bodies["Block"].color, [188, 133, 55]); + run(&mut s, "component.activate", json!({"component":c})); + run(&mut s, "solid.box", json!({"corner":[18,0,0],"length":2,"width":10,"height":10,"operation":"join"})); + assert_eq!(s.model.state().bodies.len(), 1); + assert_eq!(s.model.state().body("Block").unwrap().component, c); + let final_volume = solvecraft_kernel::measure(&s.model.state().body("Block").unwrap().body).unwrap().volume; + assert!((final_volume - 1960.0).abs() < 1e-6); + run(&mut s, "component.move_bodies", json!({"component":0,"bodies":["Block"]})); + assert_eq!(s.model.state().body("Block").unwrap().component, 0); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.model.state().body("Block").unwrap().component, c); + let mut reopened = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() }; + reopened.refresh(); + assert!((solvecraft_kernel::measure(&reopened.model.state().body("Block").unwrap().body).unwrap().volume - final_volume).abs() < 1e-6); +} + +#[test] +fn ownership_only_boundary_invalidates_old_boolean_cache() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Block"})); + run(&mut s, "solid.box", json!({"corner":[8,0,0],"length":10,"width":10,"height":10,"operation":"join"})); + assert_eq!(s.model.state().bodies.len(), 1); + let c = s.doc_mut().add_component(Some("Destination"), 0).unwrap(); + let id = s + .doc_mut() + .add_feature(FeatureKind::BodyTransfer { bodies: vec!["Block".into()], from: 0, to: c, transform: solvecraft_doc::IDENTITY }, None) + .unwrap(); + let index = s.doc.feature_index(id).unwrap(); + let transfer = s.doc_mut().features.remove(index); + s.doc_mut().features.insert(1, transfer); + s.refresh(); + assert_eq!(s.model.state().bodies.len(), 2); + assert_eq!(s.model.state().body("Block").unwrap().component, c); + let mut reopened = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() }; + reopened.refresh(); + assert_eq!(reopened.model.state().bodies.len(), 2); + assert_eq!(reopened.model.state().body("Block").unwrap().component, c); +} + +#[test] +fn failed_transfer_cannot_change_evaluated_world_owner() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Block"})); + let c = s.doc_mut().add_component(Some("Destination"), 0).unwrap(); + let id = s + .doc_mut() + .add_feature(FeatureKind::BodyTransfer { bodies: vec!["Block".into()], from: c, to: c, transform: solvecraft_doc::IDENTITY }, None) + .unwrap(); + s.refresh(); + assert!(s.model.result(id).unwrap().error.is_some()); + assert_eq!(s.world_state().body("Block").unwrap().component, 0); +} + +#[test] +fn transferred_thread_follows_body_frame_without_changing_world_axis() { + let mut s = Session::default(); + run(&mut s, "solid.cylinder", json!({"radius":4,"height":20,"body_name":"Shaft"})); + run(&mut s, "solid.thread", json!({"face":[4,0,10],"designation":"M8"})); + let before = s.world_state().threads[0].clone(); + let c = run(&mut s, "component.create", json!({"name":"Threaded"})); + let to = c["component"].as_u64().unwrap(); + let occurrence = c["occurrence"].as_u64().unwrap(); + run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[30,20,10],"axis":[1,0,0],"angle":90,"capture":true})); + run(&mut s, "component.move_bodies", json!({"component":to,"bodies":["Shaft"]})); + let after = s.world_state().threads[0].clone(); + assert!(after.axis_point.dist(before.axis_point) < 1e-6); + assert!(after.axis.dist(before.axis) < 1e-6); + let mut reopened = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() }; + reopened.refresh(); + assert!(reopened.world_state().threads[0].axis.dist(before.axis) < 1e-6); +} + +#[test] +fn inherited_component_appearance_and_legacy_assignment_survive_transfer() { + let mut s = Session::default(); + let source = run(&mut s, "component.create", json!({"name":"Source"}))["component"].as_u64().unwrap(); + run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Block"})); + s.doc_mut().appearances.components.insert(source, solvecraft_doc::appearance::Look { name: "Green".into(), color: [0, 150, 0], opacity: 0.7 }); + s.refresh(); + let look = s.doc.model_body_look(s.model.state().body("Block").unwrap()).unwrap(); + run(&mut s, "component.move_bodies", json!({"component":0,"bodies":["Block"]})); + assert_eq!(s.doc.model_body_look(s.model.state().body("Block").unwrap()), Some(look)); + // An unrecorded legacy assignment retains its established replay policy. + let mut legacy = Session::default(); + run(&mut legacy, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Old"})); + let c = legacy.doc_mut().add_component(Some("Legacy"), 0).unwrap(); + legacy.doc_mut().body_components.insert("Old".into(), c); + legacy.doc_mut().body_offsets.insert("Old".into(), solvecraft_doc::rigid(Vec3::new(7.0, 0.0, 0.0), Vec3::ZERO, Vec3::Z, 0.0)); + legacy.refresh(); + assert!(!legacy.model.state().body("Old").unwrap().transferred); + run(&mut legacy, "component.move_bodies", json!({"component":0,"bodies":["Old"]})); + assert!((legacy.world_state().body("Old").unwrap().mesh().bounds().min.x - 7.0).abs() < 1e-6); + let mut reopened = Session { doc: solvecraft_doc::Document::from_json(&legacy.doc.to_json()).unwrap().into(), ..Session::default() }; + reopened.refresh(); + assert!((reopened.world_state().body("Old").unwrap().mesh().bounds().min.x - 7.0).abs() < 1e-6); +} + +#[test] +fn deleting_external_transfer_source_refuses_without_dangling_archive() { + let mut s = Session::default(); + let a = run(&mut s, "component.create", json!({"name":"Source"}))["component"].as_u64().unwrap(); + run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"body_name":"Block"})); + run(&mut s, "component.activate", json!({"component":0})); + let b = run(&mut s, "component.create", json!({"name":"Destination"}))["component"].as_u64().unwrap(); + run(&mut s, "component.move_bodies", json!({"component":b,"bodies":["Block"]})); + let document = s.doc.to_json(); + assert!(s.execute("component.delete", &json!({"component":a})).is_err()); + assert_eq!(s.doc.to_json(), document); + assert!(solvecraft_doc::Document::from_json(&document).is_ok()); +} + +#[test] +fn earlier_selected_cylinder_emboss_keeps_source_frame_after_transfer_and_edit() { + let mut s = Session::default(); + let source = run(&mut s, "component.create", json!({"name":"Source"})); + let occurrence = source["occurrence"].as_u64().unwrap(); + run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"})); + run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[30,0,0],"capture":true})); + run(&mut s, "sketch.create", json!({"name":"Badge","plane":{"origin":[30,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]}})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]})); + run(&mut s, "sketch.finish", json!({})); + let emboss = run(&mut s, "solid.emboss", json!({"sketch":"Badge","face":[30,10,7.5],"depth":1}))["feature"].as_u64().unwrap(); + let before = solvecraft_kernel::measure(&s.world_state().body("Sleeve").unwrap().body).unwrap().volume; + run(&mut s, "component.activate", json!({"component":0})); + let destination = run(&mut s, "component.create", json!({"name":"Destination"})); + let to = destination["component"].as_u64().unwrap(); + let destination_occurrence = destination["occurrence"].as_u64().unwrap(); + run(&mut s, "occurrence.move", json!({"occurrence":destination_occurrence,"translate":[0,40,0],"axis":[1,0,0],"angle":90,"capture":true})); + run(&mut s, "component.move_bodies", json!({"component":to,"bodies":["Sleeve"]})); + assert!(s.doc.features.iter().all(|feature| s.model.result(feature.id).is_some_and(|result| result.error.is_none()))); + let mut reopened = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() }; + reopened.refresh(); + assert!(reopened.doc.features.iter().all(|feature| reopened.model.result(feature.id).is_some_and(|result| result.error.is_none()))); + assert!((solvecraft_kernel::measure(&reopened.world_state().body("Sleeve").unwrap().body).unwrap().volume - before).abs() < 1e-5); + let depth = reopened.doc.feature(emboss).unwrap().param_names[0].clone(); + run(&mut reopened, "parameters.change", json!({"name":depth,"expression":"2 mm"})); + assert!(solvecraft_kernel::measure(&reopened.world_state().body("Sleeve").unwrap().body).unwrap().volume > before); +} + +#[test] +fn transfer_undo_redo_restores_original_pending_source_world_pose() { + let mut s = Session::default(); + let created = run(&mut s, "component.create", json!({"name":"Source"})); + let source = created["component"].as_u64().unwrap(); + let occurrence = created["occurrence"].as_u64().unwrap(); + run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"body_name":"Block"})); + run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[10,0,0],"capture":false})); + let pending = s.pending_moves.clone(); + let before = s.world_state().body("Block").unwrap().mesh().bounds(); + run(&mut s, "component.move_bodies", json!({"component":0,"bodies":["Block"]})); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.pending_moves, pending); + assert_eq!(s.world_state().body("Block").unwrap().component, source); + assert!(s.world_state().body("Block").unwrap().mesh().bounds().min.dist(before.min) < 1e-6); + run(&mut s, "edit.redo", json!({})); + assert!(s.pending_moves.is_empty()); + assert_eq!(s.world_state().body("Block").unwrap().component, 0); + assert!(s.world_state().body("Block").unwrap().mesh().bounds().min.dist(before.min) < 1e-6); +} + +#[test] +fn transfer_checkpoint_restores_pending_world_pose_and_history() { + let mut s = Session::default(); + let created = run(&mut s, "component.create", json!({"name":"Source"})); + let source = created["component"].as_u64().unwrap(); + let occurrence = created["occurrence"].as_u64().unwrap(); + run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"body_name":"Block"})); + run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[10,0,0],"capture":false})); + let pending = s.pending_moves.clone(); + let before = s.world_state().body("Block").unwrap().mesh().bounds(); + let undo_count = s.undo.len(); + let checkpoint = s.checkpoint(); + run(&mut s, "component.move_bodies", json!({"component":0,"bodies":["Block"]})); + s.restore_checkpoint(checkpoint).unwrap(); + assert_eq!(s.pending_moves, pending); + assert_eq!(s.undo.len(), undo_count); + assert_eq!(s.world_state().body("Block").unwrap().component, source); + assert!(s.world_state().body("Block").unwrap().mesh().bounds().min.dist(before.min) < 1e-6); +} + +#[test] +fn edited_transfer_destination_updates_history_owner_copy_and_reopen() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"body_name":"Block"})); + let a = run(&mut s, "component.create", json!({"name":"A","parent":0}))["component"].as_u64().unwrap(); + let b = run(&mut s, "component.create", json!({"name":"B","parent":0}))["component"].as_u64().unwrap(); + let transferred = run(&mut s, "component.move_bodies", json!({"component":a,"bodies":["Block"]})); + let feature = transferred["features"][0].as_u64().unwrap(); + run(&mut s, "timeline.edit", json!({"feature":feature,"set":{"to":b,"component":a}})); + assert_eq!(s.doc.feature(feature).unwrap().component, b); + assert_eq!(s.model.state().body("Block").unwrap().component, b); + let saved = s.doc.to_json(); + let pending = s.pending_moves.clone(); + assert!(s.execute("timeline.edit", &json!({"feature":feature,"set":{"to":u64::MAX}})).is_err()); + assert_eq!(s.doc.to_json(), saved); + assert_eq!(s.pending_moves, pending); + let copied = run(&mut s, "component.paste_new", json!({"component":b}))["component"].as_u64().unwrap(); + assert_eq!(s.model.state().body("Block (copy)").unwrap().component, copied); + let mut reopened = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() }; + reopened.refresh(); + assert_eq!(reopened.model.state().body("Block").unwrap().component, b); + assert_eq!(reopened.model.state().body("Block (copy)").unwrap().component, copied); +} diff --git a/crates/engine/src/cmd/config.rs b/crates/engine/src/cmd/config.rs index 7e61db30..d5335c98 100644 --- a/crates/engine/src/cmd/config.rs +++ b/crates/engine/src/cmd/config.rs @@ -63,6 +63,15 @@ fn column_of(s: &Session, p: &Value, cmd: &str) -> Result { .cloned() .ok_or_else(|| bad(cmd, "no face column")); } + if let Some(target) = value.get("target") { + solvecraft_doc::config::validate_face_target_json(target).map_err(|error| bad(cmd, error.to_string()))?; + let target: solvecraft_doc::config::ConfigFaceTarget = + serde_json::from_value(target.clone()).map_err(|error| bad(cmd, error.to_string()))?; + let state = s.model.state(); + let body = state.body(&target.body).ok_or_else(|| bad(cmd, "no face definition"))?; + s.doc.configuration_face_indices(body, &target).map_err(|error| bad(cmd, error.to_string()))?; + return Ok(Column::FaceAppearance(target)); + } let body_name = value.get("body").and_then(Value::as_str).ok_or_else(|| bad(cmd, "face body required"))?; let point = value.get("point").and_then(crate::params::vec3).ok_or_else(|| bad(cmd, "finite face point required"))?; let world = s.world_state(); @@ -71,20 +80,7 @@ fn column_of(s: &Session, p: &Value, cmd: &str) -> Result { return Err(bad(cmd, "mesh faces have no supported semantic lineage")); } let index = solvecraft_doc::appearance::face_index_at(body, point).ok_or_else(|| bad(cmd, "no face at picked point"))?; - let names = solvecraft_doc::naming::face_names(body); - let name = names.get(index).map(|name| solvecraft_doc::naming::strip_piece(name).to_string()).ok_or_else(|| bad(cmd, "face name missing"))?; - let origin_feature = name - .strip_prefix('F') - .and_then(|name| name.split_once(':')) - .and_then(|(id, _)| id.parse::().ok()) - .ok_or_else(|| bad(cmd, "face lineage missing"))?; - let inverse = solvecraft_doc::mat_inverse(&body.source_transform).ok_or_else(|| bad(cmd, "invalid definition placement transform"))?; - let target = solvecraft_doc::config::ConfigFaceTarget { - body: body.definition_name().into(), - origin_feature, - name, - point: solvecraft_doc::apply_point(&inverse, point), - }; + let target = s.doc.configuration_face_target(body, index, point).map_err(|error| bad(cmd, error.to_string()))?; s.doc.certify_config_face(&target).map_err(|error| bad(cmd, error.to_string()))?; s.doc.configuration_face_indices(body, &target).map_err(|error| bad(cmd, error.to_string()))?; return Ok(Column::FaceAppearance(target)); @@ -415,6 +411,10 @@ fn preview(s: &mut Session, p: &Value) -> Result { Ok(out) } +#[cfg(test)] +#[path = "config_certificate_tests.rs"] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod certificate_tests; #[cfg(test)] #[path = "config_style_tests.rs"] #[allow(clippy::unwrap_used, clippy::expect_used)] diff --git a/crates/engine/src/cmd/config_certificate_tests.rs b/crates/engine/src/cmd/config_certificate_tests.rs new file mode 100644 index 00000000..da39d20a --- /dev/null +++ b/crates/engine/src/cmd/config_certificate_tests.rs @@ -0,0 +1,251 @@ +use crate::Session; +use serde_json::{Value, json}; +use solvecraft_doc::{ + FeatureKind, + config::{Column, FaceCertificate}, +}; +use solvecraft_geom::Vec3; + +fn run(session: &mut Session, command: &str, params: Value) -> Value { + session.execute(command, ¶ms).unwrap_or_else(|error| panic!("{command} {params}: {error}")) +} +fn paint(session: &mut Session, body: &str, point: Value) { + let table = run(session, "config.configure", json!({"face_appearances":[{"body":body,"point":point}]})); + let key = table["columns"][0].as_str().unwrap(); + run(session, "config.row", json!({"name":"Painted","values":{key:"ABS - Red"}})); + run(session, "config.activate", json!({"row":"Painted"})); +} +fn painted_radius(session: &Session, name: &str, radius: f64) -> usize { + let state = session.model.state(); + let body = + state.body(name).unwrap_or_else(|| panic!("missing {name}; bodies {:?}", state.bodies.iter().map(|body| &body.name).collect::>())); + let painted = session.doc.face_colors(body); + let mesh = body.body.tessellate(0.001).unwrap(); + let inverse = solvecraft_doc::mat_inverse(&body.source_transform).unwrap(); + for (triangle, face) in mesh.triangles.iter().zip(&mesh.tri_face) { + if painted.iter().any(|(index, _)| *index == *face as usize) { + for vertex in mesh.tri(triangle).unwrap() { + let vertex = solvecraft_doc::apply_point(&inverse, vertex); + assert!((vertex.x.hypot(vertex.y) - radius).abs() < 1e-7, "{name}: {vertex:?}"); + } + } + } + assert!(!painted.is_empty()); + painted.len() +} + +#[test] +fn certified_annulus_side_rows_follow_radius_edits_and_clone() { + let mut s = Session::default(); + let part = run(&mut s, "component.create", json!({"name":"RingPart"}))["component"].as_u64().unwrap(); + let sketch = run(&mut s, "sketch.create", json!({"plane":"XY"}))["sketch"].as_u64().unwrap(); + run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":10,"id":"outer"})); + run(&mut s, "sketch.dimension", json!({"entities":["outer"],"type":"radius","value":10})); + run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":9,"id":"inner"})); + run(&mut s, "sketch.dimension", json!({"entities":["inner"],"type":"radius","value":9})); + let FeatureKind::Sketch { sketch: source_sketch, .. } = &s.doc.feature(sketch).unwrap().kind else { panic!() }; + let dimensions: Vec<_> = source_sketch.constraints.iter().filter_map(|dimension| dimension.param.clone()).collect(); + assert_eq!(dimensions.len(), 2); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"distance":10,"profiles":[{"point":[9.5,0]}],"body_name":"Ring"})); + paint(&mut s, "Ring", json!([10, 0, 5])); + assert_eq!(painted_radius(&s, "Ring", 10.), 2, "both constructed arc pieces share outer curve"); + let Column::FaceAppearance(target) = &s.doc.configs.columns[0] else { panic!() }; + assert_eq!(target.certificate, Some(FaceCertificate::ExtrudeSide { sketch, curve: "outer".into() })); + run(&mut s, "parameters.change", json!({"name":dimensions[0],"expression":"12 mm"})); + run(&mut s, "parameters.change", json!({"name":dimensions[1],"expression":"9.25 mm"})); + run(&mut s, "config.activate", json!({"row":"Painted"})); + assert_eq!(painted_radius(&s, "Ring", 12.), 2); + run(&mut s, "component.paste_new", json!({"component":part,"translate":[30,0,0]})); + assert_eq!(s.doc.configs.columns.len(), 2); + run(&mut s, "config.activate", json!({"row":"Painted"})); + assert_eq!(painted_radius(&s, "Ring (copy)", 12.), 2); + let reopened = solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap(); + let mut reopened = Session::new(reopened); + run(&mut reopened, "config.activate", json!({"row":"Painted"})); + assert_eq!(painted_radius(&reopened, "Ring", 12.), 2); + assert_eq!(painted_radius(&reopened, "Ring (copy)", 12.), 2); + run(&mut reopened, "config.activate", json!({"row":"Default"})); + assert!(reopened.doc.appearances.faces.is_empty()); +} + +fn imported(session: &mut Session, step: String, name: &str) -> u64 { + let owner = session.active_component; + let id = session.doc_mut().add_feature(FeatureKind::Import { file: "fixture.step".into(), step, components: vec![] }, Some(name)).unwrap(); + let feature = session.doc_mut().feature_mut(id).unwrap(); + feature.body_names = vec![name.into()]; + feature.component = owner; + session.refresh(); + assert!(session.model.result(id).unwrap().error.is_none()); + id +} + +#[test] +fn legacy_v9_imported_emboss_anchor_and_ordinary_face_paint_replay_without_renaming() { + let mut s = Session::default(); + let cylinder = solvecraft_kernel::cylinder(Vec3::ZERO, Vec3::Z, 10., 20.).unwrap(); + let step = solvecraft_kernel::step_export(&[&cylinder], "legacy anchored cylinder").unwrap(); + let source = imported(&mut s, step, "Sleeve"); + run(&mut s, "material.assign", json!({"bodies":["Sleeve"],"material":"Steel"})); + let before_volume = s.model.state().body("Sleeve").unwrap().body.tessellate_with(1e-4, true).unwrap().measure().volume; + let legacy_names = solvecraft_doc::naming::face_names(s.model.state().body("Sleeve").unwrap()); + assert!(legacy_names.iter().all(|name| name.starts_with(&format!("F{source}:face:")))); + run(&mut s, "appearance.assign", json!({"faces":[{"body":"Sleeve","point":[6,0,20]}],"appearance":"ABS - Blue"})); + assert!(s.doc.appearances.faces[0].certificate.is_none()); + let badge = run(&mut s, "sketch.create", json!({"plane":{"face":[0,0,20]},"name":"Badge","project_edges":false}))["sketch"].as_u64().unwrap(); + let FeatureKind::Sketch { plane: saved_plane, .. } = &s.doc.feature(badge).unwrap().kind else { panic!() }; + let saved_plane = saved_plane.clone(); + assert!(matches!(&saved_plane, solvecraft_doc::PlaneRef::Face { name: Some(name), .. } if legacy_names.contains(name))); + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,-2],"p1":[2,2]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.emboss", json!({"sketch":"Badge","profiles":"all","depth":1,"targets":["Sleeve"],"name":"Raised Badge"})); + let anchored = s.doc.find_feature("Raised Badge").unwrap().clone(); + assert!(matches!(anchored.kind, FeatureKind::Emboss { face: None, .. })); + let volume = s.model.state().body("Sleeve").unwrap().body.tessellate_with(1e-4, true).unwrap().measure().volume; + assert!((volume - std::f64::consts::PI * 2000. - 16.).abs() < 0.1, "volume {volume}"); + assert!((volume - before_volume - 16.).abs() < 0.01, "addedvolume {} baseline {before_volume}", volume - before_volume); + let paint = s.doc.appearances.faces.clone(); + let mut legacy: Value = serde_json::from_str(&s.doc.to_json()).unwrap(); + legacy["format"] = json!("solvecraft/9"); + let reopened = Session::new(solvecraft_doc::Document::from_json(&legacy.to_string()).unwrap()); + assert_eq!(reopened.doc.feature(anchored.id).unwrap(), &anchored); + let FeatureKind::Sketch { plane, .. } = &reopened.doc.feature(badge).unwrap().kind else { panic!() }; + assert_eq!(plane, &saved_plane); + assert_eq!(reopened.doc.appearances.faces, paint); + assert_eq!(reopened.doc.materials.get("Sleeve"), Some(&"Steel".to_string())); + assert!(reopened.model.result(anchored.id).unwrap().error.is_none()); + let state = reopened.model.state(); + let body = state.body("Sleeve").unwrap(); + assert!((body.body.tessellate_with(1e-4, true).unwrap().measure().volume - volume).abs() < 1e-8); + assert!(!reopened.doc.face_colors(body).is_empty()); +} + +#[test] +fn certified_side_refuses_reused_body_name_from_a_new_producer() { + let mut s = Session::default(); + let sketch = run(&mut s, "sketch.create", json!({"plane":"XY"}))["sketch"].as_u64().unwrap(); + run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":10,"id":"circle"})); + run(&mut s, "sketch.finish", json!({})); + let source = run(&mut s, "solid.extrude", json!({"distance":10,"body_name":"Round"}))["feature"].as_u64().unwrap(); + paint(&mut s, "Round", json!([10, 0, 5])); + let Column::FaceAppearance(target) = s.doc.configs.columns[0].clone() else { panic!() }; + let mut replacement = s.doc.feature(source).unwrap().clone(); + replacement.id = s.doc.next_id; + assert!(matches!(replacement.kind, FeatureKind::Extrude { sketch: original, .. } if original == sketch)); + s.doc_mut().feature_mut(source).unwrap().suppressed = true; + s.doc_mut().next_id += 1; + s.doc_mut().features.push(replacement); + s.refresh(); + let state = s.model.state(); + let body = state.body("Round").unwrap(); + assert!(s.doc.configuration_face_indices(body, &target).is_err()); + assert!(s.doc.face_colors(body).is_empty()); + let before = s.doc.clone(); + let history = s.undo.len(); + assert!(s.execute("config.activate", &json!({"row":"Painted"})).is_err()); + assert_eq!(s.doc, before); + assert_eq!(s.undo.len(), history); +} + +#[test] +fn legacy_v9_annulus_outer_side_emboss_keeps_its_original_heuristic_anchor() { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane":"XY"})); + run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":10,"id":"outer"})); + run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":9,"id":"inner"})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"distance":20,"profiles":[{"point":[9.5,0]}],"body_name":"Sleeve"})); + let names = solvecraft_doc::naming::face_names(s.model.state().body("Sleeve").unwrap()); + // Preserve the legacy centroid-family name even though strict paint uses + // the separately certified outer source curve. + let name = solvecraft_doc::naming::face_names_at(&s.model.state(), &[Vec3::new(0., 10., 7.5)]).remove(0); + assert!(name.contains("side:inner")); + run(&mut s, "sketch.create", json!({"plane":{"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Badge"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.emboss", json!({"sketch":"Badge","profiles":"all","face":[0,10,7.5],"depth":1,"targets":["Sleeve"],"name":"Raised Badge"})); + let feature = s.doc.find_feature("Raised Badge").unwrap(); + assert_eq!(feature.face_names, vec![name]); + assert!(feature.face_names.iter().all(|name| names.contains(name))); + let feature_id = feature.id; + let volume = s.model.state().body("Sleeve").unwrap().body.tessellate_with(1e-4, true).unwrap().measure().volume; + assert!((volume - std::f64::consts::PI * 380. - 21.).abs() < 0.2, "volume {volume}"); + let mut legacy: Value = serde_json::from_str(&s.doc.to_json()).unwrap(); + legacy["format"] = json!("solvecraft/9"); + let reopened = Session::new(solvecraft_doc::Document::from_json(&legacy.to_string()).unwrap()); + assert!(reopened.model.result(feature_id).unwrap().error.is_none()); + assert!((reopened.model.state().body("Sleeve").unwrap().body.tessellate_with(1e-4, true).unwrap().measure().volume - volume).abs() < 1e-8); +} + +#[test] +fn certified_side_transfer_preserves_geometry_and_unsupported_taper_refuses_atomically() { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane":"XY"})); + run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":10})); + run(&mut s, "sketch.finish", json!({})); + let source = run(&mut s, "solid.extrude", json!({"distance":10,"body_name":"Round"}))["feature"].as_u64().unwrap(); + paint(&mut s, "Round", json!([10, 0, 5])); + let created = run(&mut s, "component.create", json!({"name":"Placed"})); + let destination = created["component"].as_u64().unwrap(); + let occurrence = created["occurrence"].as_u64().unwrap(); + run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[30,20,0],"angle":90,"axis":[0,0,1],"capture":false})); + run(&mut s, "component.move_bodies", json!({"component":destination,"bodies":["Round"]})); + run(&mut s, "config.activate", json!({"row":"Painted"})); + assert_eq!(painted_radius(&s, "Round", 10.), 2); + let before = s.world_state().body("Round").unwrap().mesh().bounds(); + let mut s = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); + run(&mut s, "config.activate", json!({"row":"Painted"})); + assert_eq!(painted_radius(&s, "Round", 10.), 2); + assert!( + s.world_state().body("Round").unwrap().mesh().bounds().min.dist(before.min) < 1e-8, + "before {before:?}, reopened {:?}", + s.world_state().body("Round").unwrap().mesh().bounds() + ); + // A successful ordinary modifier has no exact untapered source certificate. + let FeatureKind::Extrude { extent, .. } = &mut s.doc_mut().feature_mut(source).unwrap().kind else { panic!() }; + extent.taper = Some("5 deg".into()); + s.refresh(); + assert!(s.model.result(source).unwrap().error.is_none()); + assert!(s.doc.face_colors(s.model.state().body("Round").unwrap()).is_empty()); + let document = s.doc.clone(); + let history = s.undo.len(); + assert!(s.execute("config.preview", &json!({"row":"Painted"})).is_err()); + assert!(s.execute("config.activate", &json!({"row":"Painted"})).is_err()); + assert_eq!(s.doc, document); + assert_eq!(s.undo.len(), history); +} + +#[test] +fn certified_import_payload_binding_roundtrip_clone_and_replacement_refusal() { + let geometry = solvecraft_kernel::cylinder(Vec3::ZERO, Vec3::Z, 10., 10.).unwrap(); + let step = solvecraft_kernel::step_export(&[&geometry], "certificate fixture").unwrap(); + let mut s = Session::default(); + let part = run(&mut s, "component.create", json!({"name":"ImportedPart"}))["component"].as_u64().unwrap(); + let source = imported(&mut s, step.clone(), "Imported"); + paint(&mut s, "Imported", json!([10, 0, 5])); + let pieces = painted_radius(&s, "Imported", 10.); + let Column::FaceAppearance(target) = &s.doc.configs.columns[0] else { panic!() }; + assert!( + matches!(&target.certificate, Some(FaceCertificate::Imported { item, face, payload_sha256 }) if *item != 0 && *face != 0 && payload_sha256.len() == 64) + ); + run(&mut s, "component.paste_new", json!({"component":part,"translate":[30,0,0]})); + run(&mut s, "config.activate", json!({"row":"Painted"})); + assert_eq!(painted_radius(&s, "Imported (copy)", 10.), pieces); + let reopened = solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap(); + let mut s = Session::new(reopened); + run(&mut s, "config.activate", json!({"row":"Painted"})); + assert_eq!(painted_radius(&s, "Imported", 10.), pieces); + // Identical geometry, entity IDs and item IDs, but a replaced embedded payload. + let FeatureKind::Import { step: payload, .. } = &mut s.doc_mut().feature_mut(source).unwrap().kind else { panic!() }; + *payload = step.replace("certificate fixture", "replacement fixture"); + assert_ne!(*payload, step); + s.refresh(); + assert!(s.doc.face_colors(s.model.state().body("Imported").unwrap()).is_empty()); + assert_eq!(painted_radius(&s, "Imported (copy)", 10.), pieces); + let before = s.doc.clone(); + let history = s.undo.len(); + assert!(s.execute("config.preview", &json!({"row":"Painted"})).is_err()); + assert!(s.execute("config.activate", &json!({"row":"Painted"})).is_err()); + assert_eq!(s.doc, before); + assert_eq!(s.undo.len(), history); +} diff --git a/crates/engine/src/cmd/config_style_tests.rs b/crates/engine/src/cmd/config_style_tests.rs index 2580ea1d..d81c0591 100644 --- a/crates/engine/src/cmd/config_style_tests.rs +++ b/crates/engine/src/cmd/config_style_tests.rs @@ -133,6 +133,7 @@ fn strict_face_missing_lineage_never_retargets_and_rolls_back() { point: solvecraft_geom::Vec3::new(5., 5., 10.), name: Some(format!("F{other}:+z")), strict_name: true, + certificate: None, look, }); assert!(s.doc.face_colors(s.model.state().body(&body).unwrap()).is_empty()); @@ -175,7 +176,7 @@ fn extrude_cap_certification_follows_actual_positive_negative_and_symmetric_name for face in faces { let name = solvecraft_doc::naming::strip_piece(&names[face.index]); let origin = name.strip_prefix('F').unwrap().split_once(':').unwrap().0.parse().unwrap(); - let target = ConfigFaceTarget { body: "Pad".into(), origin_feature: origin, name: name.into(), point: face.centroid }; + let target = ConfigFaceTarget { body: "Pad".into(), origin_feature: origin, name: name.into(), point: face.centroid, certificate: None }; if name.ends_with(":start") || name.ends_with(":top") || name.ends_with(":bottom") { s.doc.certify_config_face(&target).unwrap(); s.doc.configuration_face_indices(body, &target).unwrap(); @@ -211,7 +212,13 @@ fn hostile_and_uncertified_face_targets_are_rejected() { let mut s = Session::default(); let body = block(&mut s); let id = s.doc.features[0].id; - let target = ConfigFaceTarget { body: body.clone(), origin_feature: id, name: format!("F{id}:face:0"), point: solvecraft_geom::Vec3::ZERO }; + let target = ConfigFaceTarget { + body: body.clone(), + origin_feature: id, + name: format!("F{id}:face:0"), + point: solvecraft_geom::Vec3::ZERO, + certificate: None, + }; assert!(s.doc.certify_config_face(&target).is_err()); for value in [ json!({"body":body,"origin_feature":id,"name":"x".repeat(1_000_000),"point":[0,0,0]}), diff --git a/crates/engine/src/cmd/contact_sweep.rs b/crates/engine/src/cmd/contact_sweep.rs index 45fc941c..21955181 100644 --- a/crates/engine/src/cmd/contact_sweep.rs +++ b/crates/engine/src/cmd/contact_sweep.rs @@ -65,7 +65,7 @@ impl ContactGeometry { if !sets.iter().any(|set| path.iter().any(|id| set.contains(id))) { continue; } - for body in state.bodies.iter().filter(|b| s.doc.body_component(&b.name, b.feature) == component) { + for body in state.bodies.iter().filter(|b| s.doc.model_body_component(b) == component) { if body.body.is_mesh() { return Err(limit("contact sweep does not support mesh bodies; disable their contact set to move them")); } @@ -113,7 +113,7 @@ impl ContactGeometry { }; out.bodies.push(CachedBody { path: path.clone(), - offset: s.doc.body_offsets.get(&body.name).copied().unwrap_or(IDENTITY), + offset: s.doc.model_body_offset(body).copied().unwrap_or(IDENTITY), mesh, size: body.body.size(), }); diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 89497f14..e19dc52b 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -47,9 +47,10 @@ pub static COMMANDS: &[CommandSpec] = &[ fn undo(s: &mut Session, _p: &Value) -> Result { let Some(prev) = s.undo.pop() else { return Err(EngineError::Other("nothing to undo".into())) }; let label = prev.label.clone(); - s.redo.push(Snapshot { label: label.clone(), doc: s.doc.clone(), active_sketch: s.active_sketch }); + s.redo.push(Snapshot { label: label.clone(), doc: s.doc.clone(), active_sketch: s.active_sketch, pending_moves: s.pending_moves.clone() }); s.doc = prev.doc; s.active_sketch = prev.active_sketch; + s.pending_moves = prev.pending_moves; s.selection.clear(); s.refresh(); Ok(json!({"undone": label})) @@ -58,9 +59,10 @@ fn undo(s: &mut Session, _p: &Value) -> Result { fn redo(s: &mut Session, _p: &Value) -> Result { let Some(next) = s.redo.pop() else { return Err(EngineError::Other("nothing to redo".into())) }; let label = next.label.clone(); - s.undo.push(Snapshot { label: label.clone(), doc: s.doc.clone(), active_sketch: s.active_sketch }); + s.undo.push(Snapshot { label: label.clone(), doc: s.doc.clone(), active_sketch: s.active_sketch, pending_moves: s.pending_moves.clone() }); s.doc = next.doc; s.active_sketch = next.active_sketch; + s.pending_moves = next.pending_moves; s.selection.clear(); s.refresh(); Ok(json!({"redone": label})) @@ -200,6 +202,10 @@ fn edit_feature(s: &mut Session, p: &Value) -> Result { if std::mem::discriminant(&nf.kind) != std::mem::discriminant(&f.kind) { return Err(bad(cmd, "the feature type cannot change")); } + if let FeatureKind::BodyTransfer { bodies, from, to, transform } = &nf.kind { + solvecraft_doc::validate_body_transfer(&s.doc, bodies, *from, *to, transform)?; + nf.component = *to; + } if let (FeatureKind::Sketch { sketch: a, .. }, FeatureKind::Sketch { sketch: b, .. }) = (&nf.kind, &f.kind) && a != b { @@ -360,7 +366,13 @@ fn redefine(s: &mut Session, p: &Value) -> Result { } trial.active_sketch = None; trial.selection = s.selection.clone(); - trial.doc_mut().marker = Some(idx); + // Keep the original identity as a suppressed placeholder. The constructor and + // any ownership boundary it adds go AFTER this slot, so discarding its temporary + // feature leaves auxiliary history after the rewritten original. + if let Some(slot) = trial.doc_mut().feature_mut(id) { + slot.suppressed = true; + } + trial.doc_mut().marker = Some(idx + 1); trial.refresh(); (spec.enabled)(&trial).map_err(|why| EngineError::Disabled(spec.id.to_string(), why))?; let r = (spec.run)(&mut trial, ¶ms)?; @@ -399,6 +411,7 @@ fn redefine(s: &mut Session, p: &Value) -> Result { trial.doc_mut().next_id = s.doc.next_id; } if let Some(slot) = trial.doc_mut().feature_mut(id) { + slot.suppressed = old.suppressed; slot.kind = new.kind; slot.edge_refs = new.edge_refs; slot.edge_names = new.edge_names; @@ -414,6 +427,40 @@ fn redefine(s: &mut Session, p: &Value) -> Result { if let Some(e) = trial.model.result(id).and_then(|r| r.error.clone()) { return Err(EngineError::Other(e)); } + let original_ids: std::collections::BTreeSet = s.doc.features.iter().map(|feature| feature.id).collect(); + let added_ids: std::collections::BTreeSet = + trial.doc.features.iter().filter(|feature| !original_ids.contains(&feature.id)).map(|feature| feature.id).collect(); + let adds_transfer = + trial.doc.features.iter().any(|feature| added_ids.contains(&feature.id) && matches!(feature.kind, FeatureKind::BodyTransfer { .. })); + let baseline = if adds_transfer && s.doc.marker.is_some() { + let mut baseline = s.scratch(); + baseline.doc_mut().marker = None; + baseline.refresh(); + Some(baseline) + } else { + None + }; + let proof = if (adds_transfer && trial.doc.marker.is_some()) || added_ids.iter().any(|id| trial.model.result(*id).is_none()) { + let mut proof = trial.scratch(); + proof.doc_mut().marker = None; + proof.refresh(); + Some(proof) + } else { + None + }; + let validated = proof.as_ref().map(|proof| &proof.model).unwrap_or(&trial.model); + let original_model = baseline.as_ref().map(|baseline| &baseline.model).unwrap_or(&s.model); + for feature in &trial.doc.features { + let auxiliary = added_ids.contains(&feature.id); + let protected_transfer = matches!(feature.kind, FeatureKind::BodyTransfer { .. }) + && original_model.result(feature.id).is_some_and(|result| !result.skipped && result.error.is_none()); + if (auxiliary || protected_transfer) + && let Some(error) = validated.result(feature.id).and_then(|result| result.error.as_ref()) + { + return Err(bad(cmd, format!("{}: {error}", feature.name))); + } + } + s.pending_moves = trial.pending_moves; s.doc = trial.doc; s.refresh(); if let Some(e) = s.model.result(id).and_then(|r| r.error.clone()) { diff --git a/crates/engine/src/cmd/edit_tests.rs b/crates/engine/src/cmd/edit_tests.rs index bb289609..1d1dd86e 100644 --- a/crates/engine/src/cmd/edit_tests.rs +++ b/crates/engine/src/cmd/edit_tests.rs @@ -170,11 +170,31 @@ fn redefine_keeps_allocator_above_component_and_occurrence_ids() { run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 10, "body_name": "Base"})); run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 10, "corner": [10, 0, 0], "body_name": "Tool"})); run(&mut s, "solid.combine", json!({"target": "Base", "tools": ["Tool"], "name": "Join"})); + let original = s.doc.clone(); + let original_feature = s.doc.find_feature("Join").unwrap().id; run( &mut s, "timeline.redefine", json!({"feature": "Join", "command": "solid.combine", "params": {"target": "Base", "tools": ["Tool"], "new_component": true, "component_name": "Merged"}}), ); + let destination = s.doc.find_component("Merged").unwrap(); + assert_eq!(s.model.state().body("Base").unwrap().component, destination); + assert_eq!(s.doc.find_feature("Join").unwrap().id, original_feature); + let combine_index = s.doc.feature_index(original_feature).unwrap(); + assert!( + matches!(&s.doc.features[combine_index + 1].kind, solvecraft_doc::FeatureKind::BodyTransfer { bodies, to, .. } if bodies == &["Base"] && *to == destination) + ); + assert!((solvecraft_kernel::measure(&s.world_state().body("Base").unwrap().body).unwrap().volume - 6000.0).abs() < 1e-6); + assert_eq!(s.world_state().bodies.len(), 1); + run(&mut s, "timeline.rollback", json!({"position":combine_index + 1})); + assert_eq!(s.world_state().body("Base").unwrap().component, 0, "the result transfers only AFTER Combine"); + run(&mut s, "edit.undo", json!({})); + run(&mut s, "edit.undo", json!({})); + assert_eq!(*s.doc, *original); + run(&mut s, "edit.redo", json!({})); + let reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); + assert_eq!(reopened.world_state().body("Base").unwrap().component, destination); + assert!((solvecraft_kernel::measure(&reopened.world_state().body("Base").unwrap().body).unwrap().volume - 6000.0).abs() < 1e-6); let max_id = s.doc.components.iter().map(|c| c.id).chain(s.doc.occurrences.iter().map(|o| o.id)).chain(s.doc.features.iter().map(|f| f.id)).max().unwrap(); assert!(s.doc.next_id > max_id); @@ -186,6 +206,30 @@ fn redefine_keeps_allocator_above_component_and_occurrence_ids() { assert_eq!(ids.len(), unique.len(), "feature, component and occurrence ids must remain unique: {ids:?}"); } +#[test] +fn redefine_cannot_publish_a_second_transfer_that_breaks_existing_ownership() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"body_name":"Base"})); + run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"corner":[1,0,0],"body_name":"Tool"})); + run(&mut s, "solid.combine", json!({"target":"Base","tools":["Tool"],"name":"Join","new_component":true,"component_name":"Original"})); + let full = s.doc.to_json(); + let join_index = s.doc.features.iter().position(|feature| feature.name == "Join").unwrap(); + for marker in [None, Some(join_index), Some(join_index + 1)] { + let mut s = Session::new(solvecraft_doc::Document::from_json(&full).unwrap()); + s.doc_mut().marker = marker; + s.refresh(); + let before = s.doc.to_json(); + let pending = s.pending_moves.clone(); + let error = s.execute("timeline.redefine", &json!({"feature":"Join","command":"solid.combine","params":{"target":"Base","tools":["Tool"],"new_component":true,"component_name":"Duplicate"}})).unwrap_err(); + assert!(error.to_string().contains("transfer source component"), "marker {marker:?}: {error}"); + assert_eq!(s.doc.to_json(), before); + assert_eq!(s.pending_moves, pending); + } + let reopened = Session::new(solvecraft_doc::Document::from_json(&full).unwrap()); + assert_eq!(reopened.world_state().body("Base").unwrap().component, reopened.doc.find_component("Original").unwrap()); + assert!(reopened.doc.features.iter().all(|feature| reopened.model.result(feature.id).is_some_and(|result| result.error.is_none()))); +} + #[test] fn redefine_maps_placement_inputs_in_the_feature_owner_frame() { let mut s = Session::default(); diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index 88082a8f..9a15b4f5 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -158,7 +158,7 @@ pub fn auto_operation(s: &Session, p: &Value) -> Option<&'static str> { let bodies: Vec<_> = st .bodies .iter() - .filter(|b| if parts.is_empty() { s.doc.body_component(&b.name, b.feature) == s.active_component } else { parts.contains(&b.name) }) + .filter(|b| if parts.is_empty() { s.doc.model_body_component(b) == s.active_component } else { parts.contains(&b.name) }) .collect(); if bodies.is_empty() { return Some("new"); @@ -324,7 +324,7 @@ pub(super) fn add_feature(s: &mut Session, p: &Value, kind: FeatureKind) -> Resu if let FeatureKind::Emboss { face: Some(face), .. } = &kind { let state = s.model.state(); let body = state.body(&face.body).ok_or_else(|| EngineError::Other("selected cylinder body no longer exists".into()))?; - let frame = face.body_frame(&s.doc, body.feature).map_err(|e| EngineError::Other(e.to_string()))?; + let frame = face.body_frame(&s.doc, body).map_err(|e| EngineError::Other(e.to_string()))?; let inverse = solvecraft_doc::mat_inverse(&frame).ok_or_else(|| EngineError::Other("invalid cylinder placement".into()))?; let at = solvecraft_doc::apply_point(&solvecraft_doc::mat_mul(&inverse, &s.active_component_frame()), face.at); let mut scoped = solvecraft_doc::ModelState::default(); @@ -727,7 +727,7 @@ fn combine(s: &mut Session, p: &Value) -> Result { if bool_(p, "new_component").unwrap_or(false) { let name = str_(p, "component_name").map(str::to_string).unwrap_or_else(|| format!("{target} Combined")); let c = s.execute("component.create", &json!({"name": name, "activate": false}))?; - s.execute("component.move_bodies", &json!({"bodies": [target], "component": c["component"]}))?; + super::component::transfer_constructor_result(s, &json!({"bodies": [target], "component": c["component"]}))?; out["component"] = c["component"].clone(); } Ok(out) diff --git a/crates/engine/src/cmd/features_more.rs b/crates/engine/src/cmd/features_more.rs index c7ebedc6..f512296c 100644 --- a/crates/engine/src/cmd/features_more.rs +++ b/crates/engine/src/cmd/features_more.rs @@ -29,7 +29,7 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("solid.emboss", "Emboss", emboss) .at("SOLID", "CREATE") .icon("emboss") - .params("sketch, profiles?; depth; mode?: emboss|deboss; targets?; face?: world point [x,y,z] selecting a planar or complete exterior cylindrical wall (required for wrapped orthogonal line profiles)"), + .params("sketch, profiles?; depth; mode?: emboss|deboss; targets?; face?: world point [x,y,z] selecting a planar or complete exterior cylindrical wall (inferred when exactly one supported tangent cylinder matches the selected profiles)"), CommandSpec::new("solid.replace_face", "Replace Face", replace_face) .at("SOLID", "MODIFY") .icon("offset_face") @@ -192,50 +192,120 @@ fn emboss(s: &mut Session, p: &Value) -> Result { // angular position, and certify their distance before storing an // exact surface anchor for later replay. let at = solvecraft_kernel::cylinder_emboss_anchor(body, index, at).map_err(|e| bad(cmd, e.to_string()))?; - let original = s.model.state(); - let owner = s.doc.body_component(name, selected.feature); - let paths: Vec<_> = s - .doc - .placements() - .into_iter() - .filter(|(comp, path, _)| *comp == owner && s.doc.world_body_name(&original, name, owner, path) == selected.name) - .map(|(_, path, _)| path) - .collect(); - if paths.len() != 1 { - return Err(bad(cmd, "selected cylinder placement is ambiguous")); - } - let path = paths.into_iter().next().ok_or_else(|| bad(cmd, "selected cylinder placement"))?; - if path.len() > 64 { - return Err(bad(cmd, "selected cylinder placement is too deep")); - } - let author_path = s.active_occurrence_path.clone().unwrap_or_else(|| s.doc.first_path(s.active_component)); - if author_path.len() > 64 { - return Err(bad(cmd, "author placement is too deep")); - } - let sketch_owner = s.doc.feature(sketch).map(|f| f.component).ok_or_else(|| bad(cmd, "profile sketch no longer exists"))?; - let sketch_path = if sketch_owner == owner { - path.clone() - } else if sketch_owner == s.active_component { - author_path.clone() - } else { - s.doc.first_path(sketch_owner) - }; - if path.iter().chain(&author_path).chain(&sketch_path).any(|id| s.pending_moves.contains_key(id)) { - return Err(bad(cmd, "capture pending source, author and profile positions with Capture Position before cylinder emboss")); - } - let inverse = solvecraft_doc::mat_inverse(&s.active_component_frame()).ok_or_else(|| bad(cmd, "invalid active frame"))?; - let at = solvecraft_doc::apply_point(&inverse, at); + let face = emboss_selection(s, selected, at)?; + check_emboss_positions(s, sketch, selected, &face)?; targets = vec![name.to_owned()]; - Some(solvecraft_doc::EmbossFace { body: name.to_owned(), at, path, author_path }) + Some(face) } _ => return Err(bad(cmd, "only planar faces and complete exterior cylindrical walls support emboss")), } } else { - None + let inferred = infer_emboss_selection(s, sketch, &profiles, &depth, deboss, &targets)?; + if let Some(face) = &inferred { + targets = vec![face.body.clone()]; + } + inferred }; add_feature(s, p, FeatureKind::Emboss { sketch, profiles, depth, deboss, targets, face }) } +fn emboss_selection(s: &Session, selected: &solvecraft_doc::ModelBody, at: Vec3) -> Result { + let cmd = "solid.emboss"; + let name = selected.definition_name(); + let original = s.model.state(); + let owner = s.doc.model_body_component(selected); + let paths: Vec<_> = s + .doc + .placements() + .into_iter() + .filter(|(comp, path, _)| *comp == owner && s.doc.world_body_name(&original, name, owner, path) == selected.name) + .map(|(_, path, _)| path) + .collect(); + if paths.len() != 1 { + return Err(bad(cmd, "selected cylinder placement is ambiguous")); + } + let path = paths.into_iter().next().ok_or_else(|| bad(cmd, "selected cylinder placement"))?; + let author_path = s.active_occurrence_path.clone().unwrap_or_else(|| s.doc.first_path(s.active_component)); + if path.len() > 64 || author_path.len() > 64 { + return Err(bad(cmd, "cylinder placement is too deep")); + } + let inverse = solvecraft_doc::mat_inverse(&s.active_component_frame()).ok_or_else(|| bad(cmd, "invalid active frame"))?; + Ok(solvecraft_doc::EmbossFace { body: name.to_owned(), at: solvecraft_doc::apply_point(&inverse, at), path, author_path }) +} + +fn check_emboss_positions(s: &Session, sketch: u64, body: &solvecraft_doc::ModelBody, face: &solvecraft_doc::EmbossFace) -> Result<()> { + let cmd = "solid.emboss"; + let owner = s.doc.model_body_component(body); + let sketch_owner = s.doc.feature(sketch).map(|f| f.component).ok_or_else(|| bad(cmd, "profile sketch no longer exists"))?; + let sketch_path = if sketch_owner == owner { + face.path.clone() + } else if sketch_owner == s.active_component { + face.author_path.clone() + } else { + s.doc.first_path(sketch_owner) + }; + if face.path.iter().chain(&face.author_path).chain(&sketch_path).any(|id| s.pending_moves.contains_key(id)) { + return Err(bad(cmd, "capture pending source, author and profile positions with Capture Position before cylinder emboss")); + } + Ok(()) +} + +fn infer_emboss_selection( + s: &Session, + sketch: u64, + profiles: &solvecraft_doc::ProfileSel, + depth: &str, + deboss: bool, + targets: &[String], +) -> Result> { + let cmd = "solid.emboss"; + let original = s.model.state(); + let ss = original.sketch(sketch).ok_or_else(|| bad(cmd, "the sketch is not evaluated"))?; + let regions = solvecraft_doc::select_profiles(ss, profiles).map_err(|e| bad(cmd, e.to_string()))?; + let sketch_owner = s.doc.feature(sketch).map(|f| f.component).ok_or_else(|| bad(cmd, "profile sketch no longer exists"))?; + let d = s.doc.eval(&s.doc.with_design_unit(depth, Kind::Length), Kind::Length).map_err(|e| bad(cmd, e.to_string()))?; + let world = s.world_state(); + let placed = s.placed_document(); + let mut chosen = None; + let mut faces = 0usize; + for selected in &world.bodies { + let name = selected.definition_name(); + if !targets.is_empty() && !targets.iter().any(|n| n == name || n == &selected.name) { + continue; + } + let raw = original.body(name).ok_or_else(|| bad(cmd, "target definition no longer exists"))?; + faces = faces.saturating_add(raw.body.face_count()); + if faces > 50_000 { + return Err(bad(cmd, "cylinder target search exceeds face work limit")); + } + if !solvecraft_kernel::has_cylinder_emboss_wall(&raw.body).map_err(|e| bad(cmd, e.to_string()))? { + continue; + } + let mut face = emboss_selection(s, selected, Vec3::ZERO)?; + let body_frame = face.body_frame(&placed, raw).map_err(|e| bad(cmd, e.to_string()))?; + let inverse = solvecraft_doc::mat_inverse(&body_frame).ok_or_else(|| bad(cmd, "invalid cylinder body frame"))?; + let displayed = world.sketch(sketch).ok_or_else(|| bad(cmd, "the profile sketch is not displayed"))?; + let plane = solvecraft_geom::Plane::new( + solvecraft_doc::apply_point(&inverse, displayed.plane.origin), + solvecraft_doc::apply_vector(&inverse, displayed.plane.x), + solvecraft_doc::apply_vector(&inverse, displayed.plane.y), + ) + .ok_or_else(|| bad(cmd, "invalid cylinder sketch frame"))?; + if let Some((_, at)) = + solvecraft_kernel::infer_cylinder_emboss(&raw.body, &plane, ®ions, if deboss { -d } else { d }).map_err(|e| bad(cmd, e.to_string()))? + { + face.sketch_frame(&placed, s.active_component, sketch_owner, raw).map_err(|e| bad(cmd, e.to_string()))?; + check_emboss_positions(s, sketch, selected, &face)?; + let author_inverse = solvecraft_doc::mat_inverse(&s.active_component_frame()).ok_or_else(|| bad(cmd, "invalid author frame"))?; + face.at = solvecraft_doc::apply_point(&solvecraft_doc::mat_mul(&author_inverse, &body_frame), at); + if chosen.is_some() { + return Err(bad(cmd, "multiple tangent cylinders match the selected profiles; select a target face or body")); + } + chosen = Some(face); + } + } + Ok(chosen) +} fn face_points(p: &Value, k: &str, cmd: &str) -> Result> { let a = p.get(k).and_then(Value::as_array).ok_or_else(|| bad(cmd, format!("`{k}` must list points [x, y, z]")))?; if a.is_empty() || a.len() > 1000 { diff --git a/crates/engine/src/cmd/features_more_tests.rs b/crates/engine/src/cmd/features_more_tests.rs index c4b9bb4c..9fc84134 100644 --- a/crates/engine/src/cmd/features_more_tests.rs +++ b/crates/engine/src/cmd/features_more_tests.rs @@ -9,6 +9,17 @@ fn run(s: &mut Session, id: &str, p: Value) -> Value { } } +fn auto_emboss_params(mut p: Value, auto: bool) -> Value { + if auto { + if let Some(params) = p.get_mut("params").and_then(Value::as_object_mut) { + params.remove("face"); + } else if let Some(object) = p.as_object_mut() { + object.remove("face"); + } + } + p +} + fn measure(s: &mut Session) -> Value { run(s, "inspect.measure", json!({})) } @@ -169,7 +180,8 @@ fn cylinder_small_chord_pick_is_saved_on_the_exact_surface() { #[test] fn cylinder_cross_component_emboss_keeps_source_owner_and_placements() { - for pick_offset in [0., 80.] { + for (pick_offset, auto) in [(0., false), (80., false), (0., true), (80., true)] { + let run = |s: &mut Session, id: &str, p: Value| run(s, id, auto_emboss_params(p, auto && matches!(id, "solid.emboss" | "timeline.redefine"))); let mut s = Session::default(); let a = run(&mut s, "component.create", json!({"name":"Source"}))["component"].as_u64().unwrap(); run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"})); @@ -255,6 +267,11 @@ fn cylinder_cross_component_emboss_keeps_source_owner_and_placements() { #[test] fn cylinder_active_second_source_occurrence_authors_shared_geometry() { + check_active_second_source(false); + check_active_second_source(true); +} +fn check_active_second_source(auto: bool) { + let run = |s: &mut Session, id: &str, p: Value| run(s, id, auto_emboss_params(p, auto && id == "solid.emboss")); let mut s = Session::default(); let component = run(&mut s, "component.create", json!({"name":"Source"}))["component"].as_u64().unwrap(); run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"})); @@ -279,7 +296,7 @@ fn cylinder_active_second_source_occurrence_authors_shared_geometry() { #[test] fn cylinder_pending_leaf_or_ancestor_positions_require_capture_atomically() { - for ancestor in [false, true] { + for (ancestor, auto) in [(false, false), (true, false), (false, true), (true, true)] { let mut s = Session::default(); let parent = run(&mut s, "component.create", json!({"name":"Parent"}))["occurrence"].as_u64().unwrap(); let leaf = run(&mut s, "component.create", json!({"name":"Source"}))["occurrence"].as_u64().unwrap(); @@ -291,7 +308,10 @@ fn cylinder_pending_leaf_or_ancestor_positions_require_capture_atomically() { let before = s.doc.to_json(); let pending = s.pending_moves.clone(); let volume = exact_cylinder_volume(&s); - let error = s.execute("solid.emboss", &json!({"sketch":"Badge","profiles":"all","face":[5,10,7.5],"depth":1})).unwrap_err().to_string(); + let error = s + .execute("solid.emboss", &auto_emboss_params(json!({"sketch":"Badge","profiles":"all","face":[5,10,7.5],"depth":1}), auto)) + .unwrap_err() + .to_string(); assert!(error.contains("Capture Position"), "{error}"); assert_eq!(s.doc.to_json(), before); assert_eq!(s.pending_moves, pending); @@ -301,6 +321,10 @@ fn cylinder_pending_leaf_or_ancestor_positions_require_capture_atomically() { #[test] fn cylinder_pending_third_component_profile_requires_capture_atomically() { + check_pending_third_profile(false); + check_pending_third_profile(true); +} +fn check_pending_third_profile(auto: bool) { let mut s = Session::default(); run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"})); let profile_occurrence = run(&mut s, "component.create", json!({"name":"ProfileOwner"}))["occurrence"].as_u64().unwrap(); @@ -314,7 +338,10 @@ fn cylinder_pending_third_component_profile_requires_capture_atomically() { let pending = s.pending_moves.clone(); let volume = exact_cylinder_volume(&s); let error = s - .execute("solid.emboss", &json!({"sketch":"Badge","profiles":"all","face":[0,10,7.5],"depth":1,"targets":["Sleeve"]})) + .execute( + "solid.emboss", + &auto_emboss_params(json!({"sketch":"Badge","profiles":"all","face":[0,10,7.5],"depth":1,"targets":["Sleeve"]}), auto), + ) .unwrap_err() .to_string(); assert!(error.contains("Capture Position"), "{error}"); @@ -325,12 +352,21 @@ fn cylinder_pending_third_component_profile_requires_capture_atomically() { #[test] fn cylinder_body_offset_keeps_exact_profile_and_anchor_frames() { + check_body_offset(false); + check_body_offset(true); +} +fn check_body_offset(auto: bool) { + let run = |s: &mut Session, id: &str, p: Value| run(s, id, auto_emboss_params(p, auto && id == "solid.emboss")); let mut s = Session::default(); run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"})); let before = exact_cylinder_volume(&s); let holder = run(&mut s, "component.create", json!({"name":"Holder"}))["component"].as_u64().unwrap(); run(&mut s, "occurrence.move", json!({"occurrence":"Holder:1","translate":[0,50,0]})); - run(&mut s, "component.move_bodies", json!({"bodies":["Sleeve"],"component":holder})); + std::sync::Arc::make_mut(&mut s.doc).body_components.insert("Sleeve".into(), holder); + std::sync::Arc::make_mut(&mut s.doc) + .body_offsets + .insert("Sleeve".into(), [[1., 0., 0., 0.], [0., 1., 0., 0.], [0., 0., 1., 0.], [0., -50., 0., 1.]]); + s.refresh(); assert!(s.doc.body_offsets.contains_key("Sleeve")); let offset = s.doc.body_offsets["Sleeve"]; run(&mut s, "sketch.create", json!({"plane":{"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Badge"})); @@ -399,3 +435,122 @@ fn coil_and_loft_to_a_point() { assert!(rel(volume(&mut s), 400.0 * 30.0 / 3.0) < 1e-6); assert!(s.execute("solid.loft", &json!({"sections": [{"sketch": "Base"}, {"sketch": "Top", "point": "nope"}]})).is_err()); } + +#[test] +fn cylinder_auto_inference_saves_a_profile_anchor_and_replays_both_depth_signs() { + use solvecraft_geom::Vec3; + for mode in ["emboss", "deboss"] { + let mut s = cylinder_badge(); + let before = exact_cylinder_volume(&s); + let initial = s.doc.to_json(); + run(&mut s, "solid.emboss", json!({"sketch":"Badge","profiles":"all","depth":"badge_depth","mode":mode,"name":"Auto Badge"})); + let feature = s.doc.find_feature("Auto Badge").unwrap().clone(); + let solvecraft_doc::FeatureKind::Emboss { face: Some(face), targets, .. } = &feature.kind else { + panic!("missing inferred association"); + }; + assert_eq!(targets, &["Sleeve"]); + assert_eq!(face.body, "Sleeve"); + assert!(face.at.z > 0. && face.at.z < 20.); + assert!((face.at.x.hypot(face.at.y) - 10.).abs() < 1e-10); + assert!(!feature.face_names.first().unwrap().is_empty()); + let expected = if mode == "emboss" { 21. } else { -19. }; + assert!((exact_cylinder_volume(&s) - before - expected).abs() < 0.02); + let state = s.model.state(); + let body = &state.body("Sleeve").unwrap().body; + assert_eq!(body.shell_count(), 1); + assert_eq!( + body.tessellate_with(0.001, true).unwrap().contains(Vec3::new(0., if mode == "emboss" { 10.5 } else { 9.5 }, 7.5)), + mode == "emboss" + ); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.to_json(), initial); + run(&mut s, "edit.redo", json!({})); + assert_eq!(s.doc.find_feature("Auto Badge").unwrap().kind, feature.kind); + let mut loaded = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Default::default() }; + loaded.refresh(); + assert!(loaded.model.results.iter().all(|r| r.error.is_none()), "{:?}", loaded.model.results); + run( + &mut loaded, + "timeline.redefine", + json!({"feature":feature.id,"command":"solid.emboss","params":{"sketch":"Badge","profiles":"all","depth":"badge_depth","mode":mode}}), + ); + assert_eq!(loaded.doc.feature(feature.id).unwrap().kind, feature.kind); + assert_eq!(loaded.doc.feature(feature.id).unwrap().face_names, feature.face_names); + run(&mut loaded, "parameters.change", json!({"name":"badge_depth","expression":"2mm"})); + assert!((exact_cylinder_volume(&loaded) - before - if mode == "emboss" { 44. } else { -36. }).abs() < 0.02); + } +} + +#[test] +fn cylinder_auto_inference_ignores_remote_finite_tangent_walls_in_each_body_order() { + for remote_first in [false, true] { + let mut s = Session::default(); + for (name, z) in if remote_first { [("Remote", 100.), ("Sleeve", 0.)] } else { [("Sleeve", 0.), ("Remote", 100.)] } { + run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"base":[0,0,z],"body_name":name})); + } + run(&mut s, "solid.cylinder", json!({"radius":8,"height":10,"base":[0,0,120],"body_name":"Remote Step"})); + run(&mut s, "solid.combine", json!({"target":"Remote","tools":["Remote Step"],"operation":"join"})); + run(&mut s, "sketch.create", json!({"plane":{"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Badge"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]})); + run(&mut s, "sketch.finish", json!({})); + let before = exact_cylinder_volume(&s); + let remote = s.model.state().body("Remote").unwrap().body.tessellate_with(1e-4, true).unwrap().measure().volume; + run(&mut s, "solid.emboss", json!({"sketch":"Badge","depth":1})); + assert!((exact_cylinder_volume(&s) - before - 21.).abs() < 0.02); + assert_eq!(s.model.state().body("Remote").unwrap().body.tessellate_with(1e-4, true).unwrap().measure().volume, remote); + } +} + +#[test] +fn cylinder_auto_inference_retains_selected_holes_and_nested_islands() { + use solvecraft_geom::Vec3; + for mode in ["emboss", "deboss"] { + let mut s = cylinder_badge(); + run(&mut s, "sketch.edit", json!({"sketch":"Badge"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-1,6],"p1":[1,9]})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-0.5,6.5],"p1":[0.5,8.5]})); + run(&mut s, "sketch.finish", json!({})); + let before = exact_cylinder_volume(&s); + run(&mut s, "solid.emboss", json!({"sketch":"Badge","profiles":[{"point":[1.5,7.5]},{"point":[0,7.5]}],"depth":1,"mode":mode})); + let depth = if mode == "emboss" { 1. } else { -1. }; + let expected = 16. * (depth + depth * depth / 20.); + assert!((exact_cylinder_volume(&s) - before - expected).abs() < 0.02); + let st = s.model.state(); + let body = &st.body("Sleeve").unwrap().body; + assert_eq!(body.shell_count(), 1); + let mesh = body.tessellate_with(0.001, true).unwrap(); + for (s, raised) in [(0., true), (0.75, false), (1.5, true)] { + let theta = s / 10.0_f64; + let radius = if mode == "emboss" { 10.5 } else { 9.5 }; + assert_eq!(mesh.contains(Vec3::new(-radius * theta.sin(), radius * theta.cos(), 7.5)), if mode == "emboss" { raised } else { !raised }); + } + } +} + +#[test] +fn cylinder_auto_inference_refuses_ambiguity_curves_and_seam_crossing_atomically() { + let mut s = cylinder_badge(); + run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"body_name":"Coincident"})); + let before = s.doc.to_json(); + let error = s.execute("solid.emboss", &json!({"sketch":"Badge","depth":1})).unwrap_err().to_string(); + assert!(error.contains("multiple tangent cylinders"), "{error}"); + assert_eq!(s.doc.to_json(), before); + run(&mut s, "solid.emboss", json!({"sketch":"Badge","depth":1,"targets":["Sleeve"]})); + assert!(matches!(&s.doc.features.last().unwrap().kind,solvecraft_doc::FeatureKind::Emboss{face:Some(face),..} if face.body=="Sleeve")); + for circle in [false, true] { + let mut s = cylinder_badge(); + run(&mut s, "sketch.create", json!({"plane":{"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Unsupported"})); + if circle { + run(&mut s, "sketch.circle.center", json!({"center":[0,7.5],"radius":1})); + } else { + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-20,5],"p1":[20,10]})); + } + run(&mut s, "sketch.finish", json!({})); + let before = s.doc.to_json(); + let volume = exact_cylinder_volume(&s); + let error = s.execute("solid.emboss", &json!({"sketch":"Unsupported","depth":1})).unwrap_err().to_string(); + assert!(error.contains(if circle { "orthogonal line" } else { "seam" }), "{error}"); + assert_eq!(s.doc.to_json(), before); + assert_eq!(exact_cylinder_volume(&s), volume); + } +} diff --git a/crates/engine/src/cmd/inspect.rs b/crates/engine/src/cmd/inspect.rs index 23d46a18..ca4250b4 100644 --- a/crates/engine/src/cmd/inspect.rs +++ b/crates/engine/src/cmd/inspect.rs @@ -401,7 +401,7 @@ fn appearance(s: &mut Session, p: &Value) -> Result { return Err(bad(cmd, "too many face appearances")); } let name = st_names.iter().find(|(b, p, _)| b == body && p.dist(*pt) < 1e-12).and_then(|(_, _, n)| n.clone()); - a.faces.push(FaceLook { body: body.clone(), point: *pt, look: l.clone(), name, strict_name: false }); + a.faces.push(FaceLook { body: body.clone(), point: *pt, look: l.clone(), name, strict_name: false, certificate: None }); } } for c in &comps { diff --git a/crates/engine/src/cmd/joints.rs b/crates/engine/src/cmd/joints.rs index e51d28e8..6a857611 100644 --- a/crates/engine/src/cmd/joints.rs +++ b/crates/engine/src/cmd/joints.rs @@ -85,7 +85,7 @@ pub(crate) fn occurrence_at(s: &Session, p: Vec3) -> Option { let st = s.model.state(); let mut best: Option<(f64, u64)> = None; for b in &st.bodies { - let comp = s.doc.body_component(&b.name, b.feature); + let comp = s.doc.model_body_component(b); let occs: Vec = if comp == 0 { vec![0] } else { s.doc.occurrences.iter().filter(|o| o.component == comp).map(|o| o.id).collect() }; for occ in occs { let t = if occ == 0 { solvecraft_doc::IDENTITY } else { joints::occurrence_world(&s.doc, occ) }; diff --git a/crates/engine/src/cmd/motion.rs b/crates/engine/src/cmd/motion.rs index 42bb6b66..33b6fed5 100644 --- a/crates/engine/src/cmd/motion.rs +++ b/crates/engine/src/cmd/motion.rs @@ -89,11 +89,11 @@ fn placed(s: &Session) -> Vec<(Vec, Body)> { if comp == 0 { continue; } - for b in st.bodies.iter().filter(|b| s.doc.body_component(&b.name, b.feature) == comp && !b.body.is_mesh()) { + for b in st.bodies.iter().filter(|b| s.doc.model_body_component(b) == comp && !b.body.is_mesh()) { // Moving a body into another component preserves its world placement using an // offset from its authoring frame. Contact and explode centres must use that // same offset as display and physical properties. - let frame = s.doc.body_offsets.get(&b.name).map(|offset| solvecraft_doc::mat_mul(&m, offset)).unwrap_or(m); + let frame = s.doc.model_body_offset(b).map(|offset| solvecraft_doc::mat_mul(&m, offset)).unwrap_or(m); if let Ok(t) = solvecraft_kernel::transform_matrix(&b.body, frame) { out.push((path.clone(), t)); } diff --git a/crates/engine/src/cmd/occurrence_tests.rs b/crates/engine/src/cmd/occurrence_tests.rs index 7a6f0905..515c9cb9 100644 --- a/crates/engine/src/cmd/occurrence_tests.rs +++ b/crates/engine/src/cmd/occurrence_tests.rs @@ -305,7 +305,12 @@ fn hostile_moves_copies_and_reused_ids_do_not_leak_state() { run(&mut s, "component.capture_position", json!({})); assert!(s.pending_moves.is_empty()); run(&mut s, "edit.undo", json!({})); - assert_eq!(s.occurrence_placement(&[other]).unwrap().world[3][0], 0.0); + assert_eq!(s.pending_moves.get(&other).unwrap()[3][0], 20.0); + assert_eq!(s.doc.occurrences.iter().find(|occurrence| occurrence.id == other).unwrap().transform[3][0], 0.0); + assert_eq!(s.occurrence_placement(&[other]).unwrap().world[3][0], 20.0); + run(&mut s, "edit.redo", json!({})); + assert!(s.pending_moves.is_empty()); + assert_eq!(s.doc.occurrences.iter().find(|occurrence| occurrence.id == other).unwrap().transform[3][0], 20.0); } #[test] diff --git a/crates/engine/src/cmd/parts.rs b/crates/engine/src/cmd/parts.rs index 0a5d4401..92cc7529 100644 --- a/crates/engine/src/cmd/parts.rs +++ b/crates/engine/src/cmd/parts.rs @@ -464,7 +464,7 @@ mod tests { let st = s.world_state(); let comp = s.doc.components.iter().find(|c| c.name.starts_with(prefix)).map(|c| c.id).unwrap_or(0); let (mut lo, mut hi) = (solvecraft_geom::Vec3::new(f64::MAX, f64::MAX, f64::MAX), solvecraft_geom::Vec3::new(f64::MIN, f64::MIN, f64::MIN)); - for b in st.bodies.iter().filter(|b| s.doc.body_component(&b.name, b.feature) == comp) { + for b in st.bodies.iter().filter(|b| s.doc.model_body_component(b) == comp) { let m = b.mesh().bounds(); lo = solvecraft_geom::Vec3::new(lo.x.min(m.min.x), lo.y.min(m.min.y), lo.z.min(m.min.z)); hi = solvecraft_geom::Vec3::new(hi.x.max(m.max.x), hi.y.max(m.max.y), hi.z.max(m.max.z)); diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index d04d2fe0..443a0a51 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -293,8 +293,24 @@ fn flat_json(sh: &SheetBody) -> Result { "allowance": b.allowance, "length": b.length, "start": pt(&b.start), "end": pt(&b.end)}) }) .collect(); + let pockets: Vec = sh + .machining()? + .iter() + .map(|p| { + let side = if p.z0 == 0.0 { + "bottom" + } else if p.z1 == sh.t { + "top" + } else { + "internal" + }; + json!({"outline": p.region.outer.polyline(1e-3).iter().map(pt).collect::>(), + "cutouts": p.region.holes.iter().map(|h| h.polyline(1e-3).iter().map(pt).collect::>()).collect::>(), + "side": side, "depth_mm": p.z1-p.z0, "z0_mm": p.z0, "z1_mm": p.z1}) + }) + .collect(); Ok( - json!({"body": sh.body, "rule": sh.rule, "thickness": sh.t, "k_factor": sh.k, "flat_size_mm": [w, h, sh.t], "outline": outline, "cutouts": holes, "bends": bends, "unfolded": sh.flat}), + json!({"body": sh.body, "rule": sh.rule, "thickness": sh.t, "k_factor": sh.k, "flat_size_mm": [w, h, sh.t], "outline": outline, "cutouts": holes, "pockets": pockets, "bends": bends, "unfolded": sh.flat}), ) } @@ -305,6 +321,22 @@ fn flat_pattern(s: &mut Session, p: &Value) -> Result { /// A DXF (R12 entities, mm) of the flat pattern on named layers. pub fn flat_dxf(sh: &SheetBody) -> Result { + let regions = sh.flat_regions()?; + let pockets = sh.machining()?; + let pocket_layer = |p: &solvecraft_doc::sheet::SheetPocket| { + let side = if p.z0 == 0.0 { + "BOTTOM" + } else if p.z1 == sh.t { + "TOP" + } else { + "INTERNAL" + }; + format!("POCKET_{side}_Z{:.6}_TO_{:.6}_MM", p.z0, p.z1) + }; + let mut layers = std::collections::BTreeSet::from(["OUTLINE".to_string(), "BEND_UP".to_string(), "BEND_DOWN".to_string()]); + for p in &pockets { + layers.insert(pocket_layer(p)); + } let mut out = String::new(); let mut w = |c: i32, v: &str| out.push_str(&format!("{c}\n{v}\n")); w(0, "SECTION"); @@ -316,8 +348,14 @@ pub fn flat_dxf(sh: &SheetBody) -> Result { w(2, "TABLES"); w(0, "TABLE"); w(2, "LAYER"); - w(70, "3"); - for (name, colour) in [("OUTLINE", "7"), ("BEND_UP", "1"), ("BEND_DOWN", "5")] { + w(70, &layers.len().to_string()); + for name in &layers { + let colour = match name.as_str() { + "OUTLINE" => "7", + "BEND_UP" => "1", + "BEND_DOWN" => "5", + _ => "3", + }; w(0, "LAYER"); w(2, name); w(70, "0"); @@ -337,8 +375,13 @@ pub fn flat_dxf(sh: &SheetBody) -> Result { w(11, &f(b.x)); w(21, &f(b.y)); }; - let regions = sh.flat_regions()?; - for h in regions.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)) { + let mut loops: Vec<_> = regions.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)).map(|lp| (lp, "OUTLINE".to_string())).collect(); + for p in &pockets { + for lp in std::iter::once(&p.region.outer).chain(&p.region.holes) { + loops.push((lp, pocket_layer(p))); + } + } + for (h, layer) in loops { // A circle is often represented by two semicircles. Keep the whole analytic // boundary as one CIRCLE rather than approximating outer boundaries with chords. if let Some(solvecraft_geom::Seg2::Arc { center, radius, sweep, .. }) = h.segs.first() { @@ -346,7 +389,7 @@ pub fn flat_dxf(sh: &SheetBody) -> Result { let circular = h.segs.iter().all(|s| matches!(s, solvecraft_geom::Seg2::Arc { center: c, radius: r, sweep: a, .. } if c.dist(*center) < 1e-9 && (*r-*radius).abs() < 1e-9 && a.signum() == sweep.signum())); if circular && (total.abs() - std::f64::consts::TAU).abs() < 1e-9 { w(0, "CIRCLE"); - w(8, "OUTLINE"); + w(8, &layer); w(10, &f(center.x)); w(20, &f(center.y)); w(40, &f(*radius)); @@ -358,7 +401,7 @@ pub fn flat_dxf(sh: &SheetBody) -> Result { solvecraft_geom::Seg2::Arc { center, radius, start, sweep } => { if (sweep.abs() - std::f64::consts::TAU).abs() < 1e-9 { w(0, "CIRCLE"); - w(8, "OUTLINE"); + w(8, &layer); w(10, &f(center.x)); w(20, &f(center.y)); w(40, &f(radius)); @@ -366,7 +409,7 @@ pub fn flat_dxf(sh: &SheetBody) -> Result { } let (a0, a1) = if sweep >= 0.0 { (start, start + sweep) } else { (start + sweep, start) }; w(0, "ARC"); - w(8, "OUTLINE"); + w(8, &layer); w(10, &f(center.x)); w(20, &f(center.y)); w(40, &f(radius)); @@ -376,7 +419,7 @@ pub fn flat_dxf(sh: &SheetBody) -> Result { other => { let pts = other.polyline(1e-3); for p in pts.windows(2) { - line(&mut w, "OUTLINE", p[0], p[1]); + line(&mut w, &layer, p[0], p[1]); } } } diff --git a/crates/engine/src/cmd/sheet_tests.rs b/crates/engine/src/cmd/sheet_tests.rs index 524eff6d..0c5fd39e 100644 --- a/crates/engine/src/cmd/sheet_tests.rs +++ b/crates/engine/src/cmd/sheet_tests.rs @@ -31,6 +31,363 @@ fn base(s: &mut Session, w: f64, h: f64) { run(s, "sheet.flange", json!({"sketch": "Base"})); } +fn rectangle_tool(s: &mut Session, name: &str, p0: [f64; 2], p1: [f64; 2]) { + run(s, "sketch.create", json!({"plane":"XY","name":name})); + run(s, "sketch.rectangle.two_point", json!({"p0":p0,"p1":p1})); + run(s, "sketch.finish", json!({})); +} + +#[test] +fn shallow_sheet_pocket_preserves_material_depth_export_undo_and_reopen() { + let mut s = Session::default(); + base(&mut s, 100.0, 60.0); + rectangle_tool(&mut s, "Pocket", [20.0, 20.0], [30.0, 30.0]); + run(&mut s, "solid.extrude", json!({"sketch":"Pocket","distance":1,"operation":"cut"})); + assert!((volume(&mut s) - 14900.0).abs() < 1e-6); + let f = flat(&mut s); + assert_eq!(f["cutouts"], json!([])); + assert_eq!(f["pockets"][0]["side"], "bottom"); + assert_eq!(f["pockets"][0]["depth_mm"], 1.0); + let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap().to_string(); + assert!(dxf.contains("POCKET_BOTTOM_Z0.000000_TO_1.000000_MM")); + assert_eq!(solvecraft_io::read_dxf(&dxf).unwrap().len(), 8); + run(&mut s, "edit.undo", json!({})); + assert!((volume(&mut s) - 15000.0).abs() < 1e-6); + run(&mut s, "edit.redo", json!({})); + for command in ["sheet.unfold", "sheet.refold"] { + run(&mut s, command, json!({})); + assert!((volume(&mut s) - 14900.0).abs() < 1e-6); + let state = s.model.state(); + let body = &state.bodies[0].body; + assert!(!body.contains(solvecraft_geom::Vec3::new(25.0, 25.0, 0.5))); + assert!(body.contains(solvecraft_geom::Vec3::new(25.0, 25.0, 1.5))); + } + let mut reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); + assert!((volume(&mut reopened) - 14900.0).abs() < 1e-6); + assert_eq!(flat(&mut reopened)["pockets"], f["pockets"]); +} + +#[test] +fn planar_sheet_intersection_retains_exact_outline_and_islands() { + let mut s = Session::default(); + base(&mut s, 100.0, 60.0); + rectangle_tool(&mut s, "Retain", [20.0, 20.0], [30.0, 30.0]); + run(&mut s, "solid.extrude", json!({"sketch":"Retain","through_all":true,"direction":"symmetric","operation":"intersect"})); + for command in [None, Some("sheet.unfold"), Some("sheet.refold")] { + if let Some(command) = command { + run(&mut s, command, json!({})); + } + assert!((volume(&mut s) - 250.0).abs() < 1e-6); + assert_eq!(flat(&mut s)["flat_size_mm"], json!([10.0, 10.0, 2.5])); + } + let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap().to_string(); + assert_eq!(solvecraft_io::read_dxf(&dxf).unwrap().len(), 4); + let mut imported = Session::default(); + run(&mut imported, "sketch.insert_dxf", json!({"name":"Retained","text":dxf})); + run(&mut imported, "sketch.finish", json!({})); + run(&mut imported, "solid.extrude", json!({"sketch":"Retained","distance":2.5})); + assert!((volume(&mut imported) - 250.0).abs() < 1e-6); + // An annular retained region keeps its central hole, with no first-solid shortcut. + let mut annulus = Session::default(); + base(&mut annulus, 100.0, 60.0); + run(&mut annulus, "sketch.create", json!({"plane":"XY","name":"Ring"})); + run(&mut annulus, "sketch.circle.center", json!({"center":[50,30],"radius":10})); + run(&mut annulus, "sketch.circle.center", json!({"center":[50,30],"radius":5})); + run(&mut annulus, "sketch.finish", json!({})); + run( + &mut annulus, + "solid.extrude", + json!({"sketch":"Ring","profiles":[{"point":[57,30]}],"through_all":true,"direction":"symmetric","operation":"intersect"}), + ); + run(&mut annulus, "sheet.unfold", json!({})); + let area: f64 = annulus.model.state().sheets[0].flat_regions().unwrap().iter().map(|r| r.area()).sum(); + assert!((area - PI * 75.0).abs() < 1e-8, "exact retained area: {area}"); + // inspect.measure integrates a tessellation; exact arc area is checked above. + let measured = volume(&mut annulus); + assert!((measured - PI * 75.0 * 2.5).abs() < 0.1, "measured={measured}"); + assert_eq!(flat(&mut annulus)["cutouts"].as_array().unwrap().len(), 1); +} + +#[test] +fn overlapping_opposite_side_pockets_become_true_through_material() { + let mut s = Session::default(); + base(&mut s, 100.0, 60.0); + rectangle_tool(&mut s, "Bottom", [20.0, 20.0], [30.0, 30.0]); + run(&mut s, "solid.extrude", json!({"sketch":"Bottom","distance":1.5,"operation":"cut"})); + rectangle_tool(&mut s, "Top", [25.0, 20.0], [35.0, 30.0]); + run(&mut s, "solid.extrude", json!({"sketch":"Top","start_offset":2.5,"direction":"negative","distance":1.5,"operation":"cut"})); + let expected = 15000.0 - 100.0 * 1.5 * 2.0 + 50.0 * 0.5; + for command in [None, Some("sheet.unfold"), Some("sheet.refold")] { + if let Some(command) = command { + run(&mut s, command, json!({})); + } + assert!((volume(&mut s) - expected).abs() < 1e-6); + assert_eq!(flat(&mut s)["cutouts"].as_array().unwrap().len(), 1); + assert_eq!(flat(&mut s)["pockets"].as_array().unwrap().len(), 2); + } +} + +#[test] +fn pockets_survive_forming_away_from_bends_and_refuse_bend_overlap_atomically() { + let mut s = Session::default(); + base(&mut s, 100.0, 60.0); + rectangle_tool(&mut s, "Pocket", [20.0, 20.0], [30.0, 30.0]); + run(&mut s, "solid.extrude", json!({"sketch":"Pocket","distance":1,"operation":"cut"})); + run(&mut s, "sheet.flange", json!({"edges":[[50,0,2.5]],"height":20})); + assert!((volume(&mut s) - (18972.62196081845 - 100.0)).abs() < 0.2); + run(&mut s, "sheet.unfold", json!({})); + let unfolded = volume(&mut s); + run(&mut s, "sheet.refold", json!({})); + assert!((volume(&mut s) - (18972.62196081845 - 100.0)).abs() < 0.2); + assert!(unfolded > 14900.0); + let mut cross = Session::default(); + base(&mut cross, 100.0, 60.0); + rectangle_tool(&mut cross, "Pocket", [45.0, 20.0], [55.0, 30.0]); + run(&mut cross, "solid.extrude", json!({"sketch":"Pocket","distance":1,"operation":"cut"})); + let before = cross.doc.to_json(); + let undo = cross.undo.len(); + let error = cross.execute("sheet.fold", &json!({"points":[[50,0,2.5],[50,60,2.5]]})).unwrap_err().to_string(); + assert!(error.contains("pocket") && error.contains("bend"), "{error}"); + assert_eq!(cross.doc.to_json(), before); + assert_eq!(cross.undo.len(), undo); + assert!((volume(&mut cross) - 14900.0).abs() < 1e-6); +} + +#[test] +fn unsupported_sheet_machining_is_atomic_but_true_no_change_is_allowed() { + let mut s = Session::default(); + base(&mut s, 100.0, 60.0); + rectangle_tool(&mut s, "Taper", [20.0, 20.0], [30.0, 30.0]); + let before = s.doc.to_json(); + let undo = s.undo.len(); + let error = s.execute("solid.extrude", &json!({"sketch":"Taper","distance":1,"operation":"cut","taper":"5 deg"})).unwrap_err().to_string(); + assert!(error.contains("sheet") && error.contains("preserv"), "{error}"); + assert_eq!(s.doc.to_json(), before); + assert_eq!(s.undo.len(), undo); + rectangle_tool(&mut s, "Outside", [120.0, 20.0], [130.0, 30.0]); + run(&mut s, "solid.extrude", json!({"sketch":"Outside","distance":1,"operation":"cut","taper":"5 deg"})); + assert!((volume(&mut s) - 15000.0).abs() < 1e-6); + rectangle_tool(&mut s, "All", [-20.0, -20.0], [120.0, 80.0]); + run(&mut s, "solid.extrude", json!({"sketch":"All","through_all":true,"direction":"symmetric","operation":"intersect"})); + run(&mut s, "sheet.unfold", json!({})); + assert!((volume(&mut s) - 15000.0).abs() < 1e-6); +} + +#[test] +fn empty_sheet_intersection_removes_geometry_and_metadata_together() { + let mut s = Session::default(); + base(&mut s, 100.0, 60.0); + rectangle_tool(&mut s, "Outside", [120.0, 20.0], [130.0, 30.0]); + run(&mut s, "solid.extrude", json!({"sketch":"Outside","through_all":true,"direction":"symmetric","operation":"intersect"})); + assert!(s.model.state().bodies.is_empty()); + assert!(s.model.state().sheets.is_empty()); + assert!(s.execute("sheet.unfold", &json!({})).is_err()); + run(&mut s, "edit.undo", json!({})); + assert!((volume(&mut s) - 15000.0).abs() < 1e-6); +} + +#[test] +fn intersect_preserves_both_material_regions_of_a_logical_ring_sheet() { + let mut s = Session::default(); + base(&mut s, 100.0, 60.0); + run(&mut s, "sketch.create", json!({"plane":"XY","name":"Ring"})); + for radius in [10.0, 5.0] { + run(&mut s, "sketch.circle.center", json!({"center":[50,30],"radius":radius})); + } + run(&mut s, "sketch.finish", json!({})); + run( + &mut s, + "solid.extrude", + json!({"sketch":"Ring","profiles":[{"point":[57,30]}],"through_all":true,"direction":"symmetric","operation":"cut"}), + ); + rectangle_tool(&mut s, "Crop", [35.0, 15.0], [65.0, 45.0]); + run(&mut s, "solid.extrude", json!({"sketch":"Crop","through_all":true,"direction":"symmetric","operation":"intersect"})); + for command in [None, Some("sheet.unfold"), Some("sheet.refold")] { + if let Some(command) = command { + run(&mut s, command, json!({})); + } + let state = s.model.state(); + let regions = state.sheets[0].flat_regions().unwrap(); + assert_eq!(regions.len(), 2); + let area: f64 = regions.iter().map(|r| r.area()).sum(); + assert!((area - (900.0 - PI * 75.0)).abs() < 1e-8, "area={area}"); + let body = &state.bodies[0].body; + assert!(body.contains(solvecraft_geom::Vec3::new(50.0, 30.0, 1.0))); + assert!(!body.contains(solvecraft_geom::Vec3::new(57.0, 30.0, 1.0))); + assert!(body.contains(solvecraft_geom::Vec3::new(64.0, 30.0, 1.0))); + assert!(!body.contains(solvecraft_geom::Vec3::new(70.0, 30.0, 1.0))); + assert!((volume(&mut s) - (900.0 - PI * 75.0) * 2.5).abs() < 0.2); + } + let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap().to_string(); + assert_eq!(solvecraft_io::read_dxf(&dxf).unwrap().len(), 6); +} + +#[test] +fn machining_a_folded_parallel_panel_preserves_its_pocket_on_unfold() { + use solvecraft_doc::{apply_point, apply_vector}; + use solvecraft_geom::Vec3; + let mut s = Session::default(); + base(&mut s, 100.0, 60.0); + run(&mut s, "sheet.flange", json!({"edges":[[50,0,2.5]],"height":20})); + let sh = s.model.state().sheets[0].clone(); + let points = sh.panel(&sh.flanges[0]).outer.polyline(1e-3); + let c = points.iter().copied().fold(solvecraft_geom::Vec2::ZERO, |sum, p| sum + p) / (points.len() as f64); + let w = sh.world(Some(0)); + let origin = apply_point(&w, Vec3::ZERO); + let x = apply_vector(&w, Vec3::X); + let y = apply_vector(&w, Vec3::Y); + run( + &mut s, + "sketch.create", + json!({"name":"PanelPocket","plane":{"origin":[origin.x,origin.y,origin.z],"x_dir":[x.x,x.y,x.z],"y_dir":[y.x,y.y,y.z]}}), + ); + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[c.x-2.0,c.y-2.0],"p1":[c.x+2.0,c.y+2.0]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch":"PanelPocket","distance":1,"operation":"cut"})); + assert!((volume(&mut s) - (18972.62196081845 - 16.0)).abs() < 0.2); + run(&mut s, "sheet.unfold", json!({})); + let state = s.model.state(); + let body = &state.bodies[0].body; + assert!(!body.contains(Vec3::new(c.x, c.y, 0.5))); + assert!(body.contains(Vec3::new(c.x, c.y, 1.5))); + assert_eq!(flat(&mut s)["pockets"][0]["depth_mm"], 1.0); + run(&mut s, "sheet.refold", json!({})); + assert!((volume(&mut s) - (18972.62196081845 - 16.0)).abs() < 0.2); +} + +#[test] +fn flipped_sheet_pocket_keeps_depth_in_the_local_thickness_direction() { + let mut s = Session::default(); + rectangle_tool(&mut s, "Base", [0.0, 0.0], [100.0, 60.0]); + run(&mut s, "sheet.flange", json!({"sketch":"Base","flip":true})); + rectangle_tool(&mut s, "Pocket", [20.0, 20.0], [30.0, 30.0]); + run(&mut s, "solid.extrude", json!({"sketch":"Pocket","distance":1,"direction":"negative","operation":"cut"})); + for command in [None, Some("sheet.unfold"), Some("sheet.refold")] { + if let Some(command) = command { + run(&mut s, command, json!({})); + } + assert!((volume(&mut s) - 14900.0).abs() < 1e-6); + let state = s.model.state(); + assert!(!state.bodies[0].body.contains(solvecraft_geom::Vec3::new(25.0, 25.0, -0.5))); + assert!(state.bodies[0].body.contains(solvecraft_geom::Vec3::new(25.0, 25.0, -1.5))); + } +} + +#[test] +fn multiple_profile_intersect_keeps_tool_union_for_solids_and_sheet_groups() { + for sheet in [false, true] { + let mut s = Session::default(); + if sheet { + base(&mut s, 100.0, 60.0); + } else { + run(&mut s, "solid.box", json!({"length":100,"width":60,"height":2.5,"body_name":"Blank"})); + } + let target = s.model.state().bodies[0].name.clone(); + run(&mut s, "solid.box", json!({"corner":[200,0,0],"length":10,"width":10,"height":10,"body_name":"Control"})); + run(&mut s, "sketch.create", json!({"plane":"XY","name":"TwoIslands"})); + for x in [20.0, 60.0] { + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[x,20],"p1":[x+10.0,30]})); + } + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch":"TwoIslands","operation":"intersect","distance":3,"targets":[target]})); + assert!((volume(&mut s) - 1500.0).abs() < 1e-6); + if sheet { + for command in ["sheet.unfold", "sheet.refold"] { + run(&mut s, command, json!({"body":target})); + assert!((volume(&mut s) - 1500.0).abs() < 1e-6); + assert_eq!(flat(&mut s)["outline"].as_array().unwrap().len(), 2); + } + } + let state = s.model.state(); + for x in [25.0, 65.0] { + assert!(state.bodies.iter().any(|b| b.body.contains(solvecraft_geom::Vec3::new(x, 25.0, 1.0)))); + } + assert!(!state.bodies.iter().any(|b| b.body.contains(solvecraft_geom::Vec3::new(50.0, 25.0, 1.0)))); + assert!(state.bodies.iter().any(|b| b.name == "Control" && b.body.contains(solvecraft_geom::Vec3::new(205.0, 5.0, 5.0)))); + } +} + +#[test] +fn deleting_sheet_material_prunes_cosmetic_threads_and_undo_restores_them() { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane":"XY","name":"ThreadPlate"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[100,60]})); + run(&mut s, "sketch.circle.center", json!({"center":[50,30],"radius":5})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "sheet.flange", json!({"sketch":"ThreadPlate","profiles":[{"point":[10,10]}]})); + run(&mut s, "solid.thread", json!({"face":[55,30,1],"designation":"M10"})); + assert_eq!(s.model.state().threads.len(), 1); + rectangle_tool(&mut s, "Outside", [120.0, 20.0], [130.0, 30.0]); + run(&mut s, "solid.extrude", json!({"sketch":"Outside","through_all":true,"direction":"symmetric","operation":"intersect"})); + assert!(s.model.state().bodies.is_empty()); + assert!(s.model.state().sheets.is_empty()); + assert!(s.model.state().threads.is_empty()); + assert!(solvecraft_doc::world_state(&s.doc, &s.model.state()).threads.is_empty()); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.model.state().threads.len(), 1); +} + +#[test] +fn sheet_pocket_transfer_keeps_local_depth_world_pose_and_history() { + use solvecraft_geom::Vec3; + fn check(s: &Session, name: &str, owner: u64, local_origin: Vec3, local_y: Vec3, transferred: bool) { + let state = s.model.state(); + let mb = state.body(name).unwrap(); + assert_eq!(mb.component, owner); + assert_eq!(mb.transferred, transferred); + let sh = state.sheets.iter().find(|sh| sh.body == name).unwrap(); + assert!(sh.frame.origin.dist(local_origin) < 1e-8, "{:?}", sh.frame); + assert!(sh.frame.x.dist(Vec3::X) < 1e-8); + assert!(sh.frame.y.dist(local_y) < 1e-8); + assert_eq!(sh.pockets.len(), 1); + assert_eq!((sh.pockets[0].z0, sh.pockets[0].z1), (0.0, 1.0)); + let cut = sh.frame.to_world(solvecraft_geom::Vec2::new(25.0, 25.0)) + sh.frame.normal() * 0.5; + let floor = sh.frame.to_world(solvecraft_geom::Vec2::new(25.0, 25.0)) + sh.frame.normal() * 1.5; + assert!(!mb.body.contains(cut)); + assert!(mb.body.contains(floor)); + let world = s.world_state(); + let body = &world.body(name).unwrap().body; + let bounds = body.tessellate(1e-3).unwrap().bounds(); + assert!(bounds.min.dist(Vec3::ZERO) < 1e-6, "{bounds:?}"); + assert!(bounds.max.dist(Vec3::new(100.0, 60.0, 2.5)) < 1e-6, "{bounds:?}"); + assert!(!body.contains(Vec3::new(25.0, 25.0, 0.5))); + assert!(body.contains(Vec3::new(25.0, 25.0, 1.5))); + assert!((solvecraft_kernel::measure(body).unwrap().volume - 14900.0).abs() < 1e-6); + } + let mut s = Session::default(); + base(&mut s, 100.0, 60.0); + rectangle_tool(&mut s, "Pocket", [20.0, 20.0], [30.0, 30.0]); + run(&mut s, "solid.extrude", json!({"sketch":"Pocket","distance":1,"operation":"cut"})); + let name = s.model.state().sheets[0].body.clone(); + check(&s, &name, 0, Vec3::ZERO, Vec3::Y, false); + let destination = run(&mut s, "component.create", json!({"name":"Machined","parent":0})); + let component = destination["component"].as_u64().unwrap(); + let occurrence = destination["occurrence"].as_u64().unwrap(); + run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[30,20,10],"axis":[1,0,0],"angle":"90 deg","capture":true})); + run(&mut s, "component.move_bodies", json!({"component":component,"bodies":[name]})); + assert!(!s.doc.body_offsets.contains_key(&name), "recorded transfer must not leave a legacy placement offset"); + let origin = Vec3::new(-30.0, -10.0, 20.0); + let y = Vec3::new(0.0, 0.0, -1.0); + check(&s, &name, component, origin, y, true); + run(&mut s, "edit.undo", json!({})); + check(&s, &name, 0, Vec3::ZERO, Vec3::Y, false); + run(&mut s, "edit.redo", json!({})); + check(&s, &name, component, origin, y, true); + run(&mut s, "component.activate", json!({"component":component})); + for command in ["sheet.unfold", "sheet.refold"] { + run(&mut s, command, json!({"body":name})); + check(&s, &name, component, origin, y, true); + let f = run(&mut s, "sheet.flat_pattern", json!({"body":name})); + assert_eq!(f["pockets"][0]["side"], "bottom"); + assert_eq!(f["pockets"][0]["depth_mm"], 1.0); + } + let mut reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); + check(&reopened, &name, component, origin, y, true); + run(&mut reopened, "component.activate", json!({"component":component})); + run(&mut reopened, "sheet.unfold", json!({"body":name})); + check(&reopened, &name, component, origin, y, true); +} + #[test] fn base_and_edge_flange_follow_the_k_factor() { let mut s = Session::default(); diff --git a/crates/engine/src/cmd/sketch_3d.rs b/crates/engine/src/cmd/sketch_3d.rs index 061ff353..d2a243c0 100644 --- a/crates/engine/src/cmd/sketch_3d.rs +++ b/crates/engine/src/cmd/sketch_3d.rs @@ -4,8 +4,9 @@ use serde_json::{Value, json}; use solvecraft_geom::Vec3; +use solvecraft_sketch::WireSpace; -use super::sketch::edit; +use super::sketch::{edit, target_sketch}; use super::{CommandSpec, in_sketch}; use crate::params::{bad, req_vec3, vec3}; use crate::{Result, Session}; @@ -35,32 +36,71 @@ fn needs_3d(sk: &solvecraft_sketch::Sketch, cmd: &str) -> Result<()> { if sk.view.three_d { Ok(()) } else { Err(bad(cmd, "turn on 3D Sketch (sketch.options sketch_3d: true) to draw off the sketch plane")) } } +/// World command inputs belong to the target sketch's owner, which may differ from the +/// active component when a script supplies an explicit sketch. +fn inverse_owner_frame(s: &Session, p: &Value, cmd: &str) -> Result { + let id = target_sketch(s, p, cmd)?; + let owner = s.doc.feature(id).map(|f| f.component).ok_or_else(|| bad(cmd, "target sketch is missing"))?; + if owner != 0 && owner != s.active_component { + if s.doc.occurrences.len() > 100_000 { + return Err(bad(cmd, "sketch owner placement expansion exceeds supported bounds")); + } + let mut children: std::collections::BTreeMap> = std::collections::BTreeMap::new(); + for occurrence in &s.doc.occurrences { + children.entry(occurrence.parent).or_default().push(occurrence); + } + let mut stack = vec![(0, 0usize)]; + let mut count = 0; + let mut visited = 0; + while let Some((component, depth)) = stack.pop() { + for o in children.get(&component).into_iter().flatten() { + visited += 1; + if depth >= 64 || visited > 100_000 { + return Err(bad(cmd, "sketch owner placement expansion exceeds supported bounds")); + } + if o.component == owner { + count += 1; + if count > 1 { + return Err(bad(cmd, "activate the desired sketch owner occurrence before editing world 3D points")); + } + } + stack.push((o.component, depth + 1)); + } + } + } + let frame = if owner == s.active_component { s.active_component_frame() } else { s.placed_document().component_transform(owner) }; + solvecraft_doc::mat_inverse(&frame).ok_or_else(|| bad(cmd, "the sketch owner placement is not invertible")) +} + fn line3d(s: &mut Session, p: &Value) -> Result { let cmd = "sketch.line3d"; - let pts = points(cmd, p, 2)?; + let inverse = inverse_owner_frame(s, p, cmd)?; + let pts: Vec = points(cmd, p, 2)?.into_iter().map(|at| solvecraft_doc::apply_point(&inverse, at)).collect(); let (ids, info) = edit(s, p, cmd, false, |sk, _| { needs_3d(sk, cmd)?; - pts.windows(2).map(|w| sk.add_drawn_wire(w).map_err(Into::into)).collect::>>() + pts.windows(2).map(|w| sk.add_drawn_wire_in(w, WireSpace::Component).map_err(Into::into)).collect::>>() })?; Ok(json!({"curves": ids, "sketch": info})) } fn spline3d(s: &mut Session, p: &Value) -> Result { let cmd = "sketch.spline3d"; - let pts = points(cmd, p, 2)?; + let inverse = inverse_owner_frame(s, p, cmd)?; + let pts: Vec = points(cmd, p, 2)?.into_iter().map(|at| solvecraft_doc::apply_point(&inverse, at)).collect(); let (id, info) = edit(s, p, cmd, false, |sk, _| { needs_3d(sk, cmd)?; - Ok(sk.add_drawn_wire(&pts)?) + Ok(sk.add_drawn_wire_in(&pts, WireSpace::Component)?) })?; Ok(json!({"curves": [id], "sketch": info})) } fn point3d(s: &mut Session, p: &Value) -> Result { let cmd = "sketch.point3d"; - let at = req_vec3(cmd, p, "point")?; + let inverse = inverse_owner_frame(s, p, cmd)?; + let at = solvecraft_doc::apply_point(&inverse, req_vec3(cmd, p, "point")?); let (id, info) = edit(s, p, cmd, false, |sk, _| { needs_3d(sk, cmd)?; - Ok(sk.add_drawn_wire(&[at])?) + Ok(sk.add_drawn_wire_in(&[at], WireSpace::Component)?) })?; Ok(json!({"point": id, "sketch": info})) } @@ -68,23 +108,41 @@ fn point3d(s: &mut Session, p: &Value) -> Result { fn move3d(s: &mut Session, p: &Value) -> Result { let cmd = "sketch.move3d"; let wire = p.get("wire").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`wire` must be a 3D curve or point id"))?.to_string(); - let index = p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize; - let to = p.get("to").and_then(vec3); - let by = p.get("by").and_then(vec3); + let index = match p.get("index") { + None => 0, + Some(v) => v + .as_u64() + .and_then(|v| usize::try_from(v).ok()) + .filter(|v| *v < solvecraft_sketch::MAX_FIT_POINTS) + .ok_or_else(|| bad(cmd, "`index` must be a fit point index below 500"))?, + }; + let parse = |name| p.get(name).map(|v| vec3(v).ok_or_else(|| bad(cmd, format!("`{name}` must be [x,y,z]")))).transpose(); + let to = parse("to")?; + let by = parse("by")?; + let inverse = inverse_owner_frame(s, p, cmd)?; let (n, info) = edit(s, p, cmd, false, |sk, _| { - let from = sk + let drawn = sk .wire_index(&wire) .and_then(|w| sk.wires.get(w)) - .filter(|w| !w.fit.is_empty()) - .and_then(|w| w.fit.get(index)) - .copied() - .ok_or_else(|| bad(cmd, format!("`{wire}` has no drawn point {index}")))?; + .filter(|w| !w.fit.is_empty() && w.link.is_none()) + .ok_or_else(|| bad(cmd, format!("`{wire}` is not an editable drawn 3D curve")))?; + let from = drawn.fit.get(index).copied().ok_or_else(|| bad(cmd, format!("`{wire}` has no drawn point {index}")))?; let to = match (to, by) { - (Some(t), _) => t, - (None, Some(d)) => from + d, + (Some(t), _) => { + if drawn.space == WireSpace::Component { + solvecraft_doc::apply_point(&inverse, t) + } else { + t + } + } + (None, Some(d)) => from + if drawn.space == WireSpace::Component { solvecraft_doc::apply_vector(&inverse, d) } else { d }, _ => return Err(bad(cmd, "give `to` or `by`")), }; Ok(sk.move_drawn_point(&wire, index, to)?) })?; Ok(json!({"moved": n, "sketch": info})) } + +#[cfg(test)] +#[path = "sketch_3d_frame_tests.rs"] +mod frame_tests; diff --git a/crates/engine/src/cmd/sketch_3d_frame_tests.rs b/crates/engine/src/cmd/sketch_3d_frame_tests.rs new file mode 100644 index 00000000..12f2a0dd --- /dev/null +++ b/crates/engine/src/cmd/sketch_3d_frame_tests.rs @@ -0,0 +1,312 @@ +use crate::Session; +use serde_json::{Value, json}; +use solvecraft_geom::Vec3; +use solvecraft_sketch::WireSpace; + +fn run(s: &mut Session, cmd: &str, params: Value) -> Value { + s.execute(cmd, ¶ms).unwrap_or_else(|e| panic!("{cmd}: {e}")) +} + +fn near(actual: Vec3, expected: Vec3) { + assert!(actual.dist(expected) < 1e-7, "{actual:?} != {expected:?}"); +} + +fn second_frame() -> (Session, u64, u64, u64) { + let mut s = Session::default(); + let c = run(&mut s, "component.create", json!({"name":"Wire owner"})); + let component = c["component"].as_u64().unwrap(); + let first = c["occurrence"].as_u64().unwrap(); + let second = run(&mut s, "occurrence.copy", json!({"occurrence":first}))["occurrence"].as_u64().unwrap(); + // One rigid transform: (x,y,z) -> (20-y,30+x,40+z), independent of implementation math. + run(&mut s, "occurrence.move", json!({"occurrence":second,"axis":[0,0,1],"angle":"90 deg","translate":[20,30,40]})); + run(&mut s, "component.activate", json!({"component":component,"occurrence":second,"path":[second]})); + run(&mut s, "sketch.create", json!({"plane":"XY","name":"Space path"})); + run(&mut s, "sketch.options", json!({"sketch_3d":true})); + (s, component, first, second) +} + +#[test] +fn wire_frame_second_occurrence_world_inputs_move_and_reopen() { + let (mut s, component, first, second) = second_frame(); + let sketch = s.active_sketch.unwrap(); + let r = run(&mut s, "sketch.line3d", json!({"points":[[17,32,45],[17,32,55],[14,32,55]]})); + let a = r["curves"][0].as_str().unwrap().to_string(); + let b = r["curves"][1].as_str().unwrap().to_string(); + let spline = run(&mut s, "sketch.spline3d", json!({"points":[[14,32,55],[12,34,56],[10,35,58]]}))["curves"][0].as_str().unwrap().to_string(); + let point = run(&mut s, "sketch.point3d", json!({"point":[17,32,45]}))["point"].as_str().unwrap().to_string(); + let sk = s.doc.sketch(sketch).unwrap(); + for w in &sk.wires { + assert_eq!(w.space, WireSpace::Component); + } + near(sk.wires[sk.wire_index(&a).unwrap()].fit[0], Vec3::new(2.0, 3.0, 5.0)); + near(sk.wires[sk.wire_index(&point).unwrap()].fit[0], Vec3::new(2.0, 3.0, 5.0)); + let world = s.world_state(); + let ws = &world.sketch(sketch).unwrap().sketch; + let sw = &ws.wires[ws.wire_index(&spline).unwrap()]; + near(sw.fit[0], Vec3::new(14.0, 32.0, 55.0)); + near(sw.pts[0], sw.fit[0]); + near(*sw.pts.last().unwrap(), Vec3::new(10.0, 35.0, 58.0)); + let before = s.doc.to_json(); + let moved = run(&mut s, "sketch.move3d", json!({"wire":a,"index":1,"by":[1,0,0]})); + assert_eq!(moved["moved"], 2); + let sk = s.doc.sketch(sketch).unwrap(); + near(sk.wires[sk.wire_index(&a).unwrap()].fit[1], Vec3::new(2.0, 2.0, 15.0)); + near(sk.wires[sk.wire_index(&b).unwrap()].fit[0], Vec3::new(2.0, 2.0, 15.0)); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.to_json(), before); + run(&mut s, "edit.redo", json!({})); + run(&mut s, "sketch.move3d", json!({"wire":point,"to":[18,33,46]})); + near(s.doc.sketch(sketch).unwrap().wires.iter().find(|w| w.id == point).unwrap().fit[0], Vec3::new(3.0, 2.0, 6.0)); + let restored = solvecraft_doc::format::read(&s.doc.to_json()).unwrap(); + assert_eq!(restored.sketch(sketch).unwrap().wires, s.doc.sketch(sketch).unwrap().wires); + // Same definition at the first placement has the local geometry; selected second is exact. + run(&mut s, "component.activate", json!({"component":component,"occurrence":first,"path":[first]})); + near(s.world_state().sketch(sketch).unwrap().sketch.wires.iter().find(|w| w.id == point).unwrap().fit[0], Vec3::new(3.0, 2.0, 6.0)); + run(&mut s, "component.activate", json!({"component":component,"occurrence":second,"path":[second]})); + near(s.world_state().sketch(sketch).unwrap().sketch.wires.iter().find(|w| w.id == point).unwrap().fit[0], Vec3::new(18.0, 33.0, 46.0)); +} + +#[test] +fn wire_frame_pending_moves_other_owner_and_legacy_world_do_not_join() { + let (mut s, _, _, second) = second_frame(); + let sketch = s.active_sketch.unwrap(); + let legacy = s.doc_mut().sketch_mut(sketch).unwrap().add_drawn_wire(&[Vec3::new(2.0, 3.0, 5.0)]).unwrap(); + let local = run(&mut s, "sketch.point3d", json!({"point":[17,32,45]}))["point"].as_str().unwrap().to_string(); + assert_eq!(run(&mut s, "sketch.move3d", json!({"wire":local,"by":[1,0,0]}))["moved"], 1); + near(s.doc.sketch(sketch).unwrap().wires.iter().find(|w| w.id == legacy).unwrap().fit[0], Vec3::new(2.0, 3.0, 5.0)); + run(&mut s, "occurrence.move", json!({"occurrence":second,"translate":[5,0,0],"capture":false})); + let point = run(&mut s, "sketch.point3d", json!({"point":[22,32,45]}))["point"].as_str().unwrap().to_string(); + near(s.doc.sketch(sketch).unwrap().wires.iter().find(|w| w.id == point).unwrap().fit[0], Vec3::new(2.0, 3.0, 5.0)); + near(s.world_state().sketch(sketch).unwrap().sketch.wires.iter().find(|w| w.id == legacy).unwrap().fit[0], Vec3::new(2.0, 3.0, 5.0)); + near(s.world_state().sketch(sketch).unwrap().sketch.wires.iter().find(|w| w.id == point).unwrap().fit[0], Vec3::new(22.0, 32.0, 45.0)); + run(&mut s, "component.capture_position", json!({})); + near(s.world_state().sketch(sketch).unwrap().sketch.wires.iter().find(|w| w.id == point).unwrap().fit[0], Vec3::new(22.0, 32.0, 45.0)); + // Explicit target in root must not inherit the unrelated active component frame. + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "component.activate", json!({"component":0})); + let root = run(&mut s, "sketch.create", json!({"plane":"XY","name":"Root path"}))["sketch"].as_u64().unwrap(); + run(&mut s, "sketch.options", json!({"sketch_3d":true})); + let unrelated = run(&mut s, "component.create", json!({"name":"Unrelated"}))["occurrence"].as_u64().unwrap(); + run(&mut s, "occurrence.move", json!({"occurrence":unrelated,"translate":[100,200,300]})); + run(&mut s, "sketch.point3d", json!({"sketch":root,"point":[9,8,7]})); + near(s.doc.sketch(root).unwrap().wires[0].fit[0], Vec3::new(9.0, 8.0, 7.0)); +} + +#[test] +fn wire_frame_hostile_inputs_rollback_full_session() { + let (mut s, _, _, second) = second_frame(); + let id = run(&mut s, "sketch.point3d", json!({"point":[17,32,45]}))["point"].as_str().unwrap().to_string(); + run(&mut s, "occurrence.move", json!({"occurrence":second,"translate":[1,0,0],"capture":false})); + run(&mut s, "select.set", json!({"items":[{"type":"occurrence","path":[second]}]})); + let doc = s.doc.to_json(); + let pending = s.pending_moves.clone(); + let selection = s.selection.clone(); + let history = (s.undo.len(), s.redo.len()); + for (cmd, p) in [ + ("sketch.move3d", json!({"wire":id,"index":u64::MAX,"to":[0,0,0]})), + ("sketch.move3d", json!({"wire":id,"index":-1,"to":[0,0,0]})), + ("sketch.move3d", json!({"wire":id,"index":"0","to":[0,0,0]})), + ("sketch.move3d", json!({"wire":id,"to":[null,0,0],"by":[1,0,0]})), + ("sketch.point3d", json!({"point":[1e100,0,0]})), + ("sketch.line3d", json!({"points":[[17,32,45],[17,32,45]]})), + ("sketch.spline3d", json!({"points":vec![[0,0,0];501]})), + ] { + assert!(s.execute(cmd, &p).is_err(), "{cmd}: {p}"); + assert_eq!(s.doc.to_json(), doc); + assert_eq!(s.pending_moves, pending); + assert_eq!(s.selection, selection); + assert_eq!((s.undo.len(), s.redo.len()), history); + } +} + +#[test] +fn wire_frame_include_other_component_refresh_break_lost_and_reopen() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10})); + let component = run(&mut s, "component.create", json!({"name":"Receiver"})); + let occurrence = component["occurrence"].as_u64().unwrap(); + run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[20,0,0]})); + run(&mut s, "sketch.create", json!({"plane":"XY","name":"Include target"})); + let sketch = s.active_sketch.unwrap(); + run(&mut s, "sketch.include_3d", json!({"refs":[{"edge":[10,10,5],"body":"Body1"}]})); + let initial = s.doc.sketch(sketch).unwrap().wires[0].clone(); + assert_eq!(initial.space, WireSpace::Component); + assert!(initial.pts.iter().all(|p| (p.x + 10.0).abs() < 1e-7 && (p.y - 10.0).abs() < 1e-7)); + let link = initial.link.clone().unwrap(); + // A real sketch edit exercises engine refresh, not only document evaluation. + run(&mut s, "sketch.line", json!({"points":[[0,0],[1,0]]})); + let sk = s.doc.sketch(sketch).unwrap(); + assert_eq!(sk.wires[0], initial); + assert!(!sk.link(&link).unwrap().lost); + let world = s.world_state(); + assert!(world.sketch(sketch).unwrap().sketch.wires[0].pts.iter().all(|p| (p.x - 10.0).abs() < 1e-7)); + let before = s.doc.to_json(); + run(&mut s, "sketch.break_link", json!({"link":link})); + assert_eq!(s.doc.sketch(sketch).unwrap().wires[0].space, WireSpace::Component); + assert!(s.doc.sketch(sketch).unwrap().wires[0].link.is_none()); + let restored = solvecraft_doc::format::read(&s.doc.to_json()).unwrap(); + assert_eq!(restored.sketch(sketch).unwrap().wires[0], s.doc.sketch(sketch).unwrap().wires[0]); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.to_json(), before); + // Lost references retain the last coordinates and explicit marker, with no guessing. + let sk = s.doc_mut().sketch_mut(sketch).unwrap(); + sk.links.iter_mut().find(|l| l.id == link).unwrap().source = solvecraft_sketch::LinkSource::Edge { body: "Missing".into(), at: Vec3::ZERO }; + run(&mut s, "sketch.line", json!({"points":[[2,0],[3,0]]})); + let sk = s.doc.sketch(sketch).unwrap(); + assert!(sk.link(&link).unwrap().lost); + assert_eq!(sk.wires[0], initial); + let restored = solvecraft_doc::format::read(&s.doc.to_json()).unwrap(); + assert_eq!(restored.sketch(sketch).unwrap().wires[0], initial); +} + +#[test] +fn wire_frame_pipe_component_and_world_paths_have_analytic_volume_and_position() { + for space in [WireSpace::Component, WireSpace::World] { + let (mut s, _, first, second) = second_frame(); + if space == WireSpace::World { + // A single placed owner lets an immutable world path stay physically fixed; + // replicated solids still follow their occurrence placements by design. + run(&mut s, "selection.delete", json!({"items":[{"type":"occurrence","path":[first]}]})); + } + let sketch = s.active_sketch.unwrap(); + let wire = if space == WireSpace::Component { + run(&mut s, "sketch.line3d", json!({"points":[[17,32,45],[17,32,55]]}))["curves"][0].as_str().unwrap().to_string() + } else { + s.doc_mut().sketch_mut(sketch).unwrap().add_drawn_wire(&[Vec3::new(17.0, 32.0, 45.0), Vec3::new(17.0, 32.0, 55.0)]).unwrap() + }; + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.pipe", json!({"path_sketch":sketch,"path":[wire],"diameter":4})); + let world = s.world_state(); + let body = world.bodies.last().unwrap(); + // Property measurement uses fine adaptive tessellation; the viewport mesh alone + // undershoots a small circular profile by about 0.64% at its display chord tolerance. + let measure = solvecraft_kernel::measure(&body.body).unwrap(); + assert!((measure.volume - 40.0 * std::f64::consts::PI).abs() < 0.05, "{}", measure.volume); + let bounds = body.mesh().bounds(); + near((bounds.min + bounds.max) / 2.0, Vec3::new(17.0, 32.0, 50.0)); + run(&mut s, "occurrence.move", json!({"occurrence":second,"translate":[5,0,0]})); + let world = s.world_state(); + let body = world.bodies.last().unwrap(); + let bounds = body.mesh().bounds(); + near((bounds.min + bounds.max) / 2.0, Vec3::new(if space == WireSpace::Component { 22.0 } else { 17.0 }, 32.0, 50.0)); + } +} + +#[test] +fn wire_frame_nonactive_repeated_owner_refuses_ambiguous_world_input() { + let (mut s, _, _, _) = second_frame(); + let target = s.active_sketch.unwrap(); + run(&mut s, "component.activate", json!({"component":0})); + let before = s.doc.to_json(); + let error = s.execute("sketch.point3d", &json!({"sketch":target,"point":[0,0,5]})).unwrap_err(); + assert!(error.to_string().contains("activate the desired sketch owner occurrence")); + assert_eq!(s.doc.to_json(), before); +} + +#[test] +fn wire_frame_wide_owner_graph_has_bounded_indexed_traversal() { + let mut s = Session::default(); + run(&mut s, "component.create", json!({"name":"Target"})); + let target = run(&mut s, "sketch.create", json!({"plane":"XY"}))["sketch"].as_u64().unwrap(); + run(&mut s, "component.activate", json!({"component":0})); + // A legal wide assembly used to force one whole occurrence scan for each leaf. + let doc = s.doc_mut(); + for i in 0..9998u64 { + doc.components.push(solvecraft_doc::Component { id: 10_000 + i, name: format!("Sibling{i}"), parent: 0 }); + doc.occurrences.push(solvecraft_doc::Occurrence { + id: 100_000 + i, + component: 10_000 + i, + parent: 0, + name: format!("Sibling{i}"), + transform: solvecraft_doc::IDENTITY, + grounded: false, + }); + } + assert_eq!(super::inverse_owner_frame(&s, &json!({"sketch":target}), "sketch.point3d").unwrap(), solvecraft_doc::IDENTITY); +} + +#[test] +fn wire_frame_cross_owner_sweep_follows_world_path_edit_undo_and_reopen() { + let mut s = Session::default(); + let path_sketch = run(&mut s, "sketch.create", json!({"plane":"XY","name":"Root path"}))["sketch"].as_u64().unwrap(); + run(&mut s, "sketch.options", json!({"sketch_3d":true})); + let wire = run(&mut s, "sketch.line3d", json!({"points":[[17,32,45],[17,32,55]]}))["curves"][0].as_str().unwrap().to_string(); + run(&mut s, "sketch.finish", json!({})); + let owner = run(&mut s, "component.create", json!({"name":"Sweep owner"}))["occurrence"].as_u64().unwrap(); + run(&mut s, "occurrence.move", json!({"occurrence":owner,"axis":[0,0,1],"angle":"90 deg","translate":[20,30,40]})); + let profile = run(&mut s, "sketch.create", json!({"plane":"XY","offset":5,"name":"Profile"}))["sketch"].as_u64().unwrap(); + run(&mut s, "sketch.circle.center", json!({"center":[2,3],"radius":2})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.sweep", json!({"sketch":profile,"path_sketch":path_sketch,"path":[wire],"operation":"new"})); + let check = |s: &Session, length: f64| { + let world = s.world_state(); + let body = world.bodies.last().unwrap(); + let measure = solvecraft_kernel::measure(&body.body).unwrap(); + assert!((measure.volume - 4.0 * std::f64::consts::PI * length).abs() < 0.05, "{}", measure.volume); + near(measure.centroid, Vec3::new(17.0, 32.0, 45.0 + length / 2.0)); + }; + check(&s, 10.0); + let saved = s.doc.to_json(); + run(&mut s, "sketch.edit", json!({"sketch":path_sketch})); + run(&mut s, "sketch.move3d", json!({"sketch":path_sketch,"wire":wire,"index":1,"by":[0,0,10]})); + check(&s, 20.0); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.to_json(), saved); + check(&s, 10.0); + let mut reopened = Session { doc: solvecraft_doc::format::read(&saved).unwrap().into(), ..Default::default() }; + reopened.refresh(); + check(&reopened, 10.0); +} + +#[test] +fn wire_frame_three_owner_sweep_profile_move_invalidates_warm_cache() { + let mut s = Session::default(); + let profile_owner = run(&mut s, "component.create", json!({"name":"Profile owner","parent":0})); + let profile_occurrence = profile_owner["occurrence"].as_u64().unwrap(); + run(&mut s, "occurrence.move", json!({"occurrence":profile_occurrence,"translate":[4,7,5]})); + let profile = run(&mut s, "sketch.create", json!({"plane":"XY","name":"Foreign profile"}))["sketch"].as_u64().unwrap(); + run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":2})); + run(&mut s, "sketch.finish", json!({})); + + let path_owner = run(&mut s, "component.create", json!({"name":"Path owner","parent":0})); + let path_occurrence = path_owner["occurrence"].as_u64().unwrap(); + run(&mut s, "occurrence.move", json!({"occurrence":path_occurrence,"translate":[-12,9,-3]})); + let path_sketch = run(&mut s, "sketch.create", json!({"plane":"XY","name":"Foreign path"}))["sketch"].as_u64().unwrap(); + run(&mut s, "sketch.options", json!({"sketch_3d":true})); + let wire = run(&mut s, "sketch.line3d", json!({"points":[[4,7,5],[4,7,15]]}))["curves"][0].as_str().unwrap().to_string(); + run(&mut s, "sketch.finish", json!({})); + + let consumer = run(&mut s, "component.create", json!({"name":"Sweep consumer","parent":0})); + let consumer_occurrence = consumer["occurrence"].as_u64().unwrap(); + run(&mut s, "occurrence.move", json!({"occurrence":consumer_occurrence,"axis":[0,0,1],"angle":"90 deg","translate":[100,-30,20]})); + run(&mut s, "solid.sweep", json!({"sketch":profile,"path_sketch":path_sketch,"path":[wire],"operation":"new"})); + let measure = |session: &Session, x: f64| { + let state = session.world_state(); + let body = state.bodies.last().unwrap(); + assert_eq!(body.component, consumer["component"].as_u64().unwrap()); + let result = solvecraft_kernel::measure(&body.body).unwrap(); + assert!((result.volume - 40.0 * std::f64::consts::PI).abs() < 0.05, "{}", result.volume); + near(result.centroid, Vec3::new(x, 7.0, 10.0)); + result + }; + measure(&s, 4.0); + // A repeated refresh warms the exact same evaluator cache before changing only + // the foreign profile placement, whose frame must be part of Sweep's digest. + s.refresh(); + measure(&s, 4.0); + let saved = s.doc.to_json(); + let fixed_path = s.doc.component_transform(path_owner["component"].as_u64().unwrap()); + let fixed_consumer = s.doc.component_transform(consumer["component"].as_u64().unwrap()); + run(&mut s, "occurrence.move", json!({"occurrence":profile_occurrence,"translate":[3,0,0]})); + assert_eq!(s.doc.component_transform(path_owner["component"].as_u64().unwrap()), fixed_path); + assert_eq!(s.doc.component_transform(consumer["component"].as_u64().unwrap()), fixed_consumer); + let warm = measure(&s, 7.0); + let mut cold = Session { doc: solvecraft_doc::format::read(&s.doc.to_json()).unwrap().into(), ..Default::default() }; + cold.refresh(); + let reopened = measure(&cold, 7.0); + near(warm.centroid, reopened.centroid); + assert!((warm.volume - reopened.volume).abs() < 1e-8); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.to_json(), saved); + measure(&s, 4.0); +} diff --git a/crates/engine/src/cmd/sketch_project.rs b/crates/engine/src/cmd/sketch_project.rs index b7ee7586..11fe99ce 100644 --- a/crates/engine/src/cmd/sketch_project.rs +++ b/crates/engine/src/cmd/sketch_project.rs @@ -123,27 +123,49 @@ fn sketch_plane(doc: &Document, id: u64) -> Result { } } +/// Keep candidate feature edits while overlaying uncommitted occurrence placements. +fn placed_candidate<'a>(s: &Session, doc: &'a Document) -> std::borrow::Cow<'a, Document> { + if s.pending_moves.is_empty() { + return std::borrow::Cow::Borrowed(doc); + } + let mut placed = doc.clone(); + for o in &mut placed.occurrences { + if let Some(m) = s.pending_moves.get(&o.id) { + o.transform = *m; + } + } + std::borrow::Cow::Owned(placed) +} + +fn owner_frame(s: &Session, doc: &Document, comp: u64) -> solvecraft_doc::Mat { + if comp == s.active_component { s.active_component_frame() } else { doc.component_transform(comp) } +} + /// Re-resolve the links of a sketch being edited against the model before it, so edits see /// current reference geometry. Returns warnings. pub(super) fn refresh(s: &Session, doc: &Document, id: u64, sk: &mut Sketch) -> Vec { if sk.links.is_empty() { return Vec::new(); } - let Ok(plane) = sketch_plane(doc, id) else { return Vec::new() }; + let doc = placed_candidate(s, doc); + let Ok(plane) = sketch_plane(&doc, id) else { return Vec::new() }; let (vals, _) = doc.param_values(); let st = s.model.state_before(id); - solvecraft_doc::project::refresh_links(doc, &vals, &st, &plane, sk) + let comp = doc.feature(id).map(|f| f.component).unwrap_or(0); + let st = solvecraft_doc::state_in_frame_at(&doc, &st, comp, owner_frame(s, &doc, comp)); + solvecraft_doc::project::refresh_links(&doc, &vals, &st, &plane, sk) } /// Add a link for `src` to sketch `id` in `doc` (not stored in the session). Returns the link id. pub(super) fn add_link(s: &Session, doc: &Document, id: u64, sk: &mut Sketch, kind: LinkKind, src: LinkSource) -> Result { - let plane = sketch_plane(doc, id)?; + let doc = placed_candidate(s, doc); + let plane = sketch_plane(&doc, id)?; let (vals, _) = doc.param_values(); let st = s.model.state_before(id); // The model as the sketch's component sees it. let comp = doc.feature(id).map(|f| f.component).unwrap_or(0); - let st = solvecraft_doc::state_in_frame(doc, &st, comp); - let r = resolve(doc, &vals, &st, &plane, kind, &src)?; + let st = solvecraft_doc::state_in_frame_at(&doc, &st, comp, owner_frame(s, &doc, comp)); + let r = resolve(&doc, &vals, &st, &plane, kind, &src)?; let name = if let LinkSource::Face { at, .. } = &r.source { solvecraft_doc::naming::face_names_at(&st, &[*at]).into_iter().next().filter(|n| !n.is_empty()) } else { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 86d8a4b8..e371eb15 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -133,6 +133,7 @@ pub struct Snapshot { pub label: String, pub doc: Arc, pub active_sketch: Option, + pub pending_moves: std::collections::BTreeMap, } /// What some commands would make, evaluated on a scratch copy of a session @@ -184,12 +185,13 @@ pub struct Session { next_checkpoint: u64, } -/// The design, active sketch and undo history at an `edit.checkpoint`. +/// The design, active sketch, pending positions and undo history at an `edit.checkpoint`. #[derive(Clone, Debug)] pub(crate) struct Checkpoint { pub id: u64, pub doc: Arc, pub active_sketch: Option, + pub pending_moves: std::collections::BTreeMap, pub undo: Vec, pub redo: Vec, } @@ -332,6 +334,13 @@ impl Session { ) { sketch.plane = mapped; } + for wire in &mut sketch.sketch.wires { + if wire.space == solvecraft_doc::sketch::WireSpace::Component { + for point in wire.pts.iter_mut().chain(&mut wire.fit) { + *point = solvecraft_doc::apply_point(&delta, *point); + } + } + } } } } @@ -362,7 +371,12 @@ impl Session { pub fn execute(&mut self, id: &str, params: &Value) -> Result { let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?; (spec.enabled)(self).map_err(|why| EngineError::Disabled(spec.id.to_string(), why))?; - let before = Snapshot { label: spec.label.to_string(), doc: self.doc.clone(), active_sketch: self.active_sketch }; + let before = Snapshot { + label: spec.label.to_string(), + doc: self.doc.clone(), + active_sketch: self.active_sketch, + pending_moves: self.pending_moves.clone(), + }; let null = Value::Object(Default::default()); let params = if params.is_null() { &null } else { params }; // Picks in the world go into their component's frame (once: commands run by commands @@ -394,9 +408,10 @@ impl Session { Ok(v) } Err(e) => { - if changed { + if changed || self.pending_moves != before.pending_moves { self.doc = before.doc; self.active_sketch = before.active_sketch; + self.pending_moves = before.pending_moves; self.refresh(); } Err(e) @@ -404,7 +419,7 @@ impl Session { } } - /// Remember the design, active sketch and undo history; returns the checkpoint's id. + /// Remember the design, active sketch, pending positions and undo history; returns the checkpoint's id. pub fn checkpoint(&mut self) -> u64 { let id = self.next_checkpoint; self.next_checkpoint += 1; @@ -413,6 +428,7 @@ impl Session { doc: self.doc.clone(), active_sketch: self.active_sketch, undo: self.undo.clone(), + pending_moves: self.pending_moves.clone(), redo: self.redo.clone(), }); if self.checkpoints.len() > MAX_CHECKPOINTS { @@ -428,6 +444,7 @@ impl Session { let Some(c) = self.checkpoints.drain(i..).next() else { return Err(EngineError::Other(format!("no checkpoint {id}"))) }; self.doc = c.doc; self.active_sketch = c.active_sketch; + self.pending_moves = c.pending_moves; self.undo = c.undo; self.redo = c.redo; self.selection.clear(); diff --git a/crates/engine/src/occurrences.rs b/crates/engine/src/occurrences.rs index 136a158d..70a59c3d 100644 --- a/crates/engine/src/occurrences.rs +++ b/crates/engine/src/occurrences.rs @@ -79,7 +79,7 @@ impl Session { let bodies = state .bodies .iter() - .filter(|b| self.doc.body_component(&b.name, b.feature) == component) + .filter(|b| self.doc.model_body_component(b) == component) .map(|b| OccurrenceBody { source: b.name.clone(), world: self.doc.world_body_name(&state, &b.name, component, path) }) .collect(); Ok(OccurrencePlacement { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e156d671..9e2529ac 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -864,7 +864,14 @@ fn holes_at_sketch_points_threads_and_components() { let text = s.doc.to_json(); let back = solvecraft_doc::Document::from_json(&text).unwrap(); assert_eq!(back.components.len(), 2); - assert_eq!(back.body_components.len(), 1); + let assigned = r["components"][0]["component"].as_u64().unwrap(); + let transfer = r["components"][0]["feature"].as_u64().unwrap(); + assert!( + matches!(&back.feature(transfer).unwrap().kind, solvecraft_doc::FeatureKind::BodyTransfer { bodies, to, .. } if bodies == &["Shaft"] && *to == assigned) + ); + let reopened = Session::new(back); + assert_eq!(reopened.model.state().body("Shaft").unwrap().component, assigned); + assert_eq!(reopened.world_state().threads.len(), s.world_state().threads.len()); } #[test] @@ -985,11 +992,22 @@ fn components_occurrences_and_world_placement() { run(&mut s, "component.capture_position", json!({})); assert!(s.pending_moves.is_empty()); assert!((bbox(&mut s, "ArmBody")["min"][2].as_f64().unwrap() - 5.0).abs() < 1e-9); - // Bodies and sketches move between components. - run(&mut s, "component.move_bodies", json!({"bodies": ["Base"], "component": "Arm"})); + // A definition placed twice cannot receive one world body without duplicating it. + let document = s.doc.to_json(); + let pending = s.pending_moves.clone(); + let error = s.execute("component.move_bodies", &json!({"bodies": ["Base"], "component": "Arm"})).unwrap_err(); + assert!(error.to_string().contains("one source and destination placement"), "{error}"); + assert_eq!(s.doc.to_json(), document); + assert_eq!(s.pending_moves, pending); + // The independent Paste New result has one placement and can receive it. + let copied = pn["component"].as_u64().unwrap(); + run(&mut s, "component.move_bodies", json!({"bodies": ["Base"], "component": copied})); let l = run(&mut s, "component.list", json!({})); let arm = l["components"].as_array().unwrap().iter().find(|x| x["id"] == cid).unwrap().clone(); - assert!(arm["bodies"].as_array().unwrap().iter().any(|b| b == "Base"), "{arm}"); + assert!(!arm["bodies"].as_array().unwrap().iter().any(|b| b == "Base"), "{arm}"); + let receiver = l["components"].as_array().unwrap().iter().find(|x| x["id"] == copied).unwrap(); + assert!(receiver["bodies"].as_array().unwrap().iter().any(|b| b == "Base"), "{receiver}"); + assert_eq!(bbox(&mut s, "Base")["min"], json!([0.0, 0.0, 0.0])); run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S1"})); run(&mut s, "sketch.finish", json!({})); run(&mut s, "component.move_sketches", json!({"sketches": ["S1"], "component": cid})); @@ -999,8 +1017,9 @@ fn components_occurrences_and_world_placement() { assert_eq!(s.doc.find_feature("S1").unwrap().component, 0); run(&mut s, "edit.redo", json!({})); run(&mut s, "timeline.roll_to", json!({"position": 1})); - // Only Base is left; it now lives in Arm, which is placed twice (original and instance). - assert_eq!(s.world_state().bodies.len(), 2); + // The ownership boundary is later: before it Base remains the sole root body. + assert_eq!(s.world_state().bodies.len(), 1); + assert_eq!(s.world_state().body("Base").unwrap().component, 0); run(&mut s, "timeline.roll_to", json!({})); // Saved and loaded; older designs get occurrences. let back = solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap(); diff --git a/crates/kernel/src/body.rs b/crates/kernel/src/body.rs index eafac71e..cbe92ed2 100644 --- a/crates/kernel/src/body.rs +++ b/crates/kernel/src/body.rs @@ -27,6 +27,15 @@ pub struct Body { /// Lines the user split faces along (Split Face), as polylines: export keeps the faces on /// either side apart where it would otherwise join pieces of one surface. pub(crate) splits: Option>>>, + /// Exact construction/import source identities, in this body's face order. + pub(crate) face_sources: Option>>>, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum FaceSource { + ExtrudeSide { segment: solvecraft_geom::Seg2 }, + CertifiedExtrudeSide { producer: u64, sketch: u64, curve: String }, + Imported { item: u64, face: u64, payload_sha256: Option, producer: Option }, } /// How a body looks beyond its colour, for export: its opacity and its faces' own colours. @@ -287,6 +296,7 @@ impl Body { && self.color == other.color && self.paint == other.paint && self.splits == other.splits + && self.face_sources == other.face_sources } pub(crate) fn new(solid: Solid) -> Result { @@ -324,9 +334,61 @@ impl Body { color: None, paint: None, splits: None, + face_sources: None, }) } + pub fn face_sources(&self) -> Option<&[Option]> { + self.face_sources.as_deref().map(Vec::as_slice) + } + + /// Bind constructed edges to uniquely proven source curves without changing face names. + pub fn with_extrude_certificates(mut self, producer: u64, sketch: u64, curves: &[Option]) -> Result { + if producer == 0 || sketch == 0 || curves.iter().flatten().any(|curve| curve.is_empty() || curve.len() > 128) { + return Err(KernelError::Invalid("invalid constructed side certificate".into())); + } + if let Some(sources) = &self.face_sources { + if sources.len() != curves.len() { + return Err(KernelError::Invalid("side certificate count mismatch".into())); + } + let sources = sources + .iter() + .zip(curves) + .map(|(source, curve)| match (source, curve) { + (Some(FaceSource::ExtrudeSide { .. }), Some(curve)) => { + Some(FaceSource::CertifiedExtrudeSide { producer, sketch, curve: curve.clone() }) + } + (Some(FaceSource::ExtrudeSide { .. }), None) => None, + _ => source.clone(), + }) + .collect(); + self.face_sources = Some(std::sync::Arc::new(sources)); + } + Ok(self) + } + + pub fn with_import_certificate(mut self, digest: &str, producer: u64) -> Result { + if producer == 0 || digest.len() != 64 || !digest.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) { + return Err(KernelError::Invalid("invalid immutable import certificate".into())); + } + if let Some(sources) = &self.face_sources { + let mut sources = sources.as_ref().clone(); + for source in sources.iter_mut().flatten() { + if let FaceSource::Imported { payload_sha256, producer: origin, .. } = source { + *payload_sha256 = Some(digest.into()); + *origin = Some(producer); + } + } + self.face_sources = Some(std::sync::Arc::new(sources)); + } + Ok(self) + } + + pub(crate) fn with_face_sources(mut self, sources: Option>>>) -> Self { + self.face_sources = sources; + self + } + /// A deep copy of the solid that can be mutated without affecting other bodies. pub(crate) fn deep_copy(&self) -> Solid { mt::builder::clone(&*self.solid) diff --git a/crates/kernel/src/build.rs b/crates/kernel/src/build.rs index f6fc3ba6..03899b3d 100644 --- a/crates/kernel/src/build.rs +++ b/crates/kernel/src/build.rs @@ -15,6 +15,10 @@ fn finite(x: f64, what: &str) -> Result { /// A closed wire in 3D from a sketch loop. pub(crate) fn wire(plane: &Plane, lp: &Loop2) -> Result { + wire_sources(plane, lp).map(|(wire, _)| wire) +} + +fn wire_sources(plane: &Plane, lp: &Loop2) -> Result<(mt::Wire, Vec<(mt::Edge, Seg2)>)> { let n = lp.segs.len(); if n == 0 || n > MAX_SEGS { return Err(KernelError::Invalid(format!("loop with {n} segments"))); @@ -24,6 +28,7 @@ pub(crate) fn wire(plane: &Plane, lp: &Loop2) -> Result { } let verts: Vec = lp.segs.iter().map(|s| builder::vertex(p3(plane.to_world(s.start())))).collect(); let mut edges = Vec::with_capacity(n); + let mut sources = Vec::with_capacity(n); for (i, s) in lp.segs.iter().enumerate() { let (Some(a), Some(b)) = (verts.get(i), verts.get((i + 1) % n)) else { continue }; let e: mt::Edge = match *s { @@ -35,7 +40,9 @@ pub(crate) fn wire(plane: &Plane, lp: &Loop2) -> Result { // Split big arcs so every piece is well conditioned. if sweep.abs() > std::f64::consts::PI * 1.5 { let vm = builder::vertex(p3(plane.to_world(s.mid()))); - edges.push(builder::circle_arc(a, &vm, p3(plane.to_world(s.point_at(0.25))))); + let first = builder::circle_arc(a, &vm, p3(plane.to_world(s.point_at(0.25)))); + sources.push((first.clone(), *s)); + edges.push(first); builder::circle_arc(&vm, b, p3(plane.to_world(s.point_at(0.75)))) } else { builder::circle_arc(a, b, p3(plane.to_world(s.mid()))) @@ -45,9 +52,10 @@ pub(crate) fn wire(plane: &Plane, lp: &Loop2) -> Result { crate::freeform::edge(plane, s, a, b).ok_or_else(|| KernelError::Invalid("profile curve".into()))? } }; + sources.push((e.clone(), *s)); edges.push(e); } - Ok(edges.into()) + Ok((edges.into(), sources)) } /// A planar face from a region (outer loop with holes). @@ -95,9 +103,35 @@ pub fn extrude(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Resu for r in regions { region_ok(r)?; let base = plane.offset(lo); - let f = face(&base, r)?; + let (outer, mut sources) = wire_sources(&base, &r.outer.ccw())?; + let mut wires = vec![outer]; + for hole in &r.holes { + let (wire, mut hole_sources) = wire_sources(&base, &hole.ccw().reversed())?; + wires.push(wire); + sources.append(&mut hole_sources); + } + let f = guard("extrude profile", || builder::try_attach_plane(&wires).map_err(|e| KernelError::Invalid(format!("profile: {e}"))))?; let solid: Solid = guard("extrude", || Ok(builder::tsweep(&f, v3(n * (hi - lo)))))?; - out.push(Body::new(solid)?); + let body = Body::new(solid)?; + // tsweep retains each actual input boundary edge on its generated side. + // Match topology identity, never face order, surface samples or centroids. + let provenance = guard("extrude source identities", || { + let source_edges: std::collections::HashMap<_, _> = sources.iter().map(|(edge, segment)| (edge.id(), *segment)).collect(); + let mut result = Vec::new(); + for generated in body.solid.face_iter() { + let matching: Vec<_> = generated.absolute_boundaries().iter().flatten().filter_map(|edge| source_edges.get(&edge.id())).collect(); + let source = if generated.id() == f.id() { + None + } else if let [segment] = matching.as_slice() { + Some(crate::FaceSource::ExtrudeSide { segment: **segment }) + } else { + None + }; + result.push(source); + } + Ok(result) + })?; + out.push(body.with_face_sources(Some(std::sync::Arc::new(provenance)))); } Ok(out) } diff --git a/crates/kernel/src/emboss.rs b/crates/kernel/src/emboss.rs index 9d0c337f..806af16f 100644 --- a/crates/kernel/src/emboss.rs +++ b/crates/kernel/src/emboss.rs @@ -17,6 +17,21 @@ fn unwrap(a: f64, around: f64) -> f64 { around + (a - around + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI } +fn wall_angles(face: &mt::Face, o: Vec3, axis: Vec3, radial: Vec3, tangent: Vec3, tol: f64) -> Option<(f64, f64)> { + if face.boundaries().len() != 1 || face.edge_iter().count() != 4 { + return None; + } + for edge in face.edge_iter() { + if let Some(Some(mid)) = crate::offset::arc_mid(&edge, tol * 10.) { + let am = angle(mid, o, axis, radial, tangent); + let aa = unwrap(angle(from_p3(edge.front().point()), o, axis, radial, tangent), am); + let ab = unwrap(angle(from_p3(edge.back().point()), o, axis, radial, tangent), am); + return Some((aa.min(ab), aa.max(ab))); + } + } + None +} + /// Certify a tessellated pick and project it onto the cylinder used by emboss. pub fn cylinder_emboss_anchor(body: &Body, face: usize, at: Vec3) -> Result { if !at.is_finite() || at.len() > 1e9 { @@ -40,7 +55,7 @@ pub fn cylinder_emboss_anchor(body: &Body, face: usize, at: Vec3) -> Result Result { - emboss_impl(body, None, plane, regions, depth) + emboss_impl(body, None, plane, regions, depth, false).and_then(EmbossOutcome::body) } /// As [`emboss_cylinder`], requiring the saved selected point to identify that wall. @@ -48,10 +63,101 @@ pub fn emboss_cylinder_at(body: &Body, at: Vec3, plane: &Plane, regions: &[Regio if !at.is_finite() || at.len() > 1e9 { return Err(fail("invalid selected face point")); } - emboss_impl(body, Some(at), plane, regions, depth) + emboss_impl(body, Some(at), plane, regions, depth, false).and_then(EmbossOutcome::body) } -fn emboss_impl(body: &Body, anchor: Option, plane: &Plane, regions: &[Region2], depth: f64) -> Result { +enum EmbossOutcome { + Body(Body), + Anchor(usize, Vec3), + NoMatch, +} +impl EmbossOutcome { + fn body(self) -> Result { + match self { + Self::Body(body) => Ok(body), + Self::Anchor(..) | Self::NoMatch => Err(fail("unexpected qualification result")), + } + } +} + +/// Qualify a tangent cylindrical target without changing it. A tangent exterior +/// cylinder with an unsupported profile or depth returns an explicit error; +/// a body without such a tangent wall returns `None` for planar compatibility. +/// The anchor lies on a validated profile boundary, clear of seams and endcaps. +pub fn infer_cylinder_emboss(body: &Body, plane: &Plane, regions: &[Region2], depth: f64) -> Result> { + if body.is_mesh() { + return Ok(None); + } + guard("cylinder emboss inference", || { + let tol = (body.size() * 1e-8).max(1e-8); + let mut tangent = false; + for (index, face) in body.solid.face_iter().enumerate() { + if index >= 50_000 { + return Err(fail("target search exceeds face work limit")); + } + if let Some(Surf::Cylinder { o, a, r, convex: true }) = surf_of(face, tol) { + let w = plane.origin - o; + let normal = plane.normal(); + let signed = w.dot(normal); + if normal.dot(a).abs() < 1e-8 && (signed.abs() - r).abs() <= tol * 10.0 { + let radial = if signed < 0.0 { -normal } else { normal }; + // Every supported uninterrupted wall uses one angle interval + // around this tangent normal, contained in (-pi,pi). A remote + // footprint entirely outside even that global interval cannot + // select this body, regardless of other unsupported faces. + if !profile_interval_overlaps(plane, regions, o, a.cross(radial), -std::f64::consts::PI * r, std::f64::consts::PI * r)? { + continue; + } + if let Some((lo, hi)) = wall_angles(face, o, a, radial, a.cross(radial), tol) { + if lo >= -tol / r || hi <= tol / r { + continue; + } + if !profile_interval_overlaps(plane, regions, o, a.cross(radial), lo * r, hi * r)? { + continue; + } + } + let relevant = match face_axial_bounds(face, o, a)? { + Some((lo, hi)) => profile_interval_overlaps(plane, regions, o, a, lo, hi)?, + None => true, + }; + if relevant && signed < 0.0 { + return Err(fail("sketch normal must point outwards from the exterior wall")); + } + tangent |= relevant; + } + } + } + if !tangent { + return Ok(None); + } + match emboss_impl(body, None, plane, regions, depth, true)? { + EmbossOutcome::Anchor(index, at) => Ok(Some((index, at))), + EmbossOutcome::NoMatch => Ok(None), + EmbossOutcome::Body(_) => Err(fail("unexpected construction result")), + } + }) +} + +/// Whether a body contains an analytic exterior cylindrical wall. This bounded +/// preflight avoids resolving cylinder placement context for ordinary planar targets. +pub fn has_cylinder_emboss_wall(body: &Body) -> Result { + if body.is_mesh() { + return Ok(false); + } + guard("cylinder emboss target", || { + let tol = (body.size() * 1e-8).max(1e-8); + for (index, face) in body.solid.face_iter().enumerate() { + if index >= 50_000 { + return Err(fail("target search exceeds face work limit")); + } + if matches!(surf_of(face, tol), Some(Surf::Cylinder { convex: true, .. })) { + return Ok(true); + } + } + Ok(false) + }) +} +fn emboss_impl(body: &Body, anchor: Option, plane: &Plane, regions: &[Region2], depth: f64, qualify: bool) -> Result { body.require_brep("cylinder emboss")?; if !depth.is_finite() || depth.abs() < 1e-6 || depth.abs() > 1e6 || regions.is_empty() || regions.len() > 64 { return Err(fail("needs finite nonzero depth and 1..64 profiles")); @@ -77,8 +183,8 @@ fn emboss_impl(body: &Body, anchor: Option, plane: &Plane, regions: &[Regi for (fi, s) in surfaces.iter().enumerate() { let Surf::Cylinder { o, a, r, convex: true } = *s else { continue }; let w = plane.origin - o; - let radial = w - a * w.dot(a); - if (radial.len() - r).abs() > tol * 10.0 || plane.normal().dot(radial / r) < 1.0 - 1e-8 { + let radial = plane.normal(); + if radial.dot(a).abs() > 1e-8 || (w.dot(radial) - r).abs() > tol * 10.0 { continue; } let radial = radial.normalized().ok_or_else(|| fail("radial frame"))?; @@ -87,17 +193,9 @@ fn emboss_impl(body: &Body, anchor: Option, plane: &Plane, regions: &[Regi if f.boundaries().len() != 1 || f.edge_iter().count() != 4 { continue; } - let mut angular = None; - for e in f.edge_iter() { - if let Some(Some(mid)) = crate::offset::arc_mid(&e, tol * 10.0) { - let am = angle(mid, o, a, radial, tangent); - let aa = unwrap(angle(from_p3(e.front().point()), o, a, radial, tangent), am); - let ab = unwrap(angle(from_p3(e.back().point()), o, a, radial, tangent), am); - angular = Some((aa.min(ab), aa.max(ab))); - break; - } - } - let Some((alo, ahi)) = angular else { continue }; + let Some((alo, ahi)) = wall_angles(f, o, a, radial, tangent, tol) else { + continue; + }; if alo >= -tol / r || ahi <= tol / r { continue; } @@ -108,6 +206,9 @@ fn emboss_impl(body: &Body, anchor: Option, plane: &Plane, regions: &[Regi } let (fi, o, axis, r, radial, tangent, alo, ahi) = chosen.ok_or_else(|| fail("sketch must be tangent to one complete exterior cylindrical wall, clear of its seam"))?; + if qualify && !profile_interval_overlaps(plane, regions, o, tangent, alo * r, ahi * r)? { + return Ok(EmbossOutcome::NoMatch); + } let mut caps = Vec::new(); let mut inner: f64 = 0.0; for s in &surfaces { @@ -131,6 +232,12 @@ fn emboss_impl(body: &Body, anchor: Option, plane: &Plane, regions: &[Regi } let lo = caps.iter().copied().fold(f64::INFINITY, f64::min); let hi = caps.iter().copied().fold(f64::NEG_INFINITY, f64::max); + // An infinite cylinder can be tangent while its finite wall is far from + // the selected footprint. Conservative analytic/control-polygon bounds + // exclude that body before unsupported-profile or depth diagnostics. + if qualify && !profile_interval_overlaps(plane, regions, o, axis, lo, hi)? { + return Ok(EmbossOutcome::NoMatch); + } let rr = r + depth; if rr <= inner + tol * 10.0 { return Err(fail("depth reaches the axis or inner wall")); @@ -144,14 +251,21 @@ fn emboss_impl(body: &Body, anchor: Option, plane: &Plane, regions: &[Regi } } let z0 = (plane.origin - o).dot(axis); - let map2 = |p: Vec2| Vec2::new(plane.dir_to_world(p).dot(tangent), z0 + plane.dir_to_world(p).dot(axis)); + let s0 = (plane.origin - o).dot(tangent); + let map2 = |p: Vec2| Vec2::new(s0 + plane.dir_to_world(p).dot(tangent), z0 + plane.dir_to_world(p).dot(axis)); let mut count = 0usize; let mut mapped = Vec::new(); for reg in regions { let mut loops = Vec::new(); for lp in std::iter::once(®.outer).chain(®.holes) { count = count.saturating_add(lp.segs.len()); - if count > 4096 || lp.segs.len() < 4 || !lp.is_closed(tol) { + if count > 4096 { + return Err(fail("profile geometry budget")); + } + if lp.segs.iter().any(|segment| !matches!(segment, Seg2::Line { .. })) { + return Err(fail("only orthogonal line profiles are supported on cylinders")); + } + if lp.segs.len() < 4 || !lp.is_closed(tol) { return Err(fail("profile geometry budget or open loop")); } let mut segs = Vec::new(); @@ -181,6 +295,12 @@ fn emboss_impl(body: &Body, anchor: Option, plane: &Plane, regions: &[Regi mapped.push(Region2 { outer, holes }); } validate_profiles(&mapped, tol)?; + if qualify { + let segment = mapped.first().and_then(|region| region.outer.segs.first()).ok_or_else(|| fail("empty profile"))?; + let p = (segment.start() + segment.end()) * 0.5; + let at = o + axis * p.y + (radial * (p.x / r).cos() + tangent * (p.x / r).sin()) * r; + return Ok(EmbossOutcome::Anchor(fi, at)); + } let at = |p: Vec2, radius: f64| o + axis * p.y + (radial * (p.x / r).cos() + tangent * (p.x / r).sin()) * radius; let selected = faces.get(fi).ok_or_else(|| fail("face"))?; let mut base_patches = vec![selected.absolute_boundaries().clone()]; @@ -252,10 +372,108 @@ fn emboss_impl(body: &Body, anchor: Option, plane: &Plane, regions: &[Regi if !result.validity().is_empty() { return Err(fail("rebuilt cylinder is not a closed manifold")); } - Ok(result) + Ok(EmbossOutcome::Body(result)) }) } +fn profile_interval_overlaps(plane: &Plane, regions: &[Region2], o: Vec3, axis: Vec3, lo: f64, hi: f64) -> Result { + let z0 = (plane.origin - o).dot(axis); + let direction = Vec2::new(plane.x.dot(axis), plane.y.dot(axis)); + let mut count = 0usize; + let mut overlaps = false; + for region in regions { + let mut min = f64::INFINITY; + let mut max = f64::NEG_INFINITY; + for segment in ®ion.outer.segs { + count = count.saturating_add(1); + if count > 4096 { + return Err(fail("profile geometry budget")); + } + let mut add = |p: Vec2| -> Result<()> { + if !p.is_finite() || p.len() > 1e7 { + return Err(fail("nonfinite or excessive profile coordinates")); + } + let z = z0 + p.dot(direction); + min = min.min(z); + max = max.max(z); + Ok(()) + }; + match *segment { + Seg2::Line { a, b } => { + add(a)?; + add(b)?; + } + Seg2::Arc { center, radius, .. } => { + add(center)?; + if !radius.is_finite() || radius <= 0.0 || radius > 1e7 { + return Err(fail("invalid profile radius")); + } + let z = z0 + center.dot(direction); + min = min.min(z - radius * direction.len()); + max = max.max(z + radius * direction.len()); + } + Seg2::Cubic { p0, p1, p2, p3 } => { + for p in [p0, p1, p2, p3] { + add(p)?; + } + } + Seg2::Conic { a, apex, b, w } if w.is_finite() && w > 0.0 => { + for p in [a, apex, b] { + add(p)?; + } + } + Seg2::Bezier { n, p } if (1..=7).contains(&n) => { + for p in p.iter().take(usize::from(n) + 1) { + add(*p)?; + } + } + _ => return Err(fail("unsupported profile geometry")), + } + } + overlaps |= min < hi && max > lo; + } + Ok(overlaps) +} + +fn face_axial_bounds(face: &mt::Face, o: Vec3, axis: Vec3) -> Result> { + let mut lo = f64::INFINITY; + let mut hi = f64::NEG_INFINITY; + let mut count = 0usize; + let mut add = |point: Vec3| -> Result<()> { + count = count.saturating_add(1); + if count > 100_000 || !point.is_finite() { + return Err(fail("target boundary work limit or nonfinite geometry")); + } + let z = (point - o).dot(axis); + lo = lo.min(z); + hi = hi.max(z); + Ok(()) + }; + for edge in face.edge_iter() { + match edge.curve() { + mt::Curve::Line(_) => { + add(from_p3(edge.front().point()))?; + add(from_p3(edge.back().point()))?; + } + mt::Curve::BSplineCurve(curve) => { + for point in curve.control_points() { + add(from_p3(*point))?; + } + } + mt::Curve::NurbsCurve(curve) => { + for point in curve.control_points() { + if !point.w.is_finite() || point.w <= 0.0 { + return Ok(None); + } + add(Vec3::new(point.x / point.w, point.y / point.w, point.z / point.w))?; + } + } + mt::Curve::IntersectionCurve(_) => return Ok(None), + } + } + Ok((lo.is_finite() && hi.is_finite()).then_some((lo, hi))) +} + fn wrapped_loop(lp: &Loop2, r: f64, rr: f64, at: &impl Fn(Vec2, f64) -> Vec3) -> Result<(mt::Wire, mt::Wire, Vec)> { let base: Vec<_> = lp.segs.iter().map(|s| builder::vertex(p3(at(s.start(), r)))).collect(); let top: Vec<_> = lp.segs.iter().map(|s| builder::vertex(p3(at(s.start(), rr)))).collect(); @@ -346,6 +564,45 @@ mod tests { Plane::new(Vec3::Y * 10., -Vec3::X, Vec3::Z).unwrap() } #[test] + fn inference_uses_finite_wall_footprint_and_an_interior_profile_anchor() { + let near = crate::cylinder(Vec3::ZERO, Vec3::Z, 10., 20.).unwrap(); + let far = crate::cylinder(Vec3::Z * 100., Vec3::Z, 10., 20.).unwrap(); + let other = crate::cylinder(Vec3::Z * 120., Vec3::Z, 8., 10.).unwrap(); + let unsupported = crate::ops::join_pieces(vec![far.clone(), other]).unwrap().unwrap(); + let regions = [profile()]; + for body in [&far, &unsupported] { + assert!(infer_cylinder_emboss(body, &plane(), ®ions, 1.).unwrap().is_none()); + } + for x in [20., 80.] { + let base = crate::cylinder(Vec3::X * x, Vec3::Z, 10., 20.).unwrap(); + let step = crate::cylinder(Vec3::X * x + Vec3::Z * 20., Vec3::Z, 8., 10.).unwrap(); + let remote = crate::ops::join_pieces(vec![base, step]).unwrap().unwrap(); + let inferred = infer_cylinder_emboss(&remote, &plane(), ®ions, 1.); + if x == 20. { + // The offset maps this footprint across the remote wall's seam; + // partial relevance must retain conservative unsupported refusal. + assert!(inferred.is_err()); + } else { + assert!(inferred.unwrap().is_none()); + } + } + let (index, anchor) = infer_cylinder_emboss(&near, &plane(), ®ions, 1.).unwrap().unwrap(); + assert!(anchor.z > 0. && anchor.z < 20.); + assert!((anchor.x.hypot(anchor.y) - 10.).abs() < 1e-9); + assert!(cylinder_emboss_anchor(&near, index, anchor).unwrap().dist(anchor) < 1e-10); + let shifted = Plane::new(Vec3::new(-3., 10., 0.), -Vec3::X, Vec3::Z).unwrap(); + let shifted_profile = + Region2 { outer: Loop2::polygon(&[Vec2::new(-5., 5.), Vec2::new(-1., 5.), Vec2::new(-1., 10.), Vec2::new(-5., 10.)]), holes: vec![] }; + let (_, other_anchor) = infer_cylinder_emboss(&near, &shifted, std::slice::from_ref(&shifted_profile), 1.).unwrap().unwrap(); + assert!(other_anchor.dist(anchor) < 1e-10); + let before = near.tessellate_with(1e-4, true).unwrap().measure().volume; + let result = emboss_cylinder_at(&near, other_anchor, &shifted, &[shifted_profile], 1.).unwrap(); + assert!((result.tessellate_with(1e-4, true).unwrap().measure().volume - before - 21.).abs() < 0.02); + assert!(infer_cylinder_emboss(&near, &plane(), ®ions, -10.).is_err()); + let inward = Plane::new(Vec3::Y * 10., Vec3::X, Vec3::Z).unwrap(); + assert!(infer_cylinder_emboss(&near, &inward, ®ions, 1.).unwrap_err().to_string().contains("outwards")); + } + #[test] fn wrapped_nested_island_uses_its_enclosing_hole_patch() { let body = crate::cylinder(Vec3::ZERO, Vec3::Z, 10., 20.).unwrap(); let mut ring = profile(); diff --git a/crates/kernel/src/face_source_tests.rs b/crates/kernel/src/face_source_tests.rs new file mode 100644 index 00000000..8968b2b5 --- /dev/null +++ b/crates/kernel/src/face_source_tests.rs @@ -0,0 +1,75 @@ +use crate::{FaceSource, extrude, transform_matrix}; +use solvecraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3}; + +#[test] +fn constructed_annulus_sides_follow_exact_source_edges() { + let ring = Region2 { outer: Loop2::circle(Vec2::default(), 10.), holes: vec![Loop2::circle(Vec2::default(), 9.).reversed()] }; + for (start, end) in [(0., 7.), (-7., 0.), (-7., 7.)] { + let bodies = extrude(&Plane::XY, std::slice::from_ref(&ring), start, end).unwrap(); + let body = &bodies[0]; + let sources = body.face_sources().unwrap(); + assert_eq!(sources.len(), body.face_count()); + let mesh = body.tessellate(0.001).unwrap(); + let mut inner = 0; + let mut outer = 0; + for (index, source) in sources.iter().enumerate() { + let Some(FaceSource::ExtrudeSide { segment: Seg2::Arc { radius, .. } }) = source else { continue }; + if (*radius - 10.).abs() < 1e-12 { + outer += 1; + } else { + assert!((*radius - 9.).abs() < 1e-12); + inner += 1; + } + let mut vertices = 0; + for (triangle, face) in mesh.triangles.iter().zip(&mesh.tri_face) { + if *face as usize != index { + continue; + } + for point in mesh.tri(triangle).unwrap() { + assert!((point.x.hypot(point.y) - radius).abs() < 1e-8, "face {index}, source {radius}, {point:?}"); + vertices += 1; + } + } + assert!(vertices > 0); + } + assert_eq!((outer, inner), (2, 2), "full circles split into two actual edges each"); + assert_eq!(sources.iter().filter(|source| source.is_none()).count(), 2); + } +} + +#[test] +fn constructed_side_identity_survives_affine_placement_without_recognition() { + let region = Region2 { outer: Loop2::circle(Vec2::default(), 10.), holes: vec![] }; + let bodies = extrude(&Plane::named("XY").unwrap(), &[region], 0., 7.).unwrap(); + let source = &bodies[0]; + let placed = transform_matrix(source, [[0., 1., 0., 0.], [-1., 0., 0., 0.], [0., 0., 1., 0.], [25., 30., 40., 1.]]).unwrap(); + assert_eq!(placed.face_sources(), source.face_sources()); + assert!((placed.size() - source.size()).abs() < 1e-8); + assert!(placed.faces(0.001).unwrap().iter().all(|face| face.centroid.dist(Vec3::new(25., 30., 43.5)) < 11.)); +} + +#[test] +fn constructed_large_arc_split_and_reversal_preserve_one_source_segment() { + let arc = Seg2::Arc { center: Vec2::default(), radius: 10., start: 0., sweep: std::f64::consts::PI * 5. / 3. }; + let outline = Loop2 { segs: vec![arc, Seg2::Line { a: arc.end(), b: arc.start() }] }; + for outline in [outline.clone(), outline.reversed()] { + let body = extrude(&Plane::XY, &[Region2 { outer: outline, holes: vec![] }], 0., 7.).unwrap().remove(0); + let sources = body.face_sources().unwrap(); + let arcs: Vec<_> = sources + .iter() + .filter_map(|source| match source { + Some(FaceSource::ExtrudeSide { segment: Seg2::Arc { radius, sweep, .. } }) => Some((radius, sweep)), + _ => None, + }) + .collect(); + assert_eq!(arcs.len(), 2, "large input arc explicitly creates two actual boundary edges"); + assert!(arcs.iter().all(|(radius, sweep)| (**radius - 10.).abs() < 1e-12 && (sweep.abs() - std::f64::consts::PI * 5. / 3.).abs() < 1e-12)); + assert_eq!(sources.iter().filter(|source| matches!(source, Some(FaceSource::ExtrudeSide { segment: Seg2::Line { .. } }))).count(), 1); + let mesh = body.tessellate(0.001).unwrap(); + for (triangle, face) in mesh.triangles.iter().zip(&mesh.tri_face) { + if matches!(sources[*face as usize], Some(FaceSource::ExtrudeSide { segment: Seg2::Arc { .. } })) { + assert!(mesh.tri(triangle).unwrap().iter().all(|point| (point.x.hypot(point.y) - 10.).abs() < 1e-7)); + } + } + } +} diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index a464e526..00a48f02 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -21,6 +21,8 @@ mod coplanar; mod curveblend; mod delete_face; mod emboss; +#[cfg(test)] +mod face_source_tests; mod freeform; pub mod fuzz; mod heal; @@ -52,10 +54,10 @@ mod topo; pub use blend::{ChamferSide, chamfer, chamfer_sides, fillet}; pub use blend_corner::fillet_radii; pub use blend_variable::{fillet_chord, fillet_variable, fillet_variable_points}; -pub use body::{Body, EdgeInfo, FaceInfo, FacePaint, Paint}; +pub use body::{Body, EdgeInfo, FaceInfo, FacePaint, FaceSource, Paint}; pub use build::{PathSeg, box_solid, cylinder, extrude, extrude_tapered, loft, revolve, sphere, sweep, torus}; pub use delete_face::delete_faces; -pub use emboss::{cylinder_emboss_anchor, emboss_cylinder, emboss_cylinder_at}; +pub use emboss::{cylinder_emboss_anchor, emboss_cylinder, emboss_cylinder_at, has_cylinder_emboss_wall, infer_cylinder_emboss}; pub use helix::{loft_to_point, sweep_helix, sweep_helix_mesh}; pub use iges_in::{MAX_IGES_BYTES, iges_import, iges_to_step}; pub use iges_out::iges_export_bodies; diff --git a/crates/kernel/src/meshbody.rs b/crates/kernel/src/meshbody.rs index 4ec210a0..9c2a28c6 100644 --- a/crates/kernel/src/meshbody.rs +++ b/crates/kernel/src/meshbody.rs @@ -108,6 +108,7 @@ pub fn mesh_body(positions: &[Vec3], triangles: &[[u32; 3]]) -> Result { color: None, paint: None, splits: None, + face_sources: None, }) } @@ -199,6 +200,7 @@ impl Body { color: self.color, paint: self.paint.clone(), splits: self.splits.clone(), + face_sources: None, }) } } diff --git a/crates/kernel/src/ops.rs b/crates/kernel/src/ops.rs index 105e9f15..a3125ae7 100644 --- a/crates/kernel/src/ops.rs +++ b/crates/kernel/src/ops.rs @@ -531,7 +531,8 @@ pub fn transform_matrix(body: &Body, m: [[f64; 4]; 4]) -> Result { m[3][3], ); // An affine placement retains face order and appearance metadata. - Body::new(placed_topology(&body.solid, mat)?).map(|placed| placed.with_color(body.color()).with_paint(body.paint().cloned())) + Body::new(placed_topology(&body.solid, mat)?) + .map(|placed| placed.with_color(body.color()).with_paint(body.paint().cloned()).with_face_sources(body.face_sources.clone())) }) } diff --git a/crates/kernel/src/prism.rs b/crates/kernel/src/prism.rs index a6e1f280..a6c19847 100644 --- a/crates/kernel/src/prism.rs +++ b/crates/kernel/src/prism.rs @@ -862,9 +862,13 @@ fn stack_of(b: &Body, d: Vec3, plane: &Plane, tol: f64) -> Option> { } fn stack_boolean(a: &Body, b: &Body, op: BoolOp) -> Option>> { - // This path repairs joins with a common outer cap (a boss flush with a rim). - // Other extents and subtractive operations retain their existing B-rep path. - if op != BoolOp::Union { + // Common-cap joins and partial-depth cuts of aligned stepped prisms can + // retain their exact section boundaries instead of intersecting meshes. + // Other extents retain their existing B-rep path. + if !matches!(op, BoolOp::Union | BoolOp::Cut) { + return None; + } + if op == BoolOp::Cut && a.face_count().saturating_add(b.face_count()) > 2048 { return None; } fn section(ps: &[Prism], lo: f64, hi: f64, tol: f64) -> &[Region2] { @@ -882,28 +886,46 @@ fn stack_boolean(a: &Body, b: &Body, op: BoolOp) -> Option>> } } } + let ends = |ps: &[Prism]| ps.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), p| (lo.min(p.lo), hi.max(p.hi))); let (d, plane, pa, pb) = dirs.into_iter().take(8).find_map(|d| { let plane = Plane::from_normal(Vec3::ZERO, d)?; - Some((d, plane, stack_of(a, d, &plane, tol)?, stack_of(b, d, &plane, tol)?)) + let pa = stack_of(a, d, &plane, tol)?; + let pb = stack_of(b, d, &plane, tol)?; + let (ea, eb) = (ends(&pa), ends(&pb)); + if (ea.0 - eb.0).abs() >= tol && (ea.1 - eb.1).abs() >= tol { + return None; + } + Some((d, plane, pa, pb)) })?; - let ends = |ps: &[Prism]| ps.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), p| (lo.min(p.lo), hi.max(p.hi))); - let (ea, eb) = (ends(&pa), ends(&pb)); - if (ea.0 - eb.0).abs() >= tol && (ea.1 - eb.1).abs() >= tol { - return None; - } let mut levels: Vec = pa.iter().chain(&pb).flat_map(|p| [p.lo, p.hi]).collect(); levels.sort_by(f64::total_cmp); levels.dedup_by(|x, y| (*x - *y).abs() < tol); let mut slabs = Vec::new(); + let mut work = 0usize; + let mut output_edges = 0usize; + let edges = |regions: &[Region2]| regions.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)).map(|l| l.segs.len()).sum::(); for w in levels.windows(2) { let (lo, hi) = (*w.first()?, *w.get(1)?); let (x, y) = (section(&pa, lo, hi, tol), section(&pb, lo, hi, tol)); + if op == BoolOp::Cut { + let count = edges(x).saturating_add(edges(y)); + work = work.saturating_add(count.saturating_mul(count)); + if work > 32_000_000 { + return None; + } + } let s = match (x.is_empty(), y.is_empty(), op) { (true, _, BoolOp::Cut | BoolOp::Intersect) | (_, true, BoolOp::Intersect) => Vec::new(), (true, _, BoolOp::Union) => y.to_vec(), (_, true, _) => x.to_vec(), _ => boolean2d(x, y, op, tol)?, }; + if op == BoolOp::Cut { + output_edges = output_edges.saturating_add(edges(&s)); + if output_edges > 8192 { + return None; + } + } slabs.push(oriented(&s)); } if slabs.iter().all(Vec::is_empty) { @@ -923,6 +945,42 @@ mod planar_tests { Region2 { outer: Loop2::polygon(&[a, Vec2::new(b.x, a.y), b, Vec2::new(a.x, b.y)]), holes: vec![] } } #[test] + fn partial_depth_cuts_touching_a_through_hole_use_exact_stacked_sections() { + let plane = Plane::named("XY").unwrap(); + let mut plate = rectangle(Vec2::ZERO, Vec2::new(100., 60.)); + plate.holes.push(rectangle(Vec2::new(25., 20.), Vec2::new(30., 30.)).outer); + let stock = crate::extrude(&plane, &[plate], 0., 2.5).unwrap().pop().unwrap(); + let low = crate::extrude(&plane, &[rectangle(Vec2::new(20., 20.), Vec2::new(30., 30.))], 0., 1.5).unwrap().pop().unwrap(); + let high = crate::extrude(&plane, &[rectangle(Vec2::new(25., 20.), Vec2::new(35., 30.))], 1., 2.5).unwrap().pop().unwrap(); + for tools in [[&low, &high], [&high, &low]] { + let mut result = stock.clone(); + for tool in tools { + result = stack_boolean(&result, tool, BoolOp::Cut).expect("exact stack path").unwrap().unwrap(); + } + assert!(result.validity().is_empty()); + assert_eq!(result.shell_count(), 1); + assert!((crate::measure(&result).unwrap().volume - 14_725.).abs() < 1e-6); + let mesh = result.tessellate(1e-4).unwrap(); + for (point, material) in [ + (Vec3::new(22.5, 25., 0.5), false), + (Vec3::new(22.5, 25., 2.), true), + (Vec3::new(32.5, 25., 0.5), true), + (Vec3::new(32.5, 25., 2.), false), + (Vec3::new(27.5, 25., 1.25), false), + (Vec3::new(10., 10., 1.25), true), + ] { + assert_eq!(mesh.contains(point), material, "{point:?}"); + } + let imported = crate::step_import(&crate::step_export(&[&result], "layered cut").unwrap()).unwrap(); + assert!(imported.warnings.is_empty()); + assert!((crate::measure(&imported.bodies.first().unwrap().body).unwrap().volume - 14_725.).abs() < 1e-6); + let next = crate::extrude(&plane, &[rectangle(Vec2::new(20., 20.), Vec2::new(25., 30.))], 1.5, 2.5).unwrap().pop().unwrap(); + let opened = crate::boolean(&imported.bodies.first().unwrap().body, &next, BoolOp::Cut).unwrap().unwrap(); + assert!((crate::measure(&opened).unwrap().volume - 14_675.).abs() < 1e-6); + assert!(opened.validity().is_empty()); + } + } + #[test] fn compound_planar_extrusion_normalizes_overlap_and_retains_islands() { let plane = Plane::named("XY").unwrap(); let a = rectangle(Vec2::ZERO, Vec2::new(10.0, 10.0)); diff --git a/crates/kernel/src/splitface.rs b/crates/kernel/src/splitface.rs index 2f904cd4..8cfaf654 100644 --- a/crates/kernel/src/splitface.rs +++ b/crates/kernel/src/splitface.rs @@ -878,7 +878,15 @@ pub(crate) fn split_any(body: &Body, faces: &[usize], tool: &SplitTool) -> Resul let shells = std::mem::take(&mut imp.shells); let solid = mt::Solid::new_unchecked(shells.into_iter().map(mt::Shell::from).collect()); Ok(( - Body { solid: std::sync::Arc::new(solid), coarse_missing: None, mesh: None, color: body.color, paint: None, splits: body.splits.clone() }, + Body { + solid: std::sync::Arc::new(solid), + coarse_missing: None, + mesh: None, + color: body.color, + paint: None, + splits: body.splits.clone(), + face_sources: None, + }, imp, )) }) diff --git a/crates/kernel/src/step_in/mod.rs b/crates/kernel/src/step_in/mod.rs index 6f89fd40..794fdac3 100644 --- a/crates/kernel/src/step_in/mod.rs +++ b/crates/kernel/src/step_in/mod.rs @@ -34,6 +34,10 @@ const MAX_VISITS: usize = 100_000; /// A body read from a STEP file. #[derive(Clone, Debug)] pub struct ImportedBody { + /// Immutable STEP source item, scoped to its embedded payload. + pub source_item: u64, + /// Original STEP face entity of each kernel face, including seam pieces. + pub face_origin: Vec, /// Solid name (the brep's label), else the product name. pub name: String, pub body: Body, @@ -510,7 +514,21 @@ impl<'a> Reader<'a> { .collect(); let paint = crate::Paint { opacity: self.opacity.get(&item).copied().unwrap_or(1.0), faces }; let body = body.with_color(color).with_paint(Some(paint)); - self.bodies.push(ImportedBody { name, body, color, path: path.to_vec(), closed, file_faces }); + let identities = face_origin + .iter() + .map(|face| Some(crate::FaceSource::Imported { item, face: *face, payload_sha256: None, producer: None })) + .collect(); + let body = body.with_face_sources(Some(std::sync::Arc::new(identities))); + self.bodies.push(ImportedBody { + name, + body, + color, + path: path.to_vec(), + closed, + file_faces, + source_item: item, + face_origin, + }); } Err(err) => { let label = self.label(item); diff --git a/crates/kernel/src/surfaces.rs b/crates/kernel/src/surfaces.rs index 10be23d3..83f08f58 100644 --- a/crates/kernel/src/surfaces.rs +++ b/crates/kernel/src/surfaces.rs @@ -29,6 +29,7 @@ fn surface_body(faces: Vec) -> Body { color: None, paint: None, splits: None, + face_sources: None, } } @@ -45,6 +46,7 @@ fn body_of(faces: Vec) -> Result { color: None, paint: None, splits: None, + face_sources: None, }) } } diff --git a/crates/sketch/src/lib.rs b/crates/sketch/src/lib.rs index 07c3ddcd..486faa93 100644 --- a/crates/sketch/src/lib.rs +++ b/crates/sketch/src/lib.rs @@ -33,7 +33,7 @@ pub use profiles::{Profile, find_drawn_profiles, find_profiles, merge_regions}; pub use shape::{Shape, intersections}; pub use solver::{Hold, SolveReport, SolveStatus, measure_dimension, solve, solve_holding}; pub use text::{MAX_TEXT_CHARS, text_geometry}; -pub use wire::{MAX_FIT_POINTS, MAX_WIRE_POINTS, Wire, fit_polyline}; +pub use wire::{MAX_FIT_POINTS, MAX_WIRE_POINTS, Wire, WireSpace, fit_polyline}; #[cfg(test)] mod tests; diff --git a/crates/sketch/src/wire.rs b/crates/sketch/src/wire.rs index ab0fb296..94a5f925 100644 --- a/crates/sketch/src/wire.rs +++ b/crates/sketch/src/wire.rs @@ -1,4 +1,4 @@ -//! 3D sketch curves (wires): polylines in world coordinates that a sketch carries besides its +//! 3D sketch curves (wires): polylines with explicit coordinates that a sketch carries besides its //! planar geometry — included 3D edges, curves projected onto surfaces, intersection curves. //! They are reference geometry: drawn and usable as paths, not part of profiles or the solver. @@ -11,11 +11,35 @@ use crate::model::{Result, Sketch, SketchError}; /// Most points one wire may have. pub const MAX_WIRE_POINTS: usize = 100_000; +/// Coordinate meaning of a stored wire and its editable fit points. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WireSpace { + /// Legacy drawn or detached geometry remains physically fixed in the world. + #[default] + World, + /// Geometry follows the sketch's owning component placement. + Component, +} + +impl WireSpace { + /// Coordinate semantics of a retained legacy producer, including a currently lost link. + pub fn from_legacy_link(link: &Link) -> Self { + match (&link.kind, &link.source) { + (LinkKind::Include, LinkSource::Edge { .. } | LinkSource::SketchCurve { .. }) + | (_, LinkSource::OnSurface { .. } | LinkSource::Intersection { .. } | LinkSource::Iso { .. }) => Self::Component, + _ => Self::World, + } + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Wire { pub id: String, - /// World-space polyline. + /// Polyline in `space` coordinates. pub pts: Vec, + #[serde(default)] + pub space: WireSpace, #[serde(default, skip_serializing_if = "Option::is_none")] pub link: Option, /// Drawn in a 3D sketch: the points the curve was drawn through (one: a point; two: a @@ -68,8 +92,11 @@ pub fn fit_polyline(fit: &[Vec3]) -> Vec { } fn clean(w: &[Vec3]) -> Option> { + if w.len() > MAX_WIRE_POINTS { + return None; + } let mut out: Vec = Vec::new(); - for p in w.iter().take(MAX_WIRE_POINTS) { + for p in w { if !(p.is_finite() && p.x.abs() < 1e9 && p.y.abs() < 1e9 && p.z.abs() < 1e9) { return None; } @@ -80,7 +107,77 @@ fn clean(w: &[Vec3]) -> Option> { (out.len() >= 2).then_some(out) } +fn clean_wires(wires: &[Vec]) -> Result>> { + if wires.len() > crate::link::MAX_LINK_ENTITIES || wires.iter().fold(0usize, |n, w| n.saturating_add(w.len())) > 1_000_000 { + return Err(SketchError::Invalid("3D linked curves exceed the stored geometry budget".into())); + } + wires.iter().map(|w| clean(w).ok_or_else(|| SketchError::Invalid("3D linked curve points out of range or coincident".into()))).collect() +} + +fn drawn_polyline(fit: &[Vec3]) -> Result> { + if fit.is_empty() || fit.len() > MAX_FIT_POINTS { + return Err(SketchError::Invalid(format!("a 3D curve needs 1 to {MAX_FIT_POINTS} points"))); + } + if fit.iter().any(|p| !(p.is_finite() && p.x.abs() < 1e9 && p.y.abs() < 1e9 && p.z.abs() < 1e9)) { + return Err(SketchError::Invalid("3D point out of range".into())); + } + if fit.windows(2).any(|w| w[0].dist(w[1]) < 1e-9) { + return Err(SketchError::Invalid("the points of a 3D curve must differ".into())); + } + let pts = fit_polyline(fit); + if pts.iter().any(|p| !(p.is_finite() && p.x.abs() < 1e9 && p.y.abs() < 1e9 && p.z.abs() < 1e9)) { + return Err(SketchError::Invalid("3D spline samples out of range".into())); + } + Ok(pts) +} + impl Sketch { + fn wire_budget(&self, replacing: Option<&str>, new_count: usize, new_points: usize) -> Result<()> { + let mut count = new_count; + let mut points = new_points; + for w in self.wires.iter().filter(|w| replacing.is_none() || w.link.as_deref() != replacing) { + count = count.saturating_add(1); + points = points.saturating_add(w.pts.len()).saturating_add(w.fit.len()); + } + if count > crate::link::MAX_LINK_ENTITIES || points > 1_000_000 { + return Err(SketchError::Invalid("3D curves exceed the stored geometry budget".into())); + } + Ok(()) + } + + /// Validate saved arrays without regenerating legacy geometry or changing point values. + pub fn validate_wires(&self) -> Result<()> { + if self.wires.len() > crate::link::MAX_LINK_ENTITIES { + return Err(SketchError::Invalid("too many 3D curves".into())); + } + let mut work = 0usize; + let mut ids = std::collections::BTreeSet::new(); + for w in &self.wires { + work = work.saturating_add(w.pts.len()).saturating_add(w.fit.len()); + if work > 1_000_000 + || w.id.is_empty() + || w.id.len() > 512 + || !ids.insert(&w.id) + || w.link.as_ref().is_some_and(|id| id.is_empty() || id.len() > 512) + || w.pts.is_empty() + || w.pts.len() > MAX_WIRE_POINTS + || w.fit.len() > MAX_FIT_POINTS + || (w.pts.len() == 1 && w.fit.len() != 1) + || w.pts.iter().chain(&w.fit).any(|p| !(p.is_finite() && p.x.abs() < 1e9 && p.y.abs() < 1e9 && p.z.abs() < 1e9)) + { + return Err(SketchError::Invalid("3D curve arrays, ids or point bounds are invalid".into())); + } + } + Ok(()) + } + + /// Producer semantics for legacy wires with retained association provenance. Breaking a + /// link removes this evidence, so detached wires conservatively retain world coordinates. + pub fn legacy_wire_space(&self, wire: &Wire) -> WireSpace { + let Some(link) = wire.link.as_deref().and_then(|id| self.link(id)) else { return WireSpace::World }; + WireSpace::from_legacy_link(link) + } + pub fn wire_index(&self, id: &str) -> Option { self.wires.iter().position(|w| w.id == id) } @@ -92,7 +189,8 @@ impl Sketch { /// Add a link with planar geometry and/or 3D wires. pub fn add_link_with_wires(&mut self, kind: LinkKind, source: LinkSource, geom: &[LinkGeom], wires: &[Vec]) -> Result { - let ws: Vec> = wires.iter().filter_map(|w| clean(w)).collect(); + let ws = clean_wires(wires)?; + self.wire_budget(None, ws.len(), ws.iter().map(Vec::len).sum())?; if ws.is_empty() { return self.add_link(kind, source, geom); } @@ -112,7 +210,8 @@ impl Sketch { if self.link(id).is_none() { return Err(SketchError::Unknown(id.into())); } - let ws: Vec> = wires.iter().filter_map(|w| clean(w)).collect(); + let ws = clean_wires(wires)?; + self.wire_budget(Some(id), ws.len(), ws.iter().map(Vec::len).sum())?; let old: Vec = self.link_wires(id).iter().filter_map(|i| self.wires.get(*i).map(|w| w.id.clone())).collect(); self.wires.retain(|w| w.link.as_deref() != Some(id)); for (k, pts) in ws.into_iter().enumerate() { @@ -120,24 +219,22 @@ impl Sketch { Some(o) => o.clone(), None => self.fresh("w"), }; - self.wires.push(Wire { id: wid, pts, link: Some(id.to_string()), fit: Vec::new() }); + self.wires.push(Wire { id: wid, pts, space: WireSpace::Component, link: Some(id.to_string()), fit: Vec::new() }); } Ok(()) } /// Add a curve drawn in a 3D sketch through `fit` (see [`Wire::fit`]). pub fn add_drawn_wire(&mut self, fit: &[Vec3]) -> Result { - if fit.is_empty() || fit.len() > MAX_FIT_POINTS { - return Err(SketchError::Invalid(format!("a 3D curve needs 1 to {MAX_FIT_POINTS} points"))); - } - if fit.iter().any(|p| !(p.is_finite() && p.x.abs() < 1e9 && p.y.abs() < 1e9 && p.z.abs() < 1e9)) { - return Err(SketchError::Invalid("3D point out of range".into())); - } - if fit.windows(2).any(|w| w[0].dist(w[1]) < 1e-9) { - return Err(SketchError::Invalid("the points of a 3D curve must differ".into())); - } + self.add_drawn_wire_in(fit, WireSpace::World) + } + + /// Add a drawn curve without changing the coordinate meaning supplied by its author. + pub fn add_drawn_wire_in(&mut self, fit: &[Vec3], space: WireSpace) -> Result { + let pts = drawn_polyline(fit)?; + self.wire_budget(None, 1, pts.len() + fit.len())?; let id = self.fresh(if fit.len() == 1 { "q" } else { "w" }); - self.wires.push(Wire { id: id.clone(), pts: fit_polyline(fit), link: None, fit: fit.to_vec() }); + self.wires.push(Wire { id: id.clone(), pts, space, link: None, fit: fit.to_vec() }); Ok(id) } @@ -148,17 +245,32 @@ impl Sketch { return Err(SketchError::Invalid("3D point out of range".into())); } let w = self.wire_index(wire).ok_or_else(|| SketchError::Unknown(wire.into()))?; + let space = self + .wires + .get(w) + .filter(|w| w.link.is_none()) + .map(|w| w.space) + .ok_or_else(|| SketchError::Invalid("linked 3D geometry cannot be moved".into()))?; let from = self.wires.get(w).and_then(|w| w.fit.get(index)).copied().ok_or_else(|| SketchError::Unknown(format!("{wire} point {index}")))?; let mut n = 0; - for w in self.wires.iter_mut().filter(|w| !w.fit.is_empty()) { + let mut changes = Vec::new(); + for (index, w) in self.wires.iter().enumerate().filter(|(_, w)| !w.fit.is_empty() && w.link.is_none() && w.space == space) { + let mut fit = w.fit.clone(); let mut changed = false; - for p in w.fit.iter_mut().filter(|p| p.dist(from) < 1e-9) { + for p in fit.iter_mut().filter(|p| p.dist(from) < 1e-9) { *p = to; changed = true; n += 1; } if changed { - w.pts = fit_polyline(&w.fit); + let pts = drawn_polyline(&fit)?; + changes.push((index, fit, pts)); + } + } + for (index, fit, pts) in changes { + if let Some(w) = self.wires.get_mut(index) { + w.fit = fit; + w.pts = pts; } } Ok(n) @@ -170,3 +282,52 @@ impl Sketch { self.prune_links(); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wire_space_defaults_and_preserves_detached_provenance() { + let mut sk = Sketch::new(); + let id = sk.add_drawn_wire(&[Vec3::new(1.0, 2.0, 3.0)]).unwrap(); + assert_eq!(sk.wires[0].space, WireSpace::World); + let mut raw = serde_json::to_value(&sk.wires[0]).unwrap(); + raw.as_object_mut().unwrap().remove("space"); + assert_eq!(serde_json::from_value::(raw).unwrap().space, WireSpace::World); + let link = sk + .add_link_with_wires( + LinkKind::Include, + LinkSource::Edge { body: "Missing".into(), at: Vec3::ZERO }, + &[], + &[vec![Vec3::new(1.0, 2.0, 3.0), Vec3::new(1.0, 2.0, 4.0)]], + ) + .unwrap(); + sk.set_link_lost(&link, true); + let wire = sk.wires.iter().find(|w| w.link.as_deref() == Some(&link)).unwrap(); + assert_eq!(sk.legacy_wire_space(wire), WireSpace::Component); + let wid = wire.id.clone(); + sk.break_link(&link).unwrap(); + let wire = sk.wires.iter().find(|w| w.id == wid).unwrap(); + assert_eq!(wire.space, WireSpace::Component); + assert_eq!(sk.legacy_wire_space(wire), WireSpace::World); + assert_eq!(sk.wires.iter().find(|w| w.id == id).unwrap().space, WireSpace::World); + } + + #[test] + fn wire_space_invalid_producers_and_move_are_atomic() { + let mut sk = Sketch::new(); + let wire = sk.add_drawn_wire_in(&[Vec3::ZERO, Vec3::Z], WireSpace::Component).unwrap(); + sk.add_drawn_wire(&[Vec3::ZERO]).unwrap(); + assert_eq!(sk.move_drawn_point(&wire, 0, Vec3::X).unwrap(), 1); + assert_eq!(sk.wires[1].fit[0], Vec3::ZERO); + let before = sk.clone(); + assert!(sk.move_drawn_point(&wire, 0, Vec3::Z).is_err()); + assert_eq!(sk, before); + assert!(sk.add_link_with_wires(LinkKind::Include, LinkSource::Origin, &[], &[vec![Vec3::ZERO, Vec3::new(f64::NAN, 0.0, 0.0)]]).is_err()); + assert_eq!(sk, before); + assert!(sk.add_link_with_wires(LinkKind::Include, LinkSource::Origin, &[], &[vec![Vec3::ZERO; MAX_WIRE_POINTS + 1]]).is_err()); + assert_eq!(sk, before); + sk.validate_wires().unwrap(); + } +} diff --git a/crates/ui-egui/src/browser.rs b/crates/ui-egui/src/browser.rs index e4ecaf35..9ef7bd98 100644 --- a/crates/ui-egui/src/browser.rs +++ b/crates/ui-egui/src/browser.rs @@ -1120,7 +1120,7 @@ fn entries(app: &SolveApp, comp: u64, folder: &str) -> Vec { .state() .bodies .iter() - .filter(|b| doc.body_component(&b.name, b.feature) == comp) + .filter(|b| doc.model_body_component(b) == comp) .map(|b| Entry { key: b.name.clone(), row_key: format!("b:{}", b.name), diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index a4ab9738..f82ae28e 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -234,6 +234,12 @@ fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, let focus = std::mem::take(&mut d.focus); let half_height = app.cam.half_height(); let axis = revolve_axis(app, d); + if matches!(d.kind, Kind::Revolve { .. }) { + if let Some(axis) = axis { + draw_revolve(app, ui, painter, proj, d, base, bs, axis, focus); + } + return; + } // Two sides: the second distance gets its own arrow the other way. if let (Kind::Extrude { direction: crate::dialogs::TWO_SIDES, distance2, .. }, Some(n)) = (&mut d.kind, dir) && let Some(l2) = app.session.doc.eval(distance2, ValueKind::Length).ok().filter(|v| v.is_finite()) @@ -285,11 +291,6 @@ fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, }); let before = value.clone(); let mut box_at = bs + vec2(16.0, -30.0); - if let (Some(axis), Some(angle)) = (axis, len.filter(|_| kind == ValueKind::Angle)) - && let Some(at) = rotator(ui, painter, proj, base, axis, angle, value) - { - box_at = at + vec2(14.0, -12.0); - } if let (Some(n), Some(l)) = (dir, len.filter(|_| kind == ValueKind::Length)) { let n = n * sign; let baseline = to_span.map(f64::abs).unwrap_or(0.0); @@ -464,56 +465,295 @@ pub(crate) fn revolve_axis(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Vec3)> { Some((vector("origin")?, vector("dir")?.normalized()?)) } -/// The revolve angle rotator: an arc from the profile around the axis with a handle at its end -/// that drags the angle (5° steps; Alt or Ctrl: free). Returns the handle's screen position. +/// Draw angular extents in the same clockwise sense as the modeled solid. #[allow(clippy::too_many_arguments)] -fn rotator(ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, p0: Vec3, (o, a): (Vec3, Vec3), angle: f64, value: &mut String) -> Option { - let t = Tokens::get(); - let c = o + a * (p0 - o).dot(a); - let r = (p0 - c).len(); - if r < 1e-9 { - return None; +fn draw_revolve( + app: &SolveApp, + ui: &mut egui::Ui, + painter: &egui::Painter, + proj: &Proj, + d: &mut Dialog, + base: Vec3, + bs: Pos2, + axis: (Vec3, Vec3), + focus: bool, +) { + let to_mode = d.inputs.iter().any(|input| input.label == "To Object"); + let extent = revolve_display_extent(app, d, axis).unwrap_or(solvecraft_engine::doc::RevolveExtent { + start: 0.0, + sweep: f64::NAN, + rotation_axis: axis.1, + display_start: 0.0, + curved_to: false, + }); + let mut box_at = bs + vec2(16.0, -30.0); + if !extent.curved_to && extent.sweep.is_finite() { + let (origin, direction) = axis; + let centre = origin + direction * (base - origin).dot(direction); + let radius = (base - centre).len(); + if radius > 1e-9 { + let e1 = (base - centre) / radius; + let e2 = extent.rotation_axis.cross(e1); + let handed = -direction.dot(extent.rotation_axis); + let at = |angle: f64| centre + (e1 * angle.cos() + e2 * angle.sin()) * radius; + let points: Vec<_> = (0..=48).filter_map(|i| proj.to_screen(at(extent.display_start + extent.sweep * i as f64 / 48.0))).collect(); + painter.add(egui::Shape::line(points, Stroke::new(2.0, Tokens::get().manipulator))); + if let Some(end) = proj.to_screen(at(extent.display_start + extent.sweep)) { + box_at = end + vec2(14.0, -12.0); + } + if !to_mode { + let symmetric = !d.extra.contains_key("angle2") && d.extra.get("direction").and_then(serde_json::Value::as_str) == Some("symmetric"); + let other_angle = d + .extra + .get("angle2") + .and_then(|value| value.as_str()) + .and_then(|value| app.session.doc.eval(value, ValueKind::Angle).ok()) + .unwrap_or(0.0); + let range = if extent.sweep < 0.0 { (-std::f64::consts::TAU, 0.0) } else { (0.0, std::f64::consts::TAU) }; + if let Kind::Revolve { angle, .. } = &mut d.kind { + let boundary = extent.display_start + extent.sweep; + if let Some(end) = rotation_handle( + ui, + painter, + proj, + centre, + e1, + e2, + direction, + radius, + boundary, + if symmetric { 1.0 / (1.0 - handed * 0.5) } else { 1.0 }, + if symmetric { 0.0 } else { -other_angle * (1.0 - handed) }, + (range.0 - other_angle, range.1 - other_angle), + "revolve", + angle, + ) { + box_at = end + vec2(14.0, -12.0); + } + if symmetric { + rotation_handle( + ui, + painter, + proj, + centre, + e1, + e2, + direction, + radius, + extent.display_start, + -2.0 * handed, + 0.0, + range, + "revolve2", + angle, + ); + } + } + if let Some(other) = d.extra.get_mut("angle2") { + let mut value = other.as_str().map(str::to_owned).unwrap_or_else(|| other.to_string()); + let before = value.clone(); + let first_angle = extent.sweep - other_angle; + rotation_handle( + ui, + painter, + proj, + centre, + e1, + e2, + direction, + radius, + extent.display_start, + -handed, + 0.0, + (range.0 - first_angle, range.1 - first_angle), + "revolve2", + &mut value, + ); + if value != before { + *other = serde_json::Value::String(value); + } + } + } + } } - let e1 = (p0 - c) / r; - let e2 = a.cross(e1); - let at = |th: f64| c + (e1 * th.cos() + e2 * th.sin()) * r; - let n = 48; - let pts: Vec = (0..=n).filter_map(|i| proj.to_screen(at(angle * i as f64 / n as f64))).collect(); - let end = proj.to_screen(at(angle))?; - let handle = egui::Rect::from_center_size(end, vec2(20.0, 20.0)); - let resp = ui.interact(handle, egui::Id::new("sc_rotator"), egui::Sense::drag()); - let hot = resp.hovered() || resp.dragged(); - let col = if hot { t.accent } else { t.manipulator }; - painter.add(egui::Shape::line(pts, Stroke::new(2.0, col))); - if let (Some(cs), Some(ps)) = (proj.to_screen(c), proj.to_screen(p0)) { - painter.add(egui::Shape::dashed_line(&[cs, ps], Stroke::new(1.0, col), 4.0, 3.0)); - } - painter.circle(end, if hot { 7.0 } else { 5.5 }, col, Stroke::new(1.5, Color32::WHITE)); + egui::Area::new(egui::Id::new("sc_canvas_area")).fixed_pos(box_at).order(egui::Order::Foreground).show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).inner_margin(egui::Margin::symmetric(6, 3)).show(ui, |ui| { + if to_mode { + let text = if extent.curved_to { + "To surface".into() + } else if extent.sweep.is_finite() { + format!("Sweep {:.1}°", extent.sweep.to_degrees()) + } else { + "Choose target".into() + }; + ui.label(text).on_hover_text("Determined by To Object. Choose Angle to edit the angular extent."); + } else if let Kind::Revolve { angle, .. } = &mut d.kind { + ui.horizontal(|ui| { + ui.label(egui::RichText::new("Angle").color(Tokens::get().text_dim).size(11.5)); + let id = egui::Id::new("sc_canvas_value"); + let response = ui.add(egui::TextEdit::singleline(angle).id(id).desired_width(72.0)); + crate::params_dialog::complete(ui, &response, angle); + if focus { + response.request_focus(); + let n = angle.chars().count(); + let mut state = egui::text_edit::TextEditState::load(ui.ctx(), id).unwrap_or_default(); + state + .cursor + .set_char_range(Some(egui::text_selection::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(n)))); + state.store(ui.ctx(), id); + } + }); + } + }); + }); +} + +fn revolve_display_extent(app: &SolveApp, d: &Dialog, axis: (Vec3, Vec3)) -> Option { + let Sel::Profile { sketch, .. } = d.inputs.first()?.items.first()? else { return None }; + let state = app.session.world_state(); + let ss = state.sketch(*sketch)?; + let regions: Vec<_> = d + .inputs + .first()? + .items + .iter() + .filter_map(|item| match item { + Sel::Profile { sketch: id, index } if id == sketch => ss.profiles.get(*index).map(|profile| profile.region.clone()), + _ => None, + }) + .collect(); + let regions = solvecraft_engine::sketch::merge_regions(®ions); + let plane = profile_plane(app, *sketch)?; + let to_mode = d.inputs.iter().any(|input| input.label == "To Object"); + let to = if to_mode { + let point = crate::dialogs::object_point(&d.inputs, "To Object")?; + Some(Vec3::new(point.get(0)?.as_f64()?, point.get(1)?.as_f64()?, point.get(2)?.as_f64()?)) + } else { + None + }; + let Kind::Revolve { angle, .. } = &d.kind else { return None }; + let angles = if to_mode { + (0.0, None) + } else { + let angle = app.session.doc.eval(angle, ValueKind::Angle).ok()?; + let other = d.extra.get("angle2").map(|value| app.session.doc.eval(value.as_str().unwrap_or_default(), ValueKind::Angle)).transpose().ok()?; + (angle, other) + }; + solvecraft_engine::doc::revolve_extent( + &state, + &plane, + ®ions, + axis, + angles, + d.extra.get("direction").and_then(serde_json::Value::as_str) == Some("symmetric"), + to, + ) + .ok() +} + +#[allow(clippy::too_many_arguments)] +fn rotation_handle( + ui: &mut egui::Ui, + painter: &egui::Painter, + proj: &Proj, + centre: Vec3, + e1: Vec3, + e2: Vec3, + axis: Vec3, + radius: f64, + boundary: f64, + scale: f64, + offset: f64, + range: (f64, f64), + name: &str, + value: &mut String, +) -> Option { + let end = proj.to_screen(centre + (e1 * boundary.cos() + e2 * boundary.sin()) * radius)?; + crate::scenario::publish_handle(name, end); + let response = ui.interact(egui::Rect::from_center_size(end, vec2(20.0, 20.0)), egui::Id::new(("sc_rotator", name)), egui::Sense::drag()); + let hot = response.hovered() || response.dragged(); + let color = if hot { Tokens::get().accent } else { Tokens::get().manipulator }; + painter.circle(end, if hot { 7.0 } else { 5.5 }, color, Stroke::new(1.5, Color32::WHITE)); if hot { ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); } - if resp.dragged() - && let Some(p) = resp.interact_pointer_pos() + if response.dragged() + && let Some(pointer) = response.interact_pointer_pos() { - let (ro, rd) = proj.ray(p); - let den = rd.dot(a); - if den.abs() > 1e-9 { - let q = ro + rd * ((c - ro).dot(a) / den) - c; - let mut th = q.dot(e2).atan2(q.dot(e1)).to_degrees().rem_euclid(360.0); - let free = ui.input(|i| i.modifiers.alt || i.modifiers.ctrl || i.modifiers.command); - if !free { - th = (th / 5.0).round() * 5.0; + let (origin, direction) = proj.ray(pointer); + let den = direction.dot(axis); + let (a, b) = ((range.0 - offset) / scale, (range.1 - offset) / scale); + let bounds = (a.min(b), a.max(b)); + // Near edge-on, a plane intersection magnifies subpixel pointer error. + let raw = if den.abs() > 0.05 { + let point = origin + direction * ((centre - origin).dot(axis) / den) - centre; + Some(point.dot(e2).atan2(point.dot(e1))) + } else { + projected_rotation_angle(proj, pointer, centre, e1, e2, radius, boundary, bounds) + }; + if let Some(raw) = raw { + let boundary_angle = (-4..=4) + .map(|k| raw + k as f64 * std::f64::consts::TAU) + .filter(|angle| *angle >= bounds.0 - 1e-4 && *angle <= bounds.1 + 1e-4) + .min_by(|a, b| (a - boundary).abs().total_cmp(&(b - boundary).abs())) + .unwrap_or_else(|| raw.clamp(bounds.0, bounds.1)); + let mut angle = (boundary_angle.clamp(bounds.0, bounds.1) * scale + offset).to_degrees(); + if !ui.input(|input| input.modifiers.alt || input.modifiers.ctrl || input.modifiers.command) { + angle = (angle / 5.0).round() * 5.0; } - if th < 0.5 { - th = 360.0; + if angle.abs() < 0.05 { + angle = 0.0; } - let s = format!("{th:.1}"); - *value = format!("{} deg", s.trim_end_matches('0').trim_end_matches('.')); + let text = format!("{angle:.1}"); + *value = format!("{} deg", text.trim_end_matches('0').trim_end_matches('.')); } } Some(end) } +/// An edge-on ring has two equally valid depths; retain the branch nearest the current handle. +#[allow(clippy::too_many_arguments)] +fn projected_rotation_angle( + proj: &Proj, + pointer: Pos2, + centre: Vec3, + e1: Vec3, + e2: Vec3, + radius: f64, + current: f64, + (lo, hi): (f64, f64), +) -> Option { + let distance = |angle: f64| { + proj.to_screen(centre + (e1 * angle.cos() + e2 * angle.sin()) * radius) + .map(|point| f64::from(point.distance_sq(pointer))) + .unwrap_or(f64::INFINITY) + }; + let step = (hi - lo) / 720.0; + let mut best = None; + for i in 0..=720 { + let angle = lo + step * i as f64; + let score = distance(angle); + if !score.is_finite() { + continue; + } + if best.is_none_or(|(old_angle, old_score): (f64, f64)| { + score < old_score - 1e-4 || (score - old_score).abs() <= 1e-4 && (angle - current).abs() < (old_angle - current).abs() + }) { + best = Some((angle, score)); + } + } + let (angle, _) = best?; + let (mut a, mut b) = ((angle - step).max(lo), (angle + step).min(hi)); + for _ in 0..24 { + let (left, right) = (a + (b - a) / 3.0, b - (b - a) / 3.0); + if distance(left) <= distance(right) { + b = right; + } else { + a = left; + } + } + Some((a + b) / 2.0) +} fn arrow(painter: &egui::Painter, a: Pos2, b: Pos2, col: Color32) { let v = b - a; let l = v.length(); diff --git a/crates/ui-egui/src/context_menu.rs b/crates/ui-egui/src/context_menu.rs index 4bfa024b..e22b126d 100644 --- a/crates/ui-egui/src/context_menu.rs +++ b/crates/ui-egui/src/context_menu.rs @@ -248,14 +248,7 @@ pub fn target_bodies(app: &SolveApp, target: &Target) -> Vec { /// Bodies in a component and its subcomponents. pub fn component_bodies(app: &SolveApp, id: u64) -> Vec { let doc = &app.session.doc; - app.session - .model - .state() - .bodies - .iter() - .filter(|b| doc.component_within(doc.body_component(&b.name, b.feature), id)) - .map(|b| b.name.clone()) - .collect() + app.session.model.state().bodies.iter().filter(|b| doc.component_within(doc.model_body_component(b), id)).map(|b| b.name.clone()).collect() } /// The eight radial commands, clockwise from the top. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 7dd8afda..a31fd6ad 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1054,9 +1054,10 @@ fn input_rows(d: &mut Dialog, ui: &mut egui::Ui) { ui.horizontal(|ui| { let active = i == d.active; // A chip: "↖ Select" (outlined) while empty, "N selected" (filled) once picked. - let filled = !inp.items.is_empty(); + let custom_axis = i == 1 && inp.items.is_empty() && matches!(d.kind, Kind::Revolve { axis: Some(_), .. }); + let filled = !inp.items.is_empty() || custom_axis; let text = if filled { - RichText::new(format!("↖ {} selected", inp.items.len())).color(Color32::WHITE) + RichText::new(if custom_axis { "↖ Custom Axis".into() } else { format!("↖ {} selected", inp.items.len()) }).color(Color32::WHITE) } else { RichText::new("↖ Select").color(t.text) }; @@ -1069,7 +1070,20 @@ fn input_rows(d: &mut Dialog, ui: &mut egui::Ui) { .sense(egui::Sense::CLICK); let chip = ui.add(b); publish_row(&chip); - if chip.on_hover_text(format!("{} (click to pick into this input)", hint(inp))).clicked() { + let tooltip = if custom_axis { + if let Kind::Revolve { axis: Some(axis), .. } = &d.kind { + format!( + "Custom axis\nOrigin: {}\nDirection: {}\nClick to select a replacement axis", + axis.get("origin").map(ToString::to_string).unwrap_or_default(), + axis.get("dir").map(ToString::to_string).unwrap_or_default() + ) + } else { + hint(inp).to_string() + } + } else { + format!("{} (click to pick into this input)", hint(inp)) + }; + if chip.on_hover_text(tooltip).clicked() { activate = Some(i); } if !inp.items.is_empty() && ui.small_button("×").on_hover_text("Clear the selection").clicked() { @@ -1600,7 +1614,10 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { ui.end_row(); ui.label(""); ui.add( - egui::Label::new("Pick a cylinder wall to wrap the profiles around it. Leave Target Face empty for planar embossing.").wrap(), + egui::Label::new( + "Orthogonal line profiles tangent to one supported cylinder wrap automatically. Pick a wall to choose a target.", + ) + .wrap(), ); ui.end_row(); } @@ -2259,7 +2276,7 @@ pub(crate) fn emboss_profile_plane(app: &SolveApp, sketch: u64) -> Result