diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 9c9fd809..c9008447 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -14,6 +14,8 @@ What did you expect, and what happened instead? Include steps, SolveCraft version/commit and relevant environment details. Prefer a small example without private design data. -## Concrete visual context (when helpful) +## Screenshots of the behavior (when observable) -If a screenshot, short demo, diagram or example model view helps make the problem concrete, include it with the reproduction steps, even for kernel, geometry, assembly or backend behavior. Coding agents should run the example and capture the actual result themselves when possible, rather than asking a human for evidence they can produce. Annotate the focus when helpful. Exclude private designs and sensitive details; attach media or use an accessible link, not committed generated artifacts. Problems with no useful visual explanation do not require media. +For observable CAD, UI or model behavior, include actual descriptive screenshots or a short recording from a small reproduction. This includes kernel, geometry, assembly and backend effects that can be shown in the app. Coding agents should run the app and capture the evidence themselves, rather than asking a human for evidence they can produce. Show the actual failure/result, preferably before and after, with brief steps, expected/actual behavior and annotations where helpful. + +Flowcharts and Mermaid diagrams cannot substitute for evidence of visible behavior. Use diagrams for architectural reasoning when actual screenshots cannot illustrate the point. Changes with no useful observable behavior may omit screenshot/video evidence. Exclude private designs and sensitive details; attach media or link to an accessible recording rather than committing generated review artifacts. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index a346f857..868b73f5 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -6,9 +6,11 @@ Describe the problem and resulting behavior. Link the issue when applicable. What did you run, and what were the results? Include expected and measured values for geometry changes. -## Concrete visual context (when helpful) +## Screenshots of the behavior (when observable) -If a screenshot, short demo, diagram or before/after model view helps explain what this change means, include it alongside the prose. This includes kernel, geometry, assembly and backend changes, not only UI work. Coding agents should run the example and capture the actual reproduction/result themselves, adding brief steps and annotations where helpful. Use a public-safe example; exclude private designs and sensitive details, and attach media rather than committing generated review artifacts. Changes with no useful visual explanation can omit this section. +For observable CAD, UI or model behavior, include actual descriptive screenshots or a short recording from a small reproduction. This includes kernel, geometry, assembly and backend effects that can be shown in the app. Coding agents should run the app and capture the evidence themselves, rather than asking a human for evidence they can produce. Show the actual failure/result, preferably before and after, with brief steps, expected/actual behavior and annotations where helpful. + +Flowcharts and Mermaid diagrams cannot substitute for evidence of visible behavior. Use diagrams for architectural reasoning when actual screenshots cannot illustrate the point. Changes with no useful observable behavior may omit screenshot/video evidence. Exclude private designs and sensitive details; attach media or link to an accessible recording rather than committing generated review artifacts. ## Integration status diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index fd92b969..36c6cac4 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -28,13 +28,15 @@ jobs: - name: Install tools run: | sudo apt-get update - sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml + sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml jq curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \ | tar -xz -C "$RUNNER_TEMP" actionlint - name: actionlint run: '"$RUNNER_TEMP/actionlint" -color' - name: shellcheck run: shellcheck packaging/*.sh packaging/*/*.sh packaging/linux/AppRun + - name: Nightly release selection regression + run: python3 packaging/tests/nightly_test.py - name: PowerShell shell: pwsh run: | diff --git a/AGENTS.md b/AGENTS.md index 40bf5219..bd693d2c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,12 +73,16 @@ People trust SolveCraft with their designs; a crash loses their work. **This out ## Showing and following through on changes -- Give issues and PRs concrete visual context whenever it helps explain what is broken or - changed, including kernel, geometry, assembly and backend work. Coding agents should run - the relevant example and capture a screenshot, short recording, diagram or before/after - model view themselves. Show the actual reproduction/result, annotate the focus when useful, - and pair it with brief expected/actual behavior; do not ask a human for evidence the agent - can produce. Changes with no useful visual explanation do not need media. +- Help reviewers see what is broken or changed. For observable CAD, UI or model behavior, + issues and PRs must include actual descriptive screenshots or a short recording from a + small reproduction. This includes kernel, geometry, assembly and backend changes whose + effects can be shown in the app. Coding agents should run the app and capture this evidence + themselves; do not ask a human for evidence the agent can produce. Show the actual failure + or result, preferably before and after, with brief reproduction steps, expected/actual + behavior and annotations that point out the relevant detail. +- Flowcharts and Mermaid diagrams cannot substitute for screenshots or recordings of visible + behavior. Use diagrams for architectural reasoning when actual screenshots cannot illustrate + the point. Changes with no useful observable behavior do not need screenshot or video evidence. - Use a small public-safe example and check for private designs, paths, account details or other sensitive content before sharing. Attach media to the issue/PR or link to an accessible recording; do not commit generated screenshots or videos just to provide review evidence. diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 494582f2..7971f949 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -18,7 +18,7 @@ Generated-in-code assets are original and have no file to list: | File | Author | Source | Licence | |---|---|---|---| | docs/screenshots/sample-plate.png | SolveCraft contributors | screenshot of SolveCraft itself (`solvecraft --sample`) | MIT OR Apache-2.0 | -| docs/screenshots/demo.gif | SolveCraft contributors | screen recording of SolveCraft itself driven by examples/demo (3x speed) | MIT OR Apache-2.0 | +| docs/screenshots/demo.gif | SolveCraft contributors | original screen recording of the actual SolveCraft 0.2.0 English desktop UI (2× GIF preview; full video at real-time speed) | MIT OR Apache-2.0 | | docs/book/src/generated/sample-plate.png | SolveCraft contributors | copy of docs/screenshots/sample-plate.png made by `cargo xtask book` (gitignored) | MIT OR Apache-2.0 | | docs/book/src/generated/bracket.png | SolveCraft contributors | rendered by `cargo xtask book` (`solvecraft-cli snapshot examples/bracket.json`; gitignored) | MIT OR Apache-2.0 | | docs/book/src/generated/enclosure.png | SolveCraft contributors | rendered by `cargo xtask book` (`solvecraft-cli snapshot docs/book/scripts/enclosure.json`; gitignored) | MIT OR Apache-2.0 | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f35be474..7fc67cff 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -168,15 +168,19 @@ install. If you work on geometry and want to check against it, ask in an issue. 4. Reference the issue (`Fixes #12`). In the PR, say what you tested and how. For geometry work, include the measured numbers (and the expected ones). -Words alone can leave a change hard to picture. When a screenshot, short demo, diagram or -before/after model view helps explain what is broken or changed, please include that concrete -context in the issue or PR. This applies to kernel, geometry, assembly and backend work as well -as the UI. Coding agents should run the relevant example and capture the actual reproduction -or result themselves, with a brief expected/actual explanation and annotations where useful; -please don't ask a human for evidence the agent can produce. Changes with no useful visual -explanation do not need media. Use a small example without private designs or sensitive -details; attach the media or link to an accessible recording rather than committing generated -review artifacts. +Help reviewers see what the words mean. For observable CAD, UI or model behavior, please +include actual descriptive screenshots or a short recording in the issue or PR. This evidence +is required for changes or bugs that can be demonstrated in the app, including kernel, +geometry, assembly and backend work. Coding agents should run a small reproduction and +capture the evidence themselves; please don't ask a human for evidence the agent can produce. +Show the actual failure or result, preferably before and after, with brief steps and +expected/actual behavior. An arrow or annotation can help identify the detail that matters. + +Flowcharts and Mermaid diagrams cannot replace evidence of visible behavior. Diagrams are +useful for architectural reasoning when actual screenshots cannot illustrate the point. +Changes with no useful observable behavior do not need screenshot or video evidence. Use a +small example without private designs or sensitive details; attach the media or link to an +accessible recording rather than committing generated review artifacts. Keep checking upstream commits and the relevant PR's merge status throughout the work and before handoff. Integrate upstream merges, resolve conflicts without losing others' changes, diff --git a/Cargo.lock b/Cargo.lock index 65ab5435..03282d76 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -857,6 +857,17 @@ dependencies = [ "objc2 0.6.5", ] +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "dlib" version = "0.5.3" @@ -996,6 +1007,7 @@ dependencies = [ "serde", "smithay-clipboard", "web-time", + "webbrowser", "winit", ] @@ -1237,6 +1249,15 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "futures-core" version = "0.3.34" @@ -1556,6 +1577,110 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "image" version = "0.25.10" @@ -1777,6 +1902,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + [[package]] name = "litrs" version = "1.0.0" @@ -2525,6 +2656,15 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -3116,7 +3256,7 @@ dependencies = [ [[package]] name = "solvecraft" -version = "0.1.6" +version = "0.2.0" dependencies = [ "eframe", "egui", @@ -3130,7 +3270,7 @@ dependencies = [ [[package]] name = "solvecraft-cli" -version = "0.1.6" +version = "0.2.0" dependencies = [ "serde_json", "solvecraft-engine", @@ -3139,7 +3279,7 @@ dependencies = [ [[package]] name = "solvecraft-doc" -version = "0.1.6" +version = "0.2.0" dependencies = [ "log", "serde", @@ -3154,7 +3294,7 @@ dependencies = [ [[package]] name = "solvecraft-engine" -version = "0.1.6" +version = "0.2.0" dependencies = [ "log", "serde", @@ -3170,7 +3310,7 @@ dependencies = [ [[package]] name = "solvecraft-geom" -version = "0.1.6" +version = "0.2.0" dependencies = [ "serde", "serde_json", @@ -3178,7 +3318,7 @@ dependencies = [ [[package]] name = "solvecraft-io" -version = "0.1.6" +version = "0.2.0" dependencies = [ "quick-xml 0.38.4", "solvecraft-doc", @@ -3190,7 +3330,7 @@ dependencies = [ [[package]] name = "solvecraft-kernel" -version = "0.1.6" +version = "0.2.0" dependencies = [ "log", "serde", @@ -3205,7 +3345,7 @@ dependencies = [ [[package]] name = "solvecraft-mcp" -version = "0.1.6" +version = "0.2.0" dependencies = [ "serde_json", "solvecraft-engine", @@ -3213,7 +3353,7 @@ dependencies = [ [[package]] name = "solvecraft-render" -version = "0.1.6" +version = "0.2.0" dependencies = [ "image", "serde", @@ -3222,7 +3362,7 @@ dependencies = [ [[package]] name = "solvecraft-sketch" -version = "0.1.6" +version = "0.2.0" dependencies = [ "serde", "serde_json", @@ -3233,7 +3373,7 @@ dependencies = [ [[package]] name = "solvecraft-ui-egui" -version = "0.1.6" +version = "0.2.0" dependencies = [ "egui", "egui-wgpu", @@ -3248,7 +3388,7 @@ dependencies = [ [[package]] name = "solvecraft-web" -version = "0.1.6" +version = "0.2.0" dependencies = [ "eframe", "egui", @@ -3283,6 +3423,12 @@ dependencies = [ "bitflags 2.13.2", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "static_assertions" version = "1.1.0" @@ -3321,6 +3467,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -3397,6 +3554,16 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tokio" version = "1.53.2" @@ -3669,6 +3836,24 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "uuid" version = "1.27.0" @@ -3949,6 +4134,22 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webbrowser" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94" +dependencies = [ + "jni", + "log", + "ndk-context", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "url", + "web-sys", +] + [[package]] name = "weezl" version = "0.1.12" @@ -4488,6 +4689,12 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + [[package]] name = "x11-dl" version = "2.21.0" @@ -4553,11 +4760,34 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.1.6" +version = "0.2.0" dependencies = [ "serde_json", ] +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + [[package]] name = "zbus" version = "5.19.0" @@ -4684,6 +4914,60 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "zip" version = "3.0.0" diff --git a/Cargo.toml b/Cargo.toml index 37bf16ee..7069ee39 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"] exclude = ["vendor"] [workspace.package] -version = "0.1.6" +version = "0.2.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.90" diff --git a/README.md b/README.md index 93dfde1d..a9368ae5 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,9 @@ Sketch, constrain, and build solids with a parametric feature timeline. Change a and watch your model rebuild. Run it on your desktop, in your browser, or let AI agents model through the built-in MCP server. Part of the storytold "craft" family. -![An AI agent builds a parametric enclosure in SolveCraft over MCP, then the width is changed in the Parameters dialog and the model rebuilds (3x speed)](docs/screenshots/demo.gif) +![SolveCraft 0.2.0: dimension a line, extrude a plate with an elliptical hole, and change its width while preserving the hole](docs/screenshots/demo.gif) -*An agent builds an electronics enclosure over MCP: sketches on faces, shell, a port cut, one screw boss patterned to four, then width 80 → 100 mm in the Parameters dialog and the timeline rebuilds. 3× speed; [full video](https://github.com/bherbruck/solvecraft/releases/download/v0.1.0/enclosure-demo-v3.mp4), script in [examples/demo](examples/demo).* +*The actual 0.2.0 desktop UI: dimension a line from its endpoints, build a plate with an elliptical hole, then change its width from 25 → 35 mm and watch it rebuild. Includes the new axis navigation. 2× GIF preview; [full video at real-time speed](https://github.com/bherbruck/solvecraft/releases/download/v0.2.0/solvecraft-0.2.0-demo.mp4).* **Documentation:** the [SolveCraft book](https://bherbruck.github.io/solvecraft/) (user guide, developer guide and command reference; sources in [docs/book](docs/book)), and the @@ -39,6 +39,8 @@ and help is welcome: see [CONTRIBUTING.md](CONTRIBUTING.md). equal, concentric, collinear, midpoint, symmetry, fix; linear, aligned, horizontal, vertical, radius, diameter and angle dimensions driven by parameters. Over-constraining edits are rejected; fully constrained geometry turns black. + Select a line or its two endpoints, type its dimension and press Enter to commit one undo + step. Fit keeps sketch geometry clear of the palette and navigation controls. - **Profiles** found automatically, including regions split by lines and holes inside outlines. Newly captured complete adaptive boundaries retain their outer and hole source identities when spline span counts change. Partial or ambiguous boundaries do not qualify. @@ -69,6 +71,8 @@ and help is welcome: see [CONTRIBUTING.md](CONTRIBUTING.md). - **Desktop app** (egui + wgpu): toolbar with workspace tabs, browser, timeline, 3D viewport with axis view and navigation bar, sketch tools, feature dialogs, command palette, and a JSON-lines control channel so agents and tests can drive it. + Feature errors and Help can open an editable **Create Issue** draft with relevant context + and an optional reviewed screenshot; opening GitHub does not submit the report. - **Headless CLI** `solvecraft-cli`: run command scripts, measure (volume, area, centre of mass, face/edge/vertex counts), export, render PNG snapshots, replay Fusion oracle recipes. - **MCP server** `solvecraft-cli mcp`: AI agents model, measure and look at parts headless or diff --git a/ROADMAP.md b/ROADMAP.md index f61ea780..700c8617 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -57,19 +57,37 @@ headline counts in-scope tabs only and lists the deferred ones separately. | 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 | +| M12 | Release: cross-platform packages, verification and deliberate publication implemented; original current-UI demo refreshed. Trusted distribution signing and broader performance work remain | in progress | 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. 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/12"`, 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 | -## Next usability work +## Dimensioning and error reporting -- Single-line and rectangle-side dimensioning: keep endpoints reachable after Fit when the - Sketch Palette is open; support selecting a line or its two endpoints, with typed creation - committed as one undo step and cancellation leaving the sketch unchanged. +- Single-line and rectangle-side dimensioning supports selecting a line or its two endpoints. + Type a value and press Enter to commit one undo step; rejected values remain editable and + cancellation leaves the sketch unchanged. Fit keeps geometry clear of the Sketch Palette + and navigation controls, including enlarged displays and nested pending placements. - Contextual error reporting: a **Create Issue** action beside feature errors opens a reviewable GitHub issue draft with the error, operation settings, build diagnostics and - useful reproduction context. Include relevant screenshots or a small reproduction when - they make a geometry failure or unexpected result easier to understand. + useful reproduction context. The draft is editable, with optional capture of the actual + viewport and copy/save controls. Opening GitHub does not submit the issue. Help also exposes + reporting for unexpected results that did not produce an error. + +## 0.2.0 release batch + +- Includes the new axis navigation, adaptive sketch drawing/picking, origin-corner repairs, + durable supported profile/face references, endpoint dimensioning and contextual reporting. +- Smooth ruled-wall display joins are hidden only within the supported geometric certificate; + authored splits and actual creases remain protected. Wireframe retains physical edges. +- Paste New uses a bounded current-geometry comparison for supported convex line/circle + prisms with separated circular holes; unsupported shapes retain the existing strict check. +- Common-bore Union retries check additional boundary curves before recutting the bore, + preventing the reproduced thin-web loss. Unknown additional trims refuse; inherited + common-cylinder trims retain their existing policy, so this is not a general Boolean proof. +- Exact full-path assembly joint editing remains separate work and is not part of this batch. +- The replacement README demo records the actual English 0.2.0 UI, with a 2× preview and + a full video at real-time speed. Package verification and publication follow + [the release workflow](docs/releasing.md); development pushes are not application releases. ## View navigation diff --git a/THIRD-PARTY-LICENSES.txt b/THIRD-PARTY-LICENSES.txt index b520e18b..72a77357 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 426 crates below. +SolveCraft (the desktop app, solvecraft-cli and the web app) is built from the 453 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. @@ -85,6 +85,7 @@ 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) +displaydoc 0.2.7 (MIT OR Apache-2.0) dlib 0.5.3 (MIT) document-features 0.2.12 (MIT OR Apache-2.0) downcast-rs 1.2.1 (MIT/Apache-2.0) @@ -119,6 +120,7 @@ font-types 0.12.6 (MIT OR Apache-2.0) foreign-types 0.5.0 (MIT/Apache-2.0) foreign-types-macros 0.2.4 (MIT/Apache-2.0) foreign-types-shared 0.3.1 (MIT/Apache-2.0) +form_urlencoded 1.2.2 (MIT OR Apache-2.0) futures-core 0.3.34 (MIT OR Apache-2.0) futures-io 0.3.34 (MIT OR Apache-2.0) futures-lite 2.6.1 (Apache-2.0 OR MIT) @@ -147,6 +149,15 @@ hermit-abi 0.5.3 (MIT OR Apache-2.0) hex 0.4.3 (MIT OR Apache-2.0) iana-time-zone 0.1.65 (MIT OR Apache-2.0) iana-time-zone-haiku 0.1.2 (MIT OR Apache-2.0) +icu_collections 2.3.0 (Unicode-3.0) +icu_locale_core 2.3.0 (Unicode-3.0) +icu_normalizer 2.3.0 (Unicode-3.0) +icu_normalizer_data 2.3.0 (Unicode-3.0) +icu_properties 2.3.0 (Unicode-3.0) +icu_properties_data 2.3.0 (Unicode-3.0) +icu_provider 2.3.1 (Unicode-3.0) +idna 1.1.0 (MIT OR Apache-2.0) +idna_adapter 1.2.2 (Apache-2.0 OR MIT) image 0.25.10 (MIT OR Apache-2.0) indexmap 2.14.2 (Apache-2.0 OR MIT) itertools 0.13.0 (MIT OR Apache-2.0) @@ -168,6 +179,7 @@ libredox 0.1.25 (MIT) linebender_resource_handle 0.1.1 (Apache-2.0 OR MIT) linux-raw-sys 0.12.1 (Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT) linux-raw-sys 0.4.15 (Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT) +litemap 0.8.3 (Unicode-3.0) litrs 1.0.0 (MIT OR Apache-2.0) lock_api 0.4.14 (MIT OR Apache-2.0) log 0.4.34 (MIT OR Apache-2.0) @@ -239,6 +251,7 @@ pollster 1.0.1 (Apache-2.0/MIT) polycool 0.4.0 (MIT OR Apache-2.0) portable-atomic 1.15.0 (Apache-2.0 OR MIT) portable-atomic-util 0.2.8 (Apache-2.0 OR MIT) +potential_utf 0.1.6 (Unicode-3.0) ppv-lite86 0.2.21 (MIT OR Apache-2.0) presser 0.3.1 (MIT OR Apache-2.0) proc-macro-crate 3.5.0 (MIT OR Apache-2.0) @@ -299,10 +312,12 @@ smithay-clipboard 0.7.3 (MIT) smol_str 0.2.2 (MIT OR Apache-2.0) spade 2.15.1 (MIT OR Apache-2.0) spirv 0.4.0+sdk-1.4.341.0 (Apache-2.0) +stable_deref_trait 1.2.1 (MIT OR Apache-2.0) static_assertions 1.1.0 (MIT OR Apache-2.0) syn 1.0.109 (MIT OR Apache-2.0) syn 2.0.119 (MIT OR Apache-2.0) syn 3.0.6 (MIT OR Apache-2.0) +synstructure 0.13.2 (MIT) tempfile 3.27.0 (MIT OR Apache-2.0) termcolor 1.4.1 (Unlicense OR MIT) thiserror 1.0.69 (MIT OR Apache-2.0) @@ -310,6 +325,7 @@ thiserror 2.0.21 (MIT OR Apache-2.0) thiserror-impl 1.0.69 (MIT OR Apache-2.0) thiserror-impl 2.0.21 (MIT OR Apache-2.0) tiff 0.11.3 (MIT) +tinystr 0.8.4 (Unicode-3.0) tokio 1.53.2 (MIT) toml_datetime 1.1.1+spec-1.1.0 (MIT OR Apache-2.0) toml_edit 0.25.15+spec-1.1.0 (MIT OR Apache-2.0) @@ -336,6 +352,8 @@ unicode-general-category 1.1.0 (Apache-2.0) unicode-ident 1.0.26 ((MIT OR Apache-2.0) AND Unicode-3.0) unicode-segmentation 1.13.3 (MIT OR Apache-2.0) unicode-width 0.2.2 (MIT OR Apache-2.0) +url 2.5.8 (MIT OR Apache-2.0) +utf8_iter 1.0.4 (Apache-2.0 OR MIT) uuid 1.27.0 (Apache-2.0 OR MIT) vello_common 0.1.0 (Apache-2.0 OR MIT) vello_cpu 0.1.0 (Apache-2.0 OR MIT) @@ -359,6 +377,7 @@ wayland-scanner 0.31.11 (MIT) wayland-sys 0.31.11 (MIT) web-sys 0.3.106 (MIT OR Apache-2.0) web-time 1.1.0 (MIT OR Apache-2.0) +webbrowser 1.2.4 (MIT OR Apache-2.0) weezl 0.1.12 (MIT OR Apache-2.0) wgpu 30.0.1 (MIT OR Apache-2.0) wgpu-core 30.0.1 (MIT OR Apache-2.0) @@ -406,12 +425,15 @@ windows_x86_64_msvc 0.53.1 (MIT OR Apache-2.0) winit 0.30.13 (Apache-2.0) winnow 1.0.4 (MIT) wit-bindgen 0.57.1 (Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT) +writeable 0.6.4 (Unicode-3.0) x11-dl 2.21.0 (MIT) x11rb 0.13.2 (MIT OR Apache-2.0) x11rb-protocol 0.13.2 (MIT OR Apache-2.0) xcursor 0.3.11 (MIT) xkbcommon-dl 0.4.2 (MIT) xkeysym 0.2.1 (MIT OR Apache-2.0 OR Zlib) +yoke 0.8.3 (Unicode-3.0) +yoke-derive 0.8.2 (Unicode-3.0) zbus 5.19.0 (MIT) zbus-lockstep 0.5.2 (MIT) zbus-lockstep-macros 0.5.2 (MIT) @@ -421,6 +443,11 @@ zbus_xml 5.2.1 (MIT) zcheapstr 1.1.0 (MIT) zerocopy 0.8.61 (BSD-2-Clause OR Apache-2.0 OR MIT) zerocopy-derive 0.8.61 (BSD-2-Clause OR Apache-2.0 OR MIT) +zerofrom 0.1.8 (Unicode-3.0) +zerofrom-derive 0.1.7 (Unicode-3.0) +zerotrie 0.2.5 (Unicode-3.0) +zerovec 0.11.8 (Unicode-3.0) +zerovec-derive 0.11.6 (Unicode-3.0) zip 3.0.0 (MIT) zlib-rs 0.6.8 (Zlib) zmij 1.0.23 (MIT) @@ -451,18 +478,19 @@ OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ================================================================================ adler2 2.0.1, allocator-api2 0.2.21, async-channel 2.5.0, async-executor 1.14.0, async-io 2.6.0, async-lock 3.4.2, async-process 2.5.0, async-recursion 1.2.0, async-signal 0.2.14, async-task 4.7.1, -async-trait 0.1.92, atomic-waker 1.1.2, blocking 1.7.0, concurrent-queue 2.5.0, endi 1.1.1, enumn -0.1.14, event-listener 5.4.2, event-listener-strategy 0.5.4, fastrand 2.5.0, futures-lite 2.6.1, -glow 0.17.0, hermit-abi 0.5.3, itoa 1.0.18, khronos-egl 6.0.0, linux-raw-sys 0.12.1, linux-raw-sys -0.4.15, num_enum 0.7.6, num_enum_derive 0.7.6, once_cell 1.21.4, ordered-stream 0.2.0, parking -2.2.1, pin-project 1.1.13, pin-project-internal 1.1.13, pin-project-lite 0.2.17, piper 0.2.5, -polling 3.11.0, portable-atomic 1.15.0, portable-atomic-util 0.2.8, proc-macro-crate 3.5.0, -proc-macro2 1.0.107, quote 1.0.47, rustc-hash 1.1.0, rustc-hash 2.1.3, rustix 0.38.44, rustix 1.1.5, -rustversion 1.0.23, serde 1.0.229, serde_core 1.0.229, serde_derive 1.0.229, serde_json 1.0.151, -serde_repr 0.1.21, simd_cesu8 1.2.0, smol_str 0.2.2, syn 1.0.109, syn 2.0.119, syn 3.0.6, thiserror -1.0.69, thiserror 2.0.21, thiserror-impl 1.0.69, thiserror-impl 2.0.21, typeid 1.0.3, unicode-ident -1.0.26, wasi 0.11.1+wasi-snapshot-preview1, wasip2 1.0.4+wasi-0.2.12, wit-bindgen 0.57.1, x11-dl -2.21.0, zbus-lockstep 0.5.2, zbus-lockstep-macros 0.5.2, zmij 1.0.23, zvariant_utils 4.2.0 +async-trait 0.1.92, atomic-waker 1.1.2, blocking 1.7.0, concurrent-queue 2.5.0, displaydoc 0.2.7, +endi 1.1.1, enumn 0.1.14, event-listener 5.4.2, event-listener-strategy 0.5.4, fastrand 2.5.0, +futures-lite 2.6.1, glow 0.17.0, hermit-abi 0.5.3, itoa 1.0.18, khronos-egl 6.0.0, linux-raw-sys +0.12.1, linux-raw-sys 0.4.15, num_enum 0.7.6, num_enum_derive 0.7.6, once_cell 1.21.4, +ordered-stream 0.2.0, parking 2.2.1, pin-project 1.1.13, pin-project-internal 1.1.13, +pin-project-lite 0.2.17, piper 0.2.5, polling 3.11.0, portable-atomic 1.15.0, portable-atomic-util +0.2.8, proc-macro-crate 3.5.0, proc-macro2 1.0.107, quote 1.0.47, rustc-hash 1.1.0, rustc-hash +2.1.3, rustix 0.38.44, rustix 1.1.5, rustversion 1.0.23, serde 1.0.229, serde_core 1.0.229, +serde_derive 1.0.229, serde_json 1.0.151, serde_repr 0.1.21, simd_cesu8 1.2.0, smol_str 0.2.2, syn +1.0.109, syn 2.0.119, syn 3.0.6, thiserror 1.0.69, thiserror 2.0.21, thiserror-impl 1.0.69, +thiserror-impl 2.0.21, typeid 1.0.3, unicode-ident 1.0.26, wasi 0.11.1+wasi-snapshot-preview1, +wasip2 1.0.4+wasi-0.2.12, wit-bindgen 0.57.1, x11-dl 2.21.0, zbus-lockstep 0.5.2, +zbus-lockstep-macros 0.5.2, zmij 1.0.23, zvariant_utils 4.2.0 ================================================================================ Permission is hereby granted, free of charge, to any @@ -731,20 +759,22 @@ ahash 0.8.12, arbitrary 1.5.0, arrayvec 0.7.8, async-channel 2.5.0, async-execut 4.7.1, atomic-waker 1.1.2, bitflags 1.3.2, bitflags 2.13.2, blocking 1.7.0, bumpalo 3.20.3, cfg-if 1.0.5, cgl 0.3.2, concurrent-queue 2.5.0, core-foundation 0.9.4, core-foundation-sys 0.8.7, core-graphics 0.23.2, core-graphics-types 0.1.3, crossbeam-deque 0.8.8, crossbeam-epoch 0.9.21, -crossbeam-utils 0.8.23, derive_arbitrary 1.5.0, either 1.19.0, equivalent 1.0.2, errno 0.3.14, -euclid 0.22.14, event-listener 5.4.2, event-listener-strategy 0.5.4, fastrand 2.5.0, flate2 1.1.10, -futures-lite 2.6.1, gethostname 1.1.0, glow 0.17.0, hashbrown 0.16.1, hashbrown 0.17.1, hermit-abi -0.5.3, indexmap 2.14.2, itertools 0.13.0, itertools 0.15.0, js-sys 0.3.106, khronos-egl 6.0.0, -linux-raw-sys 0.12.1, linux-raw-sys 0.4.15, lock_api 0.4.14, log 0.4.34, num-complex 0.4.6, -num-traits 0.2.19, once_cell 1.21.4, ordered-stream 0.2.0, parking 2.2.1, parking_lot 0.12.5, -parking_lot_core 0.9.12, percent-encoding 2.3.2, piper 0.2.5, plain 0.2.3, png 0.18.1, polling -3.11.0, pollster 0.4.0, pollster 1.0.1, rayon 1.12.0, rayon-core 1.13.0, renderdoc-sys 1.1.0, robust -1.2.0, ron 0.12.2, rustc-hash 1.1.0, rustix 0.38.44, rustix 1.1.5, scoped-tls 1.0.1, scopeguard -1.2.0, signal-hook-registry 1.4.8, simd_cesu8 1.2.0, smallvec 1.16.2, smol_str 0.2.2, syn 1.0.109, -tempfile 3.27.0, ttf-parser 0.25.1, unicode-segmentation 1.13.3, unicode-width 0.2.2, uuid 1.27.0, -wasi 0.11.1+wasi-snapshot-preview1, wasip2 1.0.4+wasi-0.2.12, wasm-bindgen 0.2.129, -wasm-bindgen-futures 0.4.79, wasm-bindgen-macro 0.2.129, wasm-bindgen-macro-support 0.2.129, -wasm-bindgen-shared 0.2.129, web-sys 0.3.106, weezl 0.1.12, wit-bindgen 0.57.1 +crossbeam-utils 0.8.23, derive_arbitrary 1.5.0, displaydoc 0.2.7, either 1.19.0, equivalent 1.0.2, +errno 0.3.14, euclid 0.22.14, event-listener 5.4.2, event-listener-strategy 0.5.4, fastrand 2.5.0, +flate2 1.1.10, form_urlencoded 1.2.2, futures-lite 2.6.1, gethostname 1.1.0, glow 0.17.0, hashbrown +0.16.1, hashbrown 0.17.1, hermit-abi 0.5.3, idna 1.1.0, idna_adapter 1.2.2, indexmap 2.14.2, +itertools 0.13.0, itertools 0.15.0, js-sys 0.3.106, khronos-egl 6.0.0, linux-raw-sys 0.12.1, +linux-raw-sys 0.4.15, lock_api 0.4.14, log 0.4.34, num-complex 0.4.6, num-traits 0.2.19, once_cell +1.21.4, ordered-stream 0.2.0, parking 2.2.1, parking_lot 0.12.5, parking_lot_core 0.9.12, +percent-encoding 2.3.2, piper 0.2.5, plain 0.2.3, png 0.18.1, polling 3.11.0, pollster 0.4.0, +pollster 1.0.1, rayon 1.12.0, rayon-core 1.13.0, renderdoc-sys 1.1.0, robust 1.2.0, ron 0.12.2, +rustc-hash 1.1.0, rustix 0.38.44, rustix 1.1.5, scoped-tls 1.0.1, scopeguard 1.2.0, +signal-hook-registry 1.4.8, simd_cesu8 1.2.0, smallvec 1.16.2, smol_str 0.2.2, stable_deref_trait +1.2.1, syn 1.0.109, tempfile 3.27.0, ttf-parser 0.25.1, unicode-segmentation 1.13.3, unicode-width +0.2.2, url 2.5.8, uuid 1.27.0, wasi 0.11.1+wasi-snapshot-preview1, wasip2 1.0.4+wasi-0.2.12, +wasm-bindgen 0.2.129, wasm-bindgen-futures 0.4.79, wasm-bindgen-macro 0.2.129, +wasm-bindgen-macro-support 0.2.129, wasm-bindgen-shared 0.2.129, web-sys 0.3.106, weezl 0.1.12, +wit-bindgen 0.57.1 ================================================================================ Apache License @@ -1448,7 +1478,7 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. approx 0.4.0, as-raw-xcb-connection 1.0.1, atspi 0.29.0, atspi-common 0.13.0, atspi-proxies 0.13.0, cgmath 0.18.0, codespan-reporting 0.13.1, kurbo 0.13.1, nohash-hasher 0.2.0, peniko 0.6.1, polycool 0.4.0, raw-window-metal 1.1.0, self_cell 1.3.0, siphasher 1.0.4, spade 2.15.1, static_assertions -1.1.0, x11rb 0.13.2, x11rb-protocol 0.13.2, zune-core 0.5.3, zune-jpeg 0.5.15 +1.1.0, utf8_iter 1.0.4, x11rb 0.13.2, x11rb-protocol 0.13.2, zune-core 0.5.3, zune-jpeg 0.5.15 ================================================================================ Apache License @@ -4771,6 +4801,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. +================================================================================ +form_urlencoded 1.2.2 +================================================================================ + +Copyright (c) 2013-2016 The rust-url 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. + ================================================================================ futures-core 0.3.34, futures-io 0.3.34, futures-macro 0.3.34, futures-task 0.3.34, futures-util 0.3.34 @@ -6195,6 +6255,120 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +================================================================================ +icu_collections 2.3.0, icu_locale_core 2.3.0, icu_normalizer 2.3.0, icu_normalizer_data 2.3.0, +icu_properties 2.3.0, icu_properties_data 2.3.0, icu_provider 2.3.1, litemap 0.8.3, potential_utf +0.1.6, tinystr 0.8.4, writeable 0.6.4, yoke 0.8.3, yoke-derive 0.8.2, zerofrom 0.1.8, +zerofrom-derive 0.1.7, zerotrie 0.2.5, zerovec 0.11.8, zerovec-derive 0.11.6 +================================================================================ + +UNICODE LICENSE V3 + +COPYRIGHT AND PERMISSION NOTICE + +Copyright © 2020-2024 Unicode, Inc. + +NOTICE TO USER: Carefully read the following legal agreement. BY +DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR +SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE +TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT +DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of data files and any associated documentation (the "Data Files") or +software and any associated documentation (the "Software") to deal in the +Data Files or Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, and/or sell +copies of the Data Files or Software, and to permit persons to whom the +Data Files or Software are furnished to do so, provided that either (a) +this copyright and permission notice appear with all copies of the Data +Files or Software, or (b) this copyright and permission notice appear in +associated Documentation. + +THE DATA FILES AND SOFTWARE ARE 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 OF +THIRD PARTY RIGHTS. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE +BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, +OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, +ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA +FILES OR SOFTWARE. + +Except as contained in this notice, the name of a copyright holder shall +not be used in advertising or otherwise to promote the sale, use or other +dealings in these Data Files or Software without prior written +authorization of the copyright holder. + +SPDX-License-Identifier: Unicode-3.0 + +— + +Portions of ICU4X may have been adapted from ICU4C and/or ICU4J. +ICU 1.8.1 to ICU 57.1 © 1995-2016 International Business Machines Corporation and others. + +================================================================================ +idna 1.1.0, percent-encoding 2.3.2, url 2.5.8 +================================================================================ + +Copyright (c) 2013-2025 The rust-url 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. + +================================================================================ +idna_adapter 1.2.2 +================================================================================ + +Copyright (c) The rust-url 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. + ================================================================================ indexmap 2.14.2 ================================================================================ @@ -7846,36 +8020,6 @@ http://www.apache.org/licenses/LICENSE-2.0> or the MIT license option. All files in the project carrying such notice may not be copied, modified, or distributed except according to those terms. -================================================================================ -percent-encoding 2.3.2 -================================================================================ - -Copyright (c) 2013-2025 The rust-url 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. - ================================================================================ phf 0.13.1, phf_generator 0.13.1, phf_macros 0.13.1, phf_shared 0.13.1 ================================================================================ @@ -9890,6 +10034,36 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +================================================================================ +stable_deref_trait 1.2.1 +================================================================================ + +Copyright (c) 2017 Robert Grosse + +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. + ================================================================================ static_assertions 1.1.0 ================================================================================ @@ -9916,6 +10090,18 @@ 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. +================================================================================ +synstructure 0.13.2 +================================================================================ + +Copyright 2016 Nika Layzell + +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. + ================================================================================ tempfile 3.27.0 ================================================================================ @@ -10920,6 +11106,83 @@ at your option. All files in the project carrying such notice may not be copied, modified, or distributed except according to those terms. +================================================================================ +utf8_iter 1.0.4 +================================================================================ + +Copyright Mozilla Foundation + +Licensed under the Apache License (Version 2.0), or the MIT license, +(the "Licenses") at your option. You may not use this file except in +compliance with one of the Licenses. You may obtain copies of the +Licenses at: + + https://www.apache.org/licenses/LICENSE-2.0 + https://opensource.org/licenses/MIT + +Unless required by applicable law or agreed to in writing, software +distributed under the Licenses is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the Licenses for the specific language governing permissions and +limitations under the Licenses. + +-- + +Test code is dedicated to the Public Domain when so designated (see +the individual files for PD/CC0-dedicated sections). + +-- + +The implementation for Utf8CharIndices was adapted from the +CharIndices implementation of the Rust standard library at revision +ab32548539ec38a939c1b58599249f3b54130026 +(https://github.com/rust-lang/rust/blob/ab32548539ec38a939c1b58599249f3b54130026/library/core/src/str/iter.rs). + +Excerpt from https://github.com/rust-lang/rust/blob/ab32548539ec38a939c1b58599249f3b54130026/COPYRIGHT , +which refers to +https://github.com/rust-lang/rust/blob/ab32548539ec38a939c1b58599249f3b54130026/LICENSE-APACHE +and +https://github.com/rust-lang/rust/blob/ab32548539ec38a939c1b58599249f3b54130026/LICENSE-MIT +: + +For full authorship information, see the version control history or +https://thanks.rust-lang.org + +Except as otherwise noted (below and/or in individual files), Rust is +licensed under the Apache License, Version 2.0 or + or the MIT license + or , at your option. + +================================================================================ +utf8_iter 1.0.4 +================================================================================ + +Copyright Mozilla Foundation + +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. + ================================================================================ uuid 1.27.0 ================================================================================ @@ -11239,6 +11502,233 @@ 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. +================================================================================ +webbrowser 1.2.4 +================================================================================ + +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 + +Copyright (c) 2015-2022 Amod Malviya + +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. + +================================================================================ +webbrowser 1.2.4 +================================================================================ + +The MIT License (MIT) + +Copyright (c) 2015-2022 Amod Malviya + +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. + ================================================================================ weezl 0.1.12 ================================================================================ diff --git a/apps/solvecraft-cli/src/main.rs b/apps/solvecraft-cli/src/main.rs index 47a9fe97..50f01ea0 100644 --- a/apps/solvecraft-cli/src/main.rs +++ b/apps/solvecraft-cli/src/main.rs @@ -248,7 +248,7 @@ fn icon_png(size: usize) -> Result, String> { let mesh = body.mesh(); scene.meshes.push(SceneMesh { mesh: std::sync::Arc::clone(&mesh), color: Rgb(90, 160, 240), face_colors: Vec::new() }); for (i, e) in mesh.edges.iter().enumerate() { - if !mesh.seams.get(i).copied().unwrap_or(false) { + if mesh.shaded_edge_visible(i) { scene.lines.push(SceneLine { points: e.clone(), color: Rgb(24, 52, 96), width: (3.0 * px).max(0.8), on_top: false }); } } diff --git a/apps/solvecraft/Cargo.toml b/apps/solvecraft/Cargo.toml index cb095c34..99843582 100644 --- a/apps/solvecraft/Cargo.toml +++ b/apps/solvecraft/Cargo.toml @@ -13,7 +13,7 @@ workspace = true [dependencies] solvecraft-engine = { workspace = true } solvecraft-ui-egui = { workspace = true } -eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] } +eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links"] } egui = { workspace = true } serde_json = { workspace = true } log = { workspace = true } diff --git a/crates/engine/src/cmd/component_clone.rs b/crates/engine/src/cmd/component_clone.rs index dd84a04b..87cabfbe 100644 --- a/crates/engine/src/cmd/component_clone.rs +++ b/crates/engine/src/cmd/component_clone.rs @@ -664,7 +664,15 @@ pub(super) fn paste( } } } - if (ma.volume - mb.volume).abs() > 1e-5 * (1.0 + ma.volume.abs()) + // Meshes of the same current circular section can have different + // chord-volume biases after STEP. Only independently certified current + // sections may replace that volume comparison; shape mismatches still + // refuse even when their volumes happen to agree. + let volume_matches = match solvecraft_kernel::certified_prism_equivalent(&a, &b)? { + Some(same) => same, + None => (ma.volume - mb.volume).abs() <= 1e-5 * (1.0 + ma.volume.abs()), + }; + if !volume_matches || (ma.area - mb.area).abs() > 1e-5 * (1.0 + ma.area.abs()) || ma.centroid.dist(mb.centroid) > tolerance || ma.bbox.min.dist(mb.bbox.min) > tolerance diff --git a/crates/engine/src/cmd/component_clone_tests.rs b/crates/engine/src/cmd/component_clone_tests.rs index c8743937..49483555 100644 --- a/crates/engine/src/cmd/component_clone_tests.rs +++ b/crates/engine/src/cmd/component_clone_tests.rs @@ -519,3 +519,36 @@ fn frozen_bore_boundary_copy_preserves_stock_void_and_reopen() { check(&s); check(&Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap())); } + +#[test] +fn frozen_r6_bore_copy_uses_current_geometry_despite_mesh_measure_bias() { + use solvecraft_geom::Vec3; + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":20,"width":20,"height":10,"body_name":"Drilled"})); + run(&mut s, "solid.cylinder", json!({"base":[10,10,0],"radius":6,"height":10,"operation":"cut"})); + let source = s.model.state().body("Drilled").unwrap().body.clone(); + let original = solvecraft_kernel::step_export(&[&source], "source").unwrap(); + let created = run(&mut s, "component.from_bodies", json!({"bodies":["Drilled"]})); + let part = created["components"][0]["component"].as_u64().unwrap(); + let before = s.doc.to_json(); + let copied = copy(&mut s, part)["component"].as_u64().unwrap(); + let check = |session: &Session| { + let state = session.model.state(); + let body = state.bodies.iter().find(|b| session.doc.model_body_component(b) == copied).unwrap(); + assert_eq!(solvecraft_kernel::certified_prism_equivalent(&source, &body.body).unwrap(), Some(true)); + let mesh = body.body.tessellate(1e-3).unwrap(); + for (point, material) in + [(Vec3::new(3., 10., 5.), true), (Vec3::new(5., 10., 5.), false), (Vec3::new(10., 10., 5.), false), (Vec3::new(18., 10., 5.), true)] + { + assert_eq!(mesh.contains(point), material, "{point:?}"); + } + let original_body = state.body("Drilled").unwrap(); + assert_eq!(solvecraft_kernel::step_export(&[&original_body.body], "source").unwrap(), original); + }; + check(&s); + check(&Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap())); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.to_json(), before); + run(&mut s, "edit.redo", json!({})); + check(&s); +} diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index 89eaf902..ee1bed25 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -235,7 +235,7 @@ pub fn scene_with_section_and_sketch_sampler( }; sc.meshes.push(SceneMesh { mesh: Arc::clone(&mesh), color, face_colors }); for (i, e) in mesh.edges.iter().enumerate() { - if mesh.seams.get(i).copied().unwrap_or(false) { + if !mesh.shaded_edge_visible(i) { continue; } sc.lines.push(SceneLine { points: e.clone(), color: colors::EDGE, width: 1.2, on_top: false }); diff --git a/crates/geom/src/mesh.rs b/crates/geom/src/mesh.rs index 296f4808..f11c57cf 100644 --- a/crates/geom/src/mesh.rs +++ b/crates/geom/src/mesh.rs @@ -64,6 +64,13 @@ pub struct Mesh { /// Per edge: a seam between two pieces of the same smooth surface (not drawn). #[serde(default)] pub seams: Vec, + /// Display-only tangent joins. Edge identities, picking and face groups stay intact. + #[serde(skip)] + pub smooth_edges: Vec, + /// Display-protected boundaries: certified current creases or authored splits. + /// Overrides coarse seam fits; not a geometric/topological authority. + #[serde(skip)] + pub sharp_edges: Vec, /// Per edge: the B-rep faces it bounds (one or two). #[serde(default)] pub edge_faces: Vec>, @@ -78,6 +85,15 @@ pub struct MeshMeasure { } impl Mesh { + /// Visibility in shaded edge presentation, independent of selection/topology. + pub fn shaded_edge_visible(&self, index: usize) -> bool { + self.edge_pickable(index) && !self.smooth_edges.get(index).copied().unwrap_or(false) + } + + pub fn edge_pickable(&self, index: usize) -> bool { + !self.seams.get(index).copied().unwrap_or(false) || self.sharp_edges.get(index).copied().unwrap_or(false) + } + /// Edges bounding face `f`. pub fn face_edges(&self, f: u32) -> Vec { self.edge_faces.iter().enumerate().filter(|(_, fs)| fs.contains(&f)).map(|(i, _)| i).collect() @@ -88,7 +104,10 @@ impl Mesh { let mut todo = vec![face]; while let Some(f) = todo.pop() { for (ei, fs) in self.edge_faces.iter().enumerate() { - if !self.seams.get(ei).copied().unwrap_or(false) || !fs.iter().any(|x| *x as usize == f) { + if !self.seams.get(ei).copied().unwrap_or(false) + || self.sharp_edges.get(ei).copied().unwrap_or(false) + || !fs.iter().any(|x| *x as usize == f) + { continue; } for &next in fs { @@ -176,7 +195,7 @@ impl Mesh { while let Some(cur) = todo.pop() { let Some(ce) = ends(cur) else { continue }; for (i, _) in self.edges.iter().enumerate() { - if chain.contains(&i) || self.seams.get(i).copied().unwrap_or(false) { + if chain.contains(&i) || !self.edge_pickable(i) { continue; } let Some(ne) = ends(i) else { continue }; diff --git a/crates/geom/src/tests.rs b/crates/geom/src/tests.rs index 3e996d14..d265ba10 100644 --- a/crates/geom/src/tests.rs +++ b/crates/geom/src/tests.rs @@ -6,7 +6,41 @@ fn cube_mesh(s: f64) -> Mesh { let triangles = vec![[0, 2, 1], [0, 3, 2], [4, 5, 6], [4, 6, 7], [0, 1, 5], [0, 5, 4], [1, 2, 6], [1, 6, 5], [2, 3, 7], [2, 7, 6], [3, 0, 4], [3, 4, 7]]; let n = triangles.len(); - Mesh { normals: vec![Vec3::Z; 8], positions, triangles, tri_face: vec![0; n], edges: vec![], seams: vec![], edge_faces: vec![] } + Mesh { + normals: vec![Vec3::Z; 8], + positions, + triangles, + tri_face: vec![0; n], + edges: vec![], + seams: vec![], + smooth_edges: vec![], + sharp_edges: vec![], + edge_faces: vec![], + } +} + +#[test] +fn display_edge_masks_keep_identity_and_break_only_certified_sharp_patches() { + let mut mesh = + Mesh { edges: vec![vec![Vec3::ZERO, Vec3::Z]; 2], edge_faces: vec![vec![0, 1], vec![1, 2]], seams: vec![true, true], ..Mesh::default() }; + let stored = serde_json::to_value(&mesh).unwrap(); + assert_eq!(mesh.surface_patch(0), vec![0, 1, 2]); + mesh.smooth_edges = vec![true, false]; + assert_eq!(mesh.surface_patch(0), vec![0, 1, 2]); + assert!(!mesh.shaded_edge_visible(0)); + mesh.smooth_edges[0] = false; + mesh.sharp_edges = vec![true, false]; + assert!(mesh.shaded_edge_visible(0)); + assert!(mesh.edge_pickable(0)); + assert!(!mesh.edge_pickable(1)); + assert_eq!(mesh.surface_patch(0), vec![0]); + assert_eq!(mesh.surface_patch(1), vec![1, 2]); + assert_eq!(mesh.edges.len(), 2); + assert_eq!(mesh.face_edges(1), vec![0, 1]); + assert_eq!(serde_json::to_value(&mesh).unwrap(), stored); + let reopened: Mesh = serde_json::from_value(stored).unwrap(); + assert!(reopened.smooth_edges.is_empty() && reopened.sharp_edges.is_empty()); + assert!(Mesh::default().shaded_edge_visible(0)); } #[test] diff --git a/crates/kernel/src/body.rs b/crates/kernel/src/body.rs index 022eedc4..650dba2e 100644 --- a/crates/kernel/src/body.rs +++ b/crates/kernel/src/body.rs @@ -750,6 +750,7 @@ impl Body { return Ok(m); } m.seams = crate::topo::seam_flags(self, &m); + (m.smooth_edges, m.sharp_edges) = crate::smooth_edges::flags(self, &m); Ok(m) } diff --git a/crates/kernel/src/bore_guard.rs b/crates/kernel/src/bore_guard.rs new file mode 100644 index 00000000..d173aacc --- /dev/null +++ b/crates/kernel/src/bore_guard.rs @@ -0,0 +1,186 @@ +//! Necessary boundary safety check for the narrowed-bore fallback. +//! This is not a certificate of general Boolean equivalence. It prevents a +//! cylinder recut from silently removing an additional material boundary that +//! the fallback's coarse material samples do not see. +use crate::body::{Body, from_p3}; +use solvecraft_geom::{Plane, Seg2, Vec3}; +use std::collections::HashSet; +use truck_modeling as mt; + +pub(super) fn boundaries_clear(body: &Body, bore_faces: &[usize], origin: Vec3, axis: Vec3, radius: f64, lo: f64, hi: f64, tol: f64) -> Option<()> { + let plane = Plane::from_normal(origin, axis)?; + if !origin.is_finite() + || !axis.is_finite() + || ![radius, lo, hi, tol].iter().all(|v| v.is_finite()) + || radius <= tol + || hi <= lo + || tol <= 0. + || bore_faces.len() > 16_384 + { + return None; + } + let mut remaining_faces = 16_384usize; + let mut remaining_uses = 65_536usize; + for face in body.solid.face_iter() { + remaining_faces = remaining_faces.checked_sub(1)?; + for wire in face.absolute_boundaries() { + for _ in wire.edge_iter() { + remaining_uses = remaining_uses.checked_sub(1)?; + } + } + } + // Exempt only existing coincident bore boundaries, never newly inferred + // edges or every edge of a planar face adjoining a cylinder. + let bore_faces: HashSet = bore_faces.iter().copied().collect(); + let mut bore_edges = HashSet::new(); + for (i, face) in body.solid.face_iter().enumerate() { + if bore_faces.contains(&i) { + for edge in face.edge_iter() { + bore_edges.insert(edge.id()); + } + } + } + let mut seen = HashSet::new(); + let mut work = 2_000_000usize; + for edge in body.solid.edge_iter() { + if !seen.insert(edge.id()) { + continue; + } + let common_bore = bore_edges.contains(&edge.id()); + work = work.checked_sub(1)?; + let curve = edge.curve(); + // Original matched-cylinder trims retain the legacy fallback policy. + // Boolean-produced rational trims need not be exact analytic circles. + // This exemption follows existing face/edge identity only; additional + // trims must pass the whole-boundary proof below. It is deliberately + // not a general proof that the cylinder contains no material. + if common_bore && matches!(curve, mt::Curve::IntersectionCurve(_) | mt::Curve::BSplineCurve(_) | mt::Curve::NurbsCurve(_)) { + continue; + } + if let mt::Curve::Line(line) = curve { + let (a, b) = (from_p3(line.0), from_p3(line.1)); + if !a.is_finite() || !b.is_finite() { + return None; + } + let ha = plane.height(a); + let hb = plane.height(b); + let error = tol + 512. * f64::EPSILON * (1. + a.len() + b.len() + origin.len()); + if common_bore { + let pa = plane.to_local(a); + let pb = plane.to_local(b); + if pa.dist(pb) <= error && (pa.len() - radius).abs() + (pb.len() - radius).abs() <= error * 2. { + continue; + } + return None; + } + let (mut t0, mut t1): (f64, f64) = (0., 1.); + let dh = hb - ha; + if dh.abs() <= error { + if ha.min(hb) > hi + error || ha.max(hb) < lo - error { + continue; + } + } else { + let x = (lo - error - ha) / dh; + let y = (hi + error - ha) / dh; + t0 = t0.max(x.min(y)); + t1 = t1.min(x.max(y)); + if t0 > t1 { + continue; + } + } + let pa = plane.to_local(a + (b - a) * t0); + let pb = plane.to_local(a + (b - a) * t1); + let d = pb - pa; + let t = if d.len2() > 0. { (-pa.dot(d) / d.len2()).clamp(0., 1.) } else { 0. }; + if (pa + d * t).len() <= radius + error { + return None; + } + continue; + } + // Positive rational controls enclose the entire axial extent. Project + // the current curve onto the axis plane; the existing whole-span + // certificate proves its Line/Arc image, without point sampling. + let (knots, controls): (mt::KnotVec, Vec) = match curve { + mt::Curve::BSplineCurve(c) => { + if c.control_points().len() > 4096 || c.knot_vec().len() > 8192 { + return None; + } + work = work.checked_sub(c.control_points().len().checked_add(c.knot_vec().len())?)?; + (c.knot_vec().clone(), c.control_points().iter().map(|p| mt::Vector4::new(p.x, p.y, p.z, 1.)).collect()) + } + mt::Curve::NurbsCurve(c) => { + if c.control_points().len() > 4096 || c.knot_vec().len() > 8192 { + return None; + } + work = work.checked_sub(c.control_points().len().checked_add(c.knot_vec().len())?)?; + (c.knot_vec().clone(), c.control_points().clone()) + } + _ => return None, + }; + let mut heights = (f64::INFINITY, f64::NEG_INFINITY); + let mut projection_error: f64 = 0.; + let mut projected_origin = None; + let mut projected_spread: f64 = 0.; + let mut projected = Vec::with_capacity(controls.len()); + for p in controls { + if ![p.x, p.y, p.z, p.w].iter().all(|v| v.is_finite()) || p.w <= 0. { + return None; + } + let point = Vec3::new(p.x / p.w, p.y / p.w, p.z / p.w); + if !point.is_finite() { + return None; + } + let error = tol + 512. * f64::EPSILON * (1. + point.len() + origin.len()); + projection_error = projection_error.max(error); + let h = plane.height(point); + heights.0 = heights.0.min(h - error); + heights.1 = heights.1.max(h + error); + let q = plane.to_local(point); + let first = *projected_origin.get_or_insert(q); + projected_spread = projected_spread.max(q.dist(first)); + projected.push(mt::Vector4::new(q.x * p.w, q.y * p.w, 0., p.w)); + } + if heights.0 > hi || heights.1 < lo { + continue; + } + // A swept seam may be represented as a rational straight edge. Its + // positive control hull proves a point-sized radial projection even + // though a nondegenerate planar Line/Arc recognizer cannot accept it. + if let Some(q) = projected_origin { + if common_bore && projected_spread + projection_error + (q.len() - radius).abs() <= tol * 8. { + continue; + } + if !common_bore && q.len() - projected_spread - projection_error > radius + tol * 4. { + continue; + } + } + use mt::{BoundedCurve, ParametricCurve}; + let projected = mt::Curve::NurbsCurve(mt::NurbsCurve::new(mt::BSplineCurve::try_new(knots, projected).ok()?)); + let (a, b) = projected.range_tuple(); + let projected_edge = mt::Edge::new(&mt::Vertex::new(projected.subs(a)), &mt::Vertex::new(projected.subs(b)), projected); + let segments = crate::prism_curve::segment(&projected_edge, &Plane::XY, tol, &mut work)?; + for segment in segments { + if common_bore { + if let Seg2::Arc { center, radius: r, .. } = segment + && center.len() + (r - radius).abs() + projection_error <= tol * 8. + { + continue; + } + return None; + } + let clearance = match segment { + Seg2::Line { a, b } => { + let d = b - a; + let t = if d.len2() > 0. { (-a.dot(d) / d.len2()).clamp(0., 1.) } else { 0. }; + (a + d * t).len() + } + Seg2::Arc { center, radius, .. } => radius - center.len(), + _ => return None, + }; + if clearance <= radius + tol * 4. + projection_error { + return None; + } + } + } + Some(()) +} diff --git a/crates/kernel/src/bore_union_tests.rs b/crates/kernel/src/bore_union_tests.rs new file mode 100644 index 00000000..2dd199d0 --- /dev/null +++ b/crates/kernel/src/bore_union_tests.rs @@ -0,0 +1,118 @@ +#![allow(clippy::unwrap_used, clippy::panic)] +use crate::*; +use solvecraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3}; + +fn fixture() -> (Body, Body, Vec3, f64) { + let slope = 1f64.to_radians().tan(); + let hs = |normal: Vec3, point: Vec3| { + let n = normal.normalized().unwrap(); + HalfSpace { n, d: n.dot(point) } + }; + let stock = convex_polyhedron(&[ + hs(Vec3::X, Vec3::new(40., 0., 0.)), + hs(-Vec3::X, Vec3::ZERO), + hs(Vec3::Y, Vec3::new(0., 30., 0.)), + hs(-Vec3::Y, Vec3::ZERO), + hs(-Vec3::Z, Vec3::ZERO), + hs(Vec3::new(-slope, 0., 1.), Vec3::new(0., 0., 20.)), + ]) + .unwrap(); + let cutter = cylinder(Vec3::new(20., 15., -5.), Vec3::Z, 5., 40.).unwrap(); + let bored = boolean(&stock, &cutter, BoolOp::Cut).unwrap().unwrap(); + let web = box_solid(Vec3::new(14., 14., 16.05), Vec3::new(26., 16., 16.07)).unwrap(); + let target = boolean(&bored, &web, BoolOp::Union).unwrap().unwrap(); + let region = Region2 { outer: Loop2::circle(Vec2::new(20., 15.), 8.), holes: vec![Loop2::circle(Vec2::new(20., 15.), 5.).reversed()] }; + let ring = extrude(&Plane::XY, &[region], 14., 24.).unwrap().remove(0); + let web_volume = 0.02 * 2. * (24f64.sqrt() + 25. * 0.2f64.asin()); + let fine_target = target.tessellate_with(1e-4, true).unwrap(); + let added = fine_target.measure().volume - bored.tessellate_with(1e-4, true).unwrap().measure().volume; + assert!((added - web_volume).abs() < 0.005, "actual connected web adds {added}, expected {web_volume}"); + for x in [16., 20., 24.] { + for y in [14.2, 15., 15.8] { + assert!(fine_target.contains(Vec3::new(x, y, 16.06)), "source web must exist at {x},{y}"); + } + } + assert!(!fine_target.contains(Vec3::new(20., 12., 16.06))); + let floor_volume = (20. + slope * 20.) * (1200. - 25. * std::f64::consts::PI); + let ring_added = std::f64::consts::PI * (64. - 25.) * (24. - (20. + slope * 20.)); + (target, ring, Vec3::new(20., 15., 16.06), floor_volume + web_volume + ring_added) +} + +#[test] +fn thin_connected_web_between_bore_stations_survives_union() { + let (target, ring, probe, expected) = fixture(); + let target_before = step_export(&[&target], "source").unwrap(); + let ring_before = step_export(&[&ring], "ring").unwrap(); + let size = target.size().max(ring.size()); + let tol = (size * 5e-4).max(size * 1e-5); + let ma = target.tessellate_with(tol, true).unwrap(); + let mb = ring.tessellate_with(tol, true).unwrap(); + assert!(ma.contains(probe), "fixture must contain the real connected web"); + assert!(!mb.contains(probe)); + let va = ma.measure().volume; + let vb = mb.measure().volume; + let slack = 2e-3 * (va + vb) + 1e-9; + let accept = |body: &Body| { + let mr = body.tessellate_with(tol, true).unwrap(); + let v = mr.measure().volume; + let mismatch = super::mismatch(&ma, &mb, &mr, BoolOp::Union); + eprintln!("fallback candidate V={v}, expected={expected}, web={}, mismatch={mismatch:?}", mr.contains(probe)); + v > 0. && v >= va.max(vb) - slack && v <= va + vb + slack && mismatch.is_none_or(|f| f < 0.004) + }; + let direct = crate::coplanar::bore_union(&target, &ring, &accept); + eprintln!("direct fallback result={}", direct.is_some()); + let public = boolean(&target, &ring, BoolOp::Union); + if let Ok(dir) = std::env::var("SOLVECRAFT_BORE_AUDIT_DIR") { + let dir = std::path::Path::new(&dir); + std::fs::create_dir_all(dir).unwrap(); + std::fs::write(dir.join("target-with-web.step"), step_export(&[&target], "target with web").unwrap()).unwrap(); + if let Some(result) = &direct { + std::fs::write(dir.join("direct-union.step"), step_export(&[result], "direct union").unwrap()).unwrap(); + } + if let Ok(Some(result)) = &public { + std::fs::write(dir.join("public-union.step"), step_export(&[result], "public union").unwrap()).unwrap(); + } + } + let direct_keeps_web = direct.as_ref().is_none_or(|result| result.tessellate_with(1e-3, true).unwrap().contains(probe)); + let public_keeps_web = match &public { + Ok(Some(result)) => { + let keep = result.tessellate_with(1e-3, true).unwrap().contains(probe); + eprintln!("public Union result web={keep}"); + keep + } + result => { + eprintln!("public Union honestly refused: {result:?}"); + true + } + }; + assert!(direct_keeps_web, "accepted fallback removed real web material"); + assert!(public_keeps_web, "public Union removed real web material"); + assert_eq!(step_export(&[&target], "source").unwrap(), target_before); + assert_eq!(step_export(&[&ring], "ring").unwrap(), ring_before); +} + +#[test] +fn unproved_curved_boundary_between_clear_endpoints_refuses_recut() { + let a = Vec2::new(6., -1.); + let b = Vec2::new(6., 1.); + let c = Vec2::new(8., 1.); + let d = Vec2::new(8., -1.); + let region = Region2 { + outer: Loop2 { + segs: vec![ + Seg2::Cubic { p0: a, p1: Vec2::new(3., -0.5), p2: Vec2::new(3., 0.5), p3: b }, + Seg2::Line { a: b, b: c }, + Seg2::Line { a: c, b: d }, + Seg2::Line { a: d, b: a }, + ], + } + .reversed(), + holes: vec![], + }; + let body = extrude(&Plane::XY, &[region], 16.05, 16.07).unwrap().remove(0); + assert!(a.len() > 5. && b.len() > 5., "endpoint samples miss the interior excursion"); + assert!(body.tessellate_with(1e-4, true).unwrap().contains(Vec3::new(4.5, 0., 16.06))); + let before = step_export(&[&body], "curved source").unwrap(); + assert!(crate::bore_guard::boundaries_clear(&body, &[], Vec3::ZERO, Vec3::Z, 5., 14., 24., 1e-6).is_none()); + assert_eq!(step_export(&[&body], "curved source").unwrap(), before); +} diff --git a/crates/kernel/src/coplanar.rs b/crates/kernel/src/coplanar.rs index a0eaa96c..256ea52b 100644 --- a/crates/kernel/src/coplanar.rs +++ b/crates/kernel/src/coplanar.rs @@ -461,9 +461,11 @@ pub(crate) fn has_coincident_cylinders(a: &Body, b: &Body) -> bool { /// it is joined over). The mesh intersection can't take the coincident walls, so the tool's /// bore is narrowed (its walls pushed toward the axis, now clear of the target's), the bodies /// are joined, and the cylinder of the original bore is cut out again over the tool's length. -/// That is exact when the cylinder holds no material of either body, which is checked on -/// sample points. Each way (the tool's bore narrowed, else the target's) gives a result only -/// when `accept` takes it. `None` when there are no coincident bores or no way works. +/// Recutting is valid only where that cylinder holds no material of either body. The +/// samples are plausibility checks; a whole-boundary guard rejects additional boundaries +/// crossing the recut. Original common-bore trims retain the legacy eligibility policy. +/// This is not a general emptiness or Boolean-equivalence certificate. Each direction +/// returns a result only when `accept` takes it; otherwise this returns `None`. pub(crate) fn bore_union(a: &Body, b: &Body, accept: &dyn Fn(&Body) -> bool) -> Option { let size = a.size().max(b.size()); let tol = (size * 1e-7).max(1e-9) * 10.0; @@ -536,6 +538,15 @@ pub(crate) fn bore_union(a: &Body, b: &Body, accept: &dyn Fn(&Body) -> bool) -> }; let lo = if past(t0 - ext * 0.5) && past(t0 - ext) { t0 - ext } else { t0 }; let hi = if past(t1 + ext * 0.5) && past(t1 + ext) { t1 + ext } else { t1 }; + // The samples above are plausibility checks, not proof that an + // arbitrarily thin web is absent. Every additional current boundary + // must be wholly clear of the recut; unsupported trims refuse. + let other_bore: Vec = co.iter().filter(|x| same_cylinder(c, x, tol)).map(|x| x.index).collect(); + if crate::bore_guard::boundaries_clear(tool, &bore, c.origin, c.axis, c.radius, lo, hi, tol).is_none() + || crate::bore_guard::boundaries_clear(target, &other_bore, c.origin, c.axis, c.radius, lo, hi, tol).is_none() + { + continue; + } let d = (c.radius * 0.2).min(size * 0.01); let run = || -> Result> { let narrowed = crate::offset::offset_body(tool, |i, _| if bore.contains(&i) { d } else { 0.0 })?; diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 9430c68e..1fab1618 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -16,6 +16,7 @@ mod blend; mod blend_corner; mod blend_variable; mod body; +mod bore_guard; mod build; mod coplanar; mod curveblend; @@ -39,8 +40,10 @@ mod polybool; mod polyhedron; mod prism; mod prism_curve; +mod prism_measure; pub mod provenance; mod sew; +mod smooth_edges; mod splitface; mod step; mod step_in; @@ -70,6 +73,7 @@ pub use ops::{BoolOp, boolean, can_join_touching_faces, split_by_plane, transfor pub use polybool::{faceted_boolean, planar_boolean}; pub use polyhedron::{HalfSpace, convex_polyhedron, draft, offset_faces, shell}; pub use prism::{extrude_planar_regions, planar_regions_boolean}; +pub use prism_measure::certified_prism_equivalent; pub use sew::{EdgeSpec, FaceSpec, SurfSpec, sew}; pub use splitface::{SplitTool, split_body, split_faces, split_faces_with_map}; pub use step::step_export; diff --git a/crates/kernel/src/ops.rs b/crates/kernel/src/ops.rs index ea049d5c..df4c4804 100644 --- a/crates/kernel/src/ops.rs +++ b/crates/kernel/src/ops.rs @@ -7,6 +7,10 @@ use truck_modeling as mt; use crate::body::{Body, Solid, from_p3}; use crate::{KernelError, Result, guard}; +#[cfg(test)] +#[path = "bore_union_tests.rs"] +mod bore_union_tests; + #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum BoolOp { diff --git a/crates/kernel/src/prism_measure.rs b/crates/kernel/src/prism_measure.rs new file mode 100644 index 00000000..8b3ed66b --- /dev/null +++ b/crates/kernel/src/prism_measure.rs @@ -0,0 +1,903 @@ +//! Independent current-geometry intervals for uniform prisms. +//! These are not mesh measurements or certificates of construction history. +use crate::body::from_p3; +use solvecraft_geom::{Plane, Seg2, Vec2, Vec3}; +use truck_modeling as mt; + +#[derive(Clone, Copy, Debug)] +struct Interval { + lo: f64, + hi: f64, +} + +impl Interval { + fn enclosing(value: f64, error: f64) -> Option { + if !value.is_finite() || !error.is_finite() || error < 0. { + return None; + } + // The explicit extra arithmetic allowance also covers subtraction and + // accumulation; do not round an uncertainty inward at its endpoints. + let padding = 16. * f64::EPSILON * (value.abs() + error + 1.); + Some(Self { lo: value - error - padding, hi: value + error + padding }) + } + + fn add(self, other: Self) -> Option { + Self::enclosing((self.lo + self.hi + other.lo + other.hi) * 0.5, (self.hi - self.lo + other.hi - other.lo) * 0.5) + } +} + +/// Signed Green integral of a fitted segment with the *whole-curve* spatial +/// uncertainty. Angular/longitudinal monotonicity is mandatory: a curve in a +/// thin corridor could otherwise retrace it arbitrarily often. +fn green_interval(segment: &Seg2, tolerance: f64) -> Option { + let (length, a, b) = (segment.length(), segment.start(), segment.end()); + let extra = match *segment { + Seg2::Line { .. } => 0., + Seg2::Arc { center, radius, .. } => center.len() + radius, + _ => return None, + }; + let scale = length + a.len() + b.len() + extra; + // A monotone line differs from its chord by a strip; a radial-monotone + // arc contributes 1/2 integral r(theta)^2 dtheta. The endpoint wedges, + // coordinate uncertainty, and angular endpoint uncertainty are included + // by this conservative bound (rather than treating the fitted arc exact). + let error = 8. * tolerance * scale + 64. * tolerance * tolerance + 1024. * f64::EPSILON * (1. + scale * scale); + Interval::enclosing(segment.area_term(), error) +} + +struct CapLoop { + segments: Vec, + area: Interval, +} + +/// Cross-product cancellation belongs to the surface representation, not +/// just the later incidence dot product. Reject ill-conditioned plane bases. +fn plane_normal(p: &mt::Plane) -> Option<(Vec3, Vec3)> { + let u = p.u_axis(); + let v = p.v_axis(); + let u = Vec3::new(u.x, u.y, u.z); + let v = Vec3::new(v.x, v.y, v.z); + let cross = u.cross(v); + let length = cross.len(); + if !length.is_finite() || length <= 1e-6 * u.len() * v.len() { + return None; + } + let n = cross.normalized()?; + let ce = Vec3::new((u.y * v.z).abs() + (u.z * v.y).abs(), (u.z * v.x).abs() + (u.x * v.z).abs(), (u.x * v.y).abs() + (u.y * v.x).abs()) + * (256. * f64::EPSILON); + let ne = ce * (1. / length) + Vec3::new(n.x.abs(), n.y.abs(), n.z.abs()) * (ce.len() / length); + (ne.x.is_finite() && ne.y.is_finite() && ne.z.is_finite()).then_some((n, ne)) +} + +/// A whole positive control-net bound for the wall's projected variation. +/// A sampled normal or a vertex-only test is deliberately insufficient. +fn wall_error(surface: &mt::Surface, d: Vec3, height: f64, work: &mut usize) -> Option { + if let mt::Surface::Plane(p) = surface { + let (n, ne) = plane_normal(p)?; + let terms = (n.x * d.x).abs() + (n.y * d.y).abs() + (n.z * d.z).abs(); + let error = ne.x * d.x.abs() + ne.y * d.y.abs() + ne.z * d.z.abs() + 1024. * f64::EPSILON * terms; + return Some((n.dot(d).abs() + error) * height * 2.); + } + if let mt::Surface::RevolutedCurve(p) = surface { + let r = p.entity(); + let mt::Curve::Line(line) = r.entity_curve() else { return None }; + let axis = r.axis(); + let meridian = line.1 - line.0; + let a = Vec3::new(axis.x, axis.y, axis.z).normalized()?; + let m = Vec3::new(meridian.x, meridian.y, meridian.z).normalized()?; + let placed = *p.transform() * mt::Vector4::new(axis.x, axis.y, axis.z, 0.); + if placed.w != 0. { + return None; + } + let pa = Vec3::new(placed.x, placed.y, placed.z).normalized()?; + return Some((a.cross(m).len() + pa.cross(d).len()) * height * 4.); + } + let (rows, cols) = match surface { + mt::Surface::BSplineSurface(s) => (s.control_points().len(), s.control_points().first()?.len()), + mt::Surface::NurbsSurface(s) => (s.control_points().len(), s.control_points().first()?.len()), + _ => return None, + }; + let count = rows.checked_mul(cols)?; + let rectangular = match surface { + mt::Surface::BSplineSurface(s) => s.control_points().iter().all(|r| r.len() == cols), + mt::Surface::NurbsSurface(s) => s.control_points().iter().all(|r| r.len() == cols), + _ => false, + }; + if rows < 2 || cols < 2 || count > 16_384 || !rectangular { + return None; + } + *work = work.checked_sub(count.checked_mul(4)?)?; + let points: Vec> = match surface { + mt::Surface::BSplineSurface(s) => s.control_points().iter().map(|r| r.iter().map(|p| (from_p3(*p), 1.)).collect()).collect(), + mt::Surface::NurbsSurface(s) => { + s.control_points().iter().map(|r| r.iter().map(|p| (Vec3::new(p.x / p.w, p.y / p.w, p.z / p.w), p.w)).collect()).collect() + } + _ => return None, + }; + if points.iter().any(|r| r.len() != cols) || points.iter().flatten().any(|(p, w)| !p.is_finite() || !w.is_finite() || *w <= 0.) { + return None; + } + let max_w = points.iter().flatten().map(|p| p.1).fold(0., f64::max); + let min_w = points.iter().flatten().map(|p| p.1 / max_w).fold(1., f64::min); + if min_w < 1e-6 { + return None; + } + let magnitude = points.iter().flatten().map(|p| p.0.len()).fold(1., f64::max); + let mut best = f64::INFINITY; + for transpose in [false, true] { + let at = |i: usize, j: usize| if transpose { points.get(j)?.get(i).copied() } else { points.get(i)?.get(j).copied() }; + let (groups, length) = if transpose { (cols, rows) } else { (rows, cols) }; + let mut variation: f64 = 0.; + let mut rank_error: f64 = 0.; + let mut monotone = true; + let mut axial_sign = 0.; + for i in 0..groups { + let (base, bw) = at(i, 0)?; + let mut previous = base.dot(d); + for j in 0..length { + let (point, w) = at(i, j)?; + let step = point.dot(d) - previous; + previous = point.dot(d); + if step.abs() > 2048. * f64::EPSILON * magnitude { + if axial_sign * step < 0. { + monotone = false; + } + axial_sign = step.signum(); + } + variation = variation.max((point - base).cross(d).len()); + let lhs = w / max_w * (at(0, 0)?.1 / max_w); + let rhs = bw / max_w * (at(0, j)?.1 / max_w); + rank_error = rank_error.max((lhs - rhs).abs()); + } + if (at(i, length - 1)?.0 - base).dot(d).abs() < height * 0.5 { + monotone = false; + } + } + if !monotone || axial_sign == 0. { + continue; + } + // Positive weights turn the control hull into a positional bound. + // Rank-one weight error perturbs the normalized convex coefficients; + // the positive minimum weight prevents an unbounded denominator. + let error = 2. * variation + 8. * magnitude * rank_error / min_w.powi(2) + 2048. * f64::EPSILON * magnitude / min_w.powi(2); + best = best.min(error); + } + best.is_finite().then_some(best) +} + +fn segment_same(a: &Seg2, b: &Seg2, tolerance: f64) -> bool { + match (*a, *b) { + (Seg2::Line { a, b }, Seg2::Line { a: c, b: d }) => { + (a.dist(c) <= tolerance && b.dist(d) <= tolerance) || (a.dist(d) <= tolerance && b.dist(c) <= tolerance) + } + (Seg2::Arc { center, radius, start, sweep }, Seg2::Arc { center: c, radius: r, start: t, sweep: s }) => { + center.dist(c) <= tolerance + && (radius - r).abs() <= tolerance + && (sweep.abs() - s.abs()).abs() * radius <= tolerance + && ((Vec2::new(start.cos(), start.sin()) * radius).dist(Vec2::new(t.cos(), t.sin()) * r) <= tolerance + || (Vec2::new(start.cos(), start.sin()) * radius).dist(Vec2::new((t + s).cos(), (t + s).sin()) * r) <= tolerance) + } + _ => false, + } +} + +fn wall_supports_cap(surface: &mt::Surface, edge: &mt::Edge, plane: &Plane, levels: (f64, f64), tol: f64, work: &mut usize) -> Option { + use mt::{BoundedCurve, ParametricCurve}; + let cap = crate::prism_curve::segment(edge, plane, tol, work)?; + if let mt::Surface::Plane(p) = surface { + let (n, ne) = plane_normal(p)?; + let origin = from_p3(p.origin()); + for s in cap { + let Seg2::Line { a, b } = s else { return Some(false) }; + for q in [a, b] { + let point = plane.to_world(q) + plane.normal() * plane.height(from_p3(edge.front().point())); + let delta = point - origin; + let error = + 1024. * f64::EPSILON * (1. + point.len() + origin.len()) + delta.x.abs() * ne.x + delta.y.abs() * ne.y + delta.z.abs() * ne.z; + if delta.dot(n).abs() + error > tol * 8. { + return Some(false); + } + } + } + return Some(true); + } + if let mt::Surface::RevolutedCurve(processor) = surface { + let r = processor.entity(); + let mt::Curve::Line(line) = r.entity_curve() else { return None }; + let o = from_p3(r.origin()); + let axis = r.axis(); + let axis = Vec3::new(axis.x, axis.y, axis.z).normalized()?; + let delta = from_p3(line.0) - o; + let radial = (delta - axis * delta.dot(axis)).len(); + let u = axis.any_perp(); + let v = axis.cross(u); + let matrix = *processor.transform(); + let place = |p: Vec3, w: f64| { + let q = matrix * mt::Vector4::new(p.x, p.y, p.z, w); + (q.w == w).then_some(Vec3::new(q.x, q.y, q.z)) + }; + let center = plane.to_local(place(o, 1.)?); + let ha = plane.height(place(from_p3(line.0), 1.)?); + let hb = plane.height(place(from_p3(line.1), 1.)?); + if ha.min(hb) > levels.0 + tol * 8. || ha.max(hb) < levels.1 - tol * 8. { + return Some(false); + } + let (pu, pv) = (place(u, 0.)?, place(v, 0.)?); + let radius = pu.len() * radial; + if (pu.len() - pv.len()).abs() * radial > tol + || pu.dot(pv).abs() * radial > tol + || pu.dot(plane.normal()).abs() * radial > tol + || pv.dot(plane.normal()).abs() * radial > tol + { + return None; + } + return Some(cap.iter().all(|s| matches!(*s,Seg2::Arc {center:c,radius:r,..} if c.dist(center)+(r-radius).abs()<=tol*8.))); + } + let (rows, cols) = match surface { + mt::Surface::BSplineSurface(s) => (s.control_points().len(), s.control_points().first()?.len()), + mt::Surface::NurbsSurface(s) => (s.control_points().len(), s.control_points().first()?.len()), + _ => return None, + }; + *work = work.checked_sub(rows.checked_mul(cols)?.checked_mul(4)?)?; + let at = |i: usize, j: usize| -> Option<(Vec3, f64)> { + match surface { + mt::Surface::BSplineSurface(s) => s.control_points().get(i)?.get(j).map(|p| (from_p3(*p), 1.)), + mt::Surface::NurbsSurface(s) => s.control_points().get(i)?.get(j).map(|p| (Vec3::new(p.x / p.w, p.y / p.w, p.z / p.w), p.w)), + _ => None, + } + }; + for transpose in [false, true] { + let point = |i, j| if transpose { at(j, i) } else { at(i, j) }; + let (groups, length) = if transpose { (cols, rows) } else { (rows, cols) }; + // The first measurement subset uses a degree-one axial ruling. Its + // positive rational interpolation is monotone and reaches both caps; + // projected incidence alone would miss an axially displaced support. + if !(2..=4096).contains(&groups) || length != 2 { + continue; + } + let axial_knots = match surface { + mt::Surface::BSplineSurface(s) => { + if transpose { + s.uknot_vec() + } else { + s.vknot_vec() + } + } + mt::Surface::NurbsSurface(s) => { + if transpose { + s.uknot_vec() + } else { + s.vknot_vec() + } + } + _ => return None, + }; + if axial_knots.len() != 4 + || axial_knots.first() != axial_knots.get(1) + || axial_knots.get(2) != axial_knots.last() + || axial_knots.get(1)? >= axial_knots.get(2)? + { + continue; + } + let mut projected = Vec::new(); + let mut valid = true; + let mut increasing = None; + for i in 0..groups { + let (base, w) = point(i, 0)?; + let h0 = plane.height(base); + let h1 = plane.height(point(i, 1)?.0); + if h0.min(h1) > levels.0 + tol * 8. || h0.max(h1) < levels.1 - tol * 8. || increasing.is_some_and(|s| s != (h1 > h0)) { + valid = false; + break; + } + increasing = Some(h1 > h0); + let p = plane.to_local(base); + for j in 0..length { + if plane.to_local(point(i, j)?.0).dist(p) > tol * 8. { + valid = false; + break; + } + } + let q = plane.to_world(p); + projected.push(mt::Vector4::new(q.x * w, q.y * w, q.z * w, w)); + } + if !valid { + continue; + } + let knots = match surface { + mt::Surface::BSplineSurface(s) => { + if transpose { + s.vknot_vec() + } else { + s.uknot_vec() + } + } + mt::Surface::NurbsSurface(s) => { + if transpose { + s.vknot_vec() + } else { + s.uknot_vec() + } + } + _ => return None, + }; + if knots.len() > 8192 { + return None; + } + *work = work.checked_sub(knots.len())?; + let c = mt::Curve::NurbsCurve(mt::NurbsCurve::new(mt::BSplineCurve::try_new(knots.clone(), projected).ok()?)); + let (a, b) = c.range_tuple(); + let support = mt::Edge::new(&mt::Vertex::new(c.subs(a)), &mt::Vertex::new(c.subs(b)), c); + let Some(fit) = crate::prism_curve::segment(&support, plane, tol, work) else { + continue; + }; + let fit_loop = CapLoop { segments: fit.clone(), area: Interval::enclosing(0., 0.)? }; + if let Some((center, radius)) = circle(&fit_loop, tol * 8.) + && cap.iter().all(|s| matches!(*s,Seg2::Arc {center:c,radius:r,..} if c.dist(center)+(r-radius).abs()<=tol*8.)) + { + return Some(true); + } + let same = loops_same( + &CapLoop { segments: cap.clone(), area: Interval::enclosing(0., 0.)? }, + &CapLoop { segments: fit, area: Interval::enclosing(0., 0.)? }, + tol * 8., + work, + )?; + if same { + return Some(true); + } + } + Some(false) +} + +fn circle(lp: &CapLoop, tolerance: f64) -> Option<(Vec2, f64)> { + let Seg2::Arc { center, radius, sweep: first, .. } = *lp.segments.first()? else { return None }; + let mut sweep = 0.; + for s in &lp.segments { + let Seg2::Arc { center: c, radius: r, sweep: a, .. } = *s else { return None }; + if c.dist(center) > tolerance || (r - radius).abs() > tolerance || a * first <= 0. { + return None; + } + sweep += a; + } + ((sweep.abs() - std::f64::consts::TAU).abs() * radius <= tolerance).then_some((center, radius)) +} + +fn loops_same(a: &CapLoop, b: &CapLoop, tolerance: f64, work: &mut usize) -> Option { + *work = work.checked_sub(a.segments.len().checked_add(b.segments.len())?)?; + if (a.area.lo > 0.) != (b.area.lo > 0.) { + return Some(false); + } + if let (Some((ca, ra)), Some((cb, rb))) = (circle(a, tolerance), circle(b, tolerance)) { + return Some(ca.dist(cb) + (ra - rb).abs() <= tolerance); + } + if a.segments.len() != b.segments.len() { + return Some(false); + } + *work = work.checked_sub(a.segments.len().checked_mul(b.segments.len())?)?; + let mut used = vec![false; b.segments.len()]; + for s in &a.segments { + let mut match_index = None; + for (i, t) in b.segments.iter().enumerate() { + if !used.get(i).copied()? && segment_same(s, t, tolerance) { + if match_index.is_some() { + return None; + } + match_index = Some(i); + } + } + let Some(i) = match_index else { return Some(false) }; + *used.get_mut(i)? = true; + } + Some(true) +} + +struct Certificate { + loops: Vec, + volume: Interval, + lo: f64, + hi: f64, +} + +fn preflight_body(body: &crate::Body) -> Option<()> { + if body.solid.boundaries().len() != 1 || body.mesh.is_some() { + return None; + } + let mut faces = 16_384usize; + let mut edges = 65_536usize; + for face in body.solid.face_iter() { + faces = faces.checked_sub(1)?; + // Borrow absolute wires: the oriented iterator would clone a hostile + // oversized wire before its edge count has been checked. + for wire in face.absolute_boundaries() { + for _ in wire.edge_iter() { + edges = edges.checked_sub(1)?; + } + } + } + Some(()) +} + +/// Initial material scope: a convex straight outer or a circle, with separated +/// circular holes. Wider profiles must acquire their own separation proof. +fn separated_material(loops: &[CapLoop], clearance: f64, work: &mut usize) -> Option<()> { + let outer = loops.iter().find(|l| l.area.lo > 0.)?; + if loops.iter().filter(|l| l.area.lo > 0.).count() != 1 { + return None; + } + let outer_circle = circle(outer, clearance); + if outer_circle.is_none() { + *work = work.checked_sub(outer.segments.len().checked_mul(outer.segments.len())?)?; + for s in &outer.segments { + let Seg2::Line { a, b } = *s else { return None }; + let delta = b - a; + if delta.len() <= clearance { + return None; + } + for t in &outer.segments { + if delta.cross(t.start() - a) < -clearance * delta.len() { + return None; + } + } + } + let mut turn = 0.; + let mut angular_error = 0.; + for (i, s) in outer.segments.iter().enumerate() { + let Seg2::Line { a, b } = *s else { + return None; + }; + let next = outer.segments.get((i + 1) % outer.segments.len())?; + let x = b - a; + let y = next.end() - next.start(); + turn += x.cross(y).atan2(x.dot(y)); + angular_error += clearance * 8. / x.len() + 256. * f64::EPSILON; + for (j, t) in outer.segments.iter().enumerate() { + if i != j && a.dist(t.start()) <= clearance { + return None; + } + } + } + if angular_error > 1e-3 || (turn - std::f64::consts::TAU).abs() > angular_error { + return None; + } + } + let mut holes = Vec::new(); + for lp in loops { + if std::ptr::eq(lp, outer) { + continue; + } + if lp.area.hi >= 0. { + return None; + } + let (center, radius) = circle(lp, clearance)?; + if let Some((c, r)) = outer_circle { + if center.dist(c) + radius + clearance >= r { + return None; + } + } else { + *work = work.checked_sub(outer.segments.len())?; + for s in &outer.segments { + let Seg2::Line { a, b } = *s else { return None }; + if (b - a).cross(center - a) / (b - a).len() <= radius + clearance { + return None; + } + } + } + *work = work.checked_sub(holes.len())?; + for (c, r) in &holes { + if center.dist(*c) <= radius + r + clearance { + return None; + } + } + holes.push((center, radius)); + } + Some(()) +} + +fn certificate(body: &crate::Body, plane: &Plane, tol: f64) -> Option { + preflight_body(body)?; + let d = plane.normal(); + let body_size = body.size(); + let mut remaining = 16_384usize; + let mut caps = Vec::new(); + let mut walls = Vec::new(); + let mut edge_uses = 65_536usize; + for face in body.solid.face_iter() { + remaining = remaining.checked_sub(1)?; + for _ in face.edge_iter() { + edge_uses = edge_uses.checked_sub(1)?; + } + let surface = face.oriented_surface(); + if let mt::Surface::Plane(p) = &surface { + let n = p.normal(); + let n = Vec3::new(n.x, n.y, n.z).normalized()?; + if n.dot(d).abs() > 1. - 1e-12 { + if n.cross(d).len() * body_size > tol { + return None; + } + // A planar support's parameter origin may be arbitrarily far + // from its trimmed face. Use actual boundary geometry only. + let level = plane.height(from_p3(face.vertex_iter().next()?.point())); + caps.push((face, level, n.dot(d) > 0.)); + if caps.len() > 2 { + return None; + } + continue; + } + } + walls.push((face, surface)); + } + if caps.len() != 2 { + return None; + } + let top = caps.iter().find(|c| c.2)?; + let bottom = caps.iter().find(|c| !c.2)?; + let height = top.1 - bottom.1; + if height <= tol || !height.is_finite() { + return None; + } + for cap in [top, bottom] { + let mt::Surface::Plane(support) = cap.0.oriented_surface() else { + return None; + }; + let origin = from_p3(support.origin()); + let (n, ne) = plane_normal(&support)?; + for vertex in cap.0.vertex_iter() { + let point = from_p3(vertex.point()); + let delta = point - origin; + let terms = (delta.x * n.x).abs() + (delta.y * n.y).abs() + (delta.z * n.z).abs(); + let error = 1024. * f64::EPSILON * terms + delta.x.abs() * ne.x + delta.y.abs() * ne.y + delta.z.abs() * ne.z; + if delta.dot(n).abs() + error > tol * 2. || (plane.height(point) - cap.1).abs() > tol * 2. { + return None; + } + } + } + let mut work = 2_000_000; + let loops = top.0.boundaries().iter().map(|w| cap_loop(w, plane, tol, &mut work)).collect::>>()?; + let lower = bottom.0.boundaries().iter().map(|w| cap_loop(w, plane, tol, &mut work)).collect::>>()?; + if loops.len() != lower.len() || loops.is_empty() || loops.len() > 8192 { + return None; + } + let mut pair_work = 16_000_000; + for a in &loops { + // Bottom cap has the opposite material orientation. + let reverse = CapLoop { segments: a.segments.clone(), area: Interval { lo: -a.area.hi, hi: -a.area.lo } }; + let mut matches = 0usize; + for b in &lower { + if loops_same(&reverse, b, tol * 8., &mut pair_work)? { + matches += 1; + } + } + if matches != 1 { + return None; + } + } + let mut controls = 262_144; + // Include cap fitting, support fitting and the <=8tol support-to-cap + // comparison, not just the control-net's projected ruling variation. + let mut corridor = tol * 16.; + let cap_edges = top.0.edge_iter().count().checked_add(bottom.0.edge_iter().count())?; + pair_work = pair_work.checked_sub((65_536 - edge_uses).checked_mul(cap_edges)?)?; + for (face, surface) in &walls { + let variation = wall_error(surface, d, height, &mut controls)?; + if variation > tol * 8. { + return None; + } + corridor = corridor.max(tol * 16. + variation); + if corridor > tol * 24. { + return None; + } + // Every wall must end on these actual cap wires. This is topology + // correspondence, not nearest geometry or stale face provenance. + let mut upper = 0usize; + let mut lower = 0usize; + for edge in face.edge_iter() { + if top.0.edge_iter().any(|e| e.id() == edge.id()) { + if !wall_supports_cap(surface, &edge, plane, (bottom.1, top.1), tol, &mut work)? { + return None; + } + upper += 1; + continue; + } + if bottom.0.edge_iter().any(|e| e.id() == edge.id()) { + if !wall_supports_cap(surface, &edge, plane, (bottom.1, top.1), tol, &mut work)? { + return None; + } + lower += 1; + continue; + } + let mt::Curve::Line(line) = edge.curve() else { return None }; + if from_p3(line.0).dist(from_p3(edge.absolute_front().point())) > tol || from_p3(line.1).dist(from_p3(edge.absolute_back().point())) > tol + { + return None; + } + let delta = from_p3(line.1) - from_p3(line.0); + if delta.cross(d).len() > corridor || delta.dot(d).abs() < height - tol * 8. { + return None; + } + } + if upper == 0 || upper != lower { + return None; + } + } + let mut area = Interval::enclosing(0., 0.)?; + let mut length = 0.; + let mut count = 0usize; + for lp in &loops { + area = area.add(lp.area)?; + for edge in &lp.segments { + length += edge.length(); + count = count.checked_add(1)?; + } + } + if count > 8192 || area.lo <= 0. { + return None; + } + separated_material(&loops, corridor * 16., &mut pair_work)?; + // Each boundary's monotone radial/linear corridor contributes at most a + // two-sided strip plus four endpoint disks. Unknown intersecting or + // touching corridors will be refused by the separation check below. + let strip = 2. * length * corridor + 4. * std::f64::consts::PI * count as f64 * corridor * corridor; + // Each cap's vertices are within 2tol and its whole rational edge + // control heights within another tol; include both cap corridors. + let h_error = tol * 8.; + if area.lo <= strip || height <= h_error { + return None; + } + let lo = (area.lo - strip) * (height - h_error); + let hi = (area.hi + strip) * (height + h_error); + let volume = Interval::enclosing((lo + hi) * 0.5, (hi - lo) * 0.5)?; + if volume.lo <= 0. || volume.hi - volume.lo > 0.001 { + return None; + } + Some(Certificate { loops, volume, lo: bottom.1, hi: top.1 }) +} + +/// Independently prove equality of the supported current uniform prisms. +/// `None` means unproved, including budget exhaustion; it never grants a copy. +pub fn certified_prism_equivalent(a: &crate::Body, b: &crate::Body) -> crate::Result> { + crate::guard("certified prism comparison", || { + let result = (|| { + preflight_body(a)?; + preflight_body(b)?; + if !a.size().is_finite() || !b.size().is_finite() { + return None; + } + let size = a.size().max(b.size()).max(1.); + let tol = size * 1e-10; + let identity_tol = size * 1e-7; + let origin = a.solid.vertex_iter().next().map(|v| from_p3(v.point()))?; + let mut candidates = Vec::new(); + let mut remaining = 16_384usize; + for f in a.solid.face_iter() { + remaining = remaining.checked_sub(1)?; + if let mt::Surface::Plane(p) = f.oriented_surface() { + let n = p.normal(); + let n = Vec3::new(n.x, n.y, n.z).normalized()?; + if !candidates.iter().any(|d: &Vec3| d.dot(n).abs() > 1. - 1e-12) { + candidates.push(n); + if candidates.len() == 8 { + break; + } + } + } + } + for d in candidates { + let plane = Plane::from_normal(origin, d)?; + let Some(ca) = certificate(a, &plane, tol) else { continue }; + let Some(cb) = certificate(b, &plane, tol) else { continue }; + if (ca.lo - cb.lo).abs() > identity_tol || (ca.hi - cb.hi).abs() > identity_tol || ca.loops.len() != cb.loops.len() { + return Some(false); + } + let mut work = 16_000_000; + let mut used = vec![false; cb.loops.len()]; + for lp in &ca.loops { + let mut matched = None; + for (i, other) in cb.loops.iter().enumerate() { + if !used.get(i).copied()? && loops_same(lp, other, identity_tol, &mut work)? { + if matched.is_some() { + return None; + } + matched = Some(i); + } + } + let Some(i) = matched else { return Some(false) }; + *used.get_mut(i)? = true; + } + let delta = (ca.volume.hi - cb.volume.lo).abs().max((cb.volume.hi - ca.volume.lo).abs()); + return Some(delta <= 1e-5 * (1. + ca.volume.lo.abs())); + } + None + })(); + Ok(result) + }) +} + +fn cap_loop(wire: &mt::Wire, plane: &Plane, tolerance: f64, work: &mut usize) -> Option { + let mut segments = Vec::new(); + let mut area = Interval::enclosing(0., 0.)?; + let mut ids = std::collections::HashSet::new(); + for edge in wire.edge_iter() { + if !ids.insert(edge.id()) { + return None; + } + let certified = crate::prism_curve::segment(edge, plane, tolerance, work)?; + if segments.len().checked_add(certified.len())? > 8192 { + return None; + } + for segment in &certified { + area = area.add(green_interval(segment, tolerance)?)?; + } + segments.extend(certified); + } + if segments.is_empty() || !wire.is_closed() { + return None; + } + // Curve endpoints already agree with actual shared vertices in the + // whole-span proof. Check the fitted representation's continuity too. + for i in 0..segments.len() { + if segments.get(i)?.end().dist(segments.get((i + 1) % segments.len())?.start()) > 2. * tolerance { + return None; + } + } + Some(CapLoop { segments, area }) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::*; + use mt::builder; + + fn drilled(radius: f64, shift: f64) -> crate::Body { + use solvecraft_geom::{Loop2, Region2}; + let region = Region2 { + outer: Loop2::polygon(&[Vec2::ZERO, Vec2::new(20., 0.), Vec2::new(20., 20.), Vec2::new(0., 20.)]), + holes: vec![Loop2::circle(Vec2::new(10. + shift, 10.), radius)], + }; + crate::extrude(&Plane::XY, &[region], 0., 10.).unwrap().remove(0) + } + + #[test] + fn actual_drilled_prism_and_step_reopen_are_certified_without_history() { + let source = drilled(6., 0.); + let before = crate::step_export(&[&source], "source").unwrap(); + let imported = crate::step_import(&before).unwrap(); + let copy = &imported.bodies[0].body; + let exact = 4000. - 360. * std::f64::consts::PI; + for body in [&source, copy] { + let cap = certificate(body, &Plane::XY, 3e-9).expect("actual complete current cap and walls"); + assert!(cap.volume.lo <= exact && cap.volume.hi >= exact, "{:?}, expected {exact}", cap.volume); + assert!(cap.volume.hi - cap.volume.lo <= 0.001); + } + assert_eq!(certified_prism_equivalent(&source, copy).unwrap(), Some(true)); + let wider = drilled(6.0001, 0.); + let a = certificate(&source, &Plane::XY, 3e-9).unwrap(); + let b = certificate(&wider, &Plane::XY, 3e-9).unwrap(); + assert!(a.volume.lo - b.volume.hi > 1e-5 * (1. + a.volume.hi)); + assert_eq!(certified_prism_equivalent(&source, &wider).unwrap(), Some(false)); + let shifted = drilled(6., 0.0001); + let same_volume = certificate(&shifted, &Plane::XY, 3e-9).unwrap(); + assert!(same_volume.volume.lo <= exact && same_volume.volume.hi >= exact); + assert_eq!(certified_prism_equivalent(&source, &shifted).unwrap(), Some(false)); + assert_eq!(crate::step_export(&[&source], "source").unwrap(), before); + } + + #[test] + fn poorly_conditioned_plane_walls_do_not_get_certificates() { + let origin = mt::Point3::new(0., 0., 0.); + let u = mt::Point3::new(1., 0., 0.); + let v = mt::Point3::new(1., 0., 1e-9); + let surface = mt::Surface::Plane(mt::Plane::new(origin, u, v)); + let edge = builder::line(&mt::Vertex::new(origin), &mt::Vertex::new(u)); + let mut work = 2_000_000; + assert!(wall_error(&surface, Vec3::Z, 10., &mut work).is_none()); + assert!(wall_supports_cap(&surface, &edge, &Plane::XY, (0., 10.), 1e-9, &mut work).is_none()); + let normal = plane_normal(&mt::Plane::new(origin, u, mt::Point3::new(0., 0., 1.))).unwrap(); + assert!(normal.0.dist(-Vec3::Y) < 1e-12); + assert!(normal.1.len() > 0.); + } + + #[test] + fn rotated_current_boolean_prism_step_and_wrong_surface_incidence() { + let stock = crate::box_solid(Vec3::ZERO, Vec3::new(20., 20., 10.)).unwrap(); + let cutter = crate::cylinder(Vec3::new(10., 10., 0.), Vec3::Z, 6., 10.).unwrap(); + let source = crate::boolean(&stock, &cutter, crate::BoolOp::Cut).unwrap().unwrap(); + let moved = crate::transform(&source, Vec3::new(30., 20., 10.), Vec3::ZERO, Vec3::new(1., 2., 3.), 0.7).unwrap(); + let saved = crate::step_export(&[&moved], "actual boolean").unwrap(); + let imported = crate::step_import(&saved).unwrap(); + assert_eq!(certified_prism_equivalent(&moved, &imported.bodies[0].body).unwrap(), Some(true)); + // Preserve every current cap/edge ID while replacing only the curved + // wall support with radius five. Topology incidence alone must fail. + let wrong = drilled(5., 0.); + let wrong_surface = wrong.solid.face_iter().find(|f| !matches!(f.surface(), mt::Surface::Plane(_))).unwrap().surface(); + let faces = source + .solid + .face_iter() + .map(|f| { + if matches!(f.surface(), mt::Surface::Plane(_)) { + return f.clone(); + } + let mut face = mt::Face::new(f.absolute_boundaries().clone(), wrong_surface.clone()); + if !f.orientation() { + face.invert(); + } + face + }) + .collect::>(); + let invalid = crate::Body::new(crate::body::Solid::try_new(vec![faces.into()]).unwrap()).unwrap(); + assert!(certificate(&invalid, &Plane::XY, 3e-9).is_none()); + let actual = source.solid.face_iter().find(|f| !matches!(f.surface(), mt::Surface::Plane(_))).unwrap().surface(); + let shifted = mt::Transformed::transformed(&actual, mt::Matrix4::from_translation(mt::Vector3::new(0., 0., 1000.))); + let top = source.solid.face_iter().find(|f| matches!(f.oriented_surface(),mt::Surface::Plane(p) if p.normal().z>0.9)).unwrap(); + let arc = top.edge_iter().find(|e| !matches!(e.curve(), mt::Curve::Line(_))).unwrap(); + assert_ne!(wall_supports_cap(&shifted, &arc, &Plane::XY, (0., 10.), 3e-9, &mut 2_000_000), Some(true)); + } + + #[test] + fn cap_support_origin_is_not_a_trimmed_face_height() { + let source = drilled(6., 0.); + let faces = source + .solid + .face_iter() + .map(|f| { + let mt::Surface::Plane(p) = f.surface() else { + return f.clone(); + }; + if p.normal().z.abs() < 0.9 { + return f.clone(); + } + let origin = p.origin() + p.u_axis() * 1e8; + let plane = mt::Surface::Plane(mt::Plane::new(origin, origin + p.u_axis(), origin + p.v_axis())); + let mut face = mt::Face::new(f.absolute_boundaries().clone(), plane); + if !f.orientation() { + face.invert(); + } + face + }) + .collect::>(); + let same = crate::Body::new(crate::body::Solid::try_new(vec![faces.into()]).unwrap()).unwrap(); + assert_eq!(certified_prism_equivalent(&source, &same).unwrap(), Some(true)); + assert!( + loops_same( + &cap_loop(&source.solid.face_iter().last().unwrap().boundaries()[0], &Plane::XY, 3e-9, &mut 2_000_000).unwrap(), + &cap_loop(&source.solid.face_iter().last().unwrap().boundaries()[0], &Plane::XY, 3e-9, &mut 2_000_000).unwrap(), + 1e-6, + &mut 0 + ) + .is_none() + ); + } + + #[test] + fn winding_retracing_and_uncertain_material_do_not_get_certificates() { + let arc = Seg2::Arc { center: Vec2::ZERO, radius: 6., start: 0., sweep: std::f64::consts::TAU }; + let retraced = CapLoop { segments: vec![arc, arc.reversed(), arc], area: Interval::enclosing(36. * std::f64::consts::PI, 1e-8).unwrap() }; + assert!(circle(&retraced, 1e-6).is_none()); + let region = solvecraft_geom::Loop2::polygon(&[Vec2::ZERO, Vec2::new(20., 0.), Vec2::new(20., 20.), Vec2::new(0., 20.)]); + let doubled = CapLoop { segments: region.segs.iter().chain(®ion.segs).copied().collect(), area: Interval::enclosing(800., 1e-8).unwrap() }; + assert!(separated_material(&[doubled], 1e-6, &mut 16_000_000).is_none()); + let thin = crate::box_solid(Vec3::ZERO, Vec3::new(20., 20., 1e-5)).unwrap(); + assert!(certificate(&thin, &Plane::XY, 3e-6).is_none()); + } + + #[test] + fn current_rational_circle_green_interval_encloses_independent_area() { + let r = 6.; + let vertices = + [mt::Point3::new(r, 0., 0.), mt::Point3::new(0., r, 0.), mt::Point3::new(-r, 0., 0.), mt::Point3::new(0., -r, 0.)].map(builder::vertex); + let mut edges = Vec::new(); + for i in 0..4 { + let angle = (i as f64 + 0.5) * std::f64::consts::FRAC_PI_2; + edges.push(builder::circle_arc(&vertices[i], &vertices[(i + 1) % 4], mt::Point3::new(r * angle.cos(), r * angle.sin(), 0.))); + } + let wire: mt::Wire = edges.into(); + let cap = cap_loop(&wire, &Plane::XY, 3e-9, &mut 2_000_000).unwrap(); + let exact = std::f64::consts::PI * r * r; + assert!(cap.area.lo <= exact && cap.area.hi >= exact, "{:?}", cap.area); + assert!((cap.area.hi - cap.area.lo) * 10. < 0.001); + assert_eq!(cap.segments.len(), 4); + let reversed = cap_loop(&wire.inverse(), &Plane::XY, 3e-9, &mut 2_000_000).unwrap(); + assert!(reversed.area.lo <= -exact && reversed.area.hi >= -exact); + } +} diff --git a/crates/kernel/src/smooth_edges.rs b/crates/kernel/src/smooth_edges.rs new file mode 100644 index 00000000..4da5dd1d --- /dev/null +++ b/crates/kernel/src/smooth_edges.rs @@ -0,0 +1,354 @@ +//! Display-only tangent joins. Unknown geometry retains its existing presentation. +use solvecraft_geom::{Mesh, Vec3}; +use truck_modeling as mt; + +use crate::body::{Body, from_p3}; + +const MAX_FACES: usize = 50_000; +const MAX_EDGES: usize = 200_000; +const WORK: usize = 2_000_000; +const ANGLE: f64 = 1e-8; + +#[derive(Clone, Copy)] +struct Boundary { + a: Vec3, + b: Vec3, + normal: Vec3, + error: f64, +} + +fn charge(work: &mut usize, amount: usize) -> Option<()> { + *work = work.checked_sub(amount)?; + Some(()) +} + +fn roundoff(points: &[Vec3]) -> f64 { + points.iter().flat_map(|p| [p.x.abs(), p.y.abs(), p.z.abs()]).fold(1., f64::max) * f64::EPSILON * 64. +} + +/// Only a clamped Bezier profile swept by one common linear translation. +/// Its whole ruling normal is bounded from the linear cross derivative, not sampled. +fn ruled(surface: &mt::Surface, orientation: bool, work: &mut usize) -> Option<[Boundary; 2]> { + let mt::Surface::BSplineSurface(surface) = surface else { return None }; + let controls = surface.control_points(); + let rows = controls.len(); + let cols = controls.first()?.len(); + if !(2..=33).contains(&rows) || !(2..=33).contains(&cols) || controls.iter().any(|row| row.len() != cols) { + return None; + } + charge(work, rows.checked_mul(cols)?.checked_mul(4)?)?; + let (ku, kv) = surface.knot_vecs(); + let clamped = |knots: &mt::KnotVec, count: usize| { + let (Some(first), Some(last)) = (knots.first(), knots.last()) else { return false }; + knots.len() == count * 2 + && first.is_finite() + && last.is_finite() + && first < last + && knots.iter().take(count).all(|k| k == first) + && knots.iter().skip(count).all(|k| k == last) + }; + if !clamped(ku, rows) || !clamped(kv, cols) { + return None; + } + for transpose in [false, true] { + let (count, axial) = if transpose { (cols, rows) } else { (rows, cols) }; + if axial != 2 { + continue; + } + let at = |i: usize, j: usize| { + let p = if transpose { controls.get(j)?.get(i)? } else { controls.get(i)?.get(j)? }; + let p = from_p3(*p); + p.is_finite().then_some(p) + }; + let a = at(0, 0)?; + let b = at(0, 1)?; + let ruling = b - a; + if ruling.len() < 1e-6 { + continue; + } + let mut valid = true; + for i in 0..count { + let p = at(i, 0)?; + let q = at(i, 1)?; + if (q - p - ruling).len() > roundoff(&[a, b, p, q]) { + valid = false; + break; + } + } + if !valid { + continue; + } + let boundary = |index: usize, next: usize, end: bool| { + let a = at(index, 0)?; + let b = at(index, 1)?; + let tangent = if end { a - at(next, 0)? } else { at(next, 0)? - a }; + let error = roundoff(&[a, b, at(next, 0)?]); + // Ill-conditioned derivatives cannot certify a tiny angular join. + if tangent.len() <= error / ANGLE || ruling.len() <= error / ANGLE { + return None; + } + let cross = if transpose { ruling.cross(tangent) } else { tangent.cross(ruling) }; + let normal = cross.normalized()? * if orientation { 1. } else { -1. }; + let top_tangent = if end { b - at(next, 1)? } else { at(next, 1)? - b }; + let top_cross = if transpose { ruling.cross(top_tangent) } else { top_tangent.cross(ruling) }; + let top_normal = top_cross.normalized()? * if orientation { 1. } else { -1. }; + // Along this linear ruling the cross derivative is a linear + // interpolation of these endpoint vectors. Positive alignment + // excludes a singularity; all intermediate normal directions lie + // between their directions. Include that whole-span variation. + if normal.dot(top_normal) <= 0. { + return None; + } + let conditioning = cross.len().min(top_cross.len()); + let normal_error = 8. * error * (tangent.len().max(top_tangent.len()) + ruling.len()) / conditioning + normal.dist(top_normal); + if !normal_error.is_finite() || normal_error > ANGLE { + return None; + } + Some(Boundary { a, b, normal, error: normal_error }) + }; + if let (Some(first), Some(last)) = (boundary(0, 1, false), boundary(count - 1, count - 2, true)) { + return Some([first, last]); + } + } + None +} + +fn on_boundary(boundaries: &[Boundary; 2], a: Vec3, b: Vec3) -> Option { + boundaries.iter().find_map(|boundary| { + let tolerance = roundoff(&[a, b, boundary.a, boundary.b]); + ((a.dist(boundary.a) <= tolerance && b.dist(boundary.b) <= tolerance) || (a.dist(boundary.b) <= tolerance && b.dist(boundary.a) <= tolerance)) + .then_some(*boundary) + }) +} + +pub(crate) fn flags(body: &Body, mesh: &Mesh) -> (Vec, Vec) { + crate::guard("smooth display edges", || Ok(classify(body, mesh, WORK))).unwrap_or_default() +} + +fn classify(body: &Body, mesh: &Mesh, mut work: usize) -> (Vec, Vec) { + if body.solid.face_iter().take(MAX_FACES + 1).count() > MAX_FACES || mesh.edges.len() > MAX_EDGES { + return (Vec::new(), Vec::new()); + } + let mut split_points = 0usize; + for line in body.split_keep() { + if charge(&mut work, line.len().saturating_add(1)).is_none() { + return (Vec::new(), Vec::new()); + } + let Some(total) = split_points.checked_add(line.len()) else { return (Vec::new(), Vec::new()) }; + split_points = total; + } + let mut boundaries = Vec::new(); + for face in body.solid.face_iter() { + // Borrow the geometry through mapping, avoiding a deep clone of an + // unknown/hostile surface before inspecting its control-net bounds. + let wires = face.absolute_boundaries(); + if wires.len() > 16 { + boundaries.push(None); + continue; + } + let edge_count = wires.iter().fold(0usize, |sum, wire| sum.saturating_add(wire.len())); + if edge_count > 16 || charge(&mut work, edge_count + 1).is_none() { + boundaries.push(None); + continue; + } + let mapped = face.try_mapped(|_| Some(()), |_| Some(()), |surface| ruled(surface, face.orientation(), &mut work)); + boundaries.push(mapped.map(|face| face.surface())); + } + let mut out = vec![false; mesh.edges.len()]; + let mut sharp = vec![false; mesh.edges.len()]; + // Same first-occurrence order as tessellate().edges; never remove/reindex it. + let mut seen = std::collections::HashSet::new(); + let mut index = 0usize; + for edge in body.solid.edge_iter() { + if index >= mesh.edges.len() || seen.len() >= MAX_EDGES { + break; + } + if charge(&mut work, 1).is_none() { + break; + } + if !seen.insert(edge.id()) { + continue; + } + let current = index; + index += 1; + let Some([a, b]) = mesh.edge_faces.get(current).map(Vec::as_slice) else { continue }; + if a == b { + continue; + } + let Some(line) = mesh.edges.get(current) else { continue }; + let Some(split_work) = line.len().checked_add(1).and_then(|n| n.checked_mul(split_points)) else { break }; + if charge(&mut work, split_work).is_none() { + break; + } + if crate::heal::on_split_line(line, body.split_keep()) { + if let Some(flag) = sharp.get_mut(current) { + *flag = true; + } + continue; + } + let (Some(Some(ba)), Some(Some(bb))) = (boundaries.get(*a as usize), boundaries.get(*b as usize)) else { continue }; + // Curve is inspected by borrowing. Only exact straight rulings qualify; + // a bowed/twisted edge with matching endpoints must remain visible. + let mapped = edge.try_mapped( + |_| Some(()), + |curve| match curve { + mt::Curve::Line(line) => Some((from_p3(line.0), from_p3(line.1))), + _ => None, + }, + ); + let Some(mapped) = mapped else { continue }; + let (p, q) = mapped.curve(); + let (Some(na), Some(nb)) = (on_boundary(ba, p, q), on_boundary(bb, p, q)) else { continue }; + let uncertainty = na.error + nb.error; + let cross = na.normal.cross(nb.normal).len(); + if na.normal.dot(nb.normal) > 0. + && cross + uncertainty <= ANGLE + && let Some(flag) = out.get_mut(current) + { + *flag = true; + } else if (cross > ANGLE + uncertainty || na.normal.dot(nb.normal) < -uncertainty) + && let Some(flag) = sharp.get_mut(current) + { + *flag = true; + } + } + (out, sharp) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use solvecraft_geom::{Loop2, Plane, Region2, Seg2, Vec2}; + + fn circle(radius: f64, count: usize) -> Loop2 { + let step = std::f64::consts::TAU / count as f64; + let k = 4. / 3. * (step / 4.).tan() * radius; + let point = |angle: f64| Vec2::new(angle.cos(), angle.sin()) * radius; + let tangent = |angle: f64| Vec2::new(-angle.sin(), angle.cos()) * k; + Loop2 { + segs: (0..count) + .map(|i| { + let a = i as f64 * step; + let b = (i + 1) as f64 * step; + let p0 = point(a); + let p3 = if i + 1 == count { point(0.) } else { point(b) }; + Seg2::Cubic { p0, p1: p0 + tangent(a), p2: p3 - tangent(b), p3 } + }) + .collect(), + } + } + + fn ring(count: usize) -> Body { + crate::extrude(&Plane::XY, &[Region2 { outer: circle(10., count), holes: vec![circle(9., count).reversed()] }], 0., 3.).unwrap().remove(0) + } + + #[test] + fn periodic_ring_hides_only_whole_smooth_wall_joins() { + let body = ring(256); + let mesh = body.display_mesh(0.02).unwrap(); + assert_eq!(mesh.edges.len(), 1536); + assert_eq!(mesh.smooth_edges.iter().filter(|f| **f).count(), 512); + for (index, (line, smooth)) in mesh.edges.iter().zip(&mesh.smooth_edges).enumerate() { + let vertical = (line.first().unwrap().z - line.last().unwrap().z).abs() > 2.9; + assert_eq!(*smooth, vertical); + if !vertical { + assert!(mesh.shaded_edge_visible(index)); + } + } + let raw = body.tessellate(0.02).unwrap(); + assert_eq!(mesh.edges, raw.edges); + assert_eq!(mesh.edge_faces, raw.edge_faces); + assert_eq!(mesh.tri_face, raw.tri_face); + assert_eq!(mesh.triangles, raw.triangles); + assert_eq!(mesh.positions, raw.positions); + assert_eq!(mesh.normals, raw.normals); + let moved = crate::transform_matrix(&body, [[1., 0., 0., 0.], [0., 0., 1., 0.], [0., -1., 0., 0.], [30., 20., 10., 1.]]).unwrap(); + assert_eq!(moved.display_mesh(0.02).unwrap().smooth_edges.iter().filter(|f| **f).count(), 512); + } + + #[test] + fn authored_split_unknown_adjacency_and_exhausted_work_stay_visible() { + let body = ring(8); + let mesh = body.display_mesh(0.02).unwrap(); + let index = mesh.smooth_edges.iter().position(|f| *f).unwrap(); + let split = body.clone().with_split_lines(Some(std::sync::Arc::new(vec![mesh.edges[index].clone()]))); + assert!(!flags(&split, &mesh).0[index]); + let mut unknown = mesh.clone(); + unknown.edge_faces[index].push(999); + assert!(!flags(&body, &unknown).0[index]); + assert!(classify(&body, &mesh, 0).0.iter().all(|f| !*f)); + // Matching endpoints alone cannot authorize a bowed current edge. + let changed = Body::new(body.deep_copy()).unwrap(); + let edges = Body::unique_edges(&changed.solid); + let edge = &edges[index]; + let (a, b) = (edge.front().point(), edge.back().point()); + let mid = mt::Point3::new((a.x + b.x) / 2. + 0.1, (a.y + b.y) / 2., (a.z + b.z) / 2.); + edge.set_curve(mt::Curve::BSplineCurve(mt::BSplineCurve::new(mt::KnotVec::bezier_knot(2), vec![a, mid, b]))); + let (smooth, sharp) = flags(&changed, &mesh); + assert!(!smooth[index] && !sharp[index]); + } + + #[test] + fn a_small_real_crease_is_visible() { + let mut outer = circle(10., 8); + if let Seg2::Cubic { p0, p1, .. } = &mut outer.segs[0] { + p1.x += (p1.y - p0.y) * 1e-6; + } + let body = crate::extrude(&Plane::XY, &[Region2 { outer, holes: vec![] }], 0., 3.).unwrap().remove(0); + let mesh = body.display_mesh(0.02).unwrap(); + assert_eq!(mesh.smooth_edges.iter().filter(|f| **f).count(), 7); + let crease = mesh + .edges + .iter() + .position(|line| { + line.first().is_some_and(|p| (p.x - 10.).abs() < 1e-8 && p.y.abs() < 1e-8) + && line.last().is_some_and(|p| (p.x - 10.).abs() < 1e-8 && p.y.abs() < 1e-8) + && line.first().zip(line.last()).is_some_and(|(a, b)| (a.z - b.z).abs() > 2.9) + }) + .unwrap(); + assert!(!mesh.smooth_edges[crease]); + assert!(mesh.sharp_edges[crease]); + assert!(mesh.shaded_edge_visible(crease)); + assert!(mesh.edge_pickable(crease)); + } + + #[test] + fn actual_authored_split_of_a_curved_ruled_wall_stays_visible() { + let body = ring(4); + let face = body + .solid + .face_iter() + .position(|face| face.try_mapped(|_| Some(()), |_| Some(()), |surface| ruled(surface, face.orientation(), &mut { WORK })).is_some()) + .unwrap(); + let tool = crate::SplitTool::Plane(Plane::new(Vec3::new(7., 0., 0.), Vec3::Y, Vec3::Z).unwrap()); + let split = crate::split_faces(&body, &[face], &tool).unwrap(); + assert!(split.face_count() > body.face_count()); + assert!(!split.split_keep().is_empty()); + let mesh = split.display_mesh(0.02).unwrap(); + let authored: Vec<_> = + mesh.edges.iter().enumerate().filter(|(_, line)| crate::heal::on_split_line(line, split.split_keep())).map(|(i, _)| i).collect(); + assert!(!authored.is_empty()); + for index in authored { + assert!(!mesh.smooth_edges[index]); + assert!(mesh.shaded_edge_visible(index)); + } + } + + #[test] + fn singular_twisted_and_reversed_normal_certificates_are_conservative() { + let surface = |points: Vec>| { + mt::Surface::BSplineSurface(mt::BSplineSurface::new((mt::KnotVec::bezier_knot(points.len() - 1), mt::KnotVec::bezier_knot(1)), points)) + }; + let p = |x, y, z| mt::Point3::new(x, y, z); + let straight = surface(vec![vec![p(0., 0., 0.), p(0., 0., 3.)], vec![p(1., 0., 0.), p(1., 0., 3.)]]); + let forward = ruled(&straight, true, &mut { WORK }).unwrap(); + let backward = ruled(&straight, false, &mut { WORK }).unwrap(); + assert!(forward[0].normal.dot(backward[0].normal) < -0.99); + let twisted = surface(vec![vec![p(0., 0., 0.), p(0., 0., 3.)], vec![p(1., 0., 0.), p(1., 1., 3.)]]); + assert!(ruled(&twisted, true, &mut { WORK }).is_none()); + let singular = surface(vec![vec![p(0., 0., 0.), p(0., 0., 3.)], vec![p(0., 0., 0.), p(0., 0., 3.)], vec![p(1., 0., 0.), p(1., 0., 3.)]]); + assert!(ruled(&singular, true, &mut { WORK }).is_none()); + assert!(ruled(&straight, true, &mut 0).is_none()); + } +} diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index 3b049533..5a5e2c9e 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -359,6 +359,8 @@ mod tests { tri_face: vec![0], edges: vec![], seams: vec![], + smooth_edges: vec![], + sharp_edges: vec![], edge_faces: vec![], }; let scene = Scene { diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 023632b8..a9c488a2 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -312,7 +312,7 @@ fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, } } let id = egui::Id::new("sc_canvas_value"); - egui::Area::new(egui::Id::new("sc_canvas_area")).fixed_pos(box_at).order(egui::Order::Foreground).show(ui.ctx(), |ui| { + crate::frame::area(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| { ui.horizontal(|ui| { ui.label(egui::RichText::new(label).color(t.text_dim).size(11.5)); @@ -579,7 +579,7 @@ fn draw_revolve( } } } - egui::Area::new(egui::Id::new("sc_canvas_area")).fixed_pos(box_at).order(egui::Order::Foreground).show(ui.ctx(), |ui| { + crate::frame::area(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 { diff --git a/crates/ui-egui/src/context_menu.rs b/crates/ui-egui/src/context_menu.rs index abf95097..c0963e7c 100644 --- a/crates/ui-egui/src/context_menu.rs +++ b/crates/ui-egui/src/context_menu.rs @@ -1369,7 +1369,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { let area = Rect::from_center_size(c, vec2(2.0 * (RX + 165.0), 2.0 * (RY + 34.0))); inside |= pointer.is_some_and(|p| area.contains(p)); let hot = pointer.and_then(|p| sector(p - c)); - let resp = egui::Area::new(egui::Id::new("sc_marking_menu")).fixed_pos(area.min).order(egui::Order::Foreground).show(ctx, |ui| { + let resp = crate::frame::area(egui::Id::new("sc_marking_menu")).fixed_pos(area.min).order(egui::Order::Foreground).show(ctx, |ui| { let (r, _) = ui.allocate_exact_size(area.size(), Sense::hover()); let painter = ui.painter_at(r.expand(4.0)); let font = FontId::proportional(13.0); @@ -1435,15 +1435,16 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { picked = Some(item.clone()); } let key_row = app.menu.key_row; - let resp = egui::Area::new(egui::Id::new("sc_context_menu")).fixed_pos(list_at).constrain(true).order(egui::Order::Foreground).show(ctx, |ui| { - menu_frame(ui, |ui| { - for (k, item) in list.iter().enumerate() { - if let Some(p) = list_row_lit(ui, item, key_row == Some(k)) { - picked = Some(p); + let resp = + crate::frame::area(egui::Id::new("sc_context_menu")).fixed_pos(list_at).constrain(true).order(egui::Order::Foreground).show(ctx, |ui| { + menu_frame(ui, |ui| { + for (k, item) in list.iter().enumerate() { + if let Some(p) = list_row_lit(ui, item, key_row == Some(k)) { + picked = Some(p); + } } - } + }); }); - }); inside |= resp.response.contains_pointer() || resp.response.rect.contains(pointer.unwrap_or(Pos2::ZERO)); let (primary, secondary, esc) = ctx.input(|i| { @@ -1508,6 +1509,7 @@ pub fn list_row_lit(ui: &mut egui::Ui, item: &Item, lit: bool) -> Option { return None; } let (r, resp) = ui.allocate_exact_size(vec2(w, 24.0), if item.enabled { Sense::click() } else { Sense::hover() }); + crate::scenario::publish_handle(&format!("menu:{}", item.id), r.center()); if item.enabled && (resp.hovered() || lit) { ui.painter().rect_filled(r.shrink2(vec2(2.0, 1.0)), 4.0, t.hover); } @@ -1536,7 +1538,7 @@ fn show_rename(app: &mut SolveApp, ctx: &egui::Context) { let Some(mut r) = app.menu.rename.take() else { return }; let t = Tokens::get(); let mut done: Option = None; - let resp = egui::Area::new(egui::Id::new("sc_rename")).fixed_pos(r.at).constrain(true).order(egui::Order::Foreground).show(ctx, |ui| { + let resp = crate::frame::area(egui::Id::new("sc_rename")).fixed_pos(r.at).constrain(true).order(egui::Order::Foreground).show(ctx, |ui| { menu_frame(ui, |ui| { ui.add_space(2.0); ui.label(egui::RichText::new("Rename").size(11.0).color(t.text_dim)); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 6e7fb06a..02f3865b 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -199,7 +199,7 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) -> app.animate_view(v); return ok(json!({ "animating": true, "to": app.cam_anim.map(|a| a.to) })); } - app.cam_anim = None; + app.cancel_view_animation(); match v { "fit" => app.fit_view(), "home" => { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index e889c74e..0cee7341 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1265,6 +1265,7 @@ fn auto_operation(app: &SolveApp, d: &mut Dialog) { /// Show the active dialog (if any), docked flush to the right edge of the viewport just below /// the axis view, sized to its content. pub fn show(app: &mut SolveApp, ctx: &egui::Context) { + app.viewport.dialog_rect = None; let Some(mut d) = app.dialog.take() else { return }; let _language = crate::i18n::scope(&app.preferences.language); auto_operation(app, &mut d); @@ -1294,7 +1295,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { let valid = !has_ok || !d.previews() || (apply_commands(app, &d).is_ok() && app.preview.error.is_none()); let width = if wide { crate::frame::Width::Wide } else { crate::frame::Width::Normal }; let wkey = egui::Id::new(("sc_dialog_w", wide)); - crate::frame::docked( + let response = crate::frame::docked( RichText::new(heading.clone()).strong().size(13.0), egui::Id::new(("sc_dialog", title(&d.kind))), anchor, @@ -1909,9 +1910,16 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { } } } - if let Some(e) = d.error.as_ref().or(app.preview.error.as_ref()) { + if let Some(e) = d.error.as_ref().or(app.preview.error.as_ref()).cloned() { row_label(ui, ""); - ui.add(egui::Label::new(RichText::new(e.as_str()).color(t.error)).wrap()); + ui.vertical(|ui| { + ui.add(egui::Label::new(RichText::new(e.as_str()).color(t.error)).wrap()); + let response = ui.button("Create Issue…"); + crate::scenario::publish_handle("dialog:create_issue", response.rect.center()); + if response.clicked() { + crate::issue_report::begin(app, ctx, Some(&d), Some(&e)); + } + }); ui.end_row(); } }); @@ -1959,6 +1967,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { }); crate::frame::remember_width(ui, wkey); }); + app.viewport.dialog_rect = response.map(|r| r.response.rect); // Keyboard: Enter applies (from a value field, or with nothing focused), Esc cancels. let (enter_free, esc) = ctx.input(|i| (i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Escape))); let nothing_focused = ctx.memory(|m| m.focused().is_none()); @@ -1966,10 +1975,11 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { // Tables (configurations, a study's keys) take Enter for their cells. let enter_applies = !matches!(d.kind, Kind::Sketch | Kind::Params { .. } | Kind::Measure { .. } | Kind::ConfirmDelete { .. }) && !matches!(&d.kind, Kind::Motion(k) if k.wide()); - if enter_applies && (enter || canvas_enter || (enter_free && nothing_focused)) { + let report_active = app.issue_report.draft.is_some(); + if !report_active && enter_applies && (enter || canvas_enter || (enter_free && nothing_focused)) { ok = true; } - if esc && !app.esc_handled { + if !report_active && esc && !app.esc_handled { app.esc_handled = true; cancel = true; } @@ -2264,6 +2274,11 @@ pub(crate) fn feature_frame(s: &Session, component: u64) -> solvecraft_engine::d if component == s.active_component { s.active_component_frame() } else { s.placed_document().component_transform(component) } } +/// The feature dialog's current operation name for a reviewed issue draft. +pub(crate) fn report_title(d: &Dialog) -> String { + title(&d.kind).to_string() +} + /// The commands OK runs (an edit rebuilds its feature in place); the live preview runs the same. pub fn apply_commands(app: &SolveApp, d: &Dialog) -> Result, String> { let cmds = dialog_commands(app, d)?; diff --git a/crates/ui-egui/src/dim_view.rs b/crates/ui-egui/src/dim_view.rs index 105529eb..4678ea57 100644 --- a/crates/ui-egui/src/dim_view.rs +++ b/crates/ui-egui/src/dim_view.rs @@ -10,6 +10,7 @@ //! `parameters.change`. use std::cell::RefCell; +use std::sync::Arc; use egui::{FontId, Pos2, Rect, Shape, Stroke, vec2}; use serde_json::json; @@ -44,6 +45,89 @@ struct Edit { fresh: u8, /// Frames left to wait for the dimension to be drawn. wait: u8, + draft: Option, +} + +/// Creation stays in a scratch session until the user accepts the value. Retaining the +/// document also makes the editor's identity check safe across document replacements. +#[derive(Clone, Debug)] +struct DimensionDraft { + baseline: Arc, + sketch: u64, + component: u64, + path: Option>, + pending: std::collections::BTreeMap, + entities: Vec, + at: Vec2, + checked_expr: String, + preview_doc: Arc, + preview: Arc, + error: Option, +} + +impl DimensionDraft { + fn valid(&self, app: &SolveApp) -> bool { + Arc::ptr_eq(&self.baseline, &app.session.doc) + && app.session.active_sketch == Some(self.sketch) + && app.session.active_component == self.component + && app.session.active_occurrence_path == self.path + && app.session.pending_moves == self.pending + } + fn params(&self, expr: &str) -> serde_json::Value { + json!({"entities": self.entities, "text_at": [self.at.x, self.at.y], "value": expr}) + } + fn evaluate(&self, app: &SolveApp, expr: &str) -> Result { + let mut scratch = app.session.scratch(); + let result = scratch.execute("sketch.dimension", &self.params(expr)).map_err(|e| e.to_string())?; + let param = result.get("param").and_then(serde_json::Value::as_str).ok_or("The dimension value is unavailable")?; + let state = scratch.world_state(); + let solved = state.sketch(self.sketch).ok_or("The dimension sketch is unavailable")?; + if !solved.report.ok() { + return Err("That value conflicts with the sketch constraints".into()); + } + if let Some(kind) = solved.sketch.constraints.iter().find(|c| c.param.as_deref() == Some(param)).map(|c| &c.kind) + && !kind.is_angle() + && kind.value().is_some_and(|value| !value.is_finite() || value <= 0.0) + { + return Err("Enter a dimension greater than zero".into()); + } + Ok(scratch) + } +} + +pub fn cancel() { + EDIT.with(|edit| *edit.borrow_mut() = None); +} + +/// Infer the measured value through the same command as scripts, without creating a real +/// parameter or an undo entry. Only accepting the editor executes the command on the design. +pub fn begin_dimension(app: &SolveApp, entities: Vec, at: Vec2) -> Result<(), String> { + let sketch = app.session.active_sketch.ok_or("Edit a sketch to add a dimension")?; + if entities.is_empty() || entities.len() > 2 || !at.x.is_finite() || !at.y.is_finite() { + return Err("Pick one edge or two endpoints, then place the dimension".into()); + } + let mut scratch = app.session.scratch(); + let result = scratch.execute("sketch.dimension", &json!({"entities": entities, "text_at": [at.x, at.y]})).map_err(|e| e.to_string())?; + let param = result.get("param").and_then(serde_json::Value::as_str).ok_or("This dimension has no editable value")?.to_string(); + let expr = scratch.doc.param(¶m).ok_or("The dimension value is unavailable")?.expr.clone(); + let draft = DimensionDraft { + baseline: app.session.doc.clone(), + sketch, + component: app.session.active_component, + path: app.session.active_occurrence_path.clone(), + pending: app.session.pending_moves.clone(), + entities, + at, + checked_expr: expr.clone(), + preview: scratch.world_state(), + preview_doc: scratch.doc.clone(), + error: None, + }; + if !draft.preview.sketch(sketch).is_some_and(|solved| solved.report.ok()) { + return Err("That dimension conflicts with the sketch constraints".into()); + } + EDIT.with(|edit| *edit.borrow_mut() = Some(Edit { param, expr, at: None, fresh: FRESH, wait: 10, draft: Some(draft) })); + Ok(()) } thread_local! { @@ -117,7 +201,7 @@ pub fn double_click(app: &mut SolveApp, pos: Pos2) -> bool { return true; }; let expr = app.session.doc.param(¶m).map(|p| p.expr.clone()).unwrap_or_default(); - EDIT.with(|e| *e.borrow_mut() = Some(Edit { param, expr, at: Some(d.text.center()), fresh: FRESH, wait: 0 })); + EDIT.with(|e| *e.borrow_mut() = Some(Edit { param, expr, at: Some(d.text.center()), fresh: FRESH, wait: 0, draft: None })); true } @@ -174,6 +258,30 @@ pub fn label(k: &ConstraintKind, expr: Option<&str>, driven: bool) -> String { /// Draw the active sketch's dimensions and remember where they are for picking. pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &Proj) { let mut drawn = Vec::new(); + let draft = EDIT.with(|edit| { + let mut edit = edit.borrow_mut(); + if edit.as_ref().and_then(|e| e.draft.as_ref()).is_some_and(|d| !d.valid(app)) { + *edit = None; + } + if let Some(e) = edit.as_mut() + && let Some(draft) = e.draft.as_mut() + { + if e.expr != draft.checked_expr { + draft.checked_expr = e.expr.clone(); + match draft.evaluate(app, &e.expr) { + Ok(scratch) => { + draft.preview = scratch.world_state(); + draft.preview_doc = scratch.doc.clone(); + draft.error = None; + } + Err(error) => draft.error = Some(error), + } + } + Some((e.param.clone(), draft.clone())) + } else { + None + } + }); let st = app.session.world_state(); let Some(ss) = app.session.active_sketch.and_then(|s| st.sketch(s)) else { DRAWN.with(|d| d.borrow_mut().clear()); @@ -183,15 +291,28 @@ pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &P let sk = &ss.sketch; if sk.view.hide_dimensions { DRAWN.with(|d| d.borrow_mut().clear()); - return; + if draft.is_none() { + return; + } } let tk = Tokens::get(); let is_sel = |id: &str| app.session.selection.iter().any(|s| matches!(s, Sel::SketchConstraint { id: x } if x == id)); let hover = ui.input(|i| i.pointer.hover_pos()).filter(|_| ui.rect_contains_pointer(proj.rect)).and_then(hit); - let to = |p: Vec2| proj.to_screen(ss.plane.to_world(p)); let font = FontId::proportional(12.0); let centres = chain_centres(sk); - for c in &sk.constraints { + let draft_sketch = draft.as_ref().and_then(|(_, d)| d.preview.sketch(d.sketch)); + let draft_centres = draft_sketch.map(|solved| chain_centres(&solved.sketch)); + let mut constraints = sk.constraints.iter().map(|c| (ss, c, false)).collect::>(); + if let (Some(source), Some((param, _))) = (draft_sketch, draft.as_ref()) { + constraints.extend(source.sketch.constraints.iter().filter(|c| c.param.as_deref() == Some(param.as_str())).map(|c| (source, c, true))); + } + for (source, c, is_draft) in constraints { + if ss.sketch.view.hide_dimensions && !is_draft { + continue; + } + let sk = &source.sketch; + let centres = if is_draft { draft_centres.as_ref().unwrap_or(¢res) } else { ¢res }; + let to = |p: Vec2| proj.to_screen(source.plane.to_world(p)); let Some(frame) = dim_frame(sk, &c.kind) else { continue }; let anchor = match frame { DimFrame::Linear { p0, p1, .. } => (p0 + p1) * 0.5, @@ -204,8 +325,10 @@ pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &P if !px.is_finite() { continue; } - let expr = c.param.as_ref().and_then(|p| app.session.doc.param(p)).map(|p| p.expr.as_str()); - let failing = ss.report.failing.contains(&c.id); + let display_doc = + if is_draft { draft.as_ref().map(|(_, d)| d.preview_doc.as_ref()).unwrap_or(app.session.doc.as_ref()) } else { app.session.doc.as_ref() }; + let expr = c.param.as_ref().and_then(|p| display_doc.param(p)).map(|p| p.expr.as_str()); + let failing = source.report.failing.contains(&c.id); let col = if failing { tk.error } else if is_sel(&c.id) || hover.as_deref() == Some(c.id.as_str()) { @@ -249,7 +372,15 @@ pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &P .collect(); painter.add(Shape::line(pts, stroke)); } - drawn.push(Drawn { id: c.id.clone(), param: c.param.clone(), text: r, segs, at: lay.text }); + if is_draft { + EDIT.with(|edit| { + if let Some(e) = edit.borrow_mut().as_mut() { + e.at = Some(tc); + } + }); + } else { + drawn.push(Drawn { id: c.id.clone(), param: c.param.clone(), text: r, segs, at: lay.text }); + } } DRAWN.with(|d| *d.borrow_mut() = drawn); edit_box(app, ui.ctx()); @@ -274,7 +405,7 @@ fn pos2_mid_top(r: Rect) -> Pos2 { /// its value is selected, so typing replaces it). pub fn edit_param(app: &SolveApp, param: &str) { let expr = app.session.doc.param(param).map(|p| p.expr.clone()).unwrap_or_default(); - EDIT.with(|e| *e.borrow_mut() = Some(Edit { param: param.to_string(), expr, at: None, fresh: FRESH, wait: 10 })); + EDIT.with(|e| *e.borrow_mut() = Some(Edit { param: param.to_string(), expr, at: None, fresh: FRESH, wait: 10, draft: None })); } fn edit_box(app: &mut SolveApp, ctx: &egui::Context) { @@ -292,10 +423,25 @@ fn edit_box(app: &mut SolveApp, ctx: &egui::Context) { }; let id = egui::Id::new("sc_dim_edit"); let mut done: Option = None; - egui::Area::new(egui::Id::new("sc_dim_edit_area")).fixed_pos(at - vec2(45.0, 11.0)).order(egui::Order::Foreground).show(ctx, |ui| { + let mut anchor = at - vec2(45.0, 11.0); + let mut popup_width = 220.0; + if let Some(layout) = &app.viewport.layout + && layout.drawable.is_finite() + && layout.drawable.is_positive() + { + let rect = layout.drawable; + popup_width = (rect.width() - 8.0).clamp(24.0, 220.0); + anchor.x = anchor.x.clamp(rect.left(), (rect.right() - popup_width - 6.0).max(rect.left())); + anchor.y = anchor.y.clamp(rect.top(), (rect.bottom() - 80.0).max(rect.top())); + } + crate::frame::area(egui::Id::new("sc_dim_edit_area")).fixed_pos(anchor).order(egui::Order::Foreground).show(ctx, |ui| { egui::Frame::popup(ui.style()).inner_margin(egui::Margin::symmetric(3, 1)).show(ui, |ui| { - let r = ui.add(egui::TextEdit::singleline(&mut e.expr).id(id).desired_width(90.0)); + ui.set_max_width(popup_width); + let r = ui.add(egui::TextEdit::singleline(&mut e.expr).id(id).desired_width(popup_width.min(90.0))); crate::params_dialog::complete(ui, &r, &mut e.expr); + if let Some(error) = e.draft.as_ref().and_then(|d| d.error.as_ref()) { + ui.add(egui::Label::new(egui::RichText::new(error).color(Tokens::get().error)).wrap()); + } if e.fresh == FRESH { // The whole value is selected, so typing replaces it. let n = e.expr.chars().count(); @@ -321,9 +467,20 @@ fn edit_box(app: &mut SolveApp, ctx: &egui::Context) { e.fresh = e.fresh.saturating_sub(1); match done { Some(true) => { - if let Err(err) = app.run("parameters.change", json!({"name": e.param, "expression": e.expr})) { + let result = if let Some(draft) = &e.draft { + if draft.valid(app) { + draft.evaluate(app, &e.expr).and_then(|_| app.run("sketch.dimension", draft.params(&e.expr))) + } else { + Err("The sketch changed; place the dimension again".into()) + } + } else { + app.run("parameters.change", json!({"name": e.param, "expression": e.expr})) + }; + if let Err(err) = result { app.set_status(err, true); EDIT.with(|x| *x.borrow_mut() = Some(Edit { fresh: FRESH, ..e })); + } else { + app.set_status("Dimension applied", false); } } Some(false) => {} diff --git a/crates/ui-egui/src/dimension_create_ui_tests.rs b/crates/ui-egui/src/dimension_create_ui_tests.rs new file mode 100644 index 00000000..15f2d74a --- /dev/null +++ b/crates/ui-egui/src/dimension_create_ui_tests.rs @@ -0,0 +1,292 @@ +//! Dimension creation is an editable draft, accepted by the real focused value field. +use crate::scenario::Harness; +use serde_json::json; + +fn line(end: [f64; 2]) -> Harness { + crate::dim_view::cancel(); + let mut h = Harness::new(); + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + h.app.run("sketch.line", json!({"points":[[0,0],end]})).unwrap(); + h.call("ui.view", json!({"view":"top"})); + // Leave room around the measured shape while testing the draft itself. Separate Fit + // tests exercise measured dock occlusion without changing the picking projection. + h.app.cam.distance = 80.0; + h.app.cam.target = solvecraft_engine::geom::Vec3::new(end[0] * 0.5, end[1] * 0.5, 0.0); + h.settle(); + h +} +fn place(h: &mut Harness, picks: &[[f64; 2]], at: [f64; 2]) { + h.step(&json!({"start":"sketch.dimension"})).unwrap(); + for p in picks { + h.step(&json!({"click":{"world":[p[0],p[1],0]}})).unwrap(); + } + h.step(&json!({"click":{"world":[at[0],at[1],0]}})).unwrap(); + assert!(crate::dim_view::editing(), "placement opens an editor"); + assert_eq!(h.ctx.memory(|m| m.focused()), Some(egui::Id::new("sc_dim_edit"))); +} +fn type_value(h: &mut Harness, text: &str) { + h.step(&json!({"key":"A","cmd":true})).unwrap(); + h.step(&json!({"text":text})).unwrap(); +} +#[test] +fn edge_and_endpoint_creation_commit_once_and_undo_restores_exact_design() { + for (end, picks, at) in [ + ([20., 0.], vec![[10., 0.]], [10., 5.]), + ([20., 0.], vec![[0., 0.], [20., 0.]], [10., 5.]), + ([0., 20.], vec![[0., 0.], [0., 20.]], [-5., 10.]), + ([12., 16.], vec![[0., 0.], [12., 16.]], [-5., 12.]), + ] { + let mut h = line(end); + let original = h.app.session.doc.to_json(); + let undo = h.app.session.undo.len(); + place(&mut h, &picks, at); + assert_eq!(h.app.session.doc.to_json(), original); + assert_eq!(h.app.session.undo.len(), undo); + type_value(&mut h, "30 mm"); + h.step(&json!({"key":"Enter"})).unwrap(); + assert!(!crate::dim_view::editing()); + assert_eq!(h.app.session.undo.len(), undo + 1); + let state = h.app.session.world_state(); + let sk = &state.sketch(h.app.session.active_sketch.unwrap()).unwrap().sketch; + assert!( + (sk.point(sk.resolve_point("l1.start").unwrap()).unwrap().dist(sk.point(sk.resolve_point("l1.end").unwrap()).unwrap()) - 30.).abs() + < 1e-6 + ); + assert!(sk.constraints.iter().any(|c| c.param.is_some())); + let accepted = h.app.session.doc.to_json(); + h.app.run("edit.undo", json!({})).unwrap(); + assert_eq!(h.app.session.doc.to_json(), original); + h.app.run("edit.redo", json!({})).unwrap(); + assert_eq!(h.app.session.doc.to_json(), accepted); + } +} +#[test] +fn escape_invalid_correction_and_seed_enter_do_not_create_partial_dimensions() { + let mut h = line([20., 0.]); + let original = h.app.session.doc.to_json(); + let undo = h.app.session.undo.len(); + place(&mut h, &[[10., 0.]], [10., 5.]); + h.step(&json!({"key":"Escape"})).unwrap(); + assert!(!crate::dim_view::editing()); + assert_eq!(h.app.session.doc.to_json(), original); + assert_eq!(h.app.session.undo.len(), undo); + place(&mut h, &[[0., 0.], [20., 0.]], [10., 5.]); + type_value(&mut h, "unknown_length"); + h.step(&json!({"key":"Enter"})).unwrap(); + assert!(crate::dim_view::editing()); + assert_eq!(h.app.session.doc.to_json(), original); + assert_eq!(h.app.session.undo.len(), undo); + type_value(&mut h, "25 mm"); + h.step(&json!({"key":"Enter"})).unwrap(); + assert!(!crate::dim_view::editing()); + assert_eq!(h.app.session.undo.len(), undo + 1); + h.app.run("edit.undo", json!({})).unwrap(); + h.settle(); + place(&mut h, &[[10., 0.]], [10., 5.]); + h.step(&json!({"key":"Enter"})).unwrap(); + assert_eq!(h.app.session.undo.len(), undo + 1); + assert!(!crate::dim_view::editing()); +} +#[test] +fn switching_documents_cancels_creation_and_existing_parameter_edit_is_separate() { + let mut h = line([20., 0.]); + let original = h.app.session.doc.to_json(); + place(&mut h, &[[10., 0.]], [10., 5.]); + h.call("ui.documents", json!({"action":"new"})); + assert!(!crate::dim_view::editing()); + h.call("ui.documents", json!({"action":"switch","index":0})); + assert_eq!(h.app.session.doc.to_json(), original); + place(&mut h, &[[10., 0.]], [10., 5.]); + h.step(&json!({"key":"Enter"})).unwrap(); + let undo = h.app.session.undo.len(); + crate::dim_view::edit_param(&h.app, "d1"); + h.settle(); + type_value(&mut h, "24 mm"); + h.step(&json!({"key":"Enter"})).unwrap(); + assert_eq!(h.app.session.undo.len(), undo + 1); + h.app.run("edit.undo", json!({})).unwrap(); + assert!(h.app.session.doc.param("d1").unwrap().expr.starts_with("20")); +} + +#[test] +fn rectangle_side_and_fixed_origin_line_dimensions_preserve_other_constraints() { + for edge in [false, true] { + let mut h = line([20., 0.]); + h.app.run("sketch.constraint.fix", json!({"entity":"l1.start"})).unwrap(); + h.settle(); + let original = h.app.session.doc.to_json(); + let undo = h.app.session.undo.len(); + let picks = if edge { vec![[10., 0.]] } else { vec![[0., 0.], [20., 0.]] }; + place(&mut h, &picks, [10., 5.]); + type_value(&mut h, "30 mm"); + h.step(&json!({"key":"Enter"})).unwrap(); + let state = h.app.session.world_state(); + let sk = &state.sketch(h.app.session.active_sketch.unwrap()).unwrap().sketch; + assert!(sk.point(sk.resolve_point("l1.start").unwrap()).unwrap().len() < 1e-8, "fixed origin remains fixed"); + assert!((sk.point(sk.resolve_point("l1.end").unwrap()).unwrap().x - 30.).abs() < 1e-6); + assert_eq!(h.app.session.undo.len(), undo + 1); + h.app.run("edit.undo", json!({})).unwrap(); + assert_eq!(h.app.session.doc.to_json(), original); + } + for (picks, at) in + [(vec![[0., 0.], [0., 10.]], [-5., 5.]), (vec![[0., 5.]], [-5., 5.]), (vec![[20., 0.], [20., 10.]], [25., 5.]), (vec![[20., 5.]], [25., 5.])] + { + let mut h = Harness::new(); + crate::dim_view::cancel(); + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + h.app.run("sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[20,10]})).unwrap(); + h.call("ui.view", json!({"view":"top"})); + h.app.cam.distance = 80.; + h.app.cam.target = solvecraft_engine::geom::Vec3::new(10., 5., 0.); + h.settle(); + let baseline = h.app.session.doc.to_json(); + let undo = h.app.session.undo.len(); + place(&mut h, &picks, at); + type_value(&mut h, "12 mm"); + h.step(&json!({"key":"Enter"})).unwrap(); + let state = h.app.session.world_state(); + let solved = state.sketch(h.app.session.active_sketch.unwrap()).unwrap(); + assert!((solved.profiles[0].area - 240.).abs() < 1e-5, "side dimensions keep rectangle width: {}", solved.profiles[0].area); + assert_eq!(h.app.session.undo.len(), undo + 1); + h.app.run("edit.undo", json!({})).unwrap(); + assert_eq!(h.app.session.doc.to_json(), baseline); + } +} + +#[test] +fn pending_second_nested_plane_uses_world_picks_but_one_local_dimension() { + use solvecraft_engine::geom::Vec2; + crate::dim_view::cancel(); + let mut h = Harness::new(); + let parent = h.app.run("component.create", json!({"name":"Assembly"})).unwrap(); + let parent_component = parent["component"].as_u64().unwrap(); + let first = parent["occurrence"].as_u64().unwrap(); + let child = h.app.run("component.create", json!({"name":"Part"})).unwrap(); + let component = child["component"].as_u64().unwrap(); + let leaf = child["occurrence"].as_u64().unwrap(); + h.app.run("component.activate", json!({"component":0})).unwrap(); + let second = h.app.run("occurrence.copy", json!({"component":parent_component})).unwrap()["occurrence"].as_u64().unwrap(); + h.app.run("occurrence.move", json!({"occurrence":second,"translate":[20,30,40],"axis":[1,0,0],"angle":"35 deg","capture":true})).unwrap(); + h.app.run("component.activate", json!({"component":component,"occurrence":leaf,"path":[second,leaf]})).unwrap(); + h.app.run("browser.visibility", json!({"paths":[[first]],"visible":false})).unwrap(); + let sid = h.app.run("sketch.create", json!({"plane":"XY"})).unwrap()["sketch"].as_u64().unwrap(); + // The 2D producer uses sketch-local coordinates; pointer picks below use placed world coordinates. + h.app.run("sketch.line", json!({"points":[[0,0],[20,0]]})).unwrap(); + h.app.run("occurrence.move", json!({"occurrence":second,"translate":[25,32,40],"axis":[1,0,0],"angle":"35 deg","capture":false})).unwrap(); + h.call("ui.view", json!({"view":"top"})); + h.app.cam.distance = 90.; + let plane = h.app.session.world_state().sketch(sid).unwrap().plane; + h.app.cam.target = plane.to_world(Vec2::new(10., 0.)); + h.settle(); + let baseline = h.app.session.doc.to_json(); + let undo = h.app.session.undo.len(); + let pending = h.app.session.pending_moves.clone(); + h.step(&json!({"start":"sketch.dimension"})).unwrap(); + for p in [Vec2::ZERO, Vec2::new(20., 0.), Vec2::new(10., 5.)] { + let w = plane.to_world(p); + h.step(&json!({"click":{"world":[w.x,w.y,w.z]}})).unwrap(); + } + assert!(crate::dim_view::editing()); + assert_eq!(h.app.session.doc.to_json(), baseline); + type_value(&mut h, "30 mm"); + h.step(&json!({"key":"Enter"})).unwrap(); + assert_eq!(h.app.session.undo.len(), undo + 1); + assert_eq!(h.app.session.active_occurrence_path, Some(vec![second, leaf])); + assert_eq!(h.app.session.pending_moves, pending); + let state = h.app.session.model.state(); + let sk = &state.sketch(sid).unwrap().sketch; + assert!( + (sk.point(sk.resolve_point("l1.start").unwrap()).unwrap().dist(sk.point(sk.resolve_point("l1.end").unwrap()).unwrap()) - 30.).abs() < 1e-6 + ); + h.app.run("edit.undo", json!({})).unwrap(); + assert_eq!(h.app.session.doc.to_json(), baseline); + assert_eq!(h.app.session.pending_moves, pending); + h.app.run("edit.redo", json!({})).unwrap(); + assert_eq!(h.app.session.pending_moves, pending); + assert_eq!(h.app.session.active_occurrence_path, Some(vec![second, leaf])); +} + +#[test] +fn invalid_zero_negative_failed_open_and_click_away_leave_no_dimension() { + let mut h = line([20., 0.]); + let baseline = h.app.session.doc.to_json(); + let undo = h.app.session.undo.len(); + for invalid in ["0 mm", "-2 mm"] { + place(&mut h, &[[10., 0.]], [10., 5.]); + type_value(&mut h, invalid); + h.step(&json!({"key":"Enter"})).unwrap(); + assert!(crate::dim_view::editing()); + assert_eq!(h.app.session.doc.to_json(), baseline); + assert_eq!(h.app.session.undo.len(), undo); + h.step(&json!({"key":"Escape"})).unwrap(); + } + place(&mut h, &[[10., 0.]], [10., 5.]); + assert!(h.app.run("doc.open", json!({"path":"missing-dimension-draft-design.solvecraft"})).is_err()); + assert!(crate::dim_view::editing(), "failed replacement must preserve the draft"); + assert_eq!(h.app.session.doc.to_json(), baseline); + h.frames(8); + h.step(&json!({"click":{"world":[12,-10,0]}})).unwrap(); + assert!(!crate::dim_view::editing()); + assert_eq!(h.app.session.doc.to_json(), baseline); + assert_eq!(h.app.session.undo.len(), undo); +} + +#[test] +fn dimension_to_fillet_virtual_sharp_keeps_the_arc_and_fixed_origin() { + crate::dim_view::cancel(); + let mut h = Harness::new(); + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + h.app.run("sketch.line", json!({"points":[[0,0],[0,20],[20,20]],"ids":["riser","top"]})).unwrap(); + h.app.run("sketch.constraint.fix", json!({"entity":"riser.start"})).unwrap(); + h.app.run("sketch.constraint.horizontal_vertical", json!({"line":"riser","mode":"vertical"})).unwrap(); + h.app.run("sketch.constraint.horizontal_vertical", json!({"line":"top","mode":"horizontal"})).unwrap(); + // An existing width requires the fillet's retained virtual sharp. Add an independent + // height through the visible corner while preserving that width and the fillet arc. + h.app.run("sketch.dimension", json!({"entities":["top"],"value":20})).unwrap(); + let fillet = h.app.run("sketch.fillet", json!({"point":"riser.end","radius":5})).unwrap(); + let corner = fillet["virtual_corners"][0].as_str().unwrap().to_string(); + h.call("ui.view", json!({"view":"top"})); + h.app.cam.distance = 90.; + h.app.cam.target = solvecraft_engine::geom::Vec3::new(5., 10., 0.); + h.settle(); + let baseline = h.app.session.doc.to_json(); + let undo = h.app.session.undo.len(); + place(&mut h, &[[0., 0.], [0., 20.]], [-5., 10.]); + type_value(&mut h, "30 mm"); + h.step(&json!({"key":"Enter"})).unwrap(); + assert!(!crate::dim_view::editing()); + assert_eq!(h.app.session.undo.len(), undo + 1); + let state = h.app.session.world_state(); + let solved = state.sketch(h.app.session.active_sketch.unwrap()).unwrap(); + assert!(solved.report.ok()); + let sk = &solved.sketch; + assert!((sk.point(sk.point_index(&corner).unwrap()).unwrap().y - 30.).abs() < 1e-6); + assert!(sk.point(sk.resolve_point("riser.start").unwrap()).unwrap().len() < 1e-8); + assert!(sk.curves.iter().any(|c| matches!(c.kind, solvecraft_engine::sketch::CurveKind::Arc { .. })), "the dimension retains the fillet arc"); + h.app.run("edit.undo", json!({})).unwrap(); + assert_eq!(h.app.session.doc.to_json(), baseline); +} + +#[test] +fn draft_annotation_does_not_move_committed_dimension_hitboxes() { + crate::dim_view::cancel(); + let mut h = Harness::new(); + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + h.app.run("sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[20,10]})).unwrap(); + let width = h.app.run("sketch.dimension", json!({"entities":["l3"],"value":20})).unwrap()["param"].as_str().unwrap().to_string(); + h.call("ui.view", json!({"view":"top"})); + h.app.cam.distance = 80.; + h.app.cam.target = solvecraft_engine::geom::Vec3::new(10., 5., 0.); + h.settle(); + let at = crate::dim_view::text_at(&width).unwrap(); + let drawn = crate::dim_view::drawn(); + let baseline = h.app.session.doc.to_json(); + place(&mut h, &[[0., 0.], [0., 10.]], [-5., 5.]); + type_value(&mut h, "12 mm"); + h.settle(); + assert_eq!(crate::dim_view::text_at(&width), Some(at)); + assert_eq!(crate::dim_view::drawn(), drawn, "draft annotations must not become committed hit targets"); + assert_eq!(h.app.session.doc.to_json(), baseline); + h.step(&json!({"key":"Escape"})).unwrap(); + assert_eq!(crate::dim_view::drawn(), drawn); +} diff --git a/crates/ui-egui/src/documents.rs b/crates/ui-egui/src/documents.rs index 27ce9f1c..e4632090 100644 --- a/crates/ui-egui/src/documents.rs +++ b/crates/ui-egui/src/documents.rs @@ -76,9 +76,10 @@ fn put_view(app: &mut SolveApp, v: DocView) { fn swap_in(app: &mut SolveApp, slot: DocSlot) -> DocSlot { // Whatever was going on in the design leaves with it. crate::dialogs::cancel(app); + crate::dim_view::cancel(); app.tool = None; app.params = None; - app.cam_anim = None; + app.cancel_view_animation(); app.viewport.orbit_snap = None; app.viewport.orbit_free = None; app.preview = crate::preview::PreviewState::default(); @@ -391,20 +392,21 @@ pub fn tabs(app: &mut SolveApp, ui: &mut egui::Ui, r: Rect, x0: f32, x1: f32) { ui.ctx().data_mut(|d| d.insert_temp(id, !d.get_temp::(id).unwrap_or(false))); } if ui.ctx().data(|d| d.get_temp::(id).unwrap_or(false)) { - let resp = egui::Area::new(id.with("area")).fixed_pos(pos2(mr.left(), r.bottom())).order(egui::Order::Foreground).show(ui.ctx(), |ui| { - crate::context_menu::menu_frame(ui, |ui| { - for (i, (name, dirty)) in info.iter().enumerate() { - let item = crate::context_menu::Item::action( - "doc", - &format!("{name}{}", if *dirty { " •" } else { "" }), - if i == app.docs.active { "box" } else { "" }, - ); - if crate::context_menu::list_row(ui, &item).is_some() { - pick = Some(i); + let resp = + crate::frame::area(id.with("area")).fixed_pos(pos2(mr.left(), r.bottom())).order(egui::Order::Foreground).show(ui.ctx(), |ui| { + crate::context_menu::menu_frame(ui, |ui| { + for (i, (name, dirty)) in info.iter().enumerate() { + let item = crate::context_menu::Item::action( + "doc", + &format!("{name}{}", if *dirty { " •" } else { "" }), + if i == app.docs.active { "box" } else { "" }, + ); + if crate::context_menu::list_row(ui, &item).is_some() { + pick = Some(i); + } } - } - }) - }); + }) + }); let pressed_outside = ui.ctx().input(|i| i.pointer.any_pressed()) && !resp.response.contains_pointer() && !mresp.contains_pointer(); if pick.is_some() || pressed_outside { ui.ctx().data_mut(|d| d.insert_temp(id, false)); diff --git a/crates/ui-egui/src/frame.rs b/crates/ui-egui/src/frame.rs index ca4ce833..57db7828 100644 --- a/crates/ui-egui/src/frame.rs +++ b/crates/ui-egui/src/frame.rs @@ -33,6 +33,7 @@ pub fn docked<'a>(title: impl Into, id: egui::Id, anchor: Pos2, max_ Width::Wide => DIALOG_WIDE_MAX_W, }; egui::Window::new(title) + .enabled(!crate::issue_report::cad_input_blocked()) .id(id) .title_bar(false) .pivot(Align2::RIGHT_TOP) @@ -52,7 +53,17 @@ pub fn window<'a>(ctx: &egui::Context, title: impl Into, width: Widt Width::Wide => WINDOW_WIDE_MAX_W, }; let c = ctx.content_rect().center(); - egui::Window::new(title).collapsible(false).pivot(Align2::CENTER_CENTER).default_pos(c - vec2(0.0, 60.0)).max_width(max) + egui::Window::new(title) + .enabled(!crate::issue_report::cad_input_blocked()) + .collapsible(false) + .pivot(Align2::CENTER_CENTER) + .default_pos(c - vec2(0.0, 60.0)) + .max_width(max) +} + +/// Floating model controls share the report's read-only drawing boundary with windows. +pub fn area(id: egui::Id) -> egui::Area { + egui::Area::new(id).enabled(!crate::issue_report::cad_input_blocked()) } /// The width a window's content had last frame (at least `min`), for laying out rows that end at diff --git a/crates/ui-egui/src/gizmo.rs b/crates/ui-egui/src/gizmo.rs index 8c211e6a..5b8bf20c 100644 --- a/crates/ui-egui/src/gizmo.rs +++ b/crates/ui-egui/src/gizmo.rs @@ -241,6 +241,11 @@ fn snap(v: f64, step: f64, free: bool) -> f64 { if free || step <= 0.0 { v } else { (v / step).round() * step } } +/// Discard a suspended pointer gesture without reporting a final model change. +pub(crate) fn cancel(ctx: &egui::Context, id: egui::Id) { + ctx.data_mut(|d| d.remove::(id)); +} + /// Draw the triad and run a drag on it. `step` is the distance snap step. pub fn show(ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, id: egui::Id, t: &Triad, step: f64) -> Option { let tk = Tokens::get(); diff --git a/crates/ui-egui/src/help.rs b/crates/ui-egui/src/help.rs index b07bbdf7..4a310655 100644 --- a/crates/ui-egui/src/help.rs +++ b/crates/ui-egui/src/help.rs @@ -1,6 +1,6 @@ //! The Help menu (the ? button on the application bar): Documentation (the online book), About //! SolveCraft (version, commit, build date, licences, attributions, fonts), the keyboard shortcuts -//! (read only), and Report an Issue, which copies diagnostic information to the clipboard. +//! (read only), and Report an Issue, which opens a local editable report draft. use egui::{RichText, vec2}; @@ -54,8 +54,9 @@ pub fn run(app: &mut SolveApp, ctx: &egui::Context, item: &str) { app.keymap.read_only = true; } "report" => { - ctx.copy_text(diagnostics(app)); - app.set_status("Diagnostic information copied: paste it into your issue report", false); + let dialog = app.dialog.clone(); + let error = dialog.as_ref().and_then(|d| d.error.clone()).or_else(|| app.preview.error.clone()); + crate::issue_report::begin(app, ctx, dialog.as_ref(), error.as_deref()); } _ => {} } @@ -65,17 +66,21 @@ pub fn run(app: &mut SolveApp, ctx: &egui::Context, item: &str) { pub fn show(app: &mut SolveApp, ctx: &egui::Context, anchor: egui::Pos2) { if app.help.menu { let mut pick: Option<&str> = None; - let resp = egui::Area::new(egui::Id::new("sc_help_menu")).fixed_pos(anchor).constrain(true).order(egui::Order::Foreground).show(ctx, |ui| { - crate::context_menu::menu_frame(ui, |ui| { - for (id, label) in - [("docs", "Documentation"), ("about", "About SolveCraft"), ("shortcuts", "Keyboard Shortcuts"), ("report", "Report an Issue…")] - { - if crate::context_menu::list_row(ui, &crate::context_menu::Item::action(id, label, "")).is_some() { - pick = Some(id); + let resp = + crate::frame::area(egui::Id::new("sc_help_menu")).fixed_pos(anchor).constrain(true).order(egui::Order::Foreground).show(ctx, |ui| { + crate::context_menu::menu_frame(ui, |ui| { + for (id, label) in [ + ("docs", "Documentation"), + ("about", "About SolveCraft"), + ("shortcuts", "Keyboard Shortcuts"), + ("report", "Report an Issue…"), + ] { + if crate::context_menu::list_row(ui, &crate::context_menu::Item::action(id, label, "")).is_some() { + pick = Some(id); + } } - } - }) - }); + }) + }); if let Some(p) = pick { run(app, ctx, p); } else if ctx.input(|i| i.pointer.any_pressed() || i.key_pressed(egui::Key::Escape)) && !resp.response.contains_pointer() { diff --git a/crates/ui-egui/src/issue_report.rs b/crates/ui-egui/src/issue_report.rs new file mode 100644 index 00000000..835e7c0f --- /dev/null +++ b/crates/ui-egui/src/issue_report.rs @@ -0,0 +1,992 @@ +//! Reviewed issue drafts with local evidence; never publishes or uploads a report. + +use std::fmt::Write as _; +use std::hash::{Hash, Hasher}; +use std::sync::Arc; + +use egui::{ColorImage, RichText, TextureHandle, vec2}; + +use crate::{SolveApp, dialogs, help}; + +const MAX_BODY: usize = 24_000; +const MAX_IMAGE_PIXELS: usize = 16_000_000; +const ISSUE_URL: &str = "https://github.com/bherbruck/solvecraft/issues/new"; + +thread_local! { + static CAD_INPUT_BLOCKED: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +pub(crate) fn cad_input_blocked() -> bool { + CAD_INPUT_BLOCKED.with(std::cell::Cell::get) +} + +/// Draw CAD behind a report without delivering its input or focus to any model editor. +/// Fresh focus history also prevents inline editors from committing on report focus loss. +pub(crate) struct CadInput { + ctx: egui::Context, + input: Option, + memory: Option, + previous_blocked: bool, +} + +impl CadInput { + pub(crate) fn block(app: &SolveApp, ui: &mut egui::Ui, guard: &mut Option) { + if guard.is_none() { + *guard = Self::begin(app, ui.ctx()); + } + if guard.is_some() { + ui.visuals_mut().disabled_alpha = 1.0; + ui.disable(); + } + } + + pub(crate) fn begin(app: &SolveApp, ctx: &egui::Context) -> Option { + app.issue_report.draft.as_ref()?; + let mut raw = ctx.input(|i| i.raw.clone()); + raw.events.clear(); + raw.dropped_files.clear(); + raw.hovered_files.clear(); + let neutral = egui::InputState::default().begin_pass(raw.clone(), true, ctx.pixels_per_point(), egui::InputOptions::default()); + let input = ctx.input_mut(|i| std::mem::replace(i, neutral)); + let memory = ctx.memory_mut(|m| { + let mut saved = std::mem::take(m); + let mut neutral = egui::Memory::default(); + neutral.options = saved.options.clone(); + Arc::make_mut(&mut neutral.options.dark_style).visuals.disabled_alpha = 1.0; + Arc::make_mut(&mut neutral.options.light_style).visuals.disabled_alpha = 1.0; + neutral.data = saved.data.clone(); + neutral.to_global = saved.to_global.clone(); + *neutral.areas_mut() = saved.areas().clone(); + neutral.caches = std::mem::take(&mut saved.caches); + *m = neutral; + saved + }); + let previous_blocked = CAD_INPUT_BLOCKED.with(|blocked| blocked.replace(true)); + Some(Self { ctx: ctx.clone(), input: Some(input), memory: Some(memory), previous_blocked }) + } +} + +impl Drop for CadInput { + fn drop(&mut self) { + CAD_INPUT_BLOCKED.with(|blocked| blocked.set(self.previous_blocked)); + if let Some(input) = self.input.take() { + self.ctx.input_mut(|i| *i = input); + } + if let Some(mut memory) = self.memory.take() { + self.ctx.memory_mut(|m| { + // Keep current layout/widget state and caches; isolate only input/focus history. + memory.data = std::mem::take(&mut m.data); + memory.caches = std::mem::take(&mut m.caches); + memory.to_global = std::mem::take(&mut m.to_global); + *memory.areas_mut() = m.areas().clone(); + *m = memory; + }); + } + } +} + +#[derive(Default)] +pub struct ReportState { + pub draft: Option, + pending: Option, + cad_focus: Option, +} + +pub struct Draft { + pub title: String, + pub body: String, + image: Option>, + texture: Option, + pub note: String, + source_signature: u64, +} + +struct Pending { + token: u64, + signature: u64, + deadline: f64, + window: egui::Rect, + pixels_per_point: f32, +} + +/// A bounded formatting sink also avoids allocating a full hostile Debug/JSON value first. +struct Text { + value: String, + limit: usize, +} + +impl std::fmt::Write for Text { + fn write_str(&mut self, s: &str) -> std::fmt::Result { + let room = self.limit.saturating_sub(self.value.len()); + if s.len() <= room { + self.value.push_str(s); + return Ok(()); + } + let mut end = room.min(s.len()); + while !s.is_char_boundary(end) { + end = end.saturating_sub(1); + } + self.value.push_str(s.get(..end).unwrap_or_default()); + Err(std::fmt::Error) + } +} + +fn debug(value: &impl std::fmt::Debug, limit: usize) -> String { + let mut text = Text { value: String::new(), limit }; + if write!(&mut text, "{value:#?}").is_err() { + text.value.push_str("\n[truncated]"); + } + text.value +} + +struct JsonBytes { + value: Vec, + limit: usize, +} +impl std::io::Write for JsonBytes { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + let n = bytes.len().min(self.limit.saturating_sub(self.value.len())); + self.value.extend_from_slice(bytes.get(..n).unwrap_or_default()); + if n != bytes.len() { + return Err(std::io::Error::other("report context limit")); + } + Ok(n) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +fn json_context(value: &impl serde::Serialize, limit: usize) -> String { + let mut bytes = JsonBytes { value: Vec::new(), limit }; + let truncated = serde_json::to_writer_pretty(&mut bytes, value).is_err(); + let mut value = String::from_utf8_lossy(&bytes.value).into_owned(); + if truncated { + value.push_str("\n[truncated]"); + } + value +} + +fn truncate(value: &mut String, limit: usize) { + if value.len() > limit { + let mut end = limit; + while !value.is_char_boundary(end) { + end = end.saturating_sub(1); + } + value.truncate(end); + } +} + +fn limited(value: &str, limit: usize) -> String { + let mut end = limit.min(value.len()); + while !value.is_char_boundary(end) { + end = end.saturating_sub(1); + } + value.get(..end).unwrap_or_default().to_string() +} + +fn block(value: &str) -> String { + value.lines().map(|line| format!(" {line}\n")).collect() +} + +/// Hash directly through formatting, without constructing an unbounded Debug string. +struct Fingerprint(std::collections::hash_map::DefaultHasher); +impl std::fmt::Write for Fingerprint { + fn write_str(&mut self, s: &str) -> std::fmt::Result { + self.0.write(s.as_bytes()); + Ok(()) + } +} + +fn source_signature(app: &SolveApp, dialog: Option<&dialogs::Dialog>) -> u64 { + let mut h = Fingerprint(std::collections::hash_map::DefaultHasher::new()); + app.session.revision.hash(&mut h.0); + app.docs.active.hash(&mut h.0); + app.docs.count().hash(&mut h.0); + app.session.doc.name.hash(&mut h.0); + app.preview.key.hash(&mut h.0); + app.preview.error.hash(&mut h.0); + app.preview.busy.hash(&mut h.0); + let _ = write!(&mut h, "{:?}{:?}", dialog, app.session.selection); + h.0.finish() +} + +fn signature(app: &SolveApp, dialog: Option<&dialogs::Dialog>) -> u64 { + let mut h = Fingerprint(std::collections::hash_map::DefaultHasher::new()); + source_signature(app, dialog).hash(&mut h.0); + let _ = write!(&mut h, "{:?}{:?}{:?}", app.cam, app.viewport.rect, app.ui.perspective); + h.0.finish() +} + +fn reportable(feature: &solvecraft_engine::doc::Feature) -> bool { + !matches!( + feature.kind, + solvecraft_engine::doc::FeatureKind::Sketch { .. } + | solvecraft_engine::doc::FeatureKind::Import { .. } + | solvecraft_engine::doc::FeatureKind::MeshImport { .. } + ) +} + +fn feature_context(feature: &solvecraft_engine::doc::Feature) -> String { + format!("Feature {}: {}\n{}", feature.id, debug(&feature.name, 160), block(&json_context(&feature.kind, 2000))) +} + +/// Read-only context excludes whole documents, sketch geometry and imported file contents. +pub fn draft_for(app: &SolveApp, dialog: Option<&dialogs::Dialog>, error: Option<&str>) -> Draft { + let mut context = String::new(); + let label = dialog.map(dialogs::report_title).unwrap_or_else(|| "Modeling".into()); + if let Some(d) = dialog { + match dialogs::apply_commands(app, d) { + Ok(commands) => context.push_str(&block(&json_context(&commands, 10_000))), + Err(e) => { + context.push_str(&format!("Could not build command parameters: {}\n", debug(&e, 1000))); + context.push_str(&block(&debug(&d.kind, 6000))); + context.push_str(&block(&debug(&d.inputs, 3000))); + context.push_str(&block(&debug(&d.extra, 1000))); + } + } + } + context.push_str("\nSelected items (including occurrence paths):\n"); + context.push_str(&block(&debug(&app.session.selection.iter().take(32).collect::>(), 3000))); + for item in app.session.selection.iter().take(8) { + if let solvecraft_engine::Sel::Feature { id } = item + && let Some(feature) = app.session.doc.feature(*id) + && reportable(feature) + { + context.push_str(&feature_context(feature)); + if let Some(e) = app.session.model.result(*id).and_then(|r| r.error.as_deref()) { + context.push_str(&format!("Feature error: {}\n", debug(&e, 1000))); + } + } + } + if dialog.is_none() + && let Some(feature) = app.session.doc.features.iter().rev().find(|f| reportable(f)) + { + context.push_str("\nLatest modeling operation:\n"); + context.push_str(&feature_context(feature)); + if let Some(e) = app.session.model.result(feature.id).and_then(|r| r.error.as_deref()) { + context.push_str(&format!("Feature error: {}\n", debug(&e, 1000))); + } + } + let actual = error.map(|e| debug(&e, 4000)).unwrap_or_else(|| "Describe the unexpected result shown in the view.".into()); + let image_state = if error.is_some() { + "A failed preview may display the last valid geometry; that geometry is not evidence that the failed operation succeeded." + } else { + "Describe what looks wrong in the current result." + }; + let mut body = format!( + "## Expected behavior\nDescribe the intended result.\n\n## Actual behavior\n{actual}\n\n{image_state}\n\n## Reproduction\nDescribe the steps or a small public-safe example.\n\n## Settings and selection\n{context}\n## Environment\n{}\nRevision: {}\nCamera: {}\n\n## Visual context\nAttach the reviewed screenshot or a short example showing the problem.\n", + block(&debug(&help::diagnostics(app), 2000)), + app.session.revision, + block(&debug(&app.cam, 1500)) + ); + if body.len() > MAX_BODY { + truncate(&mut body, MAX_BODY - 32); + body.push_str("\n[report context truncated]\n"); + } + let mut title = format!("{label}: {}", if error.is_some() { "operation failed" } else { "unexpected result" }); + truncate(&mut title, 160); + Draft { title, body, image: None, texture: None, note: String::new(), source_signature: source_signature(app, dialog) } +} + +fn encode(value: &str) -> String { + let mut out = String::new(); + for b in value.bytes() { + if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') { + out.push(char::from(b)); + } else { + let _ = write!(&mut out, "%{b:02X}"); + } + } + out +} + +/// Long drafts stay local and can be copied into the browser editor; never discard them. +pub fn draft_url(title: &str, body: &str) -> (String, bool) { + let title = limited(title, 160); + let base = format!("{ISSUE_URL}?title={}", encode(&title)); + if body.len() <= MAX_BODY { + let url = format!("{base}&body={}", encode(body)); + if url.len() <= 2000 { + return (url, false); + } + } + (base, true) +} + +pub fn begin(app: &mut SolveApp, ctx: &egui::Context, dialog: Option<&dialogs::Dialog>, error: Option<&str>) { + if app.issue_report.draft.is_none() { + app.issue_report.cad_focus = ctx.memory(|m| m.focused()); + } + cancel_capture(app); + app.issue_report.draft = Some(draft_for(app, dialog, error)); + if let Some(draft) = app.issue_report.draft.as_mut() { + let rect = ctx.content_rect(); + let _ = write!( + &mut draft.body, + "\nWindow: {:.0}×{:.0} logical points; {:.2} physical pixels/point\n", + rect.width(), + rect.height(), + ctx.pixels_per_point() + ); + truncate(&mut draft.body, MAX_BODY); + } + capture(app, ctx, dialog); +} + +fn cancel_capture(app: &mut SolveApp) { + if let Some(pending) = app.issue_report.pending.take() { + app.queued_shots.retain(|(token, ..)| *token != pending.token); + } +} + +fn capture(app: &mut SolveApp, ctx: &egui::Context, dialog: Option<&dialogs::Dialog>) { + cancel_capture(app); + let current_source = source_signature(app, dialog); + let Some(draft) = app.issue_report.draft.as_mut() else { return }; + draft.image = None; + draft.texture = None; + if draft.source_signature != current_source { + draft.note = + "Settings or model changed. Close this draft and create a new report to capture matching context; your text is still available to copy." + .into(); + return; + } + if app.preview.busy || app.queued_shots.len() >= 16 { + draft.note = "Screenshot unavailable while the preview is updating. Reopen the report after it settles, or continue with text.".into(); + return; + } + let Some(token) = app.shot_token.checked_add(1) else { + draft.note = "Screenshot request limit reached; restart the app to capture another frame.".into(); + return; + }; + app.shot_token = token; + draft.note = "Capturing the current app view locally…".into(); + let now = crate::now_ms(); + app.issue_report.pending = Some(Pending { + token, + signature: signature(app, dialog), + deadline: now + 8000.0, + window: ctx.content_rect(), + pixels_per_point: ctx.pixels_per_point(), + }); + app.queued_shots.push((token, now + 150.0, 0)); + ctx.request_repaint(); +} + +pub(crate) fn pending(app: &SolveApp) -> bool { + app.issue_report.pending.is_some() +} + +pub(crate) fn tick(app: &mut SolveApp, ctx: &egui::Context) { + let problem = app.issue_report.pending.as_ref().and_then(|p| { + if signature(app, app.dialog.as_ref()) != p.signature || p.window != ctx.content_rect() || p.pixels_per_point != ctx.pixels_per_point() { + Some("The model, inputs or view changed during capture. Retake the current view.") + } else if crate::now_ms() >= p.deadline { + Some("No app frame was presented. Restore the window and retake, or continue with text.") + } else { + None + } + }); + if let Some(problem) = problem { + cancel_capture(app); + if let Some(draft) = app.issue_report.draft.as_mut() { + draft.note = problem.into(); + } + } + if pending(app) { + ctx.request_repaint_after(std::time::Duration::from_millis(16)); + } +} + +pub(crate) fn receive(app: &mut SolveApp, token: u64, image: Arc) { + if !app.issue_report.pending.as_ref().is_some_and(|p| p.token == token) { + return; + } + let stale = app.issue_report.pending.as_ref().is_some_and(|p| p.signature != signature(app, app.dialog.as_ref())); + app.issue_report.pending = None; + if let Some(draft) = app.issue_report.draft.as_mut() { + if stale { + draft.note = "The model, inputs or view changed during capture. Retake the current view.".into(); + return; + } + let pixels = image.width().checked_mul(image.height()); + if !pixels.is_some_and(|n| n > 0 && n <= MAX_IMAGE_PIXELS && n == image.pixels.len()) { + draft.note = "Screenshot dimensions are unsupported. The text draft is still available.".into(); + } else { + draft.image = Some(image); + draft.note = "Screenshot captured locally. Review it before attaching it on GitHub.".into(); + } + } +} + +/// The report window is hidden only while a frame is captured, so it cannot cover the evidence. +pub fn show(app: &mut SolveApp, ctx: &egui::Context) { + if pending(app) { + return; + } + let Some(mut draft) = app.issue_report.draft.take() else { return }; + let mut open = true; + let mut retake = false; + let mut close = false; + let screen = ctx.content_rect(); + crate::frame::window(ctx, "Create Issue — review draft", crate::frame::Width::Wide) + .id(egui::Id::new("sc_issue_report")) + .open(&mut open) + .resizable(true) + .default_width((screen.width() - 32.0).clamp(100.0, 640.0)) + .max_width((screen.width() - 24.0).max(100.0)) + .max_height((screen.height() - 32.0).max(100.0)) + .vscroll(true) + .show(ctx, |ui| { + ui.label("Review the report and screenshot before opening GitHub."); + ui.add( + egui::TextEdit::singleline(&mut draft.title) + .id(egui::Id::new("issue_report_title")) + .hint_text("Issue title") + .char_limit(160) + .desired_width(f32::INFINITY), + ); + egui::ScrollArea::vertical().id_salt("issue-report-body").max_height((screen.height() * 0.35).clamp(72.0, 240.0)).show(ui, |ui| { + ui.add( + egui::TextEdit::multiline(&mut draft.body) + .id(egui::Id::new("issue_report_body")) + .char_limit(MAX_BODY) + .desired_rows(5) + .desired_width(f32::INFINITY), + ); + }); + truncate(&mut draft.title, 160); + truncate(&mut draft.body, MAX_BODY); + ui.label(RichText::new("Remove private design details or paths before sharing.").small()); + ui.label(&draft.note); + if let Some(image) = draft.image.as_ref() { + let texture = + draft.texture.get_or_insert_with(|| ctx.load_texture("issue-report-frame", image.clone(), egui::TextureOptions::LINEAR)); + ui.add( + egui::Image::new(&*texture) + .max_size(vec2(ui.available_width(), (screen.height() * 0.25).clamp(60.0, 220.0))) + .maintain_aspect_ratio(true), + ); + } + ui.horizontal_wrapped(|ui| { + retake = ui.button("Retake screenshot").clicked(); + if ui.add_enabled(draft.image.is_some(), egui::Button::new("Copy screenshot")).clicked() + && let Some(image) = draft.image.as_ref() + { + ctx.copy_image((**image).clone()); + draft.note = "Paste the image into the GitHub draft, or save the PNG if image copying is unavailable.".into(); + } + if ui.add_enabled(draft.image.is_some() && app.services.pick_save.is_some(), egui::Button::new("Save screenshot…")).clicked() + && let Some(image) = draft.image.as_ref() + && let Some(path) = + app.services.pick_save.as_ref().and_then(|pick| pick(&format!("solvecraft-issue-{}.png", app.shot_token), &["png"])) + { + let result = crate::control::save_screenshot(image, Some(&path)); + draft.note = if result.get("ok").and_then(serde_json::Value::as_bool) == Some(true) { + "Screenshot saved. Attach the PNG in the GitHub draft.".into() + } else { + format!("Could not save screenshot: {}", debug(&result.get("error"), 1000)) + }; + } + if ui.add_enabled(draft.image.is_some(), egui::Button::new("Remove screenshot")).clicked() { + draft.image = None; + draft.texture = None; + draft.note = "Screenshot removed from this draft.".into(); + } + }); + ui.horizontal_wrapped(|ui| { + if ui.button("Copy report").clicked() { + ctx.copy_text(draft.body.clone()); + draft.note = "Report copied. Paste it into the GitHub draft if needed.".into(); + } + let (url, copy_needed) = draft_url(&draft.title, &draft.body); + if ui.button("Open GitHub draft").clicked() { + if copy_needed { + ctx.copy_text(draft.body.clone()); + } + ctx.open_url(egui::OpenUrl::new_tab(url)); + draft.note = if copy_needed { + "Report copied. Paste into the GitHub draft, then attach the screenshot." + } else { + "Review the GitHub draft and attach the screenshot. GitHub's Submit new issue button publishes it." + } + .into(); + } + close = ui.button("Close draft").clicked(); + }); + }); + if open && !close { + app.issue_report.draft = Some(draft); + if retake { + let dialog = app.dialog.clone(); + capture(app, ctx, dialog.as_ref()); + } + } else { + cancel_capture(app); + // Pointer release may have happened during review; never finalize it against + // the report's last cursor position when CAD interaction resumes. + app.viewport.point_drag = None; + app.viewport.boxsel = None; + app.viewport.axis_drag = None; + app.viewport.orbit_free = None; + app.viewport.orbit_snap = None; + crate::dim_view::drag_end(); + crate::sketch3d::drag_end(); + crate::drag_snap::clear(); + crate::gizmo::cancel(ctx, egui::Id::new("sc_move_triad")); + crate::gizmo::cancel(ctx, egui::Id::new("sc_occ_triad")); + if let Some(focus) = app.issue_report.cad_focus.take() { + ctx.memory_mut(|m| m.request_focus(focus)); + } + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::panic)] + use super::*; + use serde_json::json; + use solvecraft_engine::{Sel, Session}; + + fn app() -> SolveApp { + SolveApp::new(Session::default(), Default::default()) + } + + fn combine(app: &mut SolveApp) { + app.start("solid.combine"); + let dialog = app.dialog.as_mut().unwrap(); + dialog.kind = dialogs::Kind::Combine { operation: 2, keep_tools: true }; + dialog.inputs.get_mut(0).unwrap().items = vec![Sel::Body { name: "Stock".into() }]; + dialog.inputs.get_mut(1).unwrap().items = vec![Sel::Body { name: "Cutter".into() }]; + } + + #[test] + fn issue_report_actual_pending_boolean_settings_and_failure_are_read_only() { + let mut app = app(); + combine(&mut app); + let before = (app.session.doc.to_json(), app.session.revision, app.session.undo.len(), app.session.redo.len(), app.cam); + let draft = draft_for(&app, app.dialog.as_ref(), Some("Boolean tool is outside target")); + for expected in + ["solid.combine", "Stock", "Cutter", "cut", "keep_tools", "true", "Boolean tool is outside target", help::VERSION, "last valid geometry"] + { + assert!(draft.body.contains(expected), "missing {expected}: {}", draft.body); + } + assert_eq!(before, (app.session.doc.to_json(), app.session.revision, app.session.undo.len(), app.session.redo.len(), app.cam)); + } + + #[test] + fn issue_report_hostile_context_and_url_are_bounded_without_new_query_parameters() { + let mut app = app(); + combine(&mut app); + let hostile = "錯誤 ```\n## fake &labels=secret%20tag 🚀".repeat(40_000); + let draft = draft_for(&app, app.dialog.as_ref(), Some(&hostile)); + assert!(draft.body.len() <= MAX_BODY); + assert!(draft.body.contains("[truncated]")); + let (url, copy) = draft_url("Cut &labels=secret #? 🚀", "actual\n```\n&assignees=someone"); + assert!(!copy); + assert!(url.starts_with(ISSUE_URL)); + assert_eq!(url.matches('&').count(), 1); + assert!(url.contains("%26labels%3Dsecret") && url.contains("%F0%9F%9A%80")); + let (short, copy) = draft_url(&hostile, &draft.body); + assert!(copy && short.len() <= 2000 && !short.contains("&body=")); + assert!(draft.body.contains("Stock"), "fallback must retain the editable full report"); + } + + #[test] + fn issue_report_help_supports_successful_geometry_without_mutating_it() { + let mut app = app(); + app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Stock"})).unwrap(); + app.run("solid.box", json!({"length":5,"width":5,"height":5,"body_name":"Cutter"})).unwrap(); + app.run("solid.combine", json!({"target":"Stock","tools":["Cutter"],"operation":"cut","keep_tools":true})).unwrap(); + assert!(app.session.model.results.iter().all(|r| r.error.is_none())); + let before = (app.session.doc.to_json(), app.session.undo.len(), app.session.redo.len(), app.cam); + let ctx = egui::Context::default(); + help::run(&mut app, &ctx, "report"); + let draft = app.issue_report.draft.as_ref().unwrap(); + assert!(draft.body.contains("Describe the unexpected result")); + assert!(draft.body.contains(help::COMMIT)); + assert!(draft.body.contains("combine") && draft.body.contains("keep_tools") && draft.body.contains("Cutter")); + assert!(pending(&app), "Help must request real evidence, not just copy generic text"); + assert_eq!(before, (app.session.doc.to_json(), app.session.undo.len(), app.session.redo.len(), app.cam)); + } + + #[test] + fn issue_report_selected_and_recent_sketch_geometry_is_not_exported() { + let mut app = app(); + app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"PublicExample"})).unwrap(); + let created = app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + let sketch = created["sketch"].as_u64().unwrap(); + app.run("sketch.point", json!({"point":[123456.789123,987654.321987]})).unwrap(); + app.run("sketch.finish", json!({})).unwrap(); + app.session.selection = vec![Sel::Feature { id: sketch }]; + let stored = app.session.doc.to_json(); + assert!(stored.contains("123456.789123") && stored.contains("987654.321987")); + let draft = draft_for(&app, None, None); + assert!( + draft.body.contains("\"type\": \"box\"") && draft.body.contains("\"length\""), + "keep the relevant last modeling settings: {}", + draft.body + ); + assert!(!draft.body.contains("123456.789123") && !draft.body.contains("987654.321987")); + assert!(!draft.body.contains("\"points\""), "never serialize the selected or recent sketch point list"); + assert_eq!(stored, app.session.doc.to_json()); + } + + #[test] + fn issue_report_error_row_click_opens_the_actual_boolean_draft() { + let mut h = crate::scenario::Harness::new(); + h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Stock"})).unwrap(); + h.app.home.open = false; + combine(&mut h.app); + let error = h.app.run("solid.combine", json!({"target":"Stock","tools":["Cutter"],"operation":"cut","keep_tools":true})).unwrap_err(); + h.app.dialog.as_mut().unwrap().error = Some(error.clone()); + h.frames(3); + let before = (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.redo.len()); + h.step(&json!({"widget":"Create Issue…"})).unwrap(); + let draft = h.app.issue_report.draft.as_ref().unwrap(); + assert!(draft.body.contains("solid.combine") && draft.body.contains("Cutter") && draft.body.contains(&format!("{error:?}"))); + assert!(h.app.dialog.is_some(), "reporting must not cancel the live feature dialog"); + assert_eq!(before, (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.redo.len())); + } + + #[test] + fn issue_report_long_draft_open_click_copies_the_full_reviewed_text() { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + let mut draft = draft_for(&h.app, None, None); + draft.body = "Reviewed context 🚀 &labels=private\n".repeat(120); + let reviewed = draft.body.clone(); + h.app.issue_report.draft = Some(draft); + h.frames(4); + let [x, y] = h.widget("Open GitHub draft").unwrap(); + assert_eq!(h.call("ui.move", json!({"x":x,"y":y}))["ok"], true); + let (request, _reply) = crate::control::ControlRequest::new("ui.click", json!({"x":x,"y":y})); + assert!(matches!(crate::control::handle(&mut h.app, &h.ctx, &request), crate::control::Outcome::Done(value) if value["ok"] == true)); + let mut commands = Vec::new(); + for _ in 0..12 { + h.frame(); + commands.extend(h.output.commands.clone()); + } + assert!(commands.iter().any(|c| matches!(c, egui::OutputCommand::CopyText(text) if text == &reviewed)), "commands: {commands:?}"); + assert!( + commands.iter().any(|c| matches!(c, egui::OutputCommand::OpenUrl(url) if url.url.starts_with(ISSUE_URL) && !url.url.contains("&body="))) + ); + assert_eq!(h.app.issue_report.draft.as_ref().unwrap().body, reviewed); + assert!(h.app.issue_report.draft.as_ref().unwrap().note.starts_with("Report copied. Paste into the GitHub draft")); + } + + #[test] + fn issue_report_editing_enter_and_cad_shortcuts_leave_the_live_boolean_unchanged() { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Stock"})).unwrap(); + h.app.run("solid.box", json!({"length":5,"width":5,"height":5,"body_name":"Cutter"})).unwrap(); + combine(&mut h.app); + h.app.issue_report.draft = Some(draft_for(&h.app, h.app.dialog.as_ref(), None)); + h.frames(4); + let before = (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.redo.len(), h.app.session.revision); + for field in ["issue_report_title", "issue_report_body"] { + let id = egui::Id::new(field); + h.ctx.memory_mut(|m| m.request_focus(id)); + h.frames(1); + h.step(&json!({"text":"edited-report-marker"})).unwrap(); + h.step(&json!({"key":"Enter"})).unwrap(); + assert!(h.app.dialog.is_some(), "Enter in {field} must not apply the valid Boolean"); + assert_eq!(before, (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.redo.len(), h.app.session.revision)); + h.ctx.memory_mut(|m| m.surrender_focus(id)); + } + let draft = h.app.issue_report.draft.as_ref().unwrap(); + assert!(draft.title.contains("edited-report-marker") && draft.body.contains("edited-report-marker")); + for key in [json!({"key":"Enter"}), json!({"key":"Escape"}), json!({"key":"Z","cmd":true})] { + h.step(&key).unwrap(); + assert!(h.app.dialog.is_some() && h.app.issue_report.draft.is_some()); + assert_eq!(before, (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.redo.len(), h.app.session.revision)); + } + } + + #[test] + fn issue_report_placed_sketch_text_cannot_steal_focus_apply_or_cancel_behind_draft() { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + h.app.start("sketch.text"); + h.frames(3); + h.step(&json!({"click":{"sketch":[0,0]}})).unwrap(); + h.step(&json!({"text":"Pending CAD text"})).unwrap(); + assert!(h.widget("Pending CAD text").is_some(), "the real placed Text editor must be open"); + let before = (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.redo.len(), h.app.session.revision); + h.app.help.menu = true; + h.frames(2); + h.step(&json!({"click":{"handle":"menu:report"}})).unwrap(); + h.frames(3); + assert!(h.widget("Pending CAD text").is_some(), "capture must keep the underlying Text editor drawn"); + let token = h.app.issue_report.pending.as_ref().unwrap().token; + receive(&mut h.app, token, Arc::new(ColorImage::new([2, 2], vec![egui::Color32::GREEN; 4]))); + h.frames(3); + for field in ["issue_report_title", "issue_report_body"] { + let id = egui::Id::new(field); + h.ctx.memory_mut(|m| m.request_focus(id)); + h.frames(2); + assert_eq!(h.ctx.memory(|m| m.focused()), Some(id), "placed Text must not steal report focus"); + h.step(&json!({"text":"report-only-marker"})).unwrap(); + h.step(&json!({"key":"Enter"})).unwrap(); + h.step(&json!({"key":"Escape"})).unwrap(); + assert_eq!(before, (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.redo.len(), h.app.session.revision)); + assert!(h.widget("Pending CAD text").is_some(), "report keys must not apply, edit or cancel the Text editor"); + } + let draft = h.app.issue_report.draft.as_ref().unwrap(); + assert!(draft.title.contains("report-only-marker") && draft.body.contains("report-only-marker")); + h.step(&json!({"widget":"Close draft"})).unwrap(); + assert!(h.app.issue_report.draft.is_none()); + h.frames(2); + assert_eq!(before, (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.redo.len(), h.app.session.revision)); + assert!(h.widget("Pending CAD text").is_some()); + h.step(&json!({"key":"Enter"})).unwrap(); + assert_ne!(before.0, h.app.session.doc.to_json(), "closing the report must return control to the preserved CAD text"); + assert_eq!(h.app.session.undo.len(), before.1 + 1); + } + + #[test] + fn issue_report_boundary_preserves_opacity_widget_state_and_real_focus() { + let mut app = app(); + let ctx = egui::Context::default(); + let focus = egui::Id::new("report-review-focus"); + let state = egui::Id::new("cad-layout-state"); + begin(&mut app, &ctx, None, None); + let raw = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))), + focused: true, + ..Default::default() + }; + let mut output = ctx.run_ui(raw, |ui| { + ctx.memory_mut(|m| m.request_focus(focus)); + let barrier = CadInput::begin(&app, &ctx).unwrap(); + ui.visuals_mut().disabled_alpha = 1.0; + ui.disable(); + assert_eq!(ui.opacity(), 1.0, "capture must retain normal drawing opacity"); + assert!(!ui.is_enabled()); + crate::frame::area(egui::Id::new("cad-boundary-proof")).fade_in(false).show(&ctx, |ui| { + assert!(!ui.is_enabled()); + assert_eq!(ui.opacity(), 1.0); + ui.ctx().data_mut(|d| d.insert_temp(state, 42_u32)); + ui.ctx().memory_mut(|m| m.request_focus(egui::Id::new("underlying-editor"))); + }); + drop(barrier); + assert_eq!(ctx.memory(|m| m.focused()), Some(focus)); + assert_eq!(ctx.data(|d| d.get_temp::(state)), Some(42)); + assert!(crate::frame::area(egui::Id::new("report-boundary-proof")).is_enabled()); + }); + output.textures_delta.clear(); + app.issue_report.draft = None; + assert!(!cad_input_blocked()); + } + + #[test] + fn issue_report_cached_point_drag_release_does_not_finish_cad_drag() { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + h.app.run("sketch.line", json!({"points":[[0,0],[20,0]],"id":"line"})).unwrap(); + h.frames(3); + let at = h.call("ui.at", json!({"sketch":[0,0]})); + let p = egui::pos2(at["result"][0].as_f64().unwrap() as f32, at["result"][1].as_f64().unwrap() as f32); + h.app.synthetic.push(egui::Event::PointerMoved(p)); + h.frame(); + h.app.synthetic.push(egui::Event::PointerButton { + pos: p, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }); + h.frame(); + let q = p + egui::vec2(40.0, -40.0); + h.app.synthetic.push(egui::Event::PointerMoved(q)); + h.frames(2); + let drag = h.app.viewport.point_drag.clone(); + assert!(drag.is_some(), "real viewport point drag must have started"); + let before = (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.redo.len(), h.app.session.revision); + help::run(&mut h.app, &h.ctx, "report"); + // egui calculates this release before the CAD input barrier begins. + h.app.synthetic.push(egui::Event::PointerButton { + pos: q, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }); + h.frame(); + assert_eq!(h.app.viewport.point_drag, drag, "cached release must not clear or constrain the CAD drag"); + assert_eq!(before, (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.redo.len(), h.app.session.revision)); + let token = h.app.issue_report.pending.as_ref().unwrap().token; + receive(&mut h.app, token, Arc::new(ColorImage::new([2, 2], vec![egui::Color32::GREEN; 4]))); + h.frames(3); + h.step(&json!({"widget":"Close draft"})).unwrap(); + assert!(h.app.viewport.point_drag.is_none()); + assert_eq!(before, (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.redo.len(), h.app.session.revision)); + } + + #[test] + fn issue_report_new_dimension_draft_resumes_and_commits_once_after_close() { + crate::dim_view::cancel(); + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + h.app.run("sketch.line", json!({"points":[[0,0],[20,0]]})).unwrap(); + h.call("ui.view", json!({"view":"top"})); + h.app.cam.distance = 80.0; + h.app.cam.target = solvecraft_engine::geom::Vec3::new(10.0, 0.0, 0.0); + h.settle(); + h.step(&json!({"start":"sketch.dimension"})).unwrap(); + for point in [[0, 0, 0], [20, 0, 0], [10, 5, 0]] { + h.step(&json!({"click":{"world":point}})).unwrap(); + } + h.step(&json!({"text":"30 mm"})).unwrap(); + assert!(crate::dim_view::editing()); + let before = (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.redo.len(), h.app.session.revision); + help::run(&mut h.app, &h.ctx, "report"); + h.frames(3); + let token = h.app.issue_report.pending.as_ref().unwrap().token; + receive(&mut h.app, token, Arc::new(ColorImage::new([2, 2], vec![egui::Color32::GREEN; 4]))); + h.frames(3); + h.ctx.memory_mut(|m| m.request_focus(egui::Id::new("issue_report_body"))); + h.step(&json!({"text":"Typing here describes the bug, not a CAD dimension."})).unwrap(); + h.step(&json!({"key":"Enter"})).unwrap(); + h.step(&json!({"key":"Escape"})).unwrap(); + assert_eq!(before, (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.redo.len(), h.app.session.revision)); + assert!(crate::dim_view::editing() && h.widget("30 mm").is_some()); + assert!(h.app.issue_report.draft.as_ref().unwrap().body.contains("Typing here describes")); + h.step(&json!({"widget":"Close draft"})).unwrap(); + h.frames(2); + assert_eq!(h.ctx.memory(|m| m.focused()), Some(egui::Id::new("sc_dim_edit"))); + h.step(&json!({"key":"Enter"})).unwrap(); + assert!(!crate::dim_view::editing()); + assert_eq!(h.app.session.undo.len(), before.1 + 1); + assert_eq!(h.app.session.doc.param("d1").unwrap().expr, "30 mm"); + h.app.run("edit.undo", json!({})).unwrap(); + assert_eq!(h.app.session.doc.to_json(), before.0); + } + + #[test] + fn issue_report_inline_dimension_retains_expression_and_resumes_after_close() { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + h.app.run("sketch.circle.center", json!({"center":[0,0],"radius":10,"id":"circle"})).unwrap(); + let out = h.app.run("sketch.dimension", json!({"entities":["circle"],"type":"radius","value":10})).unwrap(); + let param = out["param"].as_str().unwrap().to_owned(); + h.frames(3); + crate::dim_view::edit_param(&h.app, ¶m); + h.frames(2); + h.step(&json!({"text":"12"})).unwrap(); + assert!(crate::dim_view::editing() && h.widget("12").is_some()); + let before = (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.redo.len(), h.app.session.revision); + help::run(&mut h.app, &h.ctx, "report"); + h.frames(3); + let token = h.app.issue_report.pending.as_ref().unwrap().token; + receive(&mut h.app, token, Arc::new(ColorImage::new([2, 2], vec![egui::Color32::GREEN; 4]))); + h.frames(3); + h.ctx.memory_mut(|m| m.request_focus(egui::Id::new("issue_report_body"))); + h.step(&json!({"text":"report only"})).unwrap(); + h.step(&json!({"key":"Enter"})).unwrap(); + h.step(&json!({"key":"Escape"})).unwrap(); + assert_eq!(before, (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.redo.len(), h.app.session.revision)); + assert!(crate::dim_view::editing() && h.widget("12").is_some()); + h.step(&json!({"widget":"Close draft"})).unwrap(); + h.frames(2); + assert_eq!(before, (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.redo.len(), h.app.session.revision)); + assert!(crate::dim_view::editing() && h.widget("12").is_some()); + h.step(&json!({"key":"Enter"})).unwrap(); + assert!(!crate::dim_view::editing()); + assert_eq!(h.app.session.doc.param(¶m).unwrap().expr, "12"); + assert_eq!(h.app.session.undo.len(), before.1 + 1); + } + + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn issue_report_native_file_drop_cannot_import_behind_review() { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap(); + h.frames(3); + let tabs_before = h.app.docs.count(); + assert!(!h.app.file_load.busy()); + let before = (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.redo.len(), h.app.session.revision); + help::run(&mut h.app, &h.ctx, "report"); + let path = h.dir.join("private-design.json"); + std::fs::write(&path, h.app.session.doc.to_json()).unwrap(); + #[derive(Debug)] + struct File(std::path::PathBuf); + impl egui::DroppedFile for File { + fn path(&self) -> &std::path::Path { + &self.0 + } + fn bytes(&self) -> Result, String> { + std::fs::read(&self.0).map_err(|e| e.to_string()) + } + } + let raw = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0))), + focused: true, + dropped_files: vec![Arc::new(File(path))], + ..Default::default() + }; + let app = &mut h.app; + let mut output = h.ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + output.textures_delta.clear(); + assert_eq!(before, (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.redo.len(), h.app.session.revision)); + assert!(h.app.issue_report.draft.is_some()); + assert!(!h.app.file_load.busy(), "native drop must not queue an asynchronous open behind the draft"); + assert_eq!(h.app.docs.count(), tabs_before); + } + + #[test] + fn issue_report_capture_accepts_only_its_matching_unchanged_frame() { + let mut app = app(); + let ctx = egui::Context::default(); + begin(&mut app, &ctx, None, None); + let token = app.issue_report.pending.as_ref().unwrap().token; + let (reply, _receiver) = std::sync::mpsc::channel(); + app.pending_shots.push((token + 1, None, reply, crate::now_ms() + 8000.0)); + app.shot_token = token + 1; + let image = Arc::new(ColorImage::new([4, 3], vec![egui::Color32::GREEN; 12])); + receive(&mut app, token + 1, image.clone()); + assert!(pending(&app) && app.issue_report.draft.as_ref().unwrap().image.is_none()); + receive(&mut app, token, image.clone()); + assert!(!pending(&app)); + assert_eq!(app.issue_report.draft.as_ref().unwrap().image.as_ref().unwrap().size, [4, 3]); + assert_eq!(app.pending_shots.len(), 1, "report cannot consume the control request"); + begin(&mut app, &ctx, None, None); + let token = app.issue_report.pending.as_ref().unwrap().token; + app.cam.distance *= 2.0; + receive(&mut app, token, image); + let draft = app.issue_report.draft.as_ref().unwrap(); + assert!(draft.image.is_none() && draft.note.contains("changed")); + } + + #[test] + fn issue_report_capture_rejects_changed_inputs_timeout_and_malformed_images() { + let mut app = app(); + combine(&mut app); + let ctx = egui::Context::default(); + let dialog = app.dialog.clone(); + begin(&mut app, &ctx, dialog.as_ref(), Some("bad Boolean")); + app.dialog.as_mut().unwrap().extra.insert("direction".into(), json!("negative")); + tick(&mut app, &ctx); + assert!(!pending(&app)); + assert!(app.issue_report.draft.as_ref().unwrap().note.contains("changed")); + let dialog = app.dialog.clone(); + begin(&mut app, &ctx, dialog.as_ref(), None); + app.issue_report.pending.as_mut().unwrap().deadline = -1.0; + tick(&mut app, &ctx); + assert!(app.issue_report.draft.as_ref().unwrap().note.contains("No app frame")); + let dialog = app.dialog.clone(); + begin(&mut app, &ctx, dialog.as_ref(), None); + let token = app.issue_report.pending.as_ref().unwrap().token; + receive(&mut app, token, Arc::new(ColorImage::new([0, 0], vec![]))); + assert!(app.issue_report.draft.as_ref().unwrap().image.is_none()); + assert!(app.issue_report.draft.as_ref().unwrap().note.contains("dimensions")); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index dd9767b5..a93d03d1 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -25,6 +25,8 @@ pub mod dialogs_plastic; pub mod dialogs_sheet; pub mod dialogs_surface; pub mod dim_view; +#[cfg(test)] +mod dimension_create_ui_tests; pub mod documents; pub mod drag_snap; pub mod fonts; @@ -38,6 +40,7 @@ pub mod i18n; mod i18n_ui_tests; pub mod icons; pub mod inference; +pub mod issue_report; pub mod keymap; #[cfg(test)] mod menu_tests; @@ -98,6 +101,7 @@ pub mod toolbar; pub mod toolbar_custom; pub mod tools; pub mod viewport; +mod viewport_layout; pub mod workspace; use std::sync::mpsc::Receiver; @@ -209,6 +213,8 @@ pub struct SolveApp { pub keymap: keymap::Keymap, /// The Help menu and the About window. pub help: help::HelpState, + /// An editable local issue draft and optional captured app frame. + pub issue_report: issue_report::ReportState, /// Toolbar buttons the user pinned, removed or reordered. pub toolbar_custom: toolbar_custom::ToolbarCustom, /// Preferences kept between runs, and their window. @@ -247,6 +253,7 @@ pub struct SolveApp { shot_token: u64, styled: bool, fitted: bool, + pending_fit: Option<(Camera, solvecraft_engine::geom::Aabb3, bool)>, /// The start page. pub home: home::HomeState, pub file_load: file_load::FileLoad, @@ -275,6 +282,7 @@ impl SolveApp { sbox: shortcut_box::ShortcutBox::default(), keymap: keymap::Keymap::default(), help: help::HelpState::default(), + issue_report: issue_report::ReportState::default(), toolbar_custom: toolbar_custom::ToolbarCustom::default(), preferences: prefs::Prefs::default(), prefs_window: prefs::PrefsWindow::default(), @@ -300,6 +308,7 @@ impl SolveApp { shot_token: 0, styled: false, fitted: false, + pending_fit: None, home: home::HomeState::default(), file_load: file_load::FileLoad::default(), docs: documents::Documents::default(), @@ -433,6 +442,7 @@ impl SolveApp { Ok(_) => { self.session.echo(format!("{} done", command_label(id))); if matches!(id, "file.new" | "doc.open" | "doc.recover") { + dim_view::cancel(); self.menu.props = None; sketch_attachment_ui::clear(self); // The old document is already replaced: discard its editor without @@ -471,10 +481,11 @@ impl SolveApp { /// that have them, otherwise run it with defaults. pub fn start(&mut self, id: &str) { // A component moved without Capture Position: ask first (Capture, Revert or Cancel). - if browser::needs_capture(self, id) { + if browser::needs_capture(self, id) && !(id == "sketch.dimension" && self.session.active_sketch.is_some()) { self.tree.capture_prompt = Some(id.to_string()); return; } + dim_view::cancel(); self.tool = None; self.home.open = false; if matches!(id, "sketch.insert_dxf" | "sketch.insert_svg") { @@ -649,6 +660,7 @@ impl SolveApp { /// Finish the active sketch: stop editing, restore the history marker Edit Sketch moved and /// return to the view from before the sketch. pub fn finish_sketch(&mut self) { + dim_view::cancel(); self.tool = None; let _ = self.run("sketch.finish", json!({})); if let Some(marker) = self.pre_sketch_marker.take() { @@ -741,6 +753,8 @@ impl SolveApp { self.cam_anim = None; self.viewport.orbit_snap = None; self.viewport.orbit_free = None; + let bounds = solvecraft_engine::view::bounds(&self.session); + self.pending_fit = Some((self.cam, bounds, false)); self.cam = self.fitted(self.cam); } @@ -751,13 +765,39 @@ impl SolveApp { cam.target = solvecraft_engine::geom::Vec3::ZERO; cam.distance = 150.0; } else { - cam.fit(&b); + cam = self.fitted_bounds(cam, &b); } cam } + fn fitted_bounds(&self, mut cam: Camera, bounds: &solvecraft_engine::geom::Aabb3) -> Camera { + if let Some(layout) = &self.viewport.layout { + return viewport_layout::fit_bounds(cam, bounds, layout, self.ui.perspective).unwrap_or(cam); + } + let previous = cam; + cam.fit(bounds); + if cam.is_valid() { cam } else { previous } + } + + fn resolve_fit(&mut self, ctx: &egui::Context) { + viewport::refresh_layout(self, ctx.pixels_per_point()); + if self.viewport.layout.is_some() + && let Some((cam, bounds, animate)) = self.pending_fit.take() + { + let to = if bounds.is_empty() { self.fitted(cam) } else { self.fitted_bounds(cam, &bounds) }; + if animate { + self.animate_to(to); + } else { + self.cam = to; + self.cam_anim = None; + } + ctx.request_repaint(); + } + } + /// Move the camera to `to` over half a second (ease in-out, orientation slerped). pub fn animate_to(&mut self, to: Camera) { + self.pending_fit = None; self.viewport.orbit_snap = None; self.viewport.orbit_free = None; if !to.is_valid() { @@ -779,15 +819,23 @@ impl SolveApp { /// Animated view changes for the axis view and navigation bar. pub fn animate_view(&mut self, v: &str) { + self.pending_fit = None; let mut to = self.cam; match v { // With something selected, Fit frames the selection. "fit" => match viewport::selection_bounds(self) { - Some(b) => to.fit(&b), - None => to = self.fitted(to), + Some(b) => { + self.pending_fit = Some((to, b, true)); + to = self.fitted_bounds(to, &b); + } + None => { + self.pending_fit = Some((to, solvecraft_engine::view::bounds(&self.session), true)); + to = self.fitted(to); + } }, "home" => { to.set_view(StandardView::Iso); + self.pending_fit = Some((to, solvecraft_engine::view::bounds(&self.session), true)); to = self.fitted(to); } v => match StandardView::parse(v) { @@ -795,11 +843,14 @@ impl SolveApp { None => return, }, } + let request = self.pending_fit.take(); self.animate_to(to); + self.pending_fit = request; } /// Stop any view animation where it is (the user took over the camera). pub fn cancel_view_animation(&mut self) { + self.pending_fit = None; self.cam_anim = None; } @@ -838,14 +889,19 @@ impl SolveApp { if !self.synthetic.is_empty() { ctx.request_repaint(); } + issue_report::tick(self, ctx); self.collect_screenshots(ctx); self.issue_screenshots(ctx); - toolbar::shortcuts(self, ctx); + if self.issue_report.draft.is_none() { + toolbar::shortcuts(self, ctx); + } recovery_ui::tick(self); - for f in ctx.input(|i| i.raw.dropped_files.clone()) { - let p = f.path().to_string_lossy().to_string(); - if !p.is_empty() { - self.drop_path(&p); + if self.issue_report.draft.is_none() { + for f in ctx.input(|i| i.raw.dropped_files.clone()) { + let p = f.path().to_string_lossy().to_string(); + if !p.is_empty() { + self.drop_path(&p); + } } } } @@ -881,14 +937,26 @@ impl SolveApp { pub fn ui(&mut self, ui: &mut egui::Ui) { let t0 = now_ms(); let _language = crate::i18n::scope(&self.preferences.language); + let mut report_input = None; + issue_report::CadInput::block(self, ui, &mut report_input); + self.cad_ui(ui, &mut report_input); + drop(report_input); + issue_report::show(self, ui.ctx()); + if !self.home.open { + agent_cursor::paint(self, ui.ctx()); + } + self.frame_ms = now_ms() - t0; + } + + fn cad_ui(&mut self, ui: &mut egui::Ui, report_input: &mut Option) { scenario::clear_handles(); toolbar::app_bar(self, ui); + issue_report::CadInput::block(self, ui, report_input); file_load::show(self, ui.ctx()); documents::prompt(self, ui.ctx()); if self.home.open { home::show(self, ui); if self.home.open { - self.frame_ms = now_ms() - t0; return; } } @@ -905,7 +973,9 @@ impl SolveApp { viewport::show(self, ui); }); dialogs::show(self, ui.ctx()); + issue_report::CadInput::block(self, ui, report_input); sketch_palette::show(self, ui.ctx()); + self.resolve_fit(ui.ctx()); params_dialog::show(self, ui.ctx()); recovery_ui::show(self, ui.ctx()); stand_up::show(self, ui.ctx()); @@ -916,8 +986,6 @@ impl SolveApp { } shortcut_box::show(self, ui.ctx()); prefs::show(self, ui.ctx()); - agent_cursor::paint(self, ui.ctx()); - self.frame_ms = now_ms() - t0; } fn drain_control(&mut self, ctx: &egui::Context) { @@ -963,7 +1031,7 @@ impl SolveApp { } fn collect_screenshots(&mut self, ctx: &egui::Context) { - if self.pending_shots.is_empty() { + if self.pending_shots.is_empty() && !issue_report::pending(self) { return; } let events: Vec<_> = ctx.input(|i| { @@ -980,6 +1048,7 @@ impl SolveApp { .collect() }); for (token, image) in events { + issue_report::receive(self, token, image.clone()); if let Some(i) = self.pending_shots.iter().position(|(t, ..)| *t == token) { let (_, path, reply, _) = self.pending_shots.remove(i); let _ = reply.send(control::save_screenshot(&image, path.as_deref())); diff --git a/crates/ui-egui/src/params_dialog.rs b/crates/ui-egui/src/params_dialog.rs index c1887a2f..5954c98d 100644 --- a/crates/ui-egui/src/params_dialog.rs +++ b/crates/ui-egui/src/params_dialog.rs @@ -463,7 +463,7 @@ pub fn complete(ui: &mut egui::Ui, resp: &egui::Response, text: &mut String) -> } let mut pick: Option = if tab { list.first().map(|s| s.text.clone()) } else { None }; let t = Tokens::get(); - egui::Area::new(resp.id.with("completions")).order(egui::Order::Tooltip).fixed_pos(resp.rect.left_bottom() + egui::vec2(0.0, 2.0)).show( + crate::frame::area(resp.id.with("completions")).order(egui::Order::Tooltip).fixed_pos(resp.rect.left_bottom() + egui::vec2(0.0, 2.0)).show( &ctx, |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { diff --git a/crates/ui-egui/src/preview.rs b/crates/ui-egui/src/preview.rs index 97a01398..0d9fbef6 100644 --- a/crates/ui-egui/src/preview.rs +++ b/crates/ui-egui/src/preview.rs @@ -245,7 +245,7 @@ fn build_tool(before: &ModelState, after: &ModelState, c: Colors, op: &str) -> B } let edge = if op == "cut" { [c.removed[0], c.removed[1], c.removed[2], 255] } else { c.edge }; for (ei, e) in m.edges.iter().enumerate() { - if !m.seams.get(ei).copied().unwrap_or(false) { + if m.shaded_edge_visible(ei) { for w in e.windows(2) { sc.line(w[0].to_f32(), w[1].to_f32(), edge, 1.3, op != "join"); } @@ -306,7 +306,7 @@ pub fn build(before: &ModelState, after: &ModelState, c: Colors) -> Built { } } for (ei, e) in am.edges.iter().enumerate() { - if am.seams.get(ei).copied().unwrap_or(false) { + if !am.shaded_edge_visible(ei) { continue; } for w in e.windows(2) { diff --git a/crates/ui-egui/src/shortcut_box.rs b/crates/ui-egui/src/shortcut_box.rs index 380e9d58..5d690cac 100644 --- a/crates/ui-egui/src/shortcut_box.rs +++ b/crates/ui-egui/src/shortcut_box.rs @@ -158,7 +158,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { at.x.clamp(screen.left() + 4.0, (screen.right() - W - 4.0).max(screen.left())), at.y.clamp(screen.top() + 4.0, (screen.bottom() - h).max(screen.top())), ); - let resp = egui::Area::new(egui::Id::new("sc_sbox")).fixed_pos(pos).order(egui::Order::Foreground).show(ctx, |ui| { + let resp = crate::frame::area(egui::Id::new("sc_sbox")).fixed_pos(pos).order(egui::Order::Foreground).show(ctx, |ui| { crate::context_menu::menu_frame(ui, |ui| { ui.set_width(W - 10.0); // Pinned commands: a mini toolbar. diff --git a/crates/ui-egui/src/sketch_dims.rs b/crates/ui-egui/src/sketch_dims.rs index 38c3bf39..a2aa4965 100644 --- a/crates/ui-egui/src/sketch_dims.rs +++ b/crates/ui-egui/src/sketch_dims.rs @@ -323,7 +323,7 @@ fn boxes(app: &SolveApp, ui: &mut egui::Ui, proj: &Proj, t: &mut Tool) -> Option s.store(ui.ctx(), id); } let pos: Pos2 = sp + vec2(-34.0, -11.0); - egui::Area::new(egui::Id::new(("sc_dim_area", i))).fixed_pos(pos).order(egui::Order::Foreground).show(ui.ctx(), |ui| { + crate::frame::area(egui::Id::new(("sc_dim_area", i))).fixed_pos(pos).order(egui::Order::Foreground).show(ui.ctx(), |ui| { let fill = if b.locked { tk.accent_soft } else { tk.panel }; egui::Frame::popup(ui.style()).fill(fill).inner_margin(egui::Margin::symmetric(3, 1)).show(ui, |ui| { let r = ui diff --git a/crates/ui-egui/src/sketch_display_ui_tests.rs b/crates/ui-egui/src/sketch_display_ui_tests.rs index c9e5a7c2..f20f5727 100644 --- a/crates/ui-egui/src/sketch_display_ui_tests.rs +++ b/crates/ui-egui/src/sketch_display_ui_tests.rs @@ -90,6 +90,11 @@ fn pixel_cache_full_view_resize_and_hidpi_invalidate_within_same_grid_bucket() { let mut h = Harness::new(); h.app.run("solid.box", json!({"corner":[-0.1,-0.1,-0.2],"length":0.2,"width":0.2,"height":0.2,"body_name":"CachedStock"})).unwrap(); let sid = circle(&mut h); + // This cache test needs a controlled projected radius, independent of floating controls + // used by Fit. At this radius, doubling physical scale crosses a subdivision threshold. + h.app.cancel_view_animation(); + h.app.cam.distance = 5.0; + h.app.cam.target = Vec3::ZERO; let baseline = h.app.session.doc.to_json(); let history = (h.app.session.undo.len(), h.app.session.redo.len()); let state = h.app.session.world_state(); diff --git a/crates/ui-egui/src/sketch_palette.rs b/crates/ui-egui/src/sketch_palette.rs index 07123169..f22dcae1 100644 --- a/crates/ui-egui/src/sketch_palette.rs +++ b/crates/ui-egui/src/sketch_palette.rs @@ -167,6 +167,7 @@ pub fn set_linetype(app: &mut SolveApp, kind: u8) { /// Draw the palette (while sketching and no command dialog is open). pub fn show(app: &mut SolveApp, ctx: &egui::Context) { + app.viewport.palette_rect = None; let _language = crate::i18n::scope(&app.preferences.language); if app.dialog.is_some() { return; @@ -204,7 +205,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { let mut look = false; let mut finish = false; let has_curves = !selected_curves(app).is_empty(); - crate::frame::docked( + let response = crate::frame::docked( app.tr("ui.heading_sketch_palette"), egui::Id::new("sc_sketch_palette"), anchor, @@ -213,8 +214,9 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { ) .frame(frame) .show(ctx, |ui| { - let width = WIDTH.min((vp.width() - 24.0).max(120.0)); + let width = WIDTH.min((vp.width() * 0.46 - 16.0).max(120.0)); ui.set_width(width); + ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap); ui.horizontal(|ui| { let folded = FOLDED.with(Cell::get); if ui.add(egui::Button::new(RichText::new(if folded { "+" } else { "−" }).size(14.0)).frame(false)).clicked() { @@ -236,7 +238,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { let options_height = (bottom - ui.cursor().top() - 68.0).max(24.0); let options = egui::ScrollArea::vertical().id_salt("sc_palette_options").max_height(options_height).auto_shrink([false, true]).show(ui, |ui| { - egui::Grid::new("sc_palette_grid").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { + ui.vertical(|ui| { ui.label(app.tr("ui.linetype")); ui.horizontal(|ui| { // With a selection: convert it; without: the mode new curves are drawn in. @@ -254,21 +256,18 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { } } }); - ui.end_row(); ui.label(app.tr("ui.look_at")); if ui.button(app.tr("ui.look_at")).clicked() { look = true; } - ui.end_row(); - for (k, label) in TOGGLES { - // The label is part of the checkbox: clicking it toggles too. - let mut on = shown(k); - if ui.checkbox(&mut on, app.tr_label(label)).changed() { - set = Some((k.to_string(), on)); - } - ui.end_row(); - } }); + for (k, label) in TOGGLES { + // The label is part of the checkbox: clicking it toggles too. + let mut on = shown(k); + if ui.checkbox(&mut on, app.tr_label(label)).changed() { + set = Some((k.to_string(), on)); + } + } }); crate::scenario::publish_rect("sketch_palette:options", options.inner_rect); } @@ -284,6 +283,10 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { } }); }); + app.viewport.palette_rect = response.map(|r| r.response.rect); + if let Some(r) = app.viewport.palette_rect { + crate::scenario::publish_rect("sketch_palette:outer", r); + } if let Some((k, on)) = set { let mut p = serde_json::Map::new(); p.insert(k, Value::Bool(on)); diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index e77c0327..3cbe3329 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -148,7 +148,7 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) { return; } let mut close = false; - let resp = egui::Area::new(egui::Id::new("sc_file_area")) + let resp = crate::frame::area(egui::Id::new("sc_file_area")) .fixed_pos(pos2(100.0 + if app.integrated_titlebar { crate::titlebar::TRAFFIC_LIGHTS } else { 0.0 }, 34.0)) .order(egui::Order::Foreground) .show(ctx, |ui| { @@ -432,7 +432,7 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) { } let open = ui.ctx().data(|d| d.get_temp::(egui::Id::new("sc_panel_open"))) == Some(popup_id); if open { - let area = egui::Area::new(popup_id).fixed_pos(lr.left_bottom()).order(egui::Order::Foreground).show(ui.ctx(), |ui| { + let area = crate::frame::area(popup_id).fixed_pos(lr.left_bottom()).order(egui::Order::Foreground).show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { ui.set_min_width(200.0); if *panel == "SELECT" { diff --git a/crates/ui-egui/src/tools.rs b/crates/ui-egui/src/tools.rs index f3dfa581..167c5af1 100644 --- a/crates/ui-egui/src/tools.rs +++ b/crates/ui-egui/src/tools.rs @@ -132,6 +132,11 @@ pub fn on_hover(app: &mut SolveApp, proj: &Proj, pos: Pos2) { } pub fn on_click(app: &mut SolveApp, proj: &Proj, pos: Pos2) { + // A click away dismisses the inline editor; it must not also pick the first entity of + // the next dimension before that cancellation has been processed. + if crate::dim_view::editing() { + return; + } let Some(mut tool) = app.tool.take() else { return }; // In a 3D sketch the drawing tools take points off the plane. if crate::sketch3d::on_click(app, &mut tool, proj, pos) { @@ -199,12 +204,11 @@ pub fn on_click(app: &mut SolveApp, proj: &Proj, pos: Pos2) { } } else if !tool.picks.is_empty() && let Some((at, _)) = sketch_point_at(app, proj, pos) - && let Ok(v) = app.run("sketch.dimension", json!({"entities": tool.picks, "text_at": [at.x, at.y]})) { - if let Some(p) = v["param"].as_str() { - crate::dim_view::edit_param(app, p); + match crate::dim_view::begin_dimension(app, tool.picks.clone(), at) { + Ok(()) => tool.picks.clear(), + Err(error) => app.set_status(error, true), } - tool.picks.clear(); } } } @@ -333,6 +337,7 @@ fn run_pick(app: &mut SolveApp, t: &Tool) { /// Right click / Esc: end the current chain (line) or cancel the tool. pub fn finish(app: &mut SolveApp) { + crate::dim_view::cancel(); if crate::sketch3d::finish(app) { return; } diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index d75e4dec..15f9bcd6 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -33,6 +33,9 @@ pub struct ViewportState { /// The last pick (its key and hits): reused while nothing it depends on changed. last_pick: Option<(u64, Vec)>, pub rect: Option, + pub(crate) layout: Option, + pub(crate) palette_rect: Option, + pub(crate) dialog_rect: Option, pub gpu: Option, slot: SceneSlot, key: u64, @@ -339,7 +342,7 @@ fn build_scene(app: &SolveApp) -> GpuScene { } } for (ei, e) in m.edges.iter().enumerate().filter(|_| edges) { - if m.seams.get(ei).copied().unwrap_or(false) { + if !m.edge_pickable(ei) || (shaded && !m.shaded_edge_visible(ei)) { continue; } for w in e.windows(2) { @@ -790,7 +793,7 @@ pub fn pick(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec { } let m = b.mesh(); for (ei, e) in m.edges.iter().enumerate() { - if m.seams.get(ei).copied().unwrap_or(false) { + if !m.edge_pickable(ei) { continue; } for v in [e.first(), e.last()].into_iter().flatten() { @@ -1116,7 +1119,7 @@ fn edge_lines(sc: &mut GpuScene, e: &[Vec3], core: Color32, halo: Color32, w: f3 fn hidden_face_outline(sc: &mut GpuScene, m: &solvecraft_engine::geom::Mesh, face: usize, col: Color32) { for (ei, e) in m.edges.iter().enumerate() { let on = m.edge_faces.get(ei).is_some_and(|f| f.iter().any(|x| *x as usize == face)); - if on && !m.seams.get(ei).copied().unwrap_or(false) { + if on && m.edge_pickable(ei) { for p in e.windows(2) { sc.hidden_line(p[0].to_f32(), p[1].to_f32(), c4(col.gamma_multiply(0.45)), 1.4); } @@ -1341,9 +1344,7 @@ fn build_highlight(app: &SolveApp) -> GpuScene { } } for (ei, e) in m.edges.iter().enumerate() { - if !m.seams.get(ei).copied().unwrap_or(false) - && (whole || m.edge_faces.get(ei).is_some_and(|fs| fs.iter().any(|f| faces.contains(&(*f as usize))))) - { + if m.edge_pickable(ei) && (whole || m.edge_faces.get(ei).is_some_and(|fs| fs.iter().any(|f| faces.contains(&(*f as usize))))) { edge_lines(&mut sc, e, t.hover_edge, t.hover_edge_halo, 1.5); } } @@ -1375,7 +1376,7 @@ fn build_highlight(app: &SolveApp) -> GpuScene { } for (e, _) in m.edge_faces.iter().enumerate().filter(|(_, fs)| fs.iter().any(|f| patch.contains(&(*f as usize)))) { if let Some(p) = m.edges.get(e) - && !m.seams.get(e).copied().unwrap_or(false) + && m.edge_pickable(e) { edge_lines(&mut sc, p, t.hover_edge, t.hover_edge_halo, 1.5); } @@ -1474,6 +1475,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { let t = Tokens::get(); let rect = ui.max_rect(); app.viewport.rect = Some(rect); + refresh_layout(app, ui.ctx().pixels_per_point()); let resp = ui.interact(rect, ui.id().with("viewport"), Sense::click_and_drag()); let (hover, scroll, mods, middle, secondary_down, primary_down, delta) = ui.input(|i| { ( @@ -1489,7 +1491,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { // The right button belongs to the marking menu while it is open. let secondary_down = secondary_down && app.viewport.context_menu.is_none(); // The axis view handles its own clicks; the model underneath must not see them. - let axes = axis_rect(rect); + let axes = axis_rect(app, rect); // Floating windows and menus own input over the model, including wheel navigation. let inside = resp.contains_pointer() && hover.is_some_and(|p| !axes.contains(p)); app.viewport.mouse = hover.filter(|_| inside); @@ -1556,7 +1558,12 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { } if app.viewport.spin { // A click on the navigation bar (its own button) is not a stop. - let bar = Rect::from_center_size(pos2(rect.center().x, rect.bottom() - 22.0), vec2(320.0, 30.0)); + let bar = app + .viewport + .layout + .as_ref() + .map(|l| l.navigation) + .unwrap_or_else(|| Rect::from_center_size(pos2(rect.center().x, rect.bottom() - 22.0), vec2(320.0, 30.0))); let (pressed, key, dt) = ui.input(|i| { ( i.pointer.any_pressed() && inside && !i.pointer.press_origin().is_some_and(|p| bar.contains(p)), @@ -2057,7 +2064,7 @@ fn box_select(app: &mut SolveApp, proj: &Proj, bx: BoxSel, add: bool) { for (ei, e) in m.edges.iter().enumerate() { let rim = m.circular_rim(ei); let takes = if bx.crossing { bx.takes(&to2(e)) } else { rim.iter().all(|&i| m.edges.get(i).is_some_and(|p| bx.takes(&to2(p)))) }; - if !m.seams.get(ei).copied().unwrap_or(false) && takes { + if m.edge_pickable(ei) && takes { out.push(Sel::Edge { body: b.name.clone(), index: ei, point: polyline_mid(e) }); } } @@ -2257,22 +2264,39 @@ fn cpu_render(app: &mut SolveApp, ctx: &egui::Context, painter: &egui::Painter, } } +pub(crate) fn refresh_layout(app: &mut SolveApp, scale: f32) { + if let Some(rect) = app.viewport.rect { + let docks: Vec<_> = [ + app.viewport.palette_rect.filter(|_| app.session.active_sketch.is_some() && app.dialog.is_none()), + app.viewport.dialog_rect.filter(|_| app.dialog.is_some()), + ] + .into_iter() + .flatten() + .collect(); + app.viewport.layout = Some(crate::viewport_layout::Layout::new(rect, &docks, scale)); + } +} + /// Compact orientation gizmo, drawn and hit-tested in world coordinates. const AXIS_LENGTH: f64 = 38.0; const AXIS_RADIUS: f32 = 11.0; -fn axis_center(rect: Rect) -> Pos2 { - pos2(rect.right() - 68.0, rect.top() + 68.0) +fn axis_center(app: &SolveApp, rect: Rect) -> Pos2 { + axis_rect(app, rect).center() } -fn axis_rect(rect: Rect) -> Rect { - Rect::from_center_size(axis_center(rect), vec2(116.0, 116.0)) +fn axis_rect(app: &SolveApp, rect: Rect) -> Rect { + app.viewport + .layout + .as_ref() + .filter(|l| l.viewport == rect) + .map(|l| l.axis_view) + .unwrap_or_else(|| Rect::from_center_size(pos2(rect.right() - 68.0, rect.top() + 68.0), vec2(116.0, 116.0))) } /// Six signed world axes, sorted back to front so overlapping tips have one owner. -fn axis_tips(cam: &Camera, rect: Rect) -> Vec<(&'static str, Vec3, Pos2, f64)> { +fn axis_tips(cam: &Camera, c: Pos2) -> Vec<(&'static str, Vec3, Pos2, f64)> { let (r, u, b) = cam.basis(); - let c = axis_center(rect); let mut tips: Vec<_> = [("x", Vec3::X), ("-x", -Vec3::X), ("y", Vec3::Y), ("-y", -Vec3::Y), ("z", Vec3::Z), ("-z", -Vec3::Z)] .into_iter() .map(|(name, d)| (name, d, c + vec2((d.dot(r) * AXIS_LENGTH) as f32, -(d.dot(u) * AXIS_LENGTH) as f32), d.dot(b))) @@ -2386,7 +2410,7 @@ fn axis_view_orbit(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) -> bool { (p.primary_pressed(), p.primary_down(), p.is_decidedly_dragging(), p.press_origin(), p.interact_pos(), i.modifiers.shift) }); if pressed && ui.rect_contains_pointer(rect) { - app.viewport.axis_drag = origin.filter(|p| axis_rect(rect).contains(*p)); + app.viewport.axis_drag = origin.filter(|p| axis_rect(app, rect).contains(*p)); if app.viewport.axis_drag.is_some() { app.viewport.orbit_free = None; } @@ -2406,14 +2430,20 @@ fn axis_view_orbit(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) -> bool { } fn axis_view(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { + if !axis_rect(app, rect).is_positive() { + app.viewport.axis_drag = None; + crate::scenario::publish_count("axis_view_highlights", 0.0); + return; + } + crate::scenario::publish_rect("axis_view:outer", axis_rect(app, rect)); let t = Tokens::get(); - let c = axis_center(rect); + let c = axis_center(app, rect); let orbiting = axis_view_orbit(app, ui, rect); - let tips = axis_tips(&app.cam, rect); + let tips = axis_tips(&app.cam, c); let hover = ui.input(|i| i.pointer.hover_pos()).filter(|_| ui.rect_contains_pointer(rect)); let hovered = if orbiting { None } else { hover.and_then(|p| axis_hit(&tips, p)) }; let painter = ui.painter_at(rect); - if orbiting || hover.is_some_and(|p| axis_rect(rect).contains(p)) { + if orbiting || hover.is_some_and(|p| axis_rect(app, rect).contains(p)) { painter.circle_filled(c, 55.0, t.overlay); } for (i, (name, d, at, depth)) in tips.iter().enumerate() { @@ -2462,7 +2492,9 @@ fn axis_view(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { } } } - let home = Rect::from_center_size(pos2(c.x - 46.0, c.y - 54.0), vec2(18.0, 18.0)); + let home = Rect::from_center_size(pos2(c.x - 44.0, c.y - 44.0), vec2(18.0, 18.0)); + crate::scenario::publish_rect("axis_view:home", home); + crate::scenario::publish_handle("axis_view:home", home.center()); let hr = ui.interact(home, ui.id().with("axis_view_home"), Sense::click()); icons::paint(&painter, home, "home", t.icon, if hr.hovered() { t.accent_soft } else { t.overlay }, t.accent); if hr.on_hover_text("Home view").clicked() { @@ -2508,6 +2540,9 @@ pub fn look_at_selection(app: &mut SolveApp) { } fn nav_bar(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { + if app.viewport.layout.as_ref().is_some_and(|l| !l.navigation.is_positive()) { + return; + } let t = Tokens::get(); let items: [(&str, &str); 10] = [ ("orbit", "Orbit (right drag; hold Shift to snap)"), @@ -2524,11 +2559,21 @@ fn nav_bar(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { let menu_id = ui.id().with("nav_view_menu"); let mut menu_open = ui.data(|d| d.get_temp::(menu_id)).unwrap_or(false); let w = items.len() as f32 * 30.0 + 10.0; - let bar = Rect::from_center_size(pos2(rect.center().x, rect.bottom() - 22.0), vec2(w, 30.0)); + let bar = app + .viewport + .layout + .as_ref() + .map(|l| l.navigation) + .unwrap_or_else(|| Rect::from_center_size(pos2(rect.center().x, rect.bottom() - 22.0), vec2(w, 30.0))); + crate::scenario::publish_rect("nav:outer", bar); let painter = ui.painter_at(rect); painter.rect(bar, 6.0, t.overlay, Stroke::new(1.0, t.border), egui::StrokeKind::Inside); + let columns = ((bar.width() - 10.0) / 30.0).round().clamp(1.0, 10.0) as usize; for (i, (icon, tip)) in items.iter().enumerate() { - let br = Rect::from_min_size(pos2(bar.left() + 5.0 + i as f32 * 30.0, bar.top() + 2.0), vec2(26.0, 26.0)); + let br = Rect::from_min_size( + pos2(bar.left() + 5.0 + (i % columns) as f32 * 30.0, bar.top() + 2.0 + (i / columns) as f32 * 30.0), + vec2(26.0, 26.0), + ); let resp = ui.interact(br, ui.id().with(("nav", *icon)), Sense::click()); let active = match *icon { "orbit" => app.viewport.nav == Some(NavMode::Orbit), @@ -2574,7 +2619,7 @@ fn nav_bar(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { /// The View settings above the navigation bar's last button: grid, ground shadow and the agent /// cursor (#35). False once it should close (a click outside it). fn view_menu(app: &mut SolveApp, ui: &mut egui::Ui, button: Rect) -> bool { - let area = egui::Area::new(egui::Id::new("sc_view_menu")) + let area = crate::frame::area(egui::Id::new("sc_view_menu")) .order(egui::Order::Foreground) .pivot(Align2::RIGHT_BOTTOM) .fixed_pos(button.right_top() - vec2(0.0, 6.0)); @@ -2731,12 +2776,14 @@ mod tests { fn hover_frame(app: &mut SolveApp, ctx: &egui::Context, events: Vec) { let selected = app.highlighted(); - let _ = ctx.run_ui( + let mut output = ctx.run_ui( egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), events, ..Default::default() }, |ui| { show(app, ui); }, ); + // This headless frame checks interactions without applying textures to a renderer. + output.textures_delta.clear(); // Click selection is applied after rendering; hover itself must already be current. if app.viewport.gpu.is_some() && selected == app.highlighted() { assert_eq!(app.viewport.hl_key, highlight_key(app), "GPU highlight must use this frame's hover"); @@ -2819,6 +2866,126 @@ mod tests { app } + #[test] + fn smooth_wall_display_preserves_wireframe_and_edge_selection() { + let mut s = solvecraft_engine::Session::default(); + let sketch = s.execute("sketch.create", &json!({"plane":"XY"})).unwrap()["sketch"].as_u64().unwrap(); + s.execute("sketch.ellipse", &json!({"center":[0,0],"major":[25,0],"minor_radius":5})).unwrap(); + let inner = + s.execute("sketch.offset", &json!({"curves":["e1"],"distance":-1,"chain":false})).unwrap()["curves"][0].as_str().unwrap().to_string(); + let outer = + s.execute("sketch.offset", &json!({"curves":[inner],"distance":-0.5,"chain":false})).unwrap()["curves"][0].as_str().unwrap().to_string(); + s.execute( + "solid.extrude", + &json!({"sketch":sketch,"distance":3,"profiles":{"type":"whole_boundaries","profiles":[{"outer":[outer],"holes":[[inner]]}]}}), + ) + .unwrap(); + let mut app = SolveApp::new(s, crate::Services::default()); + app.ui.ground_shadow = false; + let st = app.session.world_state(); + let b = st.bodies.first().unwrap(); + let mesh = b.mesh(); + assert_eq!(mesh.edges.len(), 1536); + assert_eq!(mesh.smooth_edges.iter().filter(|f| **f).count(), 512); + let smooth = mesh.smooth_edges.iter().position(|f| *f).unwrap(); + let expected = mesh.edges.iter().enumerate().filter(|(i, _)| mesh.shaded_edge_visible(*i)).map(|(_, e)| e.len() - 1).sum::(); + let shaded = build_scene(&app); + assert_eq!(shaded.lines.len() / crate::gpu::LINE_SIZE, expected); + assert!(!shaded.tris.is_empty()); + app.ui.visual_style = 2; + let wire = build_scene(&app); + let expected_wire = + mesh.edges.iter().enumerate().filter(|(i, _)| !mesh.seams.get(*i).copied().unwrap_or(false)).map(|(_, e)| e.len() - 1).sum::(); + assert_eq!(wire.lines.len() / crate::gpu::LINE_SIZE, expected_wire); + assert!(expected_wire > expected); + assert!(wire.tris.is_empty()); + app.ui.visual_style = 0; + app.session.selection = vec![Sel::Edge { body: b.name.clone(), index: smooth, point: polyline_mid(&mesh.edges[smooth]) }]; + assert!(!build_highlight(&app).lines.is_empty()); + let mut without_display_mask = (*mesh).clone(); + without_display_mask.smooth_edges.clear(); + assert_eq!(mesh.surface_patch(mesh.edge_faces[smooth][0] as usize), without_display_mask.surface_patch(mesh.edge_faces[smooth][0] as usize)); + let cam = solvecraft_engine::view::home_camera(&app.session); + let cpu = solvecraft_engine::view::scene(&app.session, &cam); + let actual_edges = cpu.lines.iter().filter(|line| line.color == solvecraft_engine::view::colors::EDGE).count(); + assert_eq!(actual_edges, mesh.edges.iter().enumerate().filter(|(i, _)| mesh.shaded_edge_visible(*i)).count()); + if let Ok(directory) = std::env::var("SOLVECRAFT_SMOOTH_SCREENSHOTS") { + std::fs::create_dir_all(&directory).unwrap(); + for (name, old_edges, wireframe) in [("after", false, false), ("before", true, false), ("wireframe", true, true)] { + let mut scene = solvecraft_engine::view::scene(&app.session, &cam); + scene.lines.retain(|line| line.color == solvecraft_engine::view::colors::EDGE); + if old_edges { + scene.lines.clear(); + for (i, edge) in mesh.edges.iter().enumerate() { + if mesh.edge_pickable(i) { + scene.lines.push(solvecraft_engine::render::SceneLine { + points: edge.clone(), + color: solvecraft_engine::view::colors::EDGE, + width: 1.2, + on_top: false, + }); + } + } + } + if wireframe { + scene.meshes.clear(); + } + let png = solvecraft_engine::render::render_png(&scene, &cam, 1200, 800).unwrap(); + std::fs::write(std::path::Path::new(&directory).join(format!("smooth-ring-{name}.png")), png).unwrap(); + } + } + } + + #[test] + fn certified_small_crease_overrides_old_seam_fit_for_picking_and_patches() { + let mut s = solvecraft_engine::Session::default(); + let sketch = s.execute("sketch.create", &json!({"plane":"XY"})).unwrap()["sketch"].as_u64().unwrap(); + let step = std::f64::consts::TAU / 8.; + let k = 4. / 3. * (step / 4.).tan() * 10.; + for i in 0..8 { + let a = i as f64 * step; + let b = (i + 1) as f64 * step; + let p0 = [10. * a.cos(), 10. * a.sin()]; + let p3 = if i == 7 { [10., 0.] } else { [10. * b.cos(), 10. * b.sin()] }; + let mut p1 = [p0[0] - k * a.sin(), p0[1] + k * a.cos()]; + let mut p2 = [p3[0] + k * b.sin(), p3[1] - k * b.cos()]; + if i == 0 { + p1[0] += k * 1e-6; + p2[0] += k * 1e-6 * b.cos(); + p2[1] += k * 1e-6 * b.sin(); + } + s.execute("sketch.spline.control_point", &json!({"points":[p0,p1,p2,p3]})).unwrap(); + } + s.execute("solid.extrude", &json!({"sketch":sketch,"distance":3})).unwrap(); + let mut app = SolveApp::new(s, crate::Services::default()); + let st = app.session.world_state(); + let body = st.bodies.first().unwrap(); + let mesh = body.mesh(); + let crease = mesh + .edges + .iter() + .position(|line| { + line.first().zip(line.last()).is_some_and(|(a, b)| { + (a.x - 10.).abs() < 1e-8 && a.y.abs() < 1e-8 && (b.x - 10.).abs() < 1e-8 && b.y.abs() < 1e-8 && (a.z - b.z).abs() > 2.9 + }) + }) + .unwrap(); + assert!(mesh.seams[crease], "the fixture must exercise the coarse seam false positive"); + assert!(mesh.sharp_edges[crease]); + assert!(mesh.shaded_edge_visible(crease)); + assert!(mesh.edge_pickable(crease)); + assert!(mesh.edge_faces[crease].iter().any(|f| mesh.surface_patch(*f as usize).len() == 1)); + let mid = polyline_mid(&mesh.edges[crease]); + app.cam = app.cam.looking_from(Vec3::new(30., 0., 1.5)); + app.cam.target = Vec3::new(0., 0., 1.5); + for perspective in [false, true] { + app.ui.perspective = perspective; + let proj = projection(&app, Rect::from_min_size(pos2(0., 0.), vec2(800., 600.))); + let hits = pick(&app, &proj, proj.to_screen(mid).unwrap()); + assert!(hits.iter().any(|hit| matches!(hit,Hit::Edge{index,..} if *index==crease)), "{hits:?}"); + } + } + #[test] fn circular_items_measure_as_whole_rims_and_surfaces() { let mut app = cylinder_app(); @@ -2963,7 +3130,7 @@ mod tests { phase: egui::TouchPhase::Move, }); } - let _ = ctx.run_ui( + let mut output = ctx.run_ui( egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1100.0, 800.0))), time: Some(time), @@ -2975,6 +3142,7 @@ mod tests { crate::prefs::show(app, ui.ctx()); }, ); + output.textures_delta.clear(); }; // Let egui establish the floating window's size and hit-test widgets. for _ in 0..4 { @@ -3240,7 +3408,7 @@ mod tests { h.call("ui.view", json!({"view":"front"})); let before = h.app.cam; h.call("ui.drag", json!({"x0":800,"y0":500,"x1":1020,"y1":500,"button":"middle","shift":true,"steps":40})); - assert!((h.app.cam.yaw - std::f64::consts::FRAC_PI_2).abs() < 1e-6); + assert!((h.app.cam.yaw - std::f64::consts::FRAC_PI_2).abs() < 1e-6, "before={before:?}, after={:?}", h.app.cam); h.call("ui.view", json!({"view":"front"})); h.call("ui.drag", json!({"x0":800,"y0":500,"x1":850,"y1":520,"button":"middle"})); assert_eq!(h.app.cam.yaw, before.yaw); @@ -3260,7 +3428,7 @@ mod tests { {"call":"ui.view","params":{"view":"front"}} ])) .unwrap(); - let c = axis_center(h.app.viewport.rect.unwrap()); + let c = axis_center(&h.app, h.app.viewport.rect.unwrap()); h.call("ui.drag", json!({"x0":c.x,"y0":c.y,"x1":c.x+220.0,"y1":c.y,"shift":true,"steps":40})); assert!((h.app.cam.yaw - std::f64::consts::FRAC_PI_2).abs() < 1e-6); assert!(h.app.viewport.axis_drag.is_none()); diff --git a/crates/ui-egui/src/viewport_layout.rs b/crates/ui-egui/src/viewport_layout.rs new file mode 100644 index 00000000..69c16a9c --- /dev/null +++ b/crates/ui-egui/src/viewport_layout.rs @@ -0,0 +1,130 @@ +//! Floating controls reserve drawing space without changing rendering or picking projection. +use egui::{Rect, pos2, vec2}; +use solvecraft_engine::geom::{Aabb3, Vec3}; +use solvecraft_engine::render::Camera; + +#[derive(Clone, Debug)] +pub(crate) struct Layout { + pub viewport: Rect, + pub drawable: Rect, + pub axis_view: Rect, + pub navigation: Rect, +} + +/// Enumerate rectangles whose edges meet the viewport or a bounded number of overlays. +fn free_rect(view: Rect, obstacles: &[Rect]) -> Rect { + let mut xs = vec![view.left(), view.right()]; + let mut ys = vec![view.top(), view.bottom()]; + let obstacles: Vec<_> = obstacles.iter().take(8).map(|r| r.intersect(view)).filter(|r| r.is_positive() && r.is_finite()).collect(); + for r in &obstacles { + xs.extend([r.left(), r.right()]); + ys.extend([r.top(), r.bottom()]); + } + xs.sort_by(f32::total_cmp); + ys.sort_by(f32::total_cmp); + let mut best = Rect::from_min_size(view.min, vec2(0.0, 0.0)); + for (i, &left) in xs.iter().enumerate() { + for &right in xs.iter().skip(i + 1) { + for (j, &top) in ys.iter().enumerate() { + for &bottom in ys.iter().skip(j + 1) { + let r = Rect::from_min_max(pos2(left, top), pos2(right, bottom)); + if r.area() > best.area().max(0.0) && !obstacles.iter().any(|o| r.intersect(*o).is_positive()) { + best = r; + } + } + } + } + } + best +} + +impl Layout { + pub fn new(viewport: Rect, docks: &[Rect], scale: f32) -> Self { + let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 }; + let mut obstacles: Vec<_> = docks.iter().take(6).copied().filter(|r| r.is_finite()).map(|r| r.expand(4.0)).collect(); + let available = free_rect(viewport, &obstacles); + let axis_view = if available.width() >= 116.0 && available.height() >= 116.0 { + Rect::from_min_size(pos2(available.right() - 116.0, available.top()), vec2(116.0, 116.0)) + } else { + Rect::NOTHING + }; + if axis_view.is_positive() { + obstacles.push(axis_view); + } + let nav_space = free_rect(viewport, &obstacles); + let columns = ((nav_space.width() - 10.0) / 30.0).floor().clamp(1.0, 10.0); + let nav_height = (10.0 / columns).ceil() * 30.0; + let navigation = if nav_space.width() >= columns * 30.0 + 10.0 && nav_space.height() >= nav_height + 7.0 { + Rect::from_center_size(pos2(nav_space.center().x, nav_space.bottom() - nav_height * 0.5 - 7.0), vec2(columns * 30.0 + 10.0, nav_height)) + } else { + Rect::NOTHING + }; + if navigation.is_positive() { + obstacles.push(navigation); + } + let drawable = free_rect(viewport, &obstacles).shrink(16.0 / scale); + Self { viewport, drawable, axis_view, navigation } + } +} + +pub(crate) fn fit_bounds(mut cam: Camera, bounds: &Aabb3, layout: &Layout, perspective: bool) -> Option { + if bounds.is_empty() + || !bounds.min.is_finite() + || !bounds.max.is_finite() + || !cam.is_valid() + || !layout.viewport.is_positive() + || !layout.drawable.is_positive() + || !layout.drawable.is_finite() + { + return None; + } + let original_fov = cam.fov; + cam.fov = if perspective { 0.6 } else { 0.0 }; + let (r, u, b) = cam.basis(); + let centre = bounds.center(); + let mut hx: f64 = 0.0; + let mut hy: f64 = 0.0; + let mut depth: f64 = 0.0; + let corners: Vec<_> = [bounds.min.x, bounds.max.x] + .into_iter() + .flat_map(|x| { + [bounds.min.y, bounds.max.y].into_iter().flat_map(move |y| [bounds.min.z, bounds.max.z].into_iter().map(move |z| Vec3::new(x, y, z))) + }) + .collect(); + for p in &corners { + let d = *p - centre; + hx = hx.max(d.dot(r).abs()); + hy = hy.max(d.dot(u).abs()); + depth = depth.max(d.dot(b).abs()); + } + let h = f64::from(layout.viewport.height()); + let hh = (hx * h / f64::from(layout.drawable.width())).max(hy * h / f64::from(layout.drawable.height())).max(0.5); + let offset = layout.drawable.center() - layout.viewport.center(); + let tangent = if perspective { (0.3_f64).tan() } else { 0.4 }; + cam.distance = hh / tangent + if perspective { depth } else { 0.0 }; + // Offset the target so the model projects into the free rectangle in the original viewport. + // Perspective needs a bounded correction because corners have different depths. + for _ in 0..16 { + let k = 2.0 * cam.half_height() / h; + cam.target = centre - r * (f64::from(offset.x) * k) + u * (f64::from(offset.y) * k); + let radius = (bounds.diagonal() * 0.5 + centre.dist(cam.target)).max(cam.half_height() * 2.0).max(10.0); + let fits = corners.iter().all(|p| { + cam.to_screen(*p, f64::from(layout.viewport.width()), h, radius).is_some_and(|(x, y, z)| { + x.is_finite() + && y.is_finite() + && (0.0..=1.0).contains(&z) + && layout.drawable.contains(pos2(layout.viewport.left() + x as f32, layout.viewport.top() + y as f32)) + }) + }); + if fits { + cam.fov = original_fov; + return cam.is_valid().then_some(cam); + } + cam.distance *= 1.15; + } + None +} + +#[cfg(test)] +#[path = "viewport_layout_tests.rs"] +mod tests; diff --git a/crates/ui-egui/src/viewport_layout_tests.rs b/crates/ui-egui/src/viewport_layout_tests.rs new file mode 100644 index 00000000..c80c3c02 --- /dev/null +++ b/crates/ui-egui/src/viewport_layout_tests.rs @@ -0,0 +1,196 @@ +use super::*; +use crate::scenario::Harness; +use serde_json::json; +use solvecraft_engine::render::StandardView; + +#[test] +fn thin_rotated_bounds_fit_using_the_original_full_projection() { + for (width, height, scale) in [(1400.0, 900.0, 1.0), (700.0, 450.0, 2.0), (700.0, 360.0, 2.0), (900.0, 720.0, 1.0)] { + let vp = Rect::from_min_max(pos2(250.0, 126.0), pos2(width, height - 30.0)); + let dock = Rect::from_min_max(pos2(width - 205.0, vp.top()), vp.max); + let layout = Layout::new(vp, &[dock], scale); + assert!(layout.drawable.width() >= 80.0 && layout.drawable.height() >= 30.0, "{layout:?}"); + assert!(layout.axis_view.is_positive() && layout.navigation.is_positive(), "{layout:?}"); + assert!(!layout.drawable.intersect(dock).is_positive()); + assert!(!layout.axis_view.intersect(layout.navigation).is_positive()); + for perspective in [false, true] { + for view in [StandardView::Top, StandardView::Iso, StandardView::Right] { + let mut cam = Camera::default(); + cam.set_view(view); + let bounds = Aabb3 { min: Vec3::new(-30.0, -1.0, -2.0), max: Vec3::new(70.0, 2.0, 3.0) }; + let fit = fit_bounds(cam, &bounds, &layout, perspective).unwrap(); + assert_eq!((cam.yaw, cam.pitch, cam.up), (fit.yaw, fit.pitch, fit.up)); + assert_eq!(fit_bounds(fit, &bounds, &layout, perspective).unwrap(), fit); + let mut effective = fit; + effective.fov = if perspective { 0.6 } else { 0.0 }; + let radius = (bounds.diagonal() * 0.5 + bounds.center().dist(fit.target)).max(effective.half_height() * 2.0).max(10.0); + for x in [bounds.min.x, bounds.max.x] { + for y in [bounds.min.y, bounds.max.y] { + for z in [bounds.min.z, bounds.max.z] { + let (sx, sy, depth) = + effective.to_screen(Vec3::new(x, y, z), f64::from(vp.width()), f64::from(vp.height()), radius).unwrap(); + assert!((0.0..=1.0).contains(&depth)); + assert!(layout.drawable.contains(pos2(vp.left() + sx as f32, vp.top() + sy as f32))); + } + } + } + } + } + } +} + +#[test] +fn measured_palette_fit_exposes_endpoints_and_layout_changes_preserve_manual_view() { + let mut h = Harness::new(); + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + h.app.run("sketch.line", json!({"points":[[0,0],[20,0]]})).unwrap(); + h.call("ui.view", json!({"view":"top"})); + h.settle(); + for (scale, width, height) in [(1.0, 1400.0, 900.0), (2.0, 700.0, 450.0), (2.0, 700.0, 360.0), (1.0, 900.0, 720.0)] { + h.call("ui.prefs", json!({"ui_scale":scale})); + h.call("ui.resize", json!({"width":width,"height":height})); + h.settle(); + let before = h.app.session.doc.to_json(); + let undo = h.app.session.undo.len(); + h.app.fit_view(); + h.settle(); + let layout = h.app.viewport.layout.as_ref().unwrap(); + let palette = h.app.viewport.palette_rect.unwrap(); + assert!(layout.drawable.width() >= 80.0 && layout.drawable.height() >= 30.0, "{layout:?}"); + assert!(layout.axis_view.is_positive() && layout.navigation.is_positive(), "{layout:?}"); + assert!(palette.width() <= 260.0, "{palette:?}"); + let projection = crate::viewport::projection(&h.app, layout.viewport); + for point in [Vec3::ZERO, Vec3::new(20.0, 0.0, 0.0)] { + let p = projection.to_screen(point).unwrap(); + assert!(layout.drawable.contains(p), "endpoint {p:?} outside {layout:?}"); + assert!(!palette.contains(p)); + assert!(!layout.axis_view.contains(p)); + assert!(!layout.navigation.contains(p)); + } + let fitted = h.app.cam; + h.app.fit_view(); + h.settle(); + assert_eq!(h.app.cam, fitted); + h.app.cancel_view_animation(); + h.app.cam.pan(13.0, -19.0, 500.0); + let manual = h.app.cam; + h.call("ui.resize", json!({"width":width+20.0,"height":height})); + h.settle(); + assert_eq!(h.app.cam, manual); + assert_eq!(h.app.session.doc.to_json(), before); + assert_eq!(h.app.session.undo.len(), undo); + } +} + +#[test] +fn hostile_or_empty_fit_keeps_the_existing_camera_valid() { + let cam = Camera::default(); + let layout = Layout::new(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)), &[], 1.0); + assert!(fit_bounds(cam, &Aabb3::EMPTY, &layout, false).is_none()); + let bad = Aabb3 { min: Vec3::new(f64::NAN, 0.0, 0.0), max: Vec3::new(1.0, 1.0, 1.0) }; + assert!(fit_bounds(cam, &bad, &layout, true).is_none()); + let mut app = crate::SolveApp::new(Default::default(), Default::default()); + let tiny = Layout::new(Rect::from_min_size(pos2(0.0, 0.0), vec2(5.0, 5.0)), &[], 1.0); + app.viewport.layout = Some(tiny); + let bounds = Aabb3 { min: Vec3::ZERO, max: Vec3::new(20.0, 1.0, 1.0) }; + assert_eq!(app.fitted_bounds(cam, &bounds), cam); +} + +#[test] +fn fit_home_animation_and_manual_takeover_use_one_layout_solution() { + let mut h = Harness::new(); + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + h.app.run("sketch.circle.center", json!({"center":[0,0],"radius":1})).unwrap(); + h.settle(); + for view in ["fit", "home"] { + h.app.preferences.look_at_anim = false; + h.app.animate_view(view); + h.settle(); + let immediate = h.app.cam; + h.app.cam.pan(50.0, 10.0, 400.0); + h.app.preferences.look_at_anim = true; + h.app.animate_view(view); + h.settle(); + assert_eq!(h.app.cam, immediate); + } + h.app.fit_view(); + h.app.cancel_view_animation(); + h.app.cam.pan(7.0, 13.0, 400.0); + let takeover = h.app.cam; + h.settle(); + assert_eq!(h.app.cam, takeover); +} + +#[test] +fn measured_axis_view_targets_home_and_drag_work_beside_the_palette_at_hidpi() { + for (scale, width, height) in [(1.0, 900.0, 720.0), (2.0, 700.0, 450.0), (2.0, 700.0, 360.0)] { + let mut h = Harness::new(); + h.app.preferences.look_at_anim = false; + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + h.app.run("sketch.circle.center", json!({"center":[0,0],"radius":1})).unwrap(); + h.call("ui.prefs", json!({"ui_scale":scale})); + h.call("ui.resize", json!({"width":width,"height":height})); + h.app.fit_view(); + h.settle(); + let baseline = h.app.session.doc.to_json(); + let undo = h.app.session.undo.len(); + h.call("ui.view", json!({"view":"front"})); + let layout = h.app.viewport.layout.as_ref().unwrap().clone(); + let palette = h.app.viewport.palette_rect.unwrap(); + let outer = crate::scenario::rect_of("axis_view:outer").unwrap(); + let home = crate::scenario::rect_of("axis_view:home").unwrap(); + assert_eq!(outer, layout.axis_view); + assert!(outer.contains_rect(home)); + assert!(layout.viewport.contains_rect(outer)); + assert!(!outer.intersect(palette).is_positive()); + assert!(!layout.navigation.intersect(outer).is_positive()); + for name in ["x", "-x", "y", "-y", "z", "-z"] { + let p = crate::scenario::handle_at(&format!("axis_view:{name}")).unwrap(); + assert!(outer.contains_rect(Rect::from_center_size(p, vec2(22.0, 22.0)))); + } + let z = crate::scenario::handle_at("axis_view:z").unwrap(); + h.call("ui.click", json!({"x":z.x,"y":z.y})); + assert!(h.app.cam.back().dist(Vec3::Z) < 1e-5); + let z = crate::scenario::handle_at("axis_view:z").unwrap(); + h.call("ui.click", json!({"x":z.x,"y":z.y})); + assert!(h.app.cam.back().dist(-Vec3::Z) < 1e-5); + h.call("ui.view", json!({"view":"front"})); + h.app.viewport.nav = Some(crate::viewport::NavMode::Pan); + let before = h.app.cam; + let c = h.app.viewport.layout.as_ref().unwrap().axis_view.center(); + h.call("ui.drag", json!({"x0":c.x,"y0":c.y,"x1":c.x+24.0,"y1":c.y+8.0,"steps":8})); + assert!((h.app.cam.yaw - before.yaw - 24.0 * 0.008).abs() < 1e-6); + assert!((h.app.cam.pitch - before.pitch - 8.0 * 0.008).abs() < 1e-6); + assert_eq!(h.app.cam.target, before.target); + assert_eq!(h.app.cam.distance, before.distance); + assert!(h.app.viewport.axis_drag.is_none()); + assert!(h.app.viewport.boxsel.is_none()); + let home = crate::scenario::handle_at("axis_view:home").unwrap(); + h.call("ui.click", json!({"x":home.x,"y":home.y})); + let mut iso = h.app.cam; + iso.set_view(StandardView::Iso); + assert_eq!((h.app.cam.yaw, h.app.cam.pitch), (iso.yaw, iso.pitch)); + assert_eq!(h.app.session.doc.to_json(), baseline); + assert_eq!(h.app.session.undo.len(), undo); + assert!(h.app.session.selection.is_empty()); + } +} + +#[test] +fn immediate_standard_view_cancels_fit_before_the_measured_frame() { + let mut h = Harness::new(); + h.app.run("solid.box", json!({"length":40,"width":30,"height":20})).unwrap(); + let baseline = h.app.session.doc.to_json(); + let undo = h.app.session.undo.len(); + h.app.cam.set_view(StandardView::Iso); + h.app.fit_view(); + assert!(h.app.pending_fit.is_some(), "Fit awaits the measured overlay frame"); + let mut front = h.app.cam; + front.set_view(StandardView::Front); + h.call("ui.view", json!({"view":"front"})); + h.settle(); + assert!(h.app.pending_fit.is_none()); + assert_eq!(h.app.cam, front, "the later frame must keep the explicitly requested view"); + assert_eq!(h.app.session.doc.to_json(), baseline); + assert_eq!(h.app.session.undo.len(), undo); +} diff --git a/docs/book/src/guide/ui-tour.md b/docs/book/src/guide/ui-tour.md index 5be08a8e..9470194e 100644 --- a/docs/book/src/guide/ui-tour.md +++ b/docs/book/src/guide/ui-tour.md @@ -22,6 +22,11 @@ keep the nearest quarter-turn heading. The house and navigation bar's Home button go home. Right-click for the marking menu: eight commands around the cursor, plus a menu for whatever is under it. + Shaded views omit certified smooth joins between spline spans on extruded + walls. Real creases, cap outlines and authored face splits remain visible. + Wireframe retains its edge presentation, and hiding a join does not remove its CAD + identity or change the exported solid. Unsupported or uncertain wall geometry + keeps its existing edge presentation. - **Dialogs** dock on the right with a live preview. Most features also show drag handles and a value box in the viewport. - **Timeline** (bottom). Every feature in order. Double-click to edit, drag the marker to roll diff --git a/docs/screenshots/demo.gif b/docs/screenshots/demo.gif index 78cca05f..3d63e497 100644 Binary files a/docs/screenshots/demo.gif and b/docs/screenshots/demo.gif differ diff --git a/packaging/nightly.sh b/packaging/nightly.sh index 2a16ff2e..a79bc6b7 100644 --- a/packaging/nightly.sh +++ b/packaging/nightly.sh @@ -10,7 +10,7 @@ fi # /releases/latest excludes prereleases. Include every published release instead, # including nightlies, and sort by publication time across all API pages. latest_tag="$(gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/releases?per_page=100" \ - --jq '[.[][] | select(.draft == false and .published_at != null)] | sort_by(.published_at) | last | .tag_name // empty')" + | jq -r '[.[][] | select(.draft == false and .published_at != null)] | sort_by(.published_at) | last | .tag_name // empty')" if [ -n "$latest_tag" ]; then # Resolve the tag itself, not target_commitish (which can be a moving branch). released_sha="$(git rev-parse --verify "refs/tags/$latest_tag^{commit}")" diff --git a/packaging/tests/nightly_test.py b/packaging/tests/nightly_test.py new file mode 100644 index 00000000..916667c6 --- /dev/null +++ b/packaging/tests/nightly_test.py @@ -0,0 +1,105 @@ +"""Exercise the real nightly decision script without GitHub or repository mutations.""" + +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + + +SCRIPT = Path(__file__).resolve().parents[1] / "nightly.sh" + + +def release(tag, published, *, draft=False, prerelease=False): + return {"tag_name": tag, "published_at": published, "draft": draft, + "prerelease": prerelease, "target_commitish": "main"} + + +class NightlyDecision(unittest.TestCase): + def run_decision(self, pages, refs=None, *, event="schedule", sha="current", api_fail=False): + self.assertIsNotNone(shutil.which("jq"), "install jq to run packaging checks") + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + output = root / "output" + summary = root / "summary" + calls = root / "calls" + output.touch() + summary.touch() + for command, body in { + "gh": '''import json, os, sys +args = sys.argv[1:] +with open(os.environ["CALLS"], "a") as log: log.write(json.dumps(args) + "\\n") +if args != ["api", "--paginate", "--slurp", "repos/test/project/releases?per_page=100"]: + sys.exit("unexpected gh invocation (slurp and jq must not be combined)") +if os.environ["API_FAIL"] == "1": sys.exit("mock API failure") +print(os.environ["PAGES"]) +''', + "git": '''import json, os, sys +args = sys.argv[1:] +with open(os.environ["CALLS"], "a") as log: log.write(json.dumps(args) + "\\n") +refs = json.loads(os.environ["REFS"]) +if len(args) != 3 or args[:2] != ["rev-parse", "--verify"] or args[2] not in refs: + sys.exit("missing tag") +print(refs[args[2]]) +''', + }.items(): + path = root / command + path.write_text("#!/usr/bin/env python3\n" + body, encoding="utf-8") + path.chmod(0o755) + env = {"PATH": str(root) + os.pathsep + os.environ["PATH"], + "GITHUB_EVENT_NAME": event, "GITHUB_REPOSITORY": "test/project", + "GITHUB_SHA": sha, "GITHUB_OUTPUT": str(output), + "GITHUB_STEP_SUMMARY": str(summary), "CALLS": str(calls), + "PAGES": json.dumps(pages), "REFS": json.dumps(refs or {}), + "API_FAIL": "1" if api_fail else "0"} + result = subprocess.run(["bash", str(SCRIPT)], env=env, capture_output=True, text=True, check=False) + return result, output.read_text(), summary.read_text(), calls.read_text() if calls.exists() else "" + + def test_newest_published_prerelease_across_pages_skips_exact_tag_commit(self): + pages = [[release("v1", "2026-01-01T00:00:00Z")], + [release("v2-nightly", "2026-01-03T00:00:00Z", prerelease=True), + release("v3-draft", "2026-01-04T00:00:00Z", draft=True)]] + result, output, summary, calls = self.run_decision(pages, {"refs/tags/v2-nightly^{commit}": "current"}) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(output, "build=false\n") + self.assertIn("v2-nightly", summary) + self.assertIn("refs/tags/v2-nightly^{commit}", calls) + self.assertNotIn("v3-draft^{commit}", calls) + + def test_changed_commit_builds(self): + result, output, _, _ = self.run_decision([[release("v1", "2026-01-01T00:00:00Z")]], + {"refs/tags/v1^{commit}": "previous"}) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(output, "build=true\n") + + def test_no_published_release_builds(self): + for pages in [[], [[]], [[release("draft", None, draft=True)]]]: + with self.subTest(pages=pages): + result, output, _, calls = self.run_decision(pages) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(output, "build=true\n") + self.assertNotIn("rev-parse", calls) + + def test_non_schedule_does_not_query_github(self): + for event in ["push", "workflow_dispatch"]: + with self.subTest(event=event): + result, output, _, calls = self.run_decision([], event=event, api_fail=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(output, "build=true\n") + self.assertEqual(calls, "") + + def test_api_failure_does_not_report_build_or_skip(self): + result, output, _, _ = self.run_decision([], api_fail=True) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(output, "") + + def test_missing_tag_fails_instead_of_using_moving_target_commitish(self): + result, output, _, _ = self.run_decision([[release("missing", "2026-01-01T00:00:00Z")]]) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(output, "") + + +if __name__ == "__main__": + unittest.main()