Merge origin/main into stacked #691

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011gJAwcLRBPQXYLmvXd3pWf

# Conflicts:
#	crates/difftest/known_mismatches.toml
#	plan/findings.md
#	plan/upstream-issues.md
This commit is contained in:
Brett Lawson
2026-10-26 06:44:04 +01:00
1505 changed files with 103450 additions and 32342 deletions
+3 -2
View File
@@ -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
+209 -28
View File
@@ -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
+87 -21
View File
@@ -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/<unit>.cpp
plan/ units/<id>.toml (one file per unit), STATUS.md, findings.md, order.md
plan/ units/<id>.toml, findings/<slug>.md, upstream-issues/<slug>.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/<slug>.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/<unit id>.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(<unit>)`, 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 "<unit id> in:title"`. If none, branch from the
latest `main` (`git fetch origin && git switch -c unit/<unit id> origin/main`),
`git commit --allow-empty -m "claim: <unit id>"`, push, and open a draft PR titled
exactly **`port: <unit id>`**. **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: <unit id>`**. For completion work,
use a descriptive title such as **`wire: <unit id> — <function group>`** 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/<unit_id_with_underscores>.cpp`, functions `sc_<unit>_<fn>`, 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/<slug>.md` and `plan/upstream-issues/<slug>.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/<unit id>.toml`
(one file per unit; see `crates/difftest/src/known.rs`), and SolidCraft cases in
`fixtures/solidcraft/<issue>-<slug>.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/<slug>.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/<id>.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/<slug>.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: <heading>` (older code comments): grep the
heading under `plan/findings/`.
- `plan/upstream-issues/<slug>.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/<unit>.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).
Generated
+30
View File
@@ -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"
+1 -1
View File
@@ -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]
+32 -14
View File
@@ -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 <unit id> # 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 <unit id> # 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: <unit id>` (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: <unit id>`. 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/<unit id>.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`.
+4
View File
@@ -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"] }
+83 -3
View File
@@ -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/<unit>.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/<unit>/`) |
| `--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 <unit> cases=… passed=… failed=… deferred=… max_rel_er
```
then the first few failing cases and `reports/difftest/<unit>.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 <sweep reports> --baseline <validated base>`, 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/<slug>.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 (<f
* **Steps** (`"op"`): `box`, `sphere`, `cylinder` (`axis`, default +Z), `union`,
`difference` (optional `pattern`: the tool cut once per grid position, one cut after
the other), `intersection`, `fillet` (edges by a point on each), `offset_faces` (faces
by a point on each), `sheet_corner` (the deferred sheet-flange recipe below). Each
the other), `intersection`, `extrude`, `revolve`, `pipe` (a circle swept along a path),
`sweep` (a closed `profile` in `plane` at `origin` swept along one `path` arc or line in
the XY plane, `SmApiCreateCurveSweep`), `loft` (`SmApiCreateSkinPrimitive` through
`sections`, each one closed `profile` with its `plane` and `origin`; `degree` 1, ruled,
by default; capped at both ends), `fillet` (edges by a point on each), `offset_faces`
(faces by a point on each), `sheet_corner` (the deferred sheet-flange recipe below). Each
writes the body `id`; the last writes `result`.
* **Expectations** (`expect`): `lumps` (bounded regions; `0` = nothing left), `volume`
(`value`, absolute `tol`, the `formula` it came from), `cylinder_faces_max`,
@@ -656,6 +694,9 @@ nice -n 10 cargo run -p difftest -- --solidcraft-cases '3526-*' # ids (<f
glob): per case its source links, verdict, deferral, measured values and checks.
* `cargo test -p difftest --lib solidcraft` checks that every case file loads, every
case runs to a verdict, and every deferral names a plan unit.
* **Adding cases:** add a new `fixtures/solidcraft/<issue>-<slug>.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 (<https://steptools.com/docs/stpfiles/>, 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: <ENTITY>` (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 <file> <body>
<cpp|rust> <out.json>`). 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 -- <file> <body> [--flat]` prints one body.
## Notes
* Cases run sequentially in one process: the C++ kernel has global state. A
-224
View File
@@ -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')"
@@ -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)"
@@ -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)."
@@ -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)"
@@ -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)"
@@ -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."
+161 -1
View File
@@ -465,9 +465,106 @@ pub fn ratchet(base: &Baseline, res: &UnitResult) -> Vec<Violation> {
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<Violation> {
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(
"<seeds>",
"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 (`<unit>.json`) in `dir`, sorted. Every report is parsed.
pub fn report_units(dir: &Path) -> Result<Vec<String>, 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<Violation> {
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<BaselineCase>) -> 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<String> = 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]
+2
View File
@@ -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};
+326
View File
@@ -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<f64>,
}
/// Reads and decodes an `.smc` curve of any dimension (`z` is `NOZ` unless the
/// dimension is 3).
pub(crate) fn read(path: &str) -> Result<SmcCurve, HarnessError> {
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<SmcCurve, HarnessError> {
decode_3d(&text(path)?, path)
}
/// Reads the file's text (`{path}: {io error}` on failure, as the runners did).
fn text(path: &str) -> Result<String, HarnessError> {
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<SmcCurve, HarnessError> {
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<SmcCurve, HarnessError> {
decode_impl(text, path, true)
}
/// One header or payload number; a file that ends early is `truncated`.
fn number(numbers: &mut IntoIter<f64>, path: &str) -> Result<f64, HarnessError> {
numbers
.next()
.ok_or_else(|| HarnessError::new(format!("{path}: truncated")))
}
fn decode_impl(text: &str, path: &str, only_3d: bool) -> Result<SmcCurve, HarnessError> {
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::<f64>().map_err(|e| error(e.to_string())))
.collect::<Result<Vec<_>, _>>()?
.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::<f64>().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::<f64>().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::<f64>().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::<Vec<_>>();
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);
}
}
+17 -2
View File
@@ -635,6 +635,8 @@ pub struct FuzzResult {
pub notes: Vec<Failure>,
/// Failure to build or run the cases.
pub harness_error: Option<String>,
/// Reason the native reference is unsupported; no mutations or workers ran.
pub skipped_platform: Option<String>,
}
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);
}
+61 -24
View File
@@ -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<ExitStatus>, 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<ExitStatus>, 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<ExitStatus>, 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)"));
+140 -23
View File
@@ -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/<unit id>.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/<slug>.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<Libc>,
}
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<KnownMismatch>,
}
/// Parses the list's text.
pub fn parse(text: &str) -> Result<Vec<KnownMismatch>, 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<Vec<KnownMismatch>, 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 `<root>/crates/difftest/known_mismatches.toml` (empty if
/// the file does not exist).
/// The entries of `unit` from `<root>/crates/difftest/known_mismatches/<unit>.toml`
/// (empty if the file does not exist).
pub fn for_unit(root: &Path, unit: &str) -> Result<Vec<KnownMismatch>, 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}/<unit id>.toml"
);
let units: Vec<String> = 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");
}
}
}
+2
View File
@@ -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};
+175 -26
View File
@@ -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 <id> --fuzz N [--fuzz-out <dir>] # 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 <dir> [--digest-debug] # Rust side
//! only, one `<dir>/<unit>.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 <id>]... [--green] [--all] [--list]
[--status-of <id>] [--seeds N] [--filter <substr>] [--perturb] [--report-dir <dir>] [--no-report] [--jobs N]
[--baseline <dir>] [--digest-dir <dir> [--digest-debug]]
[--baseline <dir>] [--compare-reports <dir>] [--digest-dir <dir> [--digest-debug]]
difftest --unit <id>... --fuzz N [--fuzz-out <dir>] [--seeds N] [--filter <substr>]
difftest --ssi-corpus [--tol 1e-6|default] [--jobs N] [--report-dir <dir>] [--no-report] [case-glob]
difftest --solidcraft-cases [--report-dir <dir>] [--no-report] [case-glob]";
difftest --solidcraft-cases [--report-dir <dir>] [--no-report] [case-glob]
difftest --step-corpus [--jobs N] [--report-dir <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<PathBuf>,
no_report: bool,
baseline: Option<PathBuf>,
compare_reports: Option<PathBuf>,
ssi_corpus: bool,
solidcraft_cases: bool,
step_corpus: bool,
tol: Option<String>,
jobs: Option<usize>,
glob: Option<String>,
@@ -94,8 +97,12 @@ fn parse_args() -> Result<Args, String> {
"--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<Args, String> {
}
"-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<Violation> = 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());
}
}
+57 -4
View File
@@ -62,10 +62,15 @@ pub fn parse_unit(text: &str) -> Result<PlanUnit, String> {
.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()));
+185
View File
@@ -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<usize>,
rust: Cell<usize>,
}
impl UnitDiff for Restricted {
fn id(&self) -> &'static str {
"test.platform"
}
fn reference_platform(&self) -> Option<ReferencePlatform> {
Some(ReferencePlatform {
arch: "unsupported-test-architecture",
reason: "test reference is unavailable",
})
}
fn cases(&self, _: &CaseContext) -> Result<Vec<Case>, 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<Output, HarnessError> {
panic!("unsupported C++ must never run")
}
fn run_rust(&self, _: &Case) -> Result<Output, HarnessError> {
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"
]
);
}
}
+28 -2
View File
@@ -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<PathBuf, HarnessE
/// Aggregate counts recorded in a report (including vacuous and known outcomes).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReportCounts {
/// `green`, `partial` or `fail`.
/// `green`, `partial`, `fail` or `skipped(platform)`.
pub status: String,
/// All cases.
pub total: u64,
@@ -200,14 +203,35 @@ impl ReportCounts {
}
}
/// Aggregate CLI summary. Skipped units never contribute to the passed count.
/// The optional suffix preserves existing output when all references are supported.
pub fn run_summary(total: usize, partial: usize, failed: usize, skipped: usize) -> 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=<unit>(<cases>),...`.
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(),
+41 -15
View File
@@ -18,6 +18,13 @@ pub trait UnitDiff {
/// Unit id as in `plan/units/<id>.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<crate::platform::ReferencePlatform> {
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<String, i64>,
/// The case is listed in `known_mismatches.toml` (the entry's `why`).
/// The case is listed in `known_mismatches/<unit>.toml` (the entry's `why`).
pub known_mismatch: Option<String>,
}
@@ -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/<unit>.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<CaseResult>,
/// Failure to build the case list.
pub harness_error: Option<String>,
/// Reason the C++ reference cannot run on this architecture (no cases ran).
pub skipped_platform: Option<String>,
/// Documented absolute floors used.
pub abs_floors: Vec<AbsFloor>,
/// `--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 }
}
+586 -3
View File
@@ -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<Seg>,
/// 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<Seg>,
/// 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<Seg>,
/// 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<LoftSection>,
/// 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<Seg>,
/// 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<Seg>,
/// 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: `<file stem>/<name>` 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<Vec<Curve>, Run> {
let at = |[u, v]: [f64; 2]| Vec3::new(u, v, 0.0);
let mut out: Vec<Curve> = 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<String, Brep>, 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<String, Brep>, step: &Step) -
}
target
}
Step::Extrude {
profile,
plane,
origin,
distance,
..
} => {
let cs = profile_curves(k, profile, *plane, *origin)?;
let refs: Vec<Option<&Curve>> = 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<Option<&Curve>> = 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<Option<&Curve>> = 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<Option<&Curve>> = 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<String, Brep>, 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<Measured, SolidError> {
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::<Vec<_>>()
);
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::<Step>(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::<Step>(
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::<Step>(&loft.replace("\"origin\"", "\"orign\"")).is_err());
}
}
+26 -16
View File
@@ -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
//! (<https://github.com/jyviko/ssi-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<Vec<PathBuf>, Harne
Ok(v)
}
/// `--ssi-case <case.json> <tol|default> <cpp|rust|ssi> <out.json>`: one engine on one
/// `--ssi-case <case.json> <tol|default> <cpp|rust|ssi|solidcore-ssi> <out.json>`: 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 <case.json> <tol|default> <cpp|rust|ssi> <out.json>");
eprintln!("usage: difftest --ssi-case <case.json> <tol|default> <cpp|rust|ssi|solidcore-ssi> <out.json>");
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<Vec<[RunResult; 3]>, HarnessError> {
) -> Result<Vec<[RunResult; N_ENGINES]>, 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<f64>,
cov_floor: f64,
names: &BTreeMap<i64, String>,
jobs: usize,
) -> Vec<Vec<Option<Score>>> {
let grade = |case: &SsiCase, res: &[RunResult; 3]| -> Vec<Option<Score>> {
let grade = |case: &SsiCase, res: &[RunResult; N_ENGINES]| -> Vec<Option<Score>> {
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<String, usize>; 3] = Default::default();
let mut counts: [BTreeMap<String, usize>; 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(), &[]);
+6 -311
View File
@@ -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<f64>, 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<Result<(), Deferred>, 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<Result<Surface, Deferred>, 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<Vec<CPoint>> = 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<Vec3>, [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<String>) {
match e {
SolidError::Upstream { code, .. } => (i64::from(*code), None),
@@ -523,7 +267,7 @@ pub fn run_rust(case: &SsiCase, tol: Option<f64>) -> Result<RawOutput, EngineFai
};
built.push((s, d));
}
Err(d) => 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<f64>) -> Result<RawOutput, EngineFai
let x = match res {
Ok(x) => 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<f64>) -> Result<RawOutput, EngineFai
return Ok(out);
}
};
let surfaces = [&s1, &s2];
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(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)
}
+354
View File
@@ -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<f64>,
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<Result<(), Deferred>, 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<Result<Surface, Deferred>, 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<Vec<CPoint>> = 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<Vec3>, [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<Result<(), Deferred>, 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(()))
}
@@ -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<f64>) -> Result<RawOutput, EngineFailure> {
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)),
}
}
File diff suppressed because it is too large Load Diff
@@ -2065,6 +2065,11 @@ fn corner_rust(i: &Inputs) -> Result<Output, HarnessError> {
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)
}
@@ -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<usize>,
}
#[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<BigRadRailsIn>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct BigRadIn {
model: Model,
fg: usize,
corners: Vec<BigRadCornerIn>,
fillet_corners: Vec<usize>,
solvers: Vec<BigRadSolverIn>,
geom_solvers: Vec<i64>,
fillet_solvers: Vec<usize>,
}
#[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<f64>, b: &Brep) -> Result<(), HarnessE
Ok(())
}
/// Executive error info as `p3_put_error_info` writes it.
pub(super) fn put_error_info(
o: &mut Vec<f64>,
info: &ErrorInfo<ExecutiveErrorObject>,
corner_vert: Option<VertexId>,
) {
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<Vec<f64>, 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<Output, HarnessError> {
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<Output, HarnessError> {
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<Output, HarnessError> {
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<Vec<Case>, 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<Output, HarnessError> {
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<Output, HarnessError> {
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)
}
}
@@ -132,9 +132,8 @@ fn try_generate(rng: &mut Rng) -> Option<TopoIn> {
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,
@@ -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<solidcore::blend::fillet_standard_solver::p1::VariableRadiusFs>),
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<Brep>,
/// The executive's pseudo B-rep (`m_pPseudoBrep`): the solvers' fillet geometries.
pseudo: FilletBrep,
generators: Vec<FilletSurfaceGenerator>,
solvers: Vec<ConstantRadiusAssistedFs>,
solvers: Vec<SolverEntry>,
corners: Vec<solidcore::blend::fillet_corner::p3::FilletCorner>,
laws: Vec<FilletLaw>,
edge_caches: BTreeMap<EdgeId, solidcore::curve::curve_cache::CurveCache<Handle>>,
}
@@ -377,7 +406,7 @@ impl ExecutiveAccess<Handle> 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<Handle> 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<bool> {
@@ -704,8 +733,17 @@ impl ExecutiveAccess<Handle> for Access {
fn solver_geom(&self, solver: Handle, index: usize) -> SolidResult<Option<Handle>> {
Ok(self.solver_base(solver)?.fillet_geoms.get(index).copied().map(Handle::Geom))
}
fn solver_edge(&self, _: Handle) -> SolidResult<Handle> {
missing()
fn solver_edge(&self, solver: Handle) -> SolidResult<Handle> {
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<f64> {
missing()
@@ -805,26 +843,47 @@ impl ExecutiveAccess<Handle> for Access {
fn corner_edges(&self, _: Handle) -> SolidResult<Vec<Option<Handle>>> {
missing()
}
fn edge_vertices(&self, _: Handle) -> SolidResult<(Option<Handle>, Option<Handle>)> {
missing()
fn edge_vertices(&self, edge_handle: Handle) -> SolidResult<(Option<Handle>, Option<Handle>)> {
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<Option<&solidcore::blend::fillet_standard_solver::p1::VariableRadiusFs>> {
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<Option<&mut solidcore::blend::fillet_standard_solver::p1::VariableRadiusFs>>
{
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<Option<Handle>> {
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<Option<Handle>> {
missing()
@@ -832,6 +891,187 @@ impl ExecutiveAccess<Handle> for Access {
fn solver_other_corner(&self, _: Handle, _: Handle) -> SolidResult<Option<Handle>> {
missing()
}
fn solver_corner_in(
&self,
executive: &FilletExecutive<Handle>,
solver: Handle,
index: usize,
) -> SolidResult<Option<Handle>> {
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<Handle>,
solver: Handle,
corner: Handle,
) -> SolidResult<Option<Handle>> {
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<Handle>,
corner: Handle,
solver: Handle,
) -> SolidResult<Option<Handle>> {
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<EdgeId> {
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<T>(
&mut self,
edges: &[EdgeId],
f: impl FnOnce(&mut Brep) -> SolidResult<T>,
) -> SolidResult<T> {
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<Option<&solidcore::curve::curve::Curve>> {
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<Handle>,
error: Option<&mut ErrorInfo<Handle>>,
) -> 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::<SolidResult<Vec<_>>>()?;
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<f64> {
missing()
}
@@ -965,16 +1205,140 @@ impl ExecutiveAccess<Handle> 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<Handle> {
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<Result<Access, Output>, 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, &params)
}
fn push_bspline_curve(o: &mut Vec<f64>, 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<Output, HarnessError> {
let mut access = match box_access(bx)? {
Ok(a) => a,
@@ -1287,6 +1678,168 @@ fn exec(sub: usize, n: usize, bx: &BoxIn) -> Result<Output, HarnessError> {
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<EdgeId> = 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, &params) {
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<Option<Handle>> = 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<Output, HarnessError> {
_ => {
let mut list: Vec<Option<Handle>> = 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<Outpu
"harness::fillet_executive_p2::temporary_radius_change",
)));
};
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)),
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)),
}
}
}
}
@@ -319,7 +319,7 @@ pub(crate) fn loader_rust(i: &LoaderScript) -> Result<Output, HarnessError> {
.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<Output, HarnessError> {
}
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<Output, HarnessError> {
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)
@@ -178,6 +178,17 @@ impl ExecutiveAccess<Handle> for Access {
fn corner_tangent_solver(&self, _: Handle, _: Handle) -> SolidResult<Option<Handle>> {
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<Handle>,
error: Option<&mut ErrorInfo<Handle>>,
) -> 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<Output, HarnessError> {
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] =
@@ -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<Vec<f64>> {
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<Vec<f64>> {
}
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<Option<Output>, HarnessError> {
if !matches!(example, 0..=6) {
return Ok(None);
}
let run = || -> SolidResult<Vec<f64>> {
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<EdgeId> = 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<f64> =
(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<Output, HarnessError> {
ffi::validate_real_solid(&input.wire()).map_err(|e| HarnessError::new(e.to_string()))?;
@@ -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<Vec<f64>> {
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<f64>, Vec<f64>),
after: &(Vec<f64>, Vec<f64>),
complete: bool,
selection: &[f64],
selection_intervals: &[f64]|
-> SolidResult<Vec<f64>> {
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,
)
})
}
@@ -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<f64>) -> 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<f64>, Vec<f64>)> {
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))
}
@@ -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<f64>, Vec<f64>)> {
let vertices = p1::vertices(body, None)?;
let edges = p1::edges(body, None)?;
let faces = p1::faces(body, None)?;
@@ -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<ObjectKey>],
out: &mut Vec<f64>,
) -> 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<Vec<f64>> {
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::<SolidResult<Vec<_>>>()?,
];
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)
}
@@ -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<EntityRef>; 3],
pub(super) marks: [Vec<u64>; 3],
}
pub(super) fn entities(body: &Brep) -> SolidResult<[Vec<EntityRef>; 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<Vec<Frame>> {
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<Vec<f64>> {
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<f64>, Vec<f64>, 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))
}
@@ -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<f64>, Vec<f64>, 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))
}
@@ -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<f64>,
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<Vec<u64>> {
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::<Vec<_>>(),
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<f64>, 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::<Vec<_>>(),
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::<Vec<_>>(),
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))
}
@@ -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<Vec<f64>> {
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<Output, HarnessError> {
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<Output, HarnessError> {
.map_err(|e| HarnessError::new(e.to_string()))?;
output(&values, input.checkpoint)
}
fn insertion_output(mut values: &[f64], checkpoint: Checkpoint) -> Result<Output, HarnessError> {
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)
}
@@ -1462,6 +1462,11 @@ pub(crate) struct RustModel {
pub(crate) fillet: Brep,
pub(crate) fillet_edges: Vec<EdgeId>,
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<FilletGeomId, HarnessError> {
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);
@@ -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.
@@ -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<Output, HarnessError> {
let mut bases: Vec<FilletSolver> = 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<Output, HarnessError> {
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,
@@ -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<FaceId>) -> 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<Option<Output>, 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<Output, HarnessError> {
};
// 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<Output, HarnessError> {
}
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<Output, HarnessError> {
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();
@@ -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<f64>) -> Result<Result<(), SolidError>, 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<f64>) -> Result<Result<(), SolidError>, 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),
+51 -48
View File
@@ -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<Output, HarnessError> {
fn engine_probe(k: &mut Kernel, i: &Inputs, pool: &mut Pool) -> Result<Output, HarnessError> {
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<Output, HarnessError> {
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<port::MergeEngine>,
}
impl Pool {
fn load(i: &Inputs) -> Result<Self, HarnessError> {
fn load(k: &mut Kernel, i: &Inputs) -> Result<Self, HarnessError> {
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<usize>, b: Option<usize>) {
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<Output, HarnessError> {
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(),
@@ -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<H> {
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<EntityRef> {
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<H> for Two {
fn curve(&self, o: H) -> Option<&SmCurve> {
match o.kind {
@@ -1462,12 +1539,10 @@ impl solidcore::topology::proto_topology::p3::BrepGlue<H> 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<H> for Two {
fn topology_objects(&self) -> &dyn TopologyObjects<H> {
self
}
fn face_cache_target(&mut self, face: H) -> solidcore::Result<ti::FaceCacheTarget<'_, H>> {
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<ti::EntityHandles<H>> {
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::<Vec<_>>()
};
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"
);
}
}
}
+14 -39
View File
@@ -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<InlineSrf, HarnessError> {
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<f64, HarnessError> {
it.next()
.ok_or_else(|| harness(format!("{path}: truncated")))?
.parse::<f64>()
.map_err(|e| harness(format!("{path}: {e}")))
};
let int = |v: f64| -> Result<usize, HarnessError> {
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::<Result<Vec<_>, _>>()?;
let v = (0..m + q + 2)
.map(|_| num())
.collect::<Result<Vec<_>, _>>()?;
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,
})
}
+108 -1
View File
@@ -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<f64>, 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<Curve, HarnessError> {
))),
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() {
+282 -7
View File
@@ -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<f64>, 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<Curve, HarnessError> {
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::<CurveIn>(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() {
+18 -35
View File
@@ -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<InlineSrf, HarnessError> {
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<f64, HarnessError> {
it.next()
.ok_or_else(|| harness(format!("{path}: truncated")))?
.parse::<f64>()
.map_err(|e| harness(format!("{path}: {e}")))
};
let int = |v: f64| -> Result<usize, HarnessError> {
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::<Result<Vec<_>, _>>()?;
let v = (0..m + q + 2)
.map(|_| num())
.collect::<Result<Vec<_>, _>>()?;
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<InlineSrf, HarnessError> {
@@ -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<Vec<[f64; 4]>>,
pub(super) p: usize,
pub(super) q: usize,
pub(super) u: Vec<f64>,
pub(super) v: Vec<f64>,
}
/// Decodes `n m p q`, a rational flag, points, and u/v knots.
pub(super) fn parse_sms(text: &str, path: &str) -> Result<ParsedSrf, HarnessError> {
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<f64, HarnessError> {
it.next()
.ok_or_else(|| error("truncated"))?
.parse::<f64>()
.map_err(|e| error(&e.to_string()))
};
let int = |value: f64, name: &str| -> Result<usize, HarnessError> {
// 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 })
}
@@ -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<Vec<[f64; 4]>>,
pub(super) p: usize,
pub(super) q: usize,
pub(super) u: Vec<f64>,
pub(super) v: Vec<f64>,
}
/// Decodes `n m p q`, a rational flag, points, and u/v knots.
pub(super) fn parse_sms(text: &str, path: &str) -> Result<ParsedSrf, HarnessError> {
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::<f64>()
.map_err(|e| error(&e.to_string()))
};
let int = |value: f64, name: &str| -> Result<usize, HarnessError> {
// 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 })
}
@@ -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<f64>,
}
pub(super) fn decode(text: &str, path: &str) -> Result<SmcCurve, HarnessError> {
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::<f64>().map_err(|e| error(&e.to_string())))
.collect::<Result<Vec<_>, _>>()?;
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 })
}
@@ -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<f64>,
}
pub(super) fn decode(text: &str, path: &str) -> Result<SmcCurve, HarnessError> {
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::<f64>().map_err(|e| error(&e.to_string())))
.collect::<Result<Vec<_>, _>>()?;
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 })
}
@@ -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<f64>,
}
pub(super) fn decode(text: &str, path: &str) -> Result<SmcCurve, HarnessError> {
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::<f64>().map_err(|e| error(&e.to_string())))
.collect::<Result<Vec<_>, _>>()?;
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 })
}
@@ -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<Vec<[f64; 4]>>,
pub(super) p: usize,
pub(super) q: usize,
pub(super) u: Vec<f64>,
pub(super) v: Vec<f64>,
}
pub(super) fn decode(text: &str, path: &str) -> Result<SmsSurface, HarnessError> {
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::<f64>().map_err(|e| error(&e.to_string())))
.collect::<Result<Vec<_>, _>>()?;
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 })
}
@@ -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<f64>,
}
pub(super) fn decode(text: &str, path: &str) -> Result<SmcCurve, HarnessError> {
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::<f64>().map_err(|e| error(&e.to_string())))
.collect::<Result<Vec<_>, _>>()?;
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 })
}
@@ -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<f64>,
}
pub(super) fn decode(text: &str, path: &str) -> Result<SmcCurve, HarnessError> {
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::<f64>().map_err(|e| error(&e.to_string())))
.collect::<Result<Vec<_>, _>>()?;
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 })
}
@@ -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<Vec<[f64; 4]>>,
pub(crate) p: usize,
pub(crate) q: usize,
pub(crate) u: Vec<f64>,
pub(crate) v: Vec<f64>,
}
#[derive(Debug, PartialEq)]
pub(crate) struct Curve {
pub(crate) cpts: Vec<[f64; 4]>,
pub(crate) p: usize,
pub(crate) u: Vec<f64>,
}
struct Numbers<'a> {
path: &'a str,
values: std::vec::IntoIter<f64>,
}
impl<'a> Numbers<'a> {
fn new(path: &'a str, source: &str) -> Result<Self, String> {
let values = source
.split_whitespace()
.map(|t| t.parse::<f64>().map_err(|e| format!("{path}: {e}")))
.collect::<Result<Vec<_>, _>>()?;
Ok(Self {
path,
values: values.into_iter(),
})
}
fn num(&mut self) -> Result<f64, String> {
self.values
.next()
.ok_or_else(|| format!("{}: truncated", self.path))
}
fn count(&mut self) -> Result<usize, String> {
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<usize>) -> Result<usize, String> {
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<Vec<f64>, 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<Surface, String> {
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<Curve, String> {
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 })
}
@@ -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<Vec<[f64; 4]>>,
pub p: usize,
pub q: usize,
pub u: Vec<f64>,
pub v: Vec<f64>,
}
pub(super) fn parse_sms(text: &str, path: &str) -> Result<SmsSurface, HarnessError> {
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::<f64>()
.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 })
}
@@ -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<Vec<[f64; 4]>>,
pub p: usize,
pub q: usize,
pub u: Vec<f64>,
pub v: Vec<f64>,
}
pub(super) fn parse_sms(text: &str, path: &str) -> Result<ParsedSurface, HarnessError> {
// Parse the entire text first: even an ignored malformed tail must win.
let all = text
.split_whitespace()
.map(|t| {
t.parse::<f64>()
.map_err(|e| HarnessError::new(format!("{path}: {e}")))
})
.collect::<Result<Vec<_>, _>>()?;
let mut it = all.into_iter();
let next = |it: &mut std::vec::IntoIter<f64>| {
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 })
}
@@ -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<Vec<[f64; 4]>>,
pub p: usize,
pub q: usize,
pub u: Vec<f64>,
pub v: Vec<f64>,
}
pub(super) fn parse_sms(text: &str, path: &str) -> Result<SmsSurface, HarnessError> {
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::<f64>()
.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 })
}
@@ -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<Vec<[f64; 4]>>,
pub(super) p: usize,
pub(super) q: usize,
pub(super) u: Vec<f64>,
pub(super) v: Vec<f64>,
}
/// Decodes the header, homogeneous or polynomial points, and u/v knots.
pub(super) fn parse_sms(text: &str, path: &str) -> Result<ParsedSrf, HarnessError> {
let error = |message: &str| HarnessError::new(format!("{path}: {message}"));
let parse = |token: &str| token.parse::<f64>().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, HarnessError> {
// 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 })
}
@@ -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<Vec<[f64; 4]>>,
pub(super) p: usize,
pub(super) q: usize,
pub(super) u: Vec<f64>,
pub(super) v: Vec<f64>,
}
/// Decodes `n m p q`, a rational flag, points, and u/v knots.
pub(super) fn parse_sms(text: &str, path: &str) -> Result<ParsedSrf, HarnessError> {
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::<f64>().map_err(|e| error(&e.to_string())))
.collect::<Result<Vec<_>, _>>()?;
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, HarnessError> {
// 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 })
}
@@ -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<Vec<[f64; 4]>>,
pub(super) p: usize,
pub(super) q: usize,
pub(super) u: Vec<f64>,
pub(super) v: Vec<f64>,
}
pub(super) fn decode(text: &str, path: &str) -> Result<SmsSurface, HarnessError> {
let error = |message: &str| HarnessError::new(format!("{path}: {message}"));
let number = |tokens: &mut std::str::SplitWhitespace<'_>| {
tokens
.next()
.ok_or_else(|| error("truncated"))?
.parse::<f64>()
.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 })
}
@@ -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<Vec<[f64; 4]>>,
pub p: usize,
pub q: usize,
pub u: Vec<f64>,
pub v: Vec<f64>,
}
pub(super) fn parse_sms(text: &str, path: &str) -> Result<SmsSurface, HarnessError> {
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::<f64>()
.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 })
}
@@ -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<Vec<[f64; 4]>>,
pub p: usize,
pub q: usize,
pub u: Vec<f64>,
pub v: Vec<f64>,
}
fn error(path: &str, message: impl std::fmt::Display) -> HarnessError {
HarnessError::new(format!("{path}: {message}"))
}
fn count(value: f64, path: &str) -> Result<usize, HarnessError> {
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<ParsedSrf, HarnessError> {
// Eager lexical validation deliberately includes even the ignored numeric tail.
let values = text
.split_whitespace()
.map(|token| token.parse::<f64>().map_err(|e| error(path, e)))
.collect::<Result<Vec<_>, _>>()?;
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 })
}
@@ -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<Vec<[f64; 4]>>,
pub p: usize,
pub q: usize,
pub u: Vec<f64>,
pub v: Vec<f64>,
}
pub(super) fn decode(text: &str, path: &str) -> Result<Decoded, HarnessError> {
// Parse even unused tails before inspecting headers, as the original reader did.
let values = text
.split_whitespace()
.map(|t| {
t.parse::<f64>()
.map_err(|e| HarnessError::new(format!("{path}: {e}")))
})
.collect::<Result<Vec<_>, _>>()?;
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 })
}
@@ -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<Vec<[f64; 4]>>,
/// 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<f64>,
/// Knots in v.
pub v: Vec<f64>,
}
/// Decode the consumed SMS header and payload, qualifying errors with `path`.
pub fn parse_sms(text: &str, path: &str) -> Result<ParsedSms, HarnessError> {
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::<f64>()
.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::<f64>().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 })
}
@@ -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<Vec<[f64; 4]>>,
/// U degree.
pub p: usize,
/// V degree.
pub q: usize,
/// U knots.
pub u: Vec<f64>,
/// V knots.
pub v: Vec<f64>,
}
fn number(it: &mut std::str::SplitWhitespace<'_>, path: &str) -> Result<f64, HarnessError> {
it.next()
.ok_or_else(|| HarnessError::new(format!("{path}: truncated")))?
.parse::<f64>()
.map_err(|e| HarnessError::new(format!("{path}: {e}")))
}
fn count(it: &mut std::str::SplitWhitespace<'_>, path: &str) -> Result<usize, HarnessError> {
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<SmsSurface, HarnessError> {
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 })
}
@@ -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<Vec<[f64; 4]>>,
/// U degree.
pub p: usize,
/// V degree.
pub q: usize,
/// U knots.
pub u: Vec<f64>,
/// V knots.
pub v: Vec<f64>,
}
fn number(it: &mut std::vec::IntoIter<f64>) -> Result<f64, String> {
it.next().ok_or_else(|| "truncated".to_owned())
}
fn count(it: &mut std::vec::IntoIter<f64>) -> Result<usize, String> {
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<f64>, last: usize, degree: usize) -> Result<Vec<f64>, 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<ParsedSurface, String> {
let mut values = Vec::new();
for token in text.split_whitespace() {
values.push(token.parse::<f64>().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 })
}
@@ -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<Vec<[f64; 4]>>,
pub(super) p: usize,
pub(super) q: usize,
pub(super) u: Vec<f64>,
pub(super) v: Vec<f64>,
}
/// 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<f64>,
}
fn error(path: &str, message: impl std::fmt::Display) -> HarnessError {
HarnessError::new(format!("{path}: {message}"))
}
fn numbers(text: &str, path: &str) -> Result<Vec<f64>, 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::<f64>().map_err(|e| error(path, e)))
.collect()
}
fn int(path: &str, value: f64, name: &str) -> Result<usize, HarnessError> {
// 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<ParsedSrf, HarnessError> {
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<ParsedCrv, HarnessError> {
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 })
}
@@ -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<Vec<[f64; 4]>>,
pub(crate) p: usize,
pub(crate) q: usize,
pub(crate) u: Vec<f64>,
pub(crate) v: Vec<f64>,
}
fn count(path: &str, value: f64) -> Result<usize, HarnessError> {
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<usize, HarnessError> {
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<ParsedSrf, HarnessError> {
let values = text
.split_whitespace()
.map(|token| {
token
.parse::<f64>()
.map_err(|error| HarnessError::new(format!("{path}: {error}")))
})
.collect::<Result<Vec<_>, _>>()?;
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 })
}
@@ -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<Vec<[f64; 4]>>,
pub(super) p: usize,
pub(super) q: usize,
pub(super) u: Vec<f64>,
pub(super) v: Vec<f64>,
}
/// 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<ParsedSurface, HarnessError> {
let error = |message: &str| HarnessError::new(format!("{path}: {message}"));
let values = text
.split_whitespace()
.map(|token| token.parse::<f64>().map_err(|e| error(&e.to_string())))
.collect::<Result<Vec<_>, _>>()?;
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 })
}
@@ -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<Vec<[f64; 4]>>,
pub(crate) p: usize,
pub(crate) q: usize,
pub(crate) u: Vec<f64>,
pub(crate) v: Vec<f64>,
}
pub(crate) fn read_sms(path: &str) -> Result<ParsedSurface, HarnessError> {
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, HarnessError> {
// 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<usize, HarnessError> {
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<ParsedSurface, HarnessError> {
// Even ignored tails must be lexically valid, before any header validation.
let values = text
.split_whitespace()
.map(|token| {
token
.parse::<f64>()
.map_err(|e| HarnessError::new(format!("{path}: {e}")))
})
.collect::<Result<Vec<_>, _>>()?;
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 })
}
@@ -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<Vec<[f64; 4]>>,
pub(crate) p: usize,
pub(crate) q: usize,
pub(crate) u: Vec<f64>,
pub(crate) v: Vec<f64>,
}
fn count(path: &str, value: f64) -> Result<usize, HarnessError> {
// 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<usize, HarnessError> {
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<ParsedSurface, HarnessError> {
// Deliberately eager: even a malformed ignored tail precedes header errors.
let values = text
.split_whitespace()
.map(|token| {
token
.parse::<f64>()
.map_err(|error| HarnessError::new(format!("{path}: {error}")))
})
.collect::<Result<Vec<_>, _>>()?;
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 })
}
@@ -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<Vec<[f64; 4]>>,
pub(crate) p: usize,
pub(crate) q: usize,
pub(crate) u: Vec<f64>,
pub(crate) v: Vec<f64>,
}
fn count(path: &str, value: f64) -> Result<usize, String> {
// 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<usize, String> {
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<Parsed, String> {
let values = text
.split_whitespace()
.map(|token| token.parse::<f64>().map_err(|e| format!("{path}: {e}")))
.collect::<Result<Vec<_>, _>>()?;
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 })
}
@@ -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<i64>,
pub(super) links: Vec<Option<usize>>,
pub(super) floats: Vec<f64>,
}
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<Rec>; 10];
fn count(value: f64, name: &str) -> Result<usize, HarnessError> {
// 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<f64, HarnessError> {
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<Model, HarnessError> {
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)
}
@@ -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<Vec<[f64; 4]>>,
pub(super) p: usize,
pub(super) q: usize,
pub(super) u: Vec<f64>,
pub(super) v: Vec<f64>,
}
/// Decodes `n m p q`, a rational flag, points, and u/v knots.
pub(super) fn parse_sms(text: &str, path: &str) -> Result<ParsedSrf, HarnessError> {
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::<f64>().map_err(|e| error(&e.to_string())))
.collect::<Result<Vec<_>, _>>()?;
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, HarnessError> {
// 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 })
}
@@ -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]
@@ -637,6 +637,10 @@ fn apply(m: &mut Model, upd: &mut Vec<usize>, op: &Op) -> solidcore::Result<()>
}
impl UnitDiff for Runner {
fn reference_platform(&self) -> Option<crate::platform::ReferencePlatform> {
Some(crate::platform::X86_64_HASH_CASTS)
}
fn id(&self) -> &'static str {
"geom.hash_table"
}
+230 -1
View File
@@ -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<PatchNode>,
queries: Vec<usize>,
},
}
fn patch_program(
surfaces: &[[[f64; 4]; 4]],
owner: i64,
nodes: &[PatchNode],
queries: &[usize],
) -> Vec<f64> {
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<Vec<f64>> {
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::<solidcore::Result<Vec<_>>>()?;
let mut tree: Tree<i64> = 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<Output, HarnessError> {
match case.parse::<Inputs>()? {
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<Output, HarnessError> {
match case.parse::<Inputs>()? {
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());
}
}
+19
View File
@@ -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;
+27 -11
View File
@@ -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<Output, HarnessError> {
}
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<crate::platform::ReferencePlatform> {
Some(crate::platform::X86_64_HASH_CASTS)
}
fn id(&self) -> &'static str {
"heal.heal_data_p1"
}
@@ -2054,6 +2054,10 @@ fn gen_prob(rng: &mut Rng) -> Inputs {
}
impl UnitDiff for Runner {
fn reference_platform(&self) -> Option<crate::platform::ReferencePlatform> {
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]
@@ -104,6 +104,10 @@ fn script(rng: &mut Rng) -> Inputs {
}
impl UnitDiff for Runner {
fn reference_platform(&self) -> Option<crate::platform::ReferencePlatform> {
Some(crate::platform::X86_64_HASH_CASTS)
}
fn id(&self) -> &'static str {
"heal.heal_data_p3"
}
@@ -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<Vec<f64>, 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::<EntityRef>::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<usize> = (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<Script> {
}
}
}
// sm_ComputeFacePointClass over the face (the first topology object): points on its
// corners, edges and interior, lifted off its surface, and strays. Not on the heal.face_props
// degenerate domain [0, 0, t, t] with t = 1e-300: the shim rejects the op there (single-point
// domain) and would drop the whole script with its other ops.
if face.domain[2] - face.domain[0] > 1e-200 && rng.chance(0.6) {
let n = corners.len() as f64;
let centroid = corners.iter().fold([0.0; 3], |s, c| [s[0] + c[0], s[1] + c[1], s[2] + c[2]]);
let centroid = [centroid[0] / n, centroid[1] / n, centroid[2] / n];
let mid = [(a[0] + c[0]) / 2.0, (a[1] + c[1]) / 2.0, (a[2] + c[2]) / 2.0];
for _ in 0..=rng.below(2) {
let mut p = match rng.below(5) {
0 => a,
1 => mid,
2 => centroid,
3 => rp(rng, 3.0),
_ => c,
};
if rng.chance(0.3) {
p[2] += *rng.choose(&[1e-7, 1e-4, 0.05, -0.05]).unwrap_or(&1e-4);
}
let tol = *rng.choose(&[1e-5, 1e-4, 1e-3, 1e-6, 0.05]).unwrap_or(&1e-5);
ops.push(OpIn::FacePointClass { t: 0, f: Some(n_obj), p, tol });
}
}
for _ in 0..=rng.below(2) {
ops.push(OpIn::MakeConsistent { t: 0 });
}
@@ -2742,7 +2808,7 @@ fn edge_cases() -> Vec<(String, Value)> {
OpIn::Gaps { t: 0, cp: [0.0; 3], ep: [3.0, 4.0, 0.0], sol: [0.0, 0.0], pairs: vec![], count: 0 },
];
out.push(("edge_angles".into(), json!(Script { objects: objs, face: None, ops })));
// the topology helpers (the face point classification is deferred in the port)
// the topology helpers
let objs = vec![
line([0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0]),
ObjIn::Plane { o: [0.0, 0.0, 0.0], n: [0.0, 0.0, 1.0] },
@@ -20,7 +20,10 @@
//! flat record documented in the ffi module (per op: code, status, outputs, state of the
//! classification; `GetLoopCrossings` also the face props snapshot when they are passed),
//! reported as the `out` values. A Rust error maps to its `SmStatus` (`InvalidInput` 1007, `-1`
//! for one upstream cannot return); an `Unported` error anywhere defers the case.
//! for one upstream cannot return); an `Unported` error anywhere defers the case. What a
//! `GetLoopCrossings` leaves on the face's B-rep (the UV trim curves, tolerance growth and
//! gap caches of its `SetProps_Stage2` seam check) is kept on the Rust B-rep for the later
//! ops, as the shim's B-rep keeps it.
//!
//! Compared: `GetLoopCrossings` over the face's real loops up to the touch list (the face
//! props stage 2, computed by `SetProps_Stage2` or preset by seeds at stage `2a` or later, the
@@ -52,6 +55,7 @@ use solidcore::intersect::curve_class::{
self as cc_mod, squeeze_out_smallest_interval, ClassificationInit, CurveClassification,
CurveInterval, TouchData,
};
use solidcore::intersect::curve_class::p5::ClassGeometryMut;
use solidcore::intersect::point_class::{
ClassGeometry, ClassObject, ClassObjectKind, PointClassType, PointClassification,
};
@@ -726,6 +730,24 @@ impl ClassGeometry<H> for Pool {
}
}
impl ClassGeometryMut<H> for Pool {
fn curve_mut(&mut self, o: H) -> Option<&mut Curve> {
match o {
H::Obj(i, _) => match self.objs.get_mut(i) {
Some(PoolObj::Curve(c)) => Some(c),
_ => None,
},
_ => None,
}
}
fn topology_entity_mut(&mut self, o: H) -> Option<(&mut Brep, EntityRef)> {
match (o, &mut self.brep) {
(H::Topo(e), Some((b, _))) => Some((b, e)),
_ => None,
}
}
}
impl Pool {
fn handle(&self, i: Option<usize>) -> Result<Option<H>, HarnessError> {
i.map(|i| {
@@ -967,7 +989,7 @@ fn pc_mut(ivl: &mut CurveInterval<H>, w: usize) -> Result<&mut PointClassificati
#[allow(clippy::too_many_lines)]
fn rust_run(s: &Script) -> Result<Vec<f64>, Fail> {
let k = Kernel::default();
let pool = build_pool(&k, s)?;
let mut pool = build_pool(&k, s)?;
let mut ccs: [CurveClassification<H>; 2] =
[CurveClassification::new(&k), CurveClassification::new(&k)];
let mut o: Vec<f64> = Vec::new();
@@ -1148,7 +1170,14 @@ fn rust_run(s: &Script) -> Result<Vec<f64>, Fail> {
hfp::seed(&mut props, face, sd[0] as i64, sd[1] as i64, sd[2]);
}
}
let r = cc.get_loop_crossings(&k, &pool, &hs, with_props.then_some(&mut props));
// in place: what the call leaves on the face's B-rep stays there
// for the later ops, as on the shim's B-rep
let r = cc.get_loop_crossings_mut(
&k,
&mut pool,
&hs,
with_props.then_some(&mut props),
);
match st(r)? {
Ok(lc) => {
o.push(if lc.sm_err { 1.0 } else { 0.0 });
@@ -22,8 +22,6 @@
use crate::{Case, CaseContext, CaseSource, HarnessError, Output, Rng, UnitDiff};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::cell::OnceCell;
use std::rc::Rc;
use solidcore::curve::curve::p2::Curve;
use solidcore::curve::ellipse::{Ellipse, EllipseOptions};
use solidcore::curve::line::{BoundedLineOptions, Line};
@@ -46,12 +44,17 @@ use solidcore::topology::face::p2 as topo_face;
use solidcore::topology::vertex as topo_vertex;
use solidcore::topology::Brep;
use solidcore::{EntityRef, SolidError};
use solidcore_ffi::units::topology_brep_p1::counts;
use solidcore_ffi::units::intersect_curve_class_p6 as ffi;
use solidcore_ffi::units::topology_brep_p1::counts;
use std::cell::OnceCell;
use std::rc::Rc;
/// Curve-classification part 6 oracle.
pub struct Runner;
#[path = "intersect_curve_class_p6/paired_fillet.rs"]
mod paired_fillet;
#[derive(Clone, Serialize, Deserialize)]
struct Fixture {
#[serde(default)]
@@ -564,10 +567,15 @@ impl UnitDiff for Runner {
"intersect.curve_class_p6"
}
fn run_cpp(&self, case: &Case) -> Result<Output, HarnessError> {
if case.inputs.get("paired_fillet").and_then(Value::as_bool) == Some(true) {
return paired_fillet::cpp(paired_fillet::Mode::Metadata);
}
if let Some(mode) = paired_fillet::Mode::from_input(&case.inputs) {
return paired_fillet::cpp(mode);
}
let i: Fixture = case.parse()?;
let result = ffi::run_picked(
i.op, i.kind, i.scale, i.tol, i.flags, i.index, &i.a, &i.b, &i.input, i.pick,
i.editing,
i.op, i.kind, i.scale, i.tol, i.flags, i.index, &i.a, &i.b, &i.input, i.pick, i.editing,
);
if i.expected_rejection {
return rejected(result);
@@ -577,6 +585,12 @@ impl UnitDiff for Runner {
.map_err(|e| h(e.to_string()))
}
fn run_rust(&self, case: &Case) -> Result<Output, HarnessError> {
if case.inputs.get("paired_fillet").and_then(Value::as_bool) == Some(true) {
return paired_fillet::rust(paired_fillet::Mode::Metadata);
}
if let Some(mode) = paired_fillet::Mode::from_input(&case.inputs) {
return paired_fillet::rust(mode);
}
let i: Fixture = case.parse()?;
let validation = ffi::validate_picked(
i.op, i.kind, i.scale, i.tol, i.flags, i.index, &i.a, &i.b, &i.input, i.pick,
@@ -598,7 +612,10 @@ impl UnitDiff for Runner {
let [x, y, z] = *point;
let mut a = classification(&k, &i.a, i.tol, &g.first)?;
let mut b = classification(&k, &i.b, i.tol, &g.first)?;
a.set_fillet_data((i.flags & 2 != 0).then_some(Handle::Other), i.flags & 2 != 0);
a.set_fillet_data(
(i.flags & 2 != 0).then_some(Handle::Other),
i.flags & 2 != 0,
);
let result = match i.op {
// a surf/surf/surf point combined into a vertex boundary moves the box's
// vertex (SmVertex::SetPoint), as the shim's InsertCurveInterval does
@@ -651,6 +668,24 @@ impl UnitDiff for Runner {
}
fn cases(&self, ctx: &CaseContext) -> Result<Vec<Case>, HarnessError> {
let mut out = crate::runner::default_cases(self, ctx)?;
out.push(Case {
unit: self.id().into(),
name: "paired-fillet-real-boxes".into(),
source: CaseSource::Inline,
inputs: serde_json::json!({"paired_fillet":true}),
});
for (name, mode) in [
("linked", "linked"),
("legacy-no-peer", "legacy-none"),
("linked-disabled", "linked-disabled"),
] {
out.push(Case {
unit: self.id().into(),
name: format!("paired-fillet-real-boxes-{name}"),
source: CaseSource::Inline,
inputs: serde_json::json!({"paired_fillet":mode}),
});
}
for kind in 0..5 {
// Competing classifications exercise receiver/argument order, gap
// precedence and saved-point propagation, rather than only assigning
@@ -780,7 +815,14 @@ impl UnitDiff for Runner {
// a degenerate chord: one vertex loop
("face_point", 7, 1e-12, [0, 0, 0, 0], [2, 2, 2], [0.0, 1.0]),
// edge -> edge -> edge along the edge (1,0,1)-(1,1,1): its end vertices
("edge_coincident", 6, 1.0, [0, 0, 0, 0], [3, 3, 3], [0.0, 1.0]),
(
"edge_coincident",
6,
1.0,
[0, 0, 0, 0],
[3, 3, 3],
[0.0, 1.0],
),
// part of that edge: splits it at both ends
("edge_inner", 6, 0.5, [0, 0, 0, 0], [3, 3, 3], [0.0, 0.5]),
// region -> region -> region: a wire edge between two shell vertices
@@ -0,0 +1,294 @@
//! Actual paired classifications; each role resolves into one retained real box.
use super::{
brep_p1, counts, ffi, h, status, topo_edge, Brep, ClassGeometry, ClassGeometryMut, ClassObject,
ClassObjectKind, Curve, CurveClassification, CurveInterval, EntityRef, HarnessError, Kernel,
MergeContext, MergeIntervalBrep, Output, PointClassType, PointClassification, Surface, Vec3,
Volume,
};
use solidcore::topology::{EdgeId, VertexId};
#[derive(Clone, Copy, PartialEq, Eq)]
pub(super) enum Mode {
Metadata,
Linked,
LegacyNone,
LinkedDisabled,
}
impl Mode {
pub(super) fn from_input(input: &serde_json::Value) -> Option<Self> {
match input.get("paired_fillet")?.as_str()? {
"linked" => Some(Self::Linked),
"legacy-none" => Some(Self::LegacyNone),
"linked-disabled" => Some(Self::LinkedDisabled),
_ => None,
}
}
fn code(self) -> i32 {
match self {
Self::Metadata => 0,
Self::Linked => 1,
Self::LegacyNone => 2,
Self::LinkedDisabled => 3,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Side {
Target,
Other,
}
impl Side {
fn get<T>(self, pair: &[T; 2]) -> &T {
let [target, other] = pair;
match self {
Self::Target => target,
Self::Other => other,
}
}
fn get_mut<T>(self, pair: &mut [T; 2]) -> &mut T {
let [target, other] = pair;
match self {
Self::Target => target,
Self::Other => other,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum Object {
Curve(Side),
Topology(Side, EntityRef),
Classification(Side),
}
impl ClassObject for Object {
fn class_kind(&self) -> ClassObjectKind {
match self {
Self::Curve(_) => ClassObjectKind::Curve,
Self::Topology(_, EntityRef::Vertex(_)) => ClassObjectKind::Vertex,
Self::Topology(_, EntityRef::Edge(_)) => ClassObjectKind::Edge,
_ => ClassObjectKind::Other,
}
}
}
struct Pair {
bodies: [Brep; 2],
curves: [Curve; 2],
}
impl ClassGeometry<Object> for Pair {
fn curve(&self, o: Object) -> Option<&Curve> {
if let Object::Curve(s) = o {
Some(s.get(&self.curves))
} else {
None
}
}
fn surface(&self, _: Object) -> Option<&Surface> {
None
}
fn volume(&self, _: Object) -> Option<&Volume> {
None
}
fn point(&self, o: Object) -> Option<Vec3> {
let Object::Topology(s, EntityRef::Vertex(v)) = o else {
return None;
};
Some(s.get(&self.bodies).vertex(v).ok()?.point)
}
fn classification_vertex_point(&self, o: Object) -> Option<Vec3> {
self.point(o)
}
fn topology_entity(&self, o: Object) -> Option<(&Brep, EntityRef)> {
let Object::Topology(s, e) = o else {
return None;
};
Some((s.get(&self.bodies), e))
}
fn topology_zone_tol_3d(&self, o: Object) -> Option<f64> {
let (b, e) = self.topology_entity(o)?;
match e {
EntityRef::Vertex(v) => Some(b.vertex(v).ok()?.tolerance),
EntityRef::Edge(e) => Some(b.edge(e).ok()?.tolerance),
_ => None,
}
}
}
impl ClassGeometryMut<Object> for Pair {
fn curve_mut(&mut self, o: Object) -> Option<&mut Curve> {
if let Object::Curve(s) = o {
Some(s.get_mut(&mut self.curves))
} else {
None
}
}
fn topology_entity_mut(&mut self, o: Object) -> Option<(&mut Brep, EntityRef)> {
let Object::Topology(s, e) = o else {
return None;
};
Some((s.get_mut(&mut self.bodies), e))
}
}
impl MergeIntervalBrep<Object> for Pair {
fn merge_context(&mut self, _: Object) -> Option<MergeContext<'_, Object>> {
// This fixed, already-merged interval edits existing entities through p7.
// Any unexpected topology-creation route remains unsupported.
None
}
}
fn box_at(origin: [f64; 3]) -> Result<Brep, HarnessError> {
let ex =
super::super::heal_stitch_p1::export(&super::super::heal_stitch_p1::StitchBrep::Box {
p: origin,
size: [1.; 3],
})?;
let (_, at) = counts(&ex).ok_or_else(|| h("paired box export failed"))?;
Ok(super::super::topology_brep_p1::import(&ex, at)?.0)
}
fn selected(b: &Brep) -> Result<(EdgeId, [VertexId; 2]), HarnessError> {
let e = *brep_p1::edges(b, None)
.map_err(|e| h(e.to_string()))?
.first()
.ok_or_else(|| h("no box edge"))?;
let (a, b) = topo_edge::start_end_vertices(b, e).map_err(|e| h(e.to_string()))?;
Ok((
e,
[
a.ok_or_else(|| h("missing start vertex"))?,
b.ok_or_else(|| h("missing end vertex"))?,
],
))
}
fn edge_curve(b: &Brep, e: EdgeId) -> Result<Curve, HarnessError> {
b.edge(e)
.map_err(|e| h(e.to_string()))?
.curve
.clone()
.ok_or_else(|| h("missing box curve"))
}
fn snapshot(
k: &Kernel,
p: &Pair,
edges: [EdgeId; 2],
vertices: [[VertexId; 2]; 2],
) -> Result<Vec<f64>, HarnessError> {
let mut out = Vec::new();
for ((b, edge), vertices) in p.bodies.iter().zip(edges).zip(vertices) {
for v in vertices {
let pt = b.vertex(v).map_err(|e| h(e.to_string()))?.point;
out.extend([pt.x, pt.y, pt.z]);
}
let e = b.edge(edge).map_err(|e| h(e.to_string()))?;
let curve = e.curve.as_ref().ok_or_else(|| h("missing edge curve"))?;
let d = curve.natural_interval();
out.extend([
curve.type_code() as f64,
e.interval.min(),
e.interval.max(),
d.min(),
d.max(),
]);
for t in [0., 0.5, 1.] {
let pt = curve
.evaluate_point(k, e.interval.evaluate(t))
.map_err(|e| h(e.to_string()))?;
out.extend([pt.x, pt.y, pt.z]);
}
}
Ok(out)
}
fn classification(
k: &Kernel,
pair: &Pair,
side: Side,
edge: EdgeId,
vertices: [VertexId; 2],
) -> Result<CurveClassification<Object>, HarnessError> {
let mut cc = CurveClassification::new(k);
cc.set_curve(Some(Object::Curve(side)), false);
cc.set_src_zone_tol_3d(1e-5);
cc.set_size(k, 1).map_err(|e| h(e.to_string()))?;
let d = side
.get(&pair.bodies)
.edge(edge)
.map_err(|e| h(e.to_string()))?
.interval;
let pc = |kind, entity, t| {
let mut p = PointClassification::new(1e-5);
p.set_class_object(kind, Some(Object::Topology(side, entity)));
p.set_t_param(t, false);
p
};
let [start, end] = vertices;
*cc.at_mut(0).map_err(|e| h(e.to_string()))? = CurveInterval {
interval: d,
start: pc(PointClassType::Vertex, EntityRef::Vertex(start), d.min()),
mid: pc(PointClassType::Edge, EntityRef::Edge(edge), d.evaluate(0.5)),
end: pc(PointClassType::Vertex, EntityRef::Vertex(end), d.max()),
};
Ok(cc)
}
pub(super) fn cpp(mode: Mode) -> Result<Output, HarnessError> {
let result = if mode == Mode::Metadata {
ffi::paired_fillet()
} else {
ffi::paired_fillet_scoped(mode.code())
};
result
.map(|v| Output::new().values("out", v))
.map_err(|e| h(e.to_string()))
}
pub(super) fn rust(mode: Mode) -> Result<Output, HarnessError> {
let mut k = Kernel::default();
let mut bodies = [box_at([0.; 3])?, box_at([1e-7, 0., 0.])?];
let [target, other] = &bodies;
let (a, av) = selected(target)?;
let (b, bv) = selected(other)?;
let curves = [edge_curve(target, a)?, edge_curve(other, b)?];
for b in &mut bodies {
b.set_editing_enabled(true);
}
let mut pair = Pair { bodies, curves };
let mut left = classification(&k, &pair, Side::Target, a, av)?;
let mut right = classification(&k, &pair, Side::Other, b, bv)?;
let doing = mode != Mode::LinkedDisabled;
left.set_fillet_data(
(mode == Mode::Metadata).then_some(Object::Classification(Side::Other)),
doing,
);
right.set_fillet_data(
(mode == Mode::Metadata).then_some(Object::Classification(Side::Target)),
doing,
);
let before = snapshot(&k, &pair, [a, b], [av, bv])?;
let result = if matches!(mode, Mode::Linked | Mode::LinkedDisabled) {
left.merge_classifications_linked_mut(&mut k, &mut pair, &mut right, false)
} else {
left.merge_classifications_mut(&mut k, &mut pair, &mut right, false)
};
let mut out = vec![];
match result {
Ok((a, b)) => out.extend([
0.,
a.faces.len() as f64,
b.faces.len() as f64,
a.edges.len() as f64,
b.edges.len() as f64,
a.vertices.len() as f64,
b.vertices.len() as f64,
]),
Err(e) => {
return Ok(Output::unported(&e)
.unwrap_or_else(|| Output::new().values("out", vec![status(&e)])))
}
}
out.extend(before);
out.extend(snapshot(&k, &pair, [a, b], [av, bv])?);
if mode != Mode::Metadata {
out.extend([
f64::from(left.other_classification().is_some()),
f64::from(left.doing_fillets()),
f64::from(right.other_classification().is_some()),
f64::from(right.doing_fillets()),
]);
}
Ok(Output::new().values("out", out))
}
@@ -581,7 +581,7 @@ fn run_rust(k: &Kernel, i: &Inputs, st: &mut Stats) -> solidcore::Result<Vec<f64
solver.have_t_min_t_max = *have_t;
solver.t_min_of_bbox = *t_min;
solver.t_max_of_bbox = *t_max;
let r = solver.local_solve(k, surf, leaf);
let r = solver.local_solve(k, surf, leaf, None);
match &r.status {
Ok(()) => rec.push(0.0),
Err(e @ SolidError::Unported { .. }) => return Err(e.clone()),
@@ -693,7 +693,7 @@ fn run_op(
return Err(Fail::Harness(harness("surface cache build failed")));
};
let (tol, ang) = (t.base.approx_tol_3d, t.base.ang_tol_rad);
match st(t.drop_trace(k, &mut cache, Some(tol), Some(ang)))? {
match st(t.drop_trace(k, &mut cache, Some(tol), Some(ang), None))? {
Ok(out) => {
o.push(0.0);
put_acc(o, &out);

Some files were not shown because too many files have changed in this diff Show More