diff --git a/.gitattributes b/.gitattributes index d175f4007f..449c972e4f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1,5 @@ -# Append-only logs: a local `git merge` keeps both sides' new entries instead of -# conflicting. GitHub's own mergeability check ignores this, so merge main locally. +# Frozen archives (new entries are one file each under plan/findings/ and +# plan/upstream-issues/). Kept so branches that still append here merge locally; +# GitHub's own mergeability check ignores merge=union. plan/findings.md merge=union plan/upstream-issues.md merge=union diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5c084ecf1e..bcd4f44639 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,25 @@ permissions: contents: read jobs: + # What this run must check. `code`: false only when a pull_request or merge_group change + # touches nothing but docs (DOCS_ONLY below); those skip the build/test/difftest jobs, whose + # required checks then report "skipped" (passing). `full`: code and not a push to main. A + # push to main was already tested on the identical tree by the merge queue, so it runs only + # what publishes main's artifacts (difftest reports for the next PRs' exact-base ratchet, + # solids report); everything else is skipped there. + changes: + runs-on: blacksmith-2vcpu-ubuntu-2404 + outputs: + code: ${{ steps.c.outputs.code }} + full: ${{ steps.c.outputs.full }} + steps: + - uses: actions/checkout@v4 + - id: c + env: + EVENT: ${{ github.event_name }} + BASE: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }} + run: scripts/ci/changes.sh "$EVENT" "$BASE" >> "$GITHUB_OUTPUT" + ci-scripts: runs-on: blacksmith-4vcpu-ubuntu-2404 steps: @@ -50,8 +69,14 @@ jobs: - uses: actions/checkout@v4 - run: rustup show active-toolchain || rustup toolchain install - run: cargo fmt --all --check + - name: No new entries in frozen archives (one file per finding / upstream issue) + env: + BASE: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.before }} + run: scripts/ci/check-frozen-archives.sh "$BASE" clippy: + needs: changes + if: needs.changes.outputs.full == 'true' runs-on: blacksmith-8vcpu-ubuntu-2404 env: SOLIDCORE_CPP_DIR: ${{ github.workspace }}/.cpp @@ -69,6 +94,8 @@ jobs: run: cargo clippy --workspace --all-targets --features difftest/cpp -- -D warnings test: + needs: changes + if: needs.changes.outputs.code == 'true' runs-on: blacksmith-8vcpu-ubuntu-2404 env: SOLIDCORE_CPP_DIR: ${{ github.workspace }}/.cpp @@ -80,13 +107,15 @@ jobs: # tests/primitives_solid.rs takes minutes unoptimised, so its four tests are # skipped here and run once, optimised, in the next step (the difftest-profile # solidcore it builds is the one the runner tests below reuse). - - run: >- + - if: needs.changes.outputs.full == 'true' + run: >- cargo test --workspace -- --exact --skip complete_spheres_and_tori_have_analytic_properties --skip primitives_are_closed_solids_with_analytic_properties --skip precise_sphere_properties_scale_and_translate --skip pole_free_sphere_tolerance_is_explicit_and_validated - name: Primitives solid tests (difftest profile) + if: needs.changes.outputs.full == 'true' run: cargo test --profile difftest -p solidcore --test primitives_solid # Solids: the primitives example's per-shape results (built, blocker, checks) for # the site's Solids page. It reuses the difftest-profile build above and exits 1 @@ -112,10 +141,14 @@ jobs: # [profile.difftest]); here rather than after the difftest run, so they no longer # add to its wall time. - uses: ./.github/actions/cpp-reference + if: needs.changes.outputs.full == 'true' - name: Difftest runner tests + if: needs.changes.outputs.full == 'true' run: cargo test --profile difftest -p difftest --features cpp wasm: + needs: changes + if: needs.changes.outputs.full == 'true' runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - uses: actions/checkout@v4 @@ -127,6 +160,8 @@ jobs: # Same packaging check the release workflow runs before publishing (no secrets). publish-dry-run: + needs: changes + if: needs.changes.outputs.full == 'true' runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - uses: actions/checkout@v4 @@ -136,6 +171,8 @@ jobs: - run: cargo publish -p solidcore --locked --dry-run deny: + needs: changes + if: needs.changes.outputs.full == 'true' runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - uses: actions/checkout@v4 @@ -143,16 +180,65 @@ jobs: with: command: check licenses bans sources - difftest: - # Starts with the other jobs (no `needs`): it is the critical path, and the merge queue - # requires every job anyway. Clippy with the cpp feature and the runner tests run in - # the clippy and test jobs, in parallel with this one. + # The difftest gate is split so its slow parts overlap (they used to run one after + # another: exact-base wait, sweep, perturbation). `difftest-baseline` fetches and + # validates the exact-base artifact while `difftest-sweep` runs the green sweep and + # `difftest-perturb` the (mandatory) perturbation check; `difftest-compare` then holds the + # sweep's finished reports to the validated base with the same ratchet as `--baseline`; + # `difftest` (the required check's name) fails unless every part succeeded. + difftest-baseline: + needs: changes + if: needs.changes.outputs.full == 'true' + runs-on: blacksmith-2vcpu-ubuntu-2404 + permissions: + contents: read + actions: read # reads the exact base revision's run and report artifact + outputs: + baseline: ${{ steps.validate.outputs.baseline }} + steps: + - uses: actions/checkout@v4 + + # Fail closed: any validation error fails this step (and so the job and `difftest`). + # Only a genuine bootstrap/diagnostic run, which the helper reports by printing + # nothing, continues without a base. + - name: Validate exact-base report artifact + id: validate + env: + GH_TOKEN: ${{ github.token }} + EVENT: ${{ github.event_name }} + BASE: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.before || inputs.baseline_sha }} + run: | + arg=$(scripts/ci/difftest-baseline.sh "$EVENT" "$BASE" "$RUNNER_TEMP/difftest-baseline" --current-plan "$GITHUB_WORKSPACE") + if [ -n "$arg" ]; then + echo "baseline=yes" >> "$GITHUB_OUTPUT" + else + echo "baseline=no" >> "$GITHUB_OUTPUT" + fi + + # Hand the validated reports and their provenance to difftest-compare. + - name: Upload validated baseline + if: steps.validate.outputs.baseline == 'yes' + uses: actions/upload-artifact@v4 + with: + name: difftest-validated-baseline + path: ${{ runner.temp }}/difftest-baseline + if-no-files-found: error + retention-days: 1 + + difftest-sweep: + # Starts right after `changes`: it is the critical path. Clippy with the cpp feature and + # the runner tests run in the clippy and test jobs, in parallel with this one. Also runs on + # pushes to main, where its reports are main's published set. + needs: changes + if: needs.changes.outputs.code == 'true' runs-on: blacksmith-16vcpu-ubuntu-2404 permissions: contents: read actions: read # scripts/fetch-reports.sh reads main's report artifact env: SOLIDCORE_CPP_DIR: ${{ github.workspace }}/.cpp + outputs: + any: ${{ steps.green.outputs.any }} steps: - uses: actions/checkout@v4 @@ -166,15 +252,6 @@ jobs: echo "No current green units; exact-base retention is still required." fi - - name: Validate exact-base report artifact - env: - GH_TOKEN: ${{ github.token }} - EVENT: ${{ github.event_name }} - BASE: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.before || inputs.baseline_sha }} - run: | - arg=$(scripts/ci/difftest-baseline.sh "$EVENT" "$BASE" "$RUNNER_TEMP/difftest-baseline" --current-plan "$GITHUB_WORKSPACE") - echo "DIFFTEST_BASELINE=$arg" >> "$GITHUB_ENV" - - name: Upstream sources, sccache and the C++ reference if: steps.green.outputs.any == 'true' uses: ./.github/actions/cpp-reference @@ -198,22 +275,13 @@ jobs: if: steps.green.outputs.any == 'true' # Optimised profile (Cargo.toml [profile.difftest]): identical results, about 20x # faster than the debug build that used to take ~35 min here. --jobs 0: one child - # process per unit, one per vCPU (same output and reports as a serial run). - run: | - baseline=() - if [ -n "$DIFFTEST_BASELINE" ]; then - baseline=(--baseline "$RUNNER_TEMP/difftest-baseline/reports") - fi - cargo run --profile difftest -p difftest --features cpp -- --green --jobs 0 "${baseline[@]}" + # process per unit, one per vCPU (same output and reports as a serial run). The + # exact-base ratchet is applied afterwards by difftest-compare, to + # these reports; the per-unit status and value-share gates run here. + run: cargo run --profile difftest -p difftest --features cpp -- --green --jobs 0 - - name: Difftest perturbation check - if: steps.green.outputs.any == 'true' - run: cargo run --profile difftest -p difftest --features cpp -- --green --perturb --jobs 0 --no-report - - # main's reports are published from here (site, PROGRESS.md case counts, - # scripts/fetch-reports.sh), never committed. - name: Keep only reports of green/partial units - if: github.event_name == 'push' && github.ref == 'refs/heads/main' && steps.green.outputs.any == 'true' && !cancelled() + if: steps.green.outputs.any == 'true' && !cancelled() run: | python3 - <<'PY' import pathlib, sys @@ -225,6 +293,18 @@ jobs: p.unlink() PY + # Carries the finished reports to difftest-compare (run-internal, short-lived). + - name: Upload sweep reports + if: steps.green.outputs.any == 'true' && !cancelled() + uses: actions/upload-artifact@v4 + with: + name: difftest-sweep-reports + path: reports/difftest/*.json + if-no-files-found: error + retention-days: 1 + + # main's reports are published from here (site, PROGRESS.md case counts, + # scripts/fetch-reports.sh), never committed. - name: Upload difftest reports if: github.event_name == 'push' && github.ref == 'refs/heads/main' && steps.green.outputs.any == 'true' && !cancelled() uses: actions/upload-artifact@v4 @@ -239,3 +319,104 @@ jobs: - name: sccache stats if: always() && steps.green.outputs.any == 'true' run: sccache --show-stats || true + + # Mandatory on every pull_request and merge_group run, in parallel with the sweep. + difftest-perturb: + needs: changes + if: needs.changes.outputs.full == 'true' + runs-on: blacksmith-16vcpu-ubuntu-2404 + env: + SOLIDCORE_CPP_DIR: ${{ github.workspace }}/.cpp + steps: + - uses: actions/checkout@v4 + + - name: Select optional native execution + id: green + run: | + if grep -Eqs '^\s*status\s*=\s*"(green|partial)"' plan/units/*.toml; then + echo "any=true" >> "$GITHUB_OUTPUT" + else + echo "any=false" >> "$GITHUB_OUTPUT" + echo "No current green units; nothing to perturb." + fi + + - name: Upstream sources, sccache and the C++ reference + if: steps.green.outputs.any == 'true' + uses: ./.github/actions/cpp-reference + + - uses: Swatinem/rust-cache@v2 + if: steps.green.outputs.any == 'true' + with: + workspaces: ". -> bin" + key: difftest + + - name: Difftest perturbation check + if: steps.green.outputs.any == 'true' + run: cargo run --profile difftest -p difftest --features cpp -- --green --perturb --jobs 0 --no-report + + - name: sccache stats + if: always() && steps.green.outputs.any == 'true' + run: sccache --show-stats || true + + # The exact-base ratchet. Needs both the validated base and the finished sweep: if either + # job fails or is skipped, this job is skipped and `difftest` fails. + difftest-compare: + needs: [changes, difftest-baseline, difftest-sweep] + if: needs.changes.outputs.full == 'true' + runs-on: blacksmith-4vcpu-ubuntu-2404 + steps: + - uses: actions/checkout@v4 + + - uses: Swatinem/rust-cache@v2 + if: needs.difftest-baseline.outputs.baseline == 'yes' && needs.difftest-sweep.outputs.any == 'true' + with: + workspaces: ". -> bin" + key: difftest-compare + + - name: Download validated baseline + if: needs.difftest-baseline.outputs.baseline == 'yes' + uses: actions/download-artifact@v4 + with: + name: difftest-validated-baseline + path: ${{ runner.temp }}/difftest-baseline + + - name: Download sweep reports + if: needs.difftest-sweep.outputs.any == 'true' + uses: actions/download-artifact@v4 + with: + name: difftest-sweep-reports + path: ${{ runner.temp }}/difftest-sweep + + # No C++ and no cases: the same ratchet as --baseline, over the finished reports. + - name: Compare sweep reports with the exact base + if: needs.difftest-baseline.outputs.baseline == 'yes' && needs.difftest-sweep.outputs.any == 'true' + run: >- + cargo run -p difftest -- + --compare-reports "$RUNNER_TEMP/difftest-sweep" + --baseline "$RUNNER_TEMP/difftest-baseline/reports" + + # The required check ("difftest"). Runs even when a part failed or was skipped, and + # succeeds only if every part succeeded. + difftest: + needs: [changes, difftest-baseline, difftest-sweep, difftest-perturb, difftest-compare] + if: always() + runs-on: blacksmith-2vcpu-ubuntu-2404 + steps: + - name: Require every difftest part + env: + CHANGES: ${{ needs.changes.result }} + FULL: ${{ needs.changes.outputs.full }} + SWEEP: ${{ needs.difftest-sweep.result }} + RESULTS: ${{ join(needs.*.result, ' ') }} + run: | + echo "changes difftest-baseline difftest-sweep difftest-perturb difftest-compare: $RESULTS" + [ "$CHANGES" = success ] || { echo "the changes job did not succeed"; exit 1; } + if [ "$FULL" != true ]; then + # docs-only change (everything skipped), or a push to main (only the sweep, which + # publishes main's reports, runs; the merge queue already ran the full gate) + case "$SWEEP" in success|skipped) echo "not a full run (full=$FULL)"; exit 0;; esac + echo "difftest-sweep: $SWEEP"; exit 1 + fi + for r in $RESULTS; do + [ "$r" = success ] || { echo "a difftest part did not succeed"; exit 1; } + done diff --git a/AGENTS.md b/AGENTS.md index 2b665b0b89..1a39ad827c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ crates/solidcore the kernel (pure Rust, no unsafe, wasm-ok) owner: crates/solidcore-ffi extern "C" bindings to the C++ kernel (feature cpp) crates/difftest side-by-side Rust vs C++ runner harness/cpp C++ reference build (build.sh -> bin/cpp) + shim/.cpp -plan/ units/.toml (one file per unit), STATUS.md, findings.md, order.md +plan/ units/.toml, findings/.md, upstream-issues/.md (one file each), STATUS.md, order.md ``` ## 1. Hard rules @@ -51,7 +51,7 @@ anywhere (including dependencies; `deny.toml` bans copyleft crates). (via FFI) on the same inputs and topology is identical and every value agrees within `1e-9` relative (`|a-b| <= 1e-9 * max(|a|,|b|)`; an absolute floor only where a unit documents one per key in `abs_floors()`, recorded in its report). Never loosen a tolerance to get green. A disagreement you cannot fix -goes in `plan/findings.md` (unit, inputs, both values, suspected cause) and the unit +goes in a new `plan/findings/.md` (unit, inputs, both values, suspected cause) and the unit stays `blocked`. **Git.** Work happens through pull requests to github.com/tangency-labs/solidcore. Never @@ -141,7 +141,11 @@ Every ported file starts with: Unit list and states: `plan/units/.toml`, one file per unit (format: `plan/modules.schema.md`, order: `plan/order.md`). **`plan/PROGRESS.md` is the checklist** (regenerated on `main` by CI; -never edit it in a PR). **Any `todo` unit can be claimed.** A unit is *ready* when its `depends_on` are all +never edit it in a PR). **Any `todo` unit or unclaimed completion task can be claimed.** A claim reserves +its stated scope, not every future change to the unit. Missing functions, back-edge +wiring, exact runner imports, fixtures and demonstrated parity bugs remain useful +work in `partial` and `green` units; inspect source and reports, not status alone. +A unit is *ready* when its `depends_on` are all `green` or `partial` (`soft_depends_on` never blocks; see section 4a); ready units are the easiest, lowest `layer` first. Claiming a unit that is not ready is fine: - Calls into a missing dependency are stubbed (`UNPORTED()`, cases deferred; 4a). @@ -156,14 +160,44 @@ the easiest, lowest `layer` first. Claiming a unit that is not ready is fine: into `upstream/` (see README), then run `scripts/fetch-cpp.sh`, or `nice -n 10 harness/cpp/build.sh --jobs 6` to build from source. -1. **Claim = a draft PR.** Check nobody has it: - `gh pr list --state open --search " in:title"`. If none, branch from the - latest `main` (`git fetch origin && git switch -c unit/ origin/main`), - `git commit --allow-empty -m "claim: "`, push, and open a draft PR titled - exactly **`port: `**. **The lowest-numbered open PR for a unit wins**; if - yours lost, close it. Do not edit `plan/units/` to claim. Push WIP at least - daily; a draft PR with no pushes for 48 h may be taken over (comment on it first). - If the unit's `notes` mention a WIP branch on `origin`, start from it. +1. **Claim = a scoped draft PR.** Search open PR titles and bodies for the unit, + symbols and affected paths, including `wire:`, `fix:` and `difftest:` work; a title + search for `port:` alone misses completion work. Branch from the latest `main`. + Do not edit `plan/units/` to claim. + Initial whole-unit ports keep the title **`port: `**. For completion work, + use a descriptive title such as **`wire: — `** and name + the unit in the body. An empty claim commit is sufficient to open a draft. + + Include this scope in the PR body (update it as the work changes): + ```text + Units: + In scope: upstream symbols, Rust functions and intended behavior + Expected edits: paths and shared interfaces + Evidence: failing/deferred case IDs or missing branches; validation planned + Related PRs / dependencies: + Available follow-ups: independent work others can take + Status / blockers / next step: + ``` + For overlapping whole-unit `port:` claims, the lowest-numbered open PR wins. + For completion tasks, the earliest open claim wins only for overlapping work. + Non-overlapping work in + the same unit is welcome. Link the related PR and state the boundary before + starting; independent work need not wait for a whole-unit owner to reply. + If work changes the same function or shared interface, agree on a split in the + existing PR first, or build on its branch and document the dependency. If the + boundary is unclear, ask there and take a different independent task meanwhile. + Never implement a competing version of an actively claimed change. + + Push WIP or post a concrete progress update at least daily while implementing. + After 48 h without either, comment with the exact scope you propose to take over + and allow 24 h for a response; then continue from published WIP, crediting its + author. Do not take over a PR merely because it awaits review or CI: offer review, + conflict resolution or a coordinated follow-up instead. A blocker update should + identify what is blocked and release independent work, rather than reserve the + whole unit indefinitely. When pausing, release the scope and link the WIP. + Existing PR authors retain their active scope; this is not a reset of claims. + If the unit's notes mention a WIP branch, inspect it before starting. + 2. **Port** the upstream functions the unit owns (listed in `harness/callgraph/units.json`) into its `rust_module`, plus the C++ shim (`harness/cpp/shim/.cpp`, functions `sc__`, plain @@ -230,11 +264,17 @@ into `upstream/` (see README), then run `scripts/fetch-cpp.sh`, or there locally; CI regenerates them, uploads main's set as the `difftest-reports` artifact, and the site and `plan/PROGRESS.md` read that (`scripts/fetch-reports.sh` downloads it). Quote your summary lines in the PR instead. -- `plan/findings.md` and `plan/upstream-issues.md` are append-only: add new entries, - do not rewrite old ones. `.gitattributes` gives them `merge=union`, so a local - `git merge origin/main` keeps both sides' entries. GitHub's own conflict check - ignores that attribute, so when GitHub says your PR conflicts, merge main locally - and push. +- **Never append to a shared file.** Every finding and every upstream issue is its own + new file: `plan/findings/.md` and `plan/upstream-issues/.md` (formats in + each directory's `README.md`). Records are never edited after they merge (like ADRs): + an update is an addendum file. The old shared `plan/findings.md` and + `plan/upstream-issues.md` were split into those directories; CI fails if either comes + back. Two PRs that each add a file never conflict. + Known difftest mismatches likewise live in `crates/difftest/known_mismatches/.toml` + (one file per unit; see `crates/difftest/src/known.rs`), and SolidCraft cases in + `fixtures/solidcraft/-.json` (one file per issue). + The same goes for anything new you design: a registry or log is a directory of + per-entry files that tools read in sorted order, never one file everybody appends to. - **Branches from before the per-unit split** (they edit `plan/modules.toml` or commit reports): merge main, then run the migration, which moves your `modules.toml` edits into `plan/units/*.toml` and untracks your reports: @@ -245,10 +285,33 @@ into `upstream/` (see README), then run `scripts/fetch-cpp.sh`, or lists any other conflicted files for you to resolve before committing. **Blocked** (gate cannot be met, upstream behaviour unclear): record details in -`plan/findings.md`, set `status = "blocked"` with a reason in `notes`, and open the PR +`plan/findings/.md`, set `status = "blocked"` with a reason in `notes`, and open the PR with just that so others can see it. Never sit on a draft PR you have stopped working on: close it, and say where the WIP is. +### Completion evidence + +For new ports and completion changes, include the [completion evidence checklist](docs/completion-evidence.md) +in the PR description. The evidence table is required for `wire:` PRs that remove +`UNPORTED` stubs, including when existing fixtures do not reach the continuation. Identify the owned functions and branches implemented, which +operations actually complete successfully against C++, and which remain unreachable. +A passing status or empty result is evidence for that case, not proof that the +operation's successful construction or mutation path works. + +A dependency stub replaces the missing call only. Port the upstream logic following +it as well, preserving control flow and operation order; do not omit the continuation +and present the function as complete. If an incremental PR deliberately leaves an +owned body or continuation missing, list the exact symbols and remaining work. +Report that limitation even if existing fixtures never reach it. Unit status follows +the existing gate; it does not certify complete function or branch coverage. + +When a dependency is wired, rerun the affected callers and add cases that reach the +previously blocked continuations. Keep newly exposed mismatches visible. Review +runner skips, stand-in geometry and absent-entity cases before claiming coverage. +Record independent harness follow-ups with reproducible evidence and check existing +PRs before implementing them. This checklist supplements the existing correctness +gate; it does not change tolerances, outcomes or status accounting. + ## 4a. Cycles, back-edges and the merge lock The kernel core is one big call cycle, so the plan dropped ~2,300 calls as *back-edges* @@ -279,10 +342,13 @@ The kernel core is one big call cycle, so the plan dropped ~2,300 calls as *back - `plan/units/.toml`: one unit's plan entry (status, claims, notes). Edit only your unit's file. Every tool reads them through `harness/plan/units.py` (Rust: `crates/difftest/src/plan.rs`). -- `plan/findings.md`: Rust-vs-C++ disagreements, upstream bugs, nondeterminism (e.g. - upstream `srand(time(NULL))`), anything a later agent must know. Append-only. -- `plan/upstream-issues.md`: upstream C++ defects also get an entry here (problems only, - with location, trigger, how found and whether the C++ build confirmed it). Append-only. +- `plan/findings/.md`, one file per finding: Rust-vs-C++ disagreements, upstream + bugs, nondeterminism (e.g. upstream `srand(time(NULL))`), anything a later agent must + know. A reference to `plan/findings.md: ` (older code comments): grep the + heading under `plan/findings/`. +- `plan/upstream-issues/.md`, one file per issue: upstream C++ defects (problems + only, with location, trigger, how found and whether the C++ build confirmed it). The + site reads them all. A `plan/upstream-issues.md` ID: grep it under that directory. - `reports/difftest/.json`: difftest output. Not committed: CI uploads main's set as an artifact; `scripts/fetch-reports.sh` downloads it. - `plan/PROGRESS.md`: the checklist (generated; never edit by hand). diff --git a/Cargo.lock b/Cargo.lock index 3ebb5690bd..8a097dedaa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -28,7 +28,9 @@ dependencies = [ "serde_json", "solidcore", "solidcore-ffi", + "solidcore-ssi", "ssi", + "step-import", "toml", ] @@ -198,12 +200,40 @@ version = "0.1.0" dependencies = [ "cc", "serde_json", + "step-import", +] + +[[package]] +name = "solidcore-ssi" +version = "0.1.0" +dependencies = [ + "solidcore", + "ssi", ] [[package]] name = "ssi" version = "0.1.0" +[[package]] +name = "step-brep" +version = "0.1.0" +dependencies = [ + "solidcore", + "step-import", +] + +[[package]] +name = "step-export" +version = "0.1.0" +dependencies = [ + "step-import", +] + +[[package]] +name = "step-import" +version = "0.1.0" + [[package]] name = "syn" version = "3.0.9" diff --git a/Cargo.toml b/Cargo.toml index b368f11d72..eaf2aa607e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["crates/solidcore", "crates/solidcore-ffi", "crates/difftest", "crates/nurbs-grid-mesh", "crates/ssi"] +members = ["crates/solidcore", "crates/solidcore-ffi", "crates/difftest", "crates/nurbs-grid-mesh", "crates/ssi", "crates/solidcore-ssi", "crates/step-brep", "crates/step-export", "crates/step-import"] default-members = ["crates/solidcore"] [workspace.package] diff --git a/README.md b/README.md index 2f36611e73..5e3430b675 100644 --- a/README.md +++ b/README.md @@ -26,12 +26,17 @@ C++ to `1e-9` relative with identical topology, verified unit by unit. ## Status -About a third ported and verified against the C++: the math, NURBS and curve layers, -with surfaces and topology under way. **Checklist: [`plan/PROGRESS.md`](plan/PROGRESS.md)** -(updated automatically). Notes and handoff: [`plan/STATUS.md`](plan/STATUS.md). -The same data, browsable, is on the progress site built by +The planned kernel modules have Rust ports, and primitive construction is exercised +end to end. Integration remains in progress: partial units, deferred paths and known +mismatches still limit coverage. The API is experimental (0.0.x). + +Use the [live unit results](https://tangency-labs.github.io/solidcore/units/index.html) +for current differential coverage and blockers, and the +[primitive checks](https://tangency-labs.github.io/solidcore/solids.html) for whole-solid +results. Per-unit plans and notes live in [`plan/units/`](plan/units/); the generated +checklist is [`plan/PROGRESS.md`](plan/PROGRESS.md), with handoff notes in +[`plan/STATUS.md`](plan/STATUS.md). The progress site is built by [`site/build.py`](site/README.md) (`python3 site/build.py` -> `bin/site/`). -Nothing is usable end to end yet (no primitives, booleans or fillets). ## Layout @@ -96,8 +101,8 @@ Rust-only builds do not need these C++ build tools. git clone https://github.com/NVIDIA-Omniverse/usd-brep upstream git -C upstream checkout c9c5979 scripts/fetch-cpp.sh # C++ reference -> bin/cpp (prebuilt, or built) -cargo run -p difftest --features cpp -- --green # all units marked green -cargo run -p difftest --features cpp -- --unit # one unit +cargo run --profile difftest -p difftest --features cpp -- --green # all green and partial units +cargo run --profile difftest -p difftest --features cpp -- --unit # one unit ``` `solidcore-ffi` and `difftest` compile without the C++ build; only the `cpp` feature @@ -107,16 +112,29 @@ so they are cached like the Rust crates. ## Contributing -Contributions are pull requests, one per port unit, and AI agents are welcome: +Contributions go through pull requests, and AI agents are welcome. Useful work includes +connecting ported units, resolving deferred comparisons, adding operation regressions +and fixing bugs. -1. Pick any unclaimed unit in [`plan/PROGRESS.md`](plan/PROGRESS.md). "Ready" ones are - easiest; the others need stubs or co-porting of missing dependencies. -2. Claim it by opening a draft PR titled `port: ` (lowest-numbered PR wins). -3. Port it, prove it with difftest against the C++ reference, mark the PR ready. +1. Check the [live unit results](https://tangency-labs.github.io/solidcore/units/index.html), + [open issues](https://github.com/tangency-labs/solidcore/issues) and + [open PRs](https://github.com/tangency-labs/solidcore/pulls). Choose an unclaimed + unit or a specific completion task: a missing function, back-edge, exact import, + coverage gap or parity bug. Green reports do not prove every branch is exercised. +2. Search PR titles and bodies for the unit, symbols and paths. Claim the specific + work in a draft PR; initial ports use `port: `. A claim covers its stated + scope, leaving independent tasks in the same unit available. Coordinate overlapping + functions or shared interfaces in the existing PR before editing them. +3. Describe the intended behavior, affected paths, related PRs and validation, and + list independent follow-ups others can take. Compare against C++ and rerun affected + units with existing tolerances and coverage. CI is the merge gate. See + [AGENTS.md, section 4](AGENTS.md#4-porting-a-unit-pr-workflow) for the claim template, + handoffs and inactivity rules. To keep parallel PRs from conflicting: edit only your unit's `plan/units/.toml`, -never commit `reports/difftest/` (CI generates them), and only append to -`plan/findings.md` and `plan/upstream-issues.md`. A branch started before the per-unit +never commit `reports/difftest/` (CI generates them), and put each finding or upstream issue +in its own new file under `plan/findings/` or `plan/upstream-issues/` (never append to a +shared file). A branch started before the per-unit split migrates with `git fetch origin && git merge origin/main; scripts/units-migrate-branch.sh && git commit --no-edit`. diff --git a/crates/difftest/Cargo.toml b/crates/difftest/Cargo.toml index af87ef6365..e9785e8b3b 100644 --- a/crates/difftest/Cargo.toml +++ b/crates/difftest/Cargo.toml @@ -17,6 +17,10 @@ solidcore = { path = "../solidcore" } solidcore-ffi = { path = "../solidcore-ffi" } # The `ssi` engine of --ssi-corpus (a surface/surface intersection of its own, not a port). ssi = { path = "../ssi" } +# The `solidcore-ssi` engine of --ssi-corpus (`ssi` behind the port's inputs and output). +solidcore-ssi = { path = "../solidcore-ssi" } +# The clean-room STEP importer of --step-corpus. +step-import = { path = "../step-import" } serde = { version = "1", features = ["derive"] } # float_roundtrip: parse decimals exactly (correctly rounded, as C strtod), not to within 1 ULP serde_json = { version = "1", features = ["float_roundtrip"] } diff --git a/crates/difftest/README.md b/crates/difftest/README.md index d5038ffde7..f120b9a3c2 100644 --- a/crates/difftest/README.md +++ b/crates/difftest/README.md @@ -38,12 +38,14 @@ nice -n 10 cargo test -p difftest --features cpp # harness s | `--seeds N` | generated cases per unit (default 16) | | `--filter S` | only cases whose name contains `S` | | `--perturb` | change one item of each case's Rust output (below); passes only if **every** perturbed case is caught. No report written. | +| `--compare-reports R` | with `--baseline D` only: run no case, apply the same ratchet to the finished reports in `R` (CI's `difftest-compare` job; needs no C++) | | `--baseline D` | regression ratchet: compare each unit with its earlier report `D/.json` (see [Regression gate](#regression-gate---baseline-value-share)). CI passes the exact base revision's validated report artifact | | `--report-dir D` / `--no-report` | report location (default `reports/difftest/`) / skip writing | | `--fuzz N` | with `--unit`: probe the unit's FFI guard with N mutated cases, each run in a child process (below). No report written. | | `--fuzz-out D` | where `--fuzz` writes failing inputs (default `bin/fuzz/`; files go to `D//`) | | `--ssi-corpus [--tol T] [--jobs N] [glob]` | score the engines against the SSI corpus (see [below](#--ssi-corpus-exact-ground-truth-ssi-corpus)); not a gate; without the `cpp` feature, the `ssi` engine alone | | `--solidcraft-cases [glob]` | score the Rust port on SolidCraft's whole-operation regression cases (see [below](#--solidcraft-cases-solidcraft-regression-cases)); not a gate, no `cpp` feature needed | +| `--step-corpus [--jobs N] [glob]` | import STEP Tools' AP203/AP214 sample files and build every B-rep body on both kernels from the same intermediate (see [below](#--step-corpus-real-world-step-files)); not a gate; without `cpp`, the Rust side alone | Exit status: `0` no failures (deferred cases are not failures), `1` any failure, `2` usage or setup error. Output: one summary line per unit, @@ -53,6 +55,27 @@ PASS|PARTIAL|FAIL cases=… passed=… failed=… deferred=… max_rel_er ``` then the first few failing cases and `reports/difftest/.json`. + +**Native reference restrictions ([#448](https://github.com/tangency-labs/solidcore/issues/448)).** +`geom.hash_table`, `heal.heal_data_p1`, `heal.heal_data_p2`, `heal.heal_data_p3` +and `surface.twist_volume` require an x86-64 C++ reference. Their out-of-range +floating-point-to-integer conversions are undefined upstream; ARM64 saturates +where the pinned x86-64 reference wraps. Rust deliberately reproduces the latter +on every target. On other architectures these units print `SKIP ... +skipped(platform)` (or `FUZZ-SKIP`), before building fixtures or calling C++. +The twist unit is included because its period reduction has the same dependency, +even though the current committed cases do not expose it. + +Skipped units are counted separately from passes, partials and failures. Their +JSON reports have `status: "skipped(platform)"`, `pass: false`, a reason in +`skipped_platform`, and no compared cases. `--status-of` prints `skipped(platform)`; +ordinary comparison and fuzz runs exit successfully when they contain only passes, +partials and platform skips. `--baseline` fails if any selected reference is +unsupported, and skipped reports are invalid baselines: this cannot waive the CI +coverage ratchet. Plan statuses and tolerances are unchanged. Rust-only +`--digest-dir` runs remain enabled on every target. On Apple Silicon, an x86-64 +harness and difftest binary under Rosetta can compare these units; this does not +address independent libc++ reader or libm differences. `with_values` / `no_values` split the passing cases into those that compared at least one float, integer or topology entry and those that compared nothing (both sides returned the same error, or empty outputs). `vacuous` counts the @@ -295,6 +318,17 @@ The separate value-share floor remains 75% of a unit's passing cases and at least one value-bearing case. Both-error and vacuous passes stay in the denominator. Known mismatches are not passes and cannot waive new discrepancies. +CI runs the gate as parallel jobs (`.github/workflows/ci.yml`): `difftest-baseline` +fetches and validates the exact-base artifact (below) while `difftest-sweep` runs +`--green --jobs 0` (status and value-share gates, reports uploaded within the run) and +`difftest-perturb` runs `--green --perturb`, mandatory on every pull_request and +merge_group run. `difftest-compare` needs both the validated base and the finished sweep and +runs `--compare-reports --baseline `, which applies the +`--baseline` ratchet (unit set, seeds, case identity, outcome and value retention, waivers) +to the reports without re-running anything. The required check `difftest` is an aggregator +(`needs` all four, `if: always()`): it succeeds only if every part succeeded, so a failed or +skipped part, or a failed base validation, fails it. No base fallback and no second sweep. + CI retrieves the exact base SHA's successful push/main CI artifact through `scripts/ci/difftest-baseline.sh`. PRs use `pull_request.base.sha` and main pushes use `before`. For a merge queue, the helper snapshots the trusted repository's @@ -345,7 +379,7 @@ ratchet waivers once the PR has merged. * **Absolute floor** only where the runner documents one via `abs_floors()` (key + value + reason, written into the report). Do not add a floor to make a case pass; if the C++ and Rust genuinely disagree, record it in - `plan/findings.md` and leave the unit non-green. + a new `plan/findings/.md` and leave the unit non-green. * Key sets and array lengths must match. A case with a harness error (bad input, panic) fails. A unit with zero cases fails. @@ -630,8 +664,12 @@ nice -n 10 cargo run -p difftest -- --solidcraft-cases '3526-*' # ids (-.json`. Files load in + sorted order and nothing counts them, so no Rust file changes unless the case needs + a new step kind. ### Sheet flange corner overlap (SolidCraft #3170, PR #3472) @@ -694,6 +735,45 @@ nice -n 10 cargo run -p difftest -- --solidcraft-cases '3170-*' --no-report This case builds on the whole-operation harness in solidcore PR #345; it does not change any unit status, kernel implementation or floating-point tolerance. +## `--step-corpus`: real-world STEP files + +A correctness report on real CAD data, separate from the gate like `--ssi-corpus`: +verdicts never fail the run and it is not part of CI. The files are STEP Tools' sample +sets (, AP203 and AP214), "All Rights Reserved" +and copied only for informational, non-commercial use: they are never committed. + +```sh +scripts/fetch-steptools-corpus.sh # corpus/steptools/, SHA-256 pinned +nice -n 10 cargo run --profile difftest -p difftest --features cpp -- --step-corpus --jobs 4 +nice -n 10 cargo run --profile difftest -p difftest --features cpp -- --step-corpus 'ap203/block*' +``` + +* **Import.** `crates/step-import` (clean room, from the public ISO 10303-21 syntax and + the Part 41/42 entity definitions; no other STEP implementation consulted) parses + each file and maps its `MANIFOLD_SOLID_BREP`, `BREP_WITH_VOIDS`, `FACETED_BREP` and + `SHELL_BASED_SURFACE_MODEL` bodies to a plain-data model in millimetres. A body that + reaches an unsupported entity is skipped with `unsupported: ` (listed in the + summary). Assembly instancing (`MAPPED_ITEM`, `CONTEXT_DEPENDENT_SHAPE_REPRESENTATION`) + is counted, not applied: each body is built once in its own coordinates. +* **Build (both kernels, same numbers).** The body is encoded as one `f64` array + (`step_import::flat`); the C++ shim (`harness/cpp/shim/step_import.cpp`) reads that + array and the Rust side builds from its decoding. Per face: the surface by + `CreateCanonical`, the edge loops' 3D curves bounded to their edges, then + `CreateFacesWithCurves`; then `SmApiStitchIntoSolid`, counts (regions, shells, faces, + edges, vertices), `SmApiBrepIsManifoldSolid`, `SmApiBrepComputeArea` and + `SmApiBrepComputeVolume` (accuracy `1e-6`), `SmApiHealBrep`, and counts, manifold + test and volume again. Face orientation is left to the stitch. +* **Verdicts** (per body): `MATCH` (every stage agrees: statuses and counts equal, + values within `1e-9` relative), `HEAL-DIFF` (every build stage agrees, the heal or a + later stage differs), `DIFF` (a build stage differs), `DEFERRED` (the Rust side + reached unported code; the unit is shown), `CRASH` (a child crashed, timed out or + the shim rejected the input), `SKIPPED` (no B-rep body in the file). +* Each (body, kernel) runs in its own child process (`--step-case + `). Output: one line per body, a `STEP-CORPUS` summary line, and + `bin/step-corpus/report.json` (per body: source counts, both sides' stages, verdict). +* `cargo run -p step-import --example survey -- corpus/steptools` lists what the + importer maps per file; `--example dump_body -- [--flat]` prints one body. + ## Notes * Cases run sequentially in one process: the C++ kernel has global state. A diff --git a/crates/difftest/known_mismatches.toml b/crates/difftest/known_mismatches.toml deleted file mode 100644 index 8db276f137..0000000000 --- a/crates/difftest/known_mismatches.toml +++ /dev/null @@ -1,224 +0,0 @@ -# Known Rust-vs-C++ mismatches (see crates/difftest/src/known.rs). -# -# A case listed here still fails its comparison; difftest reports it as -# `known-mismatch` (summary line `known_mismatch=N`, report `"known_mismatch"`) and -# the unit is at best `partial`, never green. A listed case that passes, defers or no -# longer exists fails the unit: remove its entry when the port is fixed. Every entry -# needs a plan/findings.md entry. Never list a case just to get CI green. -# -# [[mismatch]] -# unit = "curve.curve_p1" -# case = "gen0042" -# why = "C++ succeeds with 0 points, Rust returns status_3 (plan/findings.md: ...)" - -# PR #691: explicit known differences for this issue. - -[[mismatch]] -unit = "topology.trimming_tools" -case = "check_loop_mirror_Mouse_0_0_0" -why = "WR-256 / #476: failed gap diagnostic uses edge-only limit in C++; shared-vertex-aware Rust limit is correct. values.out[9]: C++ 6.95115100281493e-4, Rust 2.1819300674131145e-3; operation status unchanged. See plan/findings.md: shared-vertex gap tolerance." - -[[mismatch]] -unit = "topology.trimming_tools" -case = "check_loop_mirror_Mouse_0_0_1" -why = "WR-256 / #476: failed gap diagnostic uses edge-only limit in C++; shared-vertex-aware Rust limit is correct. values.out[9]: C++ 6.95115100281493e-4, Rust 2.1819300674131145e-3; operation status unchanged. See plan/findings.md: shared-vertex gap tolerance." - -[[mismatch]] -unit = "topology.trimming_tools" -case = "check_loop_mirror_Mouse_0_1_0" -why = "WR-256 / #476: failed gap diagnostic uses edge-only limit in C++; shared-vertex-aware Rust limit is correct. values.out[9]: C++ 6.912139700099413e-4, Rust 2.1819300674131145e-3; operation status unchanged. See plan/findings.md: shared-vertex gap tolerance." - -[[mismatch]] -unit = "topology.trimming_tools" -case = "check_loop_mirror_Mouse_0_1_1" -why = "WR-256 / #476: failed gap diagnostic uses edge-only limit in C++; shared-vertex-aware Rust limit is correct. values.out[9]: C++ 6.912139700099413e-4, Rust 2.1819300674131145e-3; operation status unchanged. See plan/findings.md: shared-vertex gap tolerance." - -[[mismatch]] -unit = "topology.trimming_tools" -case = "check_loop_mirror_Mouse_0_3_0" -why = "WR-256 / #476: failed gap diagnostic uses edge-only limit in C++; shared-vertex-aware Rust limit is correct. values.out[9]: C++ 6.95115100281493e-4, Rust 2.1819300674131145e-3; operation status unchanged. See plan/findings.md: shared-vertex gap tolerance." - -[[mismatch]] -unit = "topology.trimming_tools" -case = "check_loop_mirror_Mouse_0_3_1" -why = "WR-256 / #476: failed gap diagnostic uses edge-only limit in C++; shared-vertex-aware Rust limit is correct. values.out[9]: C++ 6.95115100281493e-4, Rust 2.1819300674131145e-3; operation status unchanged. See plan/findings.md: shared-vertex gap tolerance." - -[[mismatch]] -unit = "topology.trimming_tools" -case = "vertex_gap_covered_eu0" -why = "WR-256 / #476: C++ reports closure 3 for gap 1.3000000000040757e-5 against edge sum 1.16e-5 despite shared vertex zone 2.6e-5; Rust reports closure 0. See plan/findings.md: shared-vertex gap tolerance." - -[[mismatch]] -unit = "topology.trimming_tools" -case = "vertex_gap_covered_eu1" -why = "WR-256 / #476: C++ reports closure 3 for gap 1.3000000000040757e-5 against edge sum 1.16e-5 despite shared vertex zone 2.6e-5; Rust reports closure 0. See plan/findings.md: shared-vertex gap tolerance." - -[[mismatch]] -unit = "topology.trimming_tools" -case = "vertex_gap_covered_eu3" -why = "WR-256 / #476: C++ reports closure 3 for gap 1.3000000000040757e-5 against edge sum 1.16e-5 despite shared vertex zone 2.6e-5; Rust reports closure 0. See plan/findings.md: shared-vertex gap tolerance." - -[[mismatch]] -unit = "topology.trimming_tools" -case = "vertex_gap_too_wide_eu0" -why = "WR-256 / #476: failed gap diagnostic uses edge-only limit in C++; shared-vertex-aware Rust limit is correct. values.out[11]: C++ 1.16e-5, Rust 2.6e-5; operation status unchanged. See plan/findings.md: shared-vertex gap tolerance." - -[[mismatch]] -unit = "topology.trimming_tools" -case = "vertex_gap_too_wide_eu1" -why = "WR-256 / #476: failed gap diagnostic uses edge-only limit in C++; shared-vertex-aware Rust limit is correct. values.out[11]: C++ 1.16e-5, Rust 2.6e-5; operation status unchanged. See plan/findings.md: shared-vertex gap tolerance." - -[[mismatch]] -unit = "topology.trimming_tools" -case = "vertex_gap_too_wide_eu3" -why = "WR-256 / #476: failed gap diagnostic uses edge-only limit in C++; shared-vertex-aware Rust limit is correct. values.out[11]: C++ 1.16e-5, Rust 2.6e-5; operation status unchanged. See plan/findings.md: shared-vertex gap tolerance." - -[[mismatch]] -unit = "topology.brep_p3" -case = "nurbs_off_Mouse_0.1" -why = "TurnToNURBS tracker: upstream curves/surfaces notify their own SM_NO_PRE_EDIT/SM_NO_POST_EDIT edits, the port's geometry sends none (plan/findings.md: topology.face_p1 SetSurface SM_NO_CHANGE_GEOMETRY wired (PR #637), 'Still deferred: geometry edit notifications')" - -[[mismatch]] -unit = "topology.brep_p3" -case = "nurbs_off_Mouse_-0.05" -why = "TurnToNURBS tracker: upstream curves/surfaces notify their own SM_NO_PRE_EDIT/SM_NO_POST_EDIT edits, the port's geometry sends none (plan/findings.md: topology.face_p1 SetSurface SM_NO_CHANGE_GEOMETRY wired (PR #637), 'Still deferred: geometry edit notifications')" - -[[mismatch]] -unit = "topology.brep_p3" -case = "nurbs_off_toruswvy_0.1" -why = "TurnToNURBS tracker: upstream curves/surfaces notify their own SM_NO_PRE_EDIT/SM_NO_POST_EDIT edits, the port's geometry sends none (plan/findings.md: topology.face_p1 SetSurface SM_NO_CHANGE_GEOMETRY wired (PR #637), 'Still deferred: geometry edit notifications')" - -[[mismatch]] -unit = "topology.brep_p3" -case = "gen0138" -why = "TurnToNURBS tracker: upstream curves/surfaces notify their own SM_NO_PRE_EDIT/SM_NO_POST_EDIT edits, the port's geometry sends none (plan/findings.md: topology.face_p1 SetSurface SM_NO_CHANGE_GEOMETRY wired (PR #637), 'Still deferred: geometry edit notifications')" - -[[mismatch]] -unit = "topology.brep_p3" -case = "gen0164" -why = "TurnToNURBS tracker: upstream curves/surfaces notify their own SM_NO_PRE_EDIT/SM_NO_POST_EDIT edits, the port's geometry sends none (plan/findings.md: topology.face_p1 SetSurface SM_NO_CHANGE_GEOMETRY wired (PR #637), 'Still deferred: geometry edit notifications')" - -[[mismatch]] -unit = "topology.brep_p3" -case = "gen0241" -why = "TurnToNURBS tracker: upstream curves/surfaces notify their own SM_NO_PRE_EDIT/SM_NO_POST_EDIT edits, the port's geometry sends none (plan/findings.md: topology.face_p1 SetSurface SM_NO_CHANGE_GEOMETRY wired (PR #637), 'Still deferred: geometry edit notifications')" - -[[mismatch]] -unit = "query.face_p4" -case = "gen0157" -why = "the importer's approximation of the analytic edge curves moves the loop bisector's UV line (rel 1.6e-8) across a loop at scale 1e-3 (plan/findings.md: query.face_p4: two scale-1e-3 cylinder cases move with the importer's curve approximation) [ints.classify_loop_ios[4] cpp 1 rust 0]" - -[[mismatch]] -unit = "query.face_p4" -case = "gen0300" -why = "the importer's approximation of the analytic edge curves moves the loop bisector's UV line (rel 1.6e-8) across a loop at scale 1e-3 (plan/findings.md: query.face_p4: two scale-1e-3 cylinder cases move with the importer's curve approximation) [ints.classify_loop_ios[2] cpp 2 rust 1]" - -[[mismatch]] -unit = "topology.brep_p10" -case = "healer10_always_Defeature_inset_cube" -why = "still differs with ClassifyLoops' loop bisector keeping its B-rep edits (the healer copies its snapshot's UV trims, tolerance growth and gap caches back); cause not traced (plan/findings.md: ClassifyLoops' loop bisector keeps its B-rep edits: 37 of 49 listed cases match, 12 topology.brep_p10 cases still differ) [ints.ints[1336] cpp 1 rust 0]" - -[[mismatch]] -unit = "topology.brep_p10" -case = "read_Boolean_extrudeHoles1" -why = "still differs with ClassifyLoops' loop bisector keeping its B-rep edits (the healer copies its snapshot's UV trims, tolerance growth and gap caches back); cause not traced (plan/findings.md: ClassifyLoops' loop bisector keeps its B-rep edits: 37 of 49 listed cases match, 12 topology.brep_p10 cases still differ) [values.dbls.len cpp 909 rust 921]" - -[[mismatch]] -unit = "topology.brep_p10" -case = "read_Solids_InsetTaperedExtrude" -why = "still differs with ClassifyLoops' loop bisector keeping its B-rep edits (the healer copies its snapshot's UV trims, tolerance growth and gap caches back); cause not traced (plan/findings.md: ClassifyLoops' loop bisector keeps its B-rep edits: 37 of 49 listed cases match, 12 topology.brep_p10 cases still differ) [ints.dbl_bits[805] cpp 4556438595713448769 rust 4556438595713597268]" - -[[mismatch]] -unit = "topology.brep_p10" -case = "read_Solids_fillet_ts" -why = "still differs with ClassifyLoops' loop bisector keeping its B-rep edits (the healer copies its snapshot's UV trims, tolerance growth and gap caches back); cause not traced (plan/findings.md: ClassifyLoops' loop bisector keeps its B-rep edits: 37 of 49 listed cases match, 12 topology.brep_p10 cases still differ) [values.dbls.len cpp 1659 rust 1683]" - -[[mismatch]] -unit = "topology.brep_p10" -case = "gen0115" -why = "still differs with ClassifyLoops' loop bisector keeping its B-rep edits (the healer copies its snapshot's UV trims, tolerance growth and gap caches back); cause not traced (plan/findings.md: ClassifyLoops' loop bisector keeps its B-rep edits: 37 of 49 listed cases match, 12 topology.brep_p10 cases still differ) [values.dbls[648] cpp 2.917160020417993e-3 rust 2.9171595358599514e-3]" - -[[mismatch]] -unit = "topology.brep_p10" -case = "gen0117" -why = "still differs with ClassifyLoops' loop bisector keeping its B-rep edits (the healer copies its snapshot's UV trims, tolerance growth and gap caches back); cause not traced (plan/findings.md: ClassifyLoops' loop bisector keeps its B-rep edits: 37 of 49 listed cases match, 12 topology.brep_p10 cases still differ) [ints.ints[166] cpp 1 rust 0]" - -[[mismatch]] -unit = "topology.brep_p10" -case = "read_Fillet_bottle" -why = "still differs with ClassifyLoops' loop bisector keeping its B-rep edits (the healer copies its snapshot's UV trims, tolerance growth and gap caches back); cause not traced (plan/findings.md: ClassifyLoops' loop bisector keeps its B-rep edits: 37 of 49 listed cases match, 12 topology.brep_p10 cases still differ) [ints.dbl_bits[2437] cpp 4533227276183232325 rust 4533227276183193973]" - -[[mismatch]] -unit = "topology.brep_p10" -case = "read_Fillet_cyl_cyl_shell" -why = "still differs with ClassifyLoops' loop bisector keeping its B-rep edits (the healer copies its snapshot's UV trims, tolerance growth and gap caches back); cause not traced (plan/findings.md: ClassifyLoops' loop bisector keeps its B-rep edits: 37 of 49 listed cases match, 12 topology.brep_p10 cases still differ) [ints.dbl_bits[351] cpp 4541087340294538906 rust 4541087340294541262]" - -[[mismatch]] -unit = "topology.brep_p10" -case = "read_Fillet_fillet0227" -why = "still differs with ClassifyLoops' loop bisector keeping its B-rep edits (the healer copies its snapshot's UV trims, tolerance growth and gap caches back); cause not traced (plan/findings.md: ClassifyLoops' loop bisector keeps its B-rep edits: 37 of 49 listed cases match, 12 topology.brep_p10 cases still differ) [ints.dbl_bits[587] cpp 4584643767240576693 rust 4584643767240344909]" - -[[mismatch]] -unit = "topology.brep_p10" -case = "read_Solids_extrusion2" -why = "still differs with ClassifyLoops' loop bisector keeping its B-rep edits (the healer copies its snapshot's UV trims, tolerance growth and gap caches back); cause not traced (plan/findings.md: ClassifyLoops' loop bisector keeps its B-rep edits: 37 of 49 listed cases match, 12 topology.brep_p10 cases still differ) [ints.dbl_bits[3949] cpp 4566519071875143604 rust 4566519071875138963]" - -[[mismatch]] -unit = "topology.brep_p10" -case = "gen0040" -why = "still differs with ClassifyLoops' loop bisector keeping its B-rep edits (the healer copies its snapshot's UV trims, tolerance growth and gap caches back); cause not traced (plan/findings.md: ClassifyLoops' loop bisector keeps its B-rep edits: 37 of 49 listed cases match, 12 topology.brep_p10 cases still differ) [ints.ints[1681] cpp 1 rust 0]" - -[[mismatch]] -unit = "topology.brep_p10" -case = "gen0165" -why = "still differs with ClassifyLoops' loop bisector keeping its B-rep edits (the healer copies its snapshot's UV trims, tolerance growth and gap caches back); cause not traced (plan/findings.md: ClassifyLoops' loop bisector keeps its B-rep edits: 37 of 49 listed cases match, 12 topology.brep_p10 cases still differ) [ints.ints[170] cpp 1 rust 0]" - -[[mismatch]] -unit = "topology.brep_p10" -case = "project_t0_Defeature_brep1" -why = "ProjectAndTrim removes every face; C++ keeps the former solid region as the infinite region, the port keeps the original one (its SmTopology fields differ: cpp 99999999, rust 0) (plan/findings.md: 'topology.brep_p10 project_t0_Defeature_brep1: the region left after ProjectAndTrim')" - -[[mismatch]] -unit = "boolean.merge_p3" -case = "upstream_merge_breps_operation_3" -why = "same result topology and vertex positions, different vertex list order: C++ lists one part's exclusive vertices last (plan/findings.md: 'boolean.merge_p3: vertex list order after merges that imprint edges into faces'; issue #832)" - -[[mismatch]] -unit = "boolean.merge_p3" -case = "upstream_piecewise_boxes_2_keep_other" -why = "same result topology and vertex positions, different vertex list order: C++ lists one part's exclusive vertices last (plan/findings.md: 'boolean.merge_p3: vertex list order after merges that imprint edges into faces'; issue #832)" - -[[mismatch]] -unit = "boolean.merge_p3" -case = "upstream_piecewise_boxes_3_keep_other" -why = "same result topology and vertex positions, different vertex list order: C++ lists one part's exclusive vertices last (plan/findings.md: 'boolean.merge_p3: vertex list order after merges that imprint edges into faces'; issue #832)" - -[[mismatch]] -unit = "boolean.merge_p3" -case = "upstream_merge_breps_operation_0" -why = "same result topology and vertex positions, different vertex list order; deferred on main (SmRegion::GetBrep / SmFace::Point3DClassify), compared since PR #816 (plan/findings.md: 'boolean.merge_p3: vertex list order after merges that imprint edges into faces'; 'Public boolean e2e (PR #816): cases newly compared once the context stubs were wired'; issue #832)" - -[[mismatch]] -unit = "boolean.merge_p3" -case = "upstream_merge_breps_operation_1" -why = "same result topology and vertex positions, different vertex list order; deferred on main (SmRegion::GetBrep / SmFace::Point3DClassify), compared since PR #816 (plan/findings.md: 'boolean.merge_p3: vertex list order after merges that imprint edges into faces'; 'Public boolean e2e (PR #816): cases newly compared once the context stubs were wired'; issue #832)" - -[[mismatch]] -unit = "boolean.merge_p3" -case = "upstream_merge_breps_operation_2" -why = "same result topology and vertex positions, different vertex list order; deferred on main (SmRegion::GetBrep / SmFace::Point3DClassify), compared since PR #816 (plan/findings.md: 'boolean.merge_p3: vertex list order after merges that imprint edges into faces'; 'Public boolean e2e (PR #816): cases newly compared once the context stubs were wired'; issue #832)" - -[[mismatch]] -unit = "boolean.merge_p3" -case = "upstream_piecewise_boxes_0" -why = "same result topology and vertex positions, different vertex list order; deferred on main (SmRegion::GetBrep / SmFace::Point3DClassify), compared since PR #816 (plan/findings.md: 'boolean.merge_p3: vertex list order after merges that imprint edges into faces'; 'Public boolean e2e (PR #816): cases newly compared once the context stubs were wired'; issue #832)" - -[[mismatch]] -unit = "boolean.merge_p3" -case = "upstream_piecewise_boxes_1" -why = "same result topology and vertex positions, different vertex list order; deferred on main (SmRegion::GetBrep / SmFace::Point3DClassify), compared since PR #816 (plan/findings.md: 'boolean.merge_p3: vertex list order after merges that imprint edges into faces'; 'Public boolean e2e (PR #816): cases newly compared once the context stubs were wired'; issue #832)" - -[[mismatch]] -unit = "blend.fillet_executive_p1" -case = "remove_topo_edges_mouse_two_struts" -why = "RemoveTopoEdges with topo edges 1 and 2 of Mouse.smb in sequence: after the second deletion one edge use's UV curve on the plane differs (faces, surfaces and the remaining edge agree; each edge alone matches); in DeleteEdge / DeleteTopologicalVertex / SetSurface, not located (plan/findings.md: 'blend.fillet_executive_p1, RemoveTopoEdges and SetBevelCorner')" diff --git a/crates/difftest/known_mismatches/offset.offset_geometry_creation.toml b/crates/difftest/known_mismatches/offset.offset_geometry_creation.toml new file mode 100644 index 0000000000..a2f4ae2829 --- /dev/null +++ b/crates/difftest/known_mismatches/offset.offset_geometry_creation.toml @@ -0,0 +1,4 @@ +[[mismatch]] +unit = "offset.offset_geometry_creation" +case = "gen0350" +why = "WR-255: the drop in ExtendTrimEdges builds the sphere face's trimmed cache, whose CreateUVTrimCurves -> sm_FixUVTrimCurves closes a 0.61 UV corner gap on the offset edge use's 5-point UV trim; upstream (c9c5979) drops the vertex onto the trim (t = 0.01177), trims there and stretches it back (Trim + EditParameterization: knots 0.0411/0.1687/0.8571 -> 0.0296/0.1588/0.8554), the port keeps the fork fix (end point only; Context::trim_uv_curves_at_corner_drops makes it pass); parked with the other WR-255 cases pending #959 (plan/findings/offset.offset_geometry_creation--addendum-2026-10-25-kept-trim-cache-and-wr-255.md)" diff --git a/crates/difftest/known_mismatches/surface.surface_p3.toml b/crates/difftest/known_mismatches/surface.surface_p3.toml new file mode 100644 index 0000000000..ade3c2089e --- /dev/null +++ b/crates/difftest/known_mismatches/surface.surface_p3.toml @@ -0,0 +1,39 @@ +# C++ merges across a continuation that folds; the port refuses it (SolidCraft change from +# tangency-labs/smlib 1b6f5fa, tangency-labs/solidcore#434). The case becomes a +# `deviation(#434)` once difftest has that outcome (#989); it is a declared, deliberate +# divergence, not an unfixed port bug. +[[mismatch]] +unit = "surface.surface_p3" +case = "gen0041" +why = "CoverCoincidentSurface refuses a cover whose continued domain folds: this surface's Du x Dv normal turns back (dot <= 0) between neighbouring samples of the 21 x 21 grid over the domain the merge would use. C++ success=true, Rust success=false. SolidCraft change, tangency-labs/smlib 1b6f5fa, tangency-labs/solidcore#434 (plan/findings/surface.surface_p3--cover-refuses-a-folded-continuation.md)." + +[[mismatch]] +unit = "surface.surface_p3" +case = "gen1448" +why = "CoverCoincidentSurface refuses a cover whose continued domain folds: this surface's Du x Dv normal turns back (dot <= 0) between neighbouring samples of the 21 x 21 grid over the domain the merge would use. C++ success=true, Rust success=false. SolidCraft change, tangency-labs/smlib 1b6f5fa, tangency-labs/solidcore#434 (plan/findings/surface.surface_p3--cover-refuses-a-folded-continuation.md)." + +[[mismatch]] +unit = "surface.surface_p3" +case = "gen1514" +why = "CoverCoincidentSurface refuses a cover whose continued domain folds: this surface's Du x Dv normal turns back (dot <= 0) between neighbouring samples of the 21 x 21 grid over the domain the merge would use. C++ success=true, Rust success=false. SolidCraft change, tangency-labs/smlib 1b6f5fa, tangency-labs/solidcore#434 (plan/findings/surface.surface_p3--cover-refuses-a-folded-continuation.md)." + +[[mismatch]] +unit = "surface.surface_p3" +case = "gen1567" +why = "CoverCoincidentSurface refuses a cover whose continued domain folds: this surface's Du x Dv normal turns back (dot <= 0) between neighbouring samples of the 21 x 21 grid over the domain the merge would use. C++ success=true, Rust success=false. SolidCraft change, tangency-labs/smlib 1b6f5fa, tangency-labs/solidcore#434 (plan/findings/surface.surface_p3--cover-refuses-a-folded-continuation.md)." + +[[mismatch]] +unit = "surface.surface_p3" +case = "gen2066" +why = "CoverCoincidentSurface refuses a cover whose continued domain folds: this surface's Du x Dv normal turns back (dot <= 0) between neighbouring samples of the 21 x 21 grid over the domain the merge would use. C++ success=true, Rust success=false. SolidCraft change, tangency-labs/smlib 1b6f5fa, tangency-labs/solidcore#434 (plan/findings/surface.surface_p3--cover-refuses-a-folded-continuation.md)." + +[[mismatch]] +unit = "surface.surface_p3" +case = "gen2303" +why = "CoverCoincidentSurface refuses a cover whose continued domain folds: this surface's Du x Dv normal turns back (dot <= 0) between neighbouring samples of the 21 x 21 grid over the domain the merge would use. C++ success=true, Rust success=false. SolidCraft change, tangency-labs/smlib 1b6f5fa, tangency-labs/solidcore#434 (plan/findings/surface.surface_p3--cover-refuses-a-folded-continuation.md)." + +[[mismatch]] +unit = "surface.surface_p3" +case = "gen2376" +why = "CoverCoincidentSurface refuses a cover whose continued domain folds: this surface's Du x Dv normal turns back (dot <= 0) between neighbouring samples of the 21 x 21 grid over the domain the merge would use. C++ success=true, Rust success=false. SolidCraft change, tangency-labs/smlib 1b6f5fa, tangency-labs/solidcore#434 (plan/findings/surface.surface_p3--cover-refuses-a-folded-continuation.md)." + diff --git a/crates/difftest/known_mismatches/topology.brep_p10.toml b/crates/difftest/known_mismatches/topology.brep_p10.toml new file mode 100644 index 0000000000..2d1f8df559 --- /dev/null +++ b/crates/difftest/known_mismatches/topology.brep_p10.toml @@ -0,0 +1,5 @@ +[[mismatch]] +unit = "topology.brep_p10" +case = "read_Solids_fillet_ts" +libc = "glibc" +why = "SmHealData::Fix_CoinVertices dangling-pointer heap reuse (UB-255): UpdateTargetLists frees both pSurvivorProps and pDeletedProps on SM_NO_MERGE_IN_BREP, Fix_CoinVertices rewrites sProbArray to the freed pSurvivorProps, and once glibc's 7-slot tcache bin fills on the 6th merge, new SmVertexProps reuses pSurvivorProps's address so C++ merges 3 extra 3-way vertex clusters (22 vertices, 1 shell vs Rust's fresh-id 25 vertices, 2 shells; plan/findings.md: 'topology.brep_p10, topology.brep_p1, sweep.api_brep: heal/notify/ProjectAndTrim fixes and `read_Solids_fillet_ts` UAF root cause'); only glibc reuses the address: with macOS libmalloc the C++ agrees with Rust, plan/findings/topology.brep_p10--read-solids-fillet-ts-mismatch-is-glibc-only.md)" diff --git a/crates/difftest/known_mismatches/topology.brep_p3.toml b/crates/difftest/known_mismatches/topology.brep_p3.toml new file mode 100644 index 0000000000..09838d132e --- /dev/null +++ b/crates/difftest/known_mismatches/topology.brep_p3.toml @@ -0,0 +1,9 @@ +[[mismatch]] +unit = "topology.brep_p3" +case = "nurbs_off_Mouse_0.1" +why = "TurnToNURBS tracker: C++ sm_FixUVTrimCurves trims two UV trim curves at their vertex drop points and stretches them back (Trim + EditParameterization, two SM_NO_POST_EDITs each); the port skips that step on purpose (WR-255, #570), so its tracker lacks those 4 events (plan/findings.md: topology.brep_p3: TurnToNURBS UV trim curve edits notify)" + +[[mismatch]] +unit = "topology.brep_p3" +case = "nurbs_off_Mouse_-0.05" +why = "TurnToNURBS tracker: C++ sm_FixUVTrimCurves trims two UV trim curves at their vertex drop points and stretches them back (Trim + EditParameterization, two SM_NO_POST_EDITs each); the port skips that step on purpose (WR-255, #570), so its tracker lacks those 4 events (plan/findings.md: topology.brep_p3: TurnToNURBS UV trim curve edits notify)" diff --git a/crates/difftest/known_mismatches/topology.trimming_tools.toml b/crates/difftest/known_mismatches/topology.trimming_tools.toml new file mode 100644 index 0000000000..4cc5e9bc22 --- /dev/null +++ b/crates/difftest/known_mismatches/topology.trimming_tools.toml @@ -0,0 +1,61 @@ +# PR #691: explicit known differences for this issue. + +[[mismatch]] +unit = "topology.trimming_tools" +case = "check_loop_mirror_Mouse_0_0_0" +why = "WR-381 / #476: failed gap diagnostic uses edge-only limit in C++; shared-vertex-aware Rust limit is correct. values.out[9]: C++ 6.95115100281493e-4, Rust 2.1819300674131145e-3; operation status unchanged. See plan/findings/topology.trimming_tools--shared-vertex-gap-tolerance-issue-476.md." + +[[mismatch]] +unit = "topology.trimming_tools" +case = "check_loop_mirror_Mouse_0_0_1" +why = "WR-381 / #476: failed gap diagnostic uses edge-only limit in C++; shared-vertex-aware Rust limit is correct. values.out[9]: C++ 6.95115100281493e-4, Rust 2.1819300674131145e-3; operation status unchanged. See plan/findings/topology.trimming_tools--shared-vertex-gap-tolerance-issue-476.md." + +[[mismatch]] +unit = "topology.trimming_tools" +case = "check_loop_mirror_Mouse_0_1_0" +why = "WR-381 / #476: failed gap diagnostic uses edge-only limit in C++; shared-vertex-aware Rust limit is correct. values.out[9]: C++ 6.912139700099413e-4, Rust 2.1819300674131145e-3; operation status unchanged. See plan/findings/topology.trimming_tools--shared-vertex-gap-tolerance-issue-476.md." + +[[mismatch]] +unit = "topology.trimming_tools" +case = "check_loop_mirror_Mouse_0_1_1" +why = "WR-381 / #476: failed gap diagnostic uses edge-only limit in C++; shared-vertex-aware Rust limit is correct. values.out[9]: C++ 6.912139700099413e-4, Rust 2.1819300674131145e-3; operation status unchanged. See plan/findings/topology.trimming_tools--shared-vertex-gap-tolerance-issue-476.md." + +[[mismatch]] +unit = "topology.trimming_tools" +case = "check_loop_mirror_Mouse_0_3_0" +why = "WR-381 / #476: failed gap diagnostic uses edge-only limit in C++; shared-vertex-aware Rust limit is correct. values.out[9]: C++ 6.95115100281493e-4, Rust 2.1819300674131145e-3; operation status unchanged. See plan/findings/topology.trimming_tools--shared-vertex-gap-tolerance-issue-476.md." + +[[mismatch]] +unit = "topology.trimming_tools" +case = "check_loop_mirror_Mouse_0_3_1" +why = "WR-381 / #476: failed gap diagnostic uses edge-only limit in C++; shared-vertex-aware Rust limit is correct. values.out[9]: C++ 6.95115100281493e-4, Rust 2.1819300674131145e-3; operation status unchanged. See plan/findings/topology.trimming_tools--shared-vertex-gap-tolerance-issue-476.md." + +[[mismatch]] +unit = "topology.trimming_tools" +case = "vertex_gap_covered_eu0" +why = "WR-381 / #476: C++ reports closure 3 for gap 1.3000000000040757e-5 against edge sum 1.16e-5 despite shared vertex zone 2.6e-5; Rust reports closure 0. See plan/findings/topology.trimming_tools--shared-vertex-gap-tolerance-issue-476.md." + +[[mismatch]] +unit = "topology.trimming_tools" +case = "vertex_gap_covered_eu1" +why = "WR-381 / #476: C++ reports closure 3 for gap 1.3000000000040757e-5 against edge sum 1.16e-5 despite shared vertex zone 2.6e-5; Rust reports closure 0. See plan/findings/topology.trimming_tools--shared-vertex-gap-tolerance-issue-476.md." + +[[mismatch]] +unit = "topology.trimming_tools" +case = "vertex_gap_covered_eu3" +why = "WR-381 / #476: C++ reports closure 3 for gap 1.3000000000040757e-5 against edge sum 1.16e-5 despite shared vertex zone 2.6e-5; Rust reports closure 0. See plan/findings/topology.trimming_tools--shared-vertex-gap-tolerance-issue-476.md." + +[[mismatch]] +unit = "topology.trimming_tools" +case = "vertex_gap_too_wide_eu0" +why = "WR-381 / #476: failed gap diagnostic uses edge-only limit in C++; shared-vertex-aware Rust limit is correct. values.out[11]: C++ 1.16e-5, Rust 2.6e-5; operation status unchanged. See plan/findings/topology.trimming_tools--shared-vertex-gap-tolerance-issue-476.md." + +[[mismatch]] +unit = "topology.trimming_tools" +case = "vertex_gap_too_wide_eu1" +why = "WR-381 / #476: failed gap diagnostic uses edge-only limit in C++; shared-vertex-aware Rust limit is correct. values.out[11]: C++ 1.16e-5, Rust 2.6e-5; operation status unchanged. See plan/findings/topology.trimming_tools--shared-vertex-gap-tolerance-issue-476.md." + +[[mismatch]] +unit = "topology.trimming_tools" +case = "vertex_gap_too_wide_eu3" +why = "WR-381 / #476: failed gap diagnostic uses edge-only limit in C++; shared-vertex-aware Rust limit is correct. values.out[11]: C++ 1.16e-5, Rust 2.6e-5; operation status unchanged. See plan/findings/topology.trimming_tools--shared-vertex-gap-tolerance-issue-476.md." diff --git a/crates/difftest/src/baseline.rs b/crates/difftest/src/baseline.rs index fcd16e765b..8e9664500c 100644 --- a/crates/difftest/src/baseline.rs +++ b/crates/difftest/src/baseline.rs @@ -465,9 +465,106 @@ pub fn ratchet(base: &Baseline, res: &UnitResult) -> Vec { out } +/// Ratchet violations of a completed report `now` against the earlier report `base` +/// (`--compare-reports`). The same rules as [`ratchet`], applied to two parsed reports +/// instead of a live [`UnitResult`]: the sweep ran elsewhere and only its reports remain. +pub fn ratchet_reports(base: &Baseline, now: &Baseline) -> Vec { + let current: BTreeMap<&str, &BaselineCase> = + now.cases.iter().map(|c| (c.name.as_str(), c)).collect(); + let mut out = Vec::new(); + let mut push = |case: &str, detail: String| { + out.push(Violation { + unit: now.unit.clone(), + check: Check::Ratchet, + case: Some(case.to_string()), + detail, + }); + }; + if base.seeds != now.seeds { + push( + "", + "baseline/current seeds differ; generated coverage cannot be ratcheted".into(), + ); + } + for b in &base.cases { + if b.outcome == "rejected" { + continue; + } + let Some(c) = current.get(b.name.as_str()) else { + push( + &b.name, + format!("case `{}` ({} in the baseline) is gone", b.name, b.outcome), + ); + continue; + }; + if b.outcome == "known_mismatch" && !matches!(c.outcome.as_str(), "known_mismatch" | "pass") + { + push( + &b.name, + format!("case `{}` was a known mismatch, now {}", b.name, c.outcome), + ); + continue; + } + if b.outcome == "pass" && c.outcome != "pass" { + if c.outcome == "fail" { + continue; // already fails the unit in the sweep + } + push( + &b.name, + format!( + "case `{}` passed in the baseline, now {}", + b.name, c.outcome + ), + ); + continue; + } + if b.compared_values && !c.compared_values { + push( + &b.name, + format!( + "case `{}` compared values in the baseline, now compares nothing", + b.name + ), + ); + } + } + out +} + +/// Units that have a report (`.json`) in `dir`, sorted. Every report is parsed. +pub fn report_units(dir: &Path) -> Result, HarnessError> { + let entries = + fs::read_dir(dir).map_err(|e| HarnessError::new(format!("{}: {e}", dir.display())))?; + let mut out = Vec::new(); + for e in entries { + let path = e + .map_err(|e| HarnessError::new(format!("{}: {e}", dir.display())))? + .path(); + if path.extension().and_then(|x| x.to_str()) != Some("json") { + continue; + } + let unit = path + .file_stem() + .and_then(|x| x.to_str()) + .ok_or_else(|| HarnessError::new("invalid report filename"))? + .to_string(); + if load_baseline(dir, &unit)?.is_none() { + return Err(HarnessError::new(format!("{unit}: report disappeared"))); + } + out.push(unit); + } + out.sort(); + Ok(out) +} + /// Value-share violation of `res`, if any. Only meaningful for a unit that does not -/// already fail. +/// already fail. A unit whose every case was `skipped(platform)` (e.g. a libstdc++-only +/// reference on libc++) compared nothing and has no share to check, like a unit-level +/// `skipped(platform)`. pub fn value_share(res: &UnitResult) -> Option { + if !res.cases.is_empty() && res.skipped_platform() == res.cases.len() { + return None; + } let passed = res.passed(); let with_values = res.passed_with_values(); // Exact integer arithmetic for `with_values / passed < MIN_VALUE_SHARE` (= 3/4). @@ -568,6 +665,59 @@ mod tests { use crate::output::Deferred; use crate::runner::CaseResult; + fn bcase(name: &str, outcome: &str, compared_values: bool) -> BaselineCase { + BaselineCase { + name: name.into(), + outcome: outcome.into(), + generated: false, + compared_values, + } + } + + fn report(seeds: u64, cases: Vec) -> Baseline { + Baseline { + unit: "a.b".into(), + status: Some("green".into()), + seeds: Some(seeds), + cases, + } + } + + #[test] + fn ratchet_reports_flags_regressions() { + let base = report( + 16, + vec![ + bcase("p", "pass", true), + bcase("q", "pass", true), + bcase("r", "known_mismatch", false), + bcase("s", "deferred", false), + bcase("x", "rejected", false), + bcase("f", "pass", false), + ], + ); + assert!(ratchet_reports(&base, &base).is_empty()); + let now = report( + 16, + vec![ + bcase("p", "deferred", false), + bcase("q", "pass", false), + bcase("r", "deferred", false), + bcase("f", "fail", false), + ], + ); + let names: Vec = ratchet_reports(&base, &now) + .into_iter() + .filter_map(|v| v.case) + .collect(); + // `f` failing is left to the sweep; `x` (rejected) may vanish; `s` may not. + assert_eq!(names, ["p", "q", "r", "s"]); + assert_eq!( + ratchet_reports(&base, &report(8, base.cases.clone())).len(), + 1 + ); + } + fn case(name: &str, outcome: &str, values: usize) -> CaseResult { let mut c = CaseResult { name: name.into(), @@ -595,6 +745,7 @@ mod tests { }); } "rejected" => c.rejected = Some("bad".into()), + "skipped(platform)" => c.skipped_platform = Some("libc++".into()), "fail" => c.diff.mismatch_count = 1, "known_mismatch" => { c.diff.mismatch_count = 1; @@ -610,6 +761,7 @@ mod tests { unit: "a.b".into(), cases, harness_error: None, + skipped_platform: None, abs_floors: Vec::new(), seeds: 16, timing: crate::runner::UnitTiming::default(), @@ -800,6 +952,14 @@ mod tests { // Deferred cases do not count either way, but some pass must compare a value. assert!(value_share(&unit(vec![case("a", "deferred", 0)])).is_some()); assert!(value_share(&unit(vec![case("a", "pass", 1), case("b", "deferred", 0)])).is_none()); + // A unit whose every case is platform-skipped compared nothing: no share to + // check. One case that ran brings the floor back. + let skipped = |n: usize| (0..n).map(|i| case(&format!("s{i}"), "skipped(platform)", 0)); + assert!(value_share(&unit(skipped(3).collect())).is_none()); + let mut v: Vec<_> = skipped(3).collect(); + v.push(case("e", "pass", 0)); + assert!(value_share(&unit(v)).is_some()); + assert!(value_share(&unit(Vec::new())).is_some()); } #[test] diff --git a/crates/difftest/src/fixtures.rs b/crates/difftest/src/fixtures.rs index c2caac27be..b83d65c1ce 100644 --- a/crates/difftest/src/fixtures.rs +++ b/crates/difftest/src/fixtures.rs @@ -15,6 +15,8 @@ #[cfg(feature = "cpp")] pub(crate) mod regions; +#[cfg(feature = "cpp")] +pub(crate) mod text_curve; use std::fs; use std::path::{Path, PathBuf}; diff --git a/crates/difftest/src/fixtures/text_curve.rs b/crates/difftest/src/fixtures/text_curve.rs new file mode 100644 index 0000000000..9af170f3e2 --- /dev/null +++ b/crates/difftest/src/fixtures/text_curve.rs @@ -0,0 +1,326 @@ +//! Bounded decoding of the `.smc` curve fixture files the unit runners read. +//! +//! Upstream text form (`N_CrvReadFromFilePtr`): `n`, `p`, rational flag, dimension, +//! `n + 1` control points (`x y [z]`, or `wx wy [wz] w` when rational) and `n + p + 2` +//! knots. +//! +//! The runners validated the whole file eagerly (a malformed tail precedes header +//! errors) but then trusted the header: `Vec::with_capacity(n + 1)`, `n + p + 2` and the +//! point loop all followed the declared count, so a short file with a huge `n` +//! reserved (or overflowed) gigabytes before the truncation was ever seen. +//! +//! Deviations: a count must be finite, non-negative, integral and below +//! `2^usize::BITS` (the exclusive boundary of the `f64`-to-`usize` cast), and the +//! declared payload is checked (`checked_add`/`checked_mul`) and compared with the +//! numbers actually present before any output grows. Parsing stays eager: every token +//! must be a number before the headers are inspected, and numeric tails are ignored. + +use std::vec::IntoIter; + +use solidcore::nurbs::math::{NOW, NOZ}; + +use crate::HarnessError; + +/// A decoded `.smc` curve: control points (weight `NOW` when not rational), degree and +/// knots. +pub(crate) struct SmcCurve { + /// The `n + 1` control points, `[x, y, z, w]`. + pub(crate) cpts: Vec<[f64; 4]>, + /// The curve degree `p`. + pub(crate) p: usize, + /// The `n + p + 2` knots. + pub(crate) u: Vec, +} + +/// Reads and decodes an `.smc` curve of any dimension (`z` is `NOZ` unless the +/// dimension is 3). +pub(crate) fn read(path: &str) -> Result { + decode(&text(path)?, path) +} + +/// Reads and decodes a 3D `.smc` curve, rejecting any other dimension as +/// `not a 3D curve`. +pub(crate) fn read_3d(path: &str) -> Result { + decode_3d(&text(path)?, path) +} + +/// Reads the file's text (`{path}: {io error}` on failure, as the runners did). +fn text(path: &str) -> Result { + std::fs::read_to_string(path).map_err(|e| HarnessError::new(format!("{path}: {e}"))) +} + +/// Decodes an `.smc` curve of any dimension (`z` is `NOZ` unless the dimension is 3). +pub(crate) fn decode(text: &str, path: &str) -> Result { + decode_impl(text, path, false) +} + +/// Decodes a 3D `.smc` curve, rejecting any other dimension as `not a 3D curve`. +pub(crate) fn decode_3d(text: &str, path: &str) -> Result { + decode_impl(text, path, true) +} + +/// One header or payload number; a file that ends early is `truncated`. +fn number(numbers: &mut IntoIter, path: &str) -> Result { + numbers + .next() + .ok_or_else(|| HarnessError::new(format!("{path}: truncated"))) +} + +fn decode_impl(text: &str, path: &str, only_3d: bool) -> Result { + let error = |message: String| HarnessError::new(format!("{path}: {message}")); + // Eager lexical validation of the whole file, including ignored tails, before any + // header is inspected (the readers' long-standing malformed-tail precedence). + let mut numbers = text + .split_whitespace() + .map(|token| token.parse::().map_err(|e| error(e.to_string()))) + .collect::, _>>()? + .into_iter(); + let count = |value: f64| { + // `usize::MAX as f64` rounds up, so the `2^BITS` boundary is exclusive. + if !value.is_finite() + || value < 0.0 + || value.fract() != 0.0 + || value >= 2.0_f64.powi(usize::BITS as i32) + { + return Err(error(format!("bad count {value}"))); + } + Ok(value as usize) + }; + let n = count(number(&mut numbers, path)?)?; + let p = count(number(&mut numbers, path)?)?; + let rational = number(&mut numbers, path)? != 0.0; + let dim = count(number(&mut numbers, path)?)?; + if only_3d && dim != 3 { + return Err(error("not a 3D curve".to_owned())); + } + let overflow = || error("count overflow".to_owned()); + let points = n.checked_add(1).ok_or_else(overflow)?; + let knots = n + .checked_add(p) + .and_then(|v| v.checked_add(2)) + .ok_or_else(overflow)?; + let stride = 2usize + .checked_add(usize::from(dim == 3)) + .and_then(|v| v.checked_add(usize::from(rational))) + .ok_or_else(overflow)?; + let payload = points + .checked_mul(stride) + .and_then(|v| v.checked_add(knots)) + .ok_or_else(overflow)?; + // Preflight the numbers the file actually holds before growing any output. + if numbers.len() < payload { + return Err(error("truncated".to_owned())); + } + let mut cpts = Vec::new(); + for _ in 0..points { + let (x, y) = (number(&mut numbers, path)?, number(&mut numbers, path)?); + let z = if dim == 3 { + number(&mut numbers, path)? + } else { + NOZ + }; + let w = if rational { + number(&mut numbers, path)? + } else { + NOW + }; + cpts.push([x, y, z, w]); + } + let mut u = Vec::new(); + for _ in 0..knots { + u.push(number(&mut numbers, path)?); + } + Ok(SmcCurve { cpts, p, u }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const PATH: &str = "fixtures/example.smc"; + + fn failure(text: &str) -> String { + let result = decode(text, PATH); + assert!(result.is_err(), "unexpected success: {text}"); + result.err().map_or_else(String::new, |e| e.to_string()) + } + + fn assert_error(text: &str, message: &str) { + assert_eq!(failure(text), format!("{PATH}: {message}")); + } + + #[test] + fn dimensions_rational_flags_and_undivided_order() { + for dim in [0, 1, 2, 3, 4, 100] { + for flag in ["0", "-0", "1", "-1", "0.5", "-0.5", "NaN", "inf", "-inf"] { + let rational = flag.parse::().unwrap() != 0.0; + let mut text = format!("1 1 {flag} {dim} -0 2 "); + if dim == 3 { + text.push_str("3 "); + } + if rational { + text.push_str("-4 "); + } + text.push_str("5 6 "); + if dim == 3 { + text.push_str("7 "); + } + if rational { + text.push_str("8 "); + } + text.push_str("10 11 12 13"); + let c = decode(&text, PATH).unwrap(); + assert_eq!(c.p, 1); + assert_eq!(c.u, [10.0, 11.0, 12.0, 13.0]); + assert_eq!( + c.cpts, + [ + [ + -0.0, + 2.0, + if dim == 3 { 3.0 } else { NOZ }, + if rational { -4.0 } else { NOW } + ], + [ + 5.0, + 6.0, + if dim == 3 { 7.0 } else { NOZ }, + if rational { 8.0 } else { NOW } + ], + ] + ); + assert_eq!(c.cpts[0][0].to_bits(), (-0.0_f64).to_bits()); + } + } + } + + #[test] + fn three_dimensional_variant_rejects_other_dimensions() { + let c = decode_3d("0 0 0 3 1 2 3 10 11", PATH).unwrap(); + assert_eq!(c.cpts, [[1.0, 2.0, 3.0, NOW]]); + for dim in ["0", "1", "2", "4", "100"] { + let text = format!("0 0 0 {dim} 1 2 10 11"); + assert_eq!( + decode_3d(&text, PATH).err().map(|e| e.to_string()), + Some(format!("{PATH}: not a 3D curve")) + ); + assert!(decode(&text, PATH).is_ok()); + } + } + + #[test] + fn invalid_nonfinite_and_exclusive_counts() { + let exclusive = 2.0_f64.powi(usize::BITS as i32).to_string(); + for value in [ + "-1", "0.5", "NaN", "inf", "-inf", "1e300", "1e999", &exclusive, + ] { + for index in [0, 1, 3] { + let mut header = ["0", "0", "0", "2"]; + header[index] = value; + let parsed = value.parse::().unwrap(); + let text = header.join(" "); + assert_eq!( + decode(&text, PATH).err().map(|e| e.to_string()), + Some(format!("{PATH}: bad count {parsed}")) + ); + assert_eq!( + decode_3d(&text, PATH).err().map(|e| e.to_string()), + Some(format!("{PATH}: bad count {parsed}")) + ); + } + } + } + + #[test] + fn huge_headers_with_tiny_bodies_do_not_allocate() { + let huge = 2.0_f64.powi((usize::BITS - 4) as i32).to_string(); + for index in [0, 1] { + let mut header = ["0", "0", "0", "2"]; + header[index] = &huge; + assert_error(&header.join(" "), "truncated"); + assert_error(&format!("{} 1 2 3", header.join(" ")), "truncated"); + } + // An absurd dimension does not grow a point; only `== 3` adds a coordinate. + let c = decode(&format!("0 0 0 {huge} 1 2 10 11"), PATH).unwrap(); + assert_eq!(c.cpts, [[1.0, 2.0, NOZ, NOW]]); + } + + #[test] + fn checked_knot_coordinate_and_payload_arithmetic() { + let power = |bits| 2.0_f64.powi(bits).to_string(); + let half = power(usize::BITS as i32 - 1); + let quarter = power(usize::BITS as i32 - 2); + let exclusive = 2.0_f64.powi(usize::BITS as i32); + let largest = f64::from_bits(exclusive.to_bits() - 1).floor(); + let remainder = (exclusive - largest - 1.0).to_string(); + for header in [ + format!("{half} {half} 0 2"), // n + p + format!("{largest} {remainder} 0 2"), // the final +2 + format!("{half} 0 0 2"), // points * 2 + format!("{quarter} 0 1 3"), // points * 4 + format!("{quarter} 0 0 3"), // coordinates and knots + ] { + assert_error(&header, "count overflow"); + } + // usize::MAX is exactly representable on 32-bit targets, unlike on 64-bit. + if usize::BITS <= f64::MANTISSA_DIGITS { + assert_error(&format!("{} 0 0 2", usize::MAX), "count overflow"); + } + } + + #[test] + fn all_header_and_payload_truncations() { + for text in [ + "1 1 0 2 -0 2 5 6 10 11 12 13", + "1 1 0 3 -0 2 3 5 6 7 10 11 12 13", + "1 1 1 2 -0 2 -4 5 6 8 10 11 12 13", + "1 1 1 3 -0 2 3 -4 5 6 7 8 10 11 12 13", + ] { + let tokens: Vec<_> = text.split_whitespace().collect(); + for end in 0..tokens.len() { + assert_error(&tokens[..end].join(" "), "truncated"); + } + assert!(decode(text, PATH).is_ok()); + } + } + + #[test] + fn eager_bad_tail_precedes_bad_headers_overflow_and_truncation() { + let lexical = "bad".parse::().unwrap_err().to_string(); + for prefix in ["", "-1", "0", "0 0 0 2", "0 0 0 2 1 2 10 11"] { + assert_error(&format!("{prefix} bad"), &lexical); + } + let half = 2.0_f64.powi(usize::BITS as i32 - 1); + assert_error(&format!("{half} {half} 0 2 bad"), &lexical); + assert_error("-1", "bad count -1"); + assert_error("0 -2", "bad count -2"); + assert_error("0 0 0 -3", "bad count -3"); + assert_error("-1 -2 0 -3", "bad count -1"); + assert_error("0 0 NaN -3", "bad count -3"); + } + + #[test] + fn numeric_tails_and_nonfinite_payload_are_preserved() { + let c = decode("0 0 0 2 1 2 10 11 999 NaN inf -inf", PATH).unwrap(); + assert_eq!(c.cpts, [[1.0, 2.0, NOZ, NOW]]); + assert_eq!(c.u, [10.0, 11.0]); + let c = decode("0 0 1 3 NaN inf -inf -0 NaN inf", PATH).unwrap(); + assert!(c.cpts[0][0].is_nan()); + assert_eq!(c.cpts[0][1], f64::INFINITY); + assert_eq!(c.cpts[0][2], f64::NEG_INFINITY); + assert_eq!(c.cpts[0][3].to_bits(), (-0.0_f64).to_bits()); + assert!(c.u[0].is_nan()); + assert_eq!(c.u[1], f64::INFINITY); + } + + #[test] + fn zero_length_curve_and_large_degree_shape() { + let c = decode("0 0 0 2 1 2 10 11", PATH).unwrap(); + assert_eq!(c.p, 0); + assert_eq!(c.u, [10.0, 11.0]); + let knots = (10..19).map(|k| k.to_string()).collect::>(); + let c = decode(&format!("0 7 0 2 1 2 {}", knots.join(" ")), PATH).unwrap(); + assert_eq!(c.p, 7); + assert_eq!(c.u.len(), 9); + } +} diff --git a/crates/difftest/src/fuzz.rs b/crates/difftest/src/fuzz.rs index cfc01428c4..ea012414f7 100644 --- a/crates/difftest/src/fuzz.rs +++ b/crates/difftest/src/fuzz.rs @@ -635,6 +635,8 @@ pub struct FuzzResult { pub notes: Vec, /// Failure to build or run the cases. pub harness_error: Option, + /// Reason the native reference is unsupported; no mutations or workers ran. + pub skipped_platform: Option, } impl FuzzResult { @@ -643,15 +645,21 @@ impl FuzzResult { self.verdicts.iter().filter(|&&x| x == v).count() } - /// No failure and no harness error. + /// At least one verdict, no failure, no harness error and no platform skip. pub fn pass(&self) -> bool { - self.harness_error.is_none() && self.failures.is_empty() && !self.verdicts.is_empty() + self.skipped_platform.is_none() + && self.harness_error.is_none() + && self.failures.is_empty() + && !self.verdicts.is_empty() } } /// One-line summary, e.g. `FUZZ-PASS harness.selftest cases=300 rejected=81 malformed=40 /// ran=179 differs=2 crashes=0 panics=0 timeouts=0` (`ran` includes `differs`). pub fn summary_line(r: &FuzzResult) -> String { + if let Some(reason) = &r.skipped_platform { + return format!("FUZZ-SKIP {} skipped(platform): {reason}", r.unit); + } let mut s = format!( "{} {} cases={} rejected={} malformed={} ran={} differs={} crashes={} panics={} timeouts={}", if r.pass() { "FUZZ-PASS" } else { "FUZZ-FAIL" }, @@ -732,8 +740,15 @@ fn clear_old(dir: &Path) { pub fn run_fuzz(unit: &dyn UnitDiff, ctx: &CaseContext, opts: &FuzzOptions) -> FuzzResult { let mut res = FuzzResult { unit: unit.id().to_string(), + skipped_platform: unit + .reference_platform() + .and_then(|p| p.skip_reason(std::env::consts::ARCH)) + .map(str::to_string), ..FuzzResult::default() }; + if res.skipped_platform.is_some() { + return res; + } if let Err(e) = fuzz_unit(unit, ctx, opts, &mut res) { res.harness_error = Some(e.0); } diff --git a/crates/difftest/src/jobs.rs b/crates/difftest/src/jobs.rs index 492d084ec8..d82db88bb6 100644 --- a/crates/difftest/src/jobs.rs +++ b/crates/difftest/src/jobs.rs @@ -161,10 +161,8 @@ pub struct UnitRun { pub stdout: String, /// The child's stderr, forwarded as is. pub stderr: String, - /// The unit counts as failed. - pub failed: bool, - /// The unit counts as partial. - pub partial: bool, + /// Comparison outcome, including unsupported native references. + pub status: crate::UnitStatus, /// The child hit a setup error (exit status 2), e.g. an unwritable report. pub setup_error: bool, } @@ -181,14 +179,20 @@ fn exit_detail(status: ExitStatus) -> String { format!("exit code {}", status.code().unwrap_or(-1)) } -/// Parses `difftest: 1 unit(s), P passed, Q partial, F failed[...]` into `(Q, F)`. -fn parse_summary(line: &str) -> Option<(usize, usize)> { +/// Parses the aggregate counts `(partial, failed, skipped)` of one child. +fn parse_summary(line: &str) -> Option<(usize, usize, usize)> { let rest = line.strip_prefix("difftest: 1 unit(s), ")?; let mut parts = rest.split(", "); let _passed = parts.next()?.strip_suffix(" passed")?; let partial = parts.next()?.strip_suffix(" partial")?.parse().ok()?; let failed = parts.next()?.split(' ').next()?.parse().ok()?; - Some((partial, failed)) + let skipped = parts + .find_map(|p| p.strip_suffix(" skipped(platform)")) + .map(str::parse) + .transpose() + .ok()? + .unwrap_or(0); + Some((partial, failed, skipped)) } /// Turns a finished child's output into what the parent prints and counts. A child that @@ -197,7 +201,7 @@ pub fn interpret(id: &str, status: Option, stdout: &str, stderr: &st let code = status.and_then(|s| s.code()); let body = stdout.trim_end_matches('\n'); let (block, last) = body.rsplit_once('\n').unwrap_or(("", body)); - if let (Some(c @ 0..=2), Some((partial, failed))) = (code, parse_summary(last)) { + if let (Some(c @ 0..=2), Some((partial, failed, skipped))) = (code, parse_summary(last)) { let mut out = block.to_string(); if !out.is_empty() { out.push('\n'); @@ -205,8 +209,15 @@ pub fn interpret(id: &str, status: Option, stdout: &str, stderr: &st return UnitRun { stdout: out, stderr: stderr.to_string(), - failed: failed > 0, - partial: partial > 0, + status: if failed > 0 { + crate::UnitStatus::Fail + } else if partial > 0 { + crate::UnitStatus::Partial + } else if skipped > 0 { + crate::UnitStatus::SkippedPlatform + } else { + crate::UnitStatus::Green + }, setup_error: c == 2, }; } @@ -226,8 +237,7 @@ pub fn interpret(id: &str, status: Option, stdout: &str, stderr: &st UnitRun { stdout: out, stderr: String::new(), - failed: true, - partial: false, + status: crate::UnitStatus::Fail, setup_error: false, } } @@ -317,6 +327,7 @@ pub fn run_parallel( let mut printed = 0usize; let mut pool = Pool(Vec::new()); let (mut failed, mut partial, mut setup_error) = (0usize, 0usize, false); + let mut skipped = 0usize; let stdout = std::io::stdout(); while printed < units.len() { while pool.0.len() < jobs.max(1) { @@ -361,8 +372,9 @@ pub fn run_parallel( let _ = so.flush(); drop(so); let _ = std::io::stderr().write_all(r.stderr.as_bytes()); - failed += usize::from(r.failed); - partial += usize::from(r.partial && !r.failed); + failed += usize::from(r.status == crate::UnitStatus::Fail); + partial += usize::from(r.status == crate::UnitStatus::Partial); + skipped += usize::from(r.status == crate::UnitStatus::SkippedPlatform); setup_error |= r.setup_error; printed += 1; } @@ -371,9 +383,8 @@ pub fn run_parallel( } } println!( - "difftest: {} unit(s), {} passed, {partial} partial, {failed} failed", - units.len(), - units.len() - failed - partial + "{}", + crate::report::run_summary(units.len(), partial, failed, skipped) ); // Timing goes to stderr so stdout stays identical to a serial run. let sum: Duration = times.iter().sum(); @@ -467,17 +478,42 @@ mod tests { #[test] fn summary_lines() { + assert_eq!( + parse_summary( + "difftest: 1 unit(s), 0 passed, 0 partial, 0 failed, 1 skipped(platform)" + ), + Some((0, 0, 1)) + ); assert_eq!( parse_summary("difftest: 1 unit(s), 0 passed, 1 partial, 0 failed"), - Some((1, 0)) + Some((1, 0, 0)) ); assert_eq!( parse_summary("difftest: 1 unit(s), 0 passed, 0 partial, 1 failed, 1 dropped"), - Some((0, 1)) + Some((0, 1, 0)) ); assert_eq!(parse_summary("PASS a cases=1"), None); } + #[test] + #[cfg(unix)] + fn skipped_child_is_neither_a_pass_nor_a_failure() { + use std::os::unix::process::ExitStatusExt; + let summary = crate::report::run_summary(1, 0, 0, 1); + let stdout = format!("SKIP u skipped(platform): unsupported reference\n{summary}\n"); + let r = interpret("u", Some(ExitStatus::from_raw(0)), &stdout, ""); + assert_eq!(r.status, crate::UnitStatus::SkippedPlatform); + assert!(!r.setup_error); + assert_eq!( + r.stdout, + "SKIP u skipped(platform): unsupported reference\n" + ); + assert_eq!( + crate::report::run_summary(3, 1, 0, 1), + "difftest: 3 unit(s), 1 passed, 1 partial, 0 failed, 1 skipped(platform)" + ); + } + #[cfg(unix)] #[test] fn interprets_children() { @@ -490,31 +526,32 @@ mod tests { "", ); assert_eq!(r.stdout, "PASS u cases=1\n report: r/u.json\n"); - assert!(!r.failed && !r.partial && !r.setup_error); + assert_eq!(r.status, crate::UnitStatus::Green); + assert!(!r.setup_error); let partial = interpret( "u", Some(ok), "PARTIAL u\ndifftest: 1 unit(s), 0 passed, 1 partial, 0 failed\n", "", ); - assert!(partial.partial && !partial.failed); + assert_eq!(partial.status, crate::UnitStatus::Partial); let fail = interpret( "u", Some(ExitStatus::from_raw(1 << 8)), "FAIL u\ndifftest: 1 unit(s), 0 passed, 0 partial, 1 failed\n", "", ); - assert!(fail.failed); + assert_eq!(fail.status, crate::UnitStatus::Fail); // SIGSEGV let crash = interpret("u", Some(ExitStatus::from_raw(11)), "", "a\nboom\n"); - assert!(crash.failed); + assert_eq!(crash.status, crate::UnitStatus::Fail); assert_eq!( crash.stdout, "FAIL u (worker process killed by signal 11)\n stderr: a\n stderr: boom\n" ); // A clean exit without the summary line is a crash too. let cut = interpret("u", Some(ok), "PASS u\n", ""); - assert!(cut.failed); + assert_eq!(cut.status, crate::UnitStatus::Fail); assert!(cut .stdout .starts_with("PASS u\nFAIL u (worker process exit code 0)")); diff --git a/crates/difftest/src/known.rs b/crates/difftest/src/known.rs index 97196cd073..34f10e77b0 100644 --- a/crates/difftest/src/known.rs +++ b/crates/difftest/src/known.rs @@ -1,6 +1,10 @@ //! Known mismatches: cases that genuinely disagree with the C++ reference and are -//! tracked in one checked-in list, `crates/difftest/known_mismatches.toml`, until the -//! port is fixed. +//! tracked in checked-in lists, one file per unit, +//! `crates/difftest/known_mismatches/.toml`, until the port is fixed. +//! +//! One file per unit (never one shared list) so parallel PRs touching different units +//! never conflict. A unit with no file has no known mismatches; delete the file when +//! its last entry goes. //! //! A listed case that fails its comparison is classified //! [`CaseOutcome::KnownMismatch`](crate::runner::CaseOutcome::KnownMismatch): it is not a @@ -8,13 +12,20 @@ //! printed and written to the report like a failure. A listed case that does anything //! else (passes, defers, or is no longer produced) fails the unit, so the list cannot go //! stale. Never add a case here to get a unit green; every entry needs a -//! `plan/findings.md` entry. +//! `plan/findings/.md` entry. Every entry's `unit` must equal the file name. +//! +//! An entry whose disagreement comes from the C library the C++ reference runs on (e.g. +//! glibc's allocator reusing a freed address under an upstream use-after-free) names it +//! in `libc`. On any other C library the entry does not apply: the case runs as an +//! ordinary case and must pass. //! //! ```toml +//! # crates/difftest/known_mismatches/curve.curve_p1.toml //! [[mismatch]] //! unit = "curve.curve_p1" //! case = "gen0042" -//! why = "C++ succeeds with 0 points, Rust returns status_3 (plan/findings.md: ...)" +//! why = "C++ succeeds with 0 points, Rust returns status_3 (plan/findings/...)" +//! # libc = "glibc" # optional: only where the C++ reference runs on glibc //! ``` use std::path::Path; @@ -23,19 +34,54 @@ use serde::Deserialize; use crate::HarnessError; -/// Path of the list, relative to the repository root. -pub const KNOWN_MISMATCHES: &str = "crates/difftest/known_mismatches.toml"; +/// Directory of the per-unit lists, relative to the repository root. +pub const KNOWN_MISMATCHES_DIR: &str = "crates/difftest/known_mismatches"; + +/// The former single shared list; it must not come back (one file per unit instead). +pub const RETIRED_SHARED_LIST: &str = "crates/difftest/known_mismatches.toml"; + +/// Path of `unit`'s list, relative to the repository root. +pub fn path_for(unit: &str) -> String { + format!("{KNOWN_MISMATCHES_DIR}/{unit}.toml") +} /// One listed case. #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] #[serde(deny_unknown_fields)] pub struct KnownMismatch { - /// Unit id. + /// Unit id (must equal the file name). pub unit: String, /// Case name within the unit. pub case: String, /// Why it disagrees (C++ vs Rust status) and where it is recorded. pub why: String, + /// The C library the disagreement depends on; `None` applies everywhere. + #[serde(default)] + pub libc: Option, +} + +impl KnownMismatch { + /// Whether the entry applies to this build of the C++ reference. + pub fn applies(&self) -> bool { + self.libc.is_none_or(Libc::is_current) + } +} + +/// A C library a known mismatch can be restricted to ([`KnownMismatch::libc`]). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Libc { + /// GNU libc (Linux `*-gnu` targets), whose allocator the CI reference uses. + Glibc, +} + +impl Libc { + /// Whether the C++ reference in this process runs on this C library. + pub fn is_current(self) -> bool { + match self { + Self::Glibc => cfg!(all(target_os = "linux", target_env = "gnu")), + } + } } #[derive(Debug, Default, Deserialize)] @@ -45,26 +91,36 @@ struct File { mismatch: Vec, } -/// Parses the list's text. -pub fn parse(text: &str) -> Result, HarnessError> { - let f: File = - toml::from_str(text).map_err(|e| HarnessError::new(format!("{KNOWN_MISMATCHES}: {e}")))?; +/// Parses the text of `unit`'s list, rejecting entries for any other unit. +pub fn parse(text: &str, unit: &str) -> Result, HarnessError> { + let path = path_for(unit); + let f: File = toml::from_str(text).map_err(|e| HarnessError::new(format!("{path}: {e}")))?; + if let Some(m) = f.mismatch.iter().find(|m| m.unit != unit) { + return Err(HarnessError::new(format!( + "{path}: entry for case `{}` names unit `{}`; it belongs in {}", + m.case, + m.unit, + path_for(&m.unit) + ))); + } Ok(f.mismatch) } -/// The entries of `unit` from `/crates/difftest/known_mismatches.toml` (empty if -/// the file does not exist). +/// The entries of `unit` from `/crates/difftest/known_mismatches/.toml` +/// (empty if the file does not exist). pub fn for_unit(root: &Path, unit: &str) -> Result, HarnessError> { - let path = root.join(KNOWN_MISMATCHES); + if unit.is_empty() || unit.contains(['/', '\\']) || unit.starts_with('.') { + return Err(HarnessError::new(format!( + "{KNOWN_MISMATCHES_DIR}: `{unit}` is not a unit id" + ))); + } + let path = root.join(path_for(unit)); let text = match std::fs::read_to_string(&path) { Ok(t) => t, Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), Err(e) => return Err(HarnessError::new(format!("{}: {e}", path.display()))), }; - Ok(parse(&text)? - .into_iter() - .filter(|m| m.unit == unit) - .collect()) + parse(&text, unit) } #[cfg(test)] @@ -72,17 +128,78 @@ mod tests { use super::*; #[test] - fn parses_entries_and_rejects_typos() { - let v = parse("[[mismatch]]\nunit = \"a.b\"\ncase = \"c\"\nwhy = \"w\"\n").unwrap(); + fn parses_entries_and_rejects_typos_and_foreign_units() { + let v = parse( + "[[mismatch]]\nunit = \"a.b\"\ncase = \"c\"\nwhy = \"w\"\n", + "a.b", + ) + .unwrap(); assert_eq!(v.len(), 1); assert_eq!((v[0].unit.as_str(), v[0].case.as_str()), ("a.b", "c")); - assert!(parse("").unwrap().is_empty()); - assert!(parse("[[mismatch]]\nunit = \"a\"\ncase = \"c\"\nwhy = \"w\"\nx = 1\n").is_err()); + assert!(parse("", "a.b").unwrap().is_empty()); + assert!(parse( + "[[mismatch]]\nunit = \"a.b\"\ncase = \"c\"\nwhy = \"w\"\nx = 1\n", + "a.b" + ) + .is_err()); + let e = parse( + "[[mismatch]]\nunit = \"a.c\"\ncase = \"c\"\nwhy = \"w\"\n", + "a.b", + ) + .unwrap_err(); + assert!(e.0.contains("known_mismatches/a.c.toml"), "{}", e.0); + assert!(for_unit(Path::new("."), "../x").is_err()); } #[test] - fn checked_in_list_parses() { + fn libc_restricted_entries_apply_only_on_that_libc() { + let entry = |libc: &str| { + parse( + &format!("[[mismatch]]\nunit = \"a.b\"\ncase = \"c\"\nwhy = \"w\"\n{libc}"), + "a.b", + ) + }; + let any = entry("").unwrap(); + assert_eq!(any[0].libc, None); + assert!(any[0].applies()); + let glibc = entry("libc = \"glibc\"\n").unwrap(); + assert_eq!(glibc[0].libc, Some(Libc::Glibc)); + assert_eq!( + glibc[0].applies(), + cfg!(all(target_os = "linux", target_env = "gnu")) + ); + assert!(entry("libc = \"musl\"\n").is_err()); + } + + #[test] + fn checked_in_lists_parse_and_match_their_file_names() { let root = crate::case::repo_root(); assert!(for_unit(&root, "no.such.unit").unwrap().is_empty()); + assert!( + !root.join(RETIRED_SHARED_LIST).exists(), + "{RETIRED_SHARED_LIST} is retired: put entries in {KNOWN_MISMATCHES_DIR}/.toml" + ); + let units: Vec = crate::plan::load(&root) + .unwrap() + .into_iter() + .map(|u| u.id) + .collect(); + let mut files: Vec<_> = std::fs::read_dir(root.join(KNOWN_MISMATCHES_DIR)) + .unwrap() + .map(|e| e.unwrap().path()) + .collect(); + files.sort(); + assert!(!files.is_empty()); + for p in files { + assert_eq!( + p.extension().and_then(|e| e.to_str()), + Some("toml"), + "{p:?}" + ); + let unit = p.file_stem().and_then(|s| s.to_str()).unwrap(); + assert!(units.iter().any(|u| u == unit), "{p:?}: no such unit"); + let v = for_unit(&root, unit).unwrap(); + assert!(!v.is_empty(), "{p:?}: empty list; delete the file"); + } } } diff --git a/crates/difftest/src/lib.rs b/crates/difftest/src/lib.rs index d9f5d70247..626a03631e 100644 --- a/crates/difftest/src/lib.rs +++ b/crates/difftest/src/lib.rs @@ -19,11 +19,13 @@ pub mod json; pub mod known; pub mod output; pub mod plan; +pub mod platform; pub mod report; pub mod rng; pub mod runner; pub mod solidcraft; pub mod ssi; +pub mod step; pub mod util; pub use case::{Case, CaseContext, CaseSource}; diff --git a/crates/difftest/src/main.rs b/crates/difftest/src/main.rs index 4afe77ba7c..76ee7d9e3b 100644 --- a/crates/difftest/src/main.rs +++ b/crates/difftest/src/main.rs @@ -12,7 +12,7 @@ //! --jobs N (-j; default 1, 0 = all cores) # units in N child processes (`difftest::jobs`) //! cargo run -p difftest --features cpp -- --unit --fuzz N [--fuzz-out ] # FFI guard probing //! cargo run -p difftest --features cpp -- --ssi-corpus [--tol 1e-6|default] [--jobs N] [glob] -//! cargo run -p difftest -- --ssi-corpus [glob] # the `ssi` engine only; no `cpp` feature needed +//! cargo run -p difftest -- --ssi-corpus [glob] # the `ssi` and `solidcore-ssi` engines; no `cpp` needed //! cargo run -p difftest -- --solidcraft-cases [glob] # Rust only; no `cpp` feature needed //! cargo run -p difftest --features cpp -- --green --digest-dir [--digest-debug] # Rust side //! only, one `/.digest` per unit (native vs wasm32: scripts/wasm-digest.sh) @@ -30,10 +30,11 @@ use difftest::{fuzz, plan, registry, report, run_unit, CaseContext, RunOptions, const USAGE: &str = "usage: difftest [--unit ]... [--green] [--all] [--list] [--status-of ] [--seeds N] [--filter ] [--perturb] [--report-dir ] [--no-report] [--jobs N] - [--baseline ] [--digest-dir [--digest-debug]] + [--baseline ] [--compare-reports ] [--digest-dir [--digest-debug]] difftest --unit ... --fuzz N [--fuzz-out ] [--seeds N] [--filter ] difftest --ssi-corpus [--tol 1e-6|default] [--jobs N] [--report-dir ] [--no-report] [case-glob] - difftest --solidcraft-cases [--report-dir ] [--no-report] [case-glob]"; + difftest --solidcraft-cases [--report-dir ] [--no-report] [case-glob] + difftest --step-corpus [--jobs N] [--report-dir ] [--no-report] [file-glob]"; /// Default child processes of `--ssi-corpus`. const DEFAULT_SSI_JOBS: usize = 2; @@ -62,8 +63,10 @@ struct Args { report_dir: Option, no_report: bool, baseline: Option, + compare_reports: Option, ssi_corpus: bool, solidcraft_cases: bool, + step_corpus: bool, tol: Option, jobs: Option, glob: Option, @@ -94,8 +97,12 @@ fn parse_args() -> Result { "--report-dir" => a.report_dir = Some(PathBuf::from(val("--report-dir")?)), "--no-report" => a.no_report = true, "--baseline" => a.baseline = Some(PathBuf::from(val("--baseline")?)), + "--compare-reports" => { + a.compare_reports = Some(PathBuf::from(val("--compare-reports")?)); + } "--ssi-corpus" => a.ssi_corpus = true, "--solidcraft-cases" => a.solidcraft_cases = true, + "--step-corpus" => a.step_corpus = true, "--tol" => a.tol = Some(val("--tol")?), "--jobs" | "-j" => { let v = val("--jobs")?; @@ -114,7 +121,7 @@ fn parse_args() -> Result { } "-h" | "--help" => return Err(String::new()), other - if (a.ssi_corpus || a.solidcraft_cases) + if (a.ssi_corpus || a.solidcraft_cases || a.step_corpus) && !other.starts_with('-') && a.glob.is_none() => { @@ -135,6 +142,10 @@ fn main() -> ExitCode { if raw.first().map(String::as_str) == Some("--ssi-case") { return difftest::ssi::run_child(raw.get(1..).unwrap_or(&[])); } + // Child of `--step-corpus`: one kernel on one body (see `difftest::step::run_child`). + if raw.first().map(String::as_str) == Some("--step-case") { + return difftest::step::run_child(raw.get(1..).unwrap_or(&[])); + } // Child of `--fuzz`: one batch of fuzz cases (see `difftest::fuzz::run_child`). if raw.first().map(String::as_str) == Some("--fuzz-exec") { return fuzz::run_child(raw.get(1..).unwrap_or(&[])); @@ -165,12 +176,24 @@ fn main() -> ExitCode { || args.digest_dir.is_some() || args.fuzz.is_some() || args.ssi_corpus - || args.solidcraft_cases) + || args.solidcraft_cases + || args.step_corpus) { eprintln!("difftest: --baseline requires an unfiltered comparison run"); return ExitCode::from(2); } + // Without the C++ kernel it builds the Rust side alone. + if args.step_corpus { + let opts = difftest::step::CorpusOptions { + glob: args.glob.clone(), + jobs: args.jobs.unwrap_or(DEFAULT_SSI_JOBS).max(1), + report_dir: args.report_dir.clone(), + no_report: args.no_report, + }; + return difftest::step::run_corpus(&root, &opts); + } + if args.solidcraft_cases { let opts = difftest::solidcraft::CasesOptions { glob: args.glob.clone(), @@ -195,6 +218,17 @@ fn main() -> ExitCode { return difftest::ssi::run_corpus(&root, &opts); } + // Reports only: no C++ kernel needed. + if let Some(current) = &args.compare_reports { + return match Waivers::load(&root) { + Ok(waivers) => compare_reports_main(&args, current, &waivers), + Err(e) => { + eprintln!("difftest: {e}"); + ExitCode::from(2) + } + }; + } + if !solidcore_ffi::LINKED { eprintln!( "difftest: built without the C++ kernel; run with \ @@ -252,9 +286,7 @@ fn main() -> ExitCode { let res = run_unit(unit.as_ref(), &ctx, &opts); // The value-share floor is part of the gate (`--green` enforces it too). match baseline::value_share(&res) { - Some(v) - if res.status() != UnitStatus::Fail && waivers.covering(&v).is_none() => - { + Some(v) if res.pass() && waivers.covering(&v).is_none() => { eprintln!("difftest: {id}: {}", v.detail); UnitStatus::Fail } @@ -380,6 +412,7 @@ fn main() -> ExitCode { let mut failed = 0usize; let mut partial = 0usize; + let mut skipped = 0usize; for id in &wanted { let Some(unit) = reg.iter().find(|u| u.id() == id.as_str()) else { println!("FAIL {id} (no difftest runner registered for this unit)"); @@ -387,7 +420,7 @@ fn main() -> ExitCode { continue; }; let res = run_unit(unit.as_ref(), &ctx, &opts); - if args.perturb { + if args.perturb && res.status() != UnitStatus::SkippedPlatform { let (n, caught) = res.perturb_stats(); let ok = n > 0 && n == caught; let unperturbed = res @@ -455,18 +488,12 @@ fn main() -> ExitCode { .iter() .find(|p| p.id == id.as_str()) .map(|p| p.status.as_str()); - let mut unit_failed = match res.status() { - UnitStatus::Fail => true, - UnitStatus::Partial if plan_status == Some("green") => { - // Green units cannot contain deferred or known mismatches. - println!( - " FAIL {id}: plan status is green but cases are deferred or known mismatches" - ); - true - } - UnitStatus::Partial | UnitStatus::Green => false, - }; - if !unit_failed { + let gate_error = selection_gate(res.status(), plan_status, args.baseline.is_some()); + if let Some(reason) = gate_error { + println!(" FAIL {id}: {reason}"); + } + let mut unit_failed = res.status() == UnitStatus::Fail || gate_error.is_some(); + if !unit_failed && res.status() != UnitStatus::SkippedPlatform { let mut violations: Vec = baseline::value_share(&res).into_iter().collect(); if let (Some(dir), None) = (&args.baseline, &args.filter) { match baseline::load_baseline(dir, id) { @@ -484,8 +511,10 @@ fn main() -> ExitCode { failed += 1; } else if res.status() == UnitStatus::Partial { partial += 1; + } else if res.status() == UnitStatus::SkippedPlatform { + skipped += 1; } - if !args.no_report { + if !args.no_report && !args.perturb { match report::write_unit_report(&report_dir, &res) { Ok(p) => println!( " report: {}", @@ -504,9 +533,8 @@ fn main() -> ExitCode { String::new() }; println!( - "difftest: {} unit(s), {} passed, {partial} partial, {failed} failed{dropped_note}", - wanted.len(), - wanted.len() - failed - partial + "{}{dropped_note}", + report::run_summary(wanted.len(), partial, failed, skipped) ); if setup_error { ExitCode::from(2) @@ -517,7 +545,100 @@ fn main() -> ExitCode { } } +/// Selection constraints are not case-level waivers. An unsupported oracle cannot +/// satisfy an exact-base run, including when that baseline has no earlier unit report. +fn selection_gate( + status: UnitStatus, + plan_status: Option<&str>, + has_baseline: bool, +) -> Option<&'static str> { + match status { + UnitStatus::Partial if plan_status == Some("green") => { + Some("plan status is green but cases are deferred or known mismatches") + } + UnitStatus::SkippedPlatform if has_baseline => { + Some("skipped reference cannot validate an exact-base comparison") + } + _ => None, + } +} + /// Prints gate violations (waived ones with their reason); true if any is not waived. +/// `--compare-reports DIR --baseline BASE`: the regression ratchet applied to the finished +/// reports of a sweep run elsewhere (CI runs the sweep while the exact-base artifact is +/// still being validated). Runs no case and needs no C++. The sweep itself already enforced +/// per-unit status and the value-share floor; this adds exactly the `--baseline` checks: +/// every green/partial baseline unit still has a report, seeds match, and no case is lost. +fn compare_reports_main(args: &Args, current: &Path, waivers: &Waivers) -> ExitCode { + let Some(base_dir) = &args.baseline else { + eprintln!("difftest: --compare-reports requires --baseline"); + return ExitCode::from(2); + }; + if args.green + || args.all + || args.perturb + || args.filter.is_some() + || args.seeds.is_some() + || !args.units.is_empty() + { + eprintln!("difftest: --compare-reports takes only --baseline"); + return ExitCode::from(2); + } + let setup = |e: difftest::HarnessError| { + eprintln!("difftest: {e}"); + ExitCode::from(2) + }; + let base_units = match baseline::baseline_units(base_dir) { + Ok(u) => u, + Err(e) => return setup(e), + }; + let now_units = match baseline::report_units(current) { + Ok(u) => u, + Err(e) => return setup(e), + }; + let mut failed = 0usize; + for u in base_units.iter().filter(|u| !now_units.contains(u)) { + let v = Violation { + unit: u.clone(), + check: Check::Ratchet, + case: None, + detail: format!("unit `{u}` passed in the baseline but the sweep produced no report"), + }; + println!("{u}:"); + if print_violations(&[v], waivers) { + failed += 1; + } + } + for unit in &now_units { + let (b, n) = match ( + baseline::load_baseline(base_dir, unit), + baseline::load_baseline(current, unit), + ) { + (Ok(Some(b)), Ok(Some(n))) => (b, n), + (Ok(None), Ok(Some(_))) => { + println!("{unit}: no earlier report (new unit)"); + continue; + } + (Err(e), _) | (_, Err(e)) => return setup(e), + (_, Ok(None)) => return setup(difftest::HarnessError::new("report disappeared")), + }; + let violations = baseline::ratchet_reports(&b, &n); + if violations.is_empty() { + println!("OK {unit} ({} baseline cases retained)", b.cases.len()); + } else { + println!("{unit}:"); + if print_violations(&violations, waivers) { + failed += 1; + } + } + } + println!( + "difftest: compared {} reports against the baseline: {failed} unit(s) regressed", + now_units.len() + ); + ExitCode::from(u8::from(failed > 0)) +} + fn print_violations(violations: &[Violation], waivers: &Waivers) -> bool { let (waived, open): (Vec<&Violation>, Vec<&Violation>) = violations .iter() @@ -634,6 +755,7 @@ fn fuzz_main(args: &Args, ctx: &CaseContext, root: &Path) -> ExitCode { let mut seen = std::collections::BTreeSet::new(); wanted.retain(|w| seen.insert(w.clone())); let mut failed = 0usize; + let mut skipped = 0usize; for id in &wanted { let Some(unit) = reg.iter().find(|u| u.id() == id.as_str()) else { println!("FUZZ-FAIL {id} (no difftest runner registered for this unit)"); @@ -642,6 +764,10 @@ fn fuzz_main(args: &Args, ctx: &CaseContext, root: &Path) -> ExitCode { }; let res = fuzz::run_fuzz(unit.as_ref(), ctx, &opts); println!("{}", fuzz::summary_line(&res)); + if res.skipped_platform.is_some() { + skipped += 1; + continue; + } for f in res.failures.iter().take(MAX_FUZZ_LINES) { let file = f.file.as_deref().map_or_else(String::new, |p| { format!(" -> {}", p.strip_prefix(root).unwrap_or(p).display()) @@ -667,8 +793,13 @@ fn fuzz_main(args: &Args, ctx: &CaseContext, root: &Path) -> ExitCode { failed += 1; } } + let skipped_note = if skipped > 0 { + format!(", {skipped} skipped(platform)") + } else { + String::new() + }; println!( - "difftest --fuzz: {} unit(s), {failed} failed, {:.1} s", + "difftest --fuzz: {} unit(s), {failed} failed, {:.1} s{skipped_note}", wanted.len(), start.elapsed().as_secs_f64() ); @@ -678,3 +809,21 @@ fn fuzz_main(args: &Args, ctx: &CaseContext, root: &Path) -> ExitCode { ExitCode::SUCCESS } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn platform_skips_are_not_exact_base_comparisons() { + for plan in [None, Some("green"), Some("partial")] { + assert!(selection_gate(UnitStatus::SkippedPlatform, plan, false).is_none()); + assert!(selection_gate(UnitStatus::SkippedPlatform, plan, true).is_some()); + assert!(selection_gate(UnitStatus::Green, plan, true).is_none()); + } + // A real deferral in a green unit must still fail, on every platform. + assert!(selection_gate(UnitStatus::Partial, Some("green"), false).is_some()); + assert!(selection_gate(UnitStatus::Partial, Some("green"), true).is_some()); + assert!(selection_gate(UnitStatus::Partial, Some("partial"), true).is_none()); + } +} diff --git a/crates/difftest/src/plan.rs b/crates/difftest/src/plan.rs index 2fcfb3dd69..e9938e4815 100644 --- a/crates/difftest/src/plan.rs +++ b/crates/difftest/src/plan.rs @@ -62,10 +62,15 @@ pub fn parse_unit(text: &str) -> Result { .get("id") .and_then(toml::Value::as_str) .ok_or_else(|| "missing id".to_string())?; - let status = v - .get("status") - .and_then(toml::Value::as_str) - .unwrap_or("todo"); + let status = match v.get("status") { + None => "todo", + Some(value) => value.as_str().ok_or_else(|| { + format!( + "unit {id}: status must be a string, found {}", + value.type_str() + ) + })?, + }; if !STATUSES.contains(&status) { return Err(format!( "unit {id}: unknown status `{status}` (expected one of {})", @@ -110,6 +115,54 @@ mod tests { assert!(parse_unit("status = \"green\"\n").is_err()); } + #[test] + fn rejects_non_string_statuses() { + for (status, kind) in [ + ("[\"green\"]", "array"), + ("1", "integer"), + ("true", "boolean"), + ("{ value = \"green\" }", "table"), + ] { + let err = + parse_unit(&format!("id = \"malformed.unit\"\nstatus = {status}\n")).unwrap_err(); + assert_eq!( + err, + format!("unit malformed.unit: status must be a string, found {kind}") + ); + } + } + + #[test] + fn accepts_all_string_statuses() { + for status in STATUSES { + let unit = parse_unit(&format!("id = \"x\"\nstatus = \"{status}\"\n")).unwrap(); + assert_eq!(unit.id, "x"); + assert_eq!(unit.status, *status); + } + } + + #[test] + fn missing_status_defaults_to_todo() { + let unit = parse_unit("id = \"x\"\n").unwrap(); + assert_eq!(unit.status, "todo"); + } + + #[test] + fn load_rejects_non_string_status_instead_of_omitting_unit() { + let dir = std::env::temp_dir().join(format!( + "difftest-plan-malformed-status-{}", + std::process::id() + )); + let units = dir.join("plan").join("units"); + fs::create_dir_all(&units).unwrap(); + fs::write(units.join("a.toml"), "id = \"a\"\nstatus = \"green\"\n").unwrap(); + fs::write(units.join("b.toml"), "id = \"b\"\nstatus = [\"green\"]\n").unwrap(); + let err = load(&dir).unwrap_err().to_string(); + assert!(err.contains("b.toml")); + assert!(err.contains("unit b: status must be a string, found array")); + fs::remove_dir_all(&dir).unwrap(); + } + #[test] fn loads_unit_dir_and_checks_file_names() { let dir = std::env::temp_dir().join(format!("difftest-plan-{}", std::process::id())); diff --git a/crates/difftest/src/platform.rs b/crates/difftest/src/platform.rs new file mode 100644 index 0000000000..5f6319da6a --- /dev/null +++ b/crates/difftest/src/platform.rs @@ -0,0 +1,185 @@ +//! Platform requirements of the native reference, independent of Rust portability. +//! +//! Some pinned C++ operations convert out-of-range floats to integers (undefined +//! behavior). The Rust port deliberately reproduces the x86-64 reference on every +//! target. A different native instruction cannot act as its parity oracle (#448). + +/// A documented architecture requirement of a unit's C++ reference. +#[derive(Debug, Clone, Copy)] +pub struct ReferencePlatform { + /// Architecture name, using Rust's `std::env::consts::ARCH` spelling. + pub arch: &'static str, + /// Why other architectures cannot supply this unit's reference results. + pub reason: &'static str, +} + +impl ReferencePlatform { + /// Reason to skip comparison on `arch`; `None` when it is supported. + pub fn skip_reason(self, arch: &str) -> Option<&'static str> { + (arch != self.arch).then_some(self.reason) + } +} + +/// Native voxel mapping and edge-length histograms depend on x86-64 float casts. +pub const X86_64_HASH_CASTS: ReferencePlatform = ReferencePlatform { + arch: "x86_64", + reason: "C++ float-to-ULONG conversions require x86-64 (UB-35/UB-194, #448)", +}; + +/// Native twist-period reduction uses an out-of-range float-to-LONG conversion. +pub const X86_64_TWIST_CASTS: ReferencePlatform = ReferencePlatform { + arch: "x86_64", + reason: "C++ float-to-LONG conversion requires x86-64 (TwistVolume::EvaluateBoundingBoxSimple, #448)", +}; + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::Cell; + + use crate::{Case, CaseContext, CaseSource, HarnessError, Output, RunOptions, UnitDiff}; + + #[test] + fn reference_requirements_are_architecture_specific() { + for platform in [X86_64_HASH_CASTS, X86_64_TWIST_CASTS] { + assert_eq!(platform.skip_reason("x86_64"), None); + for arch in ["aarch64", "arm", "x86", "wasm32"] { + assert_eq!(platform.skip_reason(arch), Some(platform.reason)); + } + } + } + + #[derive(Default)] + struct Restricted { + cases: Cell, + rust: Cell, + } + + impl UnitDiff for Restricted { + fn id(&self) -> &'static str { + "test.platform" + } + + fn reference_platform(&self) -> Option { + Some(ReferencePlatform { + arch: "unsupported-test-architecture", + reason: "test reference is unavailable", + }) + } + + fn cases(&self, _: &CaseContext) -> Result, HarnessError> { + self.cases.set(self.cases.get() + 1); + Ok(vec![Case { + unit: self.id().into(), + name: "value".into(), + source: CaseSource::Inline, + inputs: serde_json::Value::Null, + }]) + } + + fn run_cpp(&self, _: &Case) -> Result { + panic!("unsupported C++ must never run") + } + + fn run_rust(&self, _: &Case) -> Result { + self.rust.set(self.rust.get() + 1); + Ok(Output::new().scalar("value", 42.0)) + } + } + + #[test] + fn unsupported_comparisons_skip_before_fixture_construction_and_never_pass() { + let unit = Restricted::default(); + let ctx = CaseContext { + root: crate::case::repo_root(), + seeds: 16, + }; + for perturb in [false, true] { + let r = crate::run_unit( + &unit, + &ctx, + &RunOptions { + perturb, + ..RunOptions::default() + }, + ); + assert_eq!(r.status(), crate::UnitStatus::SkippedPlatform); + assert!(!r.pass()); + assert!(r.cases.is_empty()); + assert_eq!((unit.cases.get(), unit.rust.get()), (0, 0)); + let report = crate::report::unit_report(&r); + assert_eq!(report["status"], "skipped(platform)"); + assert_eq!(report["pass"], false); + assert_eq!(report["cases_passed"], 0); + assert_eq!(report["skipped_platform"], "test reference is unavailable"); + assert!(crate::report::summary_line(&r) + .starts_with("SKIP test.platform skipped(platform):")); + // A platform-only report must never replace a trusted CI baseline. + assert!(crate::baseline::parse_baseline(&report.to_string()).is_err()); + let baseline = crate::baseline::Baseline { + unit: unit.id().into(), + status: Some("green".into()), + seeds: Some(16), + cases: vec![crate::baseline::BaselineCase { + name: "value".into(), + outcome: "pass".into(), + generated: false, + compared_values: true, + }], + }; + assert!(!crate::baseline::ratchet(&baseline, &r).is_empty()); + } + } + + #[test] + fn fuzz_skips_without_workers_but_rust_digests_still_run() { + let unit = Restricted::default(); + let ctx = CaseContext { + root: crate::case::repo_root(), + seeds: 16, + }; + let r = crate::fuzz::run_fuzz( + &unit, + &ctx, + &crate::fuzz::FuzzOptions { + cases: 10, + out_dir: ctx.root.join("bin/platform-test-unused"), + filter: None, + }, + ); + assert!(r.skipped_platform.is_some()); + assert!(!r.pass()); + assert!(r.verdicts.is_empty()); + assert_eq!((unit.cases.get(), unit.rust.get()), (0, 0)); + assert!(crate::fuzz::summary_line(&r).starts_with("FUZZ-SKIP test.platform")); + + let digest = crate::runner::digest_unit(&unit, &ctx, &RunOptions::default(), false); + assert!(digest.harness_error.is_none()); + assert_eq!(digest.lines.len(), 1); + assert_eq!((unit.cases.get(), unit.rust.get()), (1, 1)); + } + + #[test] + #[cfg(feature = "cpp")] + fn registry_marks_only_the_documented_units() { + let marked: Vec<_> = crate::registry() + .iter() + .filter_map(|u| { + u.reference_platform().map(|p| { + assert_eq!(p.arch, "x86_64"); + u.id() + }) + }) + .collect(); + assert_eq!( + marked, + [ + "geom.hash_table", + "heal.heal_data_p1", + "heal.heal_data_p2", + "heal.heal_data_p3", + "surface.twist_volume" + ] + ); + } +} diff --git a/crates/difftest/src/report.rs b/crates/difftest/src/report.rs index c4fff7cdfc..e902b32f73 100644 --- a/crates/difftest/src/report.rs +++ b/crates/difftest/src/report.rs @@ -87,6 +87,9 @@ pub fn unit_report(r: &UnitResult) -> Value { m.insert("unit".into(), json!(r.unit)); m.insert("pass".into(), json!(r.pass())); m.insert("status".into(), json!(r.status().as_str())); + if let Some(reason) = &r.skipped_platform { + m.insert("skipped_platform".into(), json!(reason)); + } m.insert("rel_tol".into(), num(REL_TOL)); m.insert("abs_floors".into(), Value::Array(floors)); m.insert("seeds".into(), json!(r.seeds)); @@ -134,7 +137,7 @@ pub fn write_unit_report(dir: &Path, r: &UnitResult) -> Result String { + let mut s = format!( + "difftest: {total} unit(s), {} passed, {partial} partial, {failed} failed", + total.saturating_sub(partial + failed + skipped) + ); + if skipped > 0 { + let _ = write!(s, ", {skipped} skipped(platform)"); + } + s +} + /// One-line summary, e.g. /// `PASS harness.selftest cases=40 passed=40 failed=0 deferred=0 max_rel_err=1.1e-16 with_values=36 no_values=4 vacuous=1`. -/// The status word is `PASS` (green), `PARTIAL` or `FAIL`; `with_values` / `no_values` +/// The status word is `PASS` (green), `PARTIAL`, `FAIL` or `SKIP`; a skip prints +/// only the unit and its platform reason. Otherwise, `with_values` / `no_values` /// split the passing cases into those that compared at least one value and those that /// compared nothing (the same error on both sides, or empty outputs); `vacuous` counts /// the `no_values` cases where both sides succeeded, so only the status agreed; with deferred /// cases the line ends with ` blocked_by=(),...`. pub fn summary_line(r: &UnitResult) -> String { + if r.status() == UnitStatus::SkippedPlatform { + return format!( + "SKIP {} skipped(platform): {}", + r.unit, + r.skipped_platform.as_deref().unwrap_or_default() + ); + } let with_values = r.passed_with_values(); let mut s = format!( "{} {} cases={} passed={} failed={} deferred={} max_rel_err={:e} with_values={with_values} no_values={} vacuous={}", @@ -215,6 +239,7 @@ pub fn summary_line(r: &UnitResult) -> String { UnitStatus::Green => "PASS", UnitStatus::Partial => "PARTIAL", UnitStatus::Fail => "FAIL", + UnitStatus::SkippedPlatform => "SKIP", }, r.unit, r.cases.len(), @@ -262,6 +287,7 @@ mod tests { unit: "a.b".into(), cases: Vec::new(), harness_error: None, + skipped_platform: None, abs_floors: Vec::new(), seeds: 16, timing: crate::runner::UnitTiming::default(), diff --git a/crates/difftest/src/runner.rs b/crates/difftest/src/runner.rs index dd18d61828..7c3e449ae7 100644 --- a/crates/difftest/src/runner.rs +++ b/crates/difftest/src/runner.rs @@ -18,6 +18,13 @@ pub trait UnitDiff { /// Unit id as in `plan/units/.toml` (e.g. `nlib.knots`). fn id(&self) -> &'static str; + /// Architecture restriction of the C++ oracle, if any. This does not restrict + /// Rust-only digest runs. Unsupported comparisons are skipped before fixtures + /// are constructed and never count as parity passes. + fn reference_platform(&self) -> Option { + None + } + /// Runs the upstream C++ (via `solidcore_ffi::units::*`). /// Return `Ok(Output::err(..))` when the *operation* fails and `Err` only /// for harness problems (bad inputs); `Err` always fails the case. Native fixture @@ -115,7 +122,7 @@ pub struct CaseResult { pub perturbed: Option<&'static str>, /// The Rust side's [`Output::diagnostics`] (reported, never compared). pub diagnostics: BTreeMap, - /// The case is listed in `known_mismatches.toml` (the entry's `why`). + /// The case is listed in `known_mismatches/.toml` (the entry's `why`). pub known_mismatch: Option, } @@ -133,7 +140,7 @@ pub enum CaseOutcome { Deferred, /// Skipped because the platform's C++ standard library is not libstdc++. SkippedPlatform, - /// A mismatch listed in `known_mismatches.toml` ([`crate::known`]): not a pass, + /// A mismatch listed in `known_mismatches/.toml` ([`crate::known`]): not a pass, /// keeps the unit from being green, reported like a failure. KnownMismatch, } @@ -224,15 +231,18 @@ pub enum UnitStatus { Partial, /// Any failing case, a harness error, no cases, or only rejected fixtures. Fail, + /// The C++ reference is unsupported on this architecture; no cases ran. + SkippedPlatform, } impl UnitStatus { - /// `green`, `partial` or `fail`. + /// `green`, `partial`, `fail` or `skipped(platform)`. pub fn as_str(self) -> &'static str { match self { Self::Green => "green", Self::Partial => "partial", Self::Fail => "fail", + Self::SkippedPlatform => "skipped(platform)", } } } @@ -246,6 +256,8 @@ pub struct UnitResult { pub cases: Vec, /// Failure to build the case list. pub harness_error: Option, + /// Reason the C++ reference cannot run on this architecture (no cases ran). + pub skipped_platform: Option, /// Documented absolute floors used. pub abs_floors: Vec, /// `--seeds` value used. @@ -307,13 +319,13 @@ impl UnitTiming { } impl UnitResult { - /// Green, partial or fail (see [`UnitStatus`]). + /// Comparison status, including an unsupported reference (see [`UnitStatus`]). pub fn status(&self) -> UnitStatus { - if self.harness_error.is_some() - || self.cases.is_empty() - || self.failed() > 0 - || self.cases.len() == self.rejected() - { + if self.harness_error.is_some() || self.failed() > 0 { + UnitStatus::Fail + } else if self.skipped_platform.is_some() { + UnitStatus::SkippedPlatform + } else if self.cases.is_empty() || self.cases.len() == self.rejected() { UnitStatus::Fail } else if self.deferred() > 0 || self.known_mismatches() > 0 { UnitStatus::Partial @@ -324,9 +336,9 @@ impl UnitResult { } } - /// No failure: the unit is green or partial (the exit status is 0). + /// Parity was checked: the unit is green or partial. Skipped units do not pass. pub fn pass(&self) -> bool { - self.status() != UnitStatus::Fail + matches!(self.status(), UnitStatus::Green | UnitStatus::Partial) } /// Number of passing cases. @@ -489,10 +501,17 @@ pub fn run_unit(unit: &dyn UnitDiff, ctx: &CaseContext, opts: &RunOptions) -> Un unit: unit.id().to_string(), cases: Vec::new(), harness_error: None, + skipped_platform: unit + .reference_platform() + .and_then(|p| p.skip_reason(std::env::consts::ARCH)) + .map(str::to_string), abs_floors: unit.abs_floors().to_vec(), seeds: ctx.seeds, timing: UnitTiming::default(), }; + if res.skipped_platform.is_some() { + return res; + } let digest = std::env::var_os("DIFFTEST_RUST_DIGEST").is_some(); let t0 = Instant::now(); let cases = unit.cases(ctx); @@ -520,12 +539,18 @@ pub fn run_unit(unit: &dyn UnitDiff, ctx: &CaseContext, opts: &RunOptions) -> Un if !missing.is_empty() { res.harness_error = Some(format!( "{}: no such case(s): {}", - crate::known::KNOWN_MISMATCHES, + crate::known::path_for(unit.id()), missing.join(", ") )); return res; } } + // Entries for another C library are checked for existence above but do not apply + // here: their cases run as ordinary cases. + let known: Vec<_> = known + .into_iter() + .filter(crate::known::KnownMismatch::applies) + .collect(); for case in cases { if let Some(f) = &opts.filter { if !case.name.contains(f.as_str()) { @@ -655,7 +680,7 @@ pub fn run_unit(unit: &dyn UnitDiff, ctx: &CaseContext, opts: &RunOptions) -> Un { cr.harness_error = Some(format!( "listed in {} but the case is now {}: remove the entry", - crate::known::KNOWN_MISMATCHES, + crate::known::path_for(unit.id()), cr.outcome().as_str() )); } @@ -799,6 +824,7 @@ mod tests { unit: "u".into(), cases: vec![case(None, true), case(d(), true)], harness_error: None, + skipped_platform: None, abs_floors: Vec::new(), seeds: 0, timing: UnitTiming::default(), @@ -1012,9 +1038,9 @@ mod tests { fn known_root(tag: &str, list: &str) -> CaseContext { let root = std::env::temp_dir().join(format!("difftest-known-{tag}-{}", std::process::id())); - let dir = root.join("crates/difftest"); + let dir = root.join(crate::known::KNOWN_MISMATCHES_DIR); std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join("known_mismatches.toml"), list).unwrap(); + std::fs::write(dir.join("empty.unit.toml"), list).unwrap(); CaseContext { root, seeds: 0 } } diff --git a/crates/difftest/src/solidcraft.rs b/crates/difftest/src/solidcraft.rs index 976bcffafc..88621b57e5 100644 --- a/crates/difftest/src/solidcraft.rs +++ b/crates/difftest/src/solidcraft.rs @@ -24,14 +24,19 @@ use serde::Deserialize; use serde_json::{json, Value}; use solidcore::boolean::api_brep as boolean; +use solidcore::curve::curve::Curve; use solidcore::geom::attribute::{AttributeStore, ObjectKey}; use solidcore::geom::object::ObjectRef; use solidcore::geom::vector3d::{Point3, Vec3}; use solidcore::kernel::Kernel; use solidcore::mass::integrator::properties::{brep_volume, PropertyAttributes}; +use solidcore::primitive::api_curves as curves; use solidcore::primitive::api_primitives as primitives; +use solidcore::primitive::primitive_creation::SweepOptions; use solidcore::surface::surface::p2::SurfaceKind; use solidcore::surface::surface::Surface; +use solidcore::sweep::api_brep as sweeps; +use solidcore::sweep::api_surfaces::create_skin_primitive; use solidcore::topology::brep::p1::regions; use solidcore::topology::brep::p2::ArenaNotifyState; use solidcore::topology::brep::p3; @@ -159,6 +164,103 @@ pub enum Step { /// Tool. b: String, }, + /// `SmApiCreateLinearSweep`: the closed `profile` (built in `plane`, moved by + /// `origin`) swept `distance` along the plane's normal, capped at both ends. + Extrude { + /// Body written. + id: String, + /// Closed loops of segments, in the plane's (u, v) coordinates. + profile: Vec, + /// Plane of the profile (default XY). + #[serde(default)] + plane: ProfilePlane, + /// Where the plane's origin sits. + #[serde(default)] + origin: P3, + /// Sweep length along the normal. + distance: f64, + }, + /// `SmApiCreateRotationalSweep`: the closed `profile` (built in `plane`, moved by + /// `origin`) turned `angle_deg` about the axis through `axis_point` along + /// `axis_dir`, capped at both ends. + Revolve { + /// Body written. + id: String, + /// Closed loops of segments, in the plane's (u, v) coordinates. + profile: Vec, + /// Plane of the profile (default XY). + #[serde(default)] + plane: ProfilePlane, + /// Where the plane's origin sits. + #[serde(default)] + origin: P3, + /// A point on the axis (global coordinates). + axis_point: P3, + /// Axis direction (global coordinates). + axis_dir: P3, + /// Turn in degrees. + angle_deg: f64, + }, + /// A circle of `radius` swept along `path` (built in `plane`, moved by `origin`), + /// capped at both ends: `SmApiCreatePipeSweep` for one segment, + /// `SmApiCreateSweepAlongPlanarPath` of a circle normal to the path start for more. + Pipe { + /// Body written. + id: String, + /// Pipe radius. + radius: f64, + /// Lines and arcs joined end to end, in the plane's (u, v) coordinates. + path: Vec, + /// Plane of the path (default XY). + #[serde(default)] + plane: ProfilePlane, + /// Where the plane's origin sits. + #[serde(default)] + origin: P3, + }, + /// `SmApiCreateSkinPrimitive`: a solid lofted through `sections` in order, each one + /// closed profile, of `degree` in the loft direction (1, ruled, by default), capped + /// at the first and last section. + Loft { + /// Body written. + id: String, + /// The sections, at least two. + sections: Vec, + /// Degree in the loft direction: 1 linear, 2 quadratic, 3 cubic. + #[serde(default = "ruled")] + degree: usize, + }, + /// `SmApiCreateCurveSweep`: the closed `profile` (built in `plane`, moved by + /// `origin`, so its plane is normal to the path's start tangent) swept along the + /// single `path` arc or line (in the XY plane at the world origin), capped at both + /// ends. The profile keeps its place relative to the path's start. + Sweep { + /// Body written. + id: String, + /// Closed loops of segments, in the plane's (u, v) coordinates. + profile: Vec, + /// Plane of the profile (default XY). + #[serde(default)] + plane: ProfilePlane, + /// Where the plane's origin sits. + #[serde(default)] + origin: P3, + /// The path: one arc or line in the plane's (u, v) coordinates. + path: Seg, + }, + /// Reflection of `body` in the plane `normal = at`; the original is not changed, join + /// the two with a `union` step. `SM_API` has no mirror, so this defers (see `step_rust`). + Mirror { + /// Body written. + id: String, + /// Body reflected. + body: String, + /// Axis the mirror plane is perpendicular to. + normal: MirrorAxis, + /// Where the plane cuts that axis. + #[serde(default)] + at: f64, + }, /// `SmApiFilletEdges`, circular section. Fillet { /// Body written. @@ -213,6 +315,85 @@ pub enum Step { }, } +/// One piece of a profile, in the profile plane's (u, v) coordinates. Pieces that share +/// end points form closed loops; a profile may hold several (outer boundary and holes). +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum Seg { + /// `SmApiCreateLineSegment`. + Line { + /// Start. + from: [f64; 2], + /// End. + to: [f64; 2], + }, + /// A closed polygon: one line segment per consecutive pair, the last back to the + /// first. + Polygon { + /// Corners, at least three. + points: Vec<[f64; 2]>, + }, + /// `SmApiCreateArc`, counter-clockwise from `start_deg` to `end_deg`. + Arc { + /// Centre. + center: [f64; 2], + /// Radius. + radius: f64, + /// Start angle from +u. + start_deg: f64, + /// End angle from +u. + end_deg: f64, + }, + /// `SmApiCreateCircle`, analytic. + Circle { + /// Centre. + center: [f64; 2], + /// Radius. + radius: f64, + }, +} + +/// One section of a `loft`: a closed profile built in `plane` and moved by `origin`. +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LoftSection { + /// One closed loop of segments, in the plane's (u, v) coordinates. + pub profile: Vec, + /// Plane of the profile (default XY). + #[serde(default)] + pub plane: ProfilePlane, + /// Where the plane's origin sits. + #[serde(default)] + pub origin: P3, +} + +/// The plane a profile is drawn in, as a right-handed rotation of XY. +#[derive(Debug, Clone, Copy, Default, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ProfilePlane { + /// u along X, v along Y, normal +Z. + #[default] + Xy, + /// u along Y, v along Z, normal +X. + Yz, + /// u along Z, v along X, normal +Y. + Zx, + /// u along X, v along Z, normal -Y (XY turned +90 degrees about X). + Xz, +} + +/// The axis a mirror plane is perpendicular to. +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MirrorAxis { + /// The plane `x = at`. + X, + /// The plane `y = at`. + Y, + /// The plane `z = at`. + Z, +} + /// A grid of positions: `pitch[0] * i + pitch[1] * j` for `i < count[0]`, /// `j < count[1]`, `i` varying fastest. #[derive(Debug, Clone, Copy, Deserialize)] @@ -274,6 +455,10 @@ fn z_axis() -> P3 { [0.0, 0.0, 1.0] } +fn ruled() -> usize { + 1 +} + /// A loaded case: `/` and its file. #[derive(Debug, Clone)] pub struct Loaded { @@ -348,6 +533,12 @@ impl Step { | Self::Union { id, .. } | Self::Difference { id, .. } | Self::Intersection { id, .. } + | Self::Extrude { id, .. } + | Self::Revolve { id, .. } + | Self::Pipe { id, .. } + | Self::Loft { id, .. } + | Self::Sweep { id, .. } + | Self::Mirror { id, .. } | Self::Fillet { id, .. } | Self::OffsetFaces { id, .. } | Self::SheetCorner { id, .. } => id, @@ -360,15 +551,46 @@ impl Step { Self::Box { .. } | Self::Sphere { .. } | Self::Cylinder { .. } + | Self::Extrude { .. } + | Self::Revolve { .. } + | Self::Pipe { .. } + | Self::Loft { .. } + | Self::Sweep { .. } | Self::SheetCorner { .. } => Vec::new(), Self::Union { a, b, .. } | Self::Difference { a, b, .. } | Self::Intersection { a, b, .. } => vec![a, b], - Self::Fillet { body, .. } | Self::OffsetFaces { body, .. } => vec![body], + Self::Fillet { body, .. } + | Self::OffsetFaces { body, .. } + | Self::Mirror { body, .. } => vec![body], } } } +/// A profile has pieces, and every number in them is finite. +fn valid_profile(profile: &[Seg]) -> bool { + let pt = |p: &[f64; 2]| p.iter().all(|x| x.is_finite()); + !profile.is_empty() + && profile.iter().all(|s| match s { + Seg::Line { from, to } => pt(from) && pt(to), + Seg::Polygon { points } => points.len() >= 3 && points.iter().all(pt), + Seg::Arc { + center, + radius, + start_deg, + end_deg, + } => pt(center) && radius.is_finite() && start_deg.is_finite() && end_deg.is_finite(), + Seg::Circle { center, radius } => pt(center) && radius.is_finite(), + }) +} + +/// A sweep path has only lines and arcs (and its numbers are finite, checked by +/// [`valid_profile`]). +fn valid_path_segments(path: &[Seg]) -> bool { + path.iter() + .all(|s| matches!(s, Seg::Line { .. } | Seg::Arc { .. })) +} + /// Every step reads only bodies written before it, the last step writes [`RESULT`], /// and every number is finite. /// @@ -402,6 +624,60 @@ pub fn validate(case: &Case) -> Result<(), String> { Step::Difference { pattern: Some(p), .. } => p.pitch.iter().flatten().all(|x| x.is_finite()), + Step::Extrude { + profile, + origin, + distance, + .. + } => { + origin.iter().all(|x| x.is_finite()) + && distance.is_finite() + && valid_profile(profile) + } + Step::Revolve { + profile, + origin, + axis_point, + axis_dir, + angle_deg, + .. + } => { + origin + .iter() + .chain(axis_point) + .chain(axis_dir) + .all(|x| x.is_finite()) + && angle_deg.is_finite() + && valid_profile(profile) + } + Step::Pipe { + radius, + path, + origin, + .. + } => { + radius.is_finite() + && origin.iter().all(|x| x.is_finite()) + && valid_path_segments(path) + && valid_profile(path) + } + Step::Loft { sections, .. } => { + sections.len() >= 2 + && sections.iter().all(|s| { + s.origin.iter().all(|x| x.is_finite()) && valid_profile(&s.profile) + }) + } + Step::Mirror { at, .. } => at.is_finite(), + Step::Sweep { + profile, + origin, + path, + .. + } => { + origin.iter().all(|x| x.is_finite()) + && valid_profile(profile) + && valid_path_segments(std::slice::from_ref(path)) + } Step::Fillet { edges, radius, .. } => { edges.iter().flatten().all(|x| x.is_finite()) && radius.is_finite() } @@ -578,6 +854,71 @@ fn cylinder(k: &mut Kernel, origin: P3, axis: P3, radius: f64, height: f64) -> R Ok(b) } +/// The curves of `profile`, drawn in XY, turned onto `plane` about the origin and moved +/// by `origin`. +fn profile_curves( + k: &mut Kernel, + profile: &[Seg], + plane: ProfilePlane, + origin: P3, +) -> Result, Run> { + let at = |[u, v]: [f64; 2]| Vec3::new(u, v, 0.0); + let mut out: Vec = Vec::new(); + for seg in profile { + match seg { + Seg::Line { from, to } => { + let l = curves::create_line_segment(at(*from), at(*to)).map_err(|e| solid(&e))?; + out.push(Curve::from(l)); + } + Seg::Polygon { points } => { + // Each point to the next, the last back to the first. + for (from, to) in points.iter().zip(points.iter().cycle().skip(1)) { + let l = + curves::create_line_segment(at(*from), at(*to)).map_err(|e| solid(&e))?; + out.push(Curve::from(l)); + } + } + Seg::Arc { + center, + radius, + start_deg, + end_deg, + } => { + let a = curves::create_arc(at(*center), *radius, *start_deg, *end_deg) + .map_err(|e| solid(&e))?; + out.push(Curve::from(a)); + } + Seg::Circle { center, radius } => { + let c = curves::create_analytic_circle(k, at(*center), *radius) + .map_err(|e| solid(&e))?; + out.push(Curve::from(c)); + } + } + } + // XY -> YZ is the turn x -> y -> z -> x: 120 degrees about (1, 1, 1); ZX is 240. + // XZ is XY turned 90 degrees about X, so u is x and v is z. + let turn = match plane { + ProfilePlane::Xy => None, + ProfilePlane::Yz => Some((Vec3::new(1.0, 1.0, 1.0), 120.0)), + ProfilePlane::Zx => Some((Vec3::new(1.0, 1.0, 1.0), 240.0)), + ProfilePlane::Xz => Some((Vec3::X, 90.0)), + }; + for c in &mut out { + if let Some((axis, deg)) = turn { + api_rotate( + k, + Some(TransformObject::Curve(c)), + Vec3::new(0.0, 0.0, 0.0), + axis, + deg, + ) + .map_err(|e| solid(&e))?; + } + api_translate(k, Some(TransformObject::Curve(c)), p3(origin)).map_err(|e| solid(&e))?; + } + Ok(out) +} + fn step_rust(k: &mut Kernel, bodies: &mut BTreeMap, step: &Step) -> Result<(), Run> { let body = match step { Step::Box { origin, size, .. } => { @@ -635,6 +976,156 @@ fn step_rust(k: &mut Kernel, bodies: &mut BTreeMap, step: &Step) - } target } + Step::Extrude { + profile, + plane, + origin, + distance, + .. + } => { + let cs = profile_curves(k, profile, *plane, *origin)?; + let refs: Vec> = cs.iter().map(Some).collect(); + let normal = match plane { + ProfilePlane::Xy => Vec3::new(0.0, 0.0, 1.0), + ProfilePlane::Yz => Vec3::new(1.0, 0.0, 0.0), + ProfilePlane::Zx => Vec3::new(0.0, 1.0, 0.0), + ProfilePlane::Xz => Vec3::new(0.0, -1.0, 0.0), + }; + sweeps::create_linear_sweep(k, &refs, normal, *distance, true).map_err(|e| solid(&e))? + } + Step::Revolve { + profile, + plane, + origin, + axis_point, + axis_dir, + angle_deg, + .. + } => { + let cs = profile_curves(k, profile, *plane, *origin)?; + let refs: Vec> = cs.iter().map(Some).collect(); + sweeps::create_rotational_sweep( + k, + &refs, + p3(*axis_point), + p3(*axis_dir), + *angle_deg, + true, + ) + .map_err(|e| solid(&e))? + } + Step::Pipe { + radius, + path, + plane, + origin, + .. + } => { + let cs = profile_curves(k, path, *plane, *origin)?; + if let [single] = cs.as_slice() { + sweeps::create_pipe_sweep(k, *radius, Some(single), true).map_err(|e| solid(&e))? + } else { + // The profile: a circle at the origin turned from +Z onto the path's start + // tangent; the sweep moves its centre to the path start. + let (u, v) = match path.first() { + Some(Seg::Line { from, to }) => (to[0] - from[0], to[1] - from[1]), + Some(Seg::Arc { start_deg, .. }) => { + let (s, c) = start_deg.to_radians().sin_cos(); + (-s, c) + } + _ => return Err(Run::Error("pipe path starts with no line or arc".into())), + }; + let t = match plane { + ProfilePlane::Xy => Vec3::new(u, v, 0.0), + ProfilePlane::Yz => Vec3::new(0.0, u, v), + ProfilePlane::Zx => Vec3::new(v, 0.0, u), + ProfilePlane::Xz => Vec3::new(u, 0.0, v), + } + .unitize() + .map_err(|e| solid(&e))?; + let mut circle = Curve::from( + curves::create_analytic_circle(k, Vec3::new(0.0, 0.0, 0.0), *radius) + .map_err(|e| solid(&e))?, + ); + let z = Vec3::new(0.0, 0.0, 1.0); + let cos = t.dot(z).clamp(-1.0, 1.0); + if cos < 1.0 { + let axis = if cos > -1.0 { + z.cross(t) + } else { + Vec3::new(1.0, 0.0, 0.0) + }; + api_rotate( + k, + Some(TransformObject::Curve(&mut circle)), + Vec3::new(0.0, 0.0, 0.0), + axis, + cos.acos().to_degrees(), + ) + .map_err(|e| solid(&e))?; + } + let refs: Vec<&Curve> = cs.iter().collect(); + sweeps::create_sweep_along_planar_path(k, &[&circle], &refs, true, 3) + .map_err(|e| solid(&e))? + } + } + Step::Loft { + sections, degree, .. + } => { + let mut counts = Vec::new(); + let mut cs = Vec::new(); + for s in sections { + let section = profile_curves(k, &s.profile, s.plane, s.origin)?; + counts.push(section.len()); + cs.extend(section); + } + let refs: Vec> = cs.iter().map(Some).collect(); + create_skin_primitive(k, &counts, &refs, *degree, true) + .map_err(|e| solid(&e))? + .brep + } + Step::Sweep { + profile, + plane, + origin, + path, + .. + } => { + let cs = profile_curves(k, profile, *plane, *origin)?; + // The path is one arc or line in the XY plane at the world origin (no plane + // turn, no translation), so the profile's plane is normal to its start tangent. + let path = profile_curves(k, std::slice::from_ref(path), ProfilePlane::Xy, [0.0; 3])? + .into_iter() + .next() + .ok_or_else(|| Run::Error("sweep path has no curve".into()))?; + let refs: Vec> = cs.iter().map(Some).collect(); + let options = SweepOptions { + cap_ends: true, + // `SMLIB_SWEEP_FOLLOW`: the profile turns with the path's frame. + translational_sweep: false, + move_profile_center_to_path: false, + orient_profile_perpendicular_to_path: false, + move_to_profile: false, + }; + sweeps::create_curve_sweep(k, &refs, Some(&path), None, None, Some(&options)) + .map_err(|e| solid(&e))? + .brep + } + // SM_API has no mirror and no matrix transform: SmBrep::Mirror and + // SmBrep::Transform(double[4][4]) are SMLib-level and not ported. The nearest + // SM_API route, SmApiScaleByPt with a reflecting scale, is refused for any body + // with a circular edge (SmEllipse::Transform takes uniform scales only), and a + // uniform -1 scale plus a half turn leaves bodies built by booleans inside out + // (negative volume): SmBrep::Transform skips the FindAndSetInfiniteRegion and + // UV-trim rebuild that SmBrep::Mirror does. Neither is run as a mirror. + Step::Mirror { .. } => { + return Err(Run::Deferred(Deferral { + blocked_by: "no SM_API entry point".into(), + symbol: "mirror of a body (SmBrep::Mirror is not in SM_API; SmApiScaleByPt \ + with a reflecting scale fails or inverts boolean results)" + .into(), + })) + } // No Rust `SmApiFilletEdges` yet: it belongs to `blend.fillet_executive_p1`. Step::Fillet { .. } => { return Err(Run::Deferred(Deferral { @@ -661,7 +1152,49 @@ fn step_rust(k: &mut Kernel, bodies: &mut BTreeMap, step: &Step) - Ok(()) } +/// Makes the NURB of every analytic surface that the operation left without one (a +/// boolean result's swept faces carry an `SmSurfOfExtrusion` whose base has none). +/// Upstream's first `SmBSplineSurface::CalculateBoundingBox` makes it through the virtual +/// `GetOrCreateGwNurbPointer`; the port's bounding box is a `const` method and returns +/// `SM_ERR` instead, so the volume of such a body would fail here (recorded in +/// plan/findings/query.brep--boolean-results-need-their-analytic-nurbs.md). +fn make_missing_nurbs(k: &Kernel, b: &mut Brep) -> Result<(), SolidError> { + for f in b.face_list().to_vec() { + let Ok(face) = b.face_mut(f) else { continue }; + let Some(surface) = face.surface.as_mut() else { + continue; + }; + let missing = match surface.kind() { + SurfaceKind::Plane(s) => s.base().gw_nurb().is_none(), + SurfaceKind::Cone(s) => s.rev().base().gw_nurb().is_none(), + SurfaceKind::Sphere(s) => s.base().base().gw_nurb().is_none(), + SurfaceKind::Torus(s) => s.base().base().gw_nurb().is_none(), + SurfaceKind::SurfOfRevolution(s) => s.base().gw_nurb().is_none(), + SurfaceKind::SurfOfExtrusion(s) => s.base().gw_nurb().is_none(), + SurfaceKind::BSpline(_) + | SurfaceKind::CurveBounded(_) + | SurfaceKind::Step(_) + | SurfaceKind::OffsetSurface(_) + | SurfaceKind::SrfInVolume(_) => false, + }; + if !missing { + continue; + } + match surface.kind_mut() { + SurfaceKind::Plane(s) => s.make_nurb()?, + SurfaceKind::Cone(s) => s.make_nurb(k)?, + SurfaceKind::Sphere(s) => s.make_nurb(k)?, + SurfaceKind::Torus(s) => s.make_nurb(k)?, + SurfaceKind::SurfOfRevolution(s) => s.make_nurb(k)?, + SurfaceKind::SurfOfExtrusion(s) => s.make_nurb(k)?, + _ => {} + } + } + Ok(()) +} + fn measure(k: &Kernel, b: &mut Brep) -> Result { + make_missing_nurbs(k, b)?; let store = AttributeStore::default(); let attrs = PropertyAttributes { store: &store, @@ -947,16 +1480,33 @@ mod tests { #[test] fn every_case_file_loads() { + // No hard-coded total (every new case file would edit it and conflict): every + // file in the directory is a case file, and every case of every file loads. + let dir = cases_dir(&crate::case::repo_root()); + let mut expected = 0; + for e in std::fs::read_dir(&dir).unwrap() { + let p = e.unwrap().path(); + assert_eq!( + p.extension().and_then(|x| x.to_str()), + Some("json"), + "{p:?}" + ); + let v: Value = serde_json::from_str(&std::fs::read_to_string(&p).unwrap()).unwrap(); + let n = v["cases"].as_array().map_or(0, Vec::len); + assert!(n > 0, "{p:?}: no cases"); + expected += n; + } let cases = all(); assert_eq!( cases.len(), - 61, + expected, "{:?}", cases.iter().map(|c| &c.id).collect::>() ); for l in &cases { assert!( - l.file.issue.contains("/issues/") && l.file.pr.contains("/pull/"), + l.id.starts_with("a52-") + || (l.file.issue.contains("/issues/") && l.file.pr.contains("/pull/")), "{}", l.id ); @@ -1188,5 +1738,38 @@ mod tests { ) .is_err()); assert!(serde_json::from_str::(r#"{"op": "chamfer", "id": "a"}"#).is_err()); + let ext = r#"{"op": "extrude", "id": "result", "distance": 1, "profile": [{"kind": "polygon", "points": [[0, 0], [1, 0], [1, 1]]}]}"#; + assert!(validate(&case(vec![step(ext)])).is_ok()); + let flat = ext.replace("[[0, 0], [1, 0], [1, 1]]", "[[0, 0], [1, 0]]"); + assert!(validate(&case(vec![step(&flat)])).is_err()); + let empty = ext.replace( + r#"[{"kind": "polygon", "points": [[0, 0], [1, 0], [1, 1]]}]"#, + "[]", + ); + assert!(validate(&case(vec![step(&empty)])).is_err()); + let mirror = r#"{"op": "mirror", "id": "result", "body": "nope", "normal": "y"}"#; + assert!(validate(&case(vec![step(mirror)])).is_err()); + assert!(serde_json::from_str::( + r#"{"op": "mirror", "id": "a", "body": "b", "normal": "w"}"# + ) + .is_err()); + let pipe = r#"{"op": "pipe", "id": "result", "radius": 1, "path": [{"kind": "line", "from": [0, 0], "to": [5, 0]}]}"#; + assert!(validate(&case(vec![step(pipe)])).is_ok()); + let no_path = pipe.replace(r#"[{"kind": "line", "from": [0, 0], "to": [5, 0]}]"#, "[]"); + assert!(validate(&case(vec![step(&no_path)])).is_err()); + let circle_path = pipe.replace( + r#"{"kind": "line", "from": [0, 0], "to": [5, 0]}"#, + r#"{"kind": "circle", "center": [0, 0], "radius": 5}"#, + ); + assert!(validate(&case(vec![step(&circle_path)])).is_err()); + let loft = r#"{"op": "loft", "id": "result", "sections": [{"profile": [{"kind": "circle", "center": [0, 0], "radius": 2}]}, {"profile": [{"kind": "circle", "center": [0, 0], "radius": 1}], "origin": [0, 0, 5]}]}"#; + assert!(validate(&case(vec![step(loft)])).is_ok()); + let Step::Loft { degree, .. } = step(loft) else { + panic!("not a loft") + }; + assert_eq!(degree, 1); + let one = r#"{"op": "loft", "id": "result", "sections": [{"profile": [{"kind": "circle", "center": [0, 0], "radius": 2}]}]}"#; + assert!(validate(&case(vec![step(one)])).is_err()); + assert!(serde_json::from_str::(&loft.replace("\"origin\"", "\"orign\"")).is_err()); } } diff --git a/crates/difftest/src/ssi.rs b/crates/difftest/src/ssi.rs index 45d8d23c1b..c80f6ab260 100644 --- a/crates/difftest/src/ssi.rs +++ b/crates/difftest/src/ssi.rs @@ -1,5 +1,6 @@ -//! `--ssi-corpus`: scores the C++ reference, the Rust port and the `ssi` crate against -//! jyviko's exact ground-truth surface/surface intersection corpus +//! `--ssi-corpus`: scores the C++ reference, the Rust port, the `ssi` crate and the `ssi` +//! crate through `solidcore-ssi` against jyviko's exact ground-truth surface/surface +//! intersection corpus //! (, commit `b04c8a6`, MIT OR Apache-2.0; fetched //! into `corpus/ssi/` by `scripts/fetch-ssi-corpus.sh`, never committed). //! @@ -9,16 +10,20 @@ //! ([`score`]); where the C++ and the port both answered, their outputs are also //! compared with the difftest rule (topology identical, values within //! [`crate::REL_TOL`]). The `ssi` engine ([`ssi_engine`]) is a method of its own, never -//! compared for parity, and the only one a build without the `cpp` feature runs. This +//! compared for parity; the `solidcore-ssi` engine ([`solidcore_ssi_engine`]) runs it +//! from solidcore's own surfaces into the port's output form, sampled as the port's is +//! ([`kernel`]). Those two are the engines a build without the `cpp` feature runs. This //! is a correctness report: it never changes a unit's difftest status. pub mod case; #[cfg(feature = "cpp")] pub mod engine; pub mod geom; +pub mod kernel; pub mod polyset; pub mod raw; pub mod score; +pub mod solidcore_ssi_engine; pub mod ssi_engine; use std::collections::BTreeMap; @@ -43,12 +48,16 @@ pub const TIMEOUT: Duration = Duration::from_secs(300); /// The default approximation tolerance. pub const DEFAULT_TOL: &str = "1e-6"; -/// The engines, in report order: upstream SMLib (C++), the Rust port, the `ssi` crate. -pub const ENGINES: [&str; 3] = ["cpp", "rust", "ssi"]; +/// How many engines there are. +pub const N_ENGINES: usize = 4; + +/// The engines, in report order: upstream SMLib (C++), the Rust port, the `ssi` crate, +/// and the `ssi` crate through `solidcore-ssi` on solidcore's own surfaces. +pub const ENGINES: [&str; N_ENGINES] = ["cpp", "rust", "ssi", "solidcore-ssi"]; /// Whether this build runs each of [`ENGINES`]: `cpp` and `rust` need the C++ kernel -/// (feature `cpp`), `ssi` nothing. -pub const BUILT: [bool; 3] = [cfg!(feature = "cpp"), cfg!(feature = "cpp"), true]; +/// (feature `cpp`), `ssi` and `solidcore-ssi` nothing. +pub const BUILT: [bool; N_ENGINES] = [cfg!(feature = "cpp"), cfg!(feature = "cpp"), true, true]; /// Whether the C++ and the port both run, so their outputs are compared for parity. const PARITY: bool = BUILT[0] && BUILT[1]; @@ -147,13 +156,13 @@ pub fn case_files(root: &Path, glob: Option<&str>) -> Result, Harne Ok(v) } -/// `--ssi-case `: one engine on one +/// `--ssi-case `: one engine on one /// case, writing the corpus runner's JSON. Exit `0` on success (including a deferred Rust /// run and a non-success status), `3` when a surface cannot be built, `2` otherwise. /// `cpp` and `rust` need the `cpp` feature. pub fn run_child(args: &[String]) -> ExitCode { let [case_path, tol, engine_name, out_path] = args else { - eprintln!("usage: difftest --ssi-case "); + eprintln!("usage: difftest --ssi-case "); return ExitCode::from(2); }; let tol = match parse_tol(tol) { @@ -176,6 +185,7 @@ pub fn run_child(args: &[String]) -> ExitCode { #[cfg(feature = "cpp")] "rust" => engine::run_rust(&case, tol), "ssi" => ssi_engine::run_ssi(&case, tol), + "solidcore-ssi" => solidcore_ssi_engine::run_solidcore_ssi(&case, tol), other if ENGINES.contains(&other) => { eprintln!("difftest: engine `{other}` needs feature `cpp`"); return ExitCode::from(2); @@ -240,12 +250,12 @@ fn read_raw(path: &Path) -> RunResult { fn run_children( files: &[PathBuf], tol: &str, - rawdirs: &[PathBuf; 3], + rawdirs: &[PathBuf; N_ENGINES], jobs: usize, -) -> Result, HarnessError> { +) -> Result, HarnessError> { let exe = std::env::current_exe().map_err(|e| HarnessError::new(format!("current_exe: {e}")))?; - let mut results: Vec<[RunResult; 3]> = files + let mut results: Vec<[RunResult; N_ENGINES]> = files .iter() .map(|_| std::array::from_fn(|_| Err("not run".to_string()))) .collect(); @@ -374,13 +384,13 @@ fn truncate(s: &str, n: usize) -> String { /// timing. fn score_all( cases: &[SsiCase], - results: &[[RunResult; 3]], + results: &[[RunResult; N_ENGINES]], acc_tol: Option, cov_floor: f64, names: &BTreeMap, jobs: usize, ) -> Vec>> { - let grade = |case: &SsiCase, res: &[RunResult; 3]| -> Vec> { + let grade = |case: &SsiCase, res: &[RunResult; N_ENGINES]| -> Vec> { res.iter() .zip(BUILT) .map(|(r, run)| match r { @@ -502,7 +512,7 @@ pub fn run_corpus(root: &Path, opts: &CorpusOptions) -> ExitCode { let names = status_names(root); let acc_tol = tol.map(|t| 10.0 * t); let cov_floor = if tol.is_none() { 5e-3 } else { 0.0 }; - let mut counts: [BTreeMap; 3] = Default::default(); + let mut counts: [BTreeMap; N_ENGINES] = Default::default(); let (mut compared, mut identical, mut same_verdict) = (0usize, 0usize, 0usize); let mut entries = Vec::new(); let all_scores = score_all(&cases, &results, acc_tol, cov_floor, &names, opts.jobs); @@ -532,7 +542,7 @@ pub fn run_corpus(root: &Path, opts: &CorpusOptions) -> ExitCode { } // Rust vs C++ parity where both answered let mut parity = "-".to_string(); - if let [Ok(c), Ok(r), _] = res { + if let [Ok(c), Ok(r), ..] = res { if r.deferred.is_none() { compared += 1; let d = compare(&c.parity_output(), &r.parity_output(), &[]); diff --git a/crates/difftest/src/ssi/engine.rs b/crates/difftest/src/ssi/engine.rs index 7ee050d93d..56cc591a35 100644 --- a/crates/difftest/src/ssi/engine.rs +++ b/crates/difftest/src/ssi/engine.rs @@ -8,29 +8,18 @@ use std::time::Instant; -use solidcore::curve::curve::Curve; -use solidcore::geom::axis2_placement::Axis2Placement; use solidcore::geom::extent2d::Extent2d; use solidcore::geom::tol::approx_tol3d; -use solidcore::geom::vector2d::{Point2, Vec2}; -use solidcore::geom::vector3d::Vec3; use solidcore::kernel::Kernel; -use solidcore::nurbs::b_spline_surface::BSplineSurface; -use solidcore::nurbs::math::CPoint; -use solidcore::nurbs::srf_basic::Surface as NurbSurface; -use solidcore::surface::cone::Cone; -use solidcore::surface::plane::Plane; -use solidcore::surface::sphere::{Sphere, SphereOptions}; use solidcore::surface::surface::Surface; -use solidcore::surface::torus::{Torus, TorusOptions}; use solidcore::SolidError; use solidcore_ffi::units::ssi_corpus::{self as ffi, SsiSurface, SsiSurfaceIn}; use solidcore_ffi::FfiError; use super::case::{SsiCase, SurfaceSpec}; use super::geom::P3; +use super::kernel::{deferred_output, rust_surface, write_intersection}; use super::raw::{type_name, RawCurve, RawOutput, N_SAMPLES, SM_SUCCESS}; -use crate::Deferred; /// `SM_ERR`. const SM_ERR: i64 = 1001; @@ -249,251 +238,6 @@ fn decode( }) } -fn v3(p: P3) -> Vec3 { - Vec3::new(p[0], p[1], p[2]) -} - -fn deferred_output(case: &SsiCase, tol: Option, unit: &str, symbol: &str) -> RawOutput { - RawOutput { - case: case.id.clone(), - engine: "rust".into(), - status: 0, - approx_tol: tol, - deferred: Some(Deferred { - unit: unit.into(), - symbol: symbol.into(), - }), - ..RawOutput::default() - } -} - -/// The result of making an analytic surface's NURB in place, as the C++ runner's -/// `GetNaturalUVDomain()` does (`GetOrCreateGwNurbPointer` through a const cast): an -/// unported step defers the case. -fn nurb_made(r: Result<(), SolidError>, what: &str) -> Result, String> { - match r { - Ok(()) => Ok(Ok(())), - Err(SolidError::Unported { upstream, unit }) => Ok(Err(Deferred { - unit: unit.into(), - symbol: upstream.into(), - })), - Err(e) => Err(format!("{what}: {e}")), - } -} - -/// The runner's `unit_cross` (same operation order): the unit vector along `a x b`. -fn unit_cross(a: Vec3, b: Vec3) -> Vec3 { - let c = [ - a.y * b.z - a.z * b.y, - a.z * b.x - a.x * b.z, - a.x * b.y - a.y * b.x, - ]; - let l = (c[0] * c[0] + c[1] * c[1] + c[2] * c[2]).sqrt(); - Vec3::new(c[0] / l, c[1] / l, c[2] / l) -} - -/// Builds a corpus surface with solidcore's types, or names the unit that must land first. -fn rust_surface(k: &Kernel, spec: &SurfaceSpec) -> Result, String> { - match spec { - SurfaceSpec::Plane { - origin, - xaxis, - yaxis, - u, - v, - } => { - let dom = Extent2d::from_bounds(u[0], v[0], u[1], v[1]); - let mut plane = Plane::new( - v3(*origin), - v3(*xaxis), - v3(*yaxis), - Vec2::new(1.0, 1.0), - &dom, - ); - // The C++ runner's `GetNaturalUVDomain()` makes the plane's NURB in place - // (`GetOrCreateGwNurbPointer` through a const cast); the port's query works on a - // copy, so make it here to start GlobalSurfaceIntersect from the same state - // (`IntersectWithPlane`'s parallel branch tests for the NURB). - match plane.make_nurb() { - Ok(()) => {} - Err(SolidError::Unported { upstream, unit }) => { - return Ok(Err(Deferred { - unit: unit.into(), - symbol: upstream.into(), - })) - } - Err(e) => return Err(format!("plane: {e}")), - } - Ok(Ok(Surface::from(plane))) - } - SurfaceSpec::Nurbs { - degree, - knots_u, - knots_v, - nu, - nv, - points, - weights, - } => { - if *nu == 0 || *nv == 0 || points.len() != nu * nv || weights.len() != nu * nv { - return Err("nurbs: bad sizes".into()); - } - if knots_u.len() != nu + degree[0] + 1 || knots_v.len() != nv + degree[1] + 1 { - return Err("nurbs: bad knots".into()); - } - // NLib control points are homogeneous: Cartesian = (x/w, y/w, z/w) - let net: Vec> = points - .chunks(*nv) - .zip(weights.chunks(*nv)) - .map(|(row, w)| { - row.iter() - .zip(w) - .map(|(p, &w)| CPoint::new(p[0] * w, p[1] * w, p[2] * w, w)) - .collect() - }) - .collect(); - let sur = NurbSurface::new(net, degree[0], degree[1], knots_u.clone(), knots_v.clone()) - .map_err(|e| format!("nurbs: {e}"))?; - Ok(Ok(Surface::from(BSplineSurface::from_nurb(sur, false)))) - } - SurfaceSpec::Cylinder { - origin, - xaxis, - yaxis, - radius, - angle, - height, - } => { - let z = unit_cross(v3(*xaxis), v3(*yaxis)); - let o = v3(*origin) + z * height[0]; - let mut cyl = Cone::new_cylinder_bounded( - o, - v3(*xaxis), - v3(*yaxis), - *radius, - angle[0], - angle[1], - height[1] - height[0], - false, - false, - false, - ); - Ok(nurb_made(cyl.make_nurb(k), "cylinder")?.map(|()| Surface::from(cyl))) - } - SurfaceSpec::Cone { - apex, - xaxis, - yaxis, - half_angle_deg, - height, - } => { - // apex-based description: radius(h) = h * tan(half_angle), h in [h0, h1] - // along z, full 360 degree rotation - let z = unit_cross(v3(*xaxis), v3(*yaxis)); - let t = (half_angle_deg * std::f64::consts::PI / 180.0).tan(); - let o = v3(*apex) + z * height[0]; - let pl = Axis2Placement::new(o, v3(*xaxis), v3(*yaxis)); - match Cone::create_canonical( - k, - &pl, - height[0] * t, - height[1] * t, - height[1] - height[0], - false, - false, - ) { - Ok(cone) => Ok(Ok(Surface::from(cone))), - Err(SolidError::Unported { upstream, unit }) => Ok(Err(Deferred { - unit: unit.into(), - symbol: upstream.into(), - })), - Err(e) => Err(format!("cone: {e}")), - } - } - SurfaceSpec::Sphere { - center, - xaxis, - yaxis, - radius, - lon, - lat, - } => { - let anal = Extent2d::from_bounds(lon[0], lat[0], lon[1], lat[1]); - let mut sphere = match Sphere::new( - k, - v3(*center), - v3(*xaxis), - v3(*yaxis), - anal, - *radius, - SphereOptions::default(), - ) { - Ok(s) => s, - Err(SolidError::Unported { upstream, unit }) => { - return Ok(Err(Deferred { - unit: unit.into(), - symbol: upstream.into(), - })) - } - Err(e) => return Err(format!("sphere: {e}")), - }; - Ok(nurb_made(sphere.make_nurb(k), "sphere")?.map(|()| Surface::from(sphere))) - } - SurfaceSpec::Torus { - center, - xaxis, - yaxis, - major_radius, - minor_radius, - major, - minor, - } => { - let anal = Extent2d::from_bounds(major[0], minor[0], major[1], minor[1]); - let mut torus = match Torus::new( - k, - v3(*center), - v3(*xaxis), - v3(*yaxis), - anal, - *major_radius, - *minor_radius, - TorusOptions::default(), - ) { - Ok(s) => s, - Err(SolidError::Unported { upstream, unit }) => { - return Ok(Err(Deferred { - unit: unit.into(), - symbol: upstream.into(), - })) - } - Err(e) => return Err(format!("torus: {e}")), - }; - Ok(nurb_made(torus.make_nurb(k), "torus")?.map(|()| Surface::from(torus))) - } - } -} - -/// Samples a curve as the runner's `sample_curve` (failed evaluations skipped); an -/// unported evaluation defers the case. -fn sample(k: &Kernel, c: &Curve, n: i32) -> Result<(Vec, [f64; 2]), SolidError> { - let ivl = c.natural_interval(); - let (lo, hi) = (ivl.min(), ivl.max()); - let mut out = Vec::new(); - for i in 0..n { - let t = lo + (hi - lo) * (f64::from(i) / f64::from(n - 1)); - match c.evaluate_point(k, t) { - Ok(p) => out.push(p), - Err(e @ SolidError::Unported { .. }) => return Err(e), - Err(_) => {} - } - } - Ok((out, [lo, hi])) -} - -fn to_p3(v: Vec3) -> P3 { - [v.x, v.y, v.z] -} - fn rust_status(e: &SolidError) -> (i64, Option) { match e { SolidError::Upstream { code, .. } => (i64::from(*code), None), @@ -523,7 +267,7 @@ pub fn run_rust(case: &SsiCase, tol: Option) -> Result return Ok(deferred_output(case, tol, &d.unit, &d.symbol)), + Err(d) => return Ok(deferred_output(case, tol, "rust", &d.unit, &d.symbol)), } } let [(s1, d1), (s2, d2)] = <[(Surface, Extent2d); 2]>::try_from(built) @@ -546,7 +290,7 @@ pub fn run_rust(case: &SsiCase, tol: Option) -> Result x, Err(SolidError::Unported { upstream, unit }) => { - return Ok(deferred_output(case, tol, unit, upstream)) + return Ok(deferred_output(case, tol, "rust", unit, upstream)) } Err(e) => { let (status, name) = rust_status(&e); @@ -555,57 +299,8 @@ pub fn run_rust(case: &SsiCase, tol: Option) -> Result r, - Err(SolidError::Unported { upstream, unit }) => { - return Ok(deferred_output(case, tol, unit, upstream)) - } - Err(e) => return Err(EngineFailure::Other(e.to_string())), - }; - let mut curve = RawCurve { - ty: type_name(ty).to_string(), - deviation, - interval: Some(ivl), - class_type: Some(c3.type_code()), - xyz: pts.into_iter().map(to_p3).collect(), - ..RawCurve::default() - }; - let uvs = [&x.surface1_uv_curves, &x.surface2_uv_curves]; - for (kk, (uvlist, surf)) in uvs.iter().zip(surfaces).enumerate() { - let Some(Some(uvc)) = uvlist.get(i) else { - continue; - }; - let (uv, _) = match sample(&k, uvc, N_SAMPLES) { - Ok(r) => r, - Err(SolidError::Unported { upstream, unit }) => { - return Ok(deferred_output(case, tol, unit, upstream)) - } - Err(e) => return Err(EngineFailure::Other(e.to_string())), - }; - let mut at = Vec::with_capacity(uv.len()); - for p in &uv { - match surf.evaluate_point(&k, Point2::new(p.x, p.y)) { - Ok(q) => at.push(to_p3(q)), - Err(SolidError::Unported { upstream, unit }) => { - return Ok(deferred_output(case, tol, unit, upstream)) - } - Err(_) => at.push([f64::NAN; 3]), - } - } - let uv2: Vec<[f64; 2]> = uv.iter().map(|p| [p.x, p.y]).collect(); - if kk == 0 { - curve.uv1 = uv2; - curve.s1_at_uv = at; - } else { - curve.uv2 = uv2; - curve.s2_at_uv = at; - } - } - out.curves.push(curve); + match write_intersection(&k, &x, [&s1, &s2], &mut out)? { + Ok(()) => Ok(out), + Err(d) => Ok(deferred_output(case, tol, "rust", &d.unit, &d.symbol)), } - Ok(out) } diff --git a/crates/difftest/src/ssi/kernel.rs b/crates/difftest/src/ssi/kernel.rs new file mode 100644 index 0000000000..67a6e6ac16 --- /dev/null +++ b/crates/difftest/src/ssi/kernel.rs @@ -0,0 +1,354 @@ +//! What the Rust engines of `--ssi-corpus` share: a corpus surface built with +//! solidcore's own types ([`rust_surface`]), and an intersection in the port's form +//! written out sampled the way the C++ runner samples it ([`write_intersection`]). The +//! port (`rust`, needs the `cpp` feature for its parity checks) and `solidcore-ssi` (the +//! `solidcore-ssi` crate on the same surfaces, no C++) both use them. + +use solidcore::curve::curve::Curve; +use solidcore::geom::axis2_placement::Axis2Placement; +use solidcore::geom::extent2d::Extent2d; +use solidcore::geom::vector2d::{Point2, Vec2}; +use solidcore::geom::vector3d::Vec3; +use solidcore::kernel::Kernel; +use solidcore::nurbs::b_spline_surface::BSplineSurface; +use solidcore::nurbs::math::CPoint; +use solidcore::nurbs::srf_basic::Surface as NurbSurface; +use solidcore::surface::cone::Cone; +use solidcore::surface::plane::Plane; +use solidcore::surface::sphere::{Sphere, SphereOptions}; +use solidcore::surface::surface::p2::SurfaceIntersection; +use solidcore::surface::surface::Surface; +use solidcore::surface::torus::{Torus, TorusOptions}; +use solidcore::SolidError; + +use super::case::{SsiCase, SurfaceSpec}; +use super::geom::P3; +use super::raw::{type_name, RawCurve, RawOutput, N_SAMPLES}; +use super::EngineFailure; +use crate::Deferred; + +fn deferred(unit: &str, upstream: &str) -> Deferred { + Deferred { + unit: unit.into(), + symbol: upstream.into(), + } +} + +pub(crate) fn v3(p: P3) -> Vec3 { + Vec3::new(p[0], p[1], p[2]) +} + +/// The output of a case deferred for `engine` until `unit` is ported. +pub(crate) fn deferred_output( + case: &SsiCase, + tol: Option, + engine: &str, + unit: &str, + symbol: &str, +) -> RawOutput { + RawOutput { + case: case.id.clone(), + engine: engine.into(), + status: 0, + approx_tol: tol, + deferred: Some(Deferred { + unit: unit.into(), + symbol: symbol.into(), + }), + ..RawOutput::default() + } +} + +/// The result of making an analytic surface's NURB in place, as the C++ runner's +/// `GetNaturalUVDomain()` does (`GetOrCreateGwNurbPointer` through a const cast): an +/// unported step defers the case. +fn nurb_made(r: Result<(), SolidError>, what: &str) -> Result, String> { + match r { + Ok(()) => Ok(Ok(())), + Err(SolidError::Unported { upstream, unit }) => Ok(Err(Deferred { + unit: unit.into(), + symbol: upstream.into(), + })), + Err(e) => Err(format!("{what}: {e}")), + } +} + +/// The runner's `unit_cross` (same operation order): the unit vector along `a x b`. +fn unit_cross(a: Vec3, b: Vec3) -> Vec3 { + let c = [ + a.y * b.z - a.z * b.y, + a.z * b.x - a.x * b.z, + a.x * b.y - a.y * b.x, + ]; + let l = (c[0] * c[0] + c[1] * c[1] + c[2] * c[2]).sqrt(); + Vec3::new(c[0] / l, c[1] / l, c[2] / l) +} + +/// Builds a corpus surface with solidcore's types, or names the unit that must land first. +pub(crate) fn rust_surface( + k: &Kernel, + spec: &SurfaceSpec, +) -> Result, String> { + match spec { + SurfaceSpec::Plane { + origin, + xaxis, + yaxis, + u, + v, + } => { + let dom = Extent2d::from_bounds(u[0], v[0], u[1], v[1]); + let mut plane = Plane::new( + v3(*origin), + v3(*xaxis), + v3(*yaxis), + Vec2::new(1.0, 1.0), + &dom, + ); + // The C++ runner's `GetNaturalUVDomain()` makes the plane's NURB in place + // (`GetOrCreateGwNurbPointer` through a const cast); the port's query works on a + // copy, so make it here to start GlobalSurfaceIntersect from the same state + // (`IntersectWithPlane`'s parallel branch tests for the NURB). + match plane.make_nurb() { + Ok(()) => {} + Err(SolidError::Unported { upstream, unit }) => { + return Ok(Err(Deferred { + unit: unit.into(), + symbol: upstream.into(), + })) + } + Err(e) => return Err(format!("plane: {e}")), + } + Ok(Ok(Surface::from(plane))) + } + SurfaceSpec::Nurbs { + degree, + knots_u, + knots_v, + nu, + nv, + points, + weights, + } => { + if *nu == 0 || *nv == 0 || points.len() != nu * nv || weights.len() != nu * nv { + return Err("nurbs: bad sizes".into()); + } + if knots_u.len() != nu + degree[0] + 1 || knots_v.len() != nv + degree[1] + 1 { + return Err("nurbs: bad knots".into()); + } + // NLib control points are homogeneous: Cartesian = (x/w, y/w, z/w) + let net: Vec> = points + .chunks(*nv) + .zip(weights.chunks(*nv)) + .map(|(row, w)| { + row.iter() + .zip(w) + .map(|(p, &w)| CPoint::new(p[0] * w, p[1] * w, p[2] * w, w)) + .collect() + }) + .collect(); + let sur = NurbSurface::new(net, degree[0], degree[1], knots_u.clone(), knots_v.clone()) + .map_err(|e| format!("nurbs: {e}"))?; + Ok(Ok(Surface::from(BSplineSurface::from_nurb(sur, false)))) + } + SurfaceSpec::Cylinder { + origin, + xaxis, + yaxis, + radius, + angle, + height, + } => { + let z = unit_cross(v3(*xaxis), v3(*yaxis)); + let o = v3(*origin) + z * height[0]; + let mut cyl = Cone::new_cylinder_bounded( + o, + v3(*xaxis), + v3(*yaxis), + *radius, + angle[0], + angle[1], + height[1] - height[0], + false, + false, + false, + ); + Ok(nurb_made(cyl.make_nurb(k), "cylinder")?.map(|()| Surface::from(cyl))) + } + SurfaceSpec::Cone { + apex, + xaxis, + yaxis, + half_angle_deg, + height, + } => { + // apex-based description: radius(h) = h * tan(half_angle), h in [h0, h1] + // along z, full 360 degree rotation + let z = unit_cross(v3(*xaxis), v3(*yaxis)); + let t = (half_angle_deg * std::f64::consts::PI / 180.0).tan(); + let o = v3(*apex) + z * height[0]; + let pl = Axis2Placement::new(o, v3(*xaxis), v3(*yaxis)); + match Cone::create_canonical( + k, + &pl, + height[0] * t, + height[1] * t, + height[1] - height[0], + false, + false, + ) { + Ok(cone) => Ok(Ok(Surface::from(cone))), + Err(SolidError::Unported { upstream, unit }) => Ok(Err(Deferred { + unit: unit.into(), + symbol: upstream.into(), + })), + Err(e) => Err(format!("cone: {e}")), + } + } + SurfaceSpec::Sphere { + center, + xaxis, + yaxis, + radius, + lon, + lat, + } => { + let anal = Extent2d::from_bounds(lon[0], lat[0], lon[1], lat[1]); + let mut sphere = match Sphere::new( + k, + v3(*center), + v3(*xaxis), + v3(*yaxis), + anal, + *radius, + SphereOptions::default(), + ) { + Ok(s) => s, + Err(SolidError::Unported { upstream, unit }) => { + return Ok(Err(Deferred { + unit: unit.into(), + symbol: upstream.into(), + })) + } + Err(e) => return Err(format!("sphere: {e}")), + }; + Ok(nurb_made(sphere.make_nurb(k), "sphere")?.map(|()| Surface::from(sphere))) + } + SurfaceSpec::Torus { + center, + xaxis, + yaxis, + major_radius, + minor_radius, + major, + minor, + } => { + let anal = Extent2d::from_bounds(major[0], minor[0], major[1], minor[1]); + let mut torus = match Torus::new( + k, + v3(*center), + v3(*xaxis), + v3(*yaxis), + anal, + *major_radius, + *minor_radius, + TorusOptions::default(), + ) { + Ok(s) => s, + Err(SolidError::Unported { upstream, unit }) => { + return Ok(Err(Deferred { + unit: unit.into(), + symbol: upstream.into(), + })) + } + Err(e) => return Err(format!("torus: {e}")), + }; + Ok(nurb_made(torus.make_nurb(k), "torus")?.map(|()| Surface::from(torus))) + } + } +} + +/// Samples a curve as the runner's `sample_curve` (failed evaluations skipped); an +/// unported evaluation defers the case. +pub(crate) fn sample(k: &Kernel, c: &Curve, n: i32) -> Result<(Vec, [f64; 2]), SolidError> { + let ivl = c.natural_interval(); + let (lo, hi) = (ivl.min(), ivl.max()); + let mut out = Vec::new(); + for i in 0..n { + let t = lo + (hi - lo) * (f64::from(i) / f64::from(n - 1)); + match c.evaluate_point(k, t) { + Ok(p) => out.push(p), + Err(e @ SolidError::Unported { .. }) => return Err(e), + Err(_) => {} + } + } + Ok((out, [lo, hi])) +} + +pub(crate) fn to_p3(v: Vec3) -> P3 { + [v.x, v.y, v.z] +} + +/// Writes the intersection `x` of `surfaces` into `out`, sampled as the C++ runner +/// samples it: each curve at [`N_SAMPLES`] parameters over its natural interval, each +/// UV curve the same, and each surface at its UV samples. `Ok(Err(_))` names the unit +/// an unported evaluation on the way needs. +pub(crate) fn write_intersection( + k: &Kernel, + x: &SurfaceIntersection, + surfaces: [&Surface; 2], + out: &mut RawOutput, +) -> Result, EngineFailure> { + for (i, c3) in x.curves_3d.iter().enumerate() { + let ty = x.curve_types.get(i).map_or(-1, |t| *t as i64); + let deviation = x.deviations.get(i).copied().unwrap_or(f64::NAN); + let (pts, ivl) = match sample(k, c3, N_SAMPLES) { + Ok(r) => r, + Err(SolidError::Unported { upstream, unit }) => { + return Ok(Err(deferred(unit, upstream))) + } + Err(e) => return Err(EngineFailure::Other(e.to_string())), + }; + let mut curve = RawCurve { + ty: type_name(ty).to_string(), + deviation, + interval: Some(ivl), + class_type: Some(c3.type_code()), + xyz: pts.into_iter().map(to_p3).collect(), + ..RawCurve::default() + }; + let uvs = [&x.surface1_uv_curves, &x.surface2_uv_curves]; + for (kk, (uvlist, surf)) in uvs.iter().zip(surfaces).enumerate() { + let Some(Some(uvc)) = uvlist.get(i) else { + continue; + }; + let (uv, _) = match sample(k, uvc, N_SAMPLES) { + Ok(r) => r, + Err(SolidError::Unported { upstream, unit }) => { + return Ok(Err(deferred(unit, upstream))) + } + Err(e) => return Err(EngineFailure::Other(e.to_string())), + }; + let mut at = Vec::with_capacity(uv.len()); + for p in &uv { + match surf.evaluate_point(k, Point2::new(p.x, p.y)) { + Ok(q) => at.push(to_p3(q)), + Err(SolidError::Unported { upstream, unit }) => { + return Ok(Err(deferred(unit, upstream))) + } + Err(_) => at.push([f64::NAN; 3]), + } + } + let uv2: Vec<[f64; 2]> = uv.iter().map(|p| [p.x, p.y]).collect(); + if kk == 0 { + curve.uv1 = uv2; + curve.s1_at_uv = at; + } else { + curve.uv2 = uv2; + curve.s2_at_uv = at; + } + } + out.curves.push(curve); + } + Ok(Ok(())) +} diff --git a/crates/difftest/src/ssi/solidcore_ssi_engine.rs b/crates/difftest/src/ssi/solidcore_ssi_engine.rs new file mode 100644 index 0000000000..bd2027b785 --- /dev/null +++ b/crates/difftest/src/ssi/solidcore_ssi_engine.rs @@ -0,0 +1,90 @@ +//! The `solidcore-ssi` engine: the case's surfaces built with solidcore's own types, as +//! the `rust` engine builds them ([`rust_surface`]), intersected by the `solidcore-ssi` +//! crate (the `ssi` crate behind the port's inputs and output), and written as the +//! `rust` engine writes the port's output ([`write_intersection`]). So it grades the +//! path a kernel caller would take, the adapters into and out of `ssi` included, by the +//! port's own sampling: its curves are B-spline curves sampled at 4001 parameters, not +//! the marched points the `ssi` engine writes. Its points are the singular points the +//! crate returns beside the curves (the `ssi` engine writes the same). The timer covers +//! the whole call (patches in, `ssi`, filling in, curves out). No C++ is involved. + +use std::time::Instant; + +use solidcore::geom::extent2d::Extent2d; +use solidcore::geom::tol::approx_tol3d; +use solidcore::kernel::Kernel; +use solidcore::surface::surface::Surface; +use solidcore::SolidError; +use solidcore_ssi::Error; + +use super::case::SsiCase; +use super::kernel::{deferred_output, rust_surface, write_intersection}; +use super::raw::{RawOutput, SM_SUCCESS}; +use super::EngineFailure; + +/// The engine's name in reports. +pub const ENGINE: &str = "solidcore-ssi"; + +/// `SM_ERR`: the status of a refusal or failure, whose code and detail are in +/// `status_name`. +const SM_ERR: i64 = 1001; + +/// The `solidcore-ssi` crate's intersection of the case's two surfaces. A refusal (a +/// contact `ssi` has not built, `BOOLEAN_TANGENT`), a surface it does not take, or a +/// failure is status `SM_ERR` with its code and detail as `status_name`; an unported +/// step on the way defers the case, as for the port. +pub fn run_solidcore_ssi(case: &SsiCase, tol: Option) -> Result { + let k = Kernel::default(); + let [(a, da), (b, db)] = &case.surfaces; + let mut built = Vec::new(); + for (spec, dom) in [(a, da), (b, db)] { + match rust_surface(&k, spec).map_err(EngineFailure::Build)? { + Ok(s) => { + let d = match dom { + Some(d) => Extent2d::from_bounds(d[0], d[2], d[1], d[3]), + None => s.natural_uv_domain(), + }; + built.push((s, d)); + } + Err(d) => return Ok(deferred_output(case, tol, ENGINE, &d.unit, &d.symbol)), + } + } + let [(s1, d1), (s2, d2)] = <[(Surface, Extent2d); 2]>::try_from(built) + .map_err(|_| EngineFailure::Other("two surfaces expected".into()))?; + let dom4 = |d: &Extent2d| [d.min().x, d.max().x, d.min().y, d.max().y]; + let approx = tol.map(|t| approx_tol3d(&k, t)); + let t0 = Instant::now(); + let res = solidcore_ssi::global_surface_intersect(&k, &s1, &d1, &s2, &d2, approx, None); + let time_ms = t0.elapsed().as_secs_f64() * 1000.0; + let mut out = RawOutput { + case: case.id.clone(), + engine: ENGINE.into(), + status: SM_SUCCESS, + domain1: dom4(&d1), + domain2: dom4(&d2), + approx_tol: tol, + time_ms, + ..RawOutput::default() + }; + let x = match res { + Ok(x) => x, + Err(Error::Kernel(SolidError::Unported { upstream, unit })) => { + return Ok(deferred_output(case, tol, ENGINE, unit, upstream)) + } + Err(e) => { + out.status = SM_ERR; + out.status_name = Some(match &e { + Error::Ssi(_) => e.to_string(), + Error::Unsupported(_) => format!("UNSUPPORTED ({e})"), + Error::Kernel(_) => format!("SM_ERR ({e})"), + }); + return Ok(out); + } + }; + // the singular points on the curves, as the `ssi` engine writes them + out.points = x.vertices.iter().map(|v| [v.x, v.y, v.z]).collect(); + match write_intersection(&k, &x.curves, [&s1, &s2], &mut out)? { + Ok(()) => Ok(out), + Err(d) => Ok(deferred_output(case, tol, ENGINE, &d.unit, &d.symbol)), + } +} diff --git a/crates/difftest/src/step.rs b/crates/difftest/src/step.rs new file mode 100644 index 0000000000..451ad49ae4 --- /dev/null +++ b/crates/difftest/src/step.rs @@ -0,0 +1,1052 @@ +//! `--step-corpus`: imports real-world STEP files with the clean-room `step-import` +//! crate and builds every B-rep body on both kernels from the same intermediate. +//! +//! The corpus is STEP Tools' AP203/AP214 sample set, fetched into `corpus/steptools/` +//! by `scripts/fetch-steptools-corpus.sh` (All Rights Reserved; never committed). Each +//! body is encoded once ([`step_import::flat::encode`]); the Rust side builds it from +//! the decoded [`Body`] ([`build_rust`]) and the C++ side from the same numbers +//! (`harness/cpp/shim/step_import.cpp`), with the same recipe: per face its surface +//! and the bounded 3D curves of its edge loops into `CreateFacesWithCurves`, then +//! `SmApiStitchIntoSolid`, counts, `SmApiBrepIsManifoldSolid`, area and volume, +//! `SmApiHealBrep`, and counts, manifold test and volume again. Any difference is a +//! kernel difference. +//! +//! Every (body, kernel) runs in a child process (`--step-case`), so a crash or hang is a +//! `CRASH` verdict, not the end of the run. Stages are compared in order with the usual +//! rule (statuses and counts equal, values within [`crate::REL_TOL`]); the first +//! differing stage is reported. A Rust stage that reaches unported code is `DEFERRED` +//! with its unit. This is a report, not a gate: verdicts never fail the run, and it is +//! not part of CI (the files are not in the repository). + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, ExitCode, Stdio}; +use std::time::{Duration, Instant}; + +use serde_json::{json, Value}; + +use solidcore::curve::curve::Curve; +use solidcore::curve::ellipse::Ellipse; +use solidcore::curve::line::{BoundedLineOptions, Line}; +use solidcore::geom::attribute::{AttributeStore, ObjectKey}; +use solidcore::geom::axis2_placement::Axis2Placement; +use solidcore::geom::extent1d::Extent1d; +use solidcore::geom::vector3d::{Point3, Vec3}; +use solidcore::heal::heal_data::p1::api_heal_brep; +use solidcore::heal::stitch::p1::api_stitch_into_solid; +use solidcore::kernel::Kernel; +use solidcore::mass::integrator::properties::{brep_area, brep_volume, PropertyAttributes}; +use solidcore::nurbs::b_spline_curve::{BSplineCurve, BSplineCurveForm, CanonicalData, KnotType}; +use solidcore::nurbs::b_spline_surface::{ + BSplineSurface, BSplineSurfaceForm, CanonicalSurfaceData, +}; +use solidcore::nurbs::b_spline_surface_n_lib::p2::OrientType; +use solidcore::query::api_queries::brep_is_manifold_solid; +use solidcore::surface::cone::Cone; +use solidcore::surface::plane::Plane; +use solidcore::surface::sphere::Sphere; +use solidcore::surface::surf_of_extrusion::SurfOfExtrusion; +use solidcore::surface::surf_of_revolution::SurfOfRevolution; +use solidcore::surface::surface::Surface; +use solidcore::surface::torus::{Torus, TorusOptions}; +use solidcore::topology::brep::p1 as bp1; +use solidcore::topology::brep::p2::ArenaNotifyState; +use solidcore::topology::brep::p3; +use solidcore::topology::brep::p8::{create_faces_with_curves, FaceBuildEnv}; +use solidcore::topology::topology_traverser::Marks; +use solidcore::topology::{Brep, FaceId}; +use solidcore::{EntityRef, SolidError}; + +use step_import::model::{self as sm, Body, Bound}; + +use crate::compare::values_agree; +use crate::ssi::{exit_detail, glob_match}; +use crate::HarnessError; + +/// Relative accuracy of the area and volume integrations. +pub const ACCURACY: f64 = 1e-6; +/// Per (body, kernel) time limit. +pub const TIMEOUT: Duration = Duration::from_secs(600); +/// Where the corpus is fetched. +pub const CORPUS_DIR: &str = "corpus/steptools"; +/// Verdicts, in summary order. +/// `HEAL-DIFF`: every build stage agrees and the first difference is in `SmApiHealBrep` +/// or after it; `DIFF`: a build stage (faces, stitch, counts, manifold, area, volume) +/// differs. +pub const VERDICTS: [&str; 6] = ["MATCH", "HEAL-DIFF", "DIFF", "DEFERRED", "CRASH", "SKIPPED"]; + +/// What one kernel reported for one body: stages in order, and where the Rust side +/// stopped on unported code. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct Record { + /// `(stage, values)` in order (names as `solidcore_ffi`'s `STAGES`). + pub stages: Vec<(String, Vec)>, + /// `(unit, upstream symbol)` of the unported callee that stopped the Rust side. + pub deferred: Option<(String, String)>, + /// Rust errors with no upstream status (shown, compared as status `-1`). + pub notes: Vec, +} + +impl Record { + fn to_json(&self) -> Value { + let stages: Vec = self + .stages + .iter() + .map(|(n, v)| json!({"stage": n, "values": v})) + .collect(); + json!({ + "stages": stages, + "deferred": self.deferred.as_ref().map(|(u, s)| json!({"unit": u, "symbol": s})), + "notes": self.notes, + }) + } + + fn from_json(v: &Value) -> Result { + let mut r = Record::default(); + for s in v["stages"].as_array().ok_or("no stages")? { + let name = s["stage"].as_str().ok_or("stage name")?.to_string(); + let vals = s["values"] + .as_array() + .ok_or("stage values")? + .iter() + .map(|x| x.as_f64().unwrap_or(f64::NAN)) + .collect(); + r.stages.push((name, vals)); + } + if let Some(d) = v.get("deferred").filter(|d| !d.is_null()) { + r.deferred = Some(( + d["unit"].as_str().unwrap_or("?").to_string(), + d["symbol"].as_str().unwrap_or("?").to_string(), + )); + } + if let Some(n) = v["notes"].as_array() { + r.notes = n + .iter() + .filter_map(|x| x.as_str().map(str::to_string)) + .collect(); + } + Ok(r) + } + + fn stage(&self, name: &str) -> Option<&[f64]> { + self.stages + .iter() + .find(|(n, _)| n == name) + .map(|(_, v)| v.as_slice()) + } +} + +/// Stops the Rust build on unported code. +struct Deferred(String, String); + +/// The status of a Rust result as upstream reports it: `0` success, the code of an +/// upstream status, `SM_ERR_INVALID_INPUT` for `InvalidInput`, `-1` (with a note) for +/// an error with no upstream status; unported code stops the build. +fn status(r: &Result, notes: &mut Vec) -> Result { + match r { + Ok(_) => Ok(0.0), + Err(SolidError::Unported { upstream, unit }) => { + Err(Deferred((*unit).to_string(), (*upstream).to_string())) + } + Err(SolidError::Upstream { code, .. }) => Ok(f64::from(*code)), + Err(SolidError::InvalidInput { .. }) => { + Ok(f64::from(solidcore::error::SM_ERR_INVALID_INPUT)) + } + Err(e) => { + if notes.len() < 20 { + notes.push(e.to_string()); + } + Ok(-1.0) + } + } +} + +fn v3(p: sm::P3) -> Vec3 { + Vec3::new(p[0], p[1], p[2]) +} + +fn a2p(f: &sm::Frame) -> Axis2Placement { + Axis2Placement::new(v3(f.origin), v3(f.x), v3(f.y)) +} + +fn usizes(m: &[u32]) -> Vec { + m.iter().map(|&x| x as usize).collect() +} + +/// The curve bounded to `[lo, hi]` (an edge) or unbounded (a swept curve), as the shim's +/// `make_curve`. +fn make_curve(k: &Kernel, c: &sm::Curve, bounds: Option<(f64, f64)>) -> Result { + match c { + sm::Curve::Line { + origin, + dir, + magnitude, + } => Ok(match bounds { + Some((lo, hi)) => Curve::from(Line::bounded( + v3(*origin), + v3(*dir), + &Extent1d::from_min_max(lo, hi), + &BoundedLineOptions { + scale: *magnitude, + dim: 3, + ..BoundedLineOptions::default() + }, + )), + None => Curve::from(Line::create_canonical(v3(*origin), v3(*dir))), + }), + sm::Curve::Circle { frame, radius } => { + let ivl = bounds.map(|(lo, hi)| Extent1d::from_min_max(lo, hi)); + Ok(Curve::from(Ellipse::create_canonical_circle( + k, + &a2p(frame), + *radius, + ivl.as_ref(), + None, + )?)) + } + sm::Curve::Ellipse { + frame, + semi1, + semi2, + } => { + let ivl = bounds.map(|(lo, hi)| Extent1d::from_min_max(lo, hi)); + Ok(Curve::from(Ellipse::create_canonical( + k, + &a2p(frame), + *semi1, + *semi2, + ivl.as_ref(), + None, + )?)) + } + sm::Curve::BSpline(b) => { + let (k0, k1) = ( + b.knots.first().copied().unwrap_or(0.0), + b.knots.last().copied().unwrap_or(0.0), + ); + // Trim only to a strict sub-interval of the knot range. + let trim = bounds + .filter(|&(lo, hi)| !(lo == k0 && hi == k1)) + .map(|(lo, hi)| Extent1d::from_min_max(lo, hi)); + let pts: Vec = b.points.iter().map(|p| v3(*p)).collect(); + let mults = usizes(&b.mults); + Ok(Curve::from(BSplineCurve::create_canonical( + &CanonicalData { + dim: 3, + degree: b.degree as usize, + control_points: &pts, + form: BSplineCurveForm::Unspecified, + knot_multiplicities: &mults, + knots: &b.knots, + knot_type: KnotType::Unspecified, + weights: b.weights.as_deref(), + trim, + }, + )?)) + } + } +} + +fn make_surface(k: &Kernel, s: &sm::Surface) -> Result { + Ok(match s { + sm::Surface::Plane { frame } => Surface::from(Plane::create_canonical(&a2p(frame))?), + sm::Surface::Cylinder { frame, radius } => { + Surface::from(Cone::create_canonical_cylinder(k, &a2p(frame), *radius)?) + } + sm::Surface::Cone { + frame, + radius, + semi_angle_deg, + } => Surface::from(Cone::create_canonical_with_semi_angle( + k, + &a2p(frame), + *radius, + *semi_angle_deg, + None, + false, + false, + )?), + sm::Surface::Sphere { frame, radius } => { + Surface::from(Sphere::create_canonical(k, &a2p(frame), *radius)?) + } + sm::Surface::Torus { + frame, + major, + minor, + } => Surface::from(Torus::create_canonical( + k, + &a2p(frame), + *major, + *minor, + TorusOptions::default(), + None, + )?), + sm::Surface::Revolution { + curve, + axis_origin, + axis_dir, + } => { + let swept = make_curve(k, curve, None)?; + Surface::from(SurfOfRevolution::create_canonical( + k, + swept, + v3(*axis_origin), + v3(*axis_dir), + false, + )?) + } + sm::Surface::Extrusion { + curve, + dir, + magnitude, + } => { + let swept = make_curve(k, curve, None)?; + let m = *magnitude; + Surface::from(SurfOfExtrusion::create_canonical( + k, + swept, + Vec3::new(dir[0] * m, dir[1] * m, dir[2] * m), + )?) + } + sm::Surface::BSpline(b) => { + let pts: Vec = b.points.iter().flatten().map(|p| v3(*p)).collect(); + let w: Option> = b + .weights + .as_ref() + .map(|w| w.iter().flatten().copied().collect()); + let um = usizes(&b.u_mults); + let vm = usizes(&b.v_mults); + Surface::from(BSplineSurface::create_canonical(&CanonicalSurfaceData { + u_degree: b.u_degree as usize, + v_degree: b.v_degree as usize, + control_points: &pts, + form: BSplineSurfaceForm::Unspecified, + u_multiplicities: &um, + v_multiplicities: &vm, + u_knots: &b.u_knots, + v_knots: &b.v_knots, + knot_type: KnotType::Unspecified, + weights: w.as_deref(), + uv_domain: None, + })?) + } + }) +} + +/// One face's loops as the shim gathers them: curve counts per edge loop, the bounded +/// curves (the first failure stops making them) and their orientations. +struct FaceInput { + loops: Vec, + curves: Vec>, + orients: Vec, +} + +fn face_input( + k: &Kernel, + body: &Body, + face: &sm::Face, + mut ok: bool, + first_err: &mut Option, +) -> FaceInput { + let mut out = FaceInput { + loops: Vec::new(), + curves: Vec::new(), + orients: Vec::new(), + }; + for l in &face.loops { + let Bound::Edges(es) = &l.bound else { + continue; // vertex loops are skipped + }; + let oes: Vec<(usize, bool)> = if l.orientation { + es.iter().map(|e| (e.edge, e.forward)).collect() + } else { + es.iter().rev().map(|e| (e.edge, !e.forward)).collect() + }; + out.loops.push(oes.len()); + for (ei, fwd) in oes { + let Some(e) = body.edges.get(ei) else { + continue; + }; + let same = e.same_sense; + let (lo, hi) = if same { + (e.t_start, e.t_end) + } else { + (e.t_end, e.t_start) + }; + let c = if ok { + match make_curve(k, &e.curve, Some((lo, hi))) { + Ok(c) => Some(c), + Err(err) => { + ok = false; + *first_err = Some(err); + None + } + } + } else { + None + }; + out.curves.push(c); + out.orients.push(if fwd == same { + OrientType::Same + } else { + OrientType::Opposite + }); + } + } + out +} + +fn counts(k: &mut Kernel, b: &Brep) -> Result, SolidError> { + Ok(vec![ + bp1::regions(b, None)?.len() as f64, + bp1::shells(k, b, None)?.len() as f64, + bp1::faces(b, None)?.len() as f64, + bp1::edges(b, None)?.len() as f64, + bp1::vertices(b, None)?.len() as f64, + ]) +} + +/// Builds `body` with the Rust port (the shim's recipe, stage by stage). +pub fn build_rust(body: &Body) -> Record { + let mut rec = Record::default(); + if let Err(Deferred(u, s)) = build_rust_stages(body, &mut rec) { + rec.deferred = Some((u, s)); + } + rec +} + +fn build_rust_stages(body: &Body, rec: &mut Record) -> Result<(), Deferred> { + let mut k = Kernel::default(); + let mut notes = Vec::new(); + let mut b = match p3::new_brep( + &mut k, + solidcore::geom::tol::USE_DEFAULT, + solidcore::geom::tol::USE_DEFAULT, + ) { + Ok(b) => b, + Err(e) => { + status(&Err::<(), _>(e), &mut rec.notes)?; + return Ok(()); + } + }; + + // faces + let mut per_face = Vec::new(); + let (mut made_total, mut failed) = (0.0, 0.0); + { + let mut state = ArenaNotifyState::new(&b); + let mut env = state.env(None); + let key_of: &dyn Fn(EntityRef) -> solidcore::geom::object::ObjectRef = env.key_of; + let this_face = |f: FaceId| key_of(EntityRef::Face(f)); + let mut marks = Marks::default(); + let mut fenv = FaceBuildEnv { + env: &mut env, + marks: &mut marks, + this_face: &this_face, + }; + for face in &body.faces { + let mut first_err = None; + let srf = match make_surface(&k, &face.surface) { + Ok(s) => Some(s), + Err(e) => { + first_err = Some(e); + None + } + }; + let input = face_input(&k, body, face, srf.is_some(), &mut first_err); + let (st, made) = if let Some(e) = first_err { + (status(&Err::<(), _>(e), &mut notes)?, 0.0) + } else { + let r = create_faces_with_curves( + &mut k, + &mut b, + None, + &input.loops, + Some(input.curves), + &input.orients, + srf, + &mut fenv, + ); + let made = r.as_ref().map_or(0.0, |f| f.faces.len() as f64); + (status(&r, &mut notes)?, made) + }; + if st != 0.0 { + failed += 1.0; + } + made_total += made; + per_face.push(st); + per_face.push(made); + } + } + let mut fv = vec![body.faces.len() as f64, made_total, failed]; + fv.extend(per_face); + rec.stages.push(("faces".into(), fv)); + + let result = (|| -> Result<(), Deferred> { + let r = api_stitch_into_solid(&mut k, Some(&mut b)); + let s = status(&r, &mut notes)?; + rec.stages.push(( + "stitch".into(), + match &r { + Ok(a) => vec![ + s, + f64::from(u8::from(a.produces_a_solid)), + a.stitched_edges as f64, + a.max_vertex_gap, + a.max_edge_gap, + ], + Err(_) => vec![s, 0.0, 0.0, 0.0, 0.0], + }, + )); + let c = counts(&mut k, &b); + status(&c, &mut notes)?; + rec.stages.push(("counts".into(), c.unwrap_or_default())); + let m = brep_is_manifold_solid(&b); + let s = status(&m, &mut notes)?; + rec.stages.push(( + "manifold".into(), + vec![s, f64::from(u8::from(m.unwrap_or(false)))], + )); + let a = brep_area(&k, &mut b, ACCURACY); + let s = status(&a, &mut notes)?; + rec.stages.push(("area".into(), vec![s, a.unwrap_or(0.0)])); + let store = AttributeStore::default(); + let attrs = PropertyAttributes { + store: &store, + key_of: &|_| ObjectKey(0), + }; + let v = brep_volume(&k, &mut b, ACCURACY, &attrs); + let s = status(&v, &mut notes)?; + rec.stages + .push(("volume".into(), vec![s, v.unwrap_or(0.0)])); + let h = api_heal_brep(&mut k, Some(&mut b)); + let s = status(&h, &mut notes)?; + rec.stages.push(("heal".into(), vec![s])); + let c = counts(&mut k, &b); + status(&c, &mut notes)?; + rec.stages + .push(("healed_counts".into(), c.unwrap_or_default())); + let m = brep_is_manifold_solid(&b); + let s = status(&m, &mut notes)?; + rec.stages.push(( + "healed_manifold".into(), + vec![s, f64::from(u8::from(m.unwrap_or(false)))], + )); + let v = brep_volume(&k, &mut b, ACCURACY, &attrs); + let s = status(&v, &mut notes)?; + rec.stages + .push(("healed_volume".into(), vec![s, v.unwrap_or(0.0)])); + Ok(()) + })(); + rec.notes.extend(notes); + result +} + +/// Builds `body` with the C++ reference. +#[cfg(feature = "cpp")] +pub fn build_cpp(body: &Body) -> Result { + use solidcore_ffi::units::step_import as ffi; + let flat = step_import::flat::encode(body); + let out = ffi::build(&flat, ACCURACY).map_err(|e| e.to_string())?; + let stages = ffi::stages(&out).map_err(|e| e.to_string())?; + Ok(Record { + stages: stages + .into_iter() + .map(|(n, v)| (n.to_string(), v)) + .collect(), + ..Record::default() + }) +} + +fn load_body(path: &Path, index: usize) -> Result { + let bytes = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?; + let model = step_import::import(&bytes, &step_import::Limits::default()) + .map_err(|e| format!("parse: {e}"))?; + let body = model + .bodies + .into_iter() + .nth(index) + .ok_or_else(|| format!("no body {index}"))?; + // Both kernels build from the decoded encoding: the very numbers the shim reads. + step_import::flat::decode(&step_import::flat::encode(&body)) +} + +/// `--step-case `: one kernel on one body. +pub fn run_child(args: &[String]) -> ExitCode { + let [file, index, side, out] = args else { + eprintln!("usage: difftest --step-case "); + return ExitCode::from(2); + }; + let Ok(index) = index.parse::() else { + eprintln!("difftest: bad body index {index}"); + return ExitCode::from(2); + }; + let body = match load_body(Path::new(file), index) { + Ok(b) => b, + Err(e) => { + eprintln!("difftest: {e}"); + return ExitCode::from(2); + } + }; + let rec = match side.as_str() { + "rust" => Ok(build_rust(&body)), + #[cfg(feature = "cpp")] + "cpp" => build_cpp(&body), + other => Err(format!("side `{other}` not available in this build")), + }; + match rec { + Ok(r) => { + let text = serde_json::to_string(&r.to_json()).unwrap_or_default(); + match std::fs::write(out, text + "\n") { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("difftest: {out}: {e}"); + ExitCode::from(2) + } + } + } + Err(e) => { + eprintln!("difftest: {e}"); + ExitCode::from(2) + } + } +} + +/// Options of [`run_corpus`]. +#[derive(Debug, Clone, Default)] +pub struct CorpusOptions { + /// Only files whose path under the corpus matches this glob. + pub glob: Option, + /// Child processes at a time. + pub jobs: usize, + /// Where the report and raw outputs go (default `bin/step-corpus`). + pub report_dir: Option, + /// Write no report. + pub no_report: bool, +} + +/// The sides this build runs. +fn sides() -> &'static [&'static str] { + if cfg!(feature = "cpp") { + &["cpp", "rust"] + } else { + &["rust"] + } +} + +fn step_files(dir: &Path, out: &mut Vec) { + let Ok(rd) = std::fs::read_dir(dir) else { + return; + }; + let mut entries: Vec = rd.filter_map(|e| e.ok().map(|e| e.path())).collect(); + entries.sort(); + for p in entries { + if p.is_dir() { + step_files(&p, out); + } else if p + .extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| e.eq_ignore_ascii_case("stp") || e.eq_ignore_ascii_case("step")) + { + out.push(p); + } + } +} + +type RunResult = Result; + +struct Job { + slot: (usize, usize), + out: PathBuf, + child: Child, + start: Instant, +} + +fn read_record(path: &Path) -> RunResult { + let text = std::fs::read_to_string(path).map_err(|e| format!("unreadable output: {e}"))?; + let v: Value = serde_json::from_str(&text).map_err(|e| format!("unreadable output: {e}"))?; + Record::from_json(&v) +} + +/// Runs every (job, side) in a child process, `jobs` at a time. +fn run_children( + work: &[(PathBuf, usize, String)], + raw: &Path, + jobs: usize, +) -> Result>, HarnessError> { + let exe = + std::env::current_exe().map_err(|e| HarnessError::new(format!("current_exe: {e}")))?; + let mut results: Vec> = work + .iter() + .map(|_| sides().iter().map(|_| Err("not run".to_string())).collect()) + .collect(); + let mut queue: Vec<(usize, usize)> = (0..work.len()) + .flat_map(|w| (0..sides().len()).map(move |s| (w, s))) + .rev() + .collect(); + let mut running: Vec = Vec::new(); + while !queue.is_empty() || !running.is_empty() { + while running.len() < jobs.max(1) { + let Some((w, s)) = queue.pop() else { break }; + let (Some((file, body, id)), Some(side)) = (work.get(w), sides().get(s)) else { + continue; + }; + let out = raw.join(format!("{id}.{side}.json")); + let log = raw.join(format!("{id}.{side}.log")); + let _ = std::fs::remove_file(&out); + let log_file = std::fs::File::create(&log) + .map_err(|err| HarnessError::new(format!("{}: {err}", log.display())))?; + let log_err = log_file + .try_clone() + .map_err(|err| HarnessError::new(format!("{}: {err}", log.display())))?; + let child = Command::new(&exe) + .arg("--step-case") + .arg(file) + .arg(body.to_string()) + .arg(side) + .arg(&out) + .stdin(Stdio::null()) + .stdout(log_file) + .stderr(log_err) + .spawn() + .map_err(|err| HarnessError::new(format!("spawn {}: {err}", exe.display())))?; + running.push(Job { + slot: (w, s), + out, + child, + start: Instant::now(), + }); + } + let mut i = 0; + let mut progressed = false; + while i < running.len() { + let done = match running.get_mut(i) { + Some(job) => match job.child.try_wait() { + Ok(Some(st)) => Some(if st.success() { + read_record(&job.out) + } else { + Err(exit_detail(st)) + }), + Ok(None) if job.start.elapsed() > TIMEOUT => { + let _ = job.child.kill(); + let _ = job.child.wait(); + Some(Err(format!("timeout after {} s", TIMEOUT.as_secs()))) + } + Ok(None) => None, + Err(err) => Some(Err(format!("wait failed: {err}"))), + }, + None => None, + }; + if let Some(r) = done { + let job = running.swap_remove(i); + if let Some(slot) = results + .get_mut(job.slot.0) + .and_then(|v| v.get_mut(job.slot.1)) + { + *slot = r; + } + progressed = true; + } else { + i += 1; + } + } + if !progressed { + std::thread::sleep(Duration::from_millis(10)); + } + } + Ok(results) +} + +/// The first stage where the two records differ: `(stage, index, cpp, rust)`. +pub fn first_difference(cpp: &Record, rust: &Record) -> Option<(String, usize, f64, f64)> { + for (name, rv) in &rust.stages { + let Some(cv) = cpp.stage(name) else { + continue; + }; + if cv.len() != rv.len() { + return Some((name.clone(), usize::MAX, cv.len() as f64, rv.len() as f64)); + } + for (i, (&a, &b)) in cv.iter().zip(rv).enumerate() { + if !values_agree(a, b, None) { + return Some((name.clone(), i, a, b)); + } + } + } + None +} + +/// A short summary of one side: faces built, solid, volume. +fn side_summary(r: &RunResult) -> Value { + match r { + Err(e) => json!({"error": e}), + Ok(rec) => { + let faces = rec + .stage("faces") + .map(|v| json!({"total": v.first(), "made": v.get(1), "failed": v.get(2)})); + json!({ + "faces": faces, + "stitch": rec.stage("stitch"), + "counts": rec.stage("counts"), + "manifold": rec.stage("manifold"), + "area": rec.stage("area"), + "volume": rec.stage("volume"), + "heal": rec.stage("heal"), + "healed_counts": rec.stage("healed_counts"), + "healed_manifold": rec.stage("healed_manifold"), + "healed_volume": rec.stage("healed_volume"), + "deferred": rec.deferred.as_ref().map(|(u, s)| json!({"unit": u, "symbol": s})), + "notes": rec.notes, + }) + } + } +} + +fn verdict(results: &[RunResult]) -> (String, Value) { + let (cpp, rust) = match (results.first(), results.get(1)) { + (Some(c), Some(r)) if sides().len() == 2 => (Some(c), r), + (Some(r), _) => (None, r), + _ => return ("CRASH".into(), json!("no result")), + }; + if let Some(Err(e)) = cpp { + return ("CRASH".into(), json!({"side": "cpp", "detail": e})); + } + let rust = match rust { + Err(e) => return ("CRASH".into(), json!({"side": "rust", "detail": e})), + Ok(r) => r, + }; + let diff = cpp + .and_then(|c| c.as_ref().ok()) + .and_then(|c| first_difference(c, rust)); + if let Some((stage, i, a, b)) = diff { + let v = if stage.starts_with("heal") { + "HEAL-DIFF" + } else { + "DIFF" + }; + return ( + v.into(), + json!({"stage": stage, "index": i, "cpp": a, "rust": b}), + ); + } + if let Some((u, s)) = &rust.deferred { + return ( + "DEFERRED".into(), + json!({"unit": u, "symbol": s, "after": rust.stages.last().map(|(n, _)| n)}), + ); + } + ("MATCH".into(), Value::Null) +} + +/// Runs the corpus (see the module docs). +pub fn run_corpus(root: &Path, opts: &CorpusOptions) -> ExitCode { + match run_corpus_inner(root, opts) { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("difftest: {e}"); + ExitCode::from(2) + } + } +} + +fn run_corpus_inner(root: &Path, opts: &CorpusOptions) -> Result<(), HarnessError> { + let dir = root.join(CORPUS_DIR); + let mut files = Vec::new(); + step_files(&dir, &mut files); + if files.is_empty() { + return Err(HarnessError::new(format!( + "no STEP files in {}; run scripts/fetch-steptools-corpus.sh", + dir.display() + ))); + } + files.retain(|f| { + let rel = f + .strip_prefix(&dir) + .unwrap_or(f) + .to_string_lossy() + .into_owned(); + opts.glob.as_deref().is_none_or(|g| glob_match(g, &rel)) + }); + // smallest first + files.sort_by_key(|f| (std::fs::metadata(f).map_or(0, |m| m.len()), f.clone())); + let report_dir = opts + .report_dir + .clone() + .unwrap_or_else(|| root.join("bin/step-corpus")); + let raw = report_dir.join("raw"); + std::fs::create_dir_all(&raw) + .map_err(|e| HarnessError::new(format!("{}: {e}", raw.display())))?; + + // import in the parent: parse status, skipped bodies, the work list + let mut file_entries: Vec = Vec::new(); + let mut work: Vec<(PathBuf, usize, String)> = Vec::new(); + let mut work_file: Vec = Vec::new(); + let mut unsupported: BTreeMap = BTreeMap::new(); + for (fi, f) in files.iter().enumerate() { + let rel = f + .strip_prefix(&dir) + .unwrap_or(f) + .to_string_lossy() + .into_owned(); + let bytes = std::fs::read(f).map_err(|e| HarnessError::new(format!("{rel}: {e}")))?; + match step_import::import(&bytes, &step_import::Limits::default()) { + Err(e) => file_entries.push(json!({"file": rel, "parse": e.to_string(), "bodies": []})), + Ok(m) => { + for s in &m.skipped { + *unsupported.entry(s.reason.clone()).or_default() += 1; + } + let bodies: Vec = m + .bodies + .iter() + .enumerate() + .map(|(bi, b)| { + let id = format!("{}-{bi}", rel.replace(['/', '.'], "_")); + work.push((f.clone(), bi, id)); + work_file.push(fi); + json!({ + "step_id": b.step_id, + "kind": b.kind.step_name(), + "unit_mm": b.unit_mm, + "uncertainty_mm": b.uncertainty_mm, + "source": {"shells": b.shells.len(), "faces": b.faces.len(), + "edges": b.edges.len(), "vertices": b.vertices.len()}, + }) + }) + .collect(); + let skipped: Vec = m + .skipped + .iter() + .map(|s| json!({"step_id": s.step_id, "kind": s.kind, "reason": s.reason})) + .collect(); + file_entries.push(json!({ + "file": rel, + "schemas": m.schemas, + "bodies": bodies, + "skipped": skipped, + "assembly_instances": m.assembly_instances, + })); + } + } + } + + let results = run_children(&work, &raw, opts.jobs)?; + let mut tally: BTreeMap = BTreeMap::new(); + let mut deferred_units: BTreeMap = BTreeMap::new(); + let mut diff_stages: BTreeMap = BTreeMap::new(); + let mut body_iter = 0usize; + println!( + "{:<36} {:>5} {:<9} {:<22} {:<22} detail", + "file", "body", "verdict", "cpp faces/solid", "rust faces/solid" + ); + for (fi, entry) in file_entries.iter_mut().enumerate() { + let rel = entry["file"].as_str().unwrap_or("?").to_string(); + if let Some(p) = entry.get("parse").and_then(Value::as_str) { + println!("{rel:<36} {:>5} {:<9} parse error: {p}", "-", "PARSE"); + *tally.entry("PARSE".into()).or_default() += 1; + continue; + } + let n = entry["bodies"].as_array().map_or(0, Vec::len); + if n == 0 { + let why = entry["skipped"] + .as_array() + .and_then(|s| s.first()) + .and_then(|s| s["reason"].as_str()) + .unwrap_or("no B-rep body"); + println!("{rel:<36} {:>5} {:<9} {why}", "-", "SKIPPED"); + *tally.entry("SKIPPED".into()).or_default() += 1; + } + for bi in 0..n { + let wi = body_iter; + body_iter += 1; + if work_file.get(wi) != Some(&fi) { + continue; + } + let res = results.get(wi).map_or(&[][..], Vec::as_slice); + let (v, detail) = verdict(res); + *tally.entry(v.clone()).or_default() += 1; + if v == "DEFERRED" { + if let Some(u) = detail.get("unit").and_then(Value::as_str) { + *deferred_units.entry(u.to_string()).or_default() += 1; + } + } + if v == "DIFF" || v == "HEAL-DIFF" { + if let Some(s) = detail.get("stage").and_then(Value::as_str) { + *diff_stages.entry(s.to_string()).or_default() += 1; + } + } + let short = |r: Option<&RunResult>| -> String { + match r { + None => "-".into(), + Some(Err(_)) => "crash".into(), + Some(Ok(rec)) => { + let f = rec.stage("faces").unwrap_or(&[]); + let made = f.get(1).copied().unwrap_or(0.0); + let tot = f.first().copied().unwrap_or(0.0); + let solid = rec + .stage("stitch") + .and_then(|s| s.get(1)) + .is_some_and(|&x| x == 1.0); + format!("{made}/{tot} {}", if solid { "solid" } else { "-" }) + } + } + }; + let (c, r) = if sides().len() == 2 { + (res.first(), res.get(1)) + } else { + (None, res.first()) + }; + println!( + "{rel:<36} {bi:>5} {v:<9} {:<22} {:<22} {}", + short(c), + short(r), + if detail.is_null() { + String::new() + } else { + detail.to_string() + } + ); + if let Some(b) = entry["bodies"].get_mut(bi) { + b["verdict"] = json!(v); + b["detail"] = detail; + for (si, side) in sides().iter().enumerate() { + b[*side] = side_summary(res.get(si).unwrap_or(&Err("not run".into()))); + } + } + } + } + let summary: Vec = tally.iter().map(|(k, v)| format!("{k}={v}")).collect(); + println!( + "STEP-CORPUS files={} bodies={} {}", + files.len(), + work.len(), + summary.join(" ") + ); + if !deferred_units.is_empty() { + println!("deferred on: {deferred_units:?}"); + } + if !diff_stages.is_empty() { + println!("first differing stage: {diff_stages:?}"); + } + if !unsupported.is_empty() { + println!("unsupported (bodies skipped by the importer): {unsupported:?}"); + } + if !opts.no_report { + let report = json!({ + "corpus": CORPUS_DIR, + "accuracy": ACCURACY, + "sides": sides(), + "tally": tally, + "deferred_units": deferred_units, + "diff_stages": diff_stages, + "unsupported": unsupported, + "files": file_entries, + }); + let path = report_dir.join("report.json"); + std::fs::write( + &path, + serde_json::to_string_pretty(&report).unwrap_or_default(), + ) + .map_err(|e| HarnessError::new(format!("{}: {e}", path.display())))?; + println!("report: {}", path.display()); + } + Ok(()) +} diff --git a/crates/difftest/src/units/blend_fillet_corner_p1.rs b/crates/difftest/src/units/blend_fillet_corner_p1.rs index 4b6a8557b0..cddf005c98 100644 --- a/crates/difftest/src/units/blend_fillet_corner_p1.rs +++ b/crates/difftest/src/units/blend_fillet_corner_p1.rs @@ -2065,6 +2065,11 @@ fn corner_rust(i: &Inputs) -> Result { corner_rec(&fc, &rm, &mut rec)?; if !insert { super::blend_fillet_corner_p3::put_fillet_brep(&mut rec, &new_brep)?; + super::blend_fillet_corner_p3::put_error_info( + &mut rec, + &exec.error, + fc.filleted_vertex(), + ); } squeeze_output(&rec) } diff --git a/crates/difftest/src/units/blend_fillet_corner_p3.rs b/crates/difftest/src/units/blend_fillet_corner_p3.rs index 69eb70b900..695a03e68e 100644 --- a/crates/difftest/src/units/blend_fillet_corner_p3.rs +++ b/crates/difftest/src/units/blend_fillet_corner_p3.rs @@ -46,10 +46,13 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use solidcore::blend::fillet_corner::p3::{ - CornerEnv, CornerFilletBrep, FilletCorner, FilletCornerKind, FilletExecutiveData, + CornerEnv, CornerFilletBrep, ExecutiveErrorObject, FilletCorner, FilletCornerKind, + FilletExecutiveData, }; +use solidcore::blend::fillet_custom_solver::{OffsetSurface, SurfaceId}; +use solidcore::blend::fillet_executive::p3::ErrorInfo; use solidcore::blend::fillet_geom::p1::{FilletCornerId, FilletCornerType, FilletSolverId}; -use solidcore::blend::fillet_solver::p1::{FilletSolver, FilletSurfaceGenerator}; +use solidcore::blend::fillet_solver::p1::{FilletSolver, FilletSurfaceGenerator, OffsetSurfaceSlot}; use solidcore::intersect::surface_intersector::p1::SrfIndex; use solidcore::kernel::Kernel; use solidcore::math::EFF_ZERO_RAD; @@ -189,6 +192,50 @@ impl CornerOp { } } +#[derive(Debug, Clone, Serialize, Deserialize)] +struct BigRadCornerIn { + kind: usize, + v_edge: i64, + #[serde(default)] + v_sub: usize, + #[serde(default)] + adjusted: bool, + tol: f64, + tan: f64, + #[serde(default)] + edges: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct BigRadRailsIn { + f0: usize, + eu0: usize, + d0: f64, + f1: usize, + eu1: usize, + d1: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct BigRadSolverIn { + tol: f64, + ang: f64, + tang: f64, + #[serde(default)] + rails: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct BigRadIn { + model: Model, + fg: usize, + corners: Vec, + fillet_corners: Vec, + solvers: Vec, + geom_solvers: Vec, + fillet_solvers: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "case", rename_all = "snake_case")] enum Inputs { @@ -212,6 +259,7 @@ enum Inputs { model: Model, corner: CornerIn, }, + BigRad(BigRadIn), } const KINDS: [FilletCornerKind; 11] = [ @@ -853,8 +901,8 @@ pub(super) fn corner_rust_status( let faces = rm.target_faces.clone(); // m_pFilletBrep: `new (ctx) SmBrep()` (its constructor makes the infinite region) let mut fillet_brep = Brep::new_with_infinite_region().map_err(h)?; + let mut exec = FilletExecutiveData::default(); let r = { - let mut exec = FilletExecutiveData::default(); let mut records = BTreeMap::new(); let mut attributes = solidcore::geom::attribute::AttributeStore::default(); let mut env = CornerEnv { @@ -900,6 +948,7 @@ pub(super) fn corner_rust_status( rm.put(&mut rec)?; put_corner_surfaces(&mut rec, &c.surfaces)?; put_fillet_brep(&mut rec, &fillet_brep)?; + put_error_info(&mut rec, &exec.error, c.filleted_vertex()); corner_output(&rec) } @@ -988,6 +1037,532 @@ pub(super) fn put_fillet_brep(o: &mut Vec, b: &Brep) -> Result<(), HarnessE Ok(()) } +/// Executive error info as `p3_put_error_info` writes it. +pub(super) fn put_error_info( + o: &mut Vec, + info: &ErrorInfo, + corner_vert: Option, +) { + o.push(f64::from(info.code)); + o.push(info.corner_type as i64 as f64); + let vtag = match info.vertex { + None => -1.0, + Some(ExecutiveErrorObject::TargetVertex(v)) if Some(v) == corner_vert => 1.0, + Some(_) => 0.0, + }; + o.push(vtag); + let nb = info.entity_name.as_bytes(); + o.push(nb.len() as f64); + for &b in nb { + o.push(f64::from(b)); + } + let cb = info.comment.as_bytes(); + o.push(cb.len() as f64); + for &b in cb { + o.push(f64::from(b)); + } +} + +fn big_rad_input(b: &BigRadIn) -> Result, HarnessError> { + let mut o = Vec::new(); + push_model(&mut o, &b.model)?; + o.push(b.fg as f64); + o.push(b.corners.len() as f64); + for c in &b.corners { + o.extend([ + c.kind as f64, + c.v_edge as f64, + c.v_sub as f64, + flag(c.adjusted), + c.tol, + c.tan, + c.edges.len() as f64, + ]); + for &e in &c.edges { + o.push(e as f64); + } + } + o.push(b.fillet_corners.len() as f64); + for &ci in &b.fillet_corners { + o.push(ci as f64); + } + o.push(b.solvers.len() as f64); + for s in &b.solvers { + o.extend([s.tol, s.ang, s.tang, flag(s.rails.is_some())]); + if let Some(r) = &s.rails { + o.extend([ + r.f0 as f64, + r.eu0 as f64, + r.d0, + r.f1 as f64, + r.eu1 as f64, + r.d1, + ]); + } + o.push(0.0); // ngo = 0 so M.geoms matches rm.geoms + } + for &si in &b.geom_solvers { + o.push(si as f64); + } + o.push(b.fillet_solvers.len() as f64); + for &si in &b.fillet_solvers { + o.push(si as f64); + } + Ok(o) +} + +fn big_rad_output(rec: &[f64]) -> Result { + let first = rec + .first() + .copied() + .ok_or_else(|| harness("empty big_rad record"))?; + if first == REFUSED { + return Ok(Output::rejected("upstream would dereference null")); + } + let converted = rec + .get(1) + .copied() + .ok_or_else(|| harness("short big_rad record"))?; + let tail = rec.get(2..).unwrap_or_default().to_vec(); + Ok(Output::new() + .int("status", first as i64) + .int("converted", converted as i64) + .values("state", tail)) +} + +fn big_rad_cpp(b: &BigRadIn) -> Result { + let input = big_rad_input(b)?; + let flat = ffi::corner(Op::BigRad, &input).map_err(|e| harness(e.to_string()))?; + big_rad_output(&flat) +} + +fn big_rad_rust(k: &Kernel, b: &BigRadIn) -> Result { + ffi::validate_corner_op(Op::BigRad, &big_rad_input(b)?) + .map_err(|e| harness(e.to_string()))?; + let h = |e: SolidError| harness(e.to_string()); + let mut rm = RustModel::build(&b.model)?; + let fg = rm.geom(b.fg)?; + let mut exec = FilletExecutiveData::default(); + for (ci, c) in b.corners.iter().enumerate() { + let kind = *KINDS.get(c.kind).ok_or_else(|| harness("kind"))?; + let vert = match usize::try_from(c.v_edge) { + Ok(e) => { + let edge = *rm + .target_edges + .get(e) + .ok_or_else(|| harness("target edge"))?; + let [vs, ve] = rm.target.edge(edge).map_err(h)?.vertices; + Some(if c.v_sub == 0 { vs } else { ve }) + } + Err(_) => None, + }; + let mut fc = FilletCorner::new(kind, vert, None, None, c.tol, c.tan); + fc.topology_adjusted = c.adjusted; + for &ei in &c.edges { + fc.edges.push(rm.edge(ei)?); + } + exec.corners.insert(FilletCornerId(ci as u32), fc); + } + for &ci in &b.fillet_corners { + exec.fillet_corners.push(FilletCornerId(ci as u32)); + } + let mut solvers = Vec::new(); + for (si, s) in b.solvers.iter().enumerate() { + let mut fs = FilletSolver::new(s.tol, s.ang, s.tang); + if let Some(r) = &s.rails { + let face_eu = |rm: &RustModel, fi: usize, eui: usize| -> Result<(FaceId, EdgeuseId), HarnessError> { + let f = *rm.target_faces.get(fi).ok_or_else(|| harness("face"))?; + let loops = solidcore::topology::face::p2::loops(&rm.target, f, false, None).map_err(h)?; + let l = *loops.first().ok_or_else(|| harness("loop"))?; + let eus = solidcore::topology::loopuse::loop_edgeuses(&rm.target, l).map_err(h)?; + let eu = *eus.get(eui).ok_or_else(|| harness("eu"))?; + Ok((f, eu)) + }; + let (f0, eu0) = face_eu(&rm, r.f0, r.eu0)?; + let (f1, eu1_id) = face_eu(&rm, r.f1, r.eu1)?; + let s0_base = rm.target.face(f0).map_err(h)?.surface.clone().ok_or_else(|| harness("s0"))?; + let s1_base = rm.target.face(f1).map_err(h)?.surface.clone().ok_or_else(|| harness("s1"))?; + let mut os0 = OffsetSurface::new( + &rm.k, + r.d0, + s0_base, + SurfaceId::Face(f0), + solidcore::surface::offset_surface::SS_UNKNOWN, + ) + .map_err(h)?; + let mut os1 = OffsetSurface::new( + &rm.k, + r.d1, + s1_base, + SurfaceId::Face(f1), + solidcore::surface::offset_surface::SS_UNKNOWN, + ) + .map_err(h)?; + os0.set_allow_extension(true); + os1.set_allow_extension(true); + fs.edgeuses = [Some(eu0), Some(eu1_id)]; + fs.set_surface( + 0, + Some(OffsetSurfaceSlot { + key: (si * 2 + 1) as u64, + surface: os0, + }), + ) + .map_err(h)?; + fs.set_surface( + 1, + Some(OffsetSurfaceSlot { + key: (si * 2 + 2) as u64, + surface: os1, + }), + ) + .map_err(h)?; + } + solvers.push(fs); + } + for (gi, &si) in b.geom_solvers.iter().enumerate() { + let gid = rm.geom(gi)?; + rm.fb.geom_mut(gid).map_err(h)?.solver = + usize::try_from(si).ok().map(|i| FilletSolverId(i as u32)); + } + for &si in &b.fillet_solvers { + exec.fillet_solvers.push(FilletSolverId(si as u32)); + } + + let res = exec.test_and_convert_big_rad_fillet( + k, + &mut rm.target, + &mut rm.fb, + &mut solvers, + fg, + ); + let (st, converted) = match res { + Ok(conv) => (0.0, conv), + Err(e) if e.is_unported() => { + return Ok(Output::unported(&e).unwrap_or_else(|| Output::err(e.to_string()))) + } + Err(e) => (status(&e), false), + }; + let mut rec = vec![st, flag(converted)]; + if st == 0.0 { + rec.push(exec.fillet_corners.len() as f64); + for &cid in &exec.fillet_corners { + rec.push(f64::from(cid.0)); + } + rec.push(exec.fillet_solvers.len() as f64); + for &sid in &exec.fillet_solvers { + rec.push(f64::from(sid.0)); + } + if converted { + let new_id = FilletCornerId(b.corners.len() as u32); + let nc = exec.corners.get(&new_id).ok_or_else(|| harness("new corner"))?; + rec.push(nc.corner_type() as i64 as f64); + rec.push(nc.vertices.len() as f64); + for &v in &nc.vertices { + rec.push(rm.vertex_index(Some(v))); + } + rec.push(nc.edges.len() as f64); + for &e in &nc.edges { + rec.push(rm.edge_index(Some(e))); + } + rec.push(nc.approx_tol_3d); + rec.push(nc.tangency_tol_radians); + rec.push((rm.target_edges.len() * 2) as f64); + for &te in &rm.target_edges { + let [vs, ve] = rm.target.edge(te).map_err(h)?.vertices; + for tv in [vs, ve] { + rec.push( + exec.vertex_corner_map + .get(&tv) + .map_or(-1.0, |c| f64::from(c.0)), + ); + } + } + } + rm.put(&mut rec)?; + } + big_rad_output(&rec) +} + +/// Curated `TestAndConvertBigRadFillet` cases exercising full corner conversion +/// (`squeeze = 0` & `1`, converging `CalcRailIntRail` on 3 orthogonal planes, edge +/// reversal, corner-edge relation updates, index swap in `m_vFilletCorners`) as well as +/// non-converting early-outs and error statuses. +fn big_rad_cases() -> Result, HarnessError> { + let mk_vert = |point: [f64; 3], + vtype: i32, + status: i32, + corner: i64, + face: i64, + mates: [i64; 2]| VertexIn { + point, + vtype: i64::from(vtype), + status: i64::from(status), + corner, + pc_class: if face >= 0 { 102 } else { 100 }, + pc_kind: if face >= 0 { 3 } else { 0 }, + pc_index: face, + pc_sub: 0, + pc_face: face, + t_param: 0.0, + uv: [0.2, 0.2], + mates, + }; + let mk_edge = |v0: usize, v1: usize, etype: i32, corner: i64, g0: i64, g1: i64| EdgeIn { + vs: v0, + ve: v1, + etype: i64::from(etype), + status: 1, // SM_FIL_PROCESSED + corner, + curve: Some(line([0.0, 0.0, 0.0], [1.0, 0.0, 0.0])), + interval: Some([0.0, 1.0]), + fbe: [-1, -1], + eus: [ + EuIn { + uv_curve: None, + geom: g0, + vu_uv: [0.2, 0.2], + }, + EuIn { + uv_curve: None, + geom: g1, + vu_uv: [0.2, 0.2], + }, + ], + }; + + // Base converting setup: + // Target faces: f0 = XY plane z=0, f1 = XZ plane y=0, f2 = YZ plane x=0. + // fg = geom 0 between corner 0 (Nx2, target vert (0,0)) and corner 1 (Nx2, target vert (0,1)). + // Rail 0: v0 (1, 0.2, 0) -> v1 (0, 0.2, 0) (dir = (-1, 0, 0)), MATE vertices on f0 (z=0), mates v4 & v5. + // Rail 1: v2 (0.2, 0, 0.2) -> v3 (0.8, 0, 0.2) (dir = (+0.6, 0, 0), dot < 0!). + // Extra rails into v0 (geom 1, solver 1 on f0 & f1) and v1 (geom 2, solver 2 on f0 & f2) so that + // after squeezing rail 0 (moving v1's uses onto v0), v0 has geoms [1, 2] and CalcRailIntRail + // converges on f0 at (0.2, 0.2, 0.0)! + let base_case = |swap_mates_to_rail1: bool, reverse_cs: bool, swap_fc_order: bool| -> BigRadIn { + let (vt0, vt1) = if swap_mates_to_rail1 { + (4, 10) // RailXRail on rail 0, Mate on rail 1 -> squeeze = 1! + } else { + (10, 4) // Mate on rail 0, RailXRail on rail 1 -> squeeze = 0! + }; + let mut vertices = vec![ + mk_vert([1.0, 0.2, 0.0], vt0, 1, 0, 0, [4, -1]), // v0: start of rail 0 (corner 0) + mk_vert([0.0, 0.2, 0.0], vt0, 1, 1, 0, [-1, -1]), // v1: end of rail 0 (corner 1) + mk_vert([0.2, 0.0, 0.2], vt1, 1, 0, 0, [4, -1]), // v2: start of rail 1 (corner 0) + mk_vert([0.8, 0.0, 0.2], vt1, 1, 1, 0, [-1, -1]), // v3: end of rail 1 (corner 1) + mk_vert([0.2, 0.0, 0.3], 10, 1, 0, 1, [0, 2]), // v4: mate on f1 + mk_vert([0.0, 0.3, 0.2], 10, 1, 0, 2, [-1, -1]), // v5: extra vertex for rail of geom 1/2 + ]; + if swap_mates_to_rail1 { + if let Some(v0) = vertices.get_mut(0) { + v0.mates = [-1, -1]; + } + if let Some(v2) = vertices.get_mut(2) { + v2.mates = [4, -1]; + } + } + // Edges: + // e0: rail 0 of geom 0 (v0 -> v1) + // e1: rail 1 of geom 0 (v2 -> v3) + // e2: cross-section of corner 1 (end corner): connects v3 & v1 + // e3: cross-section of corner 0 (start corner): connects v0 & v2 + // e4: rail of geom 1 connected to squeezed start vertex (v0 or v2) -> v5 + // e5: rail of geom 2 connected to squeezed end vertex (v1 or v3) -> v5 + let sq_sv = if swap_mates_to_rail1 { 2 } else { 0 }; + let sq_ev = if swap_mates_to_rail1 { 3 } else { 1 }; + let ot_ev = if swap_mates_to_rail1 { 1 } else { 3 }; + let ot_sv = if swap_mates_to_rail1 { 0 } else { 2 }; + let (cs_end_a, cs_end_b) = if reverse_cs { + (sq_ev, ot_ev) // GetVertex() != ot_ev -> triggers SM_REVERSE_FILLET_EDGE! + } else { + (ot_ev, sq_ev) + }; + let (cs_start_a, cs_start_b) = if reverse_cs { + (ot_sv, sq_sv) // GetVertex() != sq_sv -> triggers SM_REVERSE_FILLET_EDGE! + } else { + (sq_sv, ot_sv) + }; + let edges = vec![ + mk_edge(0, 1, 9, -1, 0, -1), // e0: SM_FE_RAIL (9) of geom 0 + mk_edge(2, 3, 9, -1, 0, -1), // e1: SM_FE_RAIL (9) of geom 0 + mk_edge(cs_end_a, cs_end_b, 1, 1, 0, 2), // e2: SM_FE_CROSS_SECTION (1) of corner 1 (prim has fg=0 -> modified to mate's fg=2!) + mk_edge(cs_start_a, cs_start_b, 1, 0, 1, 0), // e3: SM_FE_CROSS_SECTION (1) of corner 0 (prim has fg=1, mate cleared!) + mk_edge(sq_sv, 5, 9, -1, 1, -1), // e4: SM_FE_RAIL of geom 1 + mk_edge(sq_ev, 5, 9, -1, 2, -1), // e5: SM_FE_RAIL of geom 2 + ]; + let model = Model { + targets: vec![line([0.0, 0.0, 0.0], [1.0, 0.0, 0.0])], + faces: vec![ + plane_srf([0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]), // f0: z=0 (normal +Z) + plane_srf([0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]), // f1: y=0 (normal -Y) + plane_srf([0.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]), // f2: x=0 (normal +X) + ], + fillet_brep: Vec::new(), + vertices, + edges, + geoms: vec![ + GeomIn { + gtype: 1, + status: 0, + rails: [0, 1], + other1: Vec::new(), + other2: Vec::new(), + center: None, + surface: None, + sides: Vec::new(), + }, + GeomIn { + gtype: 1, + status: 0, + rails: [4, -1], + other1: Vec::new(), + other2: Vec::new(), + center: None, + surface: None, + sides: Vec::new(), + }, + GeomIn { + gtype: 1, + status: 0, + rails: [5, -1], + other1: Vec::new(), + other2: Vec::new(), + center: None, + surface: None, + sides: Vec::new(), + }, + ], + }; + BigRadIn { + model, + fg: 0, + corners: vec![ + BigRadCornerIn { + kind: 7, // NByTwo + v_edge: 0, + v_sub: 0, + adjusted: false, + tol: 1.0e-5, + tan: 1.0e-2, + edges: vec![3], + }, + BigRadCornerIn { + kind: 7, // NByTwo + v_edge: 0, + v_sub: 1, + adjusted: false, + tol: 1.0e-5, + tan: 1.0e-2, + edges: vec![2], + }, + ], + fillet_corners: if swap_fc_order { vec![1, 0] } else { vec![0, 1] }, + solvers: vec![ + BigRadSolverIn { + tol: 1.0e-5, + ang: 1.0e-3, + tang: 1.0e-2, + rails: None, + }, + BigRadSolverIn { + tol: 1.0e-5, + ang: 1.0e-3, + tang: 1.0e-2, + rails: Some(BigRadRailsIn { + f0: 0, + eu0: 0, + d0: 0.2, + f1: 1, + eu1: 0, + d1: -0.2, + }), + }, + BigRadSolverIn { + tol: 1.0e-5, + ang: 1.0e-3, + tang: 1.0e-2, + rails: Some(BigRadRailsIn { + f0: 0, + eu0: 3, + d0: 0.2, + f1: 2, + eu1: 0, + d1: 0.2, + }), + }, + ], + geom_solvers: vec![0, 1, 2], + fillet_solvers: vec![0, 1, 2], + } + }; + + let mut raw: Vec<(&str, BigRadIn)> = vec![ + ("big_rad_convert_squeeze0", base_case(false, false, false)), + ("big_rad_convert_squeeze0_rev_swap", base_case(false, true, true)), + ("big_rad_convert_squeeze1", base_case(true, true, false)), + ]; + + // Non-converting: topology_adjusted == true + { + let mut c = base_case(false, false, false); + if let Some(c0) = c.corners.get_mut(0) { + c0.adjusted = true; + } + raw.push(("big_rad_skip_adjusted", c)); + } + // Non-converting: same rail direction (dot > 0) + { + let mut c = base_case(false, false, false); + if let Some(v2) = c.model.vertices.get_mut(2) { + v2.point = [0.8, 0.0, 0.2]; + } + if let Some(v3) = c.model.vertices.get_mut(3) { + v3.point = [0.2, 0.0, 0.2]; + } + raw.push(("big_rad_skip_same_dir", c)); + } + // Non-converting: non-Nx2 corner type + { + let mut c = base_case(false, false, false); + if let Some(c1) = c.corners.get_mut(1) { + c1.kind = 9; // ConvexNByN + } + raw.push(("big_rad_skip_corner_type", c)); + } + // Error: neither rail has two SM_FV_MATE vertices -> SM_ERR + { + let mut c = base_case(false, false, false); + if let Some(v0) = c.model.vertices.get_mut(0) { + v0.vtype = 4; + } + if let Some(v2) = c.model.vertices.get_mut(2) { + v2.vtype = 4; + } + raw.push(("big_rad_err_no_mate_rail", c)); + } + // Error: start corner has NULL GetFilletCorner -> SM_ERR_NULL_POINTER + { + let mut c = base_case(false, false, false); + if let Some(v0) = c.model.vertices.get_mut(0) { + v0.corner = -1; + } + raw.push(("big_rad_err_null_corner", c)); + } + + raw.into_iter() + .map(|(name, b)| { + let v = serde_json::to_value(Inputs::BigRad(b)) + .map_err(|e| harness(e.to_string()))?; + Ok(Case { + unit: "blend.fillet_corner_p3".into(), + name: name.into(), + source: crate::case::CaseSource::Inline, + inputs: v, + }) + }) + .collect() +} + /// The corner's surfaces as the shim's `p3_put_surfaces` writes them: per surface its kind /// (`1` plane, `2` surface of revolution, cone, sphere or torus, `0` any other) and the /// B-spline record of the `SmBSplineSurface` it derives from upstream. @@ -1598,6 +2173,7 @@ fn cpp(i: &Inputs) -> Result { solvers, } => create_cpp(brep, *validate_only, *init_gaps, *eu, more, solvers), Inputs::Corner { op, model, corner } => corner_cpp(*op, model, corner), + Inputs::BigRad(b) => big_rad_cpp(b), } } @@ -1615,6 +2191,7 @@ fn rust(k: &Kernel, i: &Inputs) -> Result { ffi::validate_corner(&corner_input(model, corner)?).map_err(|e| harness(e.to_string()))?; corner_rust(k, *op, model, corner) } + Inputs::BigRad(b) => big_rad_rust(k, b), } } @@ -1646,7 +2223,7 @@ impl UnitDiff for Runner { Inputs::Corner { op, model, corner } => { corner_input(model, corner).is_ok_and(|v| ffi::corner(op.ffi(), &v).is_ok()) } - Inputs::Create { .. } => true, + Inputs::Create { .. } | Inputs::BigRad(_) => true, }; if ok { return serde_json::to_value(inputs).ok(); @@ -1660,7 +2237,9 @@ impl UnitDiff for Runner { seeds: ctx.seeds.max(MIN_GENERATED), ..ctx.clone() }; - default_cases(self, &ctx) + let mut out = default_cases(self, &ctx)?; + out.extend(big_rad_cases()?); + Ok(out) } } diff --git a/crates/difftest/src/units/blend_fillet_executive_p1/topo.rs b/crates/difftest/src/units/blend_fillet_executive_p1/topo.rs index 82d8eb4d64..ad0c940c03 100644 --- a/crates/difftest/src/units/blend_fillet_executive_p1/topo.rs +++ b/crates/difftest/src/units/blend_fillet_executive_p1/topo.rs @@ -132,9 +132,8 @@ fn try_generate(rng: &mut Rng) -> Option { if n_edges == 0 { return None; } - // one edge per case: after an earlier topo edge replaced a face's surface, the - // port's later B-rep edits can diverge (plan/findings.md, RemoveTopoEdges; fixture - // `remove_topo_edges_mouse_two_struts`); lists of several edges are fixtures + // one edge per generated case; multi-edge sequences (whose earlier merge can + // delete a later listed edge unless disjoint) are covered by fixtures let edges = vec![rng.below(n_edges as u64) as i64]; Some(TopoIn { brep, diff --git a/crates/difftest/src/units/blend_fillet_executive_p2.rs b/crates/difftest/src/units/blend_fillet_executive_p2.rs index 34f1e2ac2b..10171ad1ec 100644 --- a/crates/difftest/src/units/blend_fillet_executive_p2.rs +++ b/crates/difftest/src/units/blend_fillet_executive_p2.rs @@ -262,6 +262,7 @@ enum Handle { Edgeuse(EdgeuseId), Generator(usize), Solver(usize), + Corner(usize), Law(usize), Geom(solidcore::blend::fillet_geom::p1::FilletGeomId), Pseudo, @@ -277,12 +278,40 @@ impl SolutionObject for Handle { } } +enum SolverEntry { + ConstantRadius(ConstantRadiusAssistedFs), + ConstantDistance(solidcore::blend::fillet_standard_solver::p1::ConstantDistanceFs), + VariableRadius(Box), + SurfaceSurface(solidcore::blend::fillet_standard_solver::p1::SurfaceSurfaceFs), +} + +impl SolverEntry { + fn base(&self) -> &solidcore::blend::fillet_solver::p1::FilletSolver { + match self { + Self::ConstantRadius(s) => &s.solver, + Self::ConstantDistance(s) => &s.solver, + Self::VariableRadius(s) => &s.curve_based.solver, + Self::SurfaceSurface(s) => &s.base.solver, + } + } + + fn base_mut(&mut self) -> &mut solidcore::blend::fillet_solver::p1::FilletSolver { + match self { + Self::ConstantRadius(s) => &mut s.solver, + Self::ConstantDistance(s) => &mut s.solver, + Self::VariableRadius(s) => &mut s.curve_based.solver, + Self::SurfaceSurface(s) => &mut s.base.solver, + } + } +} + struct Access { breps: Vec, /// The executive's pseudo B-rep (`m_pPseudoBrep`): the solvers' fillet geometries. pseudo: FilletBrep, generators: Vec, - solvers: Vec, + solvers: Vec, + corners: Vec, laws: Vec, edge_caches: BTreeMap>, } @@ -377,7 +406,7 @@ impl ExecutiveAccess for Access { }; self.solvers .get(index) - .map(|solver| &solver.solver) + .map(SolverEntry::base) .ok_or_else(missing_error) } fn solver_base_mut( @@ -389,7 +418,7 @@ impl ExecutiveAccess for Access { }; self.solvers .get_mut(index) - .map(|solver| &mut solver.solver) + .map(SolverEntry::base_mut) .ok_or_else(missing_error) } fn solver_has_surface(&self, solver: Handle, rail: usize) -> SolidResult { @@ -704,8 +733,17 @@ impl ExecutiveAccess for Access { fn solver_geom(&self, solver: Handle, index: usize) -> SolidResult> { Ok(self.solver_base(solver)?.fillet_geoms.get(index).copied().map(Handle::Geom)) } - fn solver_edge(&self, _: Handle) -> SolidResult { - missing() + fn solver_edge(&self, solver: Handle) -> SolidResult { + const OP: &str = "blend::fillet_executive::p2: solver_edge"; + let eu = self + .solver_base(solver)? + .edgeuse(0)? + .ok_or_else(|| SolidError::null_pointer(OP))?; + self.brep(Handle::Brep(0))? + .edgeuse(eu)? + .edge + .map(Handle::Edge) + .ok_or_else(|| SolidError::null_pointer(OP)) } fn solver_approx_tolerance(&self, _: Handle) -> SolidResult { missing() @@ -805,26 +843,47 @@ impl ExecutiveAccess for Access { fn corner_edges(&self, _: Handle) -> SolidResult>> { missing() } - fn edge_vertices(&self, _: Handle) -> SolidResult<(Option, Option)> { - missing() + fn edge_vertices(&self, edge_handle: Handle) -> SolidResult<(Option, Option)> { + let Handle::Edge(e) = edge_handle else { + return missing(); + }; + let b = self.brep(Handle::Brep(0))?; + let v1 = edge::start_vertex(b, e)?; + let v2 = match v1 { + Some(s) => edge::other_vertex(b, e, Some(s))?, + None => None, + }; + Ok((v1.map(Handle::Vertex), v2.map(Handle::Vertex))) } fn variable_radius_solver( &self, solver: Handle, ) -> SolidResult> { - self.solver_base(solver)?; - Ok(None) // Every retained solver here is a real ConstantRadiusAssistedFs. + let Handle::Solver(idx) = solver else { + return missing(); + }; + match self.solvers.get(idx).ok_or_else(missing_error)? { + SolverEntry::VariableRadius(vr) => Ok(Some(vr.as_ref())), + _ => Ok(None), + } } fn variable_radius_solver_mut( &mut self, solver: Handle, ) -> SolidResult> { - self.solver_base(solver)?; - Ok(None) + let Handle::Solver(idx) = solver else { + return missing(); + }; + match self.solvers.get_mut(idx).ok_or_else(missing_error)? { + SolverEntry::VariableRadius(vr) => Ok(Some(vr.as_mut())), + _ => Ok(None), + } } fn solver_edgeuse(&self, solver: Handle, rail: usize) -> SolidResult> { - self.solver_base(solver)?.edgeuse(rail).map(|edgeuse| edgeuse.map(Handle::Edgeuse)) + self.solver_base(solver)? + .edgeuse(rail) + .map(|edgeuse| edgeuse.map(Handle::Edgeuse)) } fn solver_corner(&self, _: Handle, _: usize) -> SolidResult> { missing() @@ -832,6 +891,187 @@ impl ExecutiveAccess for Access { fn solver_other_corner(&self, _: Handle, _: Handle) -> SolidResult> { missing() } + fn solver_corner_in( + &self, + executive: &FilletExecutive, + solver: Handle, + index: usize, + ) -> SolidResult> { + let b = self.brep(Handle::Brep(0))?; + let Some(v) = self.solver_base(solver)?.vertex(b, index)? else { + return Ok(None); + }; + executive.fillet_corner_of_vertex(Handle::Vertex(v)) + } + fn solver_other_corner_in( + &self, + executive: &FilletExecutive, + solver: Handle, + corner: Handle, + ) -> SolidResult> { + let c0 = self.solver_corner_in(executive, solver, 0)?; + let c1 = self.solver_corner_in(executive, solver, 1)?; + if c0 == Some(corner) { + Ok(c1) + } else if c1 == Some(corner) { + Ok(c0) + } else { + Ok(None) + } + } + fn corner_tangent_solver_in( + &self, + k: &Kernel, + executive: &FilletExecutive, + corner: Handle, + solver: Handle, + ) -> SolidResult> { + let Handle::Corner(cid) = corner else { + return missing(); + }; + let c = self.corners.get(cid).ok_or_else(missing_error)?; + let b = self.brep(Handle::Brep(0))?; + c.tangent_fillet_solver_with_lookup(k, b, solver, |eu| { + executive.fillet_solver_of_edgeuse(Handle::Edgeuse(eu)) + }) + } + fn smooth_solver_edge0_id(&self, solver: Handle) -> SolidResult { + let Handle::Edge(e) = self.solver_edge(solver)? else { + return missing(); + }; + Ok(e) + } + fn smooth_target_brep(&self) -> SolidResult<&Brep> { + self.brep(Handle::Brep(0)) + } + fn smooth_with_target_mut( + &mut self, + edges: &[EdgeId], + f: impl FnOnce(&mut Brep) -> SolidResult, + ) -> SolidResult { + for &e in edges { + self.edge_caches.remove(&e); + } + let b = self.breps.first_mut().ok_or_else(missing_error)?; + f(b) + } + fn smooth_set_edge_curve( + &mut self, + k: &mut Kernel, + edge_id: EdgeId, + curve: solidcore::curve::curve::Curve, + ) -> SolidResult<()> { + use solidcore::topology::brep::p3 as bp3; + self.edge_caches.remove(&edge_id); + let b = self.breps.first_mut().ok_or_else(missing_error)?; + bp3::with_kernel_callbacks(k, b, |_k, b, env| { + let _ = edge::set_curve( + b, + edge_id, + Some(curve), + edge::SetCurveOptions { + delete_old_curve: true, + dbg_warn_leaks: true, + call_notify: true, + }, + env, + [None, None], + )?; + Ok(()) + }) + } + fn smooth_solver_original_curve( + &self, + solver: Handle, + ) -> SolidResult> { + let Handle::Solver(idx) = solver else { + return missing(); + }; + Ok(match self.solvers.get(idx).ok_or_else(missing_error)? { + SolverEntry::VariableRadius(vr) => vr.curve_based.original_curve(), + _ => None, + }) + } + fn smooth_set_solver_original_curve( + &mut self, + solver: Handle, + curve: solidcore::curve::curve::Curve, + ) -> SolidResult<()> { + let Handle::Solver(idx) = solver else { + return missing(); + }; + if let SolverEntry::VariableRadius(vr) = + self.solvers.get_mut(idx).ok_or_else(missing_error)? + { + vr.curve_based.set_original_curve(Some(curve)); + } + Ok(()) + } + fn smooth_set_solver_law_interval( + &mut self, + solver: Handle, + interval: Extent1d, + ) -> SolidResult<()> { + let Handle::Solver(idx) = solver else { + return missing(); + }; + if let SolverEntry::VariableRadius(vr) = + self.solvers.get_mut(idx).ok_or_else(missing_error)? + { + vr.set_fillet_law_interval(interval); + } + Ok(()) + } + fn create_corners( + &mut self, + k: &mut Kernel, + executive: &mut FilletExecutive, + error: Option<&mut ErrorInfo>, + ) -> SolidResult<()> { + let b = self.breps.first_mut().ok_or_else(missing_error)?; + let solvers = &self.solvers; + let corners = &mut self.corners; + executive.create_fillet_corners( + k, + |k, executive, vertex, edgeuses| { + let Some(Handle::Vertex(v)) = vertex else { + return missing(); + }; + let eus = edgeuses + .iter() + .map(|h| match *h { + Handle::Edgeuse(eu) => Ok(eu), + _ => missing(), + }) + .collect::>>()?; + let made = + solidcore::blend::fillet_corner::p3::FilletCorner::create_with_solver_lookup( + k, + b, + v, + &eus, + |eu| { + let Some(Handle::Solver(idx)) = + executive.fillet_solver_of_edgeuse(Handle::Edgeuse(eu)) + else { + return missing(); + }; + solvers + .get(idx) + .map(SolverEntry::base) + .ok_or_else(missing_error) + }, + )?; + let Some(corner) = made else { + return Ok(None); + }; + let id = corners.len(); + corners.push(corner); + Ok(Some(Handle::Corner(id))) + }, + error, + ) + } fn corner_setback_distance(&self, _: Handle) -> SolidResult { missing() } @@ -965,16 +1205,140 @@ impl ExecutiveAccess for Access { eu, None, )?; - self.solvers.push(fs); + self.solvers.push(SolverEntry::ConstantRadius(fs)); Ok(Handle::Solver(self.solvers.len() - 1)) } + fn new_fillet_solver( + &mut self, + k: &mut Kernel, + spec: &solidcore::blend::fillet_executive::p2::SolverSpec<'_, Handle>, + ) -> SolidResult { + use solidcore::blend::fillet_custom_solver::SurfaceId; + use solidcore::blend::fillet_executive::p2::SolverSpec; + use solidcore::blend::fillet_standard_solver::p1::{ + ConstantDistanceFs, SurfaceSurfaceFs, VariableRadiusFs, + }; + + match *spec { + SolverSpec::ConstantRadius { + approx_tol, + ang_tol_rad, + tan_tol_rad, + radius, + edgeuse, + } => self.new_constant_radius_solver( + k, + approx_tol, + ang_tol_rad, + tan_tol_rad, + radius, + edgeuse, + ), + SolverSpec::ConstantDistance { + approx_tol, + ang_tol_rad, + tan_tol_rad, + radius, + edgeuse, + } => { + let Handle::Edgeuse(eu) = edgeuse else { + return missing(); + }; + let id = FilletSolverId( + u32::try_from(self.solvers.len()).map_err(|_| missing_error())?, + ); + let b = self.breps.first_mut().ok_or_else(missing_error)?; + let fs = ConstantDistanceFs::from_edgeuse( + k, + b, + &mut self.pseudo, + id, + approx_tol, + ang_tol_rad, + tan_tol_rad, + radius, + eu, + )?; + self.solvers.push(SolverEntry::ConstantDistance(fs)); + Ok(Handle::Solver(self.solvers.len() - 1)) + } + SolverSpec::VariableRadius { + approx_tol, + ang_tol_rad, + tan_tol_rad, + radius, + edgeuse, + law, + reverse, + } => { + let (Handle::Edgeuse(eu), Handle::Law(law_idx)) = (edgeuse, law) else { + return missing(); + }; + let law = self.laws.get(law_idx).ok_or_else(missing_error)?.clone(); + let id = FilletSolverId( + u32::try_from(self.solvers.len()).map_err(|_| missing_error())?, + ); + let b = self.breps.first_mut().ok_or_else(missing_error)?; + let fs = VariableRadiusFs::from_edgeuse( + k, + b, + &mut self.pseudo, + id, + approx_tol, + ang_tol_rad, + tan_tol_rad, + radius, + eu, + law, + reverse, + )?; + self.solvers.push(SolverEntry::VariableRadius(Box::new(fs))); + Ok(Handle::Solver(self.solvers.len() - 1)) + } + SolverSpec::SurfaceSurface { + tolerance, + ang_tol, + tan_tol, + radii, + surfaces, + orientations, + guesses, + trim_type, + reverse_trim, + } => { + let id = FilletSolverId( + u32::try_from(self.solvers.len()).map_err(|_| missing_error())?, + ); + let pair = [ + (surfaces[0].clone(), SurfaceId::Free(0)), + (surfaces[1].clone(), SurfaceId::Free(1)), + ]; + let mut fs = SurfaceSurfaceFs::new( + k, + &mut self.pseudo, + id, + tolerance, + ang_tol, + tan_tol, + radii, + pair, + orientations, + guesses, + )?; + fs.set_fillet_trim_type(trim_type); + fs.base.solver.set_reverse_trim(reverse_trim); + self.solvers.push(SolverEntry::SurfaceSurface(fs)); + Ok(Handle::Solver(self.solvers.len() - 1)) + } + } + } fn set_surface_generator(&mut self, solver: Handle, generator: Handle) -> SolidResult<()> { let (Handle::Solver(s), Handle::Generator(g)) = (solver, generator) else { return missing(); }; let fsg = *self.generators.get(g).ok_or_else(missing_error)?; let fs = self.solvers.get_mut(s).ok_or_else(missing_error)?; - fs.solver.set_fillet_surface_generator(Some(fsg)); + fs.base_mut().set_fillet_surface_generator(Some(fsg)); Ok(()) } } @@ -1101,6 +1465,7 @@ fn box_access(bx: &BoxIn) -> Result, HarnessError> { pseudo: FilletBrep::new(), generators: Vec::new(), solvers: Vec::new(), + corners: Vec::new(), laws: Vec::new(), edge_caches: BTreeMap::new(), })) @@ -1279,6 +1644,32 @@ fn load_solvers( ex.set_fillet_parameters(k, access, ¶ms) } +fn push_bspline_curve(o: &mut Vec, crv: Option<&solidcore::curve::curve::Curve>) { + use solidcore::curve::curve::CurveKind; + let bsc = crv.and_then(|c| match c.kind() { + CurveKind::BSpline(b) => Some(std::borrow::Cow::Borrowed(b)), + CurveKind::Line(l) => l.base_with_nurb().ok(), + CurveKind::Ellipse(e) => Some(std::borrow::Cow::Borrowed(e.base())), + CurveKind::Parabola(p) => Some(std::borrow::Cow::Borrowed(p.base())), + CurveKind::Hyperbola(h) => Some(std::borrow::Cow::Borrowed(h.base())), + _ => None, + }); + let Some(nurb) = bsc.as_deref().and_then(|b| b.gw_nurb()) else { + o.push(0.0); + return; + }; + o.push(1.0); + let cpts = nurb.cpts(); + let knots = nurb.knots(); + o.push(cpts.len() as f64 - 1.0); + o.push(nurb.degree() as f64); + o.push(knots.len() as f64 - 1.0); + for p in cpts { + o.extend([p.x, p.y, p.z, p.w]); + } + o.extend_from_slice(knots); +} + fn exec(sub: usize, n: usize, bx: &BoxIn) -> Result { let mut access = match box_access(bx)? { Ok(a) => a, @@ -1287,6 +1678,168 @@ fn exec(sub: usize, n: usize, bx: &BoxIn) -> Result { let mut k = Kernel::default(); let mut executive = executive(Some(Handle::Brep(0))); let ex = &mut executive; + if sub >= 3 { + use solidcore::geom::attribute::ObjectKey; + use solidcore::geom::object::ObjectRef; + use solidcore::topology::brep::{p3 as bp3, p9 as bp9}; + + let all_edges: Vec = access + .brep(Handle::Brep(0)) + .map_err(|e| harness(e.to_string()))? + .edges() + .ids() + .collect(); + let e0 = *all_edges.first().ok_or_else(|| harness("empty box edges"))?; + let ivl = access + .brep(Handle::Brep(0)) + .and_then(|b| edge::interval(b, e0)) + .map_err(|e| harness(e.to_string()))?; + let split_t = 0.35 * ivl.min() + 0.65 * ivl.max(); + let split_res = { + let b = access + .brep_mut(Handle::Brep(0)) + .map_err(|e| harness(e.to_string()))?; + bp3::with_kernel_callbacks(&mut k, b, |k, b, env| { + bp9::make_vertex_split_edge(k, b, e0, split_t, env, &|eid, kind| ObjectRef { + key: ObjectKey(((kind as u64) << 32) | u64::from(eid.index())), + type_code: kind, + }) + }) + }; + let split = match split_res { + Ok(s) => s, + Err(e) => return Ok(error_output(&e)), + }; + let Some([e1, e2]) = split.edges else { + return Ok(values_output(vec![f64::from(split.status())])); + }; + { + let b = access + .brep_mut(Handle::Brep(0)) + .map_err(|e| harness(e.to_string()))?; + if let Err(e) = edge::reverse_orientation(&k, b, e2) { + return Ok(error_output(&e)); + } + let e2_ivl = Extent1d::from_min_max(0.0, 2.0); + if let Some(crv) = b + .edge_mut(e2) + .map_err(|e| harness(e.to_string()))? + .curve + .as_mut() + { + let _ = crv.edit_parameterization(&k, &e2_ivl); + } + b.edge_mut(e2) + .map_err(|e| harness(e.to_string()))? + .interval = e2_ivl; + } + let mut edges = vec![Handle::Edge(e2), Handle::Edge(e1)]; + for &eid in all_edges.iter().skip(1).take(n.saturating_sub(2)) { + edges.push(Handle::Edge(eid)); + } + let mut law_handles = Vec::new(); + if sub == 5 { + for i in 0..edges.len() { + access.laws.push(FilletLaw::linear(1.0, 2.0, None)); + law_handles.push(Some(Handle::Law(i))); + } + } + let radius_types = [if sub == 5 { + FilletSolverType::VariableRadius + } else { + FilletSolverType::ConstRadius + }]; + let params = FilletParameters { + brep: Some(Handle::Brep(0)), + edges: &edges, + xsect_types: &[FilletSurfaceGeneratorType::Linear], + radius_types: &radius_types, + radius_values: &[1.0], + var_rad_laws: if sub == 5 { Some(&law_handles) } else { None }, + approx_tol: 0.0, + ang_tol_deg: 0.0, + tan_tol_deg: 0.0, + xsect_tol: 0.0, + continuity: 1, + thumbweight: 1.0, + }; + if let Err(e) = ex.set_fillet_parameters(&mut k, &mut access, ¶ms) { + return Ok(if e.is_unported() { + error_output(&e) + } else { + values_output(vec![status_of(&e)]) + }); + } + if let Err(e) = access.create_corners(&mut k, ex, None) { + return Ok(if e.is_unported() { + error_output(&e) + } else { + values_output(vec![status_of(&e)]) + }); + } + if sub == 3 { + let mut list: Vec> = ex.solvers.iter().copied().map(Some).collect(); + let mut seq = Vec::new(); + return match ex.collect_tangent_edge_sequence_narrow(&k, &access, &mut list, &mut seq) { + Ok(()) => Ok(values_output(vec![0.0, seq.len() as f64, list.len() as f64])), + Err(e) if e.is_unported() => Ok(error_output(&e)), + Err(e) => Ok(values_output(vec![ + status_of(&e), + seq.len() as f64, + list.len() as f64, + ])), + }; + } + if let Err(e) = ex.process_smooth_edge_sequences(&mut k, &mut access) { + return Ok(if e.is_unported() { + error_output(&e) + } else { + values_output(vec![status_of(&e)]) + }); + } + let b = access + .brep(Handle::Brep(0)) + .map_err(|e| harness(e.to_string()))?; + let ivl1 = edge::interval(b, e1).map_err(|e| harness(e.to_string()))?; + let ivl2 = edge::interval(b, e2).map_err(|e| harness(e.to_string()))?; + let mut o = vec![0.0, ivl1.min(), ivl1.max(), ivl2.min(), ivl2.max()]; + if sub == 5 { + let vr0 = access + .variable_radius_solver(Handle::Solver(0)) + .map_err(|e| harness(e.to_string()))?; + let vr1 = access + .variable_radius_solver(Handle::Solver(1)) + .map_err(|e| harness(e.to_string()))?; + o.push(vr0.map_or(-1.0, |v| v.fillet_law_interval().min())); + o.push(vr0.map_or(-1.0, |v| v.fillet_law_interval().max())); + o.push(vr1.map_or(-1.0, |v| v.fillet_law_interval().min())); + o.push(vr1.map_or(-1.0, |v| v.fillet_law_interval().max())); + push_bspline_curve( + &mut o, + b.edge(e1) + .map_err(|e| harness(e.to_string()))? + .curve + .as_ref(), + ); + push_bspline_curve(&mut o, vr0.and_then(|v| v.curve_based.original_curve())); + } else { + push_bspline_curve( + &mut o, + b.edge(e1) + .map_err(|e| harness(e.to_string()))? + .curve + .as_ref(), + ); + push_bspline_curve( + &mut o, + b.edge(e2) + .map_err(|e| harness(e.to_string()))? + .curve + .as_ref(), + ); + } + return Ok(values_output(o)); + } if n > 0 { if let Err(e) = load_solvers(&mut k, &mut access, ex, n) { return Ok(error_output(&e)); @@ -1313,7 +1866,7 @@ fn exec(sub: usize, n: usize, bx: &BoxIn) -> Result { _ => { let mut list: Vec> = ex.solvers.iter().copied().map(Some).collect(); let mut seq = Vec::new(); - ex.collect_tangent_edge_sequence(&access, &mut list, &mut seq) + ex.collect_tangent_edge_sequence_narrow(&k, &access, &mut list, &mut seq) .map(|()| vec![0.0, seq.len() as f64, list.len() as f64]) } }; @@ -1386,15 +1939,44 @@ fn temp_radius(variable: bool, start: f64, end: f64, bx: &BoxIn) -> Result Ok(values_output(vec![0.0])), - Err(e) => Ok(error_output(&e)), + let read_linear_radii = |acc: &Access| -> SolidResult<(f64, f64)> { + let vrfs = acc + .variable_radius_solver(solver)? + .ok_or_else(missing_error)?; + match vrfs.fillet_law().kind { + FilletLawKind::Linear { + start_radius, + end_radius, + } => Ok((start_radius, end_radius)), + _ => missing(), + } + }; + if variable { + let during = match solidcore::blend::fillet_executive::p2::with_temporary_radius_change( + &mut access, + solver, + 0, + side, + |acc| read_linear_radii(acc), + ) { + Ok(pair) => pair, + Err(e) => return Ok(error_output(&e)), + }; + let after = read_linear_radii(&access).map_err(|e| harness(e.to_string()))?; + Ok(values_output(vec![ + 0.0, during.0, during.1, after.0, after.1, + ])) + } else { + match solidcore::blend::fillet_executive::p2::with_temporary_radius_change( + &mut access, + solver, + 0, + side, + |_| Ok(()), + ) { + Ok(()) => Ok(values_output(vec![0.0])), + Err(e) => Ok(error_output(&e)), + } } } } diff --git a/crates/difftest/src/units/blend_fillet_executive_p2/loader.rs b/crates/difftest/src/units/blend_fillet_executive_p2/loader.rs index 21e6048d48..10496ebcda 100644 --- a/crates/difftest/src/units/blend_fillet_executive_p2/loader.rs +++ b/crates/difftest/src/units/blend_fillet_executive_p2/loader.rs @@ -319,7 +319,7 @@ pub(crate) fn loader_rust(i: &LoaderScript) -> Result { .solver .set_fillet_geom_for_surfaces(solver.solver.fillet_geoms.first().copied()); } - access.solvers.push(solver); + access.solvers.push(super::SolverEntry::ConstantRadius(solver)); } let mut executive = executive(Some(Handle::Brep(0))); executive.pseudo_brep = Some(Handle::Pseudo); @@ -369,7 +369,7 @@ pub(crate) fn loader_rust(i: &LoaderScript) -> Result { } loader_snapshot(&access, &executive, &mut out)?; for solver in &access.solvers { - let back_pointer_set = solver.solver.fillet_executive().is_some(); + let back_pointer_set = solver.base().fillet_executive().is_some(); out.push(flag(back_pointer_set)); let same_pseudo = if back_pointer_set { let pseudo = executive.pseudo_brep.ok_or_else(missing_error)?; @@ -378,8 +378,8 @@ pub(crate) fn loader_rust(i: &LoaderScript) -> Result { false }; out.push(flag(same_pseudo)); - out.push(solver.solver.g1_knots.len() as f64); - out.extend(&solver.solver.g1_knots); + out.push(solver.base().g1_knots.len() as f64); + out.extend(&solver.base().g1_knots); } } Ok(out) diff --git a/crates/difftest/src/units/blend_fillet_executive_p3.rs b/crates/difftest/src/units/blend_fillet_executive_p3.rs index b28c51327d..c284ed8927 100644 --- a/crates/difftest/src/units/blend_fillet_executive_p3.rs +++ b/crates/difftest/src/units/blend_fillet_executive_p3.rs @@ -178,6 +178,17 @@ impl ExecutiveAccess for Access { fn corner_tangent_solver(&self, _: Handle, _: Handle) -> SolidResult> { missing() } + /// `SmFilletExecutive::CreateFilletCorners`, the ported + /// `FilletExecutive::create_fillet_corners`. The fixture names no vertices, so + /// its corner factory (`SmFilletCorner::Create`) rejects any it is asked for. + fn create_corners( + &mut self, + k: &mut Kernel, + executive: &mut FilletExecutive, + error: Option<&mut ErrorInfo>, + ) -> SolidResult<()> { + executive.create_fillet_corners(k, |_, _, _, _| missing(), error) + } // This empty-B-rep fixture has no face handles or intersector world. These // required adapters reject nonexistent objects; they are not UNPORTED calls // to the now-ported face p3, topology intersector p2, stitch p2, fillet geom p4 @@ -744,24 +755,8 @@ fn rust(input: &Inputs) -> Result { if !input.record.is_empty() { return Err(HarnessError::new("mixed fixture forms")); } - // These fixture constructors are the actual missing API entrypoints, not - // geometric passes or a replacement for direct p3 parity. - let symbol = match example { - // UNPORTED(blend.fillet_executive_p1): SmApiCircularFillet - 0 => "SmApiCircularFillet", - // UNPORTED(blend.fillet_executive_p1): SmApiChamferFillet - 1 => "SmApiChamferFillet", - // UNPORTED(blend.fillet_executive_p1): SmApiFilletEdges - 2 => "SmApiFilletEdges", - // UNPORTED(blend.fillet_executive_p1): SmApiFilletEdgesPerEdge - 3 => "SmApiFilletEdgesPerEdge", - // UNPORTED(blend.fillet_executive_p1): SmApiVariableRadiusFillet - 4 => "SmApiVariableRadiusFillet", - // UNPORTED(blend.fillet_executive_p1): SmApiSetBevelCorners - 5 | 6 => "SmApiSetBevelCorners", - _ => return Err(HarnessError::new("unknown upstream fixture")), - }; - return Ok(Output::deferred("blend.fillet_executive_p1", symbol)); + return end_to_end::upstream_example(example)? + .ok_or_else(|| HarnessError::new("unknown upstream fixture")); } ffi::validate(&input.record).map_err(|e| HarnessError::new(e.to_string()))?; let [op, has_target, initialized, prior_boolean, editing, want_error, seed_code, tolerance, piecewise, insertion, global, trimming, classification] = diff --git a/crates/difftest/src/units/blend_fillet_executive_p3/end_to_end.rs b/crates/difftest/src/units/blend_fillet_executive_p3/end_to_end.rs index a0f5c5b566..780cd812b6 100644 --- a/crates/difftest/src/units/blend_fillet_executive_p3/end_to_end.rs +++ b/crates/difftest/src/units/blend_fillet_executive_p3/end_to_end.rs @@ -31,6 +31,10 @@ enum Checkpoint { ShrunkFaces, CreatedFilletBrep, MappedFilletEdges, + InsertedIntersectionTopology, + SelectedOriginalSurfaces, + DeletedOriginalFaces, + MergedFilletFaces, } impl Checkpoint { fn all_corners(self) -> bool { @@ -71,6 +75,10 @@ impl Checkpoint { Self::ShrunkFaces => 13, Self::CreatedFilletBrep => 14, Self::MappedFilletEdges => 15, + Self::InsertedIntersectionTopology => 16, + Self::SelectedOriginalSurfaces => 17, + Self::DeletedOriginalFaces => 18, + Self::MergedFilletFaces => 19, } } } @@ -292,9 +300,9 @@ fn run_rust(input: &RealSolid) -> SolidResult> { let mut geometry = Vec::new(); crate::util::primitive::append(&k, &body, &mut geometry)?; section(&mut values, &geometry); - // Unreachable while apply is unported. Before connecting the real API, - // replace this draft with its actual post-cleanup attribute store and - // entity identities; a fresh constructor alone does not justify emptiness. + // The public API leaves no attribute on the body: the executive's fillet-edge + // attribute and the temporary merge tags are destroyed with its session, as + // upstream's executive destructor and SmObjsDelete do, and the box carries none. let store = solidcore::geom::attribute::AttributeStore::default(); let attributes = solidcore::mass::integrator::properties::PropertyAttributes { store: &store, @@ -323,13 +331,92 @@ fn run_rust(input: &RealSolid) -> SolidResult> { } Ok(values) } -fn apply(_: &mut Kernel, _: &mut Brep, _: &[EdgeId], _: &RealSolid) -> SolidResult<()> { - // Replaced with the actual session API once the session worker's commit is - // available. The harness never owns an alternate executive implementation. - Err(SolidError::Unported { - upstream: "SmApiFilletEdges", - unit: "blend.fillet_executive_p1", - }) +fn apply(k: &mut Kernel, body: &mut Brep, edges: &[EdgeId], input: &RealSolid) -> SolidResult<()> { + solidcore::blend::fillet_executive::api::fillet_edges( + k, + body, + edges, + input.radius, + input.xsect, + input.continuity, + input.thumbweight, + ) +} +/// The `TestSmFillets.cpp` examples 0-6, as the shim runs them (examples 2-4 on the +/// 10x10x10 box with its first four edges in `GetEdges` order: `SmApiFilletEdges(r=1, +/// circular)`, `SmApiFilletEdgesPerEdge(r = 0.5 + 0.25 i, circular)` and +/// `SmApiVariableRadiusFillet(0.5 -> 2.0, circular, G1)`). Returns the shim's +/// `[status, faces, edges, vertices]` record, or `None` for an unknown example. +pub(super) fn upstream_example(example: usize) -> Result, HarnessError> { + if !matches!(example, 0..=6) { + return Ok(None); + } + let run = || -> SolidResult> { + let mut k = Kernel::default(); + let (dx, dy, dz) = if example < 2 { + (5.0, 10.0, 15.0) + } else { + (10.0, 10.0, 10.0) + }; + let mut body = solidcore::primitive::api_primitives::create_box(&mut k, Vec3::ZERO, dx, dy, dz)?; + let edges = p1::edges(&body, None)?; + let selected: Vec = edges.iter().copied().take(4).collect(); + let result = match example { + 0 => solidcore::blend::fillet_executive::api::circular_fillet(&mut k, &mut body, 0.5), + 1 => solidcore::blend::fillet_executive::api::chamfer_fillet(&mut k, &mut body, 0.5), + 2 => solidcore::blend::fillet_executive::api::fillet_edges( + &mut k, &mut body, &selected, 1.0, 1, 1, 1.0, + ), + 3 => { + let radii: Vec = + (0..selected.len()).map(|i| 0.5 + 0.25 * i as f64).collect(); + let types = vec![ + solidcore::blend::fillet_solver::p1::FilletSurfaceGeneratorType::Circular; + selected.len() + ]; + solidcore::blend::fillet_executive::api::fillet_edges_per_edge( + &mut k, &mut body, &selected, &radii, &types, 1, 1.0, + ) + } + 4 => solidcore::blend::fillet_executive::api::variable_radius_fillet( + &mut k, &mut body, &selected, 0.5, 2.0, 1, 1, + ), + _ => { + let bevel: Vec<_> = if example == 6 { + p1::vertices(&body, None)?.into_iter().take(1).collect() + } else { + Vec::new() + }; + solidcore::blend::fillet_executive::api::set_bevel_corners( + &mut k, &mut body, &edges, 1.0, &bevel, + ) + } + }; + let result_status = match result { + Ok(()) => 0.0, + Err(error) => status(error)?, + }; + Ok(vec![ + result_status, + p1::faces(&body, None)?.len() as f64, + p1::edges(&body, None)?.len() as f64, + p1::vertices(&body, None)?.len() as f64, + ]) + }; + match run() { + Ok(values) => { + let mut out = Output::new(); + out.ints.insert( + "api_status_counts".into(), + values.into_iter().map(|v| v as i64).collect(), + ); + Ok(Some(out)) + } + Err(error) => match Output::unported(&error) { + Some(deferred) => Ok(Some(deferred)), + None => Err(HarnessError::new(error.to_string())), + }, + } } pub(super) fn rust(input: &RealSolid) -> Result { ffi::validate_real_solid(&input.wire()).map_err(|e| HarnessError::new(e.to_string()))?; diff --git a/crates/difftest/src/units/blend_fillet_executive_p3/insertion_checkpoint.rs b/crates/difftest/src/units/blend_fillet_executive_p3/insertion_checkpoint.rs new file mode 100644 index 0000000000..2be4a091c1 --- /dev/null +++ b/crates/difftest/src/units/blend_fillet_executive_p3/insertion_checkpoint.rs @@ -0,0 +1,121 @@ +//! One call to the actual retained insertion endpoint under its production scope. +use super::{ + insertion_state, intermediate_faces, original_deletion, original_merge, original_selection, + section, status, Checkpoint, FilletSession, RealSolid, SolidResult, +}; +use solidcore::kernel::Kernel; +use solidcore::topology::{Brep, EdgeId}; + +pub(super) fn run( + k: &mut Kernel, + body: &mut Brep, + edges: &[EdgeId], + input: &RealSolid, + before: &[f64], +) -> SolidResult> { + let mut session = FilletSession::new(k, body)?; + let mut statuses = [-1.0; 5]; + statuses[0] = match session.prepare_constant_radius_edges( + k, + edges, + input.radius, + input.xsect, + input.continuity, + input.thumbweight, + ) { + Ok(()) => 0.0, + Err(error) => status(error)?, + }; + if statuses[0] == 0.0 { + statuses[1] = match session.create_corners(k, None) { + Ok(()) => 0.0, + Err(error) => status(error)?, + }; + } + let pack = |k: &mut Kernel, + session: &FilletSession<'_>, + statuses: &[f64], + prior: &(Vec, Vec), + after: &(Vec, Vec), + complete: bool, + selection: &[f64], + selection_intervals: &[f64]| + -> SolidResult> { + let mut out = Vec::new(); + section(&mut out, before); + section(&mut out, statuses); + for (state, values) in [prior, after] { + section(&mut out, state); + section(&mut out, values); + } + out.push(f64::from(complete)); + if complete { + let target = intermediate_faces::body_snapshot(k, session.target())?; + let managed = intermediate_faces::snapshot(k, session)?; + for (state, values) in [&target, &managed] { + section(&mut out, state); + section(&mut out, values); + } + } + if input.checkpoint == Checkpoint::SelectedOriginalSurfaces { + section(&mut out, selection); + } + if matches!( + input.checkpoint, + Checkpoint::DeletedOriginalFaces | Checkpoint::MergedFilletFaces + ) { + section(&mut out, selection); + section(&mut out, selection_intervals); + } + Ok(out) + }; + if statuses[1] != 0.0 { + return pack( + k, + &session, + &statuses, + &(Vec::new(), Vec::new()), + &(Vec::new(), Vec::new()), + false, + &[], + &[], + ); + } + session.with_filleting_context(k, |session, k| { + statuses[2] = match session.create_fillet_brep(k, None) { + Ok(()) => 0.0, + Err(error) => status(error)?, + }; + let mut prior = (Vec::new(), Vec::new()); + let mut after = (Vec::new(), Vec::new()); + if statuses[2] == 0.0 { + statuses[3] = match session.shrink_fillet_faces(k) { + Ok(()) => 0.0, + Err(error) => status(error)?, + }; + if statuses[3] == 0.0 { + prior = insertion_state::snapshot(k, session)?; + statuses[4] = match session.insert_fillet_intersection_topology(k) { + Ok(()) => 0.0, + Err(error) => status(error)?, + }; + after = insertion_state::snapshot(k, session)?; + } + } + let (selection, intervals, complete) = if statuses[4] == 0.0 + && input.checkpoint == Checkpoint::MergedFilletFaces + { + original_merge::run(k, session)? + } else if statuses[4] == 0.0 && input.checkpoint == Checkpoint::DeletedOriginalFaces { + original_deletion::run(k, session)? + } else if statuses[4] == 0.0 && input.checkpoint == Checkpoint::SelectedOriginalSurfaces { + let (selection, complete) = original_selection::run(k, session)?; + (selection, Vec::new(), complete) + } else { + (Vec::new(), Vec::new(), statuses[4] == 0.0) + }; + pack( + k, session, &statuses, &prior, &after, complete, &selection, &intervals, + ) + }) +} diff --git a/crates/difftest/src/units/blend_fillet_executive_p3/insertion_state.rs b/crates/difftest/src/units/blend_fillet_executive_p3/insertion_state.rs new file mode 100644 index 0000000000..8a4c230c3d --- /dev/null +++ b/crates/difftest/src/units/blend_fillet_executive_p3/insertion_state.rs @@ -0,0 +1,63 @@ +//! Passive actual target/managed state around the complete insertion component. +use super::{ + edge_map_state, intermediate_faces, section, stitch_state, FilletSession, SolidResult, +}; +use solidcore::blend::fillet_executive::session::{FilletBody, FilletObject}; +use solidcore::kernel::Kernel; +use solidcore::topology::{brep::p1, Brep}; +use solidcore::EntityRef; + +fn counts(body: &Brep, out: &mut Vec) -> SolidResult<()> { + out.extend([ + p1::vertices(body, None)?.len() as f64, + p1::edges(body, None)?.len() as f64, + p1::faces(body, None)?.len() as f64, + ]); + Ok(()) +} +pub(super) fn snapshot( + k: &Kernel, + session: &FilletSession<'_>, +) -> SolidResult<(Vec, Vec)> { + let target = session.target(); + let intermediate = session.intermediate_brep(); + let ti = &session.executive().intersector; + let mut out = vec![ + f64::from(k.context.doing_boolean()), + f64::from(ti.doing_fillets), + f64::from(target.editing_enabled()), + f64::from(ti.primary_brep() == Some(FilletObject::Body(FilletBody::Target))), + f64::from( + intermediate.is_some() && ti.other_brep() == Some(FilletObject::IntermediateBrep), + ), + f64::from(ti.topology_deleted), + ]; + counts(target, &mut out)?; + if let Some(body) = intermediate { + counts(body, &mut out)?; + } else { + out.extend([0.0; 3]); + } + counts(session.pseudo_brep().brep(), &mut out)?; + let (maps, intervals) = edge_map_state::snapshot(session)?; + section(&mut out, &maps); + section(&mut out, &intermediate_faces::source_binding(session)?); + section(&mut out, &stitch_state::attributes(session)?); + let faces = p1::faces(target, None)?; + out.push(faces.len() as f64); + for id in faces { + let key = session.target_identity(EntityRef::Face(id)).key; + let mut markers = Vec::new(); + for handle in session.attributes().attributes(key) { + let attribute = session + .attributes() + .get(handle) + .ok_or_else(|| super::invalid("missing actual target attribute"))?; + if matches!(attribute.attribute_id(), 777 | 778) { + markers.push(attribute.attribute_id() as f64); + } + } + section(&mut out, &markers); + } + Ok((out, intervals)) +} diff --git a/crates/difftest/src/units/blend_fillet_executive_p3/intermediate_faces.rs b/crates/difftest/src/units/blend_fillet_executive_p3/intermediate_faces.rs index 1c6409bf85..c53fcb483f 100644 --- a/crates/difftest/src/units/blend_fillet_executive_p3/intermediate_faces.rs +++ b/crates/difftest/src/units/blend_fillet_executive_p3/intermediate_faces.rs @@ -11,6 +11,13 @@ pub(super) fn snapshot( let Some(body) = session.intermediate_brep() else { return Ok((vec![0.0], Vec::new())); }; + body_snapshot(k, body) +} + +pub(super) fn body_snapshot( + k: &mut Kernel, + body: &solidcore::topology::Brep, +) -> SolidResult<(Vec, Vec)> { let vertices = p1::vertices(body, None)?; let edges = p1::edges(body, None)?; let faces = p1::faces(body, None)?; diff --git a/crates/difftest/src/units/blend_fillet_executive_p3/merge_attributes.rs b/crates/difftest/src/units/blend_fillet_executive_p3/merge_attributes.rs new file mode 100644 index 0000000000..c56a86a857 --- /dev/null +++ b/crates/difftest/src/units/blend_fillet_executive_p3/merge_attributes.rs @@ -0,0 +1,125 @@ +//! Actual cross-body attribute aliases and ordered users after body copying. +use super::{invalid, FilletObject, FilletSession, SolidResult}; +use solidcore::geom::attribute::{ids, AttributeHandle, ObjectKey}; +use solidcore::topology::brep::p1; +use solidcore::EntityRef; + +fn users( + session: &FilletSession<'_>, + handle: AttributeHandle, + keys: &[Vec], + out: &mut Vec, +) -> SolidResult<()> { + let store = session.attributes(); + let attribute = store + .get(handle) + .ok_or_else(|| invalid("actual merge attribute missing"))?; + let users = store.users(handle)?; + out.extend([ + attribute.attribute_id() as f64, + attribute.type_code() as f64, + users.len() as f64, + ]); + for user in users { + let mut matches = Vec::new(); + for (body, entities) in keys.iter().enumerate() { + if let Some(index) = entities.iter().position(|key| *key == user) { + matches.push(((body + 1) as f64, index as f64)); + } + } + if matches.len() != 1 { + return Err(invalid( + "merge attribute user lacks unique current body edge", + )); + } + let (body, edge) = matches + .first() + .copied() + .ok_or_else(|| invalid("missing merge attribute user"))?; + out.extend([body, edge]); + } + Ok(()) +} + +pub(super) fn snapshot(session: &FilletSession<'_>) -> SolidResult> { + let target = p1::edges(session.target(), None)?; + let managed = session + .intermediate_brep() + .ok_or_else(|| invalid("actual merge body missing"))?; + let intermediate = p1::edges(managed, None)?; + let keys = vec![ + target + .into_iter() + .map(|id| session.target_identity(EntityRef::Edge(id)).key) + .collect(), + intermediate + .into_iter() + .map(|id| { + session + .intermediate_identity(EntityRef::Edge(id)) + .map(|value| value.key) + }) + .collect::>>()?, + ]; + let expected = match session.executive().edge_attribute { + Some(FilletObject::Attribute(handle)) => Some(handle), + None => None, + _ => return Err(invalid("merge executive attribute is not actual handle")), + }; + let mut out = vec![f64::from(expected.is_some())]; + if let Some(handle) = expected { + users(session, handle, &keys, &mut out)?; + } + let store = session.attributes(); + let mut tags = Vec::new(); + for body in &keys { + for &key in body { + for handle in store.attributes(key) { + let attribute = store + .get(handle) + .ok_or_else(|| invalid("merge edge attribute not live"))?; + if attribute.attribute_id() == ids::FILLET_EDGES && !tags.contains(&handle) { + tags.push(handle); + } + } + } + } + out.push(tags.len() as f64); + for &handle in &tags { + users(session, handle, &keys, &mut out)?; + } + for body in keys { + out.push(body.len() as f64); + for key in body { + let mut own = Vec::new(); + for handle in store.attributes(key) { + let attribute = store + .get(handle) + .ok_or_else(|| invalid("merge edge attribute not live"))?; + if matches!( + attribute.attribute_id(), + ids::FILLET_EDGES | ids::ALL_FILLET_EDGES + ) { + own.push(handle); + } + } + out.push(own.len() as f64); + for handle in own { + let attribute = store + .get(handle) + .ok_or_else(|| invalid("merge edge attribute missing"))?; + if attribute.attribute_id() == ids::ALL_FILLET_EDGES && Some(handle) != expected { + return Err(invalid("unexpected unrelated executive edge attribute")); + } + out.extend([ + attribute.attribute_id() as f64, + f64::from(Some(handle) == expected), + tags.iter() + .position(|value| *value == handle) + .map_or(-1.0, |index| index as f64), + ]); + } + } + } + Ok(out) +} diff --git a/crates/difftest/src/units/blend_fillet_executive_p3/original_deletion.rs b/crates/difftest/src/units/blend_fillet_executive_p3/original_deletion.rs new file mode 100644 index 0000000000..a4bf15cc62 --- /dev/null +++ b/crates/difftest/src/units/blend_fillet_executive_p3/original_deletion.rs @@ -0,0 +1,192 @@ +//! Actual retained deletion attempt; removed identities are labeled before use. +use super::{ + insertion_state, invalid, original_selection, section, status, FilletSession, SolidResult, +}; +use solidcore::blend::fillet_executive::session::{ + FilletBody, FilletObject, OriginalSurfaceDeletion, +}; +use solidcore::geom::context::MarkType; +use solidcore::kernel::Kernel; +use solidcore::topology::brep::{p1, p3::topology_ref}; +use solidcore::topology::Brep; +use solidcore::EntityRef; + +pub(super) struct Frame { + pub(super) entities: [Vec; 3], + pub(super) marks: [Vec; 3], +} +pub(super) fn entities(body: &Brep) -> SolidResult<[Vec; 3]> { + Ok([ + p1::vertices(body, None)? + .into_iter() + .map(EntityRef::Vertex) + .collect(), + p1::edges(body, None)? + .into_iter() + .map(EntityRef::Edge) + .collect(), + p1::faces(body, None)? + .into_iter() + .map(EntityRef::Face) + .collect(), + ]) +} +pub(super) fn frames(session: &mut FilletSession<'_>) -> SolidResult> { + let state = session.insertion_state()?; + let managed = state + .intermediate + .notify_serial() + .ok_or_else(|| invalid("deletion managed identity missing"))?; + let mut frames = Vec::new(); + for (body, serial) in [ + (&*state.target, state.target_serial), + (&*state.intermediate, managed), + ] { + let entities = entities(body)?; + let mut marks = [Vec::new(), Vec::new(), Vec::new()]; + for (entities, marks) in entities.iter().zip(&mut marks) { + for entity in entities { + let record = state + .notification_records + .get(&topology_ref(serial, *entity).key) + .ok_or_else(|| invalid("predeletion actual record missing"))?; + marks.push(record.get_mark(MarkType::Mark)); + } + } + frames.push(Frame { entities, marks }); + } + Ok(frames) +} +pub(super) fn face_labels(values: &[FilletObject], frames: &[Frame]) -> SolidResult> { + let mut out = Vec::new(); + for object in values { + let (body, entity) = match object { + FilletObject::Topology(FilletBody::Target, entity @ EntityRef::Face(_)) => (0, *entity), + FilletObject::IntermediateTopology(entity @ EntityRef::Face(_)) => (1, *entity), + _ => return Err(invalid("deletion output lacks actual body-qualified face")), + }; + let faces = frames + .get(body) + .and_then(|f| f.entities.get(2)) + .ok_or_else(|| invalid("missing deletion input frame"))?; + let index = faces + .iter() + .position(|value| *value == entity) + .ok_or_else(|| invalid("deletion output not in pre-call owning traversal"))?; + out.extend([(body + 1) as f64, index as f64]); + } + Ok(out) +} + +pub(super) fn run( + k: &mut Kernel, + session: &mut FilletSession<'_>, +) -> SolidResult<(Vec, Vec, bool)> { + let mut out = vec![f64::from(session.executive().options.global_merge)]; + let epochs = original_selection::MARK_TYPES.map(|mark| k.context.current_mark(mark)); + original_selection::context(k, &mut out, None)?; + let before = frames(session)?; + let mut deleted = OriginalSurfaceDeletion::default(); + let result = session.trim_and_delete_original_fillet_faces(k, &mut deleted); + let complete = result.is_ok(); + out.push(match result { + Ok(()) => 0.0, + Err(error) => status(error)?, + }); + original_selection::context(k, &mut out, Some(&epochs))?; + out.extend([ + f64::from(k.context.doing_boolean()), + f64::from(session.target().editing_enabled()), + f64::from(session.executive().intersector.doing_fillets), + f64::from(session.executive().intersector.topology_deleted), + ]); + // Identity-only lookups in pre-call arrays: never dereference removed faces. + section(&mut out, &face_labels(&deleted.kept, &before)?); + section(&mut out, &face_labels(&deleted.deleted, &before)?); + { + let state = session.insertion_state()?; + let serial = state + .intermediate + .notify_serial() + .ok_or_else(|| invalid("managed deletion identity missing"))?; + for (body_index, (body, serial)) in [ + (&*state.target, state.target_serial), + (&*state.intermediate, serial), + ] + .into_iter() + .enumerate() + { + let frame = before + .get(body_index) + .ok_or_else(|| invalid("missing actual before frame"))?; + for (kind, entities) in entities(body)?.into_iter().enumerate() { + let previous = frame + .entities + .get(kind) + .ok_or_else(|| invalid("missing actual pre-call kind"))?; + let previous_marks = frame + .marks + .get(kind) + .ok_or_else(|| invalid("missing actual pre-call marks"))?; + let mut marks = vec![entities.len() as f64]; + for entity in entities { + let old = previous + .iter() + .position(|value| *value == entity) + .ok_or_else(|| invalid("deletion created unexpected owning topology"))?; + let raw_before = previous_marks + .get(old) + .ok_or_else(|| invalid("missing pre-call mark value"))?; + let record = state + .notification_records + .get(&topology_ref(serial, entity).key) + .ok_or_else(|| invalid("postdeletion actual record missing"))?; + let raw = record.get_mark(MarkType::Mark); + marks.extend([ + old as f64, + f64::from(record.is_marked(&k.context, MarkType::Mark)), + f64::from( + k.context.current_mark(MarkType::Mark).checked_sub(1) == Some(raw), + ), + f64::from(*raw_before == raw), + ]); + } + section(&mut out, &marks); + } + } + } + // Do not dereference a managed FG source after its owning face was retired. + // The ordinary-box observation requires an actual surviving source binding. + let managed = session + .intermediate_brep() + .ok_or_else(|| invalid("missing deletion managed body"))?; + let live_faces = p1::faces(managed, None)?; + for object in &session.executive().solvers { + let FilletObject::Solver(id) = *object else { + return Err(invalid("actual deletion solver required")); + }; + for &geometry in &session.solver(id)?.base().fillet_geoms { + if let Some(surface) = session.fillet_surface(geometry)? { + let mut live = false; + for &face in &live_faces { + if managed + .face(face)? + .surface + .as_ref() + .is_some_and(|value| std::ptr::eq(value, surface)) + { + live = true; + } + } + if !live { + return Err(invalid( + "post-deletion FG source not in surviving managed faces", + )); + } + } + } + } + let (after, intervals) = insertion_state::snapshot(k, session)?; + section(&mut out, &after); + Ok((out, intervals, complete)) +} diff --git a/crates/difftest/src/units/blend_fillet_executive_p3/original_merge.rs b/crates/difftest/src/units/blend_fillet_executive_p3/original_merge.rs new file mode 100644 index 0000000000..9f2506c398 --- /dev/null +++ b/crates/difftest/src/units/blend_fillet_executive_p3/original_merge.rs @@ -0,0 +1,124 @@ +//! Actual retained selection/deletion/tagging/merge, before body cleanup or stitch. +use super::{ + insertion_state, invalid, merge_attributes, + original_deletion::{entities, face_labels, frames}, + original_selection, section, status, FilletSession, SolidResult, +}; +use solidcore::blend::fillet_executive::session::{FilletObject, OriginalSurfaceDeletion}; +use solidcore::geom::context::MarkType; +use solidcore::kernel::Kernel; +use solidcore::topology::brep::{p1, p3::topology_ref}; + +pub(super) fn run( + k: &mut Kernel, + session: &mut FilletSession<'_>, +) -> SolidResult<(Vec, Vec, bool)> { + let mut out = vec![f64::from(session.executive().options.global_merge)]; + let epochs = original_selection::MARK_TYPES.map(|mark| k.context.current_mark(mark)); + original_selection::context(k, &mut out, None)?; + let before = frames(session)?; + let mut deleted = OriginalSurfaceDeletion::default(); + let result = session.trim_delete_and_merge_fillet_faces(k, &mut deleted); + let complete = result.is_ok(); + out.push(match result { + Ok(()) => 0.0, + Err(error) => status(error)?, + }); + original_selection::context(k, &mut out, Some(&epochs))?; + out.extend([ + f64::from(k.context.doing_boolean()), + f64::from(session.target().editing_enabled()), + f64::from(session.executive().intersector.doing_fillets), + f64::from(session.executive().intersector.topology_deleted), + ]); + // Identity-only lookups in pre-call arrays: never dereference removed faces. + section(&mut out, &face_labels(&deleted.kept, &before)?); + section(&mut out, &face_labels(&deleted.deleted, &before)?); + { + let state = session.insertion_state()?; + let serial = state + .intermediate + .notify_serial() + .ok_or_else(|| invalid("managed deletion identity missing"))?; + for (body_index, (body, serial)) in [ + (&*state.target, state.target_serial), + (&*state.intermediate, serial), + ] + .into_iter() + .enumerate() + { + let frame = before + .get(body_index) + .ok_or_else(|| invalid("missing actual before frame"))?; + for (kind, entities) in entities(body)?.into_iter().enumerate() { + let previous = frame + .entities + .get(kind) + .ok_or_else(|| invalid("missing actual pre-call kind"))?; + let previous_marks = frame + .marks + .get(kind) + .ok_or_else(|| invalid("missing actual pre-call marks"))?; + let mut marks = vec![entities.len() as f64]; + for entity in entities { + let old = previous.iter().position(|value| *value == entity); + let record = state + .notification_records + .get(&topology_ref(serial, entity).key) + .ok_or_else(|| invalid("post-merge actual record missing"))?; + let raw = record.get_mark(MarkType::Mark); + marks.extend([ + f64::from(old.is_some()), + old.map_or(-1.0, |index| index as f64), + f64::from(record.is_marked(&k.context, MarkType::Mark)), + f64::from( + k.context.current_mark(MarkType::Mark).checked_sub(1) == Some(raw), + ), + ]); + if let Some(old) = old { + let raw_before = previous_marks + .get(old) + .ok_or_else(|| invalid("missing pre-merge mark value"))?; + marks.push(f64::from(*raw_before == raw)); + } + } + section(&mut out, &marks); + } + } + } + // Do not dereference a managed FG source after its owning face was retired. + // The ordinary-box observation requires an actual surviving source binding. + let managed = session + .intermediate_brep() + .ok_or_else(|| invalid("missing deletion managed body"))?; + let live_faces = p1::faces(managed, None)?; + for object in &session.executive().solvers { + let FilletObject::Solver(id) = *object else { + return Err(invalid("actual deletion solver required")); + }; + for &geometry in &session.solver(id)?.base().fillet_geoms { + if let Some(surface) = session.fillet_surface(geometry)? { + let mut live = false; + for &face in &live_faces { + if managed + .face(face)? + .surface + .as_ref() + .is_some_and(|value| std::ptr::eq(value, surface)) + { + live = true; + } + } + if !live { + return Err(invalid( + "post-deletion FG source not in surviving managed faces", + )); + } + } + } + } + let (after, intervals) = insertion_state::snapshot(k, session)?; + section(&mut out, &after); + section(&mut out, &merge_attributes::snapshot(session)?); + Ok((out, intervals, complete)) +} diff --git a/crates/difftest/src/units/blend_fillet_executive_p3/original_selection.rs b/crates/difftest/src/units/blend_fillet_executive_p3/original_selection.rs new file mode 100644 index 0000000000..db3512804a --- /dev/null +++ b/crates/difftest/src/units/blend_fillet_executive_p3/original_selection.rs @@ -0,0 +1,214 @@ +//! One real selection call, its ordered outputs, and passive retained mark records. +use super::{invalid, label, section, status, FilletSession, SolidResult}; +use solidcore::blend::fillet_executive::session::{ + FilletBody, FilletObject, OriginalSurfaceSelection, +}; +use solidcore::geom::context::MarkType; +use solidcore::kernel::Kernel; +use solidcore::topology::brep::{p1, p3::topology_ref}; +use solidcore::EntityRef; + +pub(super) const MARK_TYPES: [MarkType; 5] = [ + MarkType::Mark, + MarkType::Mark2, + MarkType::Mark3, + MarkType::MarkIo, + MarkType::MarkAssert, +]; + +pub(super) fn context( + k: &Kernel, + out: &mut Vec, + before: Option<&[u64; 5]>, +) -> SolidResult<()> { + for (index, mark) in MARK_TYPES.into_iter().enumerate() { + out.extend([ + f64::from(mark as i32), + f64::from(k.context.is_mark_locked(mark)), + ]); + if let Some(before) = before { + let previous = before + .get(index) + .ok_or_else(|| invalid("missing mark epoch"))?; + let advance = k + .context + .current_mark(mark) + .checked_sub(*previous) + .ok_or_else(|| invalid("selection mark epoch decreased"))?; + out.push(advance as f64); + } + } + Ok(()) +} + +fn mark_history(session: &mut FilletSession<'_>) -> SolidResult> { + let state = session.insertion_state()?; + let serial = state + .intermediate + .notify_serial() + .ok_or_else(|| invalid("managed body lacks issued identity"))?; + let mut out = Vec::new(); + for (body, serial) in [ + (&*state.target, state.target_serial), + (&*state.intermediate, serial), + ] { + for entities in [ + p1::vertices(body, None)? + .into_iter() + .map(EntityRef::Vertex) + .collect::>(), + p1::edges(body, None)? + .into_iter() + .map(EntityRef::Edge) + .collect(), + p1::faces(body, None)? + .into_iter() + .map(EntityRef::Face) + .collect(), + ] { + for entity in entities { + let record = state + .notification_records + .get(&topology_ref(serial, entity).key) + .ok_or_else(|| invalid("preselection actual topology record missing"))?; + out.push(record.get_mark(MarkType::Mark)); + } + } + } + Ok(out) +} + +pub(super) fn run( + k: &mut Kernel, + session: &mut FilletSession<'_>, +) -> SolidResult<(Vec, bool)> { + let mut out = vec![f64::from(session.executive().options.global_merge)]; + let epochs = MARK_TYPES.map(|mark| k.context.current_mark(mark)); + context(k, &mut out, None)?; + let history = mark_history(session)?; + let target_vertices = p1::vertices(session.target(), None)?; + let target_edges = p1::edges(session.target(), None)?; + let managed = session + .intermediate_brep() + .ok_or_else(|| invalid("selection requires actual managed body"))?; + let managed_vertices = p1::vertices(managed, None)?; + let managed_edges = p1::edges(managed, None)?; + for entities in [ + target_vertices + .iter() + .copied() + .map(EntityRef::Vertex) + .collect::>(), + target_edges.iter().copied().map(EntityRef::Edge).collect(), + ] { + let mut mates = vec![entities.len() as f64]; + for (index, entity) in entities.into_iter().enumerate() { + let mate = session + .executive() + .intersector + .other_mate(Some(FilletObject::Topology(FilletBody::Target, entity))); + let other = match (entity, mate) { + (_, None) => -1.0, + ( + EntityRef::Vertex(_), + Some(FilletObject::IntermediateTopology(EntityRef::Vertex(id))), + ) => label(&managed_vertices, Some(id)), + ( + EntityRef::Edge(_), + Some(FilletObject::IntermediateTopology(EntityRef::Edge(id))), + ) => label(&managed_edges, Some(id)), + _ => return Err(invalid("selection mate has unexpected actual body role")), + }; + if mate.is_some() && other < 0.0 { + return Err(invalid("selection mate is not in actual owning traversal")); + } + mates.extend([index as f64, f64::from(mate.is_some()), other]); + } + section(&mut out, &mates); + } + let mut selected = OriginalSurfaceSelection::default(); + let result = session.select_original_fillet_surfaces(k, &mut selected); + let success = result.is_ok(); + out.push(match result { + Ok(()) => 0.0, + Err(error) => status(error)?, + }); + context(k, &mut out, Some(&epochs))?; + out.extend([ + f64::from(k.context.doing_boolean()), + f64::from(session.target().editing_enabled()), + f64::from(session.executive().intersector.doing_fillets), + ]); + let target_faces = p1::faces(session.target(), None)?; + let managed_faces = p1::faces( + session + .intermediate_brep() + .ok_or_else(|| invalid("selection lost actual managed body"))?, + None, + )?; + for (selected, owning) in [ + (&selected.kept, &managed_faces), + (&selected.deleted, &target_faces), + ] { + let mut labels = Vec::new(); + for id in selected { + let id = label(owning, Some(*id)); + if id < 0.0 { + return Err(invalid("selection output is outside actual owning body")); + } + labels.push(id); + } + section(&mut out, &labels); + } + let state = session.insertion_state()?; + let managed_serial = state + .intermediate + .notify_serial() + .ok_or_else(|| invalid("managed selection body lacks issued identity"))?; + let mut history = history.into_iter(); + for (body, serial) in [ + (&*state.target, state.target_serial), + (&*state.intermediate, managed_serial), + ] { + for entities in [ + p1::vertices(body, None)? + .into_iter() + .map(EntityRef::Vertex) + .collect::>(), + p1::edges(body, None)? + .into_iter() + .map(EntityRef::Edge) + .collect(), + p1::faces(body, None)? + .into_iter() + .map(EntityRef::Face) + .collect(), + ] { + let mut marks = vec![entities.len() as f64]; + for entity in entities { + let topology = state + .notification_records + .get(&topology_ref(serial, entity).key) + .ok_or_else(|| invalid("selection actual topology record missing"))?; + marks.extend([ + f64::from(topology.is_marked(&k.context, MarkType::Mark)), + f64::from( + k.context.current_mark(MarkType::Mark).checked_sub(1) + == Some(topology.get_mark(MarkType::Mark)), + ), + f64::from( + history + .next() + .ok_or_else(|| invalid("selection changed actual owning traversal"))? + == topology.get_mark(MarkType::Mark), + ), + ]); + } + section(&mut out, &marks); + } + } + if history.next().is_some() { + return Err(invalid("selection removed actual owning topology")); + } + Ok((out, success)) +} diff --git a/crates/difftest/src/units/blend_fillet_executive_p3/prefix.rs b/crates/difftest/src/units/blend_fillet_executive_p3/prefix.rs index 7261a77ea3..164aefeabc 100644 --- a/crates/difftest/src/units/blend_fillet_executive_p3/prefix.rs +++ b/crates/difftest/src/units/blend_fillet_executive_p3/prefix.rs @@ -9,8 +9,20 @@ mod edge_geometry; mod edge_map_state; #[path = "generated_geometry.rs"] mod generated_geometry; +#[path = "insertion_checkpoint.rs"] +mod insertion_checkpoint; +#[path = "insertion_state.rs"] +mod insertion_state; #[path = "intermediate_faces.rs"] mod intermediate_faces; +#[path = "merge_attributes.rs"] +mod merge_attributes; +#[path = "original_deletion.rs"] +mod original_deletion; +#[path = "original_merge.rs"] +mod original_merge; +#[path = "original_selection.rs"] +mod original_selection; #[path = "rail_classification.rs"] mod rail_classification; #[path = "rail_metadata.rs"] @@ -236,6 +248,15 @@ pub(super) fn run( input: &RealSolid, before: &[f64], ) -> SolidResult> { + if matches!( + input.checkpoint, + Checkpoint::InsertedIntersectionTopology + | Checkpoint::SelectedOriginalSurfaces + | Checkpoint::DeletedOriginalFaces + | Checkpoint::MergedFilletFaces + ) { + return insertion_checkpoint::run(k, body, selected, input, before); + } let mut session = FilletSession::new(k, body)?; let mut statuses = [-1.0; 12]; let prepare = session.prepare_constant_radius_edges( @@ -622,6 +643,16 @@ pub(super) fn run( pub(super) fn output(mut values: &[f64], checkpoint: Checkpoint) -> Result { let mut out = Output::new(); + if matches!( + checkpoint, + Checkpoint::InsertedIntersectionTopology + | Checkpoint::SelectedOriginalSurfaces + | Checkpoint::DeletedOriginalFaces + | Checkpoint::MergedFilletFaces + ) { + return insertion_output(values, checkpoint); + } + out.values.insert( "selected_edge_before".into(), take_section(&mut values)?.to_vec(), @@ -893,3 +924,108 @@ pub(super) fn cpp(input: &RealSolid) -> Result { .map_err(|e| HarnessError::new(e.to_string()))?; output(&values, input.checkpoint) } + +fn insertion_output(mut values: &[f64], checkpoint: Checkpoint) -> Result { + let mut out = Output::new(); + out.values.insert( + "selected_edge_before".into(), + take_section(&mut values)?.to_vec(), + ); + for (name, exact) in [ + ("insertion_statuses", true), + ("insertion_passive_before", true), + ("insertion_raw_intervals_before", false), + ("insertion_passive_after", true), + ("insertion_raw_intervals_after", false), + ] { + let section = take_section(&mut values)?; + if exact { + if section.iter().any(|v| !v.is_finite() || v.fract() != 0.0) { + return Err(HarnessError::new("invalid insertion integer state")); + } + out.ints + .insert(name.into(), section.iter().map(|v| *v as i64).collect()); + } else { + out.values.insert(name.into(), section.to_vec()); + } + } + let (&complete, tail) = values + .split_first() + .ok_or_else(|| HarnessError::new("missing insertion completion"))?; + if complete != 0.0 && complete != 1.0 { + return Err(HarnessError::new("invalid insertion completion")); + } + values = tail; + let geometry_kind = if checkpoint == Checkpoint::MergedFilletFaces { + "merge" + } else if checkpoint == Checkpoint::DeletedOriginalFaces { + "deletion" + } else { + "insertion" + }; + out.ints.insert( + format!("{geometry_kind}_geometry_present"), + vec![i64::from(complete == 1.0)], + ); + if complete == 1.0 { + for body in ["target", "intermediate"] { + let state = take_section(&mut values)?; + if state.iter().any(|v| !v.is_finite() || v.fract() != 0.0) { + return Err(HarnessError::new("invalid insertion topology")); + } + out.ints.insert( + format!("{geometry_kind}_{body}_topology"), + state.iter().map(|v| *v as i64).collect(), + ); + out.values.insert( + format!("{geometry_kind}_{body}_geometry"), + take_section(&mut values)?.to_vec(), + ); + } + } + if checkpoint == Checkpoint::SelectedOriginalSurfaces { + let state = take_section(&mut values)?; + if state.iter().any(|v| !v.is_finite() || v.fract() != 0.0) { + return Err(HarnessError::new("invalid original selection state")); + } + out.ints.insert( + "original_surface_selection".into(), + state.iter().map(|v| *v as i64).collect(), + ); + } + if checkpoint == Checkpoint::DeletedOriginalFaces { + let state = take_section(&mut values)?; + if state.iter().any(|v| !v.is_finite() || v.fract() != 0.0) { + return Err(HarnessError::new("invalid original deletion state")); + } + out.ints.insert( + "original_surface_deletion".into(), + state.iter().map(|v| *v as i64).collect(), + ); + out.values.insert( + "original_surface_deletion_raw_intervals".into(), + take_section(&mut values)?.to_vec(), + ); + } + if checkpoint == Checkpoint::MergedFilletFaces { + let state = take_section(&mut values)?; + if state + .iter() + .any(|value| !value.is_finite() || value.fract() != 0.0) + { + return Err(HarnessError::new("invalid retained merge state")); + } + out.ints.insert( + "original_faces_merge".into(), + state.iter().map(|value| *value as i64).collect(), + ); + out.values.insert( + "original_faces_merge_raw_intervals".into(), + take_section(&mut values)?.to_vec(), + ); + } + if !values.is_empty() { + return Err(HarnessError::new("trailing insertion data")); + } + Ok(out) +} diff --git a/crates/difftest/src/units/blend_fillet_geom_p1.rs b/crates/difftest/src/units/blend_fillet_geom_p1.rs index 9735603a23..97ab91946d 100644 --- a/crates/difftest/src/units/blend_fillet_geom_p1.rs +++ b/crates/difftest/src/units/blend_fillet_geom_p1.rs @@ -1462,6 +1462,11 @@ pub(crate) struct RustModel { pub(crate) fillet: Brep, pub(crate) fillet_edges: Vec, pub(crate) fb: FilletBrep, + /// The Model's own geometries (the first `geom_count` of the arena; the shim's + /// `geoms` vector). Geometries the library adds later (a temporary solver's, which + /// upstream deletes with the solver) are not the Model's: the shim neither exports + /// nor addresses them. + pub(crate) geom_count: usize, } impl RustModel { @@ -1656,6 +1661,7 @@ impl RustModel { fillet, fillet_edges, fb, + geom_count: gids.len(), }) } @@ -1692,7 +1698,7 @@ impl RustModel { } pub(crate) fn geom(&self, i: usize) -> Result { - if i < self.fb.geoms().len() { + if i < self.geom_count.min(self.fb.geoms().len()) { Ok(FilletGeomId(i as u32)) } else { Err(harness("geometry index")) @@ -1875,11 +1881,17 @@ impl RustModel { o.push(self.vertex_index(v)); dump_curve(o, r.uv_trim_curve.as_ref()); let g = self.fb.fillet_edgeuse(eu).map_err(h)?.fillet_geom(); - o.push(g.map_or(-1.0, |g| f64::from(g.0))); + // index_of(geoms, ..): -1 for a geometry that is not the Model's + o.push( + g.filter(|g| (g.0 as usize) < self.geom_count) + .map_or(-1.0, |g| f64::from(g.0)), + ); } } + // the shim's `geoms` (the Model's own geometries) let gs = self.fb.geoms(); + let gs = gs.get(..self.geom_count).unwrap_or(gs); o.push(gs.len() as f64); for g in gs { o.push(g.fillet_geom_type() as i64 as f64); diff --git a/crates/difftest/src/units/blend_fillet_geom_p2.rs b/crates/difftest/src/units/blend_fillet_geom_p2.rs index 5e14d57a63..c92e4db204 100644 --- a/crates/difftest/src/units/blend_fillet_geom_p2.rs +++ b/crates/difftest/src/units/blend_fillet_geom_p2.rs @@ -1279,6 +1279,27 @@ impl UnitDiff for Runner { mod tests { use super::*; + /// HANG-29: `--fuzz 30000` fuzz09135, `RailRail` whose original face has a control + /// point of `-1e300` (no native result in 60 s). Both sides refuse it. + #[test] + fn rail_rail_refuses_an_original_face_in_the_overflow_range() { + let recorded: Value = serde_json::from_str(include_str!( + "../../tests/fuzz_regressions/blend.fillet_geom_p2.json" + )) + .unwrap(); + for recorded in recorded["cases"].as_array().unwrap() { + let case = Case { + unit: "blend.fillet_geom_p2".into(), + name: recorded["name"].as_str().unwrap().into(), + source: crate::case::CaseSource::Inline, + inputs: recorded["inputs"].clone(), + }; + for error in [Runner.run_cpp(&case), Runner.run_rust(&case)] { + assert!(error.is_err(), "{}: {error:?}", case.name); + } + } + } + /// Tallies the Rust outcome of every case per query (status `0`, an upstream error /// status, a deferral): with no failed case, the passes' success-path / error-path /// split. `CASE_SEEDS` sets the generated count. diff --git a/crates/difftest/src/units/blend_fillet_geom_p3.rs b/crates/difftest/src/units/blend_fillet_geom_p3.rs index 40b831b9f6..3ada7018cc 100644 --- a/crates/difftest/src/units/blend_fillet_geom_p3.rs +++ b/crates/difftest/src/units/blend_fillet_geom_p3.rs @@ -29,8 +29,9 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; -use solidcore::blend::fillet_custom_solver::{OffsetSurface, SurfaceId}; use solidcore::blend::fillet_corner::p3::{FilletCorner, FilletCornerKind}; +use solidcore::blend::fillet_custom_solver::{OffsetSurface, SurfaceId}; +use solidcore::blend::fillet_executive::p3::ErrorInfo; use solidcore::blend::fillet_geom::p1::{ CornerLookup, FilStatus, FilletBrep, FilletCornerId, FilletEdgeType, FilletEdgeuse, FilletGeomId, FilletGeomType, FilletSolverId, FilletVertex, FilletVertexType, FilletVertexuse, @@ -987,6 +988,7 @@ fn rust(i: &Inputs) -> Result { let mut bases: Vec = m.solvers.iter().map(|s| s.base.clone()).collect(); let corners = shim_corners(&m)?; let corners = CornerLookup::table(&corners); + let mut error_info = ErrorInfo::default(); let res = match &i.op { OpIn::Export => Ok(()), OpIn::CornerVert { v } => { @@ -1000,12 +1002,27 @@ fn rust(i: &Inputs) -> Result { OpIn::CornerEdge { e } => { let e = *m.edges.get(*e).ok_or_else(|| harness("edge"))?; // the shim refuses the part 6 calculators (its corners carry no data) - m.fb.calc_corner_edge_geom(&k, e, corners, &mut bases, &mut tg.brep) + m.fb.calc_corner_edge_geom_with_error_info( + &k, + e, + corners, + &mut bases, + &mut tg.brep, + Some(&mut error_info), + ) } OpIn::FilletIntFillet { e } => { let e = *m.edges.get(*e).ok_or_else(|| harness("edge"))?; let corner = m.fb.fillet_edge(e).map_err(h)?.fillet_corner(); - m.fb.calc_edge_fillet_int_fillet(&k, e, corner, corners, &bases, &tg.brep) + m.fb.calc_edge_fillet_int_fillet_with_error_info( + &k, + e, + corner, + corners, + &bases, + &tg.brep, + Some(&mut error_info), + ) } OpIn::ReCalc { g, diff --git a/crates/difftest/src/units/blend_fillet_geom_p6.rs b/crates/difftest/src/units/blend_fillet_geom_p6.rs index d56c08562e..beab490551 100644 --- a/crates/difftest/src/units/blend_fillet_geom_p6.rs +++ b/crates/difftest/src/units/blend_fillet_geom_p6.rs @@ -17,11 +17,12 @@ //! and UVs can be given relative to the target (a fraction of an edge's interval or of a //! face's surface domain); both sides get the resolved values. //! -//! An op that evaluates a target face surface or edge curve of an analytic kind (cone, -//! cylinder, torus, sphere, surface of revolution or extrusion; ellipse, circle and the -//! other analytic curves) is deferred under `harness:analytic-geometry-export`: the -//! shared B-rep export carries those as their B-spline part, so the Rust side would -//! evaluate different geometry (`plan/findings.md`). +//! Analytic target geometry the shared B-rep export carries exactly (cone, cylinder, +//! sphere, torus, surfaces of revolution and extrusion rebuilt from their upstream +//! database record; lossless ellipses and circles) is compared as its own kind, through +//! `ImportTypes::inexact_surface` / `inexact_curve`. An op that evaluates a face surface +//! or edge curve the import still holds only as its B-spline part is deferred under +//! `harness:analytic-geometry-export` (`plan/findings.md`). //! //! `SmFilletGeom::TrimOriginalSurfaces` is not compared: it needs a fillet B-rep and the //! executive's topology intersector mates the shim does not build; its Rust port is @@ -32,6 +33,7 @@ use serde_json::Value; use solidcore::blend::fillet_corner::p3::{FilletCorner, FilletCornerKind}; use solidcore::blend::fillet_custom_solver::{OffsetSurface, SurfaceId}; +use solidcore::blend::fillet_executive::p3::ErrorInfo; use solidcore::blend::fillet_geom::p1::{ FilStatus, FilletBrep, FilletCornerId, FilletEdgeType, FilletEdgeuse, FilletErrorType, FilletGeom, FilletGeomId, FilletGeomType, FilletSolverId, FilletVertex, @@ -39,7 +41,7 @@ use solidcore::blend::fillet_geom::p1::{ }; use solidcore::blend::fillet_geom::p3::new_solver_fillet_geom; use solidcore::blend::fillet_geom::p6::{ - join_two_fillet_verts_by_curves, move_tsp, + join_two_fillet_verts_by_curves, move_tsp, GeomErrorObject, }; use solidcore::blend::fillet_solver::p1::{ FilletExecutiveRef, FilletSolver, OffsetSurfaceSlot, @@ -515,15 +517,14 @@ impl Target { self.counts[9] } - /// Whether target face `f`'s surface is imported exactly. + /// Whether target face `f`'s surface is imported exactly (a plane, a B-spline surface + /// or an analytic surface rebuilt from its upstream database record). fn exact_face(&self, f: Option) -> bool { - f.and_then(|f| { + f.is_none_or(|f| { self.types - .face_surface_types - .get(f.index() as usize) - .copied() + .inexact_surface(f.index() as usize) + .is_none_or(|t| EXACT_SURFACES.contains(&t)) }) - .is_none_or(|t| t < 0 || EXACT_SURFACES.contains(&t)) } /// Whether target edge `e`'s curve is imported exactly. @@ -1437,6 +1438,15 @@ fn inexact(i: &Inputs, tg: &Target) -> Result, HarnessError> { } ok } + OpIn::RailEuIntersect { eu, .. } => { + if let Some(eu) = tg.edgeuse(*eu) { + let e = teu::edge(&tg.brep, eu).map_err(h)?; + let f = teu::face(&tg.brep, eu).map_err(h)?; + tg.exact_edge(e) && tg.exact_face(f) + } else { + true + } + } _ => true, }; Ok((!exact).then(|| { @@ -1646,7 +1656,7 @@ fn rust(i: &Inputs) -> Result { }; // the shim's SmTsectPnt t let mut tsect = TsectPnt::default(); - let r = m.fb.rail_edgeuse_intersect( + let r = m.fb.rail_edgeuse_intersect_with_geometry( &k, g, &mut solver, @@ -1667,7 +1677,8 @@ fn rust(i: &Inputs) -> Result { } OpIn::RecordError { g, etype } => { let g = geom(&m, *g)?; - let etype = match etype { + let code = *etype; + let etype = match code { 0 => FilletErrorType::Ok, 1 => FilletErrorType::ExecUninitialized, 2 => FilletErrorType::InvalidInput, @@ -1677,7 +1688,64 @@ fn rust(i: &Inputs) -> Result { 6 => FilletErrorType::BadTrim, _ => FilletErrorType::BadMerge, }; - m.fb.record_fillet_error(g, etype, None, None, None, None, None, &m.solvers) + let filleted_edge = (tg.n_e() > 0 && (code & 1) != 0).then(|| EdgeId::from_index(0)); + let target_face = (tg.n_f() > 0 && (code & 2) != 0).then(|| FaceId::from_index(0)); + let rail = if (code & 4) != 0 { + m.fb.geom(g).map_err(h)?.rail(0) + } else { + None + }; + let fillet_surface = m + .fb + .geom(g) + .map_err(h)? + .fillet_surface() + .map_err(h)? + .map(|_| GeomErrorObject::FilletSurface(g)); + let comment = ((code & 1) != 0).then_some("SmFilletGeom::RecordFilletError"); + let mut info = ErrorInfo::default(); + let r = m.fb.record_fillet_error_in( + g, + etype, + filleted_edge, + target_face, + rail, + fillet_surface, + comment, + &m.solvers, + Some(&mut info), + ); + if r.is_ok() { + results.push(f64::from(info.code)); + results.push(f64::from(info.status)); + results.push(f64::from(info.edge_type)); + results.push(match info.edge { + Some(GeomErrorObject::FilletedEdge(e)) => f64::from(e.index()), + _ => -1.0, + }); + results.push(match info.side_face { + Some(GeomErrorObject::SideFace(f)) => f64::from(f.index()), + _ => -1.0, + }); + results.push(info.edges.len() as f64); + for item in &info.edges { + let idx = match item { + Some(GeomErrorObject::FilletEdge(e)) => m + .edges + .iter() + .position(|&x| x == *e) + .map_or(-1.0, |i| i as f64), + _ => -1.0, + }; + results.push(idx); + } + results.push(info.surfaces.len() as f64); + for item in &info.surfaces { + results.push(b(matches!(item, Some(GeomErrorObject::FilletSurface(_))))); + } + results.push(info.comment.len() as f64); + } + r } }; if let Err(e) = &res { @@ -2540,7 +2608,7 @@ fn gen_move_tsp(rng: &mut Rng) -> Inputs { } } -/// `RailEdgeuseIntersect` and `RecordFilletError` (both reach unported callees). +/// `RailEdgeuseIntersect` and `RecordFilletError`. fn gen_deferred(rng: &mut Rng) -> Inputs { let mut vertices = Vec::new(); let mut edges = Vec::new(); diff --git a/crates/difftest/src/units/blend_fillet_solver_p2.rs b/crates/difftest/src/units/blend_fillet_solver_p2.rs index 7425a87f1c..2b417417c7 100644 --- a/crates/difftest/src/units/blend_fillet_solver_p2.rs +++ b/crates/difftest/src/units/blend_fillet_solver_p2.rs @@ -24,8 +24,8 @@ //! wrong edge kind, jittered points; the C++ stores into a fresh `SmFilletGeom` with two //! rails, the Rust side compares the built parts, [`FilletSolver::cylinder_fillet_geom_parts`] //! etc.), `GetFilletCorner` above index 1 (`corner`) and `RecordFilletError` -//! (`record`: the shim skips it, upstream reaches the `NULL` executive; the Rust side -//! defers at `SmFilletGeom::GetFilletSurface`, unit `blend.fillet_geom_p1`). +//! (`record`: compares the recorded `SmFilletErrorInfo` fields across rail-0, WR-190 +//! rail-1, dual-rail deduplication and empty-tsect modes). //! //! B-rep cases build the C++ B-rep, export it through unit `topology.edgeuse_p1` and //! import the same records on the Rust side; entity selectors are resolved modulo the @@ -37,14 +37,16 @@ use serde_json::Value; use solidcore::blend::fillet_custom_solver::{ InitialValues, JacobianLoad, OffsetSurface, SurfaceId, }; +use solidcore::blend::fillet_executive::p3::ErrorInfo; use solidcore::blend::fillet_geom::p1::{ - FilStatus, FilletBrep, FilletEdgeType, FilletEdgeuse, FilletGeom, FilletGeomId, FilletGeomType, - FilletVertex, FilletVertexType, FilletVertexuse, + FilStatus, FilletBrep, FilletEdgeType, FilletEdgeuse, FilletErrorType, FilletGeom, + FilletGeomId, FilletGeomType, FilletVertex, FilletVertexType, FilletVertexuse, }; use solidcore::blend::fillet_solver::p1::{ FilletExecutiveRef, FilletSolver, FilletSolverOps, FilletSurfaceGenerator, OffsetSurfaceSlot, }; +use solidcore::blend::fillet_solver::p2::SolverErrorObject; use solidcore::curve::curve::Curve; use solidcore::curve::ellipse::Ellipse; use solidcore::curve::line::Line; @@ -1190,6 +1192,7 @@ fn rust_run(i: &Inputs, o: &mut Vec) -> Result, Harn return Err(harness("B-rep failed on the Rust side only")); }; o.push(0.0); + let h = |e: SolidError| harness(e.to_string()); let mut fs = solver_of( cfg, &Surfs { @@ -1199,18 +1202,134 @@ fn rust_run(i: &Inputs, o: &mut Vec) -> Result, Harn orient: [1.0, 1.0], }, )?; - fs.base.edgeuses = [eu_id(sel(*eu, n_eu(&c))), None]; - // the executive is not modelled: the report to it is the stop - // (`SmFilletExecutive::NoteFilletError`, deferred) + let eu0_idx = sel(*eu, n_eu(&c)); + let eu0 = eu_id(eu0_idx).ok_or_else(|| harness("record edge use"))?; + let filleted = solidcore::topology::edgeuse::p1::edge(&c.brep, eu0).map_err(h)?; + fs.base.edgeuses = [Some(eu0), None]; + fs.base.status = if eu0_idx % 2 == 0 { + FilStatus::SurfTracingFailure + } else { + FilStatus::Unprocessed + }; + let err_code = if eu0_idx % 3 == 0 { + FilletErrorType::EdgeProblem + } else { + FilletErrorType::BadInt + }; fs.base.executive = Some(FilletExecutiveRef); - let (fb, _) = fillet_geom_model(&k, None)?; - let pts = [TsectPnt::default()]; - match fs - .base - .record_fillet_error(&c.brep, &fb, 5, &pts, "difftest") - { + + let (mut fb, g) = fillet_geom_model(&k, None)?; + fs.base.fillet_geoms = vec![g]; + let rail0 = fb + .geom(g) + .map_err(h)? + .rail(0) + .ok_or_else(|| harness("rail 0"))?; + let rail1 = fb + .geom(g) + .map_err(h)? + .rail(1) + .ok_or_else(|| harness("rail 1"))?; + + let mut pts = Vec::new(); + if cfg.target < -1.5 { + let r0 = Curve::from( + solidcore::nurbs::b_spline_curve::BSplineCurve::create_line_segment( + 3, + Vec3::ZERO, + Vec3::new(1.0, 0.0, 0.0), + ) + .map_err(h)?, + ); + fb.brep_mut().edge_mut(rail0).map_err(h)?.curve = Some(r0); + let mut t = TsectPnt::default(); + *t.crv_pos_mut() = Vec3::new(cfg.target, cfg.radius, fs.base.approx_tol_3d); + pts.push(t); + } else if cfg.target < 0.0 { + let r1 = Curve::from( + solidcore::nurbs::b_spline_curve::BSplineCurve::create_line_segment( + 3, + Vec3::ZERO, + Vec3::new(0.0, 1.0, 0.0), + ) + .map_err(h)?, + ); + fb.brep_mut().edge_mut(rail1).map_err(h)?.curve = Some(r1); + let mut plane = solidcore::surface::plane::Plane::from_normal( + Vec3::ZERO, + Vec3::new(0.0, 0.0, 1.0), + ); + plane.make_nurb().map_err(h)?; + fb.geom_mut(g) + .map_err(h)? + .set_fillet_surface(Some(Surface::from(plane))); + let cl = solidcore::nurbs::b_spline_curve::BSplineCurve::create_line_segment( + 3, + Vec3::ZERO, + Vec3::new(2.0, 0.0, 0.0), + ) + .map_err(h)?; + fb.geom_mut(g).map_err(h)?.center_line_curve = Some(Curve::from(cl)); + let mut t = TsectPnt::default(); + *t.crv_pos_mut() = Vec3::new(cfg.target, -cfg.radius, fs.base.approx_tol_3d); + pts.push(t); + } else if cfg.target < 1.5 { + let r0 = Curve::from( + solidcore::nurbs::b_spline_curve::BSplineCurve::create_line_segment( + 3, + Vec3::ZERO, + Vec3::new(1.0, 0.0, 0.0), + ) + .map_err(h)?, + ); + let r1 = Curve::from( + solidcore::nurbs::b_spline_curve::BSplineCurve::create_line_segment( + 3, + Vec3::ZERO, + Vec3::new(0.0, 1.0, 0.0), + ) + .map_err(h)?, + ); + fb.brep_mut().edge_mut(rail0).map_err(h)?.curve = Some(r0); + fb.brep_mut().edge_mut(rail1).map_err(h)?.curve = Some(r1); + pts.push(TsectPnt::default()); + } + + let mut ei = ErrorInfo::default(); + match fs.base.record_fillet_error_in( + &c.brep, + &fb, + err_code, + &pts, + Some("difftest"), + Some(&mut ei), + ) { Ok(()) => { - o.push(-2.0); + o.push(f64::from(ei.code)); + o.push(f64::from(ei.status)); + o.push(f64::from(ei.edge_type)); + o.push(b(ei.edge == filleted.map(SolverErrorObject::FilletedEdge))); + o.push(ei.surfaces.len() as f64); + o.push(b( + ei.surfaces.as_slice() == [Some(SolverErrorObject::FilletSurface(g))] + )); + o.push(ei.curves.len() as f64); + o.push(b( + ei.curves.as_slice() == [Some(SolverErrorObject::CenterLineCurve(g))] + )); + o.push(ei.edges.len() as f64); + o.push(b( + ei.edges.as_slice() == [Some(SolverErrorObject::FilletEdge(rail0))] + )); + let loc = ei.location.unwrap_or(Vec3::UNDEF); + o.push(b(loc.is_initialized())); + if loc.is_initialized() { + o.extend([loc.x, loc.y, loc.z]); + } + o.push(ei.comment.len() as f64); + for byte in ei.comment.bytes() { + o.push(f64::from(byte)); + } Ok(()) } Err(e) => Err(e), diff --git a/crates/difftest/src/units/boolean_merge_p3.rs b/crates/difftest/src/units/boolean_merge_p3.rs index 41040a8f37..28b112c0d2 100644 --- a/crates/difftest/src/units/boolean_merge_p3.rs +++ b/crates/difftest/src/units/boolean_merge_p3.rs @@ -29,8 +29,10 @@ use solidcore::geom::a_object::BooleanOperation; use solidcore::geom::attribute::{ids, Attribute, AttributeStore, Behavior, ObjectKey}; use solidcore::geom::context::MarkLock; use solidcore::kernel::Kernel; +use solidcore::math::Point3; use solidcore::topology::brep_cache::{arena_identity, CacheObject}; -use solidcore::topology::{brep::p1 as bp, Brep, FaceId}; +use solidcore::topology::topology_traverser::Marks; +use solidcore::topology::{brep::p1 as bp, Brep, FaceId, NamingScheme}; use solidcore::SolidError; use solidcore_ffi::units::boolean_merge_p3 as ffi; @@ -125,7 +127,7 @@ fn engine_output(status: i64, manifold: bool, lamina: u64, result: bool) -> Outp /// The engine mirrors the native `EngineAccess`: the context-only constructor's /// members (`KernelManifoldIntersector::new`), the same explicit tolerances and /// `SetMergeOptions`, manifold flag true and lamina mode 0 before the call. -fn engine_probe(i: &Inputs, pool: &mut Pool) -> Result { +fn engine_probe(k: &mut Kernel, i: &Inputs, pool: &mut Pool) -> Result { let mut breps = std::mem::take(&mut pool.breps).into_iter(); let mut primary = if i.a.is_empty() { None } else { breps.next() }; let mut other = if i.b.is_empty() { None } else { breps.next() }; @@ -139,11 +141,10 @@ fn engine_probe(i: &Inputs, pool: &mut Pool) -> Result { state: ManifoldState::default(), attributes: AttributeStore::default(), }); - let mut k = Kernel::default(); let mut manifold = true; let mut lamina = 0; let result = port::non_manifold_boolean( - &mut k, + k, &mut driver, &mut primary, &mut other, @@ -390,44 +391,35 @@ struct Pool { engine: Option, } impl Pool { - fn load(i: &Inputs) -> Result { + fn load(k: &mut Kernel, i: &Inputs) -> Result { let mut breps = Vec::new(); for operand in i.a.iter().chain(&i.b) { - let exported = ffi::eval(&encode(&Inputs { - family: 5, - operation: 0, - options: None, - a: vec![operand.clone()], - b: Vec::new(), - tree: Vec::new(), - expected_refusal: None, - })) - .map_err(|error| harness(&error))?; - // export_graph starts at validate_only; vertexuse import expects a - // leading success tag. Geometry comes only from fixture construction. - let mut tagged = vec![0.0]; - tagged.extend(&exported); - let (mut brep, _) = super::topology_vertexuse::import(&tagged)?; - // The vertexuse importer predates canonical arena owner keys. Preserve - // its native owned/unowned flag, but translate existing owners so - // canonical GetSurfaces sees the same standalone surfaces as C++. - let faces: Vec<_> = brep.faces().ids().collect(); - for face in faces { - if let Some(surface) = brep - .face_mut(face) + let [x, y, z] = operand.point; + let point = Point3::new(x, y, z); + let mut brep = if let Some([dx, dy, dz]) = operand.size { + solidcore::primitive::api_primitives::create_box(k, point, dx, dy, dz) .map_err(|error| harness(&error))? - .surface - .as_mut() - { - if surface.owner().is_some() { - surface.set_owner(Some(arena_identity(CacheObject::Face(face)))); - } - } - // a new owner key drops the cached face tolerance: record it again - brep.sync_face_surface_tolerance(face) - .map_err(|error| harness(&error))?; - } + } else { + let mut b = empty_brep( + k, + solidcore::geom::tol::USE_DEFAULT, + solidcore::geom::tol::USE_DEFAULT, + None, + ) + .map_err(|error| harness(&error))?; + let inf = b.infinite_region(); + let mut state = solidcore::topology::brep::p2::ArenaNotifyState::new(&b); + solidcore::topology::brep::p9::make_shell_vertex( + &mut b, + inf, + point, + &mut state.env(Some(&mut k.context)), + ) + .map_err(|error| harness(&error))?; + b + }; + brep.use_arena_naming().map_err(|error| harness(&error))?; breps.push(brep); } Ok(Self { @@ -657,6 +649,14 @@ impl PiecewiseAccess for Pool { (self.primary, self.other) } fn set_operands(&mut self, a: Option, b: Option) { + if let Some(engine) = self.engine.as_mut() { + if self.primary != a { + engine.state.primary_marks = Marks::default(); + } + if self.other != b { + engine.state.other_marks = Marks::default(); + } + } self.primary = a; self.other = b; } @@ -679,7 +679,8 @@ impl PiecewiseAccess for Pool { a, b, &mut engine.attributes, - ) + )?; + engine.adopt_operand_owners(Some(a), Some(b)) }) } fn clear_relationships(&mut self) -> solidcore::Result<()> { @@ -752,6 +753,7 @@ impl PiecewiseAccess for Pool { let tolerance = self.brep(source)?.tolerance(); set_tolerance(k, &mut result, tolerance, true, true)?; result.set_editing_enabled(true); + result.set_naming(NamingScheme::Arena); let id = self.breps.len(); self.breps.push(result); // append only: a handle is never reused Ok(id) @@ -784,7 +786,8 @@ impl PiecewiseAccess for Pool { lock.mark_type1(), lock.mark_type2(), &mut keep, - ) + )?; + engine.adopt_operand_owners(Some(a), Some(b)) }) } fn release_marks(&mut self, k: &mut Kernel, lock: &mut MarkLock) -> solidcore::Result<()> { @@ -907,11 +910,11 @@ fn rust(i: &Inputs) -> Result { return Err(harness("fixture-only family is not a unit operation")); } ffi::validate(&encode(i)).map_err(|error| harness(&error))?; - let mut pool = Pool::load(i)?; - if i.family == ENGINE_PROBE { - return engine_probe(i, &mut pool); - } let mut k = Kernel::default(); + let mut pool = Pool::load(&mut k, i)?; + if i.family == ENGINE_PROBE { + return engine_probe(&mut k, i, &mut pool); + } let opts = i.options.map(options); let mut values = vec![0.0]; let result: solidcore::Result<()> = match i.family { @@ -1161,7 +1164,7 @@ mod tests { #[test] fn single_success_records_compare_rust_geometry_controls_and_nulls() { - let mut pool = Pool::load(&inputs()).unwrap(); + let mut pool = Pool::load(&mut Kernel::default(), &inputs()).unwrap(); let graph = fixture_graph(); let mut native = vec![0.0, 1.0, 1.0, 1.0, 0.0, graph.len() as f64]; native.extend(&graph); @@ -1197,7 +1200,7 @@ mod tests { #[test] fn list_success_records_compare_actual_outputs_and_surface_count() { - let pool = Pool::load(&inputs()).unwrap(); + let pool = Pool::load(&mut Kernel::default(), &inputs()).unwrap(); let graph = fixture_graph(); let mut native = vec![0.0, 1.0, -1.0, graph.len() as f64]; native.extend(&graph); @@ -1236,8 +1239,8 @@ mod tests { fn engine(pool: &Pool) -> &KernelManifoldIntersector { &pool.engine.as_ref().unwrap().intersector } - let mut pool = Pool::load(&inputs()).unwrap(); let mut k = Kernel::default(); + let mut pool = Pool::load(&mut k, &inputs()).unwrap(); pool.construct(&mut k, &mut Some(0), &mut Some(1), 0.0, 0.0, None) .unwrap(); assert_eq!(pool.surfaces(0).unwrap().len(), 6); @@ -1275,8 +1278,8 @@ mod tests { #[test] fn piecewise_dependency_errors_name_the_next_missing_owner() { - let mut pool = Pool::load(&inputs()).unwrap(); let mut k = Kernel::default(); + let mut pool = Pool::load(&mut k, &inputs()).unwrap(); // without SmMerge's constructor the pool holds no m_vTI assert!(matches!( pool.clear_relationships(), diff --git a/crates/difftest/src/units/boolean_topology_intersector_p1.rs b/crates/difftest/src/units/boolean_topology_intersector_p1.rs index 62215888cd..9e442f3e05 100644 --- a/crates/difftest/src/units/boolean_topology_intersector_p1.rs +++ b/crates/difftest/src/units/boolean_topology_intersector_p1.rs @@ -102,7 +102,7 @@ use solidcore::topology::topology::Topology; use solidcore::topology::vertex as vx; use solidcore::query::brep::{BrepIdentity, BrepQuery}; use solidcore::topology::{ - Brep, BrepId, EdgeId, EdgeuseId, FaceId, LoopId, RegionId, VertexId, VertexuseId, + Brep, BrepId, EdgeId, EdgeuseId, FaceId, LoopId, LoopuseId, RegionId, VertexId, VertexuseId, }; use solidcore::{EntityRef, SolidError}; use solidcore_ffi::units::boolean_topology_intersector_p1::{self as ffi, Op}; @@ -617,6 +617,8 @@ pub(super) const K_VERTEXUSE: u8 = 12; pub(super) const K_TRIM_CURVE: u8 = 13; /// A region (classification results). const K_REGION: u8 = 14; +/// A loop use a face's trimmed surface cache names (Rust side only, never exported). +const K_LOOPUSE: u8 = 15; /// An object handle: the B-rep (`2` for the curve stores, `-1` for the intersector), /// the kind and the index. Ordered as the shim sorts (`brep`, `kind`, `index`). A @@ -819,6 +821,50 @@ impl Two { } } + /// `SmBrep::GlueVertices(v1, v2)` where `v1` and `v2` belong to different B-reps. + /// Upstream notifies, reads the faces of `v2` and the shells of both vertices, then + /// looks for a shell pair in one region (`NER(pSurvVShell)`). A region belongs to one + /// B-rep, so a pair that shares none ends the call with `SM_ERR_NULL_POINTER` + /// before any edit. The caller has already deleted the cache of `this` + /// (`Notify(SM_NO_PRE_EDIT, this)`). The remaining notifications are not delivered + /// across the two arenas: the shim registers no context callbacks, and each face of + /// the vertex to delete was already notified `PRE_EDIT` when the vertex was made + /// (`MakeVertexLoop` / `MakeVertexSplitEdge`), with nothing recomputing its + /// rectangular-trim flag or trim cache in between. + /// + /// # Errors + /// + /// [`SolidError::Upstream`] with `SM_ERR_NULL_POINTER` (upstream's `NER`); + /// [`SolidError::InvalidInput`] for shells of both B-reps + /// without a region (`NULL == NULL`: upstream would go on to merge shells across + /// B-reps) or a missing arena. + fn glue_vertices_of_two_breps(&self, survivor: H, deleted: H) -> solidcore::Result<()> { + let arena = |h: H| { + usize::try_from(h.b) + .ok() + .and_then(|slot| self.breps.get(slot)) + .and_then(Option::as_ref) + .ok_or_else(|| invalid("handle names an absent B-rep")) + }; + let (surv_brep, del_brep) = (arena(survivor)?, arena(deleted)?); + let (surv, del) = (vid(survivor), vid(deleted)); + // pVertexToDelete->GetFaces(..) (each face is notified), then GetShells for both + vx::faces(del_brep, del)?; + let surv_shells = vx::shells(surv_brep, surv)?; + let del_shells = vx::shells(del_brep, del)?; + for &first in &surv_shells { + let region = surv_brep.shell(first)?.region; + for &last in &del_shells { + if region.is_none() && del_brep.shell(last)?.region.is_none() { + return Err(invalid("shells of two B-reps share a NULL region")); + } + } + } + Err(SolidError::null_pointer( + "difftest::boolean_topology_intersector_p1::Two::glue_vertices_of_two_breps", + )) + } + /// Deletes the cache of the B-rep behind `brep` (`SmBrep::Notify`'s `PRE_EDIT` / /// `DESTRUCTION`); the next use rebuilds it. pub(crate) fn delete_cache(&mut self, brep: H) -> solidcore::Result<()> { @@ -925,6 +971,37 @@ impl Two { } } +/// The handle of an entity of B-rep `b` a face's trimmed surface cache names (its +/// faces, edges, vertices, loop uses, edge uses and vertex uses). +fn cache_entity_h(b: i8, e: EntityRef) -> solidcore::Result { + Ok(match e { + EntityRef::Face(f) => hd(b, K_FACE, f.index()), + EntityRef::Edge(x) => hd(b, K_EDGE, x.index()), + EntityRef::Vertex(v) => hd(b, K_VERTEX, v.index()), + EntityRef::Loopuse(lu) => hd(b, K_LOOPUSE, lu.index()), + EntityRef::Edgeuse(eu) => hd(b, K_EDGEUSE, eu.index()), + EntityRef::Vertexuse(vu) => hd(b, K_VERTEXUSE, vu.index()), + _ => return Err(invalid("entity a surface cache does not name")), + }) +} + +/// The entity of B-rep `b` a surface cache handle names (inverse of +/// [`cache_entity_h`]). +fn cache_h_entity(b: i8, h: H) -> solidcore::Result { + if h.b != b { + return Err(invalid("surface cache handle of another B-rep")); + } + Ok(match h.kind { + K_FACE => EntityRef::Face(fid(h)), + K_EDGE => EntityRef::Edge(eid(h)), + K_VERTEX => EntityRef::Vertex(vid(h)), + K_LOOPUSE => EntityRef::Loopuse(LoopuseId::from_index(h.i)), + K_EDGEUSE => EntityRef::Edgeuse(EdgeuseId::from_index(h.i)), + K_VERTEXUSE => EntityRef::Vertexuse(VertexuseId::from_index(h.i)), + _ => return Err(invalid("handle a surface cache does not name")), + }) +} + impl ClassGeometry for Two { fn curve(&self, o: H) -> Option<&SmCurve> { match o.kind { @@ -1462,12 +1539,10 @@ impl solidcore::topology::proto_topology::p3::BrepGlue for Two { && v1.b != v2.b && (v1.b == brep.b || v2.b == brep.b) { - // UNPORTED(topology.brep_p5): SmBrep::GlueVertices of vertices of two B-reps -- - // justified: topology::brep::p5::glue_vertices takes one B-rep arena - return Err(SolidError::unported( - "SmBrep::GlueVertices (vertices of two B-reps)", - "topology.brep_p5", - )); + // Notify(SM_NO_PRE_EDIT, this) deletes the cache of the B-rep the call is on, + // before anything can fail + self.delete_cache(brep)?; + return self.glue_vertices_of_two_breps(v1, v2); } if brep.kind != K_BREP || v1.kind != K_VERTEX @@ -1968,6 +2043,30 @@ impl IntersectorBrep for Two { fn topology_objects(&self) -> &dyn TopologyObjects { self } + fn face_cache_target(&mut self, face: H) -> solidcore::Result> { + if face.kind != K_FACE { + return Err(invalid("not a face handle")); + } + let b = face.b; + let surface = self + .face_surface_h(face) + .flatten() + .ok_or_else(|| invalid("face without surface"))?; + Ok(ti::FaceCacheTarget { + brep: self.brep_m(face)?, + face: fid(face), + surface, + handles: Box::new(move |e| cache_entity_h(b, e)), + entities: Box::new(move |h| cache_h_entity(b, h)), + }) + } + fn face_entity_handles(&self, face: H) -> solidcore::Result> { + if face.kind != K_FACE { + return Err(invalid("not a face handle")); + } + let b = face.b; + Ok(Box::new(move |e| cache_entity_h(b, e))) + } fn brep_delete_face(&mut self, k: &mut Kernel, brep: H, face: H) -> solidcore::Result<()> { if brep.kind != K_BREP || face.b != brep.b { return Err(invalid("face is not in the B-rep")); @@ -5439,3 +5538,103 @@ fn edge_cases() -> Vec<(String, Value)> { } out } + +#[cfg(test)] +mod tests { + use super::*; + use crate::case::CaseSource; + + /// HANG-29: the `--fuzz 30000` inputs whose surface or curve control point of + /// `+-1e300` kept the C++ intersection busy past the timeout, for this unit and `_p2` + /// (which shares this reader), and UB-166's v knot snap (`ub166` in the name: the + /// shim refuses the B-rep). They cannot be fixtures (a harness error always fails a + /// case), so this test keeps them: each is refused before the intersection runs. + #[cfg(feature = "cpp")] + #[test] + fn ffi_refuses_surface_points_in_the_hang_range() { + let p2 = super::super::boolean_topology_intersector_p2::Runner; + let files: [(&str, &dyn UnitDiff); 2] = [ + ( + include_str!("../../tests/fuzz_regressions/boolean.topology_intersector_p1.json"), + &Runner, + ), + ( + include_str!("../../tests/fuzz_regressions/boolean.topology_intersector_p2.json"), + &p2, + ), + ]; + for (text, runner) in files { + let recorded: Value = serde_json::from_str(text).unwrap(); + for recorded in recorded["cases"].as_array().unwrap() { + let case = Case { + unit: runner.id().into(), + name: recorded["name"].as_str().unwrap().into(), + source: CaseSource::Inline, + inputs: recorded["inputs"].clone(), + }; + let error = runner.run_cpp(&case).unwrap_err().to_string(); + let expected = if case.name.contains("ub166") { + // the shim's UB-166 refusal (SC_ERR_ARG) + "shim_rejected_args" + } else { + "point out of range" + }; + assert!(error.contains(expected), "{}: {error}", case.name); + } + } + } +} + +#[cfg(test)] +mod glue_tests { + use super::*; + use solidcore::primitive::api_primitives::create_box; + use solidcore::topology::proto_topology::p3::BrepGlue; + + fn two_boxes() -> (Kernel, Two) { + let mut k = Kernel::default(); + let a = create_box(&mut k, Point3::new(0.0, 0.0, 0.0), 1.0, 1.0, 1.0).unwrap(); + let b = create_box(&mut k, Point3::new(5.0, 0.0, 0.0), 1.0, 1.0, 1.0).unwrap(); + (k, Two::new(a, Some(b))) + } + + /// `GlueVertices` on `this` with vertices of two B-reps: `Notify(SM_NO_PRE_EDIT, this)` + /// deletes the cache of `this`, then `NER(pSurvVShell)` (no region is shared) edits + /// nothing. + #[test] + fn glue_of_two_breps_drops_this_cache_and_returns_null_pointer() { + for this_slot in [0i8, 1] { + let (mut k, mut two) = two_boxes(); + two.brep_cache(hd(0, K_BREP, 0)).unwrap(); + two.brep_cache(hd(1, K_BREP, 0)).unwrap(); + let counts = |two: &Two| { + two.breps + .iter() + .flatten() + .map(|b| (b.vertices().len(), b.edges().len(), b.faces().len())) + .collect::>() + }; + let before = counts(&two); + let first_vertex = |slot: usize| { + let id = two.breps[slot].as_ref().unwrap().vertices().ids().next(); + hd(slot as i8, K_VERTEX, id.unwrap().index()) + }; + let (v0, v1) = (first_vertex(0), first_vertex(1)); + let r = two.glue_vertices(&mut k, hd(this_slot, K_BREP, 0), v0, v1, None); + assert!( + matches!( + r, + Err(SolidError::Upstream { code: solidcore::error::SM_ERR_NULL_POINTER, .. }) + ), + "this={this_slot}: {r:?}" + ); + assert_eq!(counts(&two), before, "no topology edit"); + let cached = |i: usize| two.caches[i].get().is_some(); + assert_eq!( + (cached(0), cached(1)), + (this_slot != 0, this_slot != 1), + "only the cache of `this` is deleted" + ); + } + } +} diff --git a/crates/difftest/src/units/curve_crv_on_surf.rs b/crates/difftest/src/units/curve_crv_on_surf.rs index 936911d326..f9f2201c8f 100644 --- a/crates/difftest/src/units/curve_crv_on_surf.rs +++ b/crates/difftest/src/units/curve_crv_on_surf.rs @@ -20,6 +20,14 @@ //! them through the base curve's virtual knot queries. Every run has at least //! [`MIN_GENERATED`] generated cases; generated inputs the ffi wrapper rejects are //! redrawn. Any genuinely unported downstream call still defers the whole case. +//! +//! Deviations: the runner-local SMS reader validates counts below the exclusive +//! `2^usize::BITS` bound, checks dimension and payload arithmetic and the available +//! tokens before constructing the net, and grows storage incrementally. Valid point +//! order, weights, knots, lazy float parsing and path-bearing errors are unchanged. + +#[path = "fixtures/crv_on_surf_sms.rs"] +mod sms; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -254,46 +262,13 @@ enum Inputs { /// `m+q+2` v knots. fn read_sms(path: &str) -> Result { let text = std::fs::read_to_string(path).map_err(|e| harness(format!("{path}: {e}")))?; - let mut it = text.split_whitespace(); - let mut num = || -> Result { - it.next() - .ok_or_else(|| harness(format!("{path}: truncated")))? - .parse::() - .map_err(|e| harness(format!("{path}: {e}"))) - }; - let int = |v: f64| -> Result { - if v.fract() != 0.0 || v < 0.0 { - return Err(harness(format!("{path}: bad count {v}"))); - } - Ok(v as usize) - }; - let n = int(num()?)?; - let m = int(num()?)?; - let p = int(num()?)?; - let q = int(num()?)?; - let rat = num()? != 0.0; - let mut net = Vec::with_capacity(n + 1); - for _ in 0..=n { - let mut row = Vec::with_capacity(m + 1); - for _ in 0..=m { - let (x, y, z) = (num()?, num()?, num()?); - let w = if rat { num()? } else { NOW }; - row.push([x, y, z, w]); - } - net.push(row); - } - let u = (0..n + p + 2) - .map(|_| num()) - .collect::, _>>()?; - let v = (0..m + q + 2) - .map(|_| num()) - .collect::, _>>()?; + let s = sms::parse_sms(&text, path)?; Ok(InlineSrf { - net, - p, - q, - u, - v, + net: s.net, + p: s.p, + q: s.q, + u: s.u, + v: s.v, borrow_nurb: false, }) } diff --git a/crates/difftest/src/units/curve_curve_p4.rs b/crates/difftest/src/units/curve_curve_p4.rs index d61cabdec8..f04805d336 100644 --- a/crates/difftest/src/units/curve_curve_p4.rs +++ b/crates/difftest/src/units/curve_curve_p4.rs @@ -125,6 +125,7 @@ enum CurveIn { Hermite(HermiteIn), Circle(CircleIn), Line(LineIn), + Ellipse(EllipseIn), } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -304,6 +305,13 @@ fn curve_block(v: &mut Vec, c: &CurveIn) -> Result<(), HarnessError> { push_line(v, l); } CurveIn::Circle(_) => return Err(harness("unresolved circle")), + CurveIn::Ellipse(e) => { + v.extend([3.0, 3.0]); + v.extend_from_slice(&e.c); + v.extend_from_slice(&e.x); + v.extend_from_slice(&e.y); + v.extend([e.domain[0], e.domain[1], e.radii[0], e.radii[1], b(e.inside_out)]); + } } Ok(()) } @@ -571,6 +579,9 @@ fn build(c: &CurveIn) -> Result { ))), CurveIn::Line(l) => Ok(Curve::from(build_line(l))), CurveIn::Circle(_) => Err(harness("unresolved circle")), + CurveIn::Ellipse(e) => build_ellipse(&Kernel::default(), e) + .map(Curve::from) + .map_err(|e| harness(format!("ellipse: {e}"))), } } @@ -1215,7 +1226,9 @@ fn range_of(c: &CurveIn) -> (f64, f64) { bs.u.first().copied().unwrap_or(0.0), bs.u.last().copied().unwrap_or(1.0), ), - CurveIn::Hermite(_) => (0.0, 1.0), + // Like Hermite curves, ownerless quadratic ellipses use a normalized interval, + // not their angular domain (NL_CrvCommon.cpp circle construction). + CurveIn::Hermite(_) | CurveIn::Ellipse(_) => (0.0, 1.0), CurveIn::Line(l) => { let nat = build_line(l).natural_interval(); (nat.min(), nat.max()) @@ -1693,6 +1706,100 @@ mod tests { use super::*; use std::collections::BTreeMap; + fn ellipse_distance_fixture() -> Case { + let ctx = CaseContext { + root: std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."), + seeds: 0, + }; + let cases = ctx.fixture_cases("curve.curve_p4").unwrap(); + let matching: Vec<_> = cases.into_iter().filter(|c| { + matches!(&c.source, crate::case::CaseSource::Fixture { path, .. } + if path == "fixtures/curve.curve_p4/analytic-distance.json") + }).collect(); + assert_eq!(matching.len(), 1); + assert_eq!(matching[0].name, "ellipse_cp_dist_at_interior_full_jacobian"); + matching.into_iter().next().unwrap() + } + + fn ellipse_distance_relative(a: f64, b: f64) { + assert!(a.is_finite() && b.is_finite()); + assert!((a - b).abs() <= 1e-9 * a.abs().max(b.abs()), "{a:?} != {b:?}"); + } + + #[test] + fn ellipse_distance_fixture_encoding_and_source_controls() { + use solidcore::curve::curve::p2::CurveKind; + let case = ellipse_distance_fixture(); + let op: OpIn = case.parse().unwrap(); + assert_eq!(serde_json::to_value(&op).unwrap(), case.inputs); + let (code, input) = cpp_input(&op).unwrap(); + assert_eq!(code, Op::CpDistAt); + assert_eq!(input, vec![3.,3.,0.,0.,0.,1.,0.,0.,0.,1.,0.,0.,180.,2.,1.,0., + 0.25,-0.5,0.25,1.,0.,0.5,0.1,0.2,0.3,1.]); + ffi::validate(code, &input).unwrap(); + let OpIn::CpDistAt { curve, p, dsq, sqtol, x, jac } = &op else { panic!("wrong op") }; + assert!(*jac); + let CurveIn::Ellipse(e) = curve else { panic!("not ellipse") }; + assert_eq!(e.domain, [0.,180.]); + assert_eq!(range_of(curve), (0.,1.)); + let k = Kernel::default(); + let c = build(curve).unwrap(); + let CurveKind::Ellipse(actual) = c.kind() else { panic!("lost analytic kind") }; + assert!(!actual.is_circle()); + let ivl = c.natural_interval(); + assert_eq!((ivl.min(),ivl.max()), (0.,1.)); + assert!(ivl.min() < x[0] && x[0] < ivl.max()); + let pv = c.evaluate(&k, x[0], 1, true, true).unwrap(); + let pos = [pv[0].x,pv[0].y,pv[0].z]; + let tangent = [pv[1].x,pv[1].y,pv[1].z]; + assert!(pos.iter().chain(&tangent).all(|v| v.is_finite())); + assert!(pv[1].length_squared() > 0.); + let gap = [pos[0]-p[0],pos[1]-p[1],pos[2]-p[2]]; + let expected_f = [gap[0]*gap[0]-x[1],gap[1]*gap[1]-x[2],gap[2]*gap[2]-x[3],x[1]+x[2]+x[3]-dsq]; + let expected_j = [2.*tangent[0]*gap[0],-1.,0.,0., + 2.*tangent[1]*gap[1],0.,-1.,0.,2.*tangent[2]*gap[2],0.,0.,-1.,0.,1.,1.,1.]; + assert_ne!(expected_j[0], 0.); + let mut f = [f64::NAN;4]; + let mut j = Matrix::new(4,4); + let found = CpDistAtEnfo::new(&k,&c,v3(*p),*dsq,*sqtol) + .evaluate(x,&mut f,Some(&mut j)).unwrap(); + assert!(!found); + for (a,b) in f.into_iter().zip(expected_f) { ellipse_distance_relative(a,b); } + for (i,b) in expected_j.into_iter().enumerate() { + ellipse_distance_relative(j.at(i/4,i%4).unwrap(),b); + } + let out = rust(&k,&op).unwrap(); + assert!(out.deferred.is_none()); + assert_eq!(out.ints["status"], vec![0]); + assert_eq!(out.values["out"].len(),21); + for (a,b) in out.values["out"].iter().copied().zip( + expected_f.into_iter().chain(expected_j).chain([0.])) { + ellipse_distance_relative(a,b); + } + } + + #[cfg(feature = "cpp")] + #[test] + fn ellipse_distance_cpp_full_record_and_geometric_perturbation() { + let case = ellipse_distance_fixture(); + let op: OpIn = case.parse().unwrap(); + let native = cpp(&op).unwrap(); + let actual = rust(&Kernel::default(),&op).unwrap(); + for out in [&native,&actual] { + assert!(out.deferred.is_none()); + assert_eq!(out.ints["status"], vec![0]); + assert_eq!(out.values["out"].len(),21); + assert!(out.values["out"].iter().all(|v| v.is_finite())); + assert_ne!(out.values["out"][4],0.); + assert_eq!(out.values["out"][20],0.); + } + assert!(crate::compare::compare(&native,&actual,&[]).pass()); + let mut changed = native.clone(); + changed.values.get_mut("out").unwrap()[4] *= 1.01; + assert!(!crate::compare::compare(&changed,&actual,&[]).pass()); + eprintln!("ellipse_distance_native={native:?}; ellipse_distance_rust={actual:?}"); + } + /// Generated cases cover every op with both successful and failing statuses. #[test] fn generated_cases_are_diverse() { diff --git a/crates/difftest/src/units/curve_curve_p7.rs b/crates/difftest/src/units/curve_curve_p7.rs index 4cc2c8d7ba..d0dcececff 100644 --- a/crates/difftest/src/units/curve_curve_p7.rs +++ b/crates/difftest/src/units/curve_curve_p7.rs @@ -19,11 +19,18 @@ //! `{"o", "x", "y", "scale"?, "repeat"?}` transforms (`property` `xf`) are applied on both sides //! (`SmAxis2Placement::SetCanonical` + `SmCurve::Transform` / `Curve::transform`) before //! the op; a circle with `quintic` is `SM_CO_QUINTIC`. +//! `{"ellipse": {}}` instead constructs a genuine fixed `Ellipse` / `SmEllipse`: +//! origin, canonical X/Y, semiaxes 3/2, angular degrees 0..180, natural NURBS 0..1. //! //! Deferred cases (unit `curve.curve_p5`): the leaf pairs that reach a Newton //! intersection (`LocalCurveIntersect`, through `SmCCIntersectENFO::Evaluate`) and the //! property solves that reach `SmCurve::LocalPropertyAnalysis` (every span the property //! solver does not cull). +//! +//! Generated circle-derived curvature-extrema cases use noncircular ellipses: a +//! circle's constant curvature otherwise makes the number of noise-selected extrema +//! machine-dependent. The original flat-circle case remains a full native regression +//! outside the canonical report corpus; no solver or comparison rule is changed. use std::collections::BTreeMap; @@ -34,6 +41,7 @@ use solidcore::curve::curve::p2::{CciGlobalSolver, Curve, CurvePropertyType}; use solidcore::curve::curve::p5::make_curve_cache; use solidcore::curve::curve::p7::PropertyAnalysisOptions; use solidcore::curve::curve_cache::{CurveCache, CurveCacheOptions}; +use solidcore::curve::ellipse::{Ellipse, EllipseOptions}; use solidcore::curve::hermite_curve::HermiteCurve; use solidcore::geom::axis2_placement::Axis2Placement; use solidcore::geom::extent1d::Extent1d; @@ -100,6 +108,12 @@ struct CircleIn { quintic: bool, } +/// Fixed genuine ellipse: origin, canonical X/Y, semiaxes 3/2, STEP degrees 0..180. +/// Its natural NURBS interval is 0..1; there is deliberately no geometry payload. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct EllipseIn {} + /// A transform applied to a curve before the op, `repeat` times /// (`SmAxis2Placement::SetCanonical(o, x, y)`, then `SmCurve::Transform` with `scale`). #[derive(Debug, Clone, Serialize, Deserialize)] @@ -123,6 +137,7 @@ enum CurveIn { Bspline(BSplineIn), Hermite(HermiteIn), Circle(CircleIn), + Ellipse(EllipseIn), } /// `SmCurveCache` constructor arguments. @@ -252,6 +267,7 @@ fn curve_block(v: &mut Vec, c: &CurveIn) -> Result<(), HarnessError> { } } CurveIn::Circle(_) => return Err(harness("unresolved circle")), + CurveIn::Ellipse(_) => v.extend([3.0, 3.0]), } Ok(()) } @@ -399,6 +415,18 @@ fn build(c: &CurveIn) -> Result { h.dim, ))), CurveIn::Circle(_) => Err(harness("unresolved circle")), + CurveIn::Ellipse(_) => Ellipse::new( + &Kernel::default(), + Vec3::new(0.0, 0.0, 0.0), + Vec3::new(1.0, 0.0, 0.0), + Vec3::new(0.0, 1.0, 0.0), + Extent1d::from_min_max(0.0, 180.0), + 3.0, + 2.0, + &EllipseOptions::default(), + ) + .map(Curve::from) + .map_err(|e| harness(format!("ellipse: {e}"))), } } @@ -846,12 +874,26 @@ fn gen_circle(rng: &mut Rng, closed: bool) -> CurveIn { } fn gen_curve(rng: &mut Rng) -> CurveIn { + gen_curve_with_circle_origin(rng).0 +} + +fn gen_curve_with_circle_origin(rng: &mut Rng) -> (CurveIn, bool) { let s = if rng.chance(0.7) { 3.0 } else { 30.0 }; match rng.below(10) { - 0 => gen_circle(rng, false), - 1 => gen_circle(rng, true), - 2..=6 => CurveIn::Bspline(gen_bspline(rng, s)), - _ => CurveIn::Hermite(gen_hermite(rng, s)), + 0 => (gen_circle(rng, false), true), + 1 => (gen_circle(rng, true), true), + 2..=6 => (CurveIn::Bspline(gen_bspline(rng, s)), false), + _ => (CurveIn::Hermite(gen_hermite(rng, s)), false), + } +} + +// Generated circles have their X axis along global X. Stretching homogeneous X +// by exactly two gives a noncircular rational ellipse without new random draws. +fn stretch_circle_x(curve: &mut CurveIn) { + if let CurveIn::Bspline(bs) = curve { + for point in &mut bs.cpts { + point[0] *= 2.0; + } } } @@ -863,7 +905,7 @@ fn range_of(c: &CurveIn) -> (f64, f64) { bs.u.first().copied().unwrap_or(0.0), bs.u.last().copied().unwrap_or(1.0), ), - CurveIn::Hermite(_) => (0.0, 1.0), + CurveIn::Hermite(_) | CurveIn::Ellipse(_) => (0.0, 1.0), } } @@ -971,7 +1013,7 @@ fn gen_partner(k: &Kernel, rng: &mut Rng, c1: &CurveIn) -> CurveIn { h.p2 = add(h.p2, off); CurveIn::Hermite(h) } - other @ CurveIn::Circle(_) => other, + other @ (CurveIn::Circle(_) | CurveIn::Ellipse(_)) => other, } } // a curve starting at a point of the first one @@ -1105,8 +1147,11 @@ fn gen_cci(k: &Kernel, rng: &mut Rng) -> CciIn { } fn gen_property(k: &Kernel, rng: &mut Rng) -> OpIn { - let curve = gen_curve(rng); + let (mut curve, from_circle) = gen_curve_with_circle_origin(rng); let prop = rng.below(29) as i32; + if from_circle && matches!(prop, 13 | 14) { + stretch_circle_x(&mut curve); + } let (a, bb) = range_of(&curve); let t = a + (bb - a) * rng.unit(); let p = point_at(k, &curve, t); @@ -1228,6 +1273,236 @@ impl UnitDiff for Runner { mod tests { use super::*; + fn real_ellipse_fixture() -> (Case, OpIn) { + let ctx = CaseContext { root: crate::case::repo_root(), seeds: 0 }; + let cases = ctx.fixture_cases(Runner.id()).unwrap(); + let mut named = cases.into_iter().filter(|c| c.name == "real_ellipse_plane_classify_upper_arc_with_comb"); + let case = named.next().unwrap(); + assert!(named.next().is_none()); + assert!(matches!(&case.source, crate::case::CaseSource::Fixture { path, index: 0, .. } + if path == "fixtures/curve.curve_p7/analytic-plane-classification.json")); + let op = case.parse().unwrap(); + (case, op) + } + + #[test] + fn real_ellipse_fixture_discovery_wire_and_concrete_geometry() { + let (case, op) = real_ellipse_fixture(); + let file: Value = serde_json::from_str(include_str!("../../../../fixtures/curve.curve_p7/analytic-plane-classification.json")).unwrap(); + assert_eq!(file["cases"].as_array().unwrap().len(), 1); + assert_eq!(case.inputs, file["cases"][0]["inputs"]); + assert_eq!(case.inputs, serde_json::json!({ + "op": "property", "curve": {"ellipse": {}}, "xf": [], "ivl": [0.0, 1.0], + "prop": 15, "val": -1.0, "vecs": [[0.0, 1.0, 0.0], [0.0, 0.0, 0.0], [0.0, 0.0, 0.0]], + "tol": 1e-6, "comb": true + })); + let (code, input) = cpp_input(&op).unwrap(); + assert_eq!(code, Op::Property); + assert_eq!(input, vec![3.0, 3.0, 0.0, 0.0, 1.0, 15.0, 1.0, -1.0, 3.0, + 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1e-6, 1.0]); + ffi::validate(code, &input).unwrap(); + assert!(serde_json::from_value::(serde_json::json!({"ellipse": {"radius": 3}})).is_err()); + let OpIn::Property { curve: leaf, .. } = op else { panic!("property fixture") }; + assert!(matches!(resolve(&leaf).unwrap(), CurveIn::Ellipse(_))); + assert_eq!(range_of(&leaf), (0.0, 1.0)); + let curve = build(&leaf).unwrap(); + assert_eq!(curve.type_string(), "SmEllipse_TYPE"); + let solidcore::curve::curve::CurveKind::Ellipse(e) = curve.kind() else { panic!("not a real ellipse") }; + assert!(!e.is_circle()); + assert_eq!((e.x_radius(), e.y_radius()), (3.0, 2.0)); + assert!(!e.inside_out()); + assert_eq!((e.step_interval().min(), e.step_interval().max()), (0.0, 180.0)); + assert_eq!((curve.natural_interval().min(), curve.natural_interval().max()), (0.0, 1.0)); + let nurb = e.base().gw_nurb().unwrap(); + assert!(!nurb.cpts().is_empty()); + assert!(nurb.cpts().iter().all(|p| [p.x, p.y, p.z, p.w].iter().all(|x| x.is_finite()))); + assert!(nurb.knots().iter().all(|x| x.is_finite())); + for (t, expected) in [(0.0, [3.0, 0.0, 0.0]), (0.5, [0.0, 2.0, 0.0]), (1.0, [-3.0, 0.0, 0.0])] { + let p = curve.evaluate_point(&Kernel::default(), t).unwrap(); + for (actual, want) in [p.x, p.y, p.z].into_iter().zip(expected) { + assert!((actual - want).abs() <= 1e-9); + } + } + } + + #[test] + fn real_ellipse_protocol_admission_and_old_restrictions() { + // Validation only: these are not native CCI or transformed-ellipse witnesses. + let (_, op) = real_ellipse_fixture(); + let (code, input) = cpp_input(&op).unwrap(); + for end in 0..input.len() { + assert!(ffi::validate(code, &input[..end]).is_err(), "prefix {end}"); + } + for (at, value) in [(0, 3.5), (0, 4.0), (0, -1.0), (0, f64::NAN), (1, 0.0), (1, 2.0), (1, 4.0), (1, 2.5)] { + let mut invalid = input.clone(); invalid[at] = value; + assert!(ffi::validate(code, &invalid).is_err()); + } + let mut extra = input.clone(); extra.push(0.0); + assert!(ffi::validate(code, &extra).is_err()); + let mut one_vector = input.clone(); + one_vector[8] = 1.0; one_vector.drain(12..18); + assert!(ffi::validate(code, &one_vector).unwrap_err().to_string().contains("more option vectors")); + // Kind 2 is refused BEFORE reading its missing/invalid dimension, as before. + assert!(ffi::validate(Op::Property, &[2.0]).unwrap_err().to_string().contains("B-splines and Hermite")); + let hermite = CurveIn::Hermite(HermiteIn { dim: 3, p1: [0.0; 3], d1: [1.0, 0.0, 0.0], p2: [1.0; 3], d2: [1.0, 0.0, 0.0] }); + let ellipse = CurveIn::Ellipse(EllipseIn {}); + for (c1, c2) in [(ellipse.clone(), hermite.clone()), (hermite.clone(), ellipse.clone())] { + let cci = CciIn { c1, c2: Some(c2), cache1: None, cache2: None, ivl1: [0.0, 1.0], ivl2: [0.0, 1.0], sop: 0, tol: 1e-6, proj: false, ptype: 0, vecs: vec![] }; + for candidate in [OpIn::CciSolve { cci: cci.clone() }, OpIn::CciLocal { cci, leaf1: 0, leaf2: 0 }] { + let (code, input) = cpp_input(&candidate).unwrap(); ffi::validate(code, &input).unwrap(); + } + } + let mut transformed = op.clone(); + let OpIn::Property { xf, .. } = &mut transformed else { panic!("property") }; + xf.push(XformIn { o: [0.0; 3], x: [1.0, 0.0, 0.0], y: [0.0, 1.0, 0.0], scale: Some([1.0; 3]), repeat: 1 }); + let (code, input) = cpp_input(&transformed).unwrap(); ffi::validate(code, &input).unwrap(); + for (at, value) in [(2, 17.0), (3, ffi::MAX_COORD), (13, 0.0), (13, 2.0)] { + let mut invalid = input.clone(); invalid[at] = value; + assert!(ffi::validate(code, &invalid).is_err()); + } + let OpIn::Property { curve, xf, .. } = &transformed else { panic!("property") }; + apply_xf(&Kernel::default(), &mut build(curve).unwrap(), xf).unwrap(); + let mut excessive = transformed.clone(); + let OpIn::Property { xf, .. } = &mut excessive else { panic!("property") }; xf[0].repeat = 17; + let (code, input) = cpp_input(&excessive).unwrap(); assert!(ffi::validate(code, &input).is_err()); + let OpIn::Property { curve, .. } = &mut transformed else { panic!("property") }; *curve = hermite; + let (code, input) = cpp_input(&transformed).unwrap(); + assert!(ffi::validate(code, &input).unwrap_err().to_string().contains("transforms apply to B-splines only")); + } + + #[cfg(feature = "cpp")] + fn assert_real_ellipse_output(out: &Output, curve: &Curve) { + assert!(out.error.is_none() && out.rejected.is_none() && out.deferred.is_none(), "{out:?}"); + assert_eq!(out.ints["status"], [0]); + let v = &out.values["out"]; + assert_eq!(v.len(), 916); + assert!(v.iter().all(|x| x.is_finite())); + assert_eq!(&v[..9], &[1.0, 2.0, 0.0, 0.0, -1.0, -1.0, -1.0, -1.0, 0.0]); + assert_eq!((v[10], v[11], v[13]), (0.0, 0.0, 0.0)); + assert_eq!((v[14], v[465]), (150.0, 150.0)); + assert!(0.0 < v[9] && v[9] < v[12] && v[12] < 1.0); + let k = Kernel::default(); + for (t, expected_t, sign) in [(v[9], 0.1705406887010544, 1.0), (v[12], 0.8294593112989456, -1.0)] { + assert!((t - expected_t).abs() <= 1e-9 * expected_t); + let p = curve.evaluate_point(&k, t).unwrap(); + assert!((p.y - 1.0).abs() <= 1e-6); // Fixture solver residual, NOT a comparator floor. + let expected_x = sign * 3.0 * 3.0_f64.sqrt() / 2.0; + assert!((p.x - expected_x).abs() <= 1e-9 * expected_x.abs()); + assert_eq!(p.z, 0.0); + } + let middle = curve.evaluate_point(&k, (v[9] + v[12]) / 2.0).unwrap(); + assert!(middle.x.abs() <= 1e-9 && (middle.y - 2.0).abs() <= 2e-9); + assert_eq!(middle.z, 0.0); + let mut positive = false; let mut negative = false; + for i in 0..150 { + let p = &v[15 + 3*i..18 + 3*i]; + let tine = &v[466 + 3*i..469 + 3*i]; + let expected = curve.evaluate_point(&k, i as f64 / 150.0).unwrap(); + for (actual, want) in p.iter().zip([expected.x, expected.y, expected.z]) { + assert!((actual - want).abs() <= 1e-9); + } + assert_eq!(p[2], 0.0); + assert!(p[1] >= 0.0); + assert!((p[0]*p[0]/9.0 + p[1]*p[1]/4.0 - 1.0).abs() <= 1e-9); + assert!(((p[0]*p[0]/9.0 + p[1]*p[1]/4.0).sqrt() - 1.0).abs() <= 1e-9); + assert_eq!((tine[0], tine[2]), (0.0, 0.0)); + assert!((tine[1] - (1.0 - p[1])).abs() <= 1e-9); + positive |= tine[1] > 0.0; negative |= tine[1] < 0.0; + } + assert!(positive && negative); + assert!(v[240].abs() <= 1e-9 && (v[241] - 2.0).abs() <= 2e-9); + assert!((v[692] + 1.0).abs() <= 1e-9); + } + + #[test] + #[cfg(feature = "cpp")] + fn real_ellipse_plane_classify_full_native_geometry_and_comb_sensitivity() { + let (case, op) = real_ellipse_fixture(); + let native = Runner.run_cpp(&case).unwrap(); + let port = Runner.run_rust(&case).unwrap(); + let OpIn::Property { curve, .. } = op else { panic!("property") }; + let curve = build(&curve).unwrap(); + for out in [&native, &port] { assert_real_ellipse_output(out, &curve); } + assert!(Runner.abs_floors().is_empty()); + let comparison = crate::compare(&native, &port, Runner.abs_floors()); + assert!(comparison.pass(), "{comparison:?}"); + assert_eq!(comparison.values_compared, 916); + let mut perturbed = port.clone(); + perturbed.values.get_mut("out").unwrap()[692] += 1e-3; + for (i, (before, after)) in port.values["out"].iter().zip(&perturbed.values["out"]).enumerate() { + if i != 692 { assert_eq!(before, after); } + } + assert_eq!(perturbed.ints, port.ints); + assert!(!crate::compare(&native, &perturbed, Runner.abs_floors()).pass()); + } + + // Compare the complete ordered native result, including its solution count. + // Constant-curvature circles intentionally have no frozen extrema count. + fn assert_native_parity(op: &OpIn) -> (usize, f64) { + let native = cpp(op).unwrap(); + let port = rust(op).unwrap(); + for out in [&native, &port] { + assert!(out.error.is_none(), "{out:?}"); + assert!(out.deferred.is_none(), "{out:?}"); + assert!(out.rejected.is_none(), "{out:?}"); + assert_eq!(out.ints.get("status"), Some(&vec![0])); + assert!(!out.values["out"].is_empty()); + } + let comparison = crate::compare(&native, &port, &[]); + assert!(comparison.pass(), "{comparison:?}"); + assert!(comparison.values_compared > 0); + (comparison.values_compared, native.values["out"][0]) + } + + #[test] + fn flat_circle_original_gen1409_retains_full_native_parity() { + let op: OpIn = serde_json::from_str(include_str!("../../tests/data/curve_p7_flat_circle.json")).unwrap(); + assert!(assert_native_parity(&op).1 > 0.0); + } + + #[test] + fn generated_ellipse_extrema_have_stable_report_shapes() { + // Frozen after replacing the seven circle-derived extrema inputs. CI on + // Linux must reproduce these macOS shapes AND compare every native value. + // No case may become rejected, deferred or error-only. + for (index, prop, expected_values) in [ + (191, 14, 942), (510, 14, 981), (1409, 13, 929), + (1526, 13, 16), (1682, 13, 955), (2276, 14, 916), (2526, 14, 16), + ] { + let seed = CaseContext::seed_for("curve.curve_p7", index); + let input = Runner.generate(&mut Rng::new(seed)).unwrap(); + let op: OpIn = serde_json::from_value(input).unwrap(); + assert!(matches!(&op, OpIn::Property { prop: actual, .. } if *actual == prop)); + assert_eq!(assert_native_parity(&op).0, expected_values, "gen{index:04}, seed {seed}"); + } + } + + #[test] + fn stretched_circle_has_known_nonconstant_curvature_and_native_extrema() { + // Semiaxes 4 and 2: rho_min = b*b/a = 1, rho_max = a*a/b = 8. + let circle = CurveIn::Circle(CircleIn { + center: [0.0; 3], x: [1.0, 0.0, 0.0], y: [0.0, 1.0, 0.0], + radius: 2.0, a0: 0.0, a1: 360.0, quintic: false, + }); + let mut ellipse = resolve(&circle).unwrap(); + stretch_circle_x(&mut ellipse); + let curve = build(&ellipse).unwrap(); + let (a, b) = range_of(&ellipse); + for (fraction, expected) in [(0.0, 1.0), (0.25, 8.0), (0.5, 1.0), (0.75, 8.0)] { + let derivatives = curve.evaluate(&Kernel::default(), a + fraction * (b - a), 2, true, false).unwrap(); + let speed = derivatives[1].length(); + let radius = speed * speed * speed / derivatives[1].cross(derivatives[2]).length(); + assert!((radius - expected).abs() <= 1e-9 * expected, "{radius} != {expected}"); + } + for prop in [13, 14] { + let (_, solutions) = assert_native_parity(&OpIn::Property { + curve: ellipse.clone(), xf: Vec::new(), ivl: [a, b], prop, val: 0.0, + vecs: vec![[0.0; 3]; 3], tol: 1e-6, comb: true, + }); + assert!(solutions > 0.0); + } + } + /// Generated cases cover every op, with passing (not deferred) cases. #[test] fn generated_cases_are_diverse() { diff --git a/crates/difftest/src/units/curve_iso_curve.rs b/crates/difftest/src/units/curve_iso_curve.rs index 3e6007ec34..3a2d6358a9 100644 --- a/crates/difftest/src/units/curve_iso_curve.rs +++ b/crates/difftest/src/units/curve_iso_curve.rs @@ -18,6 +18,16 @@ //! (`SmSurface::CalculateContinuities`, `surface.surface_p6`), the UV iso line of a //! constant-`u` curve (`SmLine`, `curve.line`) and reading (`SmSurface::ReadFromDB`, //! `surface.surface_p2`). Every run has at least [`MIN_GENERATED`] generated cases. +//! +//! Deviations: the runner-local SMS reader rejects non-finite, non-integral, +//! negative and unrepresentable counts (the exclusive `2^usize::BITS` bound), +//! checks dimension and payload arithmetic and available tokens before +//! constructing the net, and grows storage incrementally instead of from +//! header-declared dimensions. Valid point order, weights, knots and consumed-only +//! float parsing are unchanged; unconsumed trailing text is ignored. + +#[path = "fixtures/iso_curve_sms.rs"] +mod sms; use std::fmt::Write as _; @@ -176,41 +186,14 @@ struct Inputs { /// `m+q+2` v knots. fn read_sms(path: &str) -> Result { let text = std::fs::read_to_string(path).map_err(|e| harness(format!("{path}: {e}")))?; - let mut it = text.split_whitespace(); - let mut num = || -> Result { - it.next() - .ok_or_else(|| harness(format!("{path}: truncated")))? - .parse::() - .map_err(|e| harness(format!("{path}: {e}"))) - }; - let int = |v: f64| -> Result { - if v.fract() != 0.0 || v < 0.0 { - return Err(harness(format!("{path}: bad count {v}"))); - } - Ok(v as usize) - }; - let n = int(num()?)?; - let m = int(num()?)?; - let p = int(num()?)?; - let q = int(num()?)?; - let rat = num()? != 0.0; - let mut net = Vec::with_capacity(n + 1); - for _ in 0..=n { - let mut row = Vec::with_capacity(m + 1); - for _ in 0..=m { - let (x, y, z) = (num()?, num()?, num()?); - let w = if rat { num()? } else { NOW }; - row.push([x, y, z, w]); - } - net.push(row); - } - let u = (0..n + p + 2) - .map(|_| num()) - .collect::, _>>()?; - let v = (0..m + q + 2) - .map(|_| num()) - .collect::, _>>()?; - Ok(InlineSrf { net, p, q, u, v }) + let s = sms::parse_sms(&text, path)?; + Ok(InlineSrf { + net: s.net, + p: s.p, + q: s.q, + u: s.u, + v: s.v, + }) } fn resolve(s: &SrfIn) -> Result { diff --git a/crates/difftest/src/units/fixtures/crv_on_surf_sms.rs b/crates/difftest/src/units/fixtures/crv_on_surf_sms.rs new file mode 100644 index 0000000000..ed434f53af --- /dev/null +++ b/crates/difftest/src/units/fixtures/crv_on_surf_sms.rs @@ -0,0 +1,92 @@ +//! Runner-local SMS decoding, also included by non-C++ regression tests. +//! +//! Deviations: validate counts below the exclusive `2^usize::BITS` bound, check +//! dimension and payload arithmetic and the available numeric tokens before +//! constructing the net, and grow storage only as points/knots are read. Tokens are +//! still parsed as they are consumed, so a trailing non-numeric token stays ignored +//! as before. + +use solidcore::nurbs::math::NOW; + +use crate::HarnessError; + +/// Decoded surface storage, with the v index fastest. +pub(super) struct ParsedSrf { + pub(super) net: Vec>, + pub(super) p: usize, + pub(super) q: usize, + pub(super) u: Vec, + pub(super) v: Vec, +} + +/// Decodes `n m p q`, a rational flag, points, and u/v knots. +pub(super) fn parse_sms(text: &str, path: &str) -> Result { + let error = |message: &str| HarnessError::new(format!("{path}: {message}")); + // Count the tokens actually present, never the header-declared dimensions. + let available = text.split_whitespace().count().saturating_sub(5); + let mut it = text.split_whitespace(); + let mut num = || -> Result { + it.next() + .ok_or_else(|| error("truncated"))? + .parse::() + .map_err(|e| error(&e.to_string())) + }; + let int = |value: f64, name: &str| -> Result { + // usize::MAX rounds up to 2^BITS as f64 on 64-bit hosts. Use the + // exclusive bound so that rounded, unrepresentable value is rejected too. + let upper = 2.0_f64.powi(usize::BITS as i32); + if !value.is_finite() || value.fract() != 0.0 || value < 0.0 || value >= upper { + return Err(error(&format!("bad {name} count {value}"))); + } + Ok(value as usize) + }; + let n = int(num()?, "n")?; + let m = int(num()?, "m")?; + let p = int(num()?, "p")?; + let q = int(num()?, "q")?; + let rat = num()? != 0.0; + let rows = n + .checked_add(1) + .ok_or_else(|| error("row count overflow"))?; + let cols = m + .checked_add(1) + .ok_or_else(|| error("column count overflow"))?; + let u_count = n + .checked_add(p) + .and_then(|v| v.checked_add(2)) + .ok_or_else(|| error("u knot count overflow"))?; + let v_count = m + .checked_add(q) + .and_then(|v| v.checked_add(2)) + .ok_or_else(|| error("v knot count overflow"))?; + let required = rows + .checked_mul(cols) + .and_then(|v| v.checked_mul(if rat { 4 } else { 3 })) + .and_then(|v| v.checked_add(u_count)) + .and_then(|v| v.checked_add(v_count)) + .ok_or_else(|| error("required token count overflow"))?; + if required > available { + return Err(error(&format!( + "truncated: need {required} data tokens, have {available}" + ))); + } + let mut net = Vec::new(); + for _ in 0..rows { + let mut row = Vec::new(); + for _ in 0..cols { + let (x, y, z) = (num()?, num()?, num()?); + let w = if rat { num()? } else { NOW }; + row.push([x, y, z, w]); + } + net.push(row); + } + let mut u = Vec::new(); + for _ in 0..u_count { + u.push(num()?); + } + let mut v = Vec::new(); + for _ in 0..v_count { + v.push(num()?); + } + Ok(ParsedSrf { net, p, q, u, v }) +} diff --git a/crates/difftest/src/units/fixtures/iso_curve_sms.rs b/crates/difftest/src/units/fixtures/iso_curve_sms.rs new file mode 100644 index 0000000000..7b1d0ff257 --- /dev/null +++ b/crates/difftest/src/units/fixtures/iso_curve_sms.rs @@ -0,0 +1,95 @@ +//! Runner-local SMS decoding, also included by non-C++ regression tests. +//! +//! Deviations: validate counts, check arithmetic and available whitespace tokens +//! before constructing the net, and grow storage only as points/knots are read. +//! Preserve the original runner's consumed-only float parsing; ignore trailing text. + +use solidcore::nurbs::math::NOW; + +use crate::HarnessError; + +/// Decoded surface storage, with the v index fastest. +pub(super) struct ParsedSrf { + pub(super) net: Vec>, + pub(super) p: usize, + pub(super) q: usize, + pub(super) u: Vec, + pub(super) v: Vec, +} + +/// Decodes `n m p q`, a rational flag, points, and u/v knots. +pub(super) fn parse_sms(text: &str, path: &str) -> Result { + let error = |message: &str| HarnessError::new(format!("{path}: {message}")); + let mut it = text.split_whitespace(); + // Count actual tokens without parsing or allocating: trailing text was never + // consumed by the original runner and must not become a float error here. + let available = it.clone().count().saturating_sub(5); + let mut num = || { + it.next() + .ok_or_else(|| error("truncated"))? + .parse::() + .map_err(|e| error(&e.to_string())) + }; + let int = |value: f64, name: &str| -> Result { + // usize::MAX rounds up on 64-bit hosts; use the exclusive power-of-two bound. + let upper = 2.0_f64.powi(usize::BITS as i32); + if !value.is_finite() || value.fract() != 0.0 || value < 0.0 || value >= upper { + return Err(error(&format!("bad {name} count {value}"))); + } + Ok(value as usize) + }; + let n = int(num()?, "n")?; + let m = int(num()?, "m")?; + let p = int(num()?, "p")?; + let q = int(num()?, "q")?; + let rat = num()? != 0.0; + let rows = n + .checked_add(1) + .ok_or_else(|| error("row count overflow"))?; + let cols = m + .checked_add(1) + .ok_or_else(|| error("column count overflow"))?; + let u_count = n + .checked_add(p) + .and_then(|v| v.checked_add(2)) + .ok_or_else(|| error("u knot count overflow"))?; + let v_count = m + .checked_add(q) + .and_then(|v| v.checked_add(2)) + .ok_or_else(|| error("v knot count overflow"))?; + let required = rows + .checked_mul(cols) + .and_then(|v| v.checked_mul(if rat { 4 } else { 3 })) + .and_then(|v| v.checked_add(u_count)) + .and_then(|v| v.checked_add(v_count)) + .ok_or_else(|| error("required token count overflow"))?; + if required > available { + // These tokens would all be consumed before the original reader reached + // EOF. Preserve float-error precedence without constructing any storage. + for _ in 0..available { + num()?; + } + return Err(error(&format!( + "truncated: need {required} data tokens, have {available}" + ))); + } + let mut net = Vec::new(); + for _ in 0..rows { + let mut row = Vec::new(); + for _ in 0..cols { + let (x, y, z) = (num()?, num()?, num()?); + let w = if rat { num()? } else { NOW }; + row.push([x, y, z, w]); + } + net.push(row); + } + let mut u = Vec::new(); + for _ in 0..u_count { + u.push(num()?); + } + let mut v = Vec::new(); + for _ in 0..v_count { + v.push(num()?); + } + Ok(ParsedSrf { net, p, q, u, v }) +} diff --git a/crates/difftest/src/units/fixtures/nlib_crv_adv_p1_smc.rs b/crates/difftest/src/units/fixtures/nlib_crv_adv_p1_smc.rs new file mode 100644 index 0000000000..61b73722b8 --- /dev/null +++ b/crates/difftest/src/units/fixtures/nlib_crv_adv_p1_smc.rs @@ -0,0 +1,67 @@ +//! Allocation-checked SMC decoding shared by the runner and non-C++ regression tests. + +use crate::HarnessError; +use solidcore::nurbs::math::{NOW, NOZ}; + +pub(super) struct SmcCurve { + pub(super) cpts: Vec<[f64; 4]>, + pub(super) p: usize, + pub(super) u: Vec, +} + +pub(super) fn decode(text: &str, path: &str) -> Result { + let error = |message: &str| HarnessError::new(format!("{path}: {message}")); + // The original reader eagerly parsed the entire file before inspecting any + // headers. Keep malformed-tail precedence, including ignored numeric tails. + let values = text + .split_whitespace() + .map(|token| token.parse::().map_err(|e| error(&e.to_string()))) + .collect::, _>>()?; + let mut numbers = values.into_iter(); + let mut number = || numbers.next().ok_or_else(|| error("truncated")); + let count = |value: f64| { + // usize::MAX rounds up on 64-bit targets: check the exclusive 2^BITS + // bound before casting, rather than allowing a saturating conversion. + let exclusive = 2.0_f64.powi(usize::BITS as i32); + if !value.is_finite() || value < 0.0 || value.fract() != 0.0 || value >= exclusive { + return Err(error(&format!("bad count {value}"))); + } + Ok(value as usize) + }; + let n = count(number()?)?; + let p = count(number()?)?; + let rational = number()? != 0.0; + let dim = count(number()?)?; + let overflow = || error("count overflow"); + let points = n.checked_add(1).ok_or_else(overflow)?; + let knots = n + .checked_add(p) + .and_then(|v| v.checked_add(2)) + .ok_or_else(overflow)?; + let stride = 2_usize + .checked_add(usize::from(dim == 3)) + .and_then(|v| v.checked_add(usize::from(rational))) + .ok_or_else(overflow)?; + let payload = points + .checked_mul(stride) + .and_then(|v| v.checked_add(knots)) + .ok_or_else(overflow)?; + // Preflight already-parsed numeric availability before growing any output. + // No output reservation or allocation is driven by an unchecked header. + if numbers.len() < payload { + return Err(error("truncated")); + } + let mut number = || numbers.next().ok_or_else(|| error("truncated")); + let mut cpts = Vec::new(); + for _ in 0..points { + let (x, y) = (number()?, number()?); + let z = if dim == 3 { number()? } else { NOZ }; + let w = if rational { number()? } else { NOW }; + cpts.push([x, y, z, w]); + } + let mut u = Vec::new(); + for _ in 0..knots { + u.push(number()?); + } + Ok(SmcCurve { cpts, p, u }) +} diff --git a/crates/difftest/src/units/fixtures/nlib_crv_adv_p2_smc.rs b/crates/difftest/src/units/fixtures/nlib_crv_adv_p2_smc.rs new file mode 100644 index 0000000000..61b73722b8 --- /dev/null +++ b/crates/difftest/src/units/fixtures/nlib_crv_adv_p2_smc.rs @@ -0,0 +1,67 @@ +//! Allocation-checked SMC decoding shared by the runner and non-C++ regression tests. + +use crate::HarnessError; +use solidcore::nurbs::math::{NOW, NOZ}; + +pub(super) struct SmcCurve { + pub(super) cpts: Vec<[f64; 4]>, + pub(super) p: usize, + pub(super) u: Vec, +} + +pub(super) fn decode(text: &str, path: &str) -> Result { + let error = |message: &str| HarnessError::new(format!("{path}: {message}")); + // The original reader eagerly parsed the entire file before inspecting any + // headers. Keep malformed-tail precedence, including ignored numeric tails. + let values = text + .split_whitespace() + .map(|token| token.parse::().map_err(|e| error(&e.to_string()))) + .collect::, _>>()?; + let mut numbers = values.into_iter(); + let mut number = || numbers.next().ok_or_else(|| error("truncated")); + let count = |value: f64| { + // usize::MAX rounds up on 64-bit targets: check the exclusive 2^BITS + // bound before casting, rather than allowing a saturating conversion. + let exclusive = 2.0_f64.powi(usize::BITS as i32); + if !value.is_finite() || value < 0.0 || value.fract() != 0.0 || value >= exclusive { + return Err(error(&format!("bad count {value}"))); + } + Ok(value as usize) + }; + let n = count(number()?)?; + let p = count(number()?)?; + let rational = number()? != 0.0; + let dim = count(number()?)?; + let overflow = || error("count overflow"); + let points = n.checked_add(1).ok_or_else(overflow)?; + let knots = n + .checked_add(p) + .and_then(|v| v.checked_add(2)) + .ok_or_else(overflow)?; + let stride = 2_usize + .checked_add(usize::from(dim == 3)) + .and_then(|v| v.checked_add(usize::from(rational))) + .ok_or_else(overflow)?; + let payload = points + .checked_mul(stride) + .and_then(|v| v.checked_add(knots)) + .ok_or_else(overflow)?; + // Preflight already-parsed numeric availability before growing any output. + // No output reservation or allocation is driven by an unchecked header. + if numbers.len() < payload { + return Err(error("truncated")); + } + let mut number = || numbers.next().ok_or_else(|| error("truncated")); + let mut cpts = Vec::new(); + for _ in 0..points { + let (x, y) = (number()?, number()?); + let z = if dim == 3 { number()? } else { NOZ }; + let w = if rational { number()? } else { NOW }; + cpts.push([x, y, z, w]); + } + let mut u = Vec::new(); + for _ in 0..knots { + u.push(number()?); + } + Ok(SmcCurve { cpts, p, u }) +} diff --git a/crates/difftest/src/units/fixtures/nlib_crv_basic_smc.rs b/crates/difftest/src/units/fixtures/nlib_crv_basic_smc.rs new file mode 100644 index 0000000000..a03558bfff --- /dev/null +++ b/crates/difftest/src/units/fixtures/nlib_crv_basic_smc.rs @@ -0,0 +1,66 @@ +//! Allocation-checked SMC decoding shared by the runner and non-C++ regression tests. + +use crate::HarnessError; +use solidcore::nurbs::math::{NOW, NOZ}; + +pub(super) struct SmcCurve { + pub(super) cpts: Vec<[f64; 4]>, + pub(super) p: usize, + pub(super) u: Vec, +} + +pub(super) fn decode(text: &str, path: &str) -> Result { + let error = |message: &str| HarnessError::new(format!("{path}: {message}")); + // Preserve eager lexical validation of the entire file, even ignored tails, + // before inspecting headers in their original n, p, rational, dim order. + let values = text + .split_whitespace() + .map(|token| token.parse::().map_err(|e| error(&e.to_string()))) + .collect::, _>>()?; + let mut numbers = values.into_iter(); + let mut number = || numbers.next().ok_or_else(|| error("truncated")); + let count = |value: f64| { + // usize::MAX rounds up on 64-bit targets; reject the exclusive 2^BITS + // boundary before casting rather than accepting a saturating conversion. + let exclusive = 2.0_f64.powi(usize::BITS as i32); + if !value.is_finite() || value < 0.0 || value.fract() != 0.0 || value >= exclusive { + return Err(error(&format!("bad count {value}"))); + } + Ok(value as usize) + }; + let n = count(number()?)?; + let p = count(number()?)?; + let rational = number()? != 0.0; + let dim = count(number()?)?; + let overflow = || error("count overflow"); + let points = n.checked_add(1).ok_or_else(overflow)?; + let knots = n + .checked_add(p) + .and_then(|v| v.checked_add(2)) + .ok_or_else(overflow)?; + let stride = 2_usize + .checked_add(usize::from(dim == 3)) + .and_then(|v| v.checked_add(usize::from(rational))) + .ok_or_else(overflow)?; + let payload = points + .checked_mul(stride) + .and_then(|v| v.checked_add(knots)) + .ok_or_else(overflow)?; + // Preflight numeric availability before any header-driven output growth. + if numbers.len() < payload { + return Err(error("truncated")); + } + let mut number = || numbers.next().ok_or_else(|| error("truncated")); + let mut cpts = Vec::new(); + for _ in 0..points { + let (x, y) = (number()?, number()?); + let z = if dim == 3 { number()? } else { NOZ }; + let w = if rational { number()? } else { NOW }; + cpts.push([x, y, z, w]); + } + let mut u = Vec::new(); + for _ in 0..knots { + u.push(number()?); + } + Ok(SmcCurve { cpts, p, u }) +} diff --git a/crates/difftest/src/units/fixtures/nlib_crv_basic_sms.rs b/crates/difftest/src/units/fixtures/nlib_crv_basic_sms.rs new file mode 100644 index 0000000000..4f203d049d --- /dev/null +++ b/crates/difftest/src/units/fixtures/nlib_crv_basic_sms.rs @@ -0,0 +1,82 @@ +//! Allocation-checked SMS decoding shared by the runner and non-C++ regression tests. + +use crate::HarnessError; +use solidcore::nurbs::math::NOW; + +pub(super) struct SmsSurface { + pub(super) net: Vec>, + pub(super) p: usize, + pub(super) q: usize, + pub(super) u: Vec, + pub(super) v: Vec, +} + +pub(super) fn decode(text: &str, path: &str) -> Result { + let error = |message: &str| HarnessError::new(format!("{path}: {message}")); + // Lexically validate the whole file, including ignored numeric tails, before + // interpreting headers in their original n, m, p, q, rational order. + let values = text + .split_whitespace() + .map(|token| token.parse::().map_err(|e| error(&e.to_string()))) + .collect::, _>>()?; + let mut numbers = values.into_iter(); + let mut number = || numbers.next().ok_or_else(|| error("truncated")); + let count = |value: f64| { + // usize::MAX rounds up on 64-bit targets: test the exclusive bound + // before casting, rather than allowing a saturating conversion. + let exclusive = 2.0_f64.powi(usize::BITS as i32); + if !value.is_finite() || value < 0.0 || value.fract() != 0.0 || value >= exclusive { + return Err(error(&format!("bad count {value}"))); + } + Ok(value as usize) + }; + let n = count(number()?)?; + let m = count(number()?)?; + let p = count(number()?)?; + let q = count(number()?)?; + let rational = number()? != 0.0; + let overflow = || error("count overflow"); + let rows = n.checked_add(1).ok_or_else(overflow)?; + let cols = m.checked_add(1).ok_or_else(overflow)?; + let u_knots = n + .checked_add(p) + .and_then(|v| v.checked_add(2)) + .ok_or_else(overflow)?; + let v_knots = m + .checked_add(q) + .and_then(|v| v.checked_add(2)) + .ok_or_else(overflow)?; + let stride = 3_usize + .checked_add(usize::from(rational)) + .ok_or_else(overflow)?; + let payload = rows + .checked_mul(cols) + .and_then(|v| v.checked_mul(stride)) + .and_then(|v| v.checked_add(u_knots)) + .and_then(|v| v.checked_add(v_knots)) + .ok_or_else(overflow)?; + // Actual numeric availability, not the header, bounds all output growth. + if numbers.len() < payload { + return Err(error("truncated")); + } + let mut number = || numbers.next().ok_or_else(|| error("truncated")); + let mut net = Vec::new(); + for _ in 0..rows { + let mut row = Vec::new(); + for _ in 0..cols { + let (x, y, z) = (number()?, number()?, number()?); + let w = if rational { number()? } else { NOW }; + row.push([x, y, z, w]); + } + net.push(row); + } + let mut u = Vec::new(); + for _ in 0..u_knots { + u.push(number()?); + } + let mut v = Vec::new(); + for _ in 0..v_knots { + v.push(number()?); + } + Ok(SmsSurface { net, p, q, u, v }) +} diff --git a/crates/difftest/src/units/fixtures/nlib_crv_tool_p1_smc.rs b/crates/difftest/src/units/fixtures/nlib_crv_tool_p1_smc.rs new file mode 100644 index 0000000000..a03558bfff --- /dev/null +++ b/crates/difftest/src/units/fixtures/nlib_crv_tool_p1_smc.rs @@ -0,0 +1,66 @@ +//! Allocation-checked SMC decoding shared by the runner and non-C++ regression tests. + +use crate::HarnessError; +use solidcore::nurbs::math::{NOW, NOZ}; + +pub(super) struct SmcCurve { + pub(super) cpts: Vec<[f64; 4]>, + pub(super) p: usize, + pub(super) u: Vec, +} + +pub(super) fn decode(text: &str, path: &str) -> Result { + let error = |message: &str| HarnessError::new(format!("{path}: {message}")); + // Preserve eager lexical validation of the entire file, even ignored tails, + // before inspecting headers in their original n, p, rational, dim order. + let values = text + .split_whitespace() + .map(|token| token.parse::().map_err(|e| error(&e.to_string()))) + .collect::, _>>()?; + let mut numbers = values.into_iter(); + let mut number = || numbers.next().ok_or_else(|| error("truncated")); + let count = |value: f64| { + // usize::MAX rounds up on 64-bit targets; reject the exclusive 2^BITS + // boundary before casting rather than accepting a saturating conversion. + let exclusive = 2.0_f64.powi(usize::BITS as i32); + if !value.is_finite() || value < 0.0 || value.fract() != 0.0 || value >= exclusive { + return Err(error(&format!("bad count {value}"))); + } + Ok(value as usize) + }; + let n = count(number()?)?; + let p = count(number()?)?; + let rational = number()? != 0.0; + let dim = count(number()?)?; + let overflow = || error("count overflow"); + let points = n.checked_add(1).ok_or_else(overflow)?; + let knots = n + .checked_add(p) + .and_then(|v| v.checked_add(2)) + .ok_or_else(overflow)?; + let stride = 2_usize + .checked_add(usize::from(dim == 3)) + .and_then(|v| v.checked_add(usize::from(rational))) + .ok_or_else(overflow)?; + let payload = points + .checked_mul(stride) + .and_then(|v| v.checked_add(knots)) + .ok_or_else(overflow)?; + // Preflight numeric availability before any header-driven output growth. + if numbers.len() < payload { + return Err(error("truncated")); + } + let mut number = || numbers.next().ok_or_else(|| error("truncated")); + let mut cpts = Vec::new(); + for _ in 0..points { + let (x, y) = (number()?, number()?); + let z = if dim == 3 { number()? } else { NOZ }; + let w = if rational { number()? } else { NOW }; + cpts.push([x, y, z, w]); + } + let mut u = Vec::new(); + for _ in 0..knots { + u.push(number()?); + } + Ok(SmcCurve { cpts, p, u }) +} diff --git a/crates/difftest/src/units/fixtures/nlib_crv_tool_p2_smc.rs b/crates/difftest/src/units/fixtures/nlib_crv_tool_p2_smc.rs new file mode 100644 index 0000000000..61b73722b8 --- /dev/null +++ b/crates/difftest/src/units/fixtures/nlib_crv_tool_p2_smc.rs @@ -0,0 +1,67 @@ +//! Allocation-checked SMC decoding shared by the runner and non-C++ regression tests. + +use crate::HarnessError; +use solidcore::nurbs::math::{NOW, NOZ}; + +pub(super) struct SmcCurve { + pub(super) cpts: Vec<[f64; 4]>, + pub(super) p: usize, + pub(super) u: Vec, +} + +pub(super) fn decode(text: &str, path: &str) -> Result { + let error = |message: &str| HarnessError::new(format!("{path}: {message}")); + // The original reader eagerly parsed the entire file before inspecting any + // headers. Keep malformed-tail precedence, including ignored numeric tails. + let values = text + .split_whitespace() + .map(|token| token.parse::().map_err(|e| error(&e.to_string()))) + .collect::, _>>()?; + let mut numbers = values.into_iter(); + let mut number = || numbers.next().ok_or_else(|| error("truncated")); + let count = |value: f64| { + // usize::MAX rounds up on 64-bit targets: check the exclusive 2^BITS + // bound before casting, rather than allowing a saturating conversion. + let exclusive = 2.0_f64.powi(usize::BITS as i32); + if !value.is_finite() || value < 0.0 || value.fract() != 0.0 || value >= exclusive { + return Err(error(&format!("bad count {value}"))); + } + Ok(value as usize) + }; + let n = count(number()?)?; + let p = count(number()?)?; + let rational = number()? != 0.0; + let dim = count(number()?)?; + let overflow = || error("count overflow"); + let points = n.checked_add(1).ok_or_else(overflow)?; + let knots = n + .checked_add(p) + .and_then(|v| v.checked_add(2)) + .ok_or_else(overflow)?; + let stride = 2_usize + .checked_add(usize::from(dim == 3)) + .and_then(|v| v.checked_add(usize::from(rational))) + .ok_or_else(overflow)?; + let payload = points + .checked_mul(stride) + .and_then(|v| v.checked_add(knots)) + .ok_or_else(overflow)?; + // Preflight already-parsed numeric availability before growing any output. + // No output reservation or allocation is driven by an unchecked header. + if numbers.len() < payload { + return Err(error("truncated")); + } + let mut number = || numbers.next().ok_or_else(|| error("truncated")); + let mut cpts = Vec::new(); + for _ in 0..points { + let (x, y) = (number()?, number()?); + let z = if dim == 3 { number()? } else { NOZ }; + let w = if rational { number()? } else { NOW }; + cpts.push([x, y, z, w]); + } + let mut u = Vec::new(); + for _ in 0..knots { + u.push(number()?); + } + Ok(SmcCurve { cpts, p, u }) +} diff --git a/crates/difftest/src/units/fixtures/nlib_srf_adv_p1_text.rs b/crates/difftest/src/units/fixtures/nlib_srf_adv_p1_text.rs new file mode 100644 index 0000000000..b285f3ae27 --- /dev/null +++ b/crates/difftest/src/units/fixtures/nlib_srf_adv_p1_text.rs @@ -0,0 +1,139 @@ +//! Pure SMS/SMC fixture readers for `nlib.srf_adv_p1`. +//! +//! Deviations: reject unrepresentable counts and checked size overflow, and preflight +//! the numeric payload before building geometry. All tokens are parsed eagerly, +//! including ignored numeric tails, preserving float-error precedence. + +use solidcore::nurbs::math::{NOW, NOZ}; + +#[derive(Debug, PartialEq)] +pub(crate) struct Surface { + pub(crate) net: Vec>, + pub(crate) p: usize, + pub(crate) q: usize, + pub(crate) u: Vec, + pub(crate) v: Vec, +} + +#[derive(Debug, PartialEq)] +pub(crate) struct Curve { + pub(crate) cpts: Vec<[f64; 4]>, + pub(crate) p: usize, + pub(crate) u: Vec, +} + +struct Numbers<'a> { + path: &'a str, + values: std::vec::IntoIter, +} + +impl<'a> Numbers<'a> { + fn new(path: &'a str, source: &str) -> Result { + let values = source + .split_whitespace() + .map(|t| t.parse::().map_err(|e| format!("{path}: {e}"))) + .collect::, _>>()?; + Ok(Self { + path, + values: values.into_iter(), + }) + } + + fn num(&mut self) -> Result { + self.values + .next() + .ok_or_else(|| format!("{}: truncated", self.path)) + } + + fn count(&mut self) -> Result { + let v = self.num()?; + // usize::MAX rounds up on 64-bit targets; the upper bound is exclusive. + if !v.is_finite() || v.fract() != 0.0 || v < 0.0 || v >= 2.0_f64.powi(usize::BITS as i32) { + return Err(format!("{}: bad count {v}", self.path)); + } + Ok(v as usize) + } + + fn size(&self, size: Option) -> Result { + size.ok_or_else(|| format!("{}: count overflow", self.path)) + } + + fn preflight(&self, size: usize) -> Result<(), String> { + if size > self.values.len() { + return Err(format!("{}: truncated", self.path)); + } + Ok(()) + } + + fn knots(&mut self, count: usize) -> Result, String> { + let mut knots = Vec::new(); + for _ in 0..count { + knots.push(self.num()?); + } + Ok(knots) + } +} + +pub(crate) fn parse_sms(path: &str, source: &str) -> Result { + let mut nums = Numbers::new(path, source)?; + let n = nums.count()?; + let m = nums.count()?; + let p = nums.count()?; + let q = nums.count()?; + let rat = nums.num()? != 0.0; + let rows = nums.size(n.checked_add(1))?; + let cols = nums.size(m.checked_add(1))?; + let u_count = nums.size(n.checked_add(p).and_then(|v| v.checked_add(2)))?; + let v_count = nums.size(m.checked_add(q).and_then(|v| v.checked_add(2)))?; + let stride = nums.size(3_usize.checked_add(usize::from(rat)))?; + let payload = nums.size( + rows.checked_mul(cols) + .and_then(|v| v.checked_mul(stride)) + .and_then(|v| v.checked_add(u_count)) + .and_then(|v| v.checked_add(v_count)), + )?; + nums.preflight(payload)?; + let mut net = Vec::new(); + for _ in 0..rows { + let mut row = Vec::new(); + for _ in 0..cols { + let (x, y, z) = (nums.num()?, nums.num()?, nums.num()?); + let w = if rat { nums.num()? } else { NOW }; + row.push([x, y, z, w]); + } + net.push(row); + } + let u = nums.knots(u_count)?; + let v = nums.knots(v_count)?; + Ok(Surface { net, p, q, u, v }) +} + +pub(crate) fn parse_smc(path: &str, source: &str) -> Result { + let mut nums = Numbers::new(path, source)?; + let n = nums.count()?; + let p = nums.count()?; + let rat = nums.num()? != 0.0; + let dim = nums.count()?; + let points = nums.size(n.checked_add(1))?; + let u_count = nums.size(n.checked_add(p).and_then(|v| v.checked_add(2)))?; + let stride = nums.size( + 2_usize + .checked_add(usize::from(dim == 3)) + .and_then(|v| v.checked_add(usize::from(rat))), + )?; + let payload = nums.size( + points + .checked_mul(stride) + .and_then(|v| v.checked_add(u_count)), + )?; + nums.preflight(payload)?; + let mut cpts = Vec::new(); + for _ in 0..points { + let (x, y) = (nums.num()?, nums.num()?); + let z = if dim == 3 { nums.num()? } else { NOZ }; + let w = if rat { nums.num()? } else { NOW }; + cpts.push([x, y, z, w]); + } + let u = nums.knots(u_count)?; + Ok(Curve { cpts, p, u }) +} diff --git a/crates/difftest/src/units/fixtures/nlib_srf_adv_p2_sms.rs b/crates/difftest/src/units/fixtures/nlib_srf_adv_p2_sms.rs new file mode 100644 index 0000000000..470ae7e15e --- /dev/null +++ b/crates/difftest/src/units/fixtures/nlib_srf_adv_p2_sms.rs @@ -0,0 +1,94 @@ +//! Lazy SMS decoding shared by the surface-advanced runner and non-C++ tests. +//! +//! Deviations: counts and payload sizes are checked before incremental allocation. + +use solidcore::nurbs::math::NOW; + +use crate::HarnessError; + +pub(super) struct SmsSurface { + pub net: Vec>, + pub p: usize, + pub q: usize, + pub u: Vec, + pub v: Vec, +} + +pub(super) fn parse_sms(text: &str, path: &str) -> Result { + let error = |message: &str| HarnessError::new(format!("{path}: {message}")); + let mut tokens = text.split_whitespace(); + let number = |tokens: &mut std::str::SplitWhitespace<'_>| { + tokens + .next() + .ok_or_else(|| error("truncated"))? + .parse::() + .map_err(|e| error(&e.to_string())) + }; + let count = |value: f64| { + // usize::MAX as f64 rounds up on 64-bit hosts: the limit is exclusive. + if !value.is_finite() + || value.fract() != 0.0 + || value < 0.0 + || value >= 2.0_f64.powi(usize::BITS as i32) + { + return Err(error(&format!("bad count {value}"))); + } + Ok(value as usize) + }; + let n = count(number(&mut tokens)?)?; + let m = count(number(&mut tokens)?)?; + let p = count(number(&mut tokens)?)?; + let q = count(number(&mut tokens)?)?; + let rat = number(&mut tokens)? != 0.0; + let overflow = || error("SMS size overflow"); + let rows = n.checked_add(1).ok_or_else(overflow)?; + let cols = m.checked_add(1).ok_or_else(overflow)?; + let u_count = n + .checked_add(p) + .and_then(|v| v.checked_add(2)) + .ok_or_else(overflow)?; + let v_count = m + .checked_add(q) + .and_then(|v| v.checked_add(2)) + .ok_or_else(overflow)?; + let payload = rows + .checked_mul(cols) + .and_then(|v| v.checked_mul(if rat { 4 } else { 3 })) + .and_then(|v| v.checked_add(u_count)) + .and_then(|v| v.checked_add(v_count)) + .ok_or_else(overflow)?; + + if tokens.clone().count() < payload { + // Preserve the lazy reader's first numeric error before truncation, but + // never grow a net from a short file declaring huge dimensions. + while tokens.clone().next().is_some() { + number(&mut tokens)?; + } + return Err(error("truncated")); + } + + let mut net = Vec::new(); + for _ in 0..rows { + let mut row = Vec::new(); + for _ in 0..cols { + let (x, y, z) = ( + number(&mut tokens)?, + number(&mut tokens)?, + number(&mut tokens)?, + ); + let w = if rat { number(&mut tokens)? } else { NOW }; + row.push([x, y, z, w]); + } + net.push(row); + } + let mut u = Vec::new(); + for _ in 0..u_count { + u.push(number(&mut tokens)?); + } + let mut v = Vec::new(); + for _ in 0..v_count { + v.push(number(&mut tokens)?); + } + // Trailing tokens have always been ignored, even when they are not numbers. + Ok(SmsSurface { net, p, q, u, v }) +} diff --git a/crates/difftest/src/units/fixtures/nlib_srf_adv_p3_sms.rs b/crates/difftest/src/units/fixtures/nlib_srf_adv_p3_sms.rs new file mode 100644 index 0000000000..fc0d706e3c --- /dev/null +++ b/crates/difftest/src/units/fixtures/nlib_srf_adv_p3_sms.rs @@ -0,0 +1,85 @@ +//! Pure SMS fixture decoding for `nlib.srf_adv_p3`. +//! +//! Deviations: reject counts at the exclusive usize bound before casting; check +//! all size arithmetic and actual numeric payload availability before growing +//! geometry incrementally. No geometry-validity or degree restrictions are added. + +use crate::HarnessError; +use solidcore::nurbs::math::NOW; + +pub(super) struct ParsedSurface { + pub net: Vec>, + pub p: usize, + pub q: usize, + pub u: Vec, + pub v: Vec, +} + +pub(super) fn parse_sms(text: &str, path: &str) -> Result { + // Parse the entire text first: even an ignored malformed tail must win. + let all = text + .split_whitespace() + .map(|t| { + t.parse::() + .map_err(|e| HarnessError::new(format!("{path}: {e}"))) + }) + .collect::, _>>()?; + let mut it = all.into_iter(); + let next = |it: &mut std::vec::IntoIter| { + it.next() + .ok_or_else(|| HarnessError::new(format!("{path}: truncated"))) + }; + let count = |v: f64| { + // Unlike usize::MAX as f64, this bound is exact on both 32 and 64 bits. + let exclusive = 2.0_f64.powi(usize::BITS as i32); + if !v.is_finite() || v.fract() != 0.0 || v < 0.0 || v >= exclusive { + Err(HarnessError::new(format!("{path}: bad count {v}"))) + } else { + Ok(v as usize) + } + }; + let n = count(next(&mut it)?)?; + let m = count(next(&mut it)?)?; + let p = count(next(&mut it)?)?; + let q = count(next(&mut it)?)?; + let rational = next(&mut it)? != 0.0; + let overflow = || HarnessError::new(format!("{path}: count overflow")); + let rows = n.checked_add(1).ok_or_else(overflow)?; + let cols = m.checked_add(1).ok_or_else(overflow)?; + let u_count = n + .checked_add(p) + .and_then(|v| v.checked_add(2)) + .ok_or_else(overflow)?; + let v_count = m + .checked_add(q) + .and_then(|v| v.checked_add(2)) + .ok_or_else(overflow)?; + let payload = rows + .checked_mul(cols) + .and_then(|v| v.checked_mul(if rational { 4 } else { 3 })) + .and_then(|v| v.checked_add(u_count)) + .and_then(|v| v.checked_add(v_count)) + .ok_or_else(overflow)?; + if payload > it.len() { + return Err(HarnessError::new(format!("{path}: truncated"))); + } + let mut net = Vec::new(); + for _ in 0..rows { + let mut row = Vec::new(); + for _ in 0..cols { + let (x, y, z) = (next(&mut it)?, next(&mut it)?, next(&mut it)?); + let w = if rational { next(&mut it)? } else { NOW }; + row.push([x, y, z, w]); + } + net.push(row); + } + let mut u = Vec::new(); + for _ in 0..u_count { + u.push(next(&mut it)?); + } + let mut v = Vec::new(); + for _ in 0..v_count { + v.push(next(&mut it)?); + } + Ok(ParsedSurface { net, p, q, u, v }) +} diff --git a/crates/difftest/src/units/fixtures/nlib_srf_basic_sms.rs b/crates/difftest/src/units/fixtures/nlib_srf_basic_sms.rs new file mode 100644 index 0000000000..c35abad0e8 --- /dev/null +++ b/crates/difftest/src/units/fixtures/nlib_srf_basic_sms.rs @@ -0,0 +1,94 @@ +//! SMS decoding shared by the surface-basic runner and non-C++ regression tests. +//! +//! Deviations: counts and payload sizes are checked before incremental allocation. + +use solidcore::nurbs::math::NOW; + +use crate::HarnessError; + +pub(super) struct SmsSurface { + pub net: Vec>, + pub p: usize, + pub q: usize, + pub u: Vec, + pub v: Vec, +} + +pub(super) fn parse_sms(text: &str, path: &str) -> Result { + let error = |message: &str| HarnessError::new(format!("{path}: {message}")); + let mut tokens = text.split_whitespace(); + let number = |tokens: &mut std::str::SplitWhitespace<'_>| { + tokens + .next() + .ok_or_else(|| error("truncated"))? + .parse::() + .map_err(|e| error(&e.to_string())) + }; + let count = |value: f64, name: &str| { + // usize::MAX as f64 rounds up on 64-bit hosts: the limit is exclusive. + if !value.is_finite() + || value.fract() != 0.0 + || value < 0.0 + || value >= 2.0_f64.powi(usize::BITS as i32) + { + return Err(error(&format!("bad {name} count {value}"))); + } + Ok(value as usize) + }; + let n = count(number(&mut tokens)?, "n")?; + let m = count(number(&mut tokens)?, "m")?; + let p = count(number(&mut tokens)?, "p")?; + let q = count(number(&mut tokens)?, "q")?; + let rat = number(&mut tokens)? != 0.0; + let overflow = || error("SMS size overflow"); + let rows = n.checked_add(1).ok_or_else(overflow)?; + let cols = m.checked_add(1).ok_or_else(overflow)?; + let u_count = n + .checked_add(p) + .and_then(|v| v.checked_add(2)) + .ok_or_else(overflow)?; + let v_count = m + .checked_add(q) + .and_then(|v| v.checked_add(2)) + .ok_or_else(overflow)?; + let payload = rows + .checked_mul(cols) + .and_then(|v| v.checked_mul(if rat { 4 } else { 3 })) + .and_then(|v| v.checked_add(u_count)) + .and_then(|v| v.checked_add(v_count)) + .ok_or_else(overflow)?; + + if tokens.clone().count() < payload { + // Preserve lazy parsing's first error: a bad available number precedes + // truncation. Do not allocate even when a short file declares a huge net. + while tokens.clone().next().is_some() { + number(&mut tokens)?; + } + return Err(error("truncated")); + } + + let mut net = Vec::new(); + for _ in 0..rows { + let mut row = Vec::new(); + for _ in 0..cols { + let (x, y, z) = ( + number(&mut tokens)?, + number(&mut tokens)?, + number(&mut tokens)?, + ); + let w = if rat { number(&mut tokens)? } else { NOW }; + row.push([x, y, z, w]); + } + net.push(row); + } + let mut u = Vec::new(); + for _ in 0..u_count { + u.push(number(&mut tokens)?); + } + let mut v = Vec::new(); + for _ in 0..v_count { + v.push(number(&mut tokens)?); + } + // Trailing tokens have always been ignored, even when they are not numbers. + Ok(SmsSurface { net, p, q, u, v }) +} diff --git a/crates/difftest/src/units/fixtures/nlib_srf_nurb_sms.rs b/crates/difftest/src/units/fixtures/nlib_srf_nurb_sms.rs new file mode 100644 index 0000000000..890e787f75 --- /dev/null +++ b/crates/difftest/src/units/fixtures/nlib_srf_nurb_sms.rs @@ -0,0 +1,91 @@ +//! Runner-local SMS decoding, shared with non-C++ regression tests. +//! +//! Deviations: reject unrepresentable counts and arithmetic overflow; preflight +//! the consumed numeric payload without allocation before growing storage. +//! Parsing remains lazy with respect to ignored trailing tokens. + +use solidcore::nurbs::math::NOW; + +use crate::HarnessError; + +/// Decoded surface storage, with the v index fastest. +pub(super) struct ParsedSrf { + pub(super) net: Vec>, + pub(super) p: usize, + pub(super) q: usize, + pub(super) u: Vec, + pub(super) v: Vec, +} + +/// Decodes the header, homogeneous or polynomial points, and u/v knots. +pub(super) fn parse_sms(text: &str, path: &str) -> Result { + let error = |message: &str| HarnessError::new(format!("{path}: {message}")); + let parse = |token: &str| token.parse::().map_err(|e| error(&e.to_string())); + let mut it = text.split_whitespace(); + let mut num = || parse(it.next().ok_or_else(|| error("truncated"))?); + let int = |value: f64| -> Result { + // usize::MAX as f64 rounds up on 64-bit hosts: use an exclusive bound. + let upper = 2.0_f64.powi(usize::BITS as i32); + if !value.is_finite() || value.fract() != 0.0 || value < 0.0 || value >= upper { + return Err(error(&format!("bad count {value}"))); + } + Ok(value as usize) + }; + let n = int(num()?)?; + let m = int(num()?)?; + let p = int(num()?)?; + let q = int(num()?)?; + let rat = num()? != 0.0; + let rows = n + .checked_add(1) + .ok_or_else(|| error("row count overflow"))?; + let cols = m + .checked_add(1) + .ok_or_else(|| error("column count overflow"))?; + let u_count = n + .checked_add(p) + .and_then(|v| v.checked_add(2)) + .ok_or_else(|| error("u knot count overflow"))?; + let v_count = m + .checked_add(q) + .and_then(|v| v.checked_add(2)) + .ok_or_else(|| error("v knot count overflow"))?; + let required = rows + .checked_mul(cols) + .and_then(|v| v.checked_mul(if rat { 4 } else { 3 })) + .and_then(|v| v.checked_add(u_count)) + .and_then(|v| v.checked_add(v_count)) + .ok_or_else(|| error("required token count overflow"))?; + + // Validate even a short prefix before reporting truncation, preserving the + // original first numeric error. Never parse ignored trailing text or allocate + // storage from declared sizes, including when the payload is incomplete. + let mut available = 0; + for token in it.clone().take(required) { + parse(token)?; + available += 1; + } + if available < required { + return Err(error("truncated")); + } + let mut num = || parse(it.next().ok_or_else(|| error("truncated"))?); + let mut net = Vec::new(); + for _ in 0..rows { + let mut row = Vec::new(); + for _ in 0..cols { + let (x, y, z) = (num()?, num()?, num()?); + let w = if rat { num()? } else { NOW }; + row.push([x, y, z, w]); + } + net.push(row); + } + let mut u = Vec::new(); + for _ in 0..u_count { + u.push(num()?); + } + let mut v = Vec::new(); + for _ in 0..v_count { + v.push(num()?); + } + Ok(ParsedSrf { net, p, q, u, v }) +} diff --git a/crates/difftest/src/units/fixtures/nlib_srf_offset_sms.rs b/crates/difftest/src/units/fixtures/nlib_srf_offset_sms.rs new file mode 100644 index 0000000000..dcec5e46fb --- /dev/null +++ b/crates/difftest/src/units/fixtures/nlib_srf_offset_sms.rs @@ -0,0 +1,89 @@ +//! Runner-local SMS decoding, also included by non-C++ regression tests. +//! +//! Deviations: validate counts, check arithmetic and available numeric tokens +//! before constructing the net, and grow storage only as points/knots are read. + +use solidcore::nurbs::math::NOW; + +use crate::HarnessError; + +/// Decoded surface storage, with the v index fastest. +pub(super) struct ParsedSrf { + pub(super) net: Vec>, + pub(super) p: usize, + pub(super) q: usize, + pub(super) u: Vec, + pub(super) v: Vec, +} + +/// Decodes `n m p q`, a rational flag, points, and u/v knots. +pub(super) fn parse_sms(text: &str, path: &str) -> Result { + let error = |message: &str| HarnessError::new(format!("{path}: {message}")); + // Preserve the original eager float parsing, including trailing tokens. + // This allocation depends only on actual input, never declared dimensions. + let all = text + .split_whitespace() + .map(|t| t.parse::().map_err(|e| error(&e.to_string()))) + .collect::, _>>()?; + let available = all.len().saturating_sub(5); + let mut it = all.into_iter(); + let mut num = || it.next().ok_or_else(|| error("truncated")); + let int = |value: f64, name: &str| -> Result { + // usize::MAX rounds up to 2^BITS as f64 on 64-bit hosts. Use the + // exclusive bound so that rounded, unrepresentable value is rejected too. + let upper = 2.0_f64.powi(usize::BITS as i32); + if !value.is_finite() || value.fract() != 0.0 || value < 0.0 || value >= upper { + return Err(error(&format!("bad {name} count {value}"))); + } + Ok(value as usize) + }; + let n = int(num()?, "n")?; + let m = int(num()?, "m")?; + let p = int(num()?, "p")?; + let q = int(num()?, "q")?; + let rat = num()? != 0.0; + let rows = n + .checked_add(1) + .ok_or_else(|| error("row count overflow"))?; + let cols = m + .checked_add(1) + .ok_or_else(|| error("column count overflow"))?; + let u_count = n + .checked_add(p) + .and_then(|v| v.checked_add(2)) + .ok_or_else(|| error("u knot count overflow"))?; + let v_count = m + .checked_add(q) + .and_then(|v| v.checked_add(2)) + .ok_or_else(|| error("v knot count overflow"))?; + let required = rows + .checked_mul(cols) + .and_then(|v| v.checked_mul(if rat { 4 } else { 3 })) + .and_then(|v| v.checked_add(u_count)) + .and_then(|v| v.checked_add(v_count)) + .ok_or_else(|| error("required token count overflow"))?; + if required > available { + return Err(error(&format!( + "truncated: need {required} data tokens, have {available}" + ))); + } + let mut net = Vec::new(); + for _ in 0..rows { + let mut row = Vec::new(); + for _ in 0..cols { + let (x, y, z) = (num()?, num()?, num()?); + let w = if rat { num()? } else { NOW }; + row.push([x, y, z, w]); + } + net.push(row); + } + let mut u = Vec::new(); + for _ in 0..u_count { + u.push(num()?); + } + let mut v = Vec::new(); + for _ in 0..v_count { + v.push(num()?); + } + Ok(ParsedSrf { net, p, q, u, v }) +} diff --git a/crates/difftest/src/units/fixtures/nlib_srf_tool_p1_sms.rs b/crates/difftest/src/units/fixtures/nlib_srf_tool_p1_sms.rs new file mode 100644 index 0000000000..a9046092cf --- /dev/null +++ b/crates/difftest/src/units/fixtures/nlib_srf_tool_p1_sms.rs @@ -0,0 +1,87 @@ +//! Allocation-checked SMS decoding shared by the runner and non-C++ regression tests. + +use crate::HarnessError; +use solidcore::nurbs::math::NOW; + +pub(super) struct SmsSurface { + pub(super) net: Vec>, + pub(super) p: usize, + pub(super) q: usize, + pub(super) u: Vec, + pub(super) v: Vec, +} + +pub(super) fn decode(text: &str, path: &str) -> Result { + let error = |message: &str| HarnessError::new(format!("{path}: {message}")); + let number = |tokens: &mut std::str::SplitWhitespace<'_>| { + tokens + .next() + .ok_or_else(|| error("truncated"))? + .parse::() + .map_err(|e| error(&e.to_string())) + }; + let count = |value: f64| { + // usize::MAX rounds up to 2^BITS as f64 on 64-bit targets. Use the + // exclusive power-of-two bound, not that rounded maximum, before casting. + let exclusive = 2.0_f64.powi(usize::BITS as i32); + if !value.is_finite() || value < 0.0 || value.fract() != 0.0 || value >= exclusive { + return Err(error(&format!("bad count {value}"))); + } + Ok(value as usize) + }; + let mut tokens = text.split_whitespace(); + let n = count(number(&mut tokens)?)?; + let m = count(number(&mut tokens)?)?; + let p = count(number(&mut tokens)?)?; + let q = count(number(&mut tokens)?)?; + let rational = number(&mut tokens)? != 0.0; + let overflow = || error("count overflow"); + let rows = n.checked_add(1).ok_or_else(overflow)?; + let cols = m.checked_add(1).ok_or_else(overflow)?; + let u_len = n + .checked_add(p) + .and_then(|v| v.checked_add(2)) + .ok_or_else(overflow)?; + let v_len = m + .checked_add(q) + .and_then(|v| v.checked_add(2)) + .ok_or_else(overflow)?; + let payload = rows + .checked_mul(cols) + .and_then(|v| v.checked_mul(if rational { 4 } else { 3 })) + .and_then(|v| v.checked_add(u_len)) + .and_then(|v| v.checked_add(v_len)) + .ok_or_else(overflow)?; + + // No header-sized allocation until the entire consumed payload is present. + // Parse the available prefix even on truncation, preserving the old lazy + // parser's first numeric error. Never inspect ignored trailing tokens. + let mut preflight = tokens.clone(); + for _ in 0..payload { + number(&mut preflight)?; + } + + let mut net = Vec::new(); + for _ in 0..rows { + let mut row = Vec::new(); + for _ in 0..cols { + let (x, y, z) = ( + number(&mut tokens)?, + number(&mut tokens)?, + number(&mut tokens)?, + ); + let w = if rational { number(&mut tokens)? } else { NOW }; + row.push([x, y, z, w]); + } + net.push(row); + } + let mut u = Vec::new(); + for _ in 0..u_len { + u.push(number(&mut tokens)?); + } + let mut v = Vec::new(); + for _ in 0..v_len { + v.push(number(&mut tokens)?); + } + Ok(SmsSurface { net, p, q, u, v }) +} diff --git a/crates/difftest/src/units/fixtures/nlib_srf_tool_p2_sms.rs b/crates/difftest/src/units/fixtures/nlib_srf_tool_p2_sms.rs new file mode 100644 index 0000000000..76ee7a4709 --- /dev/null +++ b/crates/difftest/src/units/fixtures/nlib_srf_tool_p2_sms.rs @@ -0,0 +1,94 @@ +//! Lazy SMS decoding shared by the surface-tool-p2 runner and non-C++ tests. +//! +//! Deviations: validate counts and checked payload sizes before incremental growth. + +use solidcore::nurbs::math::NOW; + +use crate::HarnessError; + +pub(super) struct SmsSurface { + pub net: Vec>, + pub p: usize, + pub q: usize, + pub u: Vec, + pub v: Vec, +} + +pub(super) fn parse_sms(text: &str, path: &str) -> Result { + let error = |message: &str| HarnessError::new(format!("{path}: {message}")); + let mut tokens = text.split_whitespace(); + let number = |tokens: &mut std::str::SplitWhitespace<'_>| { + tokens + .next() + .ok_or_else(|| error("truncated"))? + .parse::() + .map_err(|e| error(&e.to_string())) + }; + let count = |value: f64| { + // On 64-bit hosts usize::MAX rounds up as f64; use an exclusive limit. + if !value.is_finite() + || value.fract() != 0.0 + || value < 0.0 + || value >= 2.0_f64.powi(usize::BITS as i32) + { + return Err(error(&format!("bad count {value}"))); + } + Ok(value as usize) + }; + let n = count(number(&mut tokens)?)?; + let m = count(number(&mut tokens)?)?; + let p = count(number(&mut tokens)?)?; + let q = count(number(&mut tokens)?)?; + let rat = number(&mut tokens)? != 0.0; + let overflow = || error("SMS size overflow"); + let rows = n.checked_add(1).ok_or_else(overflow)?; + let cols = m.checked_add(1).ok_or_else(overflow)?; + let u_count = n + .checked_add(p) + .and_then(|v| v.checked_add(2)) + .ok_or_else(overflow)?; + let v_count = m + .checked_add(q) + .and_then(|v| v.checked_add(2)) + .ok_or_else(overflow)?; + let payload = rows + .checked_mul(cols) + .and_then(|v| v.checked_mul(if rat { 4 } else { 3 })) + .and_then(|v| v.checked_add(u_count)) + .and_then(|v| v.checked_add(v_count)) + .ok_or_else(overflow)?; + + // Count whitespace tokens, not parsed numbers: unused tails remain ignored. + // A short payload is scanned without allocating, preserving the first bad + // consumed number instead of hiding it behind the later truncation. + if tokens.clone().count() < payload { + while tokens.clone().next().is_some() { + number(&mut tokens)?; + } + return Err(error("truncated")); + } + + let mut net = Vec::new(); + for _ in 0..rows { + let mut row = Vec::new(); + for _ in 0..cols { + let (x, y, z) = ( + number(&mut tokens)?, + number(&mut tokens)?, + number(&mut tokens)?, + ); + let w = if rat { number(&mut tokens)? } else { NOW }; + row.push([x, y, z, w]); + } + net.push(row); + } + let mut u = Vec::new(); + for _ in 0..u_count { + u.push(number(&mut tokens)?); + } + let mut v = Vec::new(); + for _ in 0..v_count { + v.push(number(&mut tokens)?); + } + Ok(SmsSurface { net, p, q, u, v }) +} diff --git a/crates/difftest/src/units/fixtures/nurbs_b_spline_surface_n_lib_p1_sms.rs b/crates/difftest/src/units/fixtures/nurbs_b_spline_surface_n_lib_p1_sms.rs new file mode 100644 index 0000000000..ae81ee1722 --- /dev/null +++ b/crates/difftest/src/units/fixtures/nurbs_b_spline_surface_n_lib_p1_sms.rs @@ -0,0 +1,83 @@ +//! Pure SMS fixture decoder shared by the runner and non-C++ regression tests. + +use crate::HarnessError; +use solidcore::nurbs::math::NOW; + +pub(super) struct ParsedSrf { + pub net: Vec>, + pub p: usize, + pub q: usize, + pub u: Vec, + pub v: Vec, +} + +fn error(path: &str, message: impl std::fmt::Display) -> HarnessError { + HarnessError::new(format!("{path}: {message}")) +} + +fn count(value: f64, path: &str) -> Result { + if !value.is_finite() + || value < 0.0 + || value.fract() != 0.0 + || value >= 2.0_f64.powi(usize::BITS as i32) + { + return Err(error(path, format!("bad count {value}"))); + } + Ok(value as usize) +} + +pub(super) fn decode(text: &str, path: &str) -> Result { + // Eager lexical validation deliberately includes even the ignored numeric tail. + let values = text + .split_whitespace() + .map(|token| token.parse::().map_err(|e| error(path, e))) + .collect::, _>>()?; + let mut values = values.into_iter(); + let mut num = || values.next().ok_or_else(|| error(path, "truncated")); + let n = count(num()?, path)?; + let m = count(num()?, path)?; + let p = count(num()?, path)?; + let q = count(num()?, path)?; + let rational = num()? != 0.0; + let overflow = || error(path, "count overflow"); + let rows = n.checked_add(1).ok_or_else(overflow)?; + let cols = m.checked_add(1).ok_or_else(overflow)?; + let u_count = n + .checked_add(p) + .and_then(|v| v.checked_add(2)) + .ok_or_else(overflow)?; + let v_count = m + .checked_add(q) + .and_then(|v| v.checked_add(2)) + .ok_or_else(overflow)?; + let payload = rows + .checked_mul(cols) + .and_then(|v| v.checked_mul(if rational { 4 } else { 3 })) + .and_then(|v| v.checked_add(u_count)) + .and_then(|v| v.checked_add(v_count)) + .ok_or_else(overflow)?; + if payload > values.len() { + return Err(error(path, "truncated")); + } + let mut num = || values.next().ok_or_else(|| error(path, "truncated")); + // Never reserve from declared counts, even after the numeric payload preflight. + let mut net = Vec::new(); + for _ in 0..rows { + let mut row = Vec::new(); + for _ in 0..cols { + let (x, y, z) = (num()?, num()?, num()?); + let w = if rational { num()? } else { NOW }; + row.push([x, y, z, w]); + } + net.push(row); + } + let mut u = Vec::new(); + for _ in 0..u_count { + u.push(num()?); + } + let mut v = Vec::new(); + for _ in 0..v_count { + v.push(num()?); + } + Ok(ParsedSrf { net, p, q, u, v }) +} diff --git a/crates/difftest/src/units/fixtures/nurbs_b_spline_surface_n_lib_p2_sms.rs b/crates/difftest/src/units/fixtures/nurbs_b_spline_surface_n_lib_p2_sms.rs new file mode 100644 index 0000000000..863c6a12c3 --- /dev/null +++ b/crates/difftest/src/units/fixtures/nurbs_b_spline_surface_n_lib_p2_sms.rs @@ -0,0 +1,83 @@ +//! Pure SMS fixture decoder for the B-spline surface NLib p2 runner. +//! +//! Retains eager lexical parsing and stored homogeneous coordinates. Header sizes +//! are checked against actual numeric payload before any decoded output grows; +//! geometric validity remains the downstream surface constructor's responsibility. + +use crate::HarnessError; +use solidcore::nurbs::math::NOW; + +#[derive(Debug)] +pub(super) struct Decoded { + pub net: Vec>, + pub p: usize, + pub q: usize, + pub u: Vec, + pub v: Vec, +} + +pub(super) fn decode(text: &str, path: &str) -> Result { + // Parse even unused tails before inspecting headers, as the original reader did. + let values = text + .split_whitespace() + .map(|t| { + t.parse::() + .map_err(|e| HarnessError::new(format!("{path}: {e}"))) + }) + .collect::, _>>()?; + let mut it = values.into_iter(); + let truncated = || HarnessError::new(format!("{path}: truncated")); + let overflow = || HarnessError::new(format!("{path}: count overflow")); + let count = |v: f64| { + // usize::MAX as f64 rounds up on 64-bit: the boundary must be exclusive. + if !v.is_finite() || v.fract() != 0.0 || v < 0.0 || v >= 2.0_f64.powi(usize::BITS as i32) { + Err(HarnessError::new(format!("{path}: bad count {v}"))) + } else { + Ok(v as usize) + } + }; + let n = count(it.next().ok_or_else(truncated)?)?; + let m = count(it.next().ok_or_else(truncated)?)?; + let p = count(it.next().ok_or_else(truncated)?)?; + let q = count(it.next().ok_or_else(truncated)?)?; + let rat = it.next().ok_or_else(truncated)? != 0.0; + let rows = n.checked_add(1).ok_or_else(overflow)?; + let cols = m.checked_add(1).ok_or_else(overflow)?; + let points = rows.checked_mul(cols).ok_or_else(overflow)?; + let u_len = n + .checked_add(p) + .and_then(|v| v.checked_add(2)) + .ok_or_else(overflow)?; + let v_len = m + .checked_add(q) + .and_then(|v| v.checked_add(2)) + .ok_or_else(overflow)?; + let coordinates = points + .checked_mul(3 + usize::from(rat)) + .ok_or_else(overflow)?; + let payload = coordinates + .checked_add(u_len) + .and_then(|v| v.checked_add(v_len)) + .ok_or_else(overflow)?; + if payload > it.len() { + return Err(truncated()); + } + let mut num = || it.next().ok_or_else(truncated); + let mut net = Vec::new(); + for _ in 0..rows { + let mut row = Vec::new(); + for _ in 0..cols { + row.push([num()?, num()?, num()?, if rat { num()? } else { NOW }]); + } + net.push(row); + } + let mut u = Vec::new(); + for _ in 0..u_len { + u.push(num()?); + } + let mut v = Vec::new(); + for _ in 0..v_len { + v.push(num()?); + } + Ok(Decoded { net, p, q, u, v }) +} diff --git a/crates/difftest/src/units/fixtures/nurbs_b_spline_surface_p1_sms.rs b/crates/difftest/src/units/fixtures/nurbs_b_spline_surface_p1_sms.rs new file mode 100644 index 0000000000..34f038ce3d --- /dev/null +++ b/crates/difftest/src/units/fixtures/nurbs_b_spline_surface_p1_sms.rs @@ -0,0 +1,97 @@ +//! Pure, lazy numeric decoder for the p1 surface runner's SMS fixtures. +//! +//! Shape arithmetic and token availability are checked before output allocation. +//! Only consumed numbers are parsed; arbitrary trailing tokens are ignored. + +use crate::HarnessError; +use solidcore::nurbs::math::NOW; + +/// Stored (not dehomogenized) surface data, with the v index varying fastest. +#[derive(Debug)] +pub struct ParsedSms { + /// Stored coordinates and weights, with rows in u. + pub net: Vec>, + /// Degree in u, without geometry-policy restrictions. + pub p: usize, + /// Degree in v, without geometry-policy restrictions. + pub q: usize, + /// Knots in u. + pub u: Vec, + /// Knots in v. + pub v: Vec, +} + +/// Decode the consumed SMS header and payload, qualifying errors with `path`. +pub fn parse_sms(text: &str, path: &str) -> Result { + let error = |message: &str| HarnessError::new(format!("{path}: {message}")); + let mut tokens = text.split_whitespace(); + let number = |tokens: &mut std::str::SplitWhitespace<'_>| { + tokens + .next() + .ok_or_else(|| error("truncated"))? + .parse::() + .map_err(|e| error(&e.to_string())) + }; + let count = |value: f64| { + // usize::MAX rounds up on 64-bit targets, so this bound is exclusive. + let exclusive = 2.0_f64.powi(usize::BITS as i32); + if !value.is_finite() || value < 0.0 || value.fract() != 0.0 || value >= exclusive { + Err(error(&format!("bad count {value}"))) + } else { + Ok(value as usize) + } + }; + let n = count(number(&mut tokens)?)?; + let m = count(number(&mut tokens)?)?; + let p = count(number(&mut tokens)?)?; + let q = count(number(&mut tokens)?)?; + let rational = number(&mut tokens)? != 0.0; + let overflow = || error("SMS size overflow"); + let rows = n.checked_add(1).ok_or_else(overflow)?; + let cols = m.checked_add(1).ok_or_else(overflow)?; + let u_len = n + .checked_add(p) + .and_then(|v| v.checked_add(2)) + .ok_or_else(overflow)?; + let v_len = m + .checked_add(q) + .and_then(|v| v.checked_add(2)) + .ok_or_else(overflow)?; + let points = rows.checked_mul(cols).ok_or_else(overflow)?; + let coordinates = points + .checked_mul(if rational { 4 } else { 3 }) + .ok_or_else(overflow)?; + let required = coordinates + .checked_add(u_len) + .and_then(|v| v.checked_add(v_len)) + .ok_or_else(overflow)?; + if tokens.clone().count() < required { + // Preserve the first consumed malformed number ahead of a later EOF, + // without allocating any output for a short body. + for token in tokens { + token.parse::().map_err(|e| error(&e.to_string()))?; + } + return Err(error("truncated")); + } + let mut net = Vec::new(); + for _ in 0..rows { + let mut row = Vec::new(); + for _ in 0..cols { + let x = number(&mut tokens)?; + let y = number(&mut tokens)?; + let z = number(&mut tokens)?; + let w = if rational { number(&mut tokens)? } else { NOW }; + row.push([x, y, z, w]); + } + net.push(row); + } + let mut u = Vec::new(); + for _ in 0..u_len { + u.push(number(&mut tokens)?); + } + let mut v = Vec::new(); + for _ in 0..v_len { + v.push(number(&mut tokens)?); + } + Ok(ParsedSms { net, p, q, u, v }) +} diff --git a/crates/difftest/src/units/fixtures/nurbs_nurbs_srf_sms.rs b/crates/difftest/src/units/fixtures/nurbs_nurbs_srf_sms.rs new file mode 100644 index 0000000000..d0dc6a7d3a --- /dev/null +++ b/crates/difftest/src/units/fixtures/nurbs_nurbs_srf_sms.rs @@ -0,0 +1,101 @@ +//! Lazy SMS surface decoding shared by the runner and non-C++ regression tests. + +use crate::HarnessError; +use solidcore::nurbs::math::NOW; + +/// Raw SMS data; geometry validation remains the surface constructor's job. +#[derive(Debug)] +pub struct SmsSurface { + /// Homogeneous control net, v index fastest. + pub net: Vec>, + /// U degree. + pub p: usize, + /// V degree. + pub q: usize, + /// U knots. + pub u: Vec, + /// V knots. + pub v: Vec, +} + +fn number(it: &mut std::str::SplitWhitespace<'_>, path: &str) -> Result { + it.next() + .ok_or_else(|| HarnessError::new(format!("{path}: truncated")))? + .parse::() + .map_err(|e| HarnessError::new(format!("{path}: {e}"))) +} + +fn count(it: &mut std::str::SplitWhitespace<'_>, path: &str) -> Result { + let value = number(it, path)?; + // usize::MAX rounds up on 64-bit hosts: the upper bound must be exclusive. + if !value.is_finite() + || value.fract() != 0.0 + || value < 0.0 + || value >= 2.0_f64.powi(usize::BITS as i32) + { + return Err(HarnessError::new(format!("{path}: bad count {value}"))); + } + Ok(value as usize) +} + +/// Decode only the consumed prefix, ignoring even malformed trailing tokens. +pub fn parse_sms(text: &str, path: &str) -> Result { + let mut it = text.split_whitespace(); + let n = count(&mut it, path)?; + let m = count(&mut it, path)?; + let p = count(&mut it, path)?; + let q = count(&mut it, path)?; + let rational = number(&mut it, path)? != 0.0; + let overflow = || HarnessError::new(format!("{path}: SMS size overflow")); + let rows = n.checked_add(1).ok_or_else(overflow)?; + let cols = m.checked_add(1).ok_or_else(overflow)?; + let u_count = n + .checked_add(p) + .and_then(|v| v.checked_add(2)) + .ok_or_else(overflow)?; + let v_count = m + .checked_add(q) + .and_then(|v| v.checked_add(2)) + .ok_or_else(overflow)?; + let required = rows + .checked_mul(cols) + .and_then(|v| v.checked_mul(if rational { 4 } else { 3 })) + .and_then(|v| v.checked_add(u_count)) + .and_then(|v| v.checked_add(v_count)) + .ok_or_else(overflow)?; + + // Count without allocating or parsing. On short input, preserve the first + // available bad float ahead of truncation, still before allocating output. + if it.clone().count() < required { + while it.clone().next().is_some() { + number(&mut it, path)?; + } + return Err(HarnessError::new(format!("{path}: truncated"))); + } + + let mut net = Vec::new(); + for _ in 0..rows { + let mut row = Vec::new(); + for _ in 0..cols { + let x = number(&mut it, path)?; + let y = number(&mut it, path)?; + let z = number(&mut it, path)?; + let w = if rational { + number(&mut it, path)? + } else { + NOW + }; + row.push([x, y, z, w]); + } + net.push(row); + } + let mut u = Vec::new(); + for _ in 0..u_count { + u.push(number(&mut it, path)?); + } + let mut v = Vec::new(); + for _ in 0..v_count { + v.push(number(&mut it, path)?); + } + Ok(SmsSurface { net, p, q, u, v }) +} diff --git a/crates/difftest/src/units/fixtures/query_tess_sms.rs b/crates/difftest/src/units/fixtures/query_tess_sms.rs new file mode 100644 index 0000000000..17de19705e --- /dev/null +++ b/crates/difftest/src/units/fixtures/query_tess_sms.rs @@ -0,0 +1,85 @@ +//! Pure text reader for query.tess SMS fixtures; no geometry validation is performed. +//! +//! Counts are bounded by usize's exclusive upper limit. Payload checks are staged: +//! net truncation precedes U arithmetic, and U consumption precedes V arithmetic. + +use solidcore::nurbs::math::NOW; + +/// Surface data in the original homogeneous, v-fastest storage order. +#[derive(Debug)] +pub struct ParsedSurface { + /// Control net, with rows in u. + pub net: Vec>, + /// U degree. + pub p: usize, + /// V degree. + pub q: usize, + /// U knots. + pub u: Vec, + /// V knots. + pub v: Vec, +} + +fn number(it: &mut std::vec::IntoIter) -> Result { + it.next().ok_or_else(|| "truncated".to_owned()) +} + +fn count(it: &mut std::vec::IntoIter) -> Result { + let value = number(it)?; + // usize::MAX rounds UP on 64-bit, so comparison with MAX as f64 is unsafe. + let exclusive = 2.0_f64.powi(usize::BITS as i32); + if !value.is_finite() || value < 0.0 || value.fract() != 0.0 || value >= exclusive { + return Err(format!("bad count {value}")); + } + Ok(value as usize) +} + +fn knots(it: &mut std::vec::IntoIter, last: usize, degree: usize) -> Result, String> { + let len = last + .checked_add(degree) + .and_then(|n| n.checked_add(2)) + .ok_or_else(|| "count overflow".to_owned())?; + if len > it.len() { + return Err("truncated".to_owned()); + } + let mut values = Vec::new(); + for _ in 0..len { + values.push(number(it)?); + } + Ok(values) +} + +/// Parse all numeric tokens eagerly, including ignored trailing tokens, before headers. +pub fn parse(text: &str) -> Result { + let mut values = Vec::new(); + for token in text.split_whitespace() { + values.push(token.parse::().map_err(|e| e.to_string())?); + } + let mut it = values.into_iter(); + let n = count(&mut it)?; + let m = count(&mut it)?; + let p = count(&mut it)?; + let q = count(&mut it)?; + let rational = number(&mut it)? != 0.0; + // Only preflight the net here: later knot arithmetic must not mask its EOF. + let net_len = n + .checked_add(1) + .and_then(|rows| m.checked_add(1).and_then(|cols| rows.checked_mul(cols))) + .and_then(|points| points.checked_mul(if rational { 4 } else { 3 })); + if net_len.is_none_or(|len| len > it.len()) { + return Err("truncated".to_owned()); + } + let mut net = Vec::new(); + for _ in 0..=n { + let mut row = Vec::new(); + for _ in 0..=m { + let (x, y, z) = (number(&mut it)?, number(&mut it)?, number(&mut it)?); + let w = if rational { number(&mut it)? } else { NOW }; + row.push([x, y, z, w]); + } + net.push(row); + } + let u = knots(&mut it, n, p)?; + let v = knots(&mut it, m, q)?; + Ok(ParsedSurface { net, p, q, u, v }) +} diff --git a/crates/difftest/src/units/fixtures/surface_curve_bounded_surface_fixtures.rs b/crates/difftest/src/units/fixtures/surface_curve_bounded_surface_fixtures.rs new file mode 100644 index 0000000000..0a696ddc0b --- /dev/null +++ b/crates/difftest/src/units/fixtures/surface_curve_bounded_surface_fixtures.rs @@ -0,0 +1,153 @@ +//! Runner-local SMS/SMC decoding, included by non-C++ regression tests. +//! +//! Deviations: validate counts, check arithmetic and available numeric tokens +//! before constructing geometry storage, and grow it only as values are decoded. + +use solidcore::nurbs::math::NOW; + +use crate::HarnessError; + +/// Decoded surface storage, with the v index fastest. +pub(super) struct ParsedSrf { + pub(super) net: Vec>, + pub(super) p: usize, + pub(super) q: usize, + pub(super) u: Vec, + pub(super) v: Vec, +} + +/// Decoded curve storage, with coordinates and weights stored verbatim. +pub(super) struct ParsedCrv { + pub(super) dim: u64, + pub(super) cpts: Vec<[f64; 4]>, + pub(super) p: usize, + pub(super) u: Vec, +} + +fn error(path: &str, message: impl std::fmt::Display) -> HarnessError { + HarnessError::new(format!("{path}: {message}")) +} + +fn numbers(text: &str, path: &str) -> Result, HarnessError> { + // Preserve eager numeric parsing, including otherwise-unused trailing tokens. + // This allocation depends only on actual input, never declared dimensions. + text.split_whitespace() + .map(|t| t.parse::().map_err(|e| error(path, e))) + .collect() +} + +fn int(path: &str, value: f64, name: &str) -> Result { + // usize::MAX rounds up to 2^BITS as f64 on 64-bit hosts. The exclusive + // power-of-two bound rejects that unrepresentable value without saturation. + let upper = 2.0_f64.powi(usize::BITS as i32); + if !value.is_finite() || value.fract() != 0.0 || value < 0.0 || value >= upper { + return Err(error(path, format!("bad {name} count {value}"))); + } + Ok(value as usize) +} + +fn ensure_available(path: &str, required: usize, available: usize) -> Result<(), HarnessError> { + if required > available { + return Err(error( + path, + format!("truncated: need {required} data tokens, have {available}"), + )); + } + Ok(()) +} + +/// Decodes `n m p q`, a rational flag, points, and u/v knots. +pub(super) fn parse_sms(text: &str, path: &str) -> Result { + let all = numbers(text, path)?; + let available = all.len().saturating_sub(5); + let mut it = all.into_iter(); + let mut num = || it.next().ok_or_else(|| error(path, "truncated")); + let n = int(path, num()?, "n")?; + let m = int(path, num()?, "m")?; + let p = int(path, num()?, "p")?; + let q = int(path, num()?, "q")?; + let rat = num()? != 0.0; + let rows = n + .checked_add(1) + .ok_or_else(|| error(path, "row count overflow"))?; + let cols = m + .checked_add(1) + .ok_or_else(|| error(path, "column count overflow"))?; + let u_count = n + .checked_add(p) + .and_then(|v| v.checked_add(2)) + .ok_or_else(|| error(path, "u knot count overflow"))?; + let v_count = m + .checked_add(q) + .and_then(|v| v.checked_add(2)) + .ok_or_else(|| error(path, "v knot count overflow"))?; + let points = rows + .checked_mul(cols) + .ok_or_else(|| error(path, "point count overflow"))?; + let required = points + .checked_mul(if rat { 4 } else { 3 }) + .and_then(|v| v.checked_add(u_count)) + .and_then(|v| v.checked_add(v_count)) + .ok_or_else(|| error(path, "required token count overflow"))?; + ensure_available(path, required, available)?; + let mut net = Vec::new(); + for _ in 0..rows { + let mut row = Vec::new(); + for _ in 0..cols { + let (x, y, z) = (num()?, num()?, num()?); + let w = if rat { num()? } else { NOW }; + row.push([x, y, z, w]); + } + net.push(row); + } + let mut u = Vec::new(); + for _ in 0..u_count { + u.push(num()?); + } + let mut v = Vec::new(); + for _ in 0..v_count { + v.push(num()?); + } + Ok(ParsedSrf { net, p, q, u, v }) +} + +/// Decodes `n p`, a rational flag, dimension, points, and knots (version <= 32). +pub(super) fn parse_smc(text: &str, path: &str) -> Result { + let all = numbers(text, path)?; + let available = all.len().saturating_sub(4); + let mut it = all.into_iter(); + let mut num = || it.next().ok_or_else(|| error(path, "truncated")); + let n = int(path, num()?, "n")?; + let p = int(path, num()?, "p")?; + let rat = num()? != 0.0; + let dim = int(path, num()?, "dimension")? as u64; + let points = n + .checked_add(1) + .ok_or_else(|| error(path, "point count overflow"))?; + let u_count = n + .checked_add(p) + .and_then(|v| v.checked_add(2)) + .ok_or_else(|| error(path, "u knot count overflow"))?; + // Preserve the original dimension branch: exactly 2 omits z; all others read z. + let width = (if dim == 2 { 2_usize } else { 3_usize }) + .checked_add(usize::from(rat)) + .ok_or_else(|| error(path, "coordinate width overflow"))?; + let required = points + .checked_mul(width) + .and_then(|v| v.checked_add(u_count)) + .ok_or_else(|| error(path, "required token count overflow"))?; + ensure_available(path, required, available)?; + let mut cpts = Vec::new(); + for _ in 0..points { + let x = num()?; + let y = num()?; + let z = if dim == 2 { 0.0 } else { num()? }; + let w = if rat { num()? } else { NOW }; + cpts.push([x, y, z, w]); + } + let mut u = Vec::new(); + for _ in 0..u_count { + u.push(num()?); + } + Ok(ParsedCrv { dim, cpts, p, u }) +} diff --git a/crates/difftest/src/units/fixtures/surface_offset_surface_sms.rs b/crates/difftest/src/units/fixtures/surface_offset_surface_sms.rs new file mode 100644 index 0000000000..581323cf04 --- /dev/null +++ b/crates/difftest/src/units/fixtures/surface_offset_surface_sms.rs @@ -0,0 +1,81 @@ +//! Pure decoder for offset-surface SMS fixtures. +//! +//! This preserves the runner's eager lexical pass, raw weighted points and +//! net → U → V error order. Counts have an exclusive usize boundary and knot +//! additions are checked at their original consumption positions. Storage grows +//! only after reads; there are no header-derived reservations or geometry caps. + +use crate::HarnessError; +use solidcore::nurbs::math::NOW; + +/// Raw SMS data, without downstream geometry validation or adapter defaults. +pub(crate) struct ParsedSrf { + pub(crate) net: Vec>, + pub(crate) p: usize, + pub(crate) q: usize, + pub(crate) u: Vec, + pub(crate) v: Vec, +} + +fn count(path: &str, value: f64) -> Result { + let exclusive_upper = 2.0_f64.powi(usize::BITS as i32); + if !value.is_finite() || value < 0.0 || value.fract() != 0.0 || value >= exclusive_upper { + return Err(HarnessError::new(format!("{path}: bad count {value}"))); + } + Ok(value as usize) +} + +/// Checked knot length, also callable by bounded arithmetic regression tests. +pub(crate) fn knot_count( + index: usize, + degree: usize, + path: &str, + axis: &str, +) -> Result { + index + .checked_add(degree) + .and_then(|sum| sum.checked_add(2)) + .ok_or_else(|| HarnessError::new(format!("{path}: {axis} knot count overflow"))) +} + +/// Decode all numeric tokens eagerly; extra numeric values remain ignored. +pub(crate) fn parse_sms(text: &str, path: &str) -> Result { + let values = text + .split_whitespace() + .map(|token| { + token + .parse::() + .map_err(|error| HarnessError::new(format!("{path}: {error}"))) + }) + .collect::, _>>()?; + let mut values = values.into_iter(); + let mut num = || { + values + .next() + .ok_or_else(|| HarnessError::new(format!("{path}: truncated"))) + }; + let n = count(path, num()?)?; + let m = count(path, num()?)?; + let p = count(path, num()?)?; + let q = count(path, num()?)?; + let rational = num()? != 0.0; + let mut net = Vec::new(); + for _ in 0..=n { + let mut row = Vec::new(); + for _ in 0..=m { + let (x, y, z) = (num()?, num()?, num()?); + let w = if rational { num()? } else { NOW }; + row.push([x, y, z, w]); + } + net.push(row); + } + let mut u = Vec::new(); + for _ in 0..knot_count(n, p, path, "u")? { + u.push(num()?); + } + let mut v = Vec::new(); + for _ in 0..knot_count(m, q, path, "v")? { + v.push(num()?); + } + Ok(ParsedSrf { net, p, q, u, v }) +} diff --git a/crates/difftest/src/units/fixtures/surface_surface_p2_sms.rs b/crates/difftest/src/units/fixtures/surface_surface_p2_sms.rs new file mode 100644 index 0000000000..8a64a06700 --- /dev/null +++ b/crates/difftest/src/units/fixtures/surface_surface_p2_sms.rs @@ -0,0 +1,85 @@ +//! Pure runner-local SMS decoding, shared with non-C++ regression tests. +//! +//! Deviations: checked counts/arithmetic and actual numeric payload preflight before +//! incremental net/knot construction. Preserve eager float parsing (including tails) +//! before header validation; numeric tails remain ignored. No geometry validity caps. + +use solidcore::nurbs::math::NOW; + +use crate::HarnessError; + +/// Decoded storage, independent of the C++-gated runner; v index is fastest. +pub(super) struct ParsedSurface { + pub(super) net: Vec>, + pub(super) p: usize, + pub(super) q: usize, + pub(super) u: Vec, + pub(super) v: Vec, +} + +/// Parses all floats before interpreting `n m p q rat`, points, and u/v knots. +pub(super) fn parse_sms(text: &str, path: &str) -> Result { + let error = |message: &str| HarnessError::new(format!("{path}: {message}")); + let values = text + .split_whitespace() + .map(|token| token.parse::().map_err(|e| error(&e.to_string()))) + .collect::, _>>()?; + let available = values.len().saturating_sub(5); + let mut it = values.into_iter(); + let mut num = || it.next().ok_or_else(|| error("truncated")); + let count = |value: f64| { + // usize::MAX rounds upward on 64-bit hosts. The upper bound is exclusive. + let upper = 2.0_f64.powi(usize::BITS as i32); + if !value.is_finite() || value.fract() != 0.0 || value < 0.0 || value >= upper { + return Err(error(&format!("bad count {value}"))); + } + Ok(value as usize) + }; + let n = count(num()?)?; + let m = count(num()?)?; + let p = count(num()?)?; + let q = count(num()?)?; + let rat = num()? != 0.0; + let rows = n + .checked_add(1) + .ok_or_else(|| error("row count overflow"))?; + let cols = m + .checked_add(1) + .ok_or_else(|| error("column count overflow"))?; + let u_count = n + .checked_add(p) + .and_then(|v| v.checked_add(2)) + .ok_or_else(|| error("u knot count overflow"))?; + let v_count = m + .checked_add(q) + .and_then(|v| v.checked_add(2)) + .ok_or_else(|| error("v knot count overflow"))?; + let required = rows + .checked_mul(cols) + .and_then(|v| v.checked_mul(if rat { 4 } else { 3 })) + .and_then(|v| v.checked_add(u_count)) + .and_then(|v| v.checked_add(v_count)) + .ok_or_else(|| error("required token count overflow"))?; + if required > available { + return Err(error("truncated")); + } + let mut net = Vec::new(); + for _ in 0..rows { + let mut row = Vec::new(); + for _ in 0..cols { + let (x, y, z) = (num()?, num()?, num()?); + let w = if rat { num()? } else { NOW }; + row.push([x, y, z, w]); + } + net.push(row); + } + let mut u = Vec::new(); + for _ in 0..u_count { + u.push(num()?); + } + let mut v = Vec::new(); + for _ in 0..v_count { + v.push(num()?); + } + Ok(ParsedSurface { net, p, q, u, v }) +} diff --git a/crates/difftest/src/units/fixtures/surface_surface_p5_sms.rs b/crates/difftest/src/units/fixtures/surface_surface_p5_sms.rs new file mode 100644 index 0000000000..6afa19e195 --- /dev/null +++ b/crates/difftest/src/units/fixtures/surface_surface_p5_sms.rs @@ -0,0 +1,81 @@ +//! Private SMS fixture reader shared by the P5 runner and non-C++ tests. +//! +//! Deviations: reject unrepresentable counts and overflowing knot lengths rather +//! than saturating casts or overflowing arithmetic. Preserve eager float parsing +//! and the original net, U-knot, V-knot processing/error order. + +use crate::HarnessError; +use solidcore::nurbs::math::NOW; + +pub(crate) struct ParsedSurface { + pub(crate) net: Vec>, + pub(crate) p: usize, + pub(crate) q: usize, + pub(crate) u: Vec, + pub(crate) v: Vec, +} + +pub(crate) fn read_sms(path: &str) -> Result { + let text = + std::fs::read_to_string(path).map_err(|e| HarnessError::new(format!("{path}: {e}")))?; + parse_sms(path, &text) +} + +fn count(path: &str, value: f64) -> Result { + // usize::MAX rounds up on 64-bit hosts. Use the exclusive power-of-two + // bound instead, so a saturating cast can never accept that rounded value. + let exclusive_bound = 2.0_f64.powi(usize::BITS as i32); + if !value.is_finite() || value.fract() != 0.0 || value < 0.0 || value >= exclusive_bound { + return Err(HarnessError::new(format!("{path}: bad count {value}"))); + } + Ok(value as usize) +} + +fn knot_count(path: &str, n: usize, degree: usize) -> Result { + n.checked_add(degree) + .and_then(|sum| sum.checked_add(2)) + .ok_or_else(|| HarnessError::new(format!("{path}: knot count overflow"))) +} + +pub(crate) fn parse_sms(path: &str, text: &str) -> Result { + // Even ignored tails must be lexically valid, before any header validation. + let values = text + .split_whitespace() + .map(|token| { + token + .parse::() + .map_err(|e| HarnessError::new(format!("{path}: {e}"))) + }) + .collect::, _>>()?; + let mut it = values.into_iter(); + let mut num = || { + it.next() + .ok_or_else(|| HarnessError::new(format!("{path}: truncated"))) + }; + let n = count(path, num()?)?; + let m = count(path, num()?)?; + let p = count(path, num()?)?; + let q = count(path, num()?)?; + let rational = num()? != 0.0; + let mut net = Vec::new(); + for _ in 0..=n { + let mut row = Vec::new(); + for _ in 0..=m { + let (x, y, z) = (num()?, num()?, num()?); + let w = if rational { num()? } else { NOW }; + row.push([x, y, z, w]); + } + net.push(row); + } + // Compute each length only at its original processing position. In + // particular, V overflow must not mask missing U data (nor net truncation). + let mut u = Vec::new(); + for _ in 0..knot_count(path, n, p)? { + u.push(num()?); + } + let mut v = Vec::new(); + for _ in 0..knot_count(path, m, q)? { + v.push(num()?); + } + Ok(ParsedSurface { net, p, q, u, v }) +} diff --git a/crates/difftest/src/units/fixtures/surface_surface_p6_sms.rs b/crates/difftest/src/units/fixtures/surface_surface_p6_sms.rs new file mode 100644 index 0000000000..cbe7c299a3 --- /dev/null +++ b/crates/difftest/src/units/fixtures/surface_surface_p6_sms.rs @@ -0,0 +1,77 @@ +//! Pure SMS reader shared by the surface P6 runner and non-C++ tests. + +use solidcore::nurbs::math::NOW; + +use crate::HarnessError; + +pub(crate) struct ParsedSurface { + pub(crate) net: Vec>, + pub(crate) p: usize, + pub(crate) q: usize, + pub(crate) u: Vec, + pub(crate) v: Vec, +} + +fn count(path: &str, value: f64) -> Result { + // usize::MAX rounds up to 2^BITS on 64-bit targets: compare exclusively. + let upper = 2.0_f64.powi(usize::BITS as i32); + if !value.is_finite() || value.fract() != 0.0 || value < 0.0 || value >= upper { + return Err(HarnessError::new(format!("{path}: bad count {value}"))); + } + Ok(value as usize) +} + +pub(crate) fn knot_count( + last: usize, + degree: usize, + axis: &str, + path: &str, +) -> Result { + last.checked_add(degree) + .and_then(|sum| sum.checked_add(2)) + .ok_or_else(|| HarnessError::new(format!("{path}: {axis} knot count overflow"))) +} + +pub(crate) fn parse_sms(text: &str, path: &str) -> Result { + // Deliberately eager: even a malformed ignored tail precedes header errors. + let values = text + .split_whitespace() + .map(|token| { + token + .parse::() + .map_err(|error| HarnessError::new(format!("{path}: {error}"))) + }) + .collect::, _>>()?; + let mut values = values.into_iter(); + let mut num = || { + values + .next() + .ok_or_else(|| HarnessError::new(format!("{path}: truncated"))) + }; + let n = count(path, num()?)?; + let m = count(path, num()?)?; + let p = count(path, num()?)?; + let q = count(path, num()?)?; + let rational = num()? != 0.0; + // Grow only after actual reads, never from declared sizes or iterator hints. + let mut net = Vec::new(); + for _ in 0..=n { + let mut row = Vec::new(); + for _ in 0..=m { + let (x, y, z) = (num()?, num()?, num()?); + let w = if rational { num()? } else { NOW }; + row.push([x, y, z, w]); + } + net.push(row); + } + // Preserve net-before-U-before-V error precedence, including arithmetic. + let mut u = Vec::new(); + for _ in 0..knot_count(n, p, "u", path)? { + u.push(num()?); + } + let mut v = Vec::new(); + for _ in 0..knot_count(m, q, "v", path)? { + v.push(num()?); + } + Ok(ParsedSurface { net, p, q, u, v }) +} diff --git a/crates/difftest/src/units/fixtures/surface_surface_p7_sms.rs b/crates/difftest/src/units/fixtures/surface_surface_p7_sms.rs new file mode 100644 index 0000000000..8d406104c3 --- /dev/null +++ b/crates/difftest/src/units/fixtures/surface_surface_p7_sms.rs @@ -0,0 +1,67 @@ +//! Standalone decoder for the P7 runner's SMS text input. +//! +//! Counts are rounded through f64 as before, but must fit the exclusive usize +//! bound. Knot additions are checked at their original processing positions: +//! net first, then u sizing/consumption, then v sizing/consumption. These formerly +//! overflowing sizes now return errors. Geometry validation remains downstream. + +/// Text payload, before the adapter supplies the B-spline form and oob defaults. +#[derive(Debug, PartialEq)] +pub(crate) struct Parsed { + pub(crate) net: Vec>, + pub(crate) p: usize, + pub(crate) q: usize, + pub(crate) u: Vec, + pub(crate) v: Vec, +} + +fn count(path: &str, value: f64) -> Result { + // Unlike usize::MAX as f64, this is exclusive even when MAX rounds upward. + let exclusive = 2.0_f64.powi(usize::BITS as i32); + if !value.is_finite() || value.fract() != 0.0 || value < 0.0 || value >= exclusive { + return Err(format!("{path}: bad count {value}")); + } + Ok(value as usize) +} + +/// Checked left-to-right knot sizing, shared with the focused arithmetic tests. +pub(crate) fn knot_count(path: &str, n: usize, p: usize) -> Result { + n.checked_add(p) + .and_then(|sum| sum.checked_add(2)) + .ok_or_else(|| format!("{path}: knot count overflow")) +} + +/// Decode all floats eagerly, retaining path-qualified errors and numeric tails. +/// `now` is the production NOW sentinel for nonrational control points. +pub(crate) fn parse(path: &str, text: &str, now: f64) -> Result { + let values = text + .split_whitespace() + .map(|token| token.parse::().map_err(|e| format!("{path}: {e}"))) + .collect::, _>>()?; + let mut it = values.into_iter(); + let mut num = || it.next().ok_or_else(|| format!("{path}: truncated")); + let n = count(path, num()?)?; + let m = count(path, num()?)?; + let p = count(path, num()?)?; + let q = count(path, num()?)?; + let rational = num()? != 0.0; + let mut net = Vec::new(); + for _ in 0..=n { + let mut row = Vec::new(); + for _ in 0..=m { + let (x, y, z) = (num()?, num()?, num()?); + let w = if rational { num()? } else { now }; + row.push([x, y, z, w]); + } + net.push(row); + } + let mut u = Vec::new(); + for _ in 0..knot_count(path, n, p)? { + u.push(num()?); + } + let mut v = Vec::new(); + for _ in 0..knot_count(path, m, q)? { + v.push(num()?); + } + Ok(Parsed { net, p, q, u, v }) +} diff --git a/crates/difftest/src/units/fixtures/topology_brep_records.rs b/crates/difftest/src/units/fixtures/topology_brep_records.rs new file mode 100644 index 0000000000..46e6f55cd7 --- /dev/null +++ b/crates/difftest/src/units/fixtures/topology_brep_records.rs @@ -0,0 +1,138 @@ +//! Runner-local native B-rep record decoding, shared with non-C++ regression tests. +//! +//! Deviations: validate counts before conversion, bound declared records and lists +//! by available tokens before iteration, and grow storage incrementally. Valid +//! record order, integer conversion and floating-point bits remain unchanged. + +use crate::HarnessError; + +/// A plain integer, record link, ordered link list, or real field. +#[derive(Clone, Copy)] +enum Slot { + I, + R(usize), + L(usize), + F, +} +use Slot::{F, I, L, R}; + +/// Fields in the shim's `emit_records` order, one layout per record kind. +const LAYOUT: [&[Slot]; 10] = [ + // region: in B-rep, shells + &[I, L(1)], + // shell: region, type, list head present, face/edge/vertex use heads + &[R(0), I, I, R(2), R(6), R(8)], + // face use: shell, face, mate, orientation, loop uses + &[R(1), R(3), R(2), I, L(4)], + // face: face use, domain, trim, tolerance, surface flags, natural domain, + // evaluated flag and centre point + &[R(2), F, F, F, F, I, F, I, I, F, F, F, F, I, F, F, F], + // loop use: face use, loop, mate, start type, edge/vertex use, orientation + &[R(2), R(5), R(4), I, R(6), R(8), I], + // loop: loop use + &[R(4)], + // edge use: edge, owner type, loop use, shell, cw, ccw, vertex use, + // orientation, UV trim present + &[R(7), I, R(4), R(1), R(6), R(6), R(8), I, I], + // edge: tolerance, interval, edge uses, curve present, natural interval + &[F, F, F, L(6), I, F, F], + // vertex use: vertex, owner type, edge use, loop use, shell, UV point + &[R(9), I, R(6), R(4), R(1), F, F], + // vertex: point, tolerance, vertex uses + &[F, F, F, F, L(8)], +]; + +/// One record, retaining labels, ordered integer links and real values. +#[derive(Debug, Clone, PartialEq)] +pub(super) struct Rec { + pub(super) label: i64, + pub(super) ints: Vec, + pub(super) links: Vec>, + pub(super) floats: Vec, +} + +impl Rec { + pub(super) fn new(label: i64) -> Self { + Self { + label, + ints: Vec::new(), + links: Vec::new(), + floats: Vec::new(), + } + } + pub(super) fn i(&mut self, x: i64) { + self.ints.push(x); + self.links.push(None); + } + pub(super) fn r(&mut self, kind: usize, x: i64) { + self.ints.push(x); + self.links.push(Some(kind)); + } + pub(super) fn l(&mut self, kind: usize, xs: &[i64]) { + self.i(xs.len() as i64); + for &x in xs { + self.r(kind, x); + } + } + pub(super) fn f(&mut self, x: f64) { + self.floats.push(x); + } +} + +/// Records in the shim's ten-kind walk order. +pub(super) type Model = [Vec; 10]; + +fn count(value: f64, name: &str) -> Result { + // usize::MAX rounds up on 64-bit hosts; use the exclusive power-of-two bound. + let upper = 2.0_f64.powi(usize::BITS as i32); + if !value.is_finite() || value < 0.0 || value.fract() != 0.0 || value >= upper { + return Err(HarnessError::new(format!("bad {name} count {value}"))); + } + Ok(value as usize) +} + +fn next(input: &mut std::slice::Iter<'_, f64>) -> Result { + input + .next() + .copied() + .ok_or_else(|| HarnessError::new("short record output")) +} + +/// Decodes the records the shim wrote, rejecting malformed counts and tails. +pub(super) fn parse_records(v: &[f64]) -> Result { + let mut input = v.iter(); + let mut model: Model = Default::default(); + for (slots, out) in LAYOUT.iter().zip(model.iter_mut()) { + let n = count(next(&mut input)?, "record")?; + // Each record needs its label and at least one token per layout slot + // (including the count of any empty list). Division avoids overflow. + if n > input.len() / (slots.len() + 1) { + return Err(HarnessError::new("short record output: record count")); + } + for _ in 0..n { + let mut rec = Rec::new(next(&mut input)? as i64); + for slot in *slots { + match *slot { + I => rec.i(next(&mut input)? as i64), + R(k) => rec.r(k, next(&mut input)? as i64), + L(k) => { + let count = count(next(&mut input)?, "list")?; + if count > input.len() { + return Err(HarnessError::new("short record output: list count")); + } + rec.i(count as i64); + for _ in 0..count { + rec.r(k, next(&mut input)? as i64); + } + } + F => rec.f(next(&mut input)?), + } + } + out.push(rec); + } + } + if !input.as_slice().is_empty() { + return Err(HarnessError::new("trailing record output")); + } + Ok(model) +} diff --git a/crates/difftest/src/units/fixtures/trim_srf_cache_sms.rs b/crates/difftest/src/units/fixtures/trim_srf_cache_sms.rs new file mode 100644 index 0000000000..0fc7cb5515 --- /dev/null +++ b/crates/difftest/src/units/fixtures/trim_srf_cache_sms.rs @@ -0,0 +1,88 @@ +//! Runner-local SMS decoding, also included by non-C++ regression tests. +//! +//! Deviations: validate counts, check arithmetic and available numeric tokens +//! before constructing the net, and grow storage only as points/knots are read. + +use solidcore::nurbs::math::NOW; + +use crate::HarnessError; + +/// Decoded surface storage, with the v index fastest. +pub(super) struct ParsedSrf { + pub(super) net: Vec>, + pub(super) p: usize, + pub(super) q: usize, + pub(super) u: Vec, + pub(super) v: Vec, +} + +/// Decodes `n m p q`, a rational flag, points, and u/v knots. +pub(super) fn parse_sms(text: &str, path: &str) -> Result { + let error = |message: &str| HarnessError::new(format!("{path}: {message}")); + // Preserve eager float parsing, including trailing tokens. This allocation + // depends only on actual input, never on header-declared dimensions. + let all = text + .split_whitespace() + .map(|t| t.parse::().map_err(|e| error(&e.to_string()))) + .collect::, _>>()?; + let available = all.len().saturating_sub(5); + let mut it = all.into_iter(); + let mut num = || it.next().ok_or_else(|| error("truncated")); + let int = |value: f64, name: &str| -> Result { + // usize::MAX rounds up on 64-bit hosts; use the exclusive power-of-two bound. + let upper = 2.0_f64.powi(usize::BITS as i32); + if !value.is_finite() || value.fract() != 0.0 || value < 0.0 || value >= upper { + return Err(error(&format!("bad {name} count {value}"))); + } + Ok(value as usize) + }; + let n = int(num()?, "n")?; + let m = int(num()?, "m")?; + let p = int(num()?, "p")?; + let q = int(num()?, "q")?; + let rat = num()? != 0.0; + let rows = n + .checked_add(1) + .ok_or_else(|| error("row count overflow"))?; + let cols = m + .checked_add(1) + .ok_or_else(|| error("column count overflow"))?; + let u_count = n + .checked_add(p) + .and_then(|v| v.checked_add(2)) + .ok_or_else(|| error("u knot count overflow"))?; + let v_count = m + .checked_add(q) + .and_then(|v| v.checked_add(2)) + .ok_or_else(|| error("v knot count overflow"))?; + let required = rows + .checked_mul(cols) + .and_then(|v| v.checked_mul(if rat { 4 } else { 3 })) + .and_then(|v| v.checked_add(u_count)) + .and_then(|v| v.checked_add(v_count)) + .ok_or_else(|| error("required token count overflow"))?; + if required > available { + return Err(error(&format!( + "truncated: need {required} data tokens, have {available}" + ))); + } + let mut net = Vec::new(); + for _ in 0..rows { + let mut row = Vec::new(); + for _ in 0..cols { + let (x, y, z) = (num()?, num()?, num()?); + let w = if rat { num()? } else { NOW }; + row.push([x, y, z, w]); + } + net.push(row); + } + let mut u = Vec::new(); + for _ in 0..u_count { + u.push(num()?); + } + let mut v = Vec::new(); + for _ in 0..v_count { + v.push(num()?); + } + Ok(ParsedSrf { net, p, q, u, v }) +} diff --git a/crates/difftest/src/units/geom_attribute.rs b/crates/difftest/src/units/geom_attribute.rs index 47359519e8..f8a05bc609 100644 --- a/crates/difftest/src/units/geom_attribute.rs +++ b/crates/difftest/src/units/geom_attribute.rs @@ -36,6 +36,8 @@ //! - `read_indexed`: skipped unless the stream is good and (ASCII) has a //! non-whitespace byte left, or (binary) holds a whole record with count and //! indices `<= 2^20` (upstream reads uninitialised values otherwise). +//! Failed ASCII index extraction is compared as `SM_ERR`, not skipped: upstream's +//! line skip returns before using the indices, and the shim discards them. //! //! C++ does not expose deletion, so the shim *predicts* which handles die from the //! state before each deleting op (the Rust side reports its store's real liveness). @@ -642,6 +644,23 @@ impl RustRun { let res = self .store .read_indexed_attributes_from_db(&mut self.all, reader); + // The safe reader rejects an index that C++ leaves unwritten (or + // writes with failbit). Upstream then reaches GOTO_NEXT_ATTLINE: + // get() fails and returns SM_ERR before any index is used. The + // shim discards those indices on error, so compare that defined + // error path rather than skipping it or inventing index values. + if matches!( + &res, + Err(SolidError::InvalidInput { + operation: "geom::attribute::AttributeStore::read_indexed_attributes_from_db", + reason: "attribute index could not be extracted", + }) + ) { + if let Some(stream) = reader.in_stream_mut().filter(|s| s.fail()) { + let _ = stream.get(); + return Ok(Step::Status(1001)); + } + } for (i, slot) in self.all.iter().enumerate() { if let Some(h) = slot { if before.get(i).copied().flatten().is_none() { @@ -1424,6 +1443,22 @@ impl UnitDiff for Runner { mod tests { use super::*; + #[test] + fn indexed_extraction_failure_compares_native_error_without_placeholders() { + for data in ["2 4\nabc\n", "2 4\n", "3 1 tail\n"] { + let v = serde_json::json!({ + "n_objects": 1, + "ops": [["read_indexed"], ["read_indexed"]], + "stream": {"data": data.as_bytes(), "file_type": 0, "byte_swap": false}, + }); + let rust = run_rust_case(&v).unwrap(); + assert_eq!(rust.ints["status"], vec![1001, ST_SKIP], "{data:?}"); + assert!(rust.ints["placeholders"].is_empty(), "{data:?}"); + let cpp = run_cpp_case(&v).unwrap(); + assert_eq!(rust, cpp, "{data:?}"); + } + } + /// Every op runs (status 0) in a good share of generated scripts, so skips and /// unresolved references do not hide parts of the unit. #[test] diff --git a/crates/difftest/src/units/geom_hash_table.rs b/crates/difftest/src/units/geom_hash_table.rs index 27eca03f94..0cddf6001e 100644 --- a/crates/difftest/src/units/geom_hash_table.rs +++ b/crates/difftest/src/units/geom_hash_table.rs @@ -637,6 +637,10 @@ fn apply(m: &mut Model, upd: &mut Vec, op: &Op) -> solidcore::Result<()> } impl UnitDiff for Runner { + fn reference_platform(&self) -> Option { + Some(crate::platform::X86_64_HASH_CASTS) + } + fn id(&self) -> &'static str { "geom.hash_table" } diff --git a/crates/difftest/src/units/geom_tree.rs b/crates/difftest/src/units/geom_tree.rs index 5f901b0aa3..9328757758 100644 --- a/crates/difftest/src/units/geom_tree.rs +++ b/crates/difftest/src/units/geom_tree.rs @@ -16,7 +16,9 @@ //! degrees and knots selected by `first_patch`). Construction patches and temporary //! subdivision surfaces carry distinct rational bilinear payloads; Rust supplies //! those same payloads through the tree's existing caller-owned resolvers. -//! Every run has at least [`MIN_GENERATED`] generated cases. +//! Every run has at least [`MIN_GENERATED`] generated cases. Additional +//! `patch_lookup` fixtures compare the selected pool identity and the actual NURB, +//! including distinct patches with identical geometry. use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -107,6 +109,14 @@ enum DOp { }, } +#[derive(Debug, Clone, Serialize, Deserialize)] +struct PatchNode { + parent: i64, + aux: i64, + patch: i64, + surface: i64, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] enum Inputs { @@ -120,6 +130,129 @@ enum Inputs { #[serde(default = "default_patch_scale")] patch_scale: f64, }, + PatchLookup { + surfaces: Vec<[[f64; 4]; 4]>, + owner: i64, + nodes: Vec, + queries: Vec, + }, +} + +fn patch_program( + surfaces: &[[[f64; 4]; 4]], + owner: i64, + nodes: &[PatchNode], + queries: &[usize], +) -> Vec { + let mut out = vec![surfaces.len() as f64, owner as f64]; + out.extend(surfaces.iter().flatten().flatten().copied()); + out.push(nodes.len() as f64); + for n in nodes { + out.extend([ + n.parent as f64, + n.aux as f64, + n.patch as f64, + n.surface as f64, + ]); + } + out.push(queries.len() as f64); + out.extend(queries.iter().map(|&q| q as f64)); + out +} + +fn rust_patch_lookup( + surfaces: &[[[f64; 4]; 4]], + owner: i64, + nodes: &[PatchNode], + queries: &[usize], +) -> solidcore::Result> { + use solidcore::nurbs::{math::CPoint, srf_basic::Surface}; + let surfaces = surfaces + .iter() + .map(|points| { + Surface::new( + points + .as_chunks::<2>() + .0 + .iter() + .map(|row| { + row.iter() + .map(|p| CPoint::new(p[0], p[1], p[2], p[3])) + .collect() + }) + .collect(), + 1, + 1, + vec![0., 0., 1., 1.], + vec![0., 0., 1., 1.], + ) + }) + .collect::>>()?; + let mut tree: Tree = Tree::new(None); + tree.set_owner_object(obj(owner).map(|_| TreeOwner::SurfaceCache)); + tree.set_owner_surface(obj(owner)); + for n in nodes { + let id = tree.add_node(TreeNode { + parent: link(n.parent), + aux_type: AuxDataType::from_code(n.aux as i32).unwrap_or_default(), + data: NodeData::Surface(SurfaceAux { + surface: obj(n.surface), + ..SurfaceAux::default() + }), + ..TreeNode::new() + }); + if let Some(parent) = link(n.parent) { + let p = tree.node_mut(parent)?; + if p.child1.is_none() { + p.child1 = Some(id); + } else { + p.child2 = Some(id); + } + } + } + tree.set_top_node(Some(NodeId(0))); + let mut out = Vec::new(); + for &query in queries { + let selected = tree.first_bezier_patch( + NodeId(query), + |n| { + let node = nodes.get(n.0).ok_or_else(|| { + SolidError::invalid_input("difftest::patch_lookup", "node index") + })?; + obj(node.patch) + .map(|i| { + surfaces.get(i as usize).map(|s| (i, s)).ok_or_else(|| { + SolidError::invalid_input("difftest::patch_lookup", "patch index") + }) + }) + .transpose() + }, + |&i| { + surfaces + .get(i as usize) + .map(|s| Some((i, s))) + .ok_or_else(|| { + SolidError::invalid_input("difftest::patch_lookup", "surface index") + }) + }, + )?; + let Some((id, surface)) = selected else { + out.push(-1.0); + continue; + }; + out.push(id as f64); + let (n, m, r, s) = surface.array_sizes(); + let (p, q) = surface.degrees(); + out.extend([n as f64, m as f64, p as f64, q as f64, r as f64, s as f64]); + for row in surface.rows() { + for p in row { + out.extend([p.x, p.y, p.z, p.w]); + } + } + out.extend(surface.u_knots()); + out.extend(surface.v_knots()); + } + Ok(out) } fn default_patch_scale() -> f64 { @@ -730,6 +863,16 @@ impl UnitDiff for Runner { fn run_cpp(&self, case: &Case) -> Result { match case.parse::()? { + Inputs::PatchLookup { + surfaces, + owner, + nodes, + queries, + } => { + let out = ffi::patch_lookup(&patch_program(&surfaces, owner, &nodes, &queries)) + .map_err(|e| ffi_err(&e))?; + Ok(Output::new().values("patches", out)) + } Inputs::Spatial { ctor, ops } => { let (oi, of) = ffi::spatial(&spatial_prog(&ctor, &ops)).map_err(|e| ffi_err(&e))?; Ok(Output::new().ints("ints", oi).values("floats", of)) @@ -751,6 +894,19 @@ impl UnitDiff for Runner { fn run_rust(&self, case: &Case) -> Result { match case.parse::()? { + Inputs::PatchLookup { + surfaces, + owner, + nodes, + queries, + } => { + ffi::validate_patch_lookup(&patch_program(&surfaces, owner, &nodes, &queries)) + .map_err(|e| ffi_err(&e))?; + match rust_patch_lookup(&surfaces, owner, &nodes, &queries) { + Ok(out) => Ok(Output::new().values("patches", out)), + Err(e) => Ok(rust_err(&e)), + } + } Inputs::Spatial { ctor, ops } => match run_spatial_rust(&ctor, &ops) { Ok((oi, of)) => Ok(Output::new().ints("ints", oi).values("floats", of)), Err(e) => Ok(rust_err(&e)), @@ -881,3 +1037,76 @@ impl UnitDiff for Runner { Ok(cases) } } + +#[cfg(test)] +mod patch_lookup_tests { + use super::*; + + #[test] + fn real_patch_identity_geometry_and_owner_boundary() { + let net = [ + [0., 0., 0., 1.], + [0., 2., 0., 1.], + [3., 0., 1., 1.], + [3., 2., 1., 1.], + ]; + let surfaces = vec![net, net, net]; + let nodes = vec![ + PatchNode { + parent: -1, + aux: 3, + patch: 1, + surface: -1, + }, + PatchNode { + parent: 0, + aux: 3, + patch: 2, + surface: -1, + }, + PatchNode { + parent: 0, + aux: 6, + patch: -1, + surface: 0, + }, + ]; + let queries = [0, 1, 2]; + let native = ffi::patch_lookup(&patch_program(&surfaces, 0, &nodes, &queries)).unwrap(); + let mut actual = rust_patch_lookup(&surfaces, 0, &nodes, &queries).unwrap(); + assert_eq!(native, actual); + assert_eq!(actual.len(), 63); + assert_eq!([actual[0], actual[31], actual[62]], [1., 2., -1.]); + assert_eq!(&actual[1..31], &actual[32..62]); + actual[0] = 2.; + assert!(!crate::compare::compare( + &Output::new().values("patches", native), + &Output::new().values("patches", actual), + &[] + ) + .pass()); + } + + #[test] + fn malformed_patch_programs_are_refused_before_native_access() { + let net = [[0., 0., 0., 1.]; 4]; + let mut nodes = vec![PatchNode { + parent: -1, + aux: 3, + patch: 0, + surface: -1, + }]; + let good = patch_program(&[net], 0, &nodes, &[0]); + assert!(ffi::validate_patch_lookup(&good).is_ok()); + for len in 0..good.len() { + assert!(ffi::validate_patch_lookup(&good[..len]).is_err()); + } + nodes[0].parent = 0; + assert!(ffi::patch_lookup(&patch_program(&[net], 0, &nodes, &[0])).is_err()); + nodes[0].parent = -1; + assert!(ffi::patch_lookup(&patch_program(&[net], 0, &nodes, &[1])).is_err()); + let mut bad_weight = net; + bad_weight[0][3] = 0.; + assert!(ffi::patch_lookup(&patch_program(&[bad_weight], 0, &nodes, &[0])).is_err()); + } +} diff --git a/crates/difftest/src/units/heal_gap.rs b/crates/difftest/src/units/heal_gap.rs index b5050b702e..0267247990 100644 --- a/crates/difftest/src/units/heal_gap.rs +++ b/crates/difftest/src/units/heal_gap.rs @@ -1641,6 +1641,25 @@ impl UnitDiff for Runner { #[cfg(test)] mod tests { use super::*; + + /// HANG-29: fuzz14876, a topology op on a face whose B-spline surface has a control + /// point of `-1e300` (no native result in 60 s). Both sides refuse its block through + /// the `topology.vertexuse` export's HANG-29 bound (`1e150`), which both run. It + /// cannot be a fixture (a harness error always fails a case). + #[test] + fn both_sides_refuse_a_topo_block_in_the_overflow_range() { + let recorded: Value = serde_json::from_str(include_str!( + "../../tests/fuzz_regressions/heal.gap.json" + )) + .unwrap(); + for recorded in recorded["cases"].as_array().unwrap() { + let i: Inputs = serde_json::from_value(recorded["inputs"].clone()).unwrap(); + for error in [run_rust(&i), run_cpp(&i)] { + let error = error.unwrap_err().to_string(); + assert!(error.contains("HANG-29"), "{}: {error}", recorded["name"]); + } + } + } use solidcore::curve::curve::p2::{CIRCLE_TYPE, ELLIPSE_TYPE}; use solidcore::curve::curve::CurveKind; diff --git a/crates/difftest/src/units/heal_heal_data_p1.rs b/crates/difftest/src/units/heal_heal_data_p1.rs index c301cb9a68..0916452b9c 100644 --- a/crates/difftest/src/units/heal_heal_data_p1.rs +++ b/crates/difftest/src/units/heal_heal_data_p1.rs @@ -28,6 +28,8 @@ use serde_json::Value; use solidcore::heal::heal_data::p1::api_heal_brep; use solidcore::kernel::Kernel; +use solidcore::math::Point3; +use solidcore::primitive::api_primitives::create_box; use solidcore::SolidError; use solidcore_ffi::units::heal_heal_data_p1 as ffi; @@ -45,6 +47,8 @@ pub const MIN_GENERATED: usize = 320; /// `SM_ERR_INVALID_INPUT`. const SM_ERR_INVALID_INPUT: f64 = 1007.0; +/// `SM_SUCCESS`. +const SM_SUCCESS: f64 = 1000.0; /// The difftest runner. pub struct Runner; @@ -123,23 +127,31 @@ fn run_rust(i: &Inputs) -> Result { } Inputs::Api { api } => { let mut k = Kernel::default(); - // The C++ side heals a box made by `SmApiCreateBox`, which the Rust side - // cannot build yet (unit primitive.api_primitives): healing any stand-in - // B-rep would compare a different input. Build the box with the ported - // `SmApiCreateBox` when that lands and compare its status here. + // The C++ side heals a box made by `SmApiCreateBox(origin, dx, dy, dz)`; + // the output is `create_status heal_status`. + let mut create_status = 0.0; let r = match api.box_size { None => api_heal_brep(&mut k, None), - Some(_) => { - return Ok(Output::deferred( - "primitive.api_primitives", - "SmApiCreateBox", - )); + Some([dx, dy, dz]) => { + match create_box(&mut k, Point3::new(0.0, 0.0, 0.0), dx, dy, dz) { + Ok(mut b) => { + create_status = SM_SUCCESS; + api_heal_brep(&mut k, Some(&mut b)) + } + Err(SolidError::InvalidInput { .. }) => { + return Ok(Output::new().values("out", vec![SM_ERR_INVALID_INPUT, 0.0])); + } + Err(e) => { + return Ok(Output::unported(&e) + .unwrap_or_else(|| Output::err(e.to_string()))); + } + } } }; Ok(match r { - Ok(()) => Output::new().values("out", vec![0.0, 0.0]), + Ok(()) => Output::new().values("out", vec![create_status, SM_SUCCESS]), Err(SolidError::InvalidInput { .. }) => { - Output::new().values("out", vec![0.0, SM_ERR_INVALID_INPUT]) + Output::new().values("out", vec![create_status, SM_ERR_INVALID_INPUT]) } Err(e) => Output::unported(&e).unwrap_or_else(|| Output::err(e.to_string())), }) @@ -336,6 +348,10 @@ fn gen_api(rng: &mut Rng) -> Inputs { } impl UnitDiff for Runner { + fn reference_platform(&self) -> Option { + Some(crate::platform::X86_64_HASH_CASTS) + } + fn id(&self) -> &'static str { "heal.heal_data_p1" } diff --git a/crates/difftest/src/units/heal_heal_data_p2.rs b/crates/difftest/src/units/heal_heal_data_p2.rs index afd88e2c80..f93ee3905a 100644 --- a/crates/difftest/src/units/heal_heal_data_p2.rs +++ b/crates/difftest/src/units/heal_heal_data_p2.rs @@ -2054,6 +2054,10 @@ fn gen_prob(rng: &mut Rng) -> Inputs { } impl UnitDiff for Runner { + fn reference_platform(&self) -> Option { + Some(crate::platform::X86_64_HASH_CASTS) + } + fn id(&self) -> &'static str { "heal.heal_data_p2" } @@ -2088,6 +2092,25 @@ impl UnitDiff for Runner { mod tests { use super::*; + /// HANG-29: `--fuzz 30000` fuzz22334, a surface control point of `-1e300` in the + /// B-rep block; the healer's caching did not return. Both sides refuse the block + /// through the `topology.vertexuse` export's HANG-29 bound (`1e150`; the Rust side + /// imports through the same validated export). + #[test] + fn both_sides_refuse_a_block_in_the_overflow_range() { + let recorded: Value = serde_json::from_str(include_str!( + "../../tests/fuzz_regressions/heal.heal_data_p2.json" + )) + .unwrap(); + for recorded in recorded["cases"].as_array().unwrap() { + let i: Inputs = serde_json::from_value(recorded["inputs"].clone()).unwrap(); + for error in [run_cpp(&i), run_rust(&i)] { + let error = error.unwrap_err().to_string(); + assert!(error.contains("HANG-29"), "{}: {error}", recorded["name"]); + } + } + } + /// A native analytic face must reach healing as the same surface kind in Rust. /// A plain B-spline copy has the same control net but different UV evaluation. #[test] diff --git a/crates/difftest/src/units/heal_heal_data_p3.rs b/crates/difftest/src/units/heal_heal_data_p3.rs index f62de33e39..8c6997c5e7 100644 --- a/crates/difftest/src/units/heal_heal_data_p3.rs +++ b/crates/difftest/src/units/heal_heal_data_p3.rs @@ -104,6 +104,10 @@ fn script(rng: &mut Rng) -> Inputs { } impl UnitDiff for Runner { + fn reference_platform(&self) -> Option { + Some(crate::platform::X86_64_HASH_CASTS) + } + fn id(&self) -> &'static str { "heal.heal_data_p3" } diff --git a/crates/difftest/src/units/intersect_curve_class_p1.rs b/crates/difftest/src/units/intersect_curve_class_p1.rs index e7614b66a8..abe30ae875 100644 --- a/crates/difftest/src/units/intersect_curve_class_p1.rs +++ b/crates/difftest/src/units/intersect_curve_class_p1.rs @@ -28,7 +28,16 @@ //! `sm_SetXSectEdgeThroughPoint` and `sm_GetCCIGeometry` run on the edge's B-rep record //! (an edited edge curve is written back to the pool curve the shim's edge shares). //! -//! Deferred cases: ops that reach other topology members (`sm_ComputeFacePointClass`), +//! `sm_ComputeFacePointClass` runs on the face's B-rep record; both sides emit the status and the whole +//! point classification (object, face, deviation, parameters). On the one-face B-rep the points sit +//! on the corners, edges and interior, lifted off the surface, and strays, so the classes are +//! vertex, edge, face and outside. On a bare face (a surface, no loops, an empty UV domain) the +//! point solve finds nothing and both sides end in `SM_ERR` with the default record, so those +//! cases check the status, not a classification; so do points too far from the one-face +//! B-rep's surface. The shim rejects a face whose UV domain has zero size on either axis (the +//! C++ crashes on the single-point domain; cause not located). +//! +//! Inputs the shim rejects, for which the Rust side would defer or that are undefined upstream: //! surface point solves (`ComputePointParameters` on surfaces, `sm_GetCCISurfaceGaps` with //! surfaces), `SmTol::MapTo2d` for a curve of dimension other than 2 or 3 on a surface //! (`SmSurface::DropPoint`) and an `SmCrvOnSurf` over a plane. Generated scripts are filtered by the shim's @@ -1765,9 +1774,35 @@ fn rust_run(s: &Script) -> Result, Fail> { } OpIn::FacePointClass { f, p, tol, .. } => { let h = pool.handle(*f)?.ok_or_else(|| harness("no face"))?; - let mut pc = PointClassification::new(zone_tol3d(&k, USE_DEFAULT)); - must(cc_mod::compute_face_point_class(v3(*p), zone_tol3d(&k, *tol), h, &mut pc))?; - return Err(harness("face point class ported").into()); + let Some((_, EntityRef::Face(fid))) = pool.topology_entity(h) else { + return Err(harness("face handle without a face").into()); + }; + let mut pc = PointClassification::::new(zone_tol3d(&k, USE_DEFAULT)); + let brep = match pool.topo.as_mut() { + Some(t) => &mut t.brep, + None => &mut pool.bare.brep, + }; + let r = cc_mod::p1::compute_face_point_class_in( + &k, + brep, + fid, + v3(*p), + zone_tol3d(&k, *tol), + &mut pc, + ); + // the status, then the whole classification (also on failure, as the shim) + o.push(match st(r)? { + Ok(()) => 0.0, + Err(s) => s, + }); + let pc = pc + .map_objects(&mut |e| { + pool.topology_handle(e).ok_or_else(|| { + SolidError::invalid_input("difftest", "entity without a pool handle") + }) + }) + .map_err(|e| harness(e.to_string()))?; + put_pc(&mut o, &pc); } _ => {} } @@ -2387,7 +2422,14 @@ fn gen_script(rng: &mut Rng) -> Script { let all: Vec = (0..objects.len()).collect(); for _ in 0..=rng.below(6) { let t = rng.below(2) as usize; - let op = match rng.below(5) { + let op = match rng.below(6) { + // sm_ComputeFacePointClass on a bare face (a surface, no loops) + 5 => OpIn::FacePointClass { + t, + f: pick(rng, &of_kind(&objects, kind::FACE)), + p: rp(rng, 10.0), + tol: *rng.choose(&[1e-5, 1e-4, 1e-3, 1e-6, 0.05]).unwrap_or(&1e-5), + }, 0 | 1 => { let c1 = maybe(rng, &curves, 0.05); let c2 = if rng.chance(0.2) { c1 } else { maybe(rng, &curves, 0.05) }; @@ -2590,6 +2632,30 @@ fn gen_face_script(rng: &mut Rng) -> Option