Compare commits

..

25 Commits

Author SHA1 Message Date
bherbruck 6d01515911 implicit, torus: evaluates_as's bound derived term by term; the recognised torus's sweep frame from its form (#3185, #3199) (#13743)
Moved from tangency-labs/solidcore#3224.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 10:19:58 -05:00
bherbruck 8ffb97d541 convert: flat parity pins the bodies that differ, not the corpus size (#13999)
The corpus grows as cases are added, so the merged kernel -> main sync saw
1,337 bodies instead of 1,332 (all five new ones identical) and failed.
Every differing body is already pinned by name; the totals now check that
the rest are identical or within rounding.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 00:27:13 -05:00
bherbruck 8f13e948a9 query + ops: whether two bodies meet, on a certified bracket of their boundary distance (#3144) (#13554)
query::contact decides whether two bodies meet from a certified bracket [lower, upper] of the distance between their boundaries; solidcore-ops exposes it as Body::meets, meets_each and boundary_distance. Undecided carries the bracket and is never read as either answer.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 19:35:56 -05:00
bherbruck 59a85a1646 blend: a circle-class round is certified on its own torus (#3066) (#13900)
Moved from tangency-labs/solidcore#3102.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 19:26:25 -05:00
bherbruck 4890cebd36 fix(boolean): a proto edge's 3D curve and UV curves are trimmed to one interval (#13555)
Fixes #3214.

create_proto_edge trimmed a copy of the 3D curve to the curve interval, then
passed the same interval to each UV curve's trim. A trim snaps an end within
100 scaled zeros onto a knot of its own curve and writes it back, so a UV
curve's knot a few ulps from the 3D trim parameter moved the edge's interval
after the 3D curve had been cut: the new edge ran up to 2.4e-12 past its
curve's natural interval, and the result failed SmEdge test 2 (Bad Domain).

Reported in tangency-labs/solidcraft#13462: a rod cut by two counter-wound
screw sweeps of one channel profile. Each sweep's rail on the rod's cylinder
is a helix, the rails cross, and the crossing is solved from more than one
face pair a few 1e-12 apart.

The 3D curve and the UV curves whose trims accept the interval are now trimmed
to the one interval none of their trims snaps (exact_pieces'
common_trim_interval and trim_unsnapped, as the offset does since #3013). A UV
curve whose trim refuses the interval is trimmed alone, as before. Where no
trim snaps, the interval and the curves are unchanged bit for bit.

Tests: the corpus case booleans/13462-rod-cut-by-two-counter-wound-channel-coils
(closed-form volume, derivation in its expected.method) and
solidcore-ops boolean::a_rod_cut_by_two_counter_wound_channel_sweeps_is_valid,
which fails without the change (NotSolid) and passes with it, cut in turn and
by the sweeps' union. Goldens: topology.proto_topology_p1-p3,
boolean.merge_p1-p5 and boolean.topology_intersector_p1-p3 unchanged;
boolean.api_brep's one change (issue1519_bench_tubes_union_measured, 1e-18)
is the same on main without the change.

The C++ defect is recorded as WR-767 (plan/upstream-issues/).

Co-authored-by: Seth LaForge <sethml@ofb.net>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 19:16:44 -05:00
bherbruck 27d5a264f3 mass integrator: rate holds a range cut into pieces to the accuracy of the whole; Length's Deviations entry (#2894) (#13556)
Finishes the review asks of #2902 that main does not have yet:

- a_rate_holds_a_range_cut_into_pieces_to_the_accuracy_of_the_whole: OdeOptions::rate as
  Curve::length uses it, a range cut into pieces (1, 8, 64) each integrated to half the
  accuracy with the whole range's mean as its rate. On e^t over [0, 4], Runge's
  1 / (1 + 25 t^2) over [-1, 1] and 2 + sin 20t over [0, pi], the pieces' sum is within the
  accuracy of the closed form at 1e-4 to 1e-10. The existing rate test covers one range.
- The doc comment of the_final_step_ends_at_the_interval_end_exactly had been left above
  a_rate_that_is_not_a_finite_nonnegative_number_is_refused; it is back on its test, and the
  refusal test has its own.
- curve/curve/p5.rs: the Length entry of the header's Deviations says, as "Not upstream",
  that the accuracy is held over the whole curve instead of dDesiredAccuracy / span count
  per span (the integrator's header already names rate).

Tests only and doc comments: no result changes.

Co-authored-by: Ahmad Khan <khanahmad4527@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 19:07:12 -05:00
bherbruck 3b82c40ff7 Preserve pcurve parameters over partial edge support (#3203) (#13557)
Co-authored-by: Benjamin Davis <bendavis@gmail.com>
Co-authored-by: OpenAI Codex <noreply@openai.com>
2026-11-08 18:57:37 -05:00
bherbruck a05b47339f fix: ContainsPeriodicValue moves a value just past the start down a period (#1518; carries #1572) (#13558)
* fix: ContainsPeriodicValue moves a value just past the start down a period (opt-in, #1518)

SmExtent1d::ContainsPeriodicValue sets a value within its tolerance of the
top of the period above an interval's start to the start, so a point of an
arc up to 1e-4 degrees past its start inverts to the start, and every point
of an ellipse arc shorter than that does (SmEllipse test 2).

New opt-in Kernel::fixes.periodic_value_past_start_moves_down (WR-509):
the value is moved down by the period and kept when that lies in the
interval, as the fork's tangency-labs/smlib#46. Extent1d gains *_with
variants taking &UpstreamFixes; the callers that hold a kernel pass it.
Off by default: upstream behaviour unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* extent1d: make the WR-509 fix the default, drop the opt-in flag

Since #2647 solidcore adds no UpstreamFixes flags. ContainsPeriodicValue now
always moves a value near the top of the period above the start down by the
period, kept where it lies in the interval, instead of setting it to the
start. The flag, the *_with variants and the old branch are gone; every
caller, including the kernel-less ones, gets the fix.

The flag-off halves of the #1518 tests are now the regression tests (they
fail on the old path). Upstream's MyTest for Extent2d::contains_periodic_point
moves from 1.0 to 0.9 (8.9 mod 4 inside [1, 3] +- 0.2); recorded in WR-509.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* goldens: the ten cases WR-509's ContainsPeriodicValue fix moves, recorded (#1518)

Measured against main 9dc37ee02 on one machine: 279 of 283 units are
unchanged; geom.extent2d (1 case), geom.extent1d (5), curve.ellipse_p2 (3)
and query.face_p3 (1) move. Each case is re-recorded on its own with
--bless --filter, and listed with its old and new values in
plan/findings/geom.extent1d--wr509-moved-ten-cases-2026-11-08.md. The
WR-509 record points there instead of the retired difftest run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: the joined tube ends now check clean; #1518's ellipse line is gone (#1518)

wr579_near_coincident_tube_ends_join pinned the one check failure the joined body
had, SmEllipse test 2 on the 8.6e-5 ellipse piece where the top tube's wall meets
the seat's end plane. That is #1518's own case, and WR-509's fix removes it: the
check is now clean, so the test asks for that instead of the old line. On main the
same assertion fails with that line, so it is the fix's regression test on a real
body as well.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* extent1d: a value kept a period down reports its distance to the start, not a signed offset (#1518 review)

The value WR-509 keeps a period down can lie up to the tolerance below the
interval's start; dist_to_min now takes it as abs there (assert_valid reads the
two distances with min2), only for that case: other inputs keep the signed
value upstream gives them (making it abs for every input moves 46 extent1d and
30 assert_valid goldens). The regression test asserts 5e-5 for 360 - 5e-5 on
[0, 90]; three extent1d golden lines flip sign and are re-recorded one by one.

The findings record now says the ellipse cases' numbers are block sums, not
points, and that they and the face_p3 sector are not checked independently.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Ivica Safranko <safranko@msn.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: austin62jake <340374338+austin62jake@users.noreply.github.com>
2026-11-08 18:48:21 -05:00
bherbruck b53fa7a569 Blend gallery: case-specific notes and renders redrawn from this repo (#13561)
* kernel cases: drop two gallery notes about SolidCraft and the known-failing list

Case 2893's note described the application's error wording, and the
partial-chain case's note restated its known_failing.json line. Neither is
this repo's to say in a gallery note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* kernel cases: KERNEL_CASES_SMB writes each case's bodies for its render

With the variable set to a directory, the runner writes a case's final
bodies, or the inputs of the refusal that ends it, as <case>.smb, so a
gallery render can be drawn with solidcore-diag from this repo alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* kernel cases: the refreshed fillet renders drawn with solidcore-diag

Thirteen of the fourteen refreshed renders are redrawn from the case's own
replay (KERNEL_CASES_SMB) with solidcore-diag --view -30,-30 --size 900x640,
so they carry no status caption or revision hash and can be regenerated
from this repo. Their gallery notes no longer describe the old views and
insets; each says what its case shows, from the case's definition.

cov-fillet-section-curvature-pipe-on-plate-r2 keeps its picture with the
caption strip cut off: the kernel's reader refuses the .smb the replay
writes for it (p2::Surface::read_from_db, status 1001), ASCII or binary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Markus Reichart <52602624+MarkusReichart@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 18:48:16 -05:00
bherbruck 4579e1ad50 Every package builds on the Rust release it declares (solidcore 1.85, ops and convert 1.88), and CI checks it (#3112) (#13563)
* ci: build each package on the Rust release its rust-version declares (#3112)

solidcore declares rust-version 1.85, but nothing checks it: rust-toolchain.toml pins
1.98 for every job. Since fc2212694 the crate no longer builds on 1.91, and it has not
built on 1.85 for longer. This job installs each declared version and runs
`cargo +<version> check` on the packages that declare it. It fails on main.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* solidcore builds on Rust 1.85 again, the release it declares; ops and convert declare 1.88 (#3112)

- blend_corner/mixed.rs: pass the side's curves to `nth_mut` as a slice
  (`as_mut_slice()`). Rust before 1.98 tried to unsize the argument of `of2m`
  instead of its result and refused the call (E0308, E0277): 1.91 to 1.97 could not
  build solidcore.
- fillet_corner/p1.rs, heal/face_props.rs, surface/volume/p2.rs: an error is passed
  on when its reason is a given one through a `matches!` guard on `&e`, not an
  `e @ (... { reason, .. })` binding. Binding a field after a by-move `@` builds
  only from 1.91 (E0382 on 1.85 to 1.90). Same arms, same order, same result.
- solidcore-convert and solidcore-ops depend on solidcore-record, which needs 1.88
  (edition 2024 let chains); they declared the workspace's 1.85, which cargo refuses.
  They now declare 1.88, like solidcore-tess.

With this, every package builds on the rust-version it declares
(scripts/ci/check-rust-version.sh): solidcore, solidcore-ssi, nurbs-grid-mesh and
xtask on 1.85; solidcore-record, -convert, -ops and -tess on 1.88. The whole
workspace also builds on 1.91, the reporter's toolchain.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* ops: pair the mitred rounds with as_chunks (Rust 1.88), as clippy asks now ops declares 1.88 (#3112)

With rust-version 1.88, clippy's MSRV-aware `chunks_exact_with_const_size` lint
fires on `mitred.chunks_exact(2)`. `as_chunks::<2>()` gives the same pairs as
`[EdgeId; 2]` arrays and drops an odd last one, so the arm for a short chunk goes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* ci: the rust-version check fails when no package declares a rust-version (#3112)

If `cargo metadata` gives no package with a rust_version (a renamed field, a
jq filter that stops matching), the loop ran zero times and the check passed
with nothing checked. It now stops with an error instead.

The header says the scope is library and binaries on purpose (no
--all-targets: dev-dependencies may need a newer compiler than the package),
and the failure message says a dependency can be the cause too, pointing at
cargo's "requires rustc" line.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Ahmad Khan <khanahmad4527@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-08 18:41:14 -05:00
bherbruck bc63b3670b sweep: a multi-section blend through ordered cross-sections, G0 to G3 at its ends (#3140) (#13568)
* sweep: a multi-section blend through ordered cross-sections, G0 to G3 at its ends (#3140)

section_blend builds SolidCraft's loft_blend in the kernel: one B-spline surface through every station of ordered cross-sections. At the first and last section it holds the frames' conditions, G0 to G3. Interior sections hold the run's finite differences, C3 with four or more sections and C2 with three. Every span is one Bezier of Hermite rows, elevated exactly. That is SolidCraft's definition of the blend, documented as such, not an interpolant with an error against anything. end_rows, the Hermite-row builder, is public for the bridge to share. Tests against closed forms: two sections at G0 are the ruled surface; G1 ends leave at the definition's speed exactly; five sections of a parabolic cylinder with its own frames reproduce it; interior joints are C3 or C2; the refusals.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* sweep: section_blend grows its vectors as it fills them (panic-site inventory)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* sweep: section blend review: the backward sign, the cubic at u = 0.75 (#3194)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 18:41:10 -05:00
bherbruck dfb240a982 Setback beside curved faces: the spring curve stays inside its trimmed face (#2343 (d)) (#13571)
* test: a spring curve leaving its trimmed face names the setback that keeps it (#2343 (d))

The bored D prism (bore R 6.9) asked 8.5 at its top corner: the top
face's spring curve dips into the bore from asked 7.8068 (closed form,
in the test). Main refuses it late, "a round kept round a feature does
not close on the body", naming no size. Expected: SetbackTooLong at the
vertex naming 7.803 (0.918 x 8.5 on the grid of thousandths), whose
spring curve stays 3.3e-4 clear of the bore, and that setback builds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: a setback corner's spring curve stays inside its trimmed face (#2343 (d))

Beside a curved face the Setback built its spring curves exactly on
their faces but never checked them against the faces' other edges: an
asked setback whose spring curve ran into a bore was built and failed
later, naming nothing. The owner's ruling B on #2343 (d): check what
the corner takes out of the face.

Before the fill, each face's spring curve is made as the fill makes it
(spring_curve, from the rails' ends and their directions in closed
form, the ball's centre and the faces' normal fields) and kept clear of
every other edge of the face by more than the tolerances, decided on
control points (new blend_corner::on_face: hulls of rational Bezier
pieces, halved without recursion, undecided refuses). An asked setback
that fails is refused as SetbackTooLong naming the largest common scale
on the grid of thousandths at which every spring curve stays on its
face (bisect_thousandths, down to where the rails cross); the rule's
setbacks name their scaled setbacks; none: no setback builds. The
setbacks the growth finds are checked too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(findings): the bored D prism's spring curve against its bore (#2343 (d))

The measurement on main and the closed form behind the new test, with
a VectorCraft schematic of the top face: the spring curve at asked 8.5
dips into the bore, the one at the named 7.803 stays clear.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: the spring-curve check reads every line and ellipse, and refuses what it cannot show (#2343 (d))

From the pre-ready review:
- a line or an ellipse edge that holds no B-spline yet gets one made on
  a copy (base_with_nurb, make_nurb), instead of counting as not clear;
- a rail whose direction is not found, or an edge without a curve, is
  not shown clear (refused), instead of letting the corner build
  unchecked; only a spring curve spring_curve refuses is left to the
  fill, which makes it from the same ends;
- the box gap's rounding is 2 gamma(1) of the coordinates, and the
  comments say where monotonicity is assumed, not proved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: the corner's neighbouring faces by checked remainders (#2343 (d))

The panic-site inventory (CI clippy job) flagged three `% n` in
corner_springs. A corner with no face is refused by checked_rem as a
null pointer instead of dividing by zero.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Markus Reichart <52602624+MarkusReichart@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 18:31:28 -05:00
bherbruck a22d42ad98 query + ops: tangent chains on the kernel's proved smooth-edge test (#3141) (#13575)
* query: whether an edge's faces meet tangentially, proved, and the turn and convexity at a vertex (#3141)

query: tangent_edge's verdict is enclosed between its points; convexity read on the material side.

ops: tangent chains on record order, and the face chain across smooth edges.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* query: tangent_edge grows its vectors as it fills them (panic-site inventory)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 18:31:23 -05:00
bherbruck 7c03f57fd9 STEP: compose the product structure into placed bodies (#2578, slice 6) (#13582)
* STEP: the units of a representation context in solidcore-record (#2578)

SolidCraft's crates/step/src/units.rs and the Units type of its read.rs
at a5b71ca2 (MIT OR Apache-2.0), carried over as
solidcore_record::step::units on top of step::p21 with their behaviour
unchanged: the length and plane angle factors of
GLOBAL_UNIT_ASSIGNED_CONTEXT (SI prefixes, CONVERSION_BASED_UNIT chains)
and GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT's distance uncertainty in mm.
Missing or malformed units read as mm and rad; nothing fails. Only
multiplications, no disallowed methods.

Deviations: Units lives in units.rs; the conversion depth bound is the
named MAX_CONVERSION_DEPTH (was the literal 4).

Unit tests with synthetic Part 21 text, and an ignored corpus test that
tabulates the units of every geometric representation context.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* STEP: Part 42 curves as plain data in solidcore-record (#2578)

Slice 2b of #2578: SolidCraft's crates/step geom.rs and read.rs (a5b71ca2), the
curve part, into solidcore_record::step::{geom, read}, without arithmetic: the
reader maps entities to data (references, nesting and count limits, units) and
keeps directions as the file gives them. Normalising, the placement's
perpendicular and y axis, and a vector's length are not carried over; they come
back as kernel primitives in slice 4. Surfaces, surface curves and pcurves
follow in slice 2c.

Unit tests per curve kind and refusal with exact values; corpus_geometry
counts every model-space curve entity of the corpus, read or refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* STEP: Part 42 surfaces and pcurves as plain data in solidcore-record (#2578)

Slice 2c of #2578, on slice 2b: the surfaces of SolidCraft's crates/step
geom.rs and read.rs (a5b71ca2), the surface curves with their pcurves, and
curves in a surface's parameter space, into solidcore_record::step::{geom,
read}, without arithmetic beyond unit factors. A conic in a parameter space
needs equal factors on both parameters (was: equal within 1e-12 relative).

Fixes an unbounded recursion SolidCraft has: the depth bound was checked only
where a 2D curve was entered, so a 2D trimmed curve whose basis chain cycles
recursed until the stack overflowed; every curve read now checks it.
Reader::pcurve reads a PCURVE on its own.

Unit tests per surface kind, pcurve scaling and refusal with exact values;
corpus_geometry also counts every surface and every pcurve, read or refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: trims on a curve over a conic are angles, the failing case (#2578)

A TRIMMED_CURVE on an offset, a trimmed or a surface curve of a circle, in a
file in degrees, keeps its parameters unscaled today (CodeRabbit on #2592).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* STEP: a trimmed curve takes its parameter unit from its basis, through offsets, trims and surface curves (#2578)

The trimmed-curve path chose angles only for a circle or ellipse as the direct
basis; curve_param_kind already follows offset, trimmed and surface curves to
the conic underneath, as the revolution and extrusion profiles use it. Now both
rules are one. SolidCraft's reader has the same gap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* STEP: the product structure as plain data in solidcore-record (#2578)

solidcore_record::step::product reads a parsed exchange structure into
products, product definitions, the shape representations they reach
(items: bodies by entity id, mapped items, others; context and units),
representation maps, relationships between representations with their
transformation as the file gives it (ITEM_DEFINED_TRANSFORMATION's two
placements, or an operator), assembly occurrences with their placements,
and styled items with their surface and curve colours (COLOUR_RGB as
written, ISO 10303-46 pre-defined colours from a fixed table).

No arithmetic beyond unit factors: composing placements is the kernel's,
sequenced by ops (slice 6). Cycles and nesting past MAX_ASSEMBLY_DEPTH are
refused; reading is linear, and the expansion of the assembly tree stops
past MAX_VISITS. Indices into id-ordered vectors, no HashMap order.

From the data walk of SolidCraft's crates/kernel/src/step_in/mod.rs at
a5b71ca2 (MIT OR Apache-2.0). corpus_product checks all 108 corpus files
against their entity counts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* STEP: compose the product structure into placed bodies (#2578, slice 6)

The record's product structure keeps placements as the file writes them; this adds the kernel matrices and the ops walk that turn occurrences, mapped items and transformation operators into per-body world transforms. It emits one PlacedBody per body instance, leaving B-rep construction and display to their existing owners.

Stacked on #2645 (product data); slice 6 of #2578.

* STEP: keep the corpus harness compiling after the rebase

* Update crates/solidcore-record/tests/step_p21_corpus.rs

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

---------

Co-authored-by: Markus Kaltenbrunner <153593707+kaltenbrunnermarkus@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: AlexMitchellMus <12004932+AlexMitchellMus@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-11-08 18:22:08 -05:00
bherbruck 91a08fc63a stitch: certify each closed gap and report the tolerance growth it applies (#2975) (#13592)
* stitch: report each closed gap and the tolerance growth its closure applies

Addresses review help wanted solidcore#2975: certify each closed gap.

- `heal::stitch::p2` (not upstream, on the stitcher): `ClosedGap` carries
  the closed entities (`Vertex`/`Edge`/`Squeezed`/`Split`), the gap the
  stitcher measured, and the tolerance growth the closure applied — read
  from the fused entity's tolerance before and after its own glue/squeeze.
  Collected only when the caller asks (zero cost off).
- `topology::brep::p4`'s `stitch_faces_with_gap_report` returns the batch;
  `topology::brep::p5`'s `stitch_and_orient_reported` exposes it over the
  same two passes. Nothing else the stitch does changes: no construction
  edit, and the growth does not feed back into the construction.
- `issue_2975_stitch_gap_report.rs`: two planar squares 1e-5 apart glue at
  both corners, each closure reporting gap == growth == 1e-5 (the glue
  keeps the closed distance inside the surviving tolerance), with the edge
  glue present; an exact match reports growth == 0 everywhere.

Evidence: fmt (1.98.0) and clippy `-D warnings --all-targets` clean;
`cargo test -p solidcore` whole-crate run green except an unrelated
`fillet_round_failure_notes_status::the_rollover_failure_notes_its_status`,
which reproduces on the clean base with the change stashed (pre-existing
on main, not this change's doing); `heal.stitch_p1` and `heal.stitch_p2`
goldens PASS 674 and 841 cases with zero changed/added/removed/broken;
wasm32-unknown-unknown check clean.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test: make the exact stitch report assertion non-vacuous

The previous duplicate-face setup returned an empty report. Use two adjacent squares with an exactly shared boundary and require all three closures: two endpoints and their edge, with zero measured gap and zero growth.

Both report regressions and focused clippy -D warnings pass. This does not establish a whole-curve certificate: the reporting implementation still exposes sampled curve distance and certification remains unfinished.

Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>

* fix: enclose stitch gaps and roll back failed reports

Bound points and tolerance growth outward, use raw homogeneous interval de Boor enclosures across every knot span, and bound curve pairs by a full parameter correspondence. Equal positive rational bases use their control-point differences; line pairs use their endpoints. Unsupported curve kinds, malformed or non-positive weights, extrapolation and non-finite enclosures fail explicitly, never by substituting samples.

Measure each survivor against its own old tolerance, including inherited growth. Bound removed squeeze geometry and the original curve at each split parameter. The reported wrapper propagates errors regardless of legacy status flags and restores the full B-rep on failure. Unreported construction and sampled acceptance decisions are unchanged.

Local evidence: five enclosure controls and four report regressions pass, including an interior bump missed by five samples, rational and reversed/subdomain inputs, tiny/overflowing data, and failure after two vertex glues with full binary/arena-id rollback under flags off and on. Fmt passes. A clippy test-predicate style error is corrected; final lint, wasm, affected goldens and canonical exact-head CI remain pending. This stays draft.

Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>

---------

Co-authored-by: Alex Lytvynenko <altvnk@me.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: GPT-6.1 Sol <noreply@openai.com>
2026-11-08 18:12:53 -05:00
bherbruck 193a13d7fc mass: the volume's sign from a certified enclosure (#13593)
* mass: the volume sign's tests, failing on an enclosure stub (#2972)

`mass::enclosure` declares the certified enclosure of a body's volume
(`enclose_volume`, `volume_sign`, `VolumeSign::{Positive, Negative,
Undecided}`) and, for now, encloses nothing: every sign is undecided over
the whole line. The tests ask for enclosures that hold closed forms:

- a quarter shell R 1e4, t 1e-3, L 10 (V 157.08; the estimate at the sign
  test's accuracy 1e-3 reads -180.05) and R 10, t 1e-4 decide positive;
- the box 40x30x20 with one edge rounded r 3 is enclosed within 1e-6 V
  around 24000 - 360 (1 - pi/4), default kernel and round definitions on;
- a box decides +120, its inside-out twin -120; a sphere r 2 holds
  4/3 pi 8 within 1e-6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* mass: enclose a closed body's volume and decide its sign (#2972)

`enclose_volume` and `volume_sign` replace the quadrature's estimate by
an enclosure [lo, hi] of the same flux, F = (0, 0, z - z0), face by face
as `brep_properties` integrates it (Green's theorem on each face, the
trims swept down to a base):

- each trim piece (a pcurve's polynomial piece, or a uv segment closing
  a loop) is swept to the base piecewise between the surface's v knots,
  and each cell of (t, sigma) is a Taylor model: the integrand's Taylor
  polynomial of degree 7 at the cell's middle, its coefficients enclosed
  there and integrated exactly, plus the Lagrange remainder from the
  degree-8 coefficients enclosed over the cell (rounded intervals,
  `curve_derivs_over`, and the new `nurbs_partials_within_span`, which
  cuts the weight's enclosure to the piece's control weights where the
  box lies in its span);
- a cell whose image may cross a knot line is enclosed by its range,
  the integrand in its factored form -(S_z - z0) (S_u x S_v)_z u' b, so
  a trim along a knot line in u (u' near 0) stays near 0; a cell whose
  weights are not proved positive is cut along t and sigma in turn;
- the faces' trimmed boundaries are joined across each edge by the ruled
  strip between two of its face sides of opposite sense (two at a
  manifold edge, four where solids touch), bounded from their enclosed
  distance (not from the edge tolerance, which is sampled), and what is
  left open at the vertices by cones of area <= L^2 / 4; a closing
  segment along a pole (to within rounding of the net's last knot) is
  bounded through the pole row's spread;
- each cell is first evaluated at remainder degree 3 and again at 8
  when it is the widest, before it is cut (a body that decides at once,
  as most do, costs little: the 21 sign decisions of the rig's Menger
  sponge 62 s -> 10.6 s);
- the widest cell is bisected until the enclosure is as narrow as asked
  or decides the sign (lo > 0, hi < 0), within 2^17 cells, no recursion,
  the cancel token polled; `Undecided` only when 0 is still inside.

On the tests' bodies: box 4x5x6 [119.99999999988, 120.00000000012],
the rounded box [23922.7378, 23922.7489] around 23922.7433, the G2
round (387 cells) positive, sphere r 2 within 1e-6 of 33.5103216383,
the quarter shell R 1e4, t 1e-3 (V 157.08, the estimate at 1e-3
-180.05) positive, nurb_fillet.smb positive.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* topology: the gate and is_outward_solid read the certified volume sign (#2972)

The local operation's soundness gate and `is_outward_solid` decided a
body's volume sign from `brep_volume_default` (at 1e-6 and 1e-3), an
estimate. Both now read `mass::enclosure::volume_sign`:

- `validation::volume` becomes `validation::volume_sign`, and
  `Soundness::volume` holds the sign; a body is sound only where its
  volume is proved positive;
- the gate refuses a negative enclosure as `VolumeNotPositive`, and an
  enclosure that still holds 0 after its budget as the new
  `Unsoundness::VolumeUndecided`;
- `is_outward_solid` (and `is_outward_touching_solids`) is true only for
  a proved positive volume; `SIGN_ACCURACY` goes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* mass: the enclosure's bound sums and widths, with a test that they round up (#2972)

The strip, cap and rounding-gap bounds add magnitudes (`mag3`) and take
widths `hi - lo` in plain f64, and round-to-nearest can put the result
below the true sum (CodeRabbit on #3006). `width` gathers the widths;
`bounds_round_up` asks both for a value above 1 where 1 + 1e-16 rounds
to 1. It fails here: both still round to the nearest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* mass: the enclosure's bound sums and widths round up (#2972)

`mag3` sums the magnitudes in `Ivl` and `width` takes `hi - lo` in
`Ivl`, each returning the upper end. So the strip and cap lengths, the
rounding gaps' terms, a closing segment's length and the pole row's
spread are bounds again, as the module's contract says, not values half
an ulp below one (CodeRabbit on #3006). `bounds_round_up` passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* mass: tests for an undecided sign and an unreadable face (#2972)

From jyviko's review of #3006:
- the quarter shell R 10, t 1e-6 passes check_brep level 2, but its
  faces' boundaries lie ~1e-6 apart, as far as its volume (1.57e-4):
  its sign is undecided, `is_outward_solid` reads that as not a solid,
  and the local operation's gate refuses it as `VolumeUndecided`. The
  integration test asks the decision to end below a quarter of the
  budget; it fails here (131072 cells, 131054 of them strips, which
  cutting does not narrow);
- a box with an offset-surface face is an `Unsupported` error, not a
  sign;
- `nurbs_partials_within_span` over a box across the span's knot is
  `nurbs_partials_over_piece`'s enclosure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* mass: an undecided sign ends where no cut can decide it (#2972)

A strip cell whose `int |D|` is at least half the gap's least size over
it (at its middle, less its slope's bound times the half length, in the
norm `int |D|` is taken in) is settled: a real gap between two faces'
boundaries, which cutting does not narrow. Settled cells are kept as
they are; when their half width with the fixed terms reaches the
magnitude of everything else, 0 stays inside whatever the rest becomes,
and the sign is `Undecided` at once. The quarter shell R 10, t 1e-6
ends after 24236 cells instead of 131072.

A unit test spends a budget of 16 cells (production keeps
`CELL_BUDGET`). `is_outward_solid` says that an undecided sign is "not
a solid" on purpose, and which bodies are an error instead (a face with
no NURBS form, without trim loops, or weights not proved positive);
docs/validity-and-tolerances.md §4 reads the certified sign. The
`Cells.` paragraph of the module doc is wrapped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: invariant 2 carries the volume sign's exception until the enclosure widens by epsilon (#2972)

The solid test and the local operation's gate read a certified face on its
net B (jyviko's option 1 on #2972); the wording is MarkusReichart's
proposal on the PR, to be confirmed by jyviko.

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

---------

Co-authored-by: Markus Reichart <52602624+MarkusReichart@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
2026-11-08 18:12:48 -05:00
bherbruck 2e8e68c64d convert: parity with SolidCraft's flat hop on the corpus; lines as the kernel holds them, tolerances as the flat import took them, Order's pieces (#3137) (#13570)
* convert: parity with SolidCraft's flat hop on the corpus; lines as the kernel holds them, tolerances as the flat import took them, Order's pieces (#3137)

Every operand record of the rig corpus is the flat export's output. Each goes to the kernel and back and is compared bit for bit (`tests/flat_parity.rs`, the whole corpus): 1,256 of 1,332 bodies come back identical, 37 within about 2 ulp, and 39 differ only in named classes.

- `record_of` writes a line as the kernel holds it: its point, point plus vector, over the STEP parameters of the edge's ends (`FlatFromBrep`'s form), not vertex to vertex over [0, 1]. That reparameterised every line and its pcurves, in 711 bodies.
- `to_kernel` keeps each edge's and vertex's own tolerance, the body's and each face's the least of theirs, as the flat import's `own_tolerances` does, instead of raising entities to the body's.
- `Order` gains its inverse maps and `pieces`, SolidCraft's `history::Piece`: the record edges of a kernel edge solids touch along.

Part of #3137

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* convert: pin flat_parity's differences per body; own_tolerances keeps invariant 5 and refuses an underflowed zone (#3187 review)

flat_parity pins every differing body (40) to exactly its classes and
asserts the totals (1332 / 1256 / 36), the rounding class relative with no
floor (largest 4.885e-16, asserted), .tolerance only lowered to the least
of the record's own, and the sheet-side bodies' geometry the same as
multisets. own_tolerances documents invariant 5 (validity-and-tolerances
§10) and returns Invalid where the least zone underflows to 0 instead of
falling back silently. Tests: a body tolerance above its entities comes
down to theirs, valid at Level 2; an underflowed zone is refused; a reversed
line goes to the record with its vector turned; pieces' faces are their
record edge's uses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: the seam case inflates every stored tolerance, not only the body's (#3187 keeps each edge's own)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 18:05:37 -05:00
bherbruck 11d408d8c2 tess: the pole classifier runs once per surface, not per evaluated point (#3152) (#13559)
* solidcore-tess: ask the pole classifier once per node, not once per look

The collapsed directions at a node are a property of its parameters, but sag
refinement, the flip pass and the sag measure asked the kernel's classifier
again at every triangle corner, side and round (and the self-crossing check
twice per node). A Poles memo, indexed by node and shared by the three passes
of a face, asks it once per node.

Classifier calls on the smlib coil half (3 faces), display / export:
1,089,434 / 1,091,452 before, 42,846 / 42,654 after. Triangle counts unchanged
(30,776 / 30,808); the meshes are the same values, only the repeated question
is gone. Regression test: the_pole_classifier_is_asked_once_per_node_of_a_spline_face.

Refs #3152

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

* solidcore-tess: count the classifier through the memo, no thread-local

The test counter was a thread_local in the library crate. Poles now carries a
test-only call counter; the test refines, flips and measures a coarse
cylinder patch and asserts at most two classifier calls per node. With the
memo's lookup removed (the old behaviour) it fails: 25,852 calls for 349
nodes. A second test keeps the coil's triangle counts (30,776 / 30,808).

Refs #3152

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

---------

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 17:58:01 -05:00
bherbruck 1b4547f2b3 goldens: the five extrusion defeature cases are no longer known failing, and are blessed (#3093) (#13560)
#3176 made the extension's mirror step reflect on the knot it lands beside, so the
add-by-faces extrusion builds the same closed solid whatever the last bits of the
extended range. The five cases listed under #3093 in known_failing.json
(add_by_faces_extrusion, attributes_add_by_faces_extrusion, multi_feature_extrusion,
multi_feature_adv_extrusion, multi_rebuild_0_extrusion) are removed from it and their
golden lines added (a listed case has no golden line; a fixed one must).
single_face_fillet1019_6 also cites #3093 but is a different defect (check_brep level 2
on a fillet), is not shown fixed, and stays listed.

Evidence: the soundness probe on #3176 builds all five closed and manifold, 13 faces,
27 edges, 20 vertices, volume 788.9154262527, 0 check_brep lines. The check run of the
unit lists exactly these five as added and the other three extrusion cases unchanged.
The lines were produced on aarch64; CI's x86 run is the reference and any line it
lists as changed is taken from it.


Claude-Session: https://claude.ai/code/session_01LUXeKMptsUdYotbrqYpSda

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 17:57:56 -05:00
bherbruck ae4f93a7cf sphere: a far sphere's meridian angles are held to their rounding at the points' size (#13565)
* sphere: a far sphere's meridian angles are held to their rounding at the points' size

`is_nurb_surface_sphere` compares the half meridian with 180 degrees, and the circle's STEP interval with the sphere's, within fixed degree tolerances. A sphere of radius 2.5 at 1e5 reads 180 + 6.67e-10 degrees and was not recognised (#3131). The angles are read off points known to gamma_K |P| (K = 16 (p + q + 2), the bound `evaluates_as` uses) and a centre fitted to them, so each is now allowed 4 gamma_K |P| / r radians more. It is recognised from 0 to 1e6, and a label or form 1e-3 off is refused at every offset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* sphere: one allowance for the meridian's angles, in the recogniser and its rebuild, tested at its size

#3196 review. `meridian_rounding` is the one bound for the half meridian and the STEP interval in `is_nurb_surface_sphere` and for the half meridian in `rebuild_step_from_nurb_parameters` (which kept upstream's fixed degrees). K comes from the form's degrees; the fitted centre's share is measured, not derived, and the doc says so (largest excess 0.0055 of the allowance, radii 1e-3 to 1e3, offsets 0 to 1e7, turned spheres). Tests: a meridian 0.4 of the allowance past 180 degrees is recognised and twice it refused, at the origin and 1e5 (a meridian d past folds the STEP interval by 2d); the allowance against its closed form (1.8e-11 degrees at the origin, 9.77e-8 at 1e5); recognition across radius, offset and orientation. The header records the default change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 17:57:48 -05:00
bherbruck 59b78db068 extend: certify_iso takes its rounding at the iso's own size again (#13567)
* extend: certify_iso takes its rounding at the iso's own size again

f99618c27a sized the iso's rounding from the net's rows, which widened the limit. With every row of a turn at one weight ratio (c74d421fbf), off / limit at the iso's own size is at most 0.0123 over 1004 goldens calls, 0.0079 in the extend tests, 0.0059 in the 500 and 1500 mm regression, and 0.0045 over 192 calls on SolidCraft's pleats: the 500 mm refusal was the row ratios' noise, not the bound. The widening is reverted (#3098, #3068).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* extend: certify_iso's limit is held to the iso's own size by a test, and labelled tuned

#3195 review. The limit's factor, 8 gamma_32, counts no operations: the doc now says it is tuned, not derived, and measured within it (largest off / limit 0.0123). A unit test pins its size: one point of a parallel on a cylinder 1000 long, off its plane by twice the limit at the point's own size, is refused, and by half of it is accepted; a limit at the rows' size would accept both. A cylinder of radius 1 extended 1e2 to 1e5 along its rulings stays on its cylinder: the limit at the iso's size holds as the extension outgrows the source.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 17:57:44 -05:00
bherbruck 9210343c9d kernel rig: check every pick before reporting a traced miss (#2993) (#13552)
* test: a traced miss before a malformed pick must not hide the bad data (#2993)

`a_traced_miss_is_unsupported_only_among_well_formed_picks`: `[{miss}, {index: 999}]`,
`[{index: 999}, {miss}]`, a miss before an empty pick and before a pick that both hits
and misses fail; `[{index: 0}, {miss}]` is unsupported. Fails on main at the first list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* kernel rig: check every pick before reporting a traced miss (#2993)

`picked` collected with `collect`, which stops at the first `Err`, so a traced miss
before a malformed pick graded the case unsupported. It now checks each pick's shape
and range first and reports the miss only after the whole list is well formed.
`record_picks`, `record_pick` and the arms that call them go through `picked`.

Fixes #2993

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Werner1303 <102161652+Werner1303@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 17:48:36 -05:00
bherbruck cce1c9dcf5 fix: 72 open panic sites in blend files no open PR edits (#2490, slice 3) (#13553)
* fix: 16 open panic sites in blend files no open PR edits (#2490, slice 3)

Each cannot panic and is marked proven with its guard: an index from
position() or enumerate() on the same Vec with nothing changed before it,
a bounds check or early return just above (dual_run, move_tsp, topology_split),
a while loop whose every increment continues, an enumerate() over the Vec
whose length divides, or a capacity read from an in-memory collection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: five more open panic sites in blend files, each inside a loop over its divisor (#2490, slice 3)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: 14 more open panic sites in fillet_corner/p3.rs and fillet_geom/p4.rs (#2490, slice 3)

Each guarded by a check that returns above, an index found in the same Vec,
a branch that fixes the divisor at 4, or an in-memory length.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: 29 more open panic sites in blend corner files, each with its divisor shown non-zero (#2490, slice 3)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: 8 more open panic sites in blend files: in-memory capacities, a constant, two checked divisors (#2490, slice 3)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: austin62jake <340374338+austin62jake@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 17:48:31 -05:00
bherbruck 11bb98683f fix: 28 open panic sites in surface and boolean files no open PR edits (#2490, slice 3) (#13573)
* fix: 25 open panic sites in surface files no open PR edits (#2490, slice 3)

Cone::evaluate joins its rows with concat, which sizes the result from the
rows evaluate_rows built, so its with_capacity site is gone. The other 24
sites cannot panic and are marked proven with the guard that rules each out:
an early return (cone ellipse trims, extrusion plane cut, grid row size), an
enclosing for loop over the divisor (plane ellipse, step-window unwind), an
in-memory length (step-window split, cache splits), a checked order bound
(srf_in_volume) or a constant divisor (cache block halving).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: three open panic sites in boolean files no open PR edits (#2490, slice 3)

merge/p2.rs MergeState::merge_added_body and topology_intersector/p2.rs
teardown_reads_freed remove an index position() just found in the same Vec;
TopologyIntersector::intersect_topology sizes a Vec from an in-memory
slice. All three are marked proven with that guard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: austin62jake <340374338+austin62jake@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 17:48:16 -05:00
bherbruck 55297e5b30 solidcore README: developed inside SolidCraft; this repo is a mirror (#13627)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 17:48:11 -05:00
152 changed files with 22744 additions and 5330 deletions
+14
View File
@@ -230,6 +230,20 @@ jobs:
- run: rustup target add wasm32-unknown-unknown
- run: cargo check -p solidcore --target wasm32-unknown-unknown
# Each package builds on the Rust release its `rust-version` declares (scripts/ci/
# check-rust-version.sh). Downstream users build on older pinned toolchains than
# rust-toolchain.toml's, and nothing else here runs one.
rust-version:
needs: changes
if: needs.changes.outputs.full == 'true'
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- uses: actions/checkout@v4
- uses: Swatinem/rust-cache@v2
with:
workspaces: ". -> bin"
- run: scripts/ci/check-rust-version.sh
# Same packaging check the release workflow runs before publishing (no secrets).
publish-dry-run:
needs: changes
+2
View File
@@ -1,3 +1,5 @@
> **solidcore is developed inside [SolidCraft](https://github.com/tangency-labs/solidcraft), at [`solidcore/`](https://github.com/tangency-labs/solidcraft/tree/main/solidcore).** The [tangency-labs/solidcore](https://github.com/tangency-labs/solidcore) repository is an automatic, read-only mirror of that folder. Open issues and pull requests in [tangency-labs/solidcraft](https://github.com/tangency-labs/solidcraft): kernel-only work against the `kernel` branch.
<p align="center"><img src="assets/logo.svg" width="140" alt="solidcore logo: an isometric solid with one filleted edge and a NURBS curve on its top face"></p>
# solidcore
@@ -1,6 +1,8 @@
{"format":1,"unit":"blend.feature_executive"}
{"case":"add_by_faces_extrusion","deferred":null,"error":null,"ints":{"base_graph":{"blocks":[[2774,380658],[2585,341911],[6017,756869],[2124,294143],[7047,692538],[7860,1181034],[13215,1649521],[2767,78418]],"n":1843},"feature0_graph":{"blocks":[[683,104052],[34,109]],"n":261},"feature1_graph":{"blocks":[[1287,176984],[2441,349823],[4137,677755],[748,14692]],"n":804},"feature2_graph":{"blocks":[[1305,197906],[1748,275169],[1932,107176]],"n":623}},"topology":null,"values":{"base_geometry":{"blocks":[[2.1599999999999874e+22,2.1599999999999874e+22,2.3436e+24],[550.6115412070844,687.1779118214433,52744.46080298995]],"n":442},"base_record":{"blocks":[[155445.255470152,155489.82184076635,18956276.02122212],[155029.88762338806,155061.88762338806,20154954.18077134],[116389.18300344986,116429.18300344986,12866754.328732701],[106102.57307303276,106122.57307303276,13684737.362150839],[45934.73239171605,45934.73239171605,3587621.6467926963]],"n":1183},"feature0_geometry":{"blocks":[[2.4e+21,2.4e+21,2.9999999999999996e+22]],"n":56},"feature0_record":{"blocks":[[87791.71681469283,87832.84955592154,6389177.88502191]],"n":197},"feature1_geometry":{"blocks":[[9.6e+21,9.6e+21,4.6559999999999995e+23]],"n":194},"feature1_record":{"blocks":[[155065.5,155109.5,18928751.0],[105021.5,105109.5,12645808.0],[135.0,135.0,1804.0]],"n":536},"feature2_geometry":{"blocks":[[7.2e+21,7.2e+21,2.628e+23]],"n":148},"feature2_record":{"blocks":[[142729.0772253012,142729.0772253012,15852924.877656039],[60998.52675559926,60998.52675559926,4015371.2724186946]],"n":423},"record":{"blocks":[[326.0,346.0,12744.0]],"n":124}}}
{"case":"all_faces_defillet","deferred":null,"error":null,"ints":{"base_graph":[1,0,0,0,0,0,0,0,0,0,1],"feature0_graph":{"blocks":[[1743,230242],[2881,397902],[3189,537461],[5001,812093],[4768,323418]],"n":1165}},"topology":null,"values":{"base_geometry":[],"base_record":[0.0,0.0],"feature0_geometry":{"blocks":[[1.3599999999999958e+22,1.3599999999999958e+22,9.316000000000001e+23],[14.000069999999997,14.000069999999997,140.00077]],"n":276},"feature0_record":{"blocks":[[154466.63959673684,154466.63959673684,18841428.541271232],[129333.9587499411,129333.9587499411,14745007.961884614],[90266.21715728752,90266.21715728752,11236079.691673888],[9.0,9.0,68.0]],"n":779},"record":[1.0,0.0,0.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,0.0,9.0,1.0,0.0]}}
{"case":"all_faces_filleted_box","deferred":null,"error":null,"ints":{"base_graph":[1,0,0,0,0,0,0,0,0,0,1],"feature0_graph":{"blocks":[[1903,220645],[3189,516856],[3449,269618],[5567,663498],[7339,1115480],[11638,1409058]],"n":1528}},"topology":null,"values":{"base_geometry":[],"base_record":[0.0,0.0],"feature0_geometry":{"blocks":[[1.91999999999999e+22,1.91999999999999e+22,1.8528000000000002e+24],[452.0003399999995,452.0003399999995,26406.02375]],"n":378},"feature0_record":{"blocks":[[155397.5,155397.5,18957409.781469982],[154927.4900204164,154927.4900204164,20151093.307439756],[103966.76733889646,103996.76733889646,12124882.750080192],[75978.24264068715,76068.24264068715,7458816.37737983]],"n":988},"record":[1.0,0.0,0.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,0.0,10.0,1.0,0.0]}}
{"case":"attributes_add_by_faces_extrusion","deferred":null,"error":null,"ints":{"base_graph":{"blocks":[[2774,380658],[2585,341911],[6017,756869],[2124,294143],[7047,692538],[7860,1181034],[13215,1649521],[2767,78418]],"n":1843},"feature0_graph":{"blocks":[[683,104052],[34,109]],"n":261},"feature1_graph":{"blocks":[[1287,176984],[2441,349823],[4137,677755],[748,14692]],"n":804},"feature2_graph":{"blocks":[[1305,197906],[1748,275169],[1932,107176]],"n":623}},"topology":null,"values":{"base_geometry":{"blocks":[[2.1599999999999874e+22,2.1599999999999874e+22,2.3436e+24],[550.6115412070844,687.1779118214433,52744.46080298995]],"n":442},"base_record":{"blocks":[[155445.255470152,155489.82184076635,18956276.02122212],[155029.88762338806,155061.88762338806,20154954.18077134],[116389.18300344986,116429.18300344986,12866754.328732701],[106102.57307303276,106122.57307303276,13684737.362150839],[45934.73239171605,45934.73239171605,3587621.6467926963]],"n":1183},"feature0_geometry":{"blocks":[[2.4e+21,2.4e+21,2.9999999999999996e+22]],"n":56},"feature0_record":{"blocks":[[87791.71681469283,87832.84955592154,6389177.88502191]],"n":197},"feature1_geometry":{"blocks":[[9.6e+21,9.6e+21,4.6559999999999995e+23]],"n":194},"feature1_record":{"blocks":[[155065.5,155109.5,18928751.0],[105021.5,105109.5,12645808.0],[135.0,135.0,1804.0]],"n":536},"feature2_geometry":{"blocks":[[7.2e+21,7.2e+21,2.628e+23]],"n":148},"feature2_record":{"blocks":[[142729.0772253012,142729.0772253012,15852924.877656039],[60998.52675559926,60998.52675559926,4015371.2724186946]],"n":423},"record":{"blocks":[[337.0,357.0,14391.0]],"n":164}}}
{"case":"attributes_inner_loop_extrusion","deferred":null,"error":null,"ints":{"base_graph":{"blocks":[[2548,339314],[2808,388653],[5366,597446],[3316,572452],[5984,574809],[9450,1382528],[11491,1234945]],"n":1760},"feature0_graph":{"blocks":[[683,104052],[34,109]],"n":261}},"topology":null,"values":{"base_geometry":{"blocks":[[2.0799999999999883e+22,2.0799999999999883e+22,2.1736000000000002e+24],[363.8030081955735,560.3693788099324,25313.152184450642]],"n":418},"base_record":{"blocks":[[155416.25547015207,155492.8218407664,18950600.3615193],[155118.1152126431,155118.1152126431,20152219.349649835],[116983.14328273362,117023.14328273362,13745846.636862068],[91170.82416150477,91234.82416150477,11435584.550245512],[30337.0,30353.0,1019935.0]],"n":1118},"feature0_geometry":{"blocks":[[2.4e+21,2.4e+21,2.9999999999999996e+22]],"n":56},"feature0_record":{"blocks":[[87791.71681469283,87832.84955592154,6385487.88502191]],"n":197},"record":{"blocks":[[81.0,89.0,1463.0]],"n":58}}}
{"case":"attributes_rebuild_fillets_defillet","deferred":null,"error":null,"ints":{"base_graph":{"blocks":[[1906,252991],[3366,516788],[2314,400069],[4443,571496],[8162,998322]],"n":1264},"feature0_graph":{"blocks":[[853,122476],[847,63880]],"n":377}},"topology":null,"values":{"base_geometry":{"blocks":[[1.439999999999995e+22,1.439999999999995e+22,1.044e+24],[21.200139999999998,21.200139999999998,379.20287]],"n":296},"base_record":{"blocks":[[154399.14159265358,154399.14159265358,18832865.4645943],[128406.54159265356,128406.54159265356,14075364.427283542],[105272.0,105272.0,13472688.57781746],[15078.741592653581,15082.741592653596,542971.1221134894]],"n":844},"feature0_geometry":{"blocks":[[4e+21,4e+21,8.199999999999998e+22]],"n":82},"feature0_record":{"blocks":[[115424.47391131187,115428.47391131187,10922544.14210909]],"n":245},"record":{"blocks":[[79.0,89.0,1508.0]],"n":70}}}
{"case":"attributes_restore_defillet","deferred":null,"error":null,"ints":{"base_graph":{"blocks":[[1508,183428],[2419,270746],[3057,403507],[6277,856170],[551,3370]],"n":1035},"feature0_graph":{"blocks":[[278,24878]],"n":146}},"topology":null,"values":{"base_geometry":{"blocks":[[1.1999999999999975e+22,1.1999999999999975e+22,7.260000000000001e+23]],"n":252},"base_record":{"blocks":[[154397.14159265358,154397.14159265358,18832863.4645943],[130360.84159265357,130360.84159265357,15657691.00865363],[60190.0,60190.0,6048065.0]],"n":714},"feature0_geometry":{"blocks":[[1.6e+21,1.6e+21,1.36e+22]],"n":38},"feature0_record":{"blocks":[[57216.03231865831,57216.03231865831,2227522.302144891]],"n":115},"record":{"blocks":[[59.0,67.0,823.0]],"n":55}}}
@@ -54,6 +56,9 @@
{"case":"fillet_group_torii_fillet_rb11","deferred":null,"error":null,"ints":{"base_graph":{"blocks":[[2573,312590],[2235,302121],[7287,1092477],[3374,249678],[9425,1441704],[5510,902128],[13186,1872602],[16615,2004017],[1666,20910]],"n":2071}},"topology":null,"values":{"base_geometry":{"blocks":[[2.5599999999999832e+22,2.5599999999999832e+22,3.2896e+24],[8e+20,8e+20,3.6e+21],[1.0000200000000001,1.0000200000000001,3.00005]],"n":516},"base_record":{"blocks":[[155035.83440579096,155661.89765365858,18948680.82052572],[155281.77152757163,155574.77152757163,20161266.305777643],[141134.20495401855,141407.41630458267,17778418.33330449],[105017.50209477526,105586.50209477526,11821412.01749248],[91174.28875598182,91629.28875598182,10910463.960739516],[15238.813143693185,15288.813143693185,109863.88984156726]],"n":1327},"record":[0.0,0.0,1.0,0.0,0.0]}}
{"case":"fillet_group_torii_fillet_rb23","deferred":null,"error":null,"ints":{"base_graph":{"blocks":[[2573,312590],[2235,302121],[7287,1092477],[3374,249678],[9425,1441704],[5510,902128],[13186,1872602],[16615,2004017],[1666,20910]],"n":2071}},"topology":null,"values":{"base_geometry":{"blocks":[[2.5599999999999832e+22,2.5599999999999832e+22,3.2896e+24],[8e+20,8e+20,3.6e+21],[1.0000200000000001,1.0000200000000001,3.00005]],"n":516},"base_record":{"blocks":[[155035.83440579096,155661.89765365858,18948680.82052572],[155281.77152757163,155574.77152757163,20161266.305777643],[141134.20495401855,141407.41630458267,17778418.33330449],[105017.50209477526,105586.50209477526,11821412.01749248],[91174.28875598182,91629.28875598182,10910463.960739516],[15238.813143693185,15288.813143693185,109863.88984156726]],"n":1327},"record":[0.0,0.0,1.0,0.0,0.0]}}
{"case":"mitred_rounds_box_top_r3","deferred":null,"error":null,"ints":{"base_graph":{"blocks":[[2083,253864],[3024,468943],[3863,321220],[5564,804198],[5754,904563],[11792,1526882],[1838,41095]],"n":1577},"feature0_graph":{"blocks":[[1806,224222],[3712,613264],[2805,507349],[4462,572148],[9492,1266469],[1232,21604]],"n":1313}},"topology":null,"values":{"base_geometry":{"blocks":[[1.91999999999999e+22,1.91999999999999e+22,1.8528000000000002e+24],[782.0829429411759,782.082942941176,29519.122368823526]],"n":383},"base_record":{"blocks":[[156507.5,156507.5,19111185.0],[156364.54117647058,156364.54117647058,20322396.93382353],[105433.0411764706,105433.0411764706,12327102.061764706],[94630.0,95718.0,11327548.0],[210.0,210.0,660.0]],"n":1029},"feature0_geometry":{"blocks":[[1.5999999999999933e+22,1.5999999999999933e+22,1.288e+24],[37.98568732985196,37.98568732985196,788.0151850985815]],"n":313},"feature0_record":{"blocks":[[156703.22494602168,156703.22494602168,19162418.692349844],[143258.58235294116,143258.58235294116,17060525.736758336],[93725.45584412271,94813.45584412271,10865237.012552246],[31834.0,31834.0,749544.5]],"n":851},"record":{"blocks":[[180.0,190.0,3080.0]],"n":52}}}
{"case":"multi_feature_adv_extrusion","deferred":null,"error":null,"ints":{"base_graph":{"blocks":[[2764,379783],[2576,339959],[6104,754984],[2151,298312],[7054,677922],[7816,1172299],[13400,1684853],[2574,69936]],"n":1843},"feature0_graph":{"blocks":[[683,104052],[34,109]],"n":261},"feature1_graph":{"blocks":[[1287,176978],[2441,349693],[4111,670924],[774,14683]],"n":804},"feature2_graph":{"blocks":[[1305,198056],[1736,271264],[1944,108295]],"n":623}},"topology":null,"values":{"base_geometry":{"blocks":[[2.1599999999999874e+22,2.1599999999999874e+22,2.3436e+24],[638.8841584798115,775.4505290941705,56271.317757521465]],"n":442},"base_record":{"blocks":[[155445.255470152,155489.82184076635,18956276.02122212],[155094.1600378454,155126.1600378454,20164389.693007357],[116325.18331626526,116365.18331626526,12865628.669316046],[106093.77870914956,106113.77870914956,13675114.593415532],[45943.52615059925,45943.52615059925,3582894.5606996478]],"n":1183},"feature0_geometry":{"blocks":[[2.4e+21,2.4e+21,2.9999999999999996e+22]],"n":56},"feature0_record":{"blocks":[[87791.71681469283,87832.84955592154,6385487.88502191]],"n":197},"feature1_geometry":{"blocks":[[9.6e+21,9.6e+21,4.6559999999999995e+23]],"n":194},"feature1_record":{"blocks":[[155083.5,155119.5,18928798.5],[105063.5,105159.5,12655567.5],[75.0,75.0,967.0]],"n":536},"feature2_geometry":{"blocks":[[7.2e+21,7.2e+21,2.628e+23]],"n":148},"feature2_record":{"blocks":[[142729.34995257392,142729.34995257392,15852336.370148472],[60998.52615059925,60998.52615059925,4010652.7699044417]],"n":423},"record":{"blocks":[[326.0,346.0,13094.0]],"n":124}}}
{"case":"multi_feature_extrusion","deferred":null,"error":null,"ints":{"base_graph":{"blocks":[[2771,380151],[2581,340435],[6027,751066],[2174,302712],[6982,679564],[7880,1185829],[13383,1678399],[2591,69948]],"n":1843},"feature0_graph":{"blocks":[[683,104052],[34,109]],"n":261},"feature1_graph":{"blocks":[[1287,176978],[2441,349693],[4111,670924],[774,14683]],"n":804},"feature2_graph":{"blocks":[[1305,197879],[1748,275250],[1932,107087]],"n":623}},"topology":null,"values":{"base_geometry":{"blocks":[[2.1599999999999874e+22,2.1599999999999874e+22,2.3436e+24],[550.6115312070845,687.1779218214433,52984.82530328035]],"n":442},"base_record":{"blocks":[[155445.255470152,155489.82184076635,18956276.02122212],[155029.88762338806,155061.88762338806,20148234.18077134],[116389.18300344986,116429.18300344986,12864740.769140378],[106102.57306303276,106122.57308303277,13677357.359610839],[45934.73234671234,45934.73240671234,3583931.6460126126]],"n":1183},"feature0_geometry":{"blocks":[[2.4e+21,2.4e+21,2.9999999999999996e+22]],"n":56},"feature0_record":{"blocks":[[87791.71681469283,87832.84955592154,6385487.88502191]],"n":197},"feature1_geometry":{"blocks":[[9.6e+21,9.6e+21,4.6559999999999995e+23]],"n":194},"feature1_record":{"blocks":[[155083.5,155119.5,18928798.5],[105063.5,105159.5,12655567.5],[75.0,75.0,967.0]],"n":536},"feature2_geometry":{"blocks":[[7.2e+21,7.2e+21,2.628e+23]],"n":148},"feature2_record":{"blocks":[[142729.0772253012,142729.0772253012,15851718.332201496],[60998.526700595554,60998.52678059555,4011681.271218581]],"n":423},"record":{"blocks":[[326.0,346.0,13090.0]],"n":124}}}
{"case":"multi_rebuild_0_extrusion","deferred":null,"error":null,"ints":{"base_graph":{"blocks":[[2771,380151],[2581,340435],[6027,751042],[2174,302712],[6995,682597],[7865,1184614],[13385,1678999],[2591,70004]],"n":1843},"feature0_graph":{"blocks":[[683,104052],[34,109]],"n":261},"feature1_graph":{"blocks":[[1287,176993],[2441,349514],[4107,669726],[778,14797]],"n":804},"feature2_graph":{"blocks":[[1305,197879],[1748,275250],[1932,107087]],"n":623}},"topology":null,"values":{"base_geometry":{"blocks":[[2.1599999999999874e+22,2.1599999999999874e+22,2.3436e+24],[550.6115312070845,667.1779218214434,52652.187164461]],"n":442},"base_record":{"blocks":[[155451.53865545918,155483.53865545918,18959902.682866313],[155023.60443808086,155068.17080869523,20150495.966324273],[116389.18300344986,116429.18300344986,12864740.769140378],[106102.57306303276,106103.90832107945,13677333.40532395],[45934.73234671234,45934.73240671234,3583931.6460126126]],"n":1183},"feature0_geometry":{"blocks":[[2.4e+21,2.4e+21,2.9999999999999996e+22]],"n":56},"feature0_record":{"blocks":[[87795.71681469283,87822.18479396813,6386245.248830656]],"n":197},"feature1_geometry":{"blocks":[[9.6e+21,9.6e+21,4.6559999999999995e+23]],"n":194},"feature1_record":{"blocks":[[155083.5,155119.5,18928798.5],[105057.83309524418,105149.83309524418,12654314.892937822],[76.66690475583123,76.66690475583123,1007.5613490585596]],"n":536},"feature2_geometry":{"blocks":[[7.2e+21,7.2e+21,2.628e+23]],"n":148},"feature2_record":{"blocks":[[142729.0772253012,142729.0772253012,15851718.332201496],[60998.526700595554,60998.52678059555,4011681.271218581]],"n":423},"record":{"blocks":[[322.0,342.0,12969.0]],"n":127}}}
{"case":"multi_rebuild_1_extrusion","deferred":null,"error":null,"ints":{"base_graph":{"blocks":[[2077,266206],[3633,579612],[2138,226476],[4989,506469],[8927,1280475],[3025,147354]],"n":1369},"feature0_graph":{"blocks":[[683,104052],[34,109]],"n":261},"feature1_graph":{"blocks":[[1287,176993],[2441,349514],[4107,669726],[778,14797]],"n":804},"feature2_graph":{"blocks":[[293,26021]],"n":149}},"topology":null,"values":{"base_geometry":{"blocks":[[1.5999999999999933e+22,1.5999999999999933e+22,1.288e+24],[110.8481596440759,207.41453025843512,3679.688704523623]],"n":343},"base_record":{"blocks":[[155448.14519604263,155480.14519604263,18960494.546111137],[155020.50867205963,155065.07504267397,20143946.545116752],[91052.57229961093,91093.23991863422,11082444.561509266],[45484.33380951166,45485.00142853499,2282412.032856306]],"n":899},"feature0_geometry":{"blocks":[[2.4e+21,2.4e+21,2.9999999999999996e+22]],"n":56},"feature0_record":{"blocks":[[87795.71681469283,87822.18479396813,6386245.248830656]],"n":197},"feature1_geometry":{"blocks":[[9.6e+21,9.6e+21,4.6559999999999995e+23]],"n":194},"feature1_record":{"blocks":[[155083.5,155119.5,18928798.5],[105057.83309524418,105149.83309524418,12654314.892937822],[76.66690475583123,76.66690475583123,1007.5613490585596]],"n":536},"feature2_geometry":{"blocks":[[1.6e+21,1.6e+21,1.36e+22]],"n":49},"feature2_record":{"blocks":[[71831.53239638661,71831.53239638661,3671408.0937480605]],"n":139},"record":{"blocks":[[287.0,307.0,9792.0]],"n":127}}}
{"case":"multi_rebuild_2_extrusion","deferred":null,"error":null,"ints":{"base_graph":{"blocks":[[2207,279844],[3288,509422],[3158,278508],[4947,640592],[6593,1014126],[9416,988308]],"n":1500},"feature0_graph":{"blocks":[[683,104052],[34,109]],"n":261},"feature1_graph":{"blocks":[[1287,176993],[2441,349514],[4107,669726],[778,14797]],"n":804}},"topology":null,"values":{"base_geometry":{"blocks":[[1.7599999999999916e+22,1.7599999999999916e+22,1.5576000000000002e+24],[152.8067495591884,269.37312017354753,7235.722609831468]],"n":364},"base_record":{"blocks":[[155260.6164362172,155305.18280683155,18929403.35444251],[155162.03743188508,155194.03743188508,20182286.063773222],[91107.57229961093,91148.23991863422,11083020.561509266],[60725.11195932037,60725.779578343696,4299053.8873962285]],"n":940},"feature0_geometry":{"blocks":[[2.4e+21,2.4e+21,2.9999999999999996e+22]],"n":56},"feature0_record":{"blocks":[[87795.71681469283,87822.18479396813,6386245.248830656]],"n":197},"feature1_geometry":{"blocks":[[9.6e+21,9.6e+21,4.6559999999999995e+23]],"n":194},"feature1_record":{"blocks":[[155083.5,155119.5,18928798.5],[105057.83309524418,105149.83309524418,12654314.892937822],[76.66690475583123,76.66690475583123,1007.5613490585596]],"n":536},"record":{"blocks":[[271.0,287.0,7844.0]],"n":102}}}
{"case":"no_ops_defillet","deferred":null,"error":null,"ints":{"base_graph":{"blocks":[[1728,230155],[2865,397674],[3250,542715],[4992,815381],[4729,327785]],"n":1165},"feature0_none":[1]},"topology":null,"values":{"base_geometry":{"blocks":[[1.3599999999999958e+22,1.3599999999999958e+22,9.316000000000001e+23],[11.000069999999997,11.000069999999997,92.00077000000002]],"n":276},"base_record":{"blocks":[[154398.14159265358,154398.14159265358,18832864.4645943],[129390.13959673682,129390.13959673682,14750795.369363222],[90273.51715728751,90273.51715728751,11239145.200591717],[14.017157287525382,14.017157287525382,85.60294372515229]],"n":779},"record":{"blocks":[[48.0,64.0,438.0]],"n":43}}}
+3 -3
View File
@@ -96,7 +96,7 @@
{"case":"gen0039","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[1745.6446517672211,1759.0009086963332,53080.473545951456]],"n":61}}}
{"case":"gen0040","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[1293.1780750967052,1331.5983705234573,43731.711951791374]],"n":54}}}
{"case":"gen0041","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[-1.512782707950123,1082.7428297938636,-509.6925401146473]],"n":89}}}
{"case":"gen0042","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[247.59502854734214,267.6372546180361,10403.49931860245]],"n":67}}}
{"case":"gen0042","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[247.59500994892622,267.637273216452,10403.498983830963]],"n":67}}}
{"case":"gen0043","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[["inf","inf","inf"]],"n":58}}}
{"case":"gen0044","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[2363.331389460768,2381.342638010389,23505.946664766696]],"n":54}}}
{"case":"gen0045","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[46563.40296431203,46630.561956428515,823550.7565376083]],"n":131}}}
@@ -909,7 +909,7 @@
{"case":"gen0852","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[160.1181334263939,588.6357976688842,7315.822570183931]],"n":78}}}
{"case":"gen0853","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[483.08853328132193,583.7951633580492,21580.922615116793]],"n":68}}}
{"case":"gen0854","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[1904.368161806397,1986.2846515331662,41598.7870653989]],"n":65}}}
{"case":"gen0855","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[-156.02409151847633,410.5124223109191,-4682.4589894044575]],"n":68}}}
{"case":"gen0855","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[-156.02408784125566,410.5124186336983,-4682.458967341133]],"n":68}}}
{"case":"gen0856","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[1840.2670017776718,2073.924694260032,52771.46827067364]],"n":81}}}
{"case":"gen0857","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[344.30209778448113,387.76064706770353,11668.607945433409]],"n":56}}}
{"case":"gen0858","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[80.44331673962174,1051.7564644076338,4225.38697740385]],"n":89}}}
@@ -1655,7 +1655,7 @@
{"case":"gen1598","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[1107.122912136224,2062.064170269247,18820.15307682447]],"n":100}}}
{"case":"gen1599","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[1649.8413176227855,1688.9076570088273,82824.75732259153]],"n":80}}}
{"case":"gen1600","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[1328.8306468841884,1537.8237072141824,64621.894244369585]],"n":89}}}
{"case":"gen1601","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[245.6209888918087,260.6012897777472,8049.2557268355995]],"n":55}}}
{"case":"gen1601","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[245.62098559210304,260.60129307745285,8049.255707037365]],"n":55}}}
{"case":"gen1602","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[1565.0240348651373,1591.1848258051928,97009.86974045317]],"n":97}}}
{"case":"gen1603","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[1295.903574378681,1475.9072442360741,96443.64845655844]],"n":103}}}
{"case":"gen1604","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[44185.678798756344,46310.99065267807,787573.7979235228]],"n":172}}}
+5 -5
View File
@@ -26,7 +26,7 @@
{"case":"edge_negate","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"extent":[-3.0,-1.0]}}
{"case":"edge_periodic_degenerate_wrap","deferred":null,"error":null,"ints":{"flags":[1,1,0,0]},"topology":null,"values":{"values":[3.0,1.0,0.0,0.0,1.0,0.0]}}
{"case":"edge_periodic_huge_value","deferred":null,"error":null,"ints":{"flags":[0,0,1,1]},"topology":null,"values":{"values":[1e+20,1e+20,-1e+20,-1e+20,1.0,-1e+20]}}
{"case":"edge_periodic_near_top","deferred":null,"error":null,"ints":{"flags":[1,1,0,0]},"topology":null,"values":{"values":[89.99999000000003,0.0,0.0,90.0,0.0,0.0]}}
{"case":"edge_periodic_near_top","deferred":null,"error":null,"ints":{"flags":[1,1,0,0]},"topology":null,"values":{"values":[89.99999000000003,-9.999999974752427e-6,9.999999974752427e-6,90.00000999999997,-9.999999974752427e-6,9.999999974752427e-6]}}
{"case":"edge_periodic_negative_period","deferred":null,"error":null,"ints":{"flags":[0,0,1,0]},"topology":null,"values":{"values":[20.0,-160.0,-200.0,-610.0,90.0,-610.0]}}
{"case":"edge_periodic_zero_period","deferred":null,"error":null,"ints":{"flags":[0,0,0,0]},"topology":null,"values":{"values":[1.0,3.0,3.0,2.0,1.0,2.0]}}
{"case":"edge_same_scaled_zero","deferred":null,"error":null,"ints":{"flags":[0,0,0,0,0,0,1]},"topology":null,"values":{"intersect":[5e-13,1000000.0],"union":[0.0,1000000.0000005]}}
@@ -921,7 +921,7 @@
{"case":"gen0867","deferred":null,"error":null,"ints":{},"topology":null,"values":{"extents":[-4032.5445657100436,0.0,-4032.5445657100436,-4032.5445657100436,1e+20,-1e+20]}}
{"case":"gen0868","deferred":null,"error":null,"ints":{"flags":[1,1,0,0]},"topology":null,"values":{"values":[-4.547682406915637,-4.547682406915637,2.000177801164682e-12,3.547682406915637,-4.547682406915637,2.000177801164682e-12]}}
{"case":"gen0869","deferred":null,"error":null,"ints":{"disjoint":[0]},"topology":null,"values":{}}
{"case":"gen0870","deferred":null,"error":null,"ints":{"flags":[1,1,1,0]},"topology":null,"values":{"values":[-3.999999999999,-4.0,0.0,1.000088900582341e-12,-4.0,0.0]}}
{"case":"gen0870","deferred":null,"error":null,"ints":{"flags":[1,1,1,0]},"topology":null,"values":{"values":[-3.999999999999,-3.999999999999,1.000088900582341e-12,0.0,-3.999999999999,0.0]}}
{"case":"gen0871","deferred":null,"error":null,"ints":{"flags":[1,1,1,0]},"topology":null,"values":{"values":[-999999.999901,-2.999901,999997.000099,0.00009900000000007125,-2.999901,0.00009900000000007125]}}
{"case":"gen0872","deferred":null,"error":null,"ints":{"status_count":[0,0]},"topology":null,"values":{"intervals":[]}}
{"case":"gen0873","deferred":null,"error":null,"ints":{"flags":[1,1,1,0]},"topology":null,"values":{"values":[-420.0475459692425,-5864.660909553929,0.0,-5444.613363584686,-5864.660909553929,-5444.613363584686]}}
@@ -1554,7 +1554,7 @@
{"case":"gen1500","deferred":null,"error":null,"ints":{"flags":[0,0,0,0]},"topology":null,"values":{"values":[1e-6,1e-6,1e+20,1e+20,-1e+20,1e+20]}}
{"case":"gen1501","deferred":null,"error":null,"ints":{"flags":[1,1,0,0]},"topology":null,"values":{"values":[-0.9587557303154033,-0.9587557303154033,4.041244269684597,0.3265002390765175,-0.9587557303154033,0.3265002390765175]}}
{"case":"gen1502","deferred":null,"error":null,"ints":{"counts":[0],"flag":[0]},"topology":null,"values":{"clamped":[-3.6531332205612728]}}
{"case":"gen1503","deferred":null,"error":null,"ints":{"flags":[1,1,0,0]},"topology":null,"values":{"values":[0.8431568102231495,-0.8412598042009409,0.0,3.841259804200941,-0.8412598042009409,0.0]}}
{"case":"gen1503","deferred":null,"error":null,"ints":{"flags":[1,1,0,0]},"topology":null,"values":{"values":[0.8431568102231495,-0.8412598042009449,3.9968028886505635e-15,3.841259804200945,-0.8412598042009449,3.9968028886505635e-15]}}
{"case":"gen1504","deferred":null,"error":null,"ints":{"flags":[1,1,0,0,0,1,1,1,1,1,0,0,0]},"topology":null,"values":{"values":[-5.0,8.696559494945177,1.8482797474725885,13.696559494945177,8.696559494945177,0.0,0.0,-5.0,0.0,-5.0,0.3650551805981122]}}
{"case":"gen1505","deferred":null,"error":null,"ints":{"flags":[0,0,0,0,0,0,0]},"topology":null,"values":{"intersect":[2.5565017038286015,3.0],"union":[-7.213387952269873e-10,3.165025619210411]}}
{"case":"gen1506","deferred":null,"error":null,"ints":{"flags":[1,1,0,1]},"topology":null,"values":{"values":[5605.960734602222,5605.960734602222,0.0,0.0,5605.960734602222,0.0]}}
@@ -2114,7 +2114,7 @@
{"case":"gen2060","deferred":null,"error":null,"ints":{"flags":[0,1,0,0,0,1,1,1,0,4,0,0,1001]},"topology":null,"values":{"values":[3564.9311334680096,3564.9311344680095,3564.93113396801,9.99999883788405e-7,3564.9311344680095,3564.9311344680095,3564.9311344680095,3564.93469839883,0.00010000000020227162,3564.9311334680096,101.0000118234325]}}
{"case":"gen2061","deferred":null,"error":null,"ints":{"counts":[],"flag":[0]},"topology":null,"values":{"clamped":[]}}
{"case":"gen2062","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"extent":[2.0,360.0]}}
{"case":"gen2063","deferred":null,"error":null,"ints":{"flags":[1,1,0,0]},"topology":null,"values":{"values":[-0.0001752855794858732,-0.0009064160095653894,0.0,0.0009064156015241429,-0.0009064160095653894,0.0]}}
{"case":"gen2063","deferred":null,"error":null,"ints":{"flags":[1,1,0,0]},"topology":null,"values":{"values":[-0.0001752855794858732,-0.0009064160095650919,2.975050761300224e-16,0.0009064156015238454,-0.0009064160095650919,2.975050761300224e-16]}}
{"case":"gen2064","deferred":null,"error":null,"ints":{"flags":[1001,1,1,0,0,0,0]},"topology":null,"values":{"intersect":[1e+20,-1e+20],"union":[0.465547398032645,1000000.0]}}
{"case":"gen2065","deferred":null,"error":null,"ints":{"flag":[1]},"topology":null,"values":{"clamped":[7.390963598478955,-5.0,-0.8157526895288466,-3.0,2.9999,0.0,-1e+20,-3.3306754088323505,7.774316697598074,4.0,-4.903134706608148,0.0,-1.5550380498551388,-4.0]}}
{"case":"gen2066","deferred":null,"error":null,"ints":{"flags":[1,1,0,1]},"topology":null,"values":{"values":[-3482.7204020819313,-3482.7204020819313,9.99999883788405e-7,3478.7204020819313,-3482.7204020819313,9.99999883788405e-7]}}
@@ -2318,7 +2318,7 @@
{"case":"gen2264","deferred":null,"error":null,"ints":{"status":[1007]},"topology":null,"values":{"extent":[1e+20,-1e+20]}}
{"case":"gen2265","deferred":null,"error":null,"ints":{"flags":[1001,1,1,0,0,0,0]},"topology":null,"values":{"intersect":[1e+20,-1e+20],"union":[-1234567.0,1234567.0]}}
{"case":"gen2266","deferred":null,"error":null,"ints":{"flags":[0,0,0,0]},"topology":null,"values":{"values":[3.5,-0.5005336876101969,0.5,4.500533687610197,-0.0005336876101968466,0.5]}}
{"case":"gen2267","deferred":null,"error":null,"ints":{"flags":[1,1,0,0]},"topology":null,"values":{"values":[0.005902837378700454,0.000983806229783426,0.0,2.0009838062297836,0.000983806229783426,0.0]}}
{"case":"gen2267","deferred":null,"error":null,"ints":{"flags":[1,1,0,0]},"topology":null,"values":{"values":[0.005902837378700454,0.000983806229783113,3.131175874138137e-16,2.000983806229783,0.000983806229783113,3.131175874138137e-16]}}
{"case":"gen2268","deferred":null,"error":null,"ints":{},"topology":null,"values":{"approx":[-0.0,0.7741788387164605],"approx_plain":[-0.0,0.7741788387164605],"expanded":[-0.0,0.7741788387164605]}}
{"case":"gen2269","deferred":null,"error":null,"ints":{"flags":[1,1,1,1]},"topology":null,"values":{"values":[4.700315805143418,302.8236045754655,0.0,1.9895196601282805e-12,302.8236045754655,0.0]}}
{"case":"gen2270","deferred":null,"error":null,"ints":{"counts":[],"flag":[0]},"topology":null,"values":{"clamped":[]}}
+1 -1
View File
@@ -3029,7 +3029,7 @@
{"case":"my_test_extent2d_classify_point_tol_v","deferred":null,"error":null,"ints":{"classify":[1,0],"contains":[1,1],"left_eval":[1,1],"on_boundary":[0],"on_boundary_bits":[0,0]},"topology":null,"values":{"binormal":[0.0,0.0],"clamp":[1.1,2.1],"outside_dist":[0.0]}}
{"case":"my_test_extent2d_clip_line","deferred":null,"error":null,"ints":{},"topology":null,"values":{"t":[1.0]}}
{"case":"my_test_extent2d_contains_periodic","deferred":null,"error":null,"ints":{"contained":[0],"contained_again":[0]},"topology":null,"values":{"periodic_point":[4.9,3.0]}}
{"case":"my_test_extent2d_contains_periodic_tol","deferred":null,"error":null,"ints":{"contained":[1],"contained_again":[1]},"topology":null,"values":{"periodic_point":[1.0,3.0]}}
{"case":"my_test_extent2d_contains_periodic_tol","deferred":null,"error":null,"ints":{"contained":[1],"contained_again":[1]},"topology":null,"values":{"periodic_point":[0.9000000000000004,3.0]}}
{"case":"my_test_extent2d_contains_point","deferred":null,"error":null,"ints":{"classify":[4,1],"contains":[0,0],"left_eval":[1,1],"on_boundary":[0],"on_boundary_bits":[0,0]},"topology":null,"values":{"binormal":[0.0,0.0],"clamp":[1.0,3.0],"outside_dist":[0.09999999999999998]}}
{"case":"my_test_extent2d_contains_point_tol","deferred":null,"error":null,"ints":{"classify":[0,1],"contains":[1,1],"left_eval":[1,1],"on_boundary":[1],"on_boundary_bits":[1,1]},"topology":null,"values":{"binormal":[1.0,0.0],"clamp":[1.0,3.0],"outside_dist":[0.0]}}
{"case":"my_test_extent2d_e1_from_points","deferred":null,"error":null,"ints":{},"topology":null,"values":{"extent":[1.0,2.0,3.0,4.0]}}
-5
View File
@@ -1,10 +1,5 @@
{
"blend.feature_executive": {
"add_by_faces_extrusion": "tangency-labs/solidcore#3093: the base body is a closed solid or an open 9-face body depending on a few ulps of the extrusion surface's extended domain, with success either way",
"attributes_add_by_faces_extrusion": "tangency-labs/solidcore#3093: the base body is a closed solid or an open 9-face body depending on a few ulps of the extrusion surface's extended domain, with success either way",
"multi_feature_adv_extrusion": "tangency-labs/solidcore#3093: the base body is a closed solid or an open 9-face body depending on a few ulps of the extrusion surface's extended domain, with success either way",
"multi_feature_extrusion": "tangency-labs/solidcore#3093: the base body is a closed solid or an open 9-face body depending on a few ulps of the extrusion surface's extended domain, with success either way",
"multi_rebuild_0_extrusion": "tangency-labs/solidcore#3093: the base body is a closed solid or an open 9-face body depending on a few ulps of the extrusion surface's extended domain, with success either way",
"single_face_fillet1019_6": "tangency-labs/solidcore#3093: success with a body that fails check_brep level 2 (11 lines, the first Bad Repeated CPts)"
},
"blend.fillet_executive_p3": {
+1 -1
View File
@@ -350,7 +350,7 @@
{"case":"adv_sec_b8_e2_m0_f0.0_c2_t0.0_0.0","deferred":null,"error":"rejected","ints":{},"topology":null,"values":{}}
{"case":"adv_sec_b8_e2_m0_f0.0_c2_t0.5_0.0","deferred":null,"error":null,"ints":{"sector":[1,0,0]},"topology":null,"values":{}}
{"case":"adv_sec_b8_e2_m0_f0.5_c1_t0.5_0.5","deferred":null,"error":null,"ints":{"sector":[1,0,0]},"topology":null,"values":{}}
{"case":"adv_sec_b8_e2_m0_f1e-09_c2_t0.0_0.0","deferred":null,"error":"rejected","ints":{},"topology":null,"values":{}}
{"case":"adv_sec_b8_e2_m0_f1e-09_c2_t0.0_0.0","deferred":null,"error":null,"ints":{"sector":[1,1,5]},"topology":null,"values":{}}
{"case":"adv_sec_b8_e2_m1_f0.5_c1_t0.0_0.0","deferred":null,"error":null,"ints":{"sector":[1,0,0]},"topology":null,"values":{}}
{"case":"adv_sec_b8_e2_m1_f0.5_c1_t1.0_0.0","deferred":null,"error":null,"ints":{"sector":[1,0,0]},"topology":null,"values":{}}
{"case":"adv_sec_b8_e2_m2_f0.5_c2_t0.25_0.75","deferred":null,"error":null,"ints":{"sector":[1,0,0]},"topology":null,"values":{}}
@@ -60,7 +60,7 @@
{"case":"fc0029","deferred":null,"error":null,"ints":{"graph":{"blocks":[[5530,1048087],[2353,345210],[3761,443563],[8649,1231166],[11139,1119692],[2807,380758],[9671,1671146],[11579,901800],[9952,1471130],[17163,2342567],[25869,3493282],[20544,2673297]],"n":3058}},"topology":null,"values":{"edge_curve_owners":{"blocks":[[1081.0,1081.0,34592.0]],"n":47},"geometry":{"blocks":[[2.5599999999999832e+22,2.5599999999999832e+22,3.2896e+24],[1.1999999999999975e+22,1.1999999999999975e+22,7.260000000000001e+23],[730.9442733577434,858.0120734429167,64858.61790680906]],"n":741},"record":{"blocks":[[155544.66853515652,155579.92476181663,19058058.264398053],[154883.12568554367,154941.51880574072,20324220.57230179],[142520.7082231047,142596.41784981615,18212653.135235615],[154806.4561564725,154972.45735892767,19093646.369808033],[155252.40131436303,155354.1721140812,20406811.642981462],[140642.9719392665,140862.60866179518,17894260.4774287],[154878.0705418264,155054.22088204307,19128506.313185643],[141068.82604785042,141433.55588031362,17527611.500434674],[154195.75,154556.75,18712557.75],[154243.50602216198,154538.41890063672,19941564.44806239],[140215.5,140487.5,17565873.5],[154703.36469041018,154878.00912893322,18777456.488563403],[155390.14231139628,155692.55151878114,20250848.848574623],[90721.45120858589,90868.34903995402,12471432.217107799],[90039.03768967514,90765.50307723251,11466839.444810867],[90449.58048159658,90968.38407563255,10645319.095995698],[15006.304828672948,15071.278910385701,75017.01759442406]],"n":4141},"surface_owners":{"blocks":[[20.0,20.0,210.0]],"n":20},"uv_curve_owners":{"blocks":[[4138.0,4332.0,257084.0]],"n":188}}}
{"case":"fc0030","deferred":null,"error":null,"ints":{"graph":{"blocks":[[5530,1048087],[2353,345210],[3761,443563],[8649,1231166],[11139,1119692],[2807,380758],[9671,1671146],[11579,901800],[9952,1471130],[17163,2342567],[25869,3493282],[20544,2673297]],"n":3058}},"topology":null,"values":{"edge_curve_owners":{"blocks":[[1081.0,1081.0,34592.0]],"n":47},"geometry":{"blocks":[[2.5599999999999832e+22,2.5599999999999832e+22,3.2896e+24],[1.1999999999999975e+22,1.1999999999999975e+22,7.260000000000001e+23],[730.9442733577434,858.0120734429167,64858.61790680906]],"n":741},"record":{"blocks":[[155544.66853515652,155579.92476181663,19058058.264398053],[154883.12568554367,154941.51880574072,20324220.57230179],[142520.7082231047,142596.41784981615,18212653.135235615],[154806.4561564725,154972.45735892767,19093646.369808033],[155252.40131436303,155354.1721140812,20406811.642981462],[140642.9719392665,140862.60866179518,17894260.4774287],[154878.0705418264,155054.22088204307,19128506.313185643],[141068.82604785042,141433.55588031362,17527611.500434674],[154195.75,154556.75,18712557.75],[154243.50602216198,154538.41890063672,19941564.44806239],[140215.5,140487.5,17565873.5],[154703.36469041018,154878.00912893322,18777456.488563403],[155390.14231139628,155692.55151878114,20250848.848574623],[90721.45120858589,90868.34903995402,12471432.217107799],[90039.03768967514,90765.50307723251,11466839.444810867],[90449.58048159658,90968.38407563255,10645319.095995698],[15006.304828672948,15071.278910385701,75017.01759442406]],"n":4141},"surface_owners":{"blocks":[[20.0,20.0,210.0]],"n":20},"uv_curve_owners":{"blocks":[[4138.0,4332.0,257084.0]],"n":188}}}
{"case":"fc0031","deferred":null,"error":null,"ints":{"graph":{"blocks":[[175,12385]],"n":121}},"topology":null,"values":{"edge_curve_owners":[0.0,1.0],"geometry":{"blocks":[[1.2e+21,1.2e+21,7.8e+21]],"n":31},"record":{"blocks":[[86448.35010058085,86489.35010058085,5479421.844715244]],"n":164},"surface_owners":[1.0],"uv_curve_owners":[0.0,1.0,2.0,-1.0,-1.0,-1.0]}}
{"case":"gen0000","deferred":null,"error":null,"ints":{"graph":{"blocks":[[2508,322157],[2737,381059],[5730,644350],[3544,630392],[6005,593789],[9923,1440534],[12774,1466844]],"n":1777}},"topology":null,"values":{"edge_curve_owners":{"blocks":[[351.0,351.0,6552.0]],"n":27},"geometry":{"blocks":[[2.1599999999999874e+22,2.1599999999999874e+22,2.3436e+24],[666.3426207657327,9877518.473090675,9934452.135103568]],"n":424},"record":{"blocks":[[140974.2238529436,141052.06708209866,18166303.110872176],[127326.48126003167,127483.81247437863,17674464.920045],[128058.53158445854,128242.78759522102,17395060.81315641],[91081.30411119803,45482006.46488623,-5192257.522908466],[60762.71681469282,60811.28318530718,5140971.371759845]],"n":1210},"surface_owners":[1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0],"uv_curve_owners":{"blocks":[[-108.0,108.0,-5886.0]],"n":108}}}
{"case":"gen0000","deferred":null,"error":null,"ints":{"graph":{"blocks":[[2468,318735],[2755,382199],[5710,650567],[3534,632497],[6013,592519],[9779,1412900],[12928,1499698]],"n":1777}},"topology":null,"values":{"edge_curve_owners":{"blocks":[[351.0,351.0,6552.0]],"n":27},"geometry":{"blocks":[[2.1599999999999874e+22,2.1599999999999874e+22,2.3436e+24],[431.5198428883937,624.652584117112,27447.906125791415]],"n":424},"record":{"blocks":[[156224.41673182778,156297.18329645565,19149526.127055034],[154598.40251211118,154985.87177838173,20301423.514221795],[140664.7203279845,140836.2024273099,17905887.2094762],[154920.03783534584,154938.03783534584,19116535.34318591],[154743.16176198598,154820.03053193798,20312026.989448104],[140446.0,140764.0,17885794.0],[154426.5,154613.5,19052446.0],[129684.24687969516,129844.4946593029,14955378.32523922],[91187.66304535375,91247.66304535375,11441009.498965224],[30370.71681469282,30387.28318530718,1022758.4248081051]],"n":2398},"surface_owners":[1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0],"uv_curve_owners":{"blocks":[[1377.0,1485.0,48069.0]],"n":108}}}
{"case":"gen0001","deferred":null,"error":null,"ints":{"graph":{"blocks":[[101,5336]],"n":97}},"topology":null,"values":{"edge_curve_owners":[0.0,1.0],"geometry":{"blocks":[[8e+20,8e+20,3.6e+21]],"n":27},"record":{"blocks":[[72141.8827762868,72278.09763810274,3691636.3260077816]],"n":140},"surface_owners":[1.0],"uv_curve_owners":[0.0,1.0,-1.0,-1.0]}}
{"case":"gen0002","deferred":null,"error":null,"ints":{"graph":{"blocks":[[1881,231784],[3468,553413],[2259,207996],[5091,517645],[9023,1312991],[4471,251788]],"n":1396}},"topology":null,"values":{"edge_curve_owners":{"blocks":[[210.0,210.0,3080.0]],"n":21},"geometry":{"blocks":[[1.6799999999999925e+22,1.6799999999999925e+22,1.4196e+24],[-69.63094524647565,356.3690547535241,-2111.0734133096885]],"n":337},"record":{"blocks":[[140553.5,140965.5,18120762.0],[126937.62293491785,127069.62293491785,17636628.660356514],[103742.12293491785,104116.12293491785,13264092.515550645],[75988.12293491783,76084.12293491783,8443981.054912265]],"n":1001},"surface_owners":[1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0],"uv_curve_owners":{"blocks":[[-84.0,84.0,-3570.0]],"n":84}}}
{"case":"gen0003","deferred":null,"error":null,"ints":{"graph":{"blocks":[[692,106331],[25,79]],"n":261}},"topology":null,"values":{"edge_curve_owners":[0.0,1.0,2.0],"geometry":{"blocks":[[2.4e+21,2.4000000000000084e+21,3.000000000000001e+22]],"n":56},"record":{"blocks":[[87521.25275474228,43841294.327081785,215143713.92267185]],"n":210},"surface_owners":[1.0,1.0,1.0],"uv_curve_owners":[-1.0,-1.0,-1.0,-1.0,-1.0,-1.0,-1.0,-1.0,-1.0,-1.0,-1.0,-1.0]}}
@@ -81,7 +81,7 @@
{"case":"gen0018","deferred":null,"error":null,"ints":{"graph":{"blocks":[[1485,181651],[2387,261025],[3175,417519],[6648,902856]],"n":1019}},"topology":null,"values":{"edge_curve_owners":[0.0,1.0,2.0,3.0,4.0,5.0,6.0,7.0,8.0,9.0,10.0,11.0,12.0,13.0,14.0],"geometry":{"blocks":[[1.1999999999999975e+22,1.1999999999999975e+22,7.260000000000001e+23]],"n":240},"record":{"blocks":[[157217.22447274308,157231.22447274308,19278198.216036484],[156157.13303146334,156419.82363523965,20447243.250780012],[141455.48021756337,141593.08071388188,17996368.187218934],[155107.52131219086,155348.7302645498,19149360.30337226],[104573.08452012748,104661.55858306825,11530544.626243796],[30393.128551147056,30431.024802910142,904071.2969962137]],"n":1370},"surface_owners":[1.0,1.0,1.0,1.0,1.0,1.0,1.0],"uv_curve_owners":{"blocks":[[405.0,465.0,7625.0]],"n":60}}}
{"case":"gen0019","deferred":null,"error":null,"ints":{"graph":{"blocks":[[101,5336]],"n":97}},"topology":null,"values":{"edge_curve_owners":[0.0,1.0],"geometry":{"blocks":[[8e+20,8e+20,3.6e+21]],"n":27},"record":{"blocks":[[72141.8827762868,72278.09763810274,3691636.3260077816]],"n":140},"surface_owners":[1.0],"uv_curve_owners":[0.0,1.0,-1.0,-1.0]}}
{"case":"gen0020","deferred":null,"error":null,"ints":{"graph":{"blocks":[[101,5336]],"n":97}},"topology":null,"values":{"edge_curve_owners":[0.0,1.0],"geometry":{"blocks":[[8e+20,8e+20,3.6e+21]],"n":27},"record":{"blocks":[[72141.8827762868,72278.09763810274,3691636.3260077816]],"n":140},"surface_owners":[1.0],"uv_curve_owners":[0.0,1.0,-1.0,-1.0]}}
{"case":"gen0021","deferred":null,"error":null,"ints":{"graph":{"blocks":[[2508,322157],[2737,381059],[5730,644350],[3544,630392],[6005,593789],[9923,1440534],[12774,1466844]],"n":1777}},"topology":null,"values":{"edge_curve_owners":{"blocks":[[351.0,351.0,6552.0]],"n":27},"geometry":{"blocks":[[2.1599999999999874e+22,2.1599999999999874e+22,2.3436e+24],[679.247730594307,9877527.828234844,9936130.320583055]],"n":424},"record":{"blocks":[[140963.82226276476,141045.3103206927,18166285.505433835],[127314.98823878425,127477.26331205857,17673788.0908405],[128034.57956527523,128233.61473746217,17393155.311088845],[91092.90486821948,45481951.13323095,-5188756.241267705],[60762.71681469282,60811.28318530718,5140971.371759845]],"n":1210},"surface_owners":[1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0],"uv_curve_owners":{"blocks":[[-108.0,108.0,-5886.0]],"n":108}}}
{"case":"gen0021","deferred":null,"error":null,"ints":{"graph":{"blocks":[[2468,318735],[2755,382199],[5710,650567],[3534,632497],[6013,592519],[9779,1412900],[12928,1499698]],"n":1777}},"topology":null,"values":{"edge_curve_owners":{"blocks":[[351.0,351.0,6552.0]],"n":27},"geometry":{"blocks":[[2.1599999999999874e+22,2.1599999999999874e+22,2.3436e+24],[431.5198428883937,624.652584117112,27447.906125791415]],"n":424},"record":{"blocks":[[156224.41673182778,156297.18329645565,19149526.127055034],[154598.40251211118,154985.87177838173,20301423.514221795],[140664.7203279845,140836.2024273099,17905887.2094762],[154920.03783534584,154938.03783534584,19116535.34318591],[154743.16176198598,154820.03053193798,20312026.989448104],[140446.0,140764.0,17885794.0],[154426.5,154613.5,19052446.0],[129684.24687969516,129844.4946593029,14955378.32523922],[91187.66304535375,91247.66304535375,11441009.498965224],[30370.71681469282,30387.28318530718,1022758.4248081051]],"n":2398},"surface_owners":[1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0],"uv_curve_owners":{"blocks":[[1377.0,1485.0,48069.0]],"n":108}}}
{"case":"gen0022","deferred":null,"error":null,"ints":{"graph":{"blocks":[[50,1663]],"n":71}},"topology":null,"values":{"edge_curve_owners":[0.0],"geometry":[1e+20,1e+20,1e+20,1e+20,5.551115123125783e-17,8.881784197001252e-16,-1.0229128535520042,0.00001,-1234567.0,-1234567.0,1234567.0,1234567.0,0.00001,-30.61467458920718,0.0,0.00001],"record":{"blocks":[[28982.89523240007,21960831.59885814,123206142.72427092]],"n":70},"surface_owners":[1.0],"uv_curve_owners":[-1.0,-1.0]}}
{"case":"gen0023","deferred":null,"error":null,"ints":{"graph":{"blocks":[[101,5336]],"n":97}},"topology":null,"values":{"edge_curve_owners":[0.0,1.0],"geometry":{"blocks":[[8e+20,8e+20,3.6e+21]],"n":27},"record":{"blocks":[[72141.8827762868,72278.09763810274,3691636.3260077816]],"n":140},"surface_owners":[1.0],"uv_curve_owners":[0.0,1.0,-1.0,-1.0]}}
{"case":"gen0024","deferred":null,"error":null,"ints":{"graph":{"blocks":[[50,1663]],"n":71}},"topology":null,"values":{"edge_curve_owners":[0.0],"geometry":[1e+20,1e+20,1e+20,1e+20,0.0,0.0,0.6729906661212666,0.00001,-1234567.0,-1234567.0,1234567.0,1234567.0,0.00001,-30.61467458920718,0.0,0.00001],"record":{"blocks":[[29004.807194736204,19782192.036543917,123949058.81897196]],"n":70},"surface_owners":[1.0],"uv_curve_owners":[-1.0,-1.0]}}
@@ -91,7 +91,7 @@
{"case":"gen0028","deferred":null,"error":null,"ints":{"graph":{"blocks":[[682,105286],[29,96]],"n":261}},"topology":null,"values":{"edge_curve_owners":[0.0,1.0,2.0],"geometry":{"blocks":[[2.4e+21,2.4e+21,2.9999999999999996e+22]],"n":56},"record":{"blocks":[[142982.44946801648,143164.44946801648,15937837.083377652],[30107.0,30287.0,771201.0]],"n":342},"surface_owners":[1.0,1.0,1.0],"uv_curve_owners":[0.0,1.0,2.0,3.0,4.0,5.0,-1.0,-1.0,-1.0,-1.0,-1.0,-1.0]}}
{"case":"gen0029","deferred":null,"error":null,"ints":{"graph":{"blocks":[[101,5336]],"n":97}},"topology":null,"values":{"edge_curve_owners":[0.0,1.0],"geometry":{"blocks":[[8e+20,8e+20,3.6e+21]],"n":27},"record":{"blocks":[[72141.8827762868,72278.09763810274,3691636.3260077816]],"n":140},"surface_owners":[1.0],"uv_curve_owners":[0.0,1.0,-1.0,-1.0]}}
{"case":"gen0030","deferred":null,"error":null,"ints":{"graph":{"blocks":[[1296,157069],[2328,235553],[3587,458155],[6012,680472]],"n":985}},"topology":null,"values":{"edge_curve_owners":[0.0,1.0,2.0,3.0,4.0,5.0,6.0,7.0,8.0,9.0,10.0,11.0,12.0,13.0,14.0],"geometry":{"blocks":[[1.1999999999999975e+22,1.1999999999999975e+22,7.260000000000001e+23]],"n":240},"record":{"blocks":[[155889.5,155901.5,19084516.0],[154524.26679056452,154754.26679056452,20294414.16484167],[140727.0,140747.0,17917951.0],[154823.9825193369,154889.9825193369,19101055.99218987],[103941.5,103945.5,11452164.0],[30322.62862549696,30351.572897406964,900460.083544985]],"n":1370},"surface_owners":[1.0,1.0,1.0,1.0,1.0,1.0,1.0],"uv_curve_owners":{"blocks":[[405.0,465.0,7625.0]],"n":60}}}
{"case":"gen0031","deferred":null,"error":null,"ints":{"graph":{"blocks":[[2508,322157],[2737,381059],[5730,644350],[3544,630392],[6005,593789],[9923,1440534],[12774,1466844]],"n":1777}},"topology":null,"values":{"edge_curve_owners":{"blocks":[[351.0,351.0,6552.0]],"n":27},"geometry":{"blocks":[[2.1599999999999874e+22,2.1599999999999874e+22,2.3436e+24],[615.8846177461531,14815740.328712061,14867121.579591237]],"n":424},"record":{"blocks":[[140987.2736643996,141060.86746726738,18167363.483604513],[127271.2210827841,127432.17815770011,17660390.497585837],[128002.63741952149,128193.04443412332,17391234.07712418],[91053.96848240259,65856740.31616496,77823261.30486597],[60762.71681469282,60811.28318530718,5140971.371759845]],"n":1210},"surface_owners":[1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0],"uv_curve_owners":{"blocks":[[-108.0,108.0,-5886.0]],"n":108}}}
{"case":"gen0031","deferred":null,"error":null,"ints":{"graph":{"blocks":[[2468,318735],[2755,382199],[5710,650567],[3534,632497],[6013,592519],[9779,1412900],[12928,1499698]],"n":1777}},"topology":null,"values":{"edge_curve_owners":{"blocks":[[351.0,351.0,6552.0]],"n":27},"geometry":{"blocks":[[2.1599999999999874e+22,2.1599999999999874e+22,2.3436e+24],[431.5198428883937,624.652584117112,27447.906125791415]],"n":424},"record":{"blocks":[[156224.41673182778,156297.18329645565,19149526.127055034],[154598.40251211118,154985.87177838173,20301423.514221795],[140664.7203279845,140836.2024273099,17905887.2094762],[154920.03783534584,154938.03783534584,19116535.34318591],[154743.16176198598,154820.03053193798,20312026.989448104],[140446.0,140764.0,17885794.0],[154426.5,154613.5,19052446.0],[129684.24687969516,129844.4946593029,14955378.32523922],[91187.66304535375,91247.66304535375,11441009.498965224],[30370.71681469282,30387.28318530718,1022758.4248081051]],"n":2398},"surface_owners":[1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0],"uv_curve_owners":{"blocks":[[1377.0,1485.0,48069.0]],"n":108}}}
{"case":"gen0032","deferred":null,"error":null,"ints":{"graph":{"blocks":[[2468,318735],[2755,382199],[5710,650567],[3534,632497],[6013,592519],[9779,1412900],[12928,1499698]],"n":1777}},"topology":null,"values":{"edge_curve_owners":{"blocks":[[351.0,351.0,6552.0]],"n":27},"geometry":{"blocks":[[2.1599999999999874e+22,2.1599999999999874e+22,2.3436e+24],[431.5198428883937,624.652584117112,27447.906125791415]],"n":424},"record":{"blocks":[[156224.41673182778,156297.18329645565,19149526.127055034],[154598.40251211118,154985.87177838173,20301423.514221795],[140664.7203279845,140836.2024273099,17905887.2094762],[154920.03783534584,154938.03783534584,19116535.34318591],[154743.16176198598,154820.03053193798,20312026.989448104],[140446.0,140764.0,17885794.0],[154426.5,154613.5,19052446.0],[129684.24687969516,129844.4946593029,14955378.32523922],[91187.66304535375,91247.66304535375,11441009.498965224],[30370.71681469282,30387.28318530718,1022758.4248081051]],"n":2398},"surface_owners":[1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0],"uv_curve_owners":{"blocks":[[1377.0,1485.0,48069.0]],"n":108}}}
{"case":"gen0033","deferred":null,"error":null,"ints":{"graph":{"blocks":[[1277,174490],[2444,346534],[4114,669278],[778,14800]],"n":804}},"topology":null,"values":{"edge_curve_owners":[0.0,1.0,2.0,3.0,4.0,5.0,6.0,7.0,8.0,9.0,10.0,11.0],"geometry":{"blocks":[[9.6e+21,9.600000000000017e+21,4.6559999999999995e+23]],"n":194},"record":{"blocks":[[141253.1029586434,141336.67310083122,18233727.58146279],[910456.8947865181,29741318.114338845,368938141.54290855],[-791133.6058079528,13482279.819274934,-89893885.21058506]],"n":585},"surface_owners":[1.0,1.0,1.0,1.0,1.0,1.0],"uv_curve_owners":{"blocks":[[-48.0,48.0,-1176.0]],"n":48}}}
{"case":"gen0034","deferred":null,"error":null,"ints":{"graph":{"blocks":[[682,105286],[29,96]],"n":261}},"topology":null,"values":{"edge_curve_owners":[0.0,1.0,2.0],"geometry":{"blocks":[[2.4e+21,2.4e+21,2.9999999999999996e+22]],"n":56},"record":{"blocks":[[142982.44946801648,143164.44946801648,15937837.083377652],[30107.0,30287.0,771201.0]],"n":342},"surface_owners":[1.0,1.0,1.0],"uv_curve_owners":[0.0,1.0,2.0,3.0,4.0,5.0,-1.0,-1.0,-1.0,-1.0,-1.0,-1.0]}}
@@ -101,14 +101,14 @@
{"case":"gen0038","deferred":null,"error":null,"ints":{"graph":{"blocks":[[104,5428]],"n":99}},"topology":null,"values":{"edge_curve_owners":[0.0],"geometry":{"blocks":[[8e+20,8e+20,3.6e+21]],"n":24},"record":{"blocks":[[58410.52515099852,58471.59621881038,2310587.9923582436]],"n":116},"surface_owners":[1.0],"uv_curve_owners":[0.0,1.0,-1.0,-1.0]}}
{"case":"gen0039","deferred":null,"error":null,"ints":{"graph":{"blocks":[[1277,174490],[2444,346534],[4114,669278],[778,14800]],"n":804}},"topology":null,"values":{"edge_curve_owners":[0.0,1.0,2.0,3.0,4.0,5.0,6.0,7.0,8.0,9.0,10.0,11.0],"geometry":{"blocks":[[9.6e+21,9.600000000000017e+21,4.656000000000001e+23]],"n":194},"record":{"blocks":[[141125.56968884318,141205.72410907954,18211054.808841206],[856315.8199191247,28873797.151187282,281138581.99902],[-736999.2588899866,14771293.71724904,24109598.28267207]],"n":585},"surface_owners":[1.0,1.0,1.0,1.0,1.0,1.0],"uv_curve_owners":{"blocks":[[-48.0,48.0,-1176.0]],"n":48}}}
{"case":"gen0040","deferred":null,"error":null,"ints":{"graph":{"blocks":[[1172,159991],[1948,311927],[978,28202]],"n":573}},"topology":null,"values":{"edge_curve_owners":[0.0,1.0,2.0,3.0,4.0,5.0,6.0,7.0],"geometry":{"blocks":[[6.4e+21,6.4e+21,2.0799999999999997e+23]],"n":133},"record":{"blocks":[[156360.52780532837,158006.30563557148,19068776.25026989],[153903.1388885975,156472.02783429623,20181493.582973957],[104892.00006401539,108480.00015670061,12407745.286694974],[258.6666741371155,509.33334589004517,2804.4445185661316]],"n":784},"surface_owners":[1.0,1.0,1.0,1.0,1.0],"uv_curve_owners":{"blocks":[[360.0,392.0,9416.0]],"n":32}}}
{"case":"gen0041","deferred":null,"error":null,"ints":{"graph":{"blocks":[[2508,322157],[2737,381059],[5730,644350],[3544,630392],[6005,593789],[9923,1440534],[12774,1466844]],"n":1777}},"topology":null,"values":{"edge_curve_owners":{"blocks":[[351.0,351.0,6552.0]],"n":27},"geometry":{"blocks":[[2.1599999999999874e+22,2.1599999999999874e+22,2.3436e+24],[493.70576312502754,4938871.29528567,4974338.599395086]],"n":424},"record":{"blocks":[[140835.77099348552,140907.77099348552,18158499.333163507],[127378.02987628388,127450.02987628388,17689934.168712076],[128134.7764585532,128226.7764585532,17415186.995899804],[91087.80571986358,20675863.00152347,72070148.91494854],[60762.71681469282,60820.78556864008,5140969.303322095]],"n":1210},"surface_owners":[1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0],"uv_curve_owners":{"blocks":[[-108.0,108.0,-5886.0]],"n":108}}}
{"case":"gen0041","deferred":null,"error":null,"ints":{"graph":{"blocks":[[2468,318735],[2755,382199],[5710,650567],[3534,632497],[6013,592519],[9779,1412900],[12928,1499698]],"n":1777}},"topology":null,"values":{"edge_curve_owners":{"blocks":[[351.0,351.0,6552.0]],"n":27},"geometry":{"blocks":[[2.1599999999999874e+22,2.1599999999999874e+22,2.3436e+24],[431.5198428883937,624.652584117112,27447.906125791415]],"n":424},"record":{"blocks":[[156224.41673182778,156297.18329645565,19149526.127055034],[154598.40251211118,154985.87177838173,20301423.514221795],[140664.7203279845,140836.2024273099,17905887.2094762],[154920.03783534584,154938.03783534584,19116535.34318591],[154743.16176198598,154820.03053193798,20312026.989448104],[140446.0,140764.0,17885794.0],[154426.5,154613.5,19052446.0],[129684.24687969516,129844.4946593029,14955378.32523922],[91187.66304535375,91247.66304535375,11441009.498965224],[30370.71681469282,30387.28318530718,1022758.4248081051]],"n":2398},"surface_owners":[1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0],"uv_curve_owners":{"blocks":[[1377.0,1485.0,48069.0]],"n":108}}}
{"case":"gen0042","deferred":null,"error":null,"ints":{"graph":{"blocks":[[1277,174490],[2444,346534],[4114,669278],[778,14800]],"n":804}},"topology":null,"values":{"edge_curve_owners":[0.0,1.0,2.0,3.0,4.0,5.0,6.0,7.0,8.0,9.0,10.0,11.0],"geometry":{"blocks":[[9.6e+21,9.600000000000008e+21,4.656000000000001e+23]],"n":194},"record":{"blocks":[[141028.51753995748,141100.51753995748,18209140.71170544],[104020.45910069923,20901260.41429212,82962163.56631431],[15281.0,15292.597876826972,503374.6497665959]],"n":585},"surface_owners":[1.0,1.0,1.0,1.0,1.0,1.0],"uv_curve_owners":{"blocks":[[-48.0,48.0,-1176.0]],"n":48}}}
{"case":"gen0043","deferred":null,"error":null,"ints":{"graph":{"blocks":[[1881,231784],[3468,553413],[2259,207996],[5091,517645],[9023,1312991],[4471,251788]],"n":1396}},"topology":null,"values":{"edge_curve_owners":{"blocks":[[210.0,210.0,3080.0]],"n":21},"geometry":{"blocks":[[1.6799999999999925e+22,1.6799999999999933e+22,1.4196e+24],[97.07644014672621,273.0764401467262,4532.062022572823]],"n":337},"record":{"blocks":[[140827.5568614641,140999.5568614641,18163612.41307346],[127029.93663265301,127151.93663265301,17644141.082648907],[103806.82188705914,19857052.821887065,77472491.1227448],[75988.12293491783,76084.12293491783,8443981.054912265]],"n":1001},"surface_owners":[1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0],"uv_curve_owners":{"blocks":[[-84.0,84.0,-3570.0]],"n":84}}}
{"case":"gen0044","deferred":null,"error":null,"ints":{"graph":{"blocks":[[1172,159991],[1948,311927],[978,28202]],"n":573}},"topology":null,"values":{"edge_curve_owners":[0.0,1.0,2.0,3.0,4.0,5.0,6.0,7.0],"geometry":{"blocks":[[6.4e+21,6.4e+21,2.0799999999999997e+23]],"n":133},"record":{"blocks":[[156360.52780532837,158006.30563557148,19068776.25026989],[153903.1388885975,156472.02783429623,20181493.582973957],[104892.00006401539,108480.00015670061,12407745.286694974],[258.6666741371155,509.33334589004517,2804.4445185661316]],"n":784},"surface_owners":[1.0,1.0,1.0,1.0,1.0],"uv_curve_owners":{"blocks":[[360.0,392.0,9416.0]],"n":32}}}
{"case":"gen0045","deferred":null,"error":null,"ints":{"graph":{"blocks":[[2468,318735],[2755,382199],[5710,650567],[3534,632497],[6013,592519],[9779,1412900],[12928,1499698]],"n":1777}},"topology":null,"values":{"edge_curve_owners":{"blocks":[[351.0,351.0,6552.0]],"n":27},"geometry":{"blocks":[[2.1599999999999874e+22,2.1599999999999874e+22,2.3436e+24],[431.5198428883937,624.652584117112,27447.906125791415]],"n":424},"record":{"blocks":[[156224.41673182778,156297.18329645565,19149526.127055034],[154598.40251211118,154985.87177838173,20301423.514221795],[140664.7203279845,140836.2024273099,17905887.2094762],[154920.03783534584,154938.03783534584,19116535.34318591],[154743.16176198598,154820.03053193798,20312026.989448104],[140446.0,140764.0,17885794.0],[154426.5,154613.5,19052446.0],[129684.24687969516,129844.4946593029,14955378.32523922],[91187.66304535375,91247.66304535375,11441009.498965224],[30370.71681469282,30387.28318530718,1022758.4248081051]],"n":2398},"surface_owners":[1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0],"uv_curve_owners":{"blocks":[[1377.0,1485.0,48069.0]],"n":108}}}
{"case":"gen0046","deferred":null,"error":null,"ints":{"graph":{"blocks":[[104,5428]],"n":99}},"topology":null,"values":{"edge_curve_owners":[0.0],"geometry":{"blocks":[[8e+20,8e+20,3.6e+21]],"n":24},"record":{"blocks":[[58410.52515099852,58471.59621881038,2310587.9923582436]],"n":116},"surface_owners":[1.0],"uv_curve_owners":[0.0,1.0,-1.0,-1.0]}}
{"case":"gen0047","deferred":null,"error":null,"ints":{"graph":{"blocks":[[692,106331],[25,79]],"n":261}},"topology":null,"values":{"edge_curve_owners":[0.0,1.0,2.0],"geometry":{"blocks":[[2.4e+21,2.4000000000000084e+21,3.000000000000001e+22]],"n":56},"record":{"blocks":[[87521.9169102232,40095667.79834579,194151991.58350927]],"n":210},"surface_owners":[1.0,1.0,1.0],"uv_curve_owners":[-1.0,-1.0,-1.0,-1.0,-1.0,-1.0,-1.0,-1.0,-1.0,-1.0,-1.0,-1.0]}}
{"case":"gen0048","deferred":null,"error":null,"ints":{"graph":{"blocks":[[2508,322157],[2737,381059],[5730,644350],[3544,630392],[6005,593789],[9923,1440534],[12774,1466844]],"n":1777}},"topology":null,"values":{"edge_curve_owners":{"blocks":[[351.0,351.0,6552.0]],"n":27},"geometry":{"blocks":[[2.1599999999999874e+22,2.1599999999999874e+22,2.3436e+24],[606.257544775902,4939172.362337735,4987194.028411555]],"n":424},"record":{"blocks":[[140935.6381299591,141013.4813591142,18164983.31700817],[127300.62823450065,127457.95944884761,17672849.589725796],[127966.47478588187,128150.73079664435,17388950.893938813],[91124.98240081035,22660748.32632806,-39751764.701571964],[60762.71681469282,60811.28318530718,5140971.371759845]],"n":1210},"surface_owners":[1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0],"uv_curve_owners":{"blocks":[[-108.0,108.0,-5886.0]],"n":108}}}
{"case":"gen0048","deferred":null,"error":null,"ints":{"graph":{"blocks":[[2468,318735],[2755,382199],[5710,650567],[3534,632497],[6013,592519],[9779,1412900],[12928,1499698]],"n":1777}},"topology":null,"values":{"edge_curve_owners":{"blocks":[[351.0,351.0,6552.0]],"n":27},"geometry":{"blocks":[[2.1599999999999874e+22,2.1599999999999874e+22,2.3436e+24],[431.5198428883937,624.652584117112,27447.906125791415]],"n":424},"record":{"blocks":[[156224.41673182778,156297.18329645565,19149526.127055034],[154598.40251211118,154985.87177838173,20301423.514221795],[140664.7203279845,140836.2024273099,17905887.2094762],[154920.03783534584,154938.03783534584,19116535.34318591],[154743.16176198598,154820.03053193798,20312026.989448104],[140446.0,140764.0,17885794.0],[154426.5,154613.5,19052446.0],[129684.24687969516,129844.4946593029,14955378.32523922],[91187.66304535375,91247.66304535375,11441009.498965224],[30370.71681469282,30387.28318530718,1022758.4248081051]],"n":2398},"surface_owners":[1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0],"uv_curve_owners":{"blocks":[[1377.0,1485.0,48069.0]],"n":108}}}
{"case":"gen0049","deferred":null,"error":null,"ints":{"graph":{"blocks":[[101,5336]],"n":97}},"topology":null,"values":{"edge_curve_owners":[0.0,1.0],"geometry":{"blocks":[[8e+20,8e+20,3.6e+21]],"n":27},"record":{"blocks":[[72141.8827762868,72278.09763810274,3691636.3260077816]],"n":140},"surface_owners":[1.0],"uv_curve_owners":[0.0,1.0,-1.0,-1.0]}}
{"case":"gen0050","deferred":null,"error":null,"ints":{"graph":{"blocks":[[1485,181651],[2387,261025],[3175,417519],[6648,902856]],"n":1019}},"topology":null,"values":{"edge_curve_owners":[0.0,1.0,2.0,3.0,4.0,5.0,6.0,7.0,8.0,9.0,10.0,11.0,12.0,13.0,14.0],"geometry":{"blocks":[[1.1999999999999975e+22,1.1999999999999975e+22,7.260000000000001e+23]],"n":240},"record":{"blocks":[[157217.22447274308,157231.22447274308,19278198.216036484],[156157.13303146334,156419.82363523965,20447243.250780012],[141455.48021756337,141593.08071388188,17996368.187218934],[155107.52131219086,155348.7302645498,19149360.30337226],[104573.08452012748,104661.55858306825,11530544.626243796],[30393.128551147056,30431.024802910142,904071.2969962137]],"n":1370},"surface_owners":[1.0,1.0,1.0,1.0,1.0,1.0,1.0],"uv_curve_owners":{"blocks":[[405.0,465.0,7625.0]],"n":60}}}
{"case":"gen0051","deferred":null,"error":null,"ints":{"graph":{"blocks":[[1277,174490],[2444,346534],[4114,669278],[778,14800]],"n":804}},"topology":null,"values":{"edge_curve_owners":[0.0,1.0,2.0,3.0,4.0,5.0,6.0,7.0,8.0,9.0,10.0,11.0],"geometry":{"blocks":[[9.6e+21,9.600000000000025e+21,4.6559999999999995e+23]],"n":194},"record":{"blocks":[[141092.90971071896,141221.69068258165,18207690.57520062],[104028.99814035674,65874074.66927339,338695340.2160862],[15280.999999999993,15402.906760956694,502964.8308552575]],"n":585},"surface_owners":[1.0,1.0,1.0,1.0,1.0,1.0],"uv_curve_owners":{"blocks":[[-48.0,48.0,-1176.0]],"n":48}}}
@@ -119,7 +119,7 @@
{"case":"gen0056","deferred":null,"error":null,"ints":{"graph":{"blocks":[[101,5336]],"n":97}},"topology":null,"values":{"edge_curve_owners":[0.0,1.0],"geometry":{"blocks":[[8e+20,8e+20,3.6e+21]],"n":27},"record":{"blocks":[[72141.8827762868,72278.09763810274,3691636.3260077816]],"n":140},"surface_owners":[1.0],"uv_curve_owners":[0.0,1.0,-1.0,-1.0]}}
{"case":"gen0057","deferred":null,"error":null,"ints":{"graph":{"blocks":[[692,106331],[25,79]],"n":261}},"topology":null,"values":{"edge_curve_owners":[0.0,1.0,2.0],"geometry":{"blocks":[[2.4e+21,2.400000000000004e+21,3.0000000000000004e+22]],"n":56},"record":{"blocks":[[87495.35549293598,19840768.588122904,71346522.82882571]],"n":210},"surface_owners":[1.0,1.0,1.0],"uv_curve_owners":[-1.0,-1.0,-1.0,-1.0,-1.0,-1.0,-1.0,-1.0,-1.0,-1.0,-1.0,-1.0]}}
{"case":"gen0058","deferred":null,"error":null,"ints":{"graph":{"blocks":[[1277,174490],[2444,346534],[4114,669278],[778,14800]],"n":804}},"topology":null,"values":{"edge_curve_owners":[0.0,1.0,2.0,3.0,4.0,5.0,6.0,7.0,8.0,9.0,10.0,11.0],"geometry":{"blocks":[[9.6e+21,9.600000000000017e+21,4.6559999999999995e+23]],"n":194},"record":{"blocks":[[141186.52911685206,141258.52911685206,18221543.461588066],[-1515580.127978588,27179463.490194604,-332182873.19711703],[1634882.5731820147,14575708.153637713,36214671.04161818]],"n":585},"surface_owners":[1.0,1.0,1.0,1.0,1.0,1.0],"uv_curve_owners":{"blocks":[[-48.0,48.0,-1176.0]],"n":48}}}
{"case":"gen0059","deferred":null,"error":null,"ints":{"graph":{"blocks":[[2508,322157],[2737,381059],[5730,644350],[3544,630392],[6005,593789],[9923,1440534],[12774,1466844]],"n":1777}},"topology":null,"values":{"edge_curve_owners":{"blocks":[[351.0,351.0,6552.0]],"n":27},"geometry":{"blocks":[[2.1599999999999874e+22,2.1599999999999874e+22,2.3436e+24],[576.479912043791,9877403.191385778,9922263.828823194]],"n":424},"record":{"blocks":[[140960.93079587494,141032.93079587494,18166850.62474821],[127255.44270984929,127414.00046731443,17659903.3699522],[127999.14773026791,128178.73507590464,17389989.730407752],[91113.69032347939,43510619.86313032,96805260.33414717],[60762.71681469282,60811.28318530718,5140971.371759845]],"n":1210},"surface_owners":[1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0],"uv_curve_owners":{"blocks":[[-108.0,108.0,-5886.0]],"n":108}}}
{"case":"gen0059","deferred":null,"error":null,"ints":{"graph":{"blocks":[[2468,318735],[2755,382199],[5710,650567],[3534,632497],[6013,592519],[9779,1412900],[12928,1499698]],"n":1777}},"topology":null,"values":{"edge_curve_owners":{"blocks":[[351.0,351.0,6552.0]],"n":27},"geometry":{"blocks":[[2.1599999999999874e+22,2.1599999999999874e+22,2.3436e+24],[431.5198428883937,624.652584117112,27447.906125791415]],"n":424},"record":{"blocks":[[156224.41673182778,156297.18329645565,19149526.127055034],[154598.40251211118,154985.87177838173,20301423.514221795],[140664.7203279845,140836.2024273099,17905887.2094762],[154920.03783534584,154938.03783534584,19116535.34318591],[154743.16176198598,154820.03053193798,20312026.989448104],[140446.0,140764.0,17885794.0],[154426.5,154613.5,19052446.0],[129684.24687969516,129844.4946593029,14955378.32523922],[91187.66304535375,91247.66304535375,11441009.498965224],[30370.71681469282,30387.28318530718,1022758.4248081051]],"n":2398},"surface_owners":[1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0],"uv_curve_owners":{"blocks":[[1377.0,1485.0,48069.0]],"n":108}}}
{"case":"gen0060","deferred":null,"error":null,"ints":{"graph":{"blocks":[[50,1663]],"n":71}},"topology":null,"values":{"edge_curve_owners":[0.0],"geometry":[1e+20,1e+20,1e+20,1e+20,0.0,0.0,0.7261627726849257,0.00001,-1234567.0,-1234567.0,1234567.0,1234567.0,0.00001,-30.61467458920718,0.0,0.00001],"record":{"blocks":[[29005.551604228152,19782192.780953407,123949089.18024483]],"n":70},"surface_owners":[1.0],"uv_curve_owners":[-1.0,-1.0]}}
{"case":"gen0061","deferred":null,"error":null,"ints":{"graph":{"blocks":[[50,1663]],"n":71}},"topology":null,"values":{"edge_curve_owners":[0.0],"geometry":[1e+20,1e+20,1e+20,1e+20,0.0,5.0,0.0,0.00001,-10.0,-5.0,0.0,4.999999999999999,0.00001,-30.61467458920718,0.0,0.00001],"record":{"blocks":[[29010.385325410793,29185.614674589207,492554.5413016432]],"n":70},"surface_owners":[1.0],"uv_curve_owners":[-1.0,-1.0]}}
{"case":"gen0062","deferred":null,"error":null,"ints":{"graph":{"blocks":[[682,105286],[29,96]],"n":261}},"topology":null,"values":{"edge_curve_owners":[0.0,1.0,2.0],"geometry":{"blocks":[[2.4e+21,2.4e+21,2.9999999999999996e+22]],"n":56},"record":{"blocks":[[142982.44946801648,143164.44946801648,15937837.083377652],[30107.0,30287.0,771201.0]],"n":342},"surface_owners":[1.0,1.0,1.0],"uv_curve_owners":[0.0,1.0,2.0,3.0,4.0,5.0,-1.0,-1.0,-1.0,-1.0,-1.0,-1.0]}}
@@ -136,7 +136,7 @@
{"case":"gen0073","deferred":null,"error":null,"ints":{"graph":{"blocks":[[101,5336]],"n":97}},"topology":null,"values":{"edge_curve_owners":[0.0,1.0],"geometry":{"blocks":[[8e+20,8e+20,3.6e+21]],"n":27},"record":{"blocks":[[72141.8827762868,72278.09763810274,3691636.3260077816]],"n":140},"surface_owners":[1.0],"uv_curve_owners":[0.0,1.0,-1.0,-1.0]}}
{"case":"gen0074","deferred":null,"error":null,"ints":{"graph":{"blocks":[[1277,174490],[2444,346534],[4114,669278],[778,14800]],"n":804}},"topology":null,"values":{"edge_curve_owners":[0.0,1.0,2.0,3.0,4.0,5.0,6.0,7.0,8.0,9.0,10.0,11.0],"geometry":{"blocks":[[9.6e+21,9.600000000000025e+21,4.6559999999999995e+23]],"n":194},"record":{"blocks":[[141078.67945932975,141262.36447332427,18213079.47648731],[-2365134.5339968833,49170000.150317684,-534069203.35257745],[2484425.6882363446,12360978.591230411,116552981.21678422]],"n":585},"surface_owners":[1.0,1.0,1.0,1.0,1.0,1.0],"uv_curve_owners":{"blocks":[[-48.0,48.0,-1176.0]],"n":48}}}
{"case":"gen0075","deferred":null,"error":null,"ints":{"graph":{"blocks":[[1172,159991],[1948,311927],[978,28202]],"n":573}},"topology":null,"values":{"edge_curve_owners":[0.0,1.0,2.0,3.0,4.0,5.0,6.0,7.0],"geometry":{"blocks":[[6.4e+21,6.4e+21,2.0799999999999997e+23]],"n":133},"record":{"blocks":[[156360.52780532837,158006.30563557148,19068776.25026989],[153903.1388885975,156472.02783429623,20181493.582973957],[104892.00006401539,108480.00015670061,12407745.286694974],[258.6666741371155,509.33334589004517,2804.4445185661316]],"n":784},"surface_owners":[1.0,1.0,1.0,1.0,1.0],"uv_curve_owners":{"blocks":[[360.0,392.0,9416.0]],"n":32}}}
{"case":"gen0076","deferred":null,"error":null,"ints":{"graph":{"blocks":[[1191,161874],[1949,313933],[974,28428]],"n":573}},"topology":null,"values":{"edge_curve_owners":[0.0,1.0,2.0,3.0,4.0,5.0,6.0,7.0],"geometry":{"blocks":[[6.4e+21,6.400000000000004e+21,2.0799999999999997e+23]],"n":133},"record":{"blocks":[[-3573955.801055106,13714426.230891941,-884935273.6169126],[3765521.6562693026,6237320.46813458,20670087.030879214]],"n":432},"surface_owners":[1.0,1.0,1.0,1.0,1.0],"uv_curve_owners":{"blocks":[[-32.0,32.0,-528.0]],"n":32}}}
{"case":"gen0076","deferred":null,"error":null,"ints":{"graph":{"blocks":[[1172,159991],[1948,311927],[978,28202]],"n":573}},"topology":null,"values":{"edge_curve_owners":[0.0,1.0,2.0,3.0,4.0,5.0,6.0,7.0],"geometry":{"blocks":[[6.4e+21,6.4e+21,2.0799999999999997e+23]],"n":133},"record":{"blocks":[[156360.52780532837,158006.30563557148,19068776.25026989],[153903.1388885975,156472.02783429623,20181493.582973957],[104892.00006401539,108480.00015670061,12407745.286694974],[258.6666741371155,509.33334589004517,2804.4445185661316]],"n":784},"surface_owners":[1.0,1.0,1.0,1.0,1.0],"uv_curve_owners":{"blocks":[[360.0,392.0,9416.0]],"n":32}}}
{"case":"gen0077","deferred":null,"error":null,"ints":{"graph":{"blocks":[[50,1663]],"n":71}},"topology":null,"values":{"edge_curve_owners":[0.0],"geometry":[1e+20,1e+20,1e+20,1e+20,0.0,5.0,0.0,0.00001,-10.0,-5.0,0.0,4.999999999999999,0.00001,-30.61467458920718,0.0,0.00001],"record":{"blocks":[[29010.385325410793,29185.614674589207,492554.5413016432]],"n":70},"surface_owners":[1.0],"uv_curve_owners":[-1.0,-1.0]}}
{"case":"gen0078","deferred":null,"error":null,"ints":{"graph":{"blocks":[[101,5336]],"n":97}},"topology":null,"values":{"edge_curve_owners":[0.0,1.0],"geometry":{"blocks":[[8e+20,8e+20,3.6e+21]],"n":27},"record":{"blocks":[[72141.8827762868,72278.09763810274,3691636.3260077816]],"n":140},"surface_owners":[1.0],"uv_curve_owners":[0.0,1.0,-1.0,-1.0]}}
{"case":"gen0079","deferred":null,"error":null,"ints":{"graph":{"blocks":[[1881,231784],[3468,553413],[2259,207996],[5091,517645],[9023,1312991],[4471,251788]],"n":1396}},"topology":null,"values":{"edge_curve_owners":{"blocks":[[210.0,210.0,3080.0]],"n":21},"geometry":{"blocks":[[1.6799999999999925e+22,1.679999999999995e+22,1.4196e+24],[247.87446819416934,292.6310905949567,11255.605714095394]],"n":337},"record":{"blocks":[[140956.03803434453,141040.53803434453,18185296.49393398],[127239.77528722705,127311.77528722705,17668690.287975267],[-3599843.39161713,48252017.31114167,-789183361.4693285],[3779671.2271521315,11187235.78436824,114616369.86204277]],"n":1001},"surface_owners":[1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0],"uv_curve_owners":{"blocks":[[-84.0,84.0,-3570.0]],"n":84}}}
+6 -6
View File
@@ -11,6 +11,7 @@
use std::cell::RefCell;
use solidcore::kernel::Kernel;
use solidcore::mass::enclosure::VolumeSign;
use solidcore::topology::brep::validation;
use solidcore::topology::Brep;
@@ -25,8 +26,8 @@ pub struct Soundness {
pub check: Result<Vec<String>, String>,
/// Whether the body is a manifold solid.
pub manifold: Option<bool>,
/// Its volume, when it is a manifold solid.
pub volume: Option<f64>,
/// Its volume's sign, when it is a manifold solid.
pub volume: Option<VolumeSign>,
/// Its face, edge and vertex counts.
pub counts: [usize; 3],
}
@@ -35,7 +36,7 @@ impl Soundness {
/// Whether the check is clean and a manifold body has a positive volume.
pub fn sound(&self) -> bool {
self.check.as_ref().is_ok_and(Vec::is_empty)
&& (self.manifold != Some(true) || self.volume.is_some_and(|v| v > 0.0))
&& (self.manifold != Some(true) || matches!(self.volume, Some(VolumeSign::Positive(_))))
}
/// One line: `sound`, the check's line count, manifold, volume and counts.
@@ -164,7 +165,7 @@ pub fn probe(runner: &dyn crate::UnitDiff) {
#[cfg(test)]
mod tests {
use super::{measure, start, take};
use super::{measure, start, take, VolumeSign};
use solidcore::kernel::Kernel;
fn cube() -> solidcore::topology::Brep {
@@ -198,8 +199,7 @@ mod tests {
assert!(s.sound(), "{}", s.line());
assert_eq!(s.manifold, Some(true));
assert!(
s.volume
.is_some_and(|v| (v - 1000.0).abs() <= 1e-6 * 1000.0),
matches!(s.volume, Some(VolumeSign::Positive(e)) if e.lo <= 1000.0 && 1000.0 <= e.hi),
"{}",
s.line()
);
+2 -1
View File
@@ -5,7 +5,8 @@ edition.workspace = true
license.workspace = true
version.workspace = true
publish = false
rust-version.workspace = true
# solidcore-record needs Rust 1.88 (edition 2024 let chains).
rust-version = "1.88"
repository = "https://github.com/tangency-labs/solidcore"
keywords = ["cad", "brep", "nurbs", "geometry"]
categories = ["data-structures", "science"]
+151 -7
View File
@@ -21,6 +21,8 @@
#![allow(clippy::field_reassign_with_default)]
#![allow(clippy::neg_cmp_op_on_partial_ord)] // NaN-refusing tests
use std::collections::BTreeMap;
use solidcore::curve::curve::Curve as KernelCurve;
use solidcore::curve::from_parameters::{
self as curves, approx_tolerance, zone_tolerance, MAX_TOLERANCE,
@@ -93,6 +95,86 @@ pub struct Order {
pub vertices: Vec<VertexId>,
}
/// A kernel edge solids touch along, as [`to_record`] writes it: one record edge per pair of
/// faces it separates (SolidCraft's `history::Piece`). An edge with uses on three or more faces
/// has one [`Piece`] per record edge [`to_record`] copied it into, each with the faces of that
/// copy's own uses (two, for the sector pairs the radial order gives); none is merged or split
/// here.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Piece {
/// The kernel edge.
pub kernel: EdgeId,
/// One of its record edges.
pub edge: u32,
/// The record faces that record edge bounds, ascending.
pub faces: Vec<u32>,
}
impl Order {
/// Each kernel face's record face (the inverse of [`Order::faces`]).
#[must_use]
pub fn record_faces(&self) -> BTreeMap<FaceId, u32> {
inverse(&self.faces)
}
/// Each kernel edge's first record edge (the inverse of [`Order::edges`]; a kernel edge
/// solids touch along has more, its [`Order::pieces`]).
#[must_use]
pub fn record_edges(&self) -> BTreeMap<EdgeId, u32> {
inverse(&self.edges)
}
/// Each kernel vertex's first record vertex (the inverse of [`Order::vertices`]).
#[must_use]
pub fn record_vertices(&self) -> BTreeMap<VertexId, u32> {
inverse(&self.vertices)
}
/// Every record edge of each kernel edge that has more than one, in record order, with the
/// record faces it bounds (`record` is the record this order came with): the faces of that
/// record edge's uses, sorted and without repeats.
///
/// # Errors
///
/// [`Unsupported::Invalid`] for a record whose edges are not this order's.
pub fn pieces(&self, record: &r::Brep) -> R<Vec<Piece>> {
if record.edges.len() != self.edges.len() {
return Err(Unsupported::Invalid("a record not of this order"));
}
let mut count: BTreeMap<EdgeId, usize> = BTreeMap::new();
for &e in &self.edges {
*count.entry(e).or_default() += 1;
}
let mut out = Vec::new();
for (i, (&kernel, e)) in self.edges.iter().zip(&record.edges).enumerate() {
if count.get(&kernel).copied().unwrap_or(0) < 2 {
continue;
}
let mut faces: Vec<u32> = e.uses.iter().map(|u| u.face).collect();
faces.sort_unstable();
faces.dedup();
out.push(Piece {
kernel,
edge: u32::try_from(i).map_err(|_| Unsupported::Invalid("index"))?,
faces,
});
}
Ok(out)
}
}
/// Each entity's first index in `list`. An index past `u32` is left out: unreachable, since
/// every list is an [`Order`]'s, indexed by a record's `u32` indices.
fn inverse<T: Copy + Ord>(list: &[T]) -> BTreeMap<T, u32> {
let mut out = BTreeMap::new();
for (i, &x) in list.iter().enumerate() {
if let Ok(i) = u32::try_from(i) {
out.entry(x).or_insert(i);
}
}
out
}
fn int(n: usize) -> R<u64> {
u64::try_from(n).map_err(|_| Unsupported::Invalid("count"))
}
@@ -231,8 +313,8 @@ fn build(
}
/// The record as the kernel's B-rep data: edgeuses face by face, loop by loop, in loop order;
/// edges and vertices in the record's order. Each edge and vertex keeps its own tolerance (at
/// least the body's); the body and every face take the body's. The record has no face
/// edges and vertices in the record's order. Each edge and vertex keeps its own tolerance; the
/// body's and each face's are the least of theirs ([`own_tolerances`]). The record has no face
/// tolerance: a face's own crosses as its edges' (`to_record` puts it into them).
fn data(k: &Kernel, rec: &r::Brep, bare: &[(u32, u32, u32)]) -> R<BrepData> {
let zone = zone_tolerance(rec.tolerance);
@@ -318,7 +400,7 @@ fn data(k: &Kernel, rec: &r::Brep, bare: &[(u32, u32, u32)]) -> R<BrepData> {
// Edges first (their kernel intervals are the trims' parameter), in the record's order.
let mut intervals = Vec::new();
for e in &rec.edges {
let tolerance = e.tolerance.max(rec.tolerance);
let tolerance = e.tolerance;
if !(tolerance <= MAX_TOLERANCE) {
return Err(Unsupported::NotYet(
"an edge tolerance over the kernel's limit",
@@ -475,9 +557,18 @@ fn data(k: &Kernel, rec: &r::Brep, bare: &[(u32, u32, u32)]) -> R<BrepData> {
let interval = intervals
.get(u.edge as usize)
.ok_or(Unsupported::Invalid("edge"))?;
let source_interval = rec
.edges
.get(u.edge as usize)
.ok_or(Unsupported::Invalid("edge"))?
.interval;
ud.uv_curve = int(d.uv_curves.len())?;
let trim =
curves::uv_trim(&crate::curve2(&u.pcurve), interval, made.scale)?;
let trim = curves::uv_trim_on_edge(
&crate::curve2(&u.pcurve),
source_interval,
interval,
made.scale,
)?;
d.uv_curves.push(Keyed {
key: key(),
object: trim,
@@ -500,9 +591,9 @@ fn data(k: &Kernel, rec: &r::Brep, bare: &[(u32, u32, u32)]) -> R<BrepData> {
}
}
// Vertices, each with its own tolerance, never below the body's.
// Vertices, each with its own tolerance.
for v in &rec.vertices {
let tolerance = v.tolerance.max(rec.tolerance);
let tolerance = v.tolerance;
if !(tolerance <= MAX_TOLERANCE) {
return Err(Unsupported::NotYet(
"a vertex tolerance over the kernel's limit",
@@ -514,9 +605,62 @@ fn data(k: &Kernel, rec: &r::Brep, bare: &[(u32, u32, u32)]) -> R<BrepData> {
vd.point = Vec3::new(v.point[0], v.point[1], v.point[2]);
d.vertices.push(vd);
}
own_tolerances(&mut d, rec, zone)?;
Ok(d)
}
/// The body's and each face's tolerance from the edges' and vertices' own, as SolidCraft's
/// flat import took them (`own_tolerances`, after the capi's): the kernel's build raises every
/// entity to the body's, so the body's is the least of the record's and its edges' and
/// vertices', and a face's the least of its loops' edges' and vertices', at most the record's.
/// Each entity keeps its own across the crossing, none raised to the body's.
///
/// This keeps invariant 5 of `docs/validity-and-tolerances.md` §10 (every local tolerance at
/// least the body's) by the other route from raising each entity to the body's (8252ae3e93,
/// "never below the body's"): the body comes down to the least of them instead, so no entity
/// is below it and none is changed. A face's is at least the body's (both are minima, the
/// body's over more) and at most its loops' edges' and vertices'.
///
/// # Errors
///
/// [`Unsupported::Invalid`] when the least zone is not positive and finite: a tolerance so
/// small that its zone (half of it) underflows to zero passes the record's validation.
fn own_tolerances(d: &mut BrepData, rec: &r::Brep, zone: f64) -> R<()> {
let edge_zone = |e: u32| -> R<f64> { Ok(at(&d.edges, e)?.zone_tol_3d) };
let vertex_zone = |v: u32| -> R<f64> { Ok(at(&d.vertices, v)?.zone_tol_3d) };
let mut faces = Vec::with_capacity(rec.faces.len());
for f in &rec.faces {
let mut t = f64::INFINITY;
for l in &f.loops {
match l {
r::Loop::Edges { edges } => {
for oe in edges {
t = t.min(edge_zone(oe.edge)?);
}
}
r::Loop::Vertex { vertex } => t = t.min(vertex_zone(*vertex)?),
}
}
faces.push(if t.is_finite() { t.min(zone) } else { zone });
}
let least = d
.edges
.iter()
.map(|e| e.zone_tol_3d)
.chain(d.vertices.iter().map(|v| v.zone_tol_3d))
.fold(zone, f64::min);
if !(least.is_finite() && least > 0.0) {
return Err(Unsupported::Invalid(
"a tolerance whose zone (half of it) is zero or not finite",
));
}
d.zone_tol_3d = least;
for (fd, t) in d.faces.iter_mut().zip(faces) {
fd.zone_tol_3d = t;
}
Ok(())
}
/// The curves of a ball patch face's loops, for [`face_surface`] to certify on its sphere:
/// each edge's NURBS form, or its curve; empty for any other face, whose label is not
/// checked against its loops.
+1 -1
View File
@@ -3,7 +3,7 @@
//! B-rep data, with no arrays between. What is done here is index mapping and field copies:
//!
//! - **Geometry:** each edge's curve and each face's surface as the kernel writes its record
//! ([`record_of`], [`record_face`]): a line by its two end points, a conic by its own frame and
//! ([`record_of`], [`record_face`]): a line as the kernel holds it, a conic by its own frame and
//! its NURBS form, proved on each other; an analytic face by its label and its NURBS form,
//! proved (`lies_on`); a plane by its placement, its parameters its kernel ones times its
//! scales. Nothing is sampled. A curve or surface with no NURBS form (an offset) is refused.
+76 -4
View File
@@ -226,6 +226,29 @@ fn a_cube_record_builds_the_kernels_cube() {
}
}
#[test]
fn a_pcurve_support_may_extend_past_its_edge_interval() {
let mut record = cube(1.0);
let Curve::Line { start, end } = &record.edges[0].curve else {
panic!("cube line")
};
let (start, end) = (*start, *end);
record.edges[0].curve = Curve::Spline {
knots: vec![0.0, 0.0, 1.0, 1.0],
points: vec![start, end],
};
record.edges[0].interval = [0.0, f64::from_bits(1.0_f64.to_bits() - 2)];
record.validate().unwrap();
let original = record.faces.clone();
let mut k = kernel();
let (mut body, _) = to_kernel(&mut k, &record, true).unwrap();
let (saved, _) = to_record(&mut k, &mut body).unwrap();
assert_eq!(
saved.faces.iter().map(|f| &f.loops).collect::<Vec<_>>(),
original.iter().map(|f| &f.loops).collect::<Vec<_>>()
);
}
#[test]
fn records_the_conversion_cannot_carry_are_refused() {
let mut k = kernel();
@@ -556,8 +579,8 @@ fn lumps_and_solids_touching_along_an_edge_go_round() {
}
/// An analytic body's record: its curved faces are labels on their NURBS forms, its circles
/// labels on theirs, its lines their two end points over [0, 1]; each pcurve on its edge's
/// interval.
/// labels on theirs, its lines as the kernel holds them (its point and unit vector, over the
/// height); each pcurve on its edge's interval.
#[test]
fn analytic_records_carry_labels_on_their_forms() {
let mut k = kernel();
@@ -576,8 +599,9 @@ fn analytic_records_carry_labels_on_their_forms() {
for e in &r.edges {
match &e.curve {
Curve::Line { start, end } => {
assert_eq!(e.interval, [0.0, 1.0]);
assert_eq!((start[2], end[2]), (0.0, 5.0));
// as the kernel holds the seam: its point and unit vector, over its height
assert_eq!(e.interval, [0.0, 5.0]);
assert_eq!((start[2], end[2]), (0.0, 1.0));
assert!(e.nurbs.is_none());
}
Curve::Circle { radius, .. } => {
@@ -1146,3 +1170,51 @@ fn a_ball_patch_far_from_the_origin_reads_as_one() {
assert!(read_ball(&mut k, &off, i).is_none(), "face {i}");
}
}
/// A record whose body tolerance is above its edges' and vertices' builds with the body's
/// lowered to the least of them (the flat import's `own_tolerances`): valid at Level 2, and
/// every edge and vertex at least the body's (invariant 5 of `docs/validity-and-tolerances.md`
/// §10), each at its own (the zone, half the record's), none raised.
#[test]
fn a_body_tolerance_above_its_entities_comes_down_to_theirs() {
let mut k = kernel();
let mut r = cube(10.0);
r.tolerance = 1e-4;
r.edges[3].tolerance = 4e-6;
r.vertices[5].tolerance = 2e-6;
r.validate().unwrap();
let (mut b, _) = to_kernel(&mut k, &r, true).unwrap();
valid(&mut k, &mut b);
// the least is the other edges' 1e-6, as a zone: half of it
assert_eq!(b.tolerance(), 5e-7);
for &e in b.edge_list() {
assert!(b.edge(e).unwrap().tolerance >= b.tolerance(), "edge {e:?}");
}
for &v in b.vertex_list() {
assert!(
b.vertex(v).unwrap().tolerance >= b.tolerance(),
"vertex {v:?}"
);
}
let mut own: Vec<f64> = b
.edge_list()
.iter()
.map(|&e| b.edge(e).unwrap().tolerance)
.collect();
own.sort_by(f64::total_cmp);
own.dedup();
assert_eq!(own, vec![5e-7, 2e-6], "edges keep their own zones");
}
/// A tolerance so small that its zone (half of it) underflows to zero passes the record's
/// validation and is refused by the conversion, not dropped to the body's.
#[test]
fn a_tolerance_whose_zone_underflows_is_refused() {
let mut k = kernel();
let mut r = cube(1.0);
r.vertices[0].tolerance = f64::from_bits(1);
assert!(matches!(
to_kernel(&mut k, &r, true),
Err(Unsupported::Invalid(_))
));
}
+602
View File
@@ -0,0 +1,602 @@
//! Parity with SolidCraft's flat-array hop (#3137): every operand record of the rig corpus
//! (`crates/solidcore/tests/kernel_cases/`) is what SolidCraft's flat export wrote (record →
//! arrays → kernel → arrays → record), or, for a body SolidCraft made or read itself (not
//! `trusted`), what it handed the kernel. Each goes to the kernel ([`to_kernel`]) and back
//! ([`to_record`]); the record that comes back is compared with the one that went in, field by
//! field, bit for bit. A difference is classed by where it is (indices dropped) and, for a
//! kind, by what it became; a number within [`ROUNDING`] of its size (relative, no floor) is
//! counted as rounding, with its count and the largest.
//!
//! On the whole corpus (1,333 bodies, at #3199 with kernel case 11991 (#3231) in): 1,259 come
//! back bit for bit, 36 within rounding (the largest 4.9e-16 relative), and 38 differ, each in exactly the classes
//! [`pinned`] lists for it.
#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing,
clippy::print_stderr
)] // test
use std::collections::{BTreeMap, BTreeSet};
use std::fmt::Write as _;
use std::io::Read as _;
use std::path::{Path, PathBuf};
use serde_json::Value;
use solidcore::kernel::{Kernel, KernelOptions, UpstreamFixes};
use solidcore_convert::brep::{to_kernel, to_kernel_body, to_record, Unsupported};
use solidcore_record::brep::Brep as Record;
fn corpus() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../solidcore/tests/kernel_cases")
}
fn entries(dir: &Path, out: &mut Vec<PathBuf>) {
let mut paths: Vec<PathBuf> = std::fs::read_dir(dir)
.unwrap()
.map(|e| e.unwrap().path())
.collect();
paths.sort();
for p in paths {
if p.is_dir() {
entries(&p, out);
} else if p.extension().is_some_and(|x| x == "json")
&& !p.to_string_lossy().ends_with(".bodies.json")
&& p.parent() != Some(corpus().as_path())
{
out.push(p);
}
}
}
#[derive(Default)]
struct Class {
bodies: usize,
places: usize,
examples: Vec<String>,
}
#[derive(Default)]
struct Tally {
bodies: usize,
identical: usize,
rounding_only: usize,
rounding_places: usize,
rounding_largest: f64,
refused_in: Vec<String>,
/// Sheets with a face used on its reverse side (every difference follows from that).
sheet_side: Vec<String>,
refused_out: Vec<String>,
classes: BTreeMap<String, Class>,
/// Each differing body's classes (sheet-side bodies' too).
per_body: BTreeMap<String, BTreeSet<String>>,
/// Bodies whose `.tolerance` moved other than down to the least of the record's.
tolerance_wrong: Vec<String>,
/// Sheet-side bodies whose geometry differs as multisets (not only renumbered).
sheet_geometry: Vec<String>,
/// (body, face, the record's kind, the kind that came back, trusted)
relabelled: Vec<(String, usize, String, String, bool)>,
}
/// The relative size of a difference counted as rounding, not a class: a few ulps.
const ROUNDING: f64 = 1e-15;
fn normal(path: &str) -> String {
let mut out = String::new();
let mut in_index = false;
for c in path.chars() {
match c {
'[' => {
in_index = true;
out.push_str("[*]");
}
']' => in_index = false,
_ if in_index => {}
_ => out.push(c),
}
}
out
}
/// Every place `a` and `b` differ: (class, place); numbers within [`ROUNDING`] of their size
/// (relative, no floor) counted apart, with the largest relative difference.
fn walk(
at: &str,
a: &Value,
b: &Value,
diffs: &mut Vec<(String, String)>,
rounding: &mut (usize, f64),
) {
if a == b {
return;
}
if let (Some(x), Some(y)) = (a.as_f64(), b.as_f64()) {
let rel = (x - y).abs() / x.abs().max(y.abs());
if rel <= ROUNDING {
rounding.0 += 1;
rounding.1 = rounding.1.max(rel);
return;
}
diffs.push((normal(at), format!("{at}: {x} -> {y}")));
return;
}
match (a, b) {
(Value::Object(x), Value::Object(y)) => {
for (k, v) in x {
walk(
&format!("{at}.{k}"),
v,
y.get(k).unwrap_or(&Value::Null),
diffs,
rounding,
);
}
for (k, w) in y {
if !x.contains_key(k) {
walk(&format!("{at}.{k}"), &Value::Null, w, diffs, rounding);
}
}
}
(Value::Array(x), Value::Array(y)) if x.len() == y.len() => {
for (i, (v, w)) in x.iter().zip(y).enumerate() {
walk(&format!("{at}[{i}]"), v, w, diffs, rounding);
}
}
(Value::Array(x), Value::Array(y)) => diffs.push((
format!("{}: length", normal(at)),
format!("{at}: {} -> {}", x.len(), y.len()),
)),
(Value::String(x), Value::String(y)) => {
diffs.push((format!("{}: {x} -> {y}", normal(at)), at.to_owned()));
}
_ => {
let short = |v: &Value| {
let s = v.to_string();
s.chars().take(60).collect::<String>()
};
diffs.push((normal(at), format!("{at}: {} -> {}", short(a), short(b))));
}
}
}
/// A record's geometry as multisets, its numbering dropped: the vertex points, each edge's
/// curve (a line's two ends unordered), each face's surface and NURBS form.
fn geometry(r: &Value) -> [Vec<String>; 3] {
let list = |key: &str, f: &dyn Fn(&Value) -> String| -> Vec<String> {
let mut v: Vec<String> = r[key]
.as_array()
.map(|a| a.iter().map(f).collect())
.unwrap_or_default();
v.sort();
v
};
let curve = |e: &Value| {
let c = &e["curve"];
if c["kind"] == "line" {
let mut ends = [c["start"].to_string(), c["end"].to_string()];
ends.sort();
format!("line {ends:?}")
} else {
c.to_string()
}
};
[
list("vertices", &|v| v["point"].to_string()),
list("edges", &curve),
list("faces", &|f| format!("{} {}", f["surface"], f["nurbs"])),
]
}
fn kind(face: &Value) -> String {
face["surface"]["kind"].as_str().unwrap_or("?").to_owned()
}
fn one(name: &str, record: &Record, trusted: bool, fixes: &UpstreamFixes, t: &mut Tally) {
t.bodies += 1;
// a sheet face used on its reverse side: the kernel keeps both uses of a sheet's face and
// the record comes back with the `same` one, as SolidCraft's flat export wrote it
// (a sheet's shell is listed by one region; a closed shell by the two it separates)
let listed = |i: usize| {
record
.regions
.iter()
.filter(|r| r.shells.iter().any(|&s| s as usize == i))
.count()
};
let sheet_side = record.shells.iter().enumerate().any(|(i, s)| match s {
solidcore_record::brep::Shell::Faces { faces, .. } => {
listed(i) == 1 && faces.iter().any(|f| !f.forward)
}
_ => false,
});
let mut k = Kernel::new(&KernelOptions::default());
k.fixes = *fixes;
let built = match to_kernel(&mut k, record, trusted) {
Ok((b, _)) => Ok(b),
Err(Unsupported::NotYet(w)) if w.contains("healer") => {
to_kernel_body(&mut k, record, trusted)
}
Err(e) => Err(e),
};
let mut b = match built {
Ok(b) => b,
Err(e) => {
t.refused_in.push(format!("{name}: {e}"));
return;
}
};
let back = match to_record(&mut k, &mut b) {
Ok((r, _)) => r,
Err(e) => {
t.refused_out.push(format!("{name}: {e}"));
return;
}
};
let (a, z) = (
serde_json::to_value(record).unwrap(),
serde_json::to_value(&back).unwrap(),
);
// `PARITY_DUMP=<dir>` and `PARITY_ONLY=<part of a name>`: both records written there
if let (Ok(dir), Ok(only)) = (std::env::var("PARITY_DUMP"), std::env::var("PARITY_ONLY")) {
if name.contains(&only) {
let stem = name.replace(['/', ':'], "_");
std::fs::write(format!("{dir}/{stem}.in.json"), a.to_string()).unwrap();
std::fs::write(format!("{dir}/{stem}.out.json"), z.to_string()).unwrap();
}
}
let mut diffs = Vec::new();
let mut rounding = (0, 0.0_f64);
walk("", &a, &z, &mut diffs, &mut rounding);
t.rounding_places += rounding.0;
t.rounding_largest = t.rounding_largest.max(rounding.1);
if let (Some(fa), Some(fz)) = (a["faces"].as_array(), z["faces"].as_array()) {
for (i, (x, y)) in fa.iter().zip(fz).enumerate() {
if kind(x) != kind(y) {
t.relabelled
.push((name.to_owned(), i, kind(x), kind(y), trusted));
}
}
}
if diffs.is_empty() {
if rounding.0 == 0 {
t.identical += 1;
} else {
t.rounding_only += 1;
}
return;
}
let classes: BTreeSet<String> = diffs.iter().map(|(c, _)| c.clone()).collect();
if classes.contains(".tolerance") {
// lowered, to the least of the record's own (the flat import's `own_tolerances`)
let least = record
.edges
.iter()
.map(|e| e.tolerance)
.chain(record.vertices.iter().map(|v| v.tolerance))
.fold(record.tolerance, f64::min);
if !(back.tolerance < record.tolerance && back.tolerance == least) {
t.tolerance_wrong.push(format!(
"{name}: {} -> {} (least {least})",
record.tolerance, back.tolerance
));
}
}
t.per_body.insert(name.to_owned(), classes);
if sheet_side && geometry(&a) != geometry(&z) {
t.sheet_geometry.push(name.to_owned());
}
if sheet_side {
t.sheet_side.push(name.to_owned());
return;
}
let mut seen = BTreeMap::new();
for (class, place) in diffs {
seen.entry(class).or_insert((0, place)).0 += 1;
}
for (class, (n, place)) in seen {
let tag = if trusted { "" } else { "(untrusted) " };
let c = t.classes.entry(format!("{tag}{class}")).or_default();
c.bodies += 1;
c.places += n;
if c.examples.len() < 3 {
c.examples.push(format!("{name} {place}"));
}
}
}
fn fixes() -> UpstreamFixes {
let fixes_json: Value =
serde_json::from_str(&std::fs::read_to_string(corpus().join("fixes.json")).unwrap())
.unwrap();
let mut fixes = UpstreamFixes::default();
for flag in fixes_json.as_array().unwrap() {
*fixes.flag_mut(flag.as_str().unwrap()).unwrap() = true;
}
fixes
}
const LABEL: [&str; 6] = [
".faces[*].nurbs",
".faces[*].surface.frame",
".faces[*].surface.homogeneous",
".faces[*].surface.u_knots",
".faces[*].surface.v_knots",
"",
];
/// Every body that does not come back bit for bit (or within [`ROUNDING`]), with exactly the
/// classes it differs in: a body that newly differs, one that no longer does, and one whose
/// classes change all fail (#3187 review). Each group says why its difference is intended.
fn pinned() -> BTreeMap<String, BTreeSet<String>> {
let set = |classes: &[&str]| -> BTreeSet<String> {
classes
.iter()
.filter(|c| !c.is_empty())
.map(|c| (*c).to_owned())
.collect()
};
let label = |kind: &str, radii: &[&str]| {
let mut c = set(&LABEL);
c.insert(format!(".faces[*].surface.kind: {kind}"));
c.extend(radii.iter().map(|r| format!(".faces[*].surface.{r}")));
c
};
let groups: Vec<(BTreeSet<String>, &[&str])> = vec![
// a record whose body tolerance is above its least entity's: the import lowers it to
// that least, as the flat import's `own_tolerances` does (checked: only lowered, and
// only to the least of the record's own)
(
set(&[".tolerance"]),
&[
"continuity/cov-patch-g1-caps-an-opened-tube-end.json:b3",
"continuity/cov-patch-g2-caps-an-opened-tube-end.json:b3",
"continuity/g1-patch-g2-at-a-corner-whose-rounds-clash.json:b13",
"continuity/g1-patch-g3-at-a-corner-whose-rounds-clash.json:b13",
"continuity/g2-patch-g2-on-a-hole-in-one-sheet.json:b11",
"continuity/g3-patch-g3-on-a-hole-in-one-sheet.json:b11",
"fillets/9-fillet-corner-r5-curvature-default-setback.json:b8",
"fillets/cov-fillet-g2-pocket-corner.json:b18",
"fillets/every-box-corner-three-edges-r3-curvature-setback-3-5.json:b8",
"fillets/report-fillet-curvature-three-edges-at-bar-corner-solver-residues.json:b18",
"fillets/report-fillet-curvature-three-edges-at-bar-corner.json:b18",
"fillets/report-fillet-g2-corner-of-bar-running-into-cylinder.json:b18",
"ladder/07-fillets/015-corner-g2-patch.json:b8",
"ladder/07-fillets/019-concave-corner-g2-patch.json:b18",
"surfaces/cov-blend-step-between-offset-sheets-g1.json:b1",
"surfaces/cov-blend-step-between-offset-sheets-g2.json:b1",
"surfaces/cov-blend-step-between-offset-sheets-g3.json:b1",
"surfaces/cov-patch-g0-fills-a-240-deg-window-in-a-cylinder.json:b13",
"surfaces/cov-patch-g0-fills-a-saddle-window-in-a-torus.json:b13",
"surfaces/cov-patch-g1-fills-a-240-deg-window-in-a-cylinder.json:b13",
"surfaces/cov-patch-g1-fills-a-saddle-window-in-a-torus.json:b13",
"surfaces/cov-patch-open-box-rim-and-stitch-closes-it.json:b3",
],
),
// a plane through three points: the kernel's plane holds no record of which kind it
// came from, and the flat export never writes this kind
(
set(&[
".faces[*].surface.kind: plane_through -> plane",
".faces[*].surface.u_axis",
".faces[*].surface.u_point",
".faces[*].surface.v_axis",
".faces[*].surface.v_point",
]),
&[
"surfaces/cov-ruled-box-edge-along-the-top-normal.json:b2",
"surfaces/cov-ruled-closed-top-chain-tangent-mitred-frame.json:b2",
"surfaces/cov-ruled-cylinder-rim-tangent-to-the-top-is-a-flat-ring.json:b2",
],
),
// a label its form is not proved on goes as the form alone (#2540): cylinders whose
// frames are off their forms past the rounding `evaluates_as` derives (margins 1.23 to
// 2.03, tests/issue_3185_label_margins.rs)
(
label("cylinder -> rational_spline", &["radius"]),
&[
"booleans/3456-deep-cut-through-drilled-taper-leaves-base.json:b94",
"crashes/2243-step-export-flanged-enclosure.json:b43",
"fillets/2903-original-rim-chamfer.json:b0",
"fillets/2903-original-rim-round.json:b0",
],
),
// a torus label whose frame is off its form's own first sweep row past the rounding
// `evaluates_as` derives (margin 1.69, tests/issue_3185_label_margins.rs)
(
label(
"torus -> rational_spline",
&["major_radius", "minor_radius"],
),
&["sweeps/4280-circular-pattern-of-sweep-cut-groove-on-hub.json:b46"],
),
// an extruded circle the flat hop left a spline, proved a cylinder
(
label("rational_spline -> cylinder", &["radius"]),
&[
"tessellation/1615-crossed-lofted-tubes.json:b3",
"tessellation/1615-crossed-lofted-tubes.json:b6",
],
),
// a line end the export wrote as 3.1e-16 (a cos 90 deg residue) where the kernel's
// line, point plus its vector, holds 0: an absolute 3e-16 on a coordinate of size 0
(
set(&[".edges[*].curve.end[*]"]),
&["assemblies/cov-contact-check-touching-faces-do-not-overlap.json:b725"],
),
];
let mut out = BTreeMap::new();
for (classes, bodies) in groups {
for b in bodies {
out.insert((*b).to_owned(), classes.clone());
}
}
// a sheet face used on its reverse side: the kernel keeps both uses of a sheet's face and
// the record comes back with the `same` one, as the flat export's rule says; every
// difference is the renumbering that follows (checked: the same vertex points, edge curves
// and face surfaces, as multisets), and two of them also lower `.tolerance`
let renumbered = [
".edges[*].uses[*].position",
".faces[*].loops[*].edges[*].edge",
".shells[*].faces[*].forward",
".vertices[*].point[*]",
];
let match_sheet = [
".edges[*].curve.knots: length",
".edges[*].curve.points: length",
".edges[*].uses[*].upper",
];
let reverse = [
".edges[*].curve.end[*]",
".edges[*].curve.start[*]",
".edges[*].vertices[*]",
];
let with = |extra: &[&[&str]]| {
let mut c = set(&renumbered);
for e in extra {
c.extend(set(e));
}
c
};
for (b, classes) in [
(
"surfaces/cov-match-coplanar-sheet-onto-the-near-edge.json:b1",
with(&[&match_sheet]),
),
(
"surfaces/cov-match-raised-sheet-down-onto-the-edge-g1.json:b1",
with(&[&match_sheet, &[".tolerance"]]),
),
(
"surfaces/cov-match-raised-sheet-down-onto-the-edge-g3.json:b1",
with(&[&match_sheet, &[".tolerance"]]),
),
(
"surfaces/cov-reverse-normal-l-sheet-swaps-the-thicken-side.json:b2",
with(&[&reverse, &[".edges[*].uses: length"]]),
),
(
"surfaces/cov-reverse-normal-then-thicken-goes-down.json:b1",
with(&[&reverse]),
),
] {
out.insert(b.to_owned(), classes);
}
out
}
/// Every `stride`th entry; the report is written to `$PARITY_OUT` when set. No body is refused
/// either way, and every difference is a named one.
fn parity(stride: usize) {
let mut files = Vec::new();
entries(&corpus(), &mut files);
let fixes = fixes();
let mut t = Tally::default();
let with_bodies = files.iter().filter_map(|f| {
let e: Value = serde_json::from_str(&std::fs::read_to_string(f).unwrap()).unwrap();
let bodies = e["bodies"].as_object()?.clone();
(!bodies.is_empty()).then_some((f.clone(), bodies))
});
for (f, bodies) in with_bodies.step_by(stride) {
let gz = f.with_extension("bodies.json.gz");
let mut text = String::new();
flate2::read::GzDecoder::new(std::fs::File::open(&gz).unwrap())
.read_to_string(&mut text)
.unwrap();
let records: Value = serde_json::from_str(&text).unwrap();
let short = f.strip_prefix(corpus()).unwrap_or(&f).display().to_string();
for (id, how) in bodies {
let record: Record = serde_json::from_value(records[&id].clone()).unwrap();
one(
&format!("{short}:{id}"),
&record,
how["trusted"].as_bool().unwrap_or(true),
&fixes,
&mut t,
);
}
}
let mut report = String::new();
let _ = writeln!(
report,
"bodies {} identical {} rounding-only {} (places {}, largest {:e}) differing {} refused in {} refused out {}",
t.bodies,
t.identical,
t.rounding_only,
t.rounding_places,
t.rounding_largest,
t.bodies - t.identical - t.rounding_only - t.refused_in.len() - t.refused_out.len(),
t.refused_in.len(),
t.refused_out.len()
);
for (class, c) in &t.classes {
let _ = writeln!(
report,
"CLASS {class} | bodies {} places {}",
c.bodies, c.places
);
for e in &c.examples {
let _ = writeln!(report, " {e}");
}
}
for n in &t.sheet_side {
let _ = writeln!(report, "SHEET-SIDE {n}");
}
for (n, classes) in &t.per_body {
let list: Vec<&str> = classes.iter().map(String::as_str).collect();
let _ = writeln!(report, "BODY {n} | {}", list.join(" ; "));
}
for w in &t.tolerance_wrong {
let _ = writeln!(report, "TOLERANCE-WRONG {w}");
}
for (n, f, a, b, trusted) in &t.relabelled {
let _ = writeln!(report, "RELABEL {n} face {f}: {a} -> {b} trusted {trusted}");
}
for r in &t.refused_in {
let _ = writeln!(report, "REFUSED-IN {r}");
}
for r in &t.refused_out {
let _ = writeln!(report, "REFUSED-OUT {r}");
}
if let Ok(p) = std::env::var("PARITY_OUT") {
std::fs::write(p, &report).unwrap();
}
eprintln!("{}", report.lines().next().unwrap_or(""));
assert!(t.bodies > 0);
assert!(t.refused_in.is_empty(), "{:#?}", t.refused_in);
assert!(t.refused_out.is_empty(), "{:#?}", t.refused_out);
assert!(t.tolerance_wrong.is_empty(), "{:#?}", t.tolerance_wrong);
assert!(t.sheet_geometry.is_empty(), "{:#?}", t.sheet_geometry);
let pinned = pinned();
let seen: BTreeMap<&String, &BTreeSet<String>> = t.per_body.iter().collect();
for (b, classes) in &seen {
assert_eq!(pinned.get(*b), Some(*classes), "{b}: classes not as pinned");
}
if stride == 1 {
let missing: Vec<&String> = pinned.keys().filter(|b| !seen.contains_key(b)).collect();
assert!(
missing.is_empty(),
"pinned bodies that no longer differ: {missing:#?}"
);
// The corpus grows as cases are added, so the totals aren't pinned: every body that
// differs is pinned above, and the rest must be identical or within rounding.
assert_eq!(
(t.bodies - t.identical - t.rounding_only, t.rounding_only),
(pinned.len(), 36),
"bodies that differ, bodies within rounding"
);
// the largest rounding difference, relative (#3137: 4.885e-16)
assert!(t.rounding_largest <= 4.9e-16, "{:e}", t.rounding_largest);
}
}
/// The whole corpus (`PARITY_STRIDE` takes every nth entry; the run takes seconds in release).
#[test]
fn every_corpus_body_comes_back_as_the_flat_export_wrote_it() {
parity(
std::env::var("PARITY_STRIDE")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(1),
);
}
@@ -0,0 +1,258 @@
//! (#3185) An analytic label is kept on its NURBS form when the kernel's analytic surface,
//! carrying the label's numbers, evaluates as the form within the rounding
//! `AnalyticSupport::build` derives term by term (`surface::implicit`'s `evaluates_as`):
//! [`AnalyticSupport::parametrisation_margin`] is the largest ratio of distance to bound over
//! the form's Greville points, at most 1 where the label stands.
//!
//! - The corpus labels #3137's parity run refused under the old constant `gamma(16 (p + q +
//! 2)) size`, each pinned to its margin as measured under the derived bound: the three tori
//! of b6 and b85 (0.02 to 0.50) stand; b46's torus (1.69) and the four cylinders (b94 1.23,
//! b43 1.40, the 2903 rims 1.52 and 2.03) do not: their frames are off their forms past
//! the rounding at their size. Since #3199 the kernel's recognised sweep frame is the form's
//! own (its end meridians), so a label is measured against the form, not against a fit
//! that shared the label's error.
//! - A torus the kernel makes at the groove's sizes stands on its own form, and the same label
//! turned about its axis by four times what the bound allows is refused.
#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing,
clippy::print_stderr,
clippy::disallowed_methods
)] // test (the turn's cos and sin)
use std::fmt::Write as _;
use std::io::Read as _;
use std::path::{Path, PathBuf};
use serde_json::Value;
use solidcore::blend::fillet_section::station::as_b_spline;
use solidcore::geom::axis2_placement::Axis2Placement;
use solidcore::geom::extent2d::Extent2d;
use solidcore::geom::vector3d::{Point3, Vec3};
use solidcore::kernel::{Kernel, KernelOptions, UpstreamFixes};
use solidcore::surface::from_parameters::from_parameters;
use solidcore::surface::implicit::AnalyticSupport;
use solidcore::surface::surface::{Surface, SurfaceKind};
use solidcore::surface::torus::{Torus, TorusOptions};
use solidcore_record::brep::Brep as Record;
fn corpus() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../solidcore/tests/kernel_cases")
}
/// The corpus's kernel, its fixes on (as the parity run and SolidCraft run it).
fn kernel() -> Kernel {
let v: Value =
serde_json::from_str(&std::fs::read_to_string(corpus().join("fixes.json")).unwrap())
.unwrap();
let mut fixes = UpstreamFixes::default();
for flag in v.as_array().unwrap() {
*fixes.flag_mut(flag.as_str().unwrap()).unwrap() = true;
}
let mut k = Kernel::new(&KernelOptions::default());
k.fixes = fixes;
k
}
fn body(case: &str, id: &str) -> Record {
let gz = corpus().join(format!("{case}.bodies.json.gz"));
let mut text = String::new();
flate2::read::GzDecoder::new(std::fs::File::open(&gz).unwrap())
.read_to_string(&mut text)
.unwrap();
let records: Value = serde_json::from_str(&text).unwrap();
serde_json::from_value(records[id].clone()).unwrap()
}
fn placement(f: &solidcore::eval::Frame) -> Axis2Placement {
let v = |a: [f64; 3]| Vec3::new(a[0], a[1], a[2]);
Axis2Placement::from_stored(
Point3::new(f.origin[0], f.origin[1], f.origin[2]),
v(f.x_axis),
v(f.y_axis),
)
}
/// A corpus face's label as the kernel's support, and its NURBS form.
fn label_and_form(
k: &Kernel,
case: &str,
id: &str,
face: usize,
) -> (
AnalyticSupport,
solidcore::nurbs::b_spline_surface::BSplineSurface,
) {
let r = body(case, id);
let f = &r.faces[face];
let nurbs = solidcore_convert::surface(f.nurbs.as_ref().unwrap()).unwrap();
let (s, _) = from_parameters(k, &nurbs, f.domain).unwrap();
let SurfaceKind::BSpline(form) = s.kind() else {
panic!("{case} {id} {face}: the form is not a B-spline")
};
let support = match solidcore_convert::surface(&f.surface).unwrap() {
solidcore::eval::Surface::Torus {
frame,
major_radius,
minor_radius,
..
} => AnalyticSupport::Torus {
placement: placement(&frame),
major_radius,
minor_radius,
},
solidcore::eval::Surface::Cylinder { frame, radius } => AnalyticSupport::Cylinder {
placement: placement(&frame),
radius,
},
other => panic!("{case} {id} {face}: {other:?}"),
};
(support, form.copy())
}
/// (case, body, face, the margin measured at #3185, whether the label stands)
const CASES: &[(&str, &str, usize, f64, bool)] = &[
(
"sweeps/3897-sweep-ring-along-a-short-arc-joined-to-a-collar",
"b6",
1,
0.0201,
true,
),
(
"sweeps/4280-circular-pattern-of-sweep-cut-groove-on-hub",
"b46",
1,
1.6943,
false,
),
(
"sweeps/4280-circular-pattern-of-sweep-cut-groove-on-hub",
"b85",
15,
0.4995,
true,
),
(
"sweeps/4280-circular-pattern-of-sweep-cut-groove-on-hub",
"b85",
21,
0.4245,
true,
),
(
"booleans/3456-deep-cut-through-drilled-taper-leaves-base",
"b94",
3,
1.2339,
false,
),
(
"crashes/2243-step-export-flanged-enclosure",
"b43",
1,
1.4008,
false,
),
("fillets/2903-original-rim-chamfer", "b0", 1, 1.5243, false),
("fillets/2903-original-rim-chamfer", "b0", 3, 2.0275, false),
("fillets/2903-original-rim-round", "b0", 1, 1.5243, false),
("fillets/2903-original-rim-round", "b0", 3, 2.0275, false),
];
/// Each corpus label's margin, pinned to 1 % of what #3185 measured, on its side of 1, and
/// `build` agreeing with it.
#[test]
fn the_corpus_labels_stand_or_fall_by_the_derived_bound() {
let k = kernel();
let mut report = String::new();
let mut wrong = Vec::new();
for &(case, id, face, pinned, stands) in CASES {
let (support, form) = label_and_form(&k, case, id, face);
let m = support.parametrisation_margin(&k, &form).unwrap().unwrap();
let _ = writeln!(report, "{case} {id} face {face}: margin {m:.4}");
let built = support.build(&k, form).is_ok();
if (m <= 1.0) != stands || built != stands || (m - pinned).abs() > 0.01 * pinned {
wrong.push(format!(
"{case} {id} face {face}: margin {m} (pinned {pinned}, stands {stands}), build {built}"
));
}
}
eprintln!("{report}");
assert!(wrong.is_empty(), "{wrong:#?}");
}
/// A torus at the groove's sizes (#3185's b46: centre (19.375, -33.558, 37.5), R 33.657, r 2,
/// a 43.6 degree sweep of the whole tube), made by the kernel: its own label stands on its
/// form; turned about its axis by four times the turn the bound allows (the margin is linear
/// in a small turn: a turn of `t` reads `m(t)`, so `4 t / m(t)` reads 4), it is refused.
#[test]
fn a_torus_label_turned_past_its_bound_is_refused() {
let k = kernel();
let o = Point3::new(19.374_999_999_999_975, -33.558_484_396_647_01, 37.5);
let x = Vec3::new(
-0.427_099_277_807_218_7,
0.739_757_649_038_078_7,
-0.519_946_946_895_745_4,
);
let y = Vec3::new(
0.259_973_473_447_872_36,
-0.450_287_264_631_874_2,
-0.854_198_555_614_438_5,
);
let domain = Extent2d::from_bounds(0.0, -180.0, 43.602_818_972_703_645, 180.0);
let made = Torus::new(
&k,
o,
x,
y,
domain,
33.657_280_044_590_65,
2.0,
TorusOptions::default(),
)
.unwrap();
let s = Surface::from(made);
let form = as_b_spline(&k, &s).unwrap().unwrap().into_owned();
let own = AnalyticSupport::of(&s).unwrap();
let m0 = own.parametrisation_margin(&k, &form).unwrap().unwrap();
assert!(m0 <= 1.0, "own label: {m0}");
assert!(own.build(&k, form.copy()).is_ok());
let turned = |t: f64| {
let AnalyticSupport::Torus {
placement,
major_radius,
minor_radius,
} = own
else {
panic!()
};
let (c, sn) = (t.cos(), t.sin());
let (px, py) = (placement.x_axis(), placement.y_axis());
AnalyticSupport::Torus {
placement: Axis2Placement::from_stored(
placement.origin(),
px * c + py * sn,
py * c - px * sn,
),
major_radius,
minor_radius,
}
};
let probe = 1e-11;
let m1 = turned(probe)
.parametrisation_margin(&k, &form)
.unwrap()
.unwrap();
assert!(m1 > 1.0, "a turn of {probe} rad reads {m1}");
let four = 4.0 * probe / m1;
let m4 = turned(four)
.parametrisation_margin(&k, &form)
.unwrap()
.unwrap();
eprintln!("own {m0:.4}; turn {probe:e}: {m1:.3}; turn {four:e}: {m4:.3}");
assert!(m4 > 1.0, "a turn of {four:e} rad reads {m4}");
assert!(turned(four).build(&k, form).is_err());
}
@@ -0,0 +1,70 @@
//! [`Order`]'s inverse and its pieces (#3137): what SolidCraft's `history::Order` gave, read off
//! the conversion's order. A body whose solids touch along an edge comes back with one record
//! edge per pair of faces that edge separates: each is a piece of that kernel edge.
#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing
)] // test
use solidcore::boolean::api_brep::boolean_union;
use solidcore::kernel::Kernel;
use solidcore::math::Point3;
use solidcore::primitive::api_primitives::create_box;
use solidcore_convert::brep::to_record;
/// Two unit boxes touching along one edge (the line x = y = 1): one body, its shared edge one
/// kernel edge with four uses, two record edges.
#[test]
fn an_edge_solids_touch_along_is_two_pieces() {
let mut k = Kernel::default();
let a = create_box(&mut k, Point3::ZERO, 1.0, 1.0, 1.0).unwrap();
let c = create_box(&mut k, Point3::new(1.0, 1.0, 0.0), 1.0, 1.0, 1.0).unwrap();
let mut b = boolean_union(&mut k, a, c).unwrap();
let (record, order) = to_record(&mut k, &mut b).unwrap();
let pieces = order.pieces(&record).unwrap();
assert_eq!(pieces.len(), 2, "{pieces:?}");
assert_eq!(pieces[0].kernel, pieces[1].kernel);
assert_ne!(pieces[0].edge, pieces[1].edge);
for p in &pieces {
assert_eq!(p.faces.len(), 2, "{p:?}");
assert_eq!(order.edges[p.edge as usize], p.kernel);
// the faces are those of that record edge's own uses
let mut uses: Vec<u32> = record.edges[p.edge as usize]
.uses
.iter()
.map(|u| u.face)
.collect();
uses.sort_unstable();
uses.dedup();
assert_eq!(p.faces, uses, "{p:?}");
}
// the two pieces bound four different faces, two of each box
let mut all: Vec<u32> = pieces.iter().flat_map(|p| p.faces.clone()).collect();
all.sort_unstable();
all.dedup();
assert_eq!(all.len(), 4);
// the inverses name each entity's first record index
for (i, f) in order.faces.iter().enumerate() {
assert_eq!(order.record_faces()[f] as usize, i);
}
let first = order.record_edges()[&pieces[0].kernel];
assert_eq!(first, pieces[0].edge.min(pieces[1].edge));
for (i, v) in order.vertices.iter().enumerate() {
assert!(order.record_vertices()[v] as usize <= i);
}
}
/// A body with no touching edge has no pieces.
#[test]
fn a_box_has_no_pieces() {
let mut k = Kernel::default();
let mut b = create_box(&mut k, Point3::ZERO, 1.0, 1.0, 1.0).unwrap();
let (record, order) = to_record(&mut k, &mut b).unwrap();
assert!(order.pieces(&record).unwrap().is_empty());
// one record edge per kernel edge here; in general there are at most as many kernel edges
// as record edges (a touching edge is copied)
assert!(order.record_edges().len() <= record.edges.len());
assert_eq!(order.record_edges().len(), record.edges.len());
}
+2 -1
View File
@@ -5,7 +5,8 @@ edition.workspace = true
license.workspace = true
version.workspace = true
publish = false
rust-version.workspace = true
# solidcore-record needs Rust 1.88 (edition 2024 let chains).
rust-version = "1.88"
repository = "https://github.com/tangency-labs/solidcore"
keywords = ["cad", "brep", "nurbs", "modelling"]
categories = ["science"]
+5 -7
View File
@@ -824,13 +824,11 @@ fn overflow_first(
}
// a round mitred to one that goes first goes with it, through every mitre
let place = |e: &EdgeId| edges.iter().position(|p| p == e);
let pairs: Vec<(usize, usize)> = mitred
// two by two (an odd last one is no pair); the chunk size 2 is not 0: cannot panic
.chunks_exact(2)
.filter_map(|pair| match pair {
[a, b] => Some((place(a)?, place(b)?)),
_ => None,
})
// two by two (an odd last one is no pair)
let (mitred_pairs, _) = mitred.as_chunks::<2>();
let pairs: Vec<(usize, usize)> = mitred_pairs
.iter()
.filter_map(|[a, b]| Some((place(a)?, place(b)?)))
.collect();
let mut grew = true;
while grew {
+140
View File
@@ -0,0 +1,140 @@
//! Contact between bodies: whether two bodies meet (share material or touch within a
//! tolerance) and the distance between their boundaries, each decided by the kernel's
//! certified bracket ([`solidcore::query::contact`]). Ops passes the tolerance on as the
//! caller gives it and reads the kernel's verdict; it computes no distance of its own.
use solidcore::geom::certified::Verdict;
use solidcore::kernel::Kernel;
use solidcore::query::contact::{self as c, Contact, Prepared};
use solidcore::topology::brep::p3::brep_copy;
use solidcore::topology::Brep;
use solidcore::Point3;
use crate::body::Body;
use crate::session::{run_on, Answer};
use crate::{Error, Result};
impl Answer for Contact {}
impl Answer for Vec<Contact> {}
/// The bracket of the distance between two bodies' boundaries, at most the width asked.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Gap {
/// No point of either boundary is nearer the other than this.
pub lower: f64,
/// The distance of the witness points, rounded up.
pub upper: f64,
/// A point on each body `upper` apart, when one is known (the first on this body).
pub witness: Option<[[f64; 3]; 2]>,
}
fn array(p: Point3) -> [f64; 3] {
[p.x, p.y, p.z]
}
/// A copy of `body`'s kernel body, the body released at once: the contact test takes its
/// operands mutably (for its classifications' caches) and never edits the caller's, and a
/// body tested against itself is locked only once at a time.
fn copy_of(k: &mut Kernel, body: &Body) -> solidcore::Result<Brep> {
let b = body.lock();
brep_copy(k, &b)
}
/// The verdict as an answer: `Yes` meets, `No` does not, `Undecided` refused with the
/// bracket, which names the tolerance that would decide.
fn decided(contact: &Contact, other: usize) -> Result<bool> {
match contact.verdict {
Verdict::Yes => Ok(true),
Verdict::No => Ok(false),
Verdict::Undecided => Err(Error::ContactUndecided {
other,
lower: contact.lower,
upper: contact.upper,
}),
}
}
fn poisoned(bodies: &[&Body]) -> Result<()> {
if bodies.iter().any(|b| b.is_poisoned()) {
return Err(Error::Internal(
"contact: a body was left unfinished by an earlier panic".to_owned(),
));
}
Ok(())
}
impl Body {
/// Whether this body and `other` meet at `tol` (the caller's: ops invents none): share
/// material, or have boundaries within `tol` of each other
/// ([`solidcore::query::contact::meets`]). Neither body changes; `other` may be this body.
///
/// # Errors
/// [`Error::ContactUndecided`] when the kernel's bracket does not decide it (`other` 0):
/// the bracket, never read as either answer; [`Error::Status`] `SM_ERR_INVALID_INPUT` for a
/// tolerance not finite and positive or a body without faces; those of the session.
pub fn meets(&self, other: &Body, tol: f64) -> Result<bool> {
poisoned(&[self, other])?;
let contact = run_on("contact::meets", &[("a", self), ("b", other)], |k| {
let mut a = copy_of(k, self)?;
let mut b = copy_of(k, other)?;
c::meets(k, &mut a, &mut b, tol)
})?;
decided(&contact, 0)
}
/// [`Body::meets`] against each of `others`, each with its own tolerance, this body
/// prepared once (its faces' pieces about the centre of its box,
/// [`Prepared::about`]).
///
/// # Errors
/// [`Error::ContactUndecided`] for the first of `others` the kernel's bracket does not
/// decide, by its index; those of [`Body::meets`].
pub fn meets_each(&self, others: &[(&Body, f64)]) -> Result<Vec<bool>> {
let mut bodies: Vec<&Body> = vec![self];
bodies.extend(others.iter().map(|(b, _)| *b));
poisoned(&bodies)?;
let inputs: Vec<(&'static str, &Body)> = bodies.iter().map(|b| ("brep", *b)).collect();
let contacts = run_on("contact::meets_each", &inputs, |k| {
let mut a = copy_of(k, self)?;
let prepared = Prepared::about(k, &a)?;
let mut contacts = Vec::new();
for (other, tol) in others {
let mut b = copy_of(k, other)?;
contacts.push(c::meets_prepared(k, &mut a, &prepared, &mut b, *tol)?);
}
Ok(contacts)
})?;
contacts
.iter()
.enumerate()
.map(|(i, contact)| decided(contact, i))
.collect()
}
/// The distance between this body's boundary and `other`'s, bracketed to at most `width`
/// (relative to its upper end, absolute below 1;
/// [`solidcore::query::contact::boundary_distance`]).
///
/// # Errors
/// [`Error::ContactUndecided`] (`other` 0) with the narrowest bracket reached, when the
/// kernel's work runs out before `width`; [`Error::Status`] `SM_ERR_INVALID_INPUT` for a
/// width not finite and positive or a body without faces; those of the session.
pub fn boundary_distance(&self, other: &Body, width: f64) -> Result<Gap> {
poisoned(&[self, other])?;
let contact = run_on(
"contact::boundary_distance",
&[("a", self), ("b", other)],
|k| {
let mut a = copy_of(k, self)?;
let mut b = copy_of(k, other)?;
c::boundary_distance(k, &mut a, &mut b, width)
},
)?;
decided(&contact, 0)?;
Ok(Gap {
lower: contact.lower,
upper: contact.upper,
witness: contact.witness.map(|[p, q]| [array(p), array(q)]),
})
}
}
+21
View File
@@ -134,6 +134,19 @@ pub enum Error {
/// The kernel found its own state inconsistent part way through a call, or was called
/// before the preparation it needs.
Exception,
/// A contact test the kernel's certified bracket of the boundary distance does not decide
/// (`solidcore::query::contact`): the tolerance lies inside `[lower, upper]` (or the
/// boundaries are apart but one body's enclosure of the other is not decided), or a
/// distance's bracket is still wider than asked. Never read as either answer. `other` is
/// the index of the body tested among the call's others.
ContactUndecided {
/// The other body's index among the call's (0 for one).
other: usize,
/// No point of either boundary is nearer the other than this.
lower: f64,
/// A pair of points, one on each boundary, is this far apart.
upper: f64,
},
/// The application cancelled the call.
Cancelled,
/// Ops was called from inside a kernel call or from the failure callback.
@@ -325,6 +338,14 @@ impl std::fmt::Display for Error {
),
Error::TooFar { largest } => write!(f, "too far to extend (largest {largest:?})"),
Error::Exception => write!(f, "solidcore raised an internal exception"),
Error::ContactUndecided {
other,
lower,
upper,
} => write!(
f,
"contact with body {other} is undecided: the boundary distance is in [{lower}, {upper}]"
),
Error::Cancelled => write!(f, "cancelled"),
Error::Reentered => write!(
f,
+9 -2
View File
@@ -19,8 +19,8 @@
//! - [`provenance`]: the hook an application's construction history rides on.
//!
//! The families: [`measure`] (mass properties, area, box, point class, gap samples, the picks'
//! distances and normals), [`boolean`], [`stitch`], [`prism`] (planar region booleans and
//! extrusion, prism sections).
//! distances and normals), [`contact`] (whether bodies meet, their boundary distance),
//! [`boolean`], [`stitch`], [`prism`] (planar region booleans and extrusion, prism sections).
//!
//! Operations change the body they are given, as the kernel does. An application that keeps
//! its input copies it first ([`Body::copy`]).
@@ -32,6 +32,7 @@ pub mod blend;
pub mod body;
pub mod boolean;
pub mod check;
pub mod contact;
pub mod error;
pub mod extend;
pub mod file;
@@ -42,8 +43,10 @@ pub mod prism;
pub mod provenance;
pub mod session;
pub mod split;
pub mod step_assembly;
pub mod stitch;
pub mod sweep;
pub mod tangent;
pub mod untrim;
pub use blend::{
@@ -54,6 +57,7 @@ pub use blend::{
pub use body::{make_box, make_cone, make_cylinder, make_sphere, make_torus, Body};
pub use boolean::BooleanOp;
pub use check::{CheckLevel, CheckReport};
pub use contact::Gap;
pub use error::{Error, FitKind, Result, RoomReason, Unblendable};
pub use measure::{
point_distance, Distance, EdgePoint, FaceCentroid, GapSample, MassPropertiesEstimate, Normal,
@@ -61,7 +65,10 @@ pub use measure::{
};
pub use offset::{draft, offset_faces, offset_sheet, shell, thicken};
pub use order::Order;
pub use step_assembly::{placed_bodies, PlacedBody};
#[cfg(test)]
mod noisy_round_tests;
#[cfg(test)]
mod test_corpus;
#[cfg(test)]
+199
View File
@@ -0,0 +1,199 @@
//! #3066: a round on supports read from a record with rounding in their frames keeps its exact
//! definition. The class is decided within the body's tolerance; the round lies on a torus of
//! its own, its net certified there at bound 0, and that torus's distance from the class's
//! torus from the supports is held to the body's tolerance, so the round answers
//! `procedural_blend()`.
#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing,
clippy::disallowed_methods
)] // test
use std::f64::consts::PI;
use solidcore::surface::surface::SurfaceKind;
use solidcore::topology::face::p2 as face;
use solidcore_record::brep::Brep as Record;
use crate::blend::{blend, Blend, Corners};
use crate::boolean::boolean;
use crate::{make_box, make_cylinder, session, Body, BooleanOp, CheckLevel};
fn fixes() {
let fixes: Vec<String> = serde_json::from_str(include_str!(
"../../solidcore/tests/kernel_cases/fixes.json"
))
.unwrap();
session::set_fixes(fixes.iter().map(String::as_str)).unwrap();
}
/// The faces of `b` that are no plane, as (with a definition, without one).
fn definitions(b: &Body) -> (usize, usize) {
let brep = b.lock();
let (mut with, mut without) = (0, 0);
for &f in brep.face_list() {
let s = face::surface(&brep, f).unwrap().unwrap();
if matches!(s.kind(), SurfaceKind::Plane(_)) {
continue;
}
if s.procedural_blend().is_some() {
with += 1;
} else {
without += 1;
}
}
(with, without)
}
/// The edge of `b` through `p`.
fn edge_through(b: &Body, p: [f64; 3]) -> usize {
let k = solidcore::Kernel::default();
let brep = b.lock();
let q = solidcore::Vec3::new(p[0], p[1], p[2]);
brep.edge_list()
.iter()
.position(|&e| {
solidcore::query::api_queries::edge_closest_point(&k, &brep, e, q)
.unwrap()
.closest
.distance
< 1e-6
})
.unwrap()
}
/// A boss R 8 standing 10 out of a 40 x 40 x 10 block, its axis turned 1e-10 off square to the
/// block's top, its root rounded 2: a circle-class round on supports only that close to the
/// class (the error the 1e-10 turn makes over the part is far inside the body's tolerance, and
/// far outside the rounding the net is certified to). The round is concave: the corner between
/// the wall and the top, less the quarter disc, turned about the axis. By Pappus, it adds
/// 2 pi (R + x) r^2 (1 - pi / 4), x = r (10 - 3 pi) / (12 - 3 pi) the corner's centroid from the
/// wall.
#[test]
fn a_round_on_a_boss_tilted_off_the_class_keeps_its_definition() {
fixes();
let block = make_box([0.0; 3], [40.0, 40.0, 10.0]).unwrap();
// the boss from z = 5 to 20, its axis turned 1e-10 about x off square to the block's top
let boss = make_cylinder([0.0; 3], 8.0, 15.0).unwrap();
let (c, s) = (1e-10_f64.cos(), 1e-10_f64.sin());
boss.transform(&[
[1.0, 0.0, 0.0, 0.0],
[0.0, c, s, 0.0],
[0.0, -s, c, 0.0],
[20.0, 20.0, 5.0, 1.0],
])
.unwrap();
let b = boolean(block, boss, BooleanOp::Union).unwrap().unwrap();
let e = edge_through(&b, [28.0, 20.0, 10.0]);
let (r, big_r) = (2.0, 8.0);
blend(&b, Blend::Round, &[e], &[r], &[], Corners::Filled).unwrap();
let report = b.check(CheckLevel::All).unwrap();
assert!(report.is_valid(), "{}", report.text);
// the boss's wall and the round: the round keeps its definition
assert_eq!(definitions(&b), (1, 1));
let x = r * (10.0 - 3.0 * PI) / (12.0 - 3.0 * PI);
let want =
16000.0 + PI * big_r * big_r * 10.0 + 2.0 * PI * (big_r + x) * r * r * (1.0 - PI / 4.0);
let got = b.volume(1e-10).unwrap();
assert!((got - want).abs() <= 1e-9 * want, "{got} {want}");
}
/// SolidCraft's raked wedge (`tests/data/raked_wedge_noisy_cylinders.json`): its end outline's
/// rounds keep their definitions, the two circle-class rounds on the read-back cylinders among
/// them; the operand's own faces, read from the record, have none.
#[test]
fn the_raked_wedges_rounds_keep_their_definitions() {
fixes();
let record: Record = serde_json::from_str(include_str!(
"../tests/data/raked_wedge_noisy_cylinders.json"
))
.unwrap();
let (b, _) = Body::from_record(&record, true).unwrap();
let (_, before) = definitions(&b);
let top = 60.0 - 60.0 * 10f64.to_radians().tan();
let mut seeds = Vec::new();
for x in [-69.0, 69.0] {
for p in [
[x, -41.0, 22.0],
[x, 8.0, 41.0],
[x, (38.0 + top) / 2.0, 60.0],
] {
seeds.push(edge_through(&b, p));
}
}
let chain = crate::blend::tangent_chain(&b, &seeds, 0.5_f64.to_radians()).unwrap();
blend(&b, Blend::Round, &chain, &[0.854], &[], Corners::Filled).unwrap();
let report = b.check(CheckLevel::All).unwrap();
assert!(report.is_valid(), "{}", report.text);
let (with, without) = definitions(&b);
// as many of the new rounds keep their definitions as on the same wedge with exact
// cylinders, and the rest are as bare there
let (exact_with, exact_bare) = exact_wedge_rounds();
assert_eq!((with, without - before), (exact_with, exact_bare));
assert_eq!(with + without - before, chain.len());
}
/// The same wedge built here, its cylinders exact, its end outline rounded as SolidCraft's: of the
/// rounds that last call makes, how many keep a definition and how many don't.
fn exact_wedge_rounds() -> (usize, usize) {
use crate::sweep::{extrude, Curve, DraftCorner};
let top = 60.0 - 60.0 * 10f64.to_radians().tan();
let profile = [
[-60.0, 0.0],
[60.0, 0.0],
[top, 60.0],
[38.0, 60.0],
[-22.0, 22.0],
[-60.0, 22.0],
];
let at = |p: [f64; 2]| [-69.0, p[0], p[1]];
let curves: Vec<Curve> = (0..6)
.map(|i| Curve::Line {
start: at(profile[i]),
end: at(profile[(i + 1) % 6]),
})
.collect();
let b = extrude(
&curves,
[1.0, 0.0, 0.0],
138.0,
0.0,
DraftCorner::Curvature,
true,
)
.unwrap();
let round = |pts: &[[f64; 3]], r: f64, chain: bool| {
let seeds: Vec<usize> = pts.iter().map(|&p| edge_through(&b, p)).collect();
let edges = if chain {
crate::blend::tangent_chain(&b, &seeds, 0.5_f64.to_radians()).unwrap()
} else {
seeds
};
blend(&b, Blend::Round, &edges, &[r], &[], Corners::Filled).unwrap();
};
round(&[[0.0, -60.0, 22.0]], 1.2, true);
round(&[[0.0, -22.0, 22.0]], 1.5, true);
round(&[[0.0, 38.0, 60.0]], 1.5, true);
let back = (60.0 + top) / 2.0;
let four = [
[-69.0, -60.0, 12.0],
[69.0, -60.0, 12.0],
[-69.0, back, 30.0],
[69.0, back, 30.0],
];
round(&four, 4.0, false);
let (with_before, bare_before) = definitions(&b);
let mut ends = Vec::new();
for x in [-69.0, 69.0] {
ends.extend([
[x, -41.0, 22.0],
[x, 8.0, 41.0],
[x, (38.0 + top) / 2.0, 60.0],
]);
}
round(&ends, 0.854, true);
let (with, bare) = definitions(&b);
(with - with_before, bare - bare_before)
}
+467
View File
@@ -0,0 +1,467 @@
//! Compose a STEP product structure into placed body instances.
//!
//! The record reader keeps the placements exactly as the file writes them: assembly
//! occurrences, `MAPPED_ITEM` targets, representation maps and transformation operators.
//! This module turns those raw placements into kernel matrices, walks the product tree and
//! returns every body with the transform that places it in the file's world frame.
//!
//! It computes no geometry of its own: frame and operator matrices, their products and
//! inverses come from `solidcore::geom::step_matrix`. Body construction is the caller's:
//! each returned [`PlacedBody::entity`] is a STEP entity id and every occurrence of a shared
//! body is emitted separately with its own transform.
use std::collections::BTreeSet;
use solidcore::geom::step_matrix::{
concat, frame_matrix, inverse, operator_matrix, Matrix4, IDENTITY,
};
use solidcore_record::step::geom::Placement;
use solidcore_record::step::product::{
Frame, ItemKind, Link, Occurrence, ProductStructure, Transformation, MAX_ASSEMBLY_DEPTH,
MAX_VISITS,
};
/// One body as it appears in the expanded product structure.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PlacedBody {
/// The STEP entity id of the body (`MANIFOLD_SOLID_BREP`, …).
pub entity: u64,
/// The body's placement in the file's world frame, in millimetres.
pub transform: Matrix4,
}
struct Walk<'a> {
structure: &'a ProductStructure,
/// Representations reachable from each definition's own shapes through plain
/// representation relationships.
closure: Vec<Vec<usize>>,
/// Child occurrences of each definition, in file order.
children: Vec<Vec<usize>>,
/// The definitions on the current path, to refuse a cycle.
path: Vec<usize>,
visits: usize,
out: Vec<PlacedBody>,
}
/// Expand the product structure and return every body with its world placement.
///
/// A root definition is placed by the identity. Each occurrence multiplies the child's
/// placement by the occurrence transformation, and each mapped item places the mapped
/// representation by `target · origin⁻¹`. Refuses cycles and past the record's depth or
/// instance bounds.
pub fn placed_bodies(structure: &ProductStructure) -> Result<Vec<PlacedBody>, String> {
let mut children = vec![Vec::new(); structure.definitions.len()];
for (index, occurrence) in structure.occurrences.iter().enumerate() {
if let Some(list) = children.get_mut(occurrence.parent) {
list.push(index);
}
}
let closure = (0..structure.definitions.len())
.map(|definition| representation_closure(structure, definition))
.collect();
let mut walk = Walk {
structure,
closure,
children,
path: Vec::new(),
visits: 0,
out: Vec::new(),
};
for &root in &structure.roots {
walk.definition(root, IDENTITY, 0)?;
}
Ok(walk.out)
}
fn representation_closure(structure: &ProductStructure, definition: usize) -> Vec<usize> {
let Some(definition) = structure.definitions.get(definition) else {
return Vec::new();
};
let mut seen = BTreeSet::new();
let mut out = Vec::new();
let mut stack = definition.shapes.clone();
while let Some(representation) = stack.pop() {
if !seen.insert(representation) {
continue;
}
out.push(representation);
for link in &structure.links {
if link.transformation.is_some() {
continue;
}
if link.rep_1 == representation {
stack.push(link.rep_2);
} else if link.rep_2 == representation {
stack.push(link.rep_1);
}
}
}
out
}
impl Walk<'_> {
fn definition(
&mut self,
definition: usize,
world: Matrix4,
depth: usize,
) -> Result<(), String> {
if depth > MAX_ASSEMBLY_DEPTH {
return Err("assembly nested too deeply".into());
}
if self.path.contains(&definition) {
return Err("assembly cycle".into());
}
self.visits += 1;
if self.visits > MAX_VISITS {
return Err(format!("more than {MAX_VISITS} assembly instances"));
}
self.path.push(definition);
let reps = self.closure.get(definition).cloned().unwrap_or_default();
let mut walked = BTreeSet::new();
for representation in reps {
self.representation(representation, world, &mut walked, depth)?;
}
let child_definitions: Vec<usize> =
self.children.get(definition).cloned().unwrap_or_default();
for occurrence_index in child_definitions {
let Some(occurrence) = self.structure.occurrences.get(occurrence_index) else {
continue;
};
let child_reps = self
.closure
.get(occurrence.child)
.cloned()
.unwrap_or_default();
let local = occurrence_matrix(self.structure, occurrence, &child_reps)?;
self.definition(occurrence.child, concat(&world, &local), depth + 1)?;
}
self.path.pop();
Ok(())
}
fn representation(
&mut self,
representation: usize,
world: Matrix4,
walked: &mut BTreeSet<usize>,
depth: usize,
) -> Result<(), String> {
if depth > MAX_ASSEMBLY_DEPTH {
return Err("mapped items nested too deeply".into());
}
if !walked.insert(representation) {
return Ok(());
}
let Some(rep) = self.structure.representations.get(representation) else {
return Err(format!("missing representation #{representation}"));
};
for item in &rep.items {
match &item.kind {
ItemKind::Body(_) => self.out.push(PlacedBody {
entity: item.entity,
transform: world,
}),
ItemKind::Mapped { map, target } => {
let Some(map) = self.structure.maps.get(*map) else {
return Err(format!("missing representation map #{}", item.entity));
};
let target = frame(target)?;
let origin = frame(&map.origin)?;
let origin_inverse = inverse(&origin)
.ok_or_else(|| format!("singular map origin #{}", map.entity))?;
let local = concat(&target, &origin_inverse);
let placed = concat(&world, &local);
let mapped = map.representation;
let mut mapped_walked = BTreeSet::new();
self.representation(mapped, placed, &mut mapped_walked, depth + 1)?;
for link in &self.structure.links {
if link.transformation.is_some() {
continue;
}
let other = if link.rep_1 == mapped {
Some(link.rep_2)
} else if link.rep_2 == mapped {
Some(link.rep_1)
} else {
None
};
if let Some(other) = other {
self.representation(other, placed, &mut mapped_walked, depth + 1)?;
}
}
}
ItemKind::Other(_) => {}
}
}
Ok(())
}
}
fn occurrence_matrix(
structure: &ProductStructure,
occurrence: &Occurrence,
child_reps: &[usize],
) -> Result<Matrix4, String> {
let Some(&link_index) = occurrence.placements.first() else {
return Ok(IDENTITY);
};
let Some(link) = structure.links.get(link_index) else {
return Err(format!("missing placement link #{link_index}"));
};
link_matrix(link, child_reps)
}
fn link_matrix(link: &Link, child_reps: &[usize]) -> Result<Matrix4, String> {
let Some(transformation) = &link.transformation else {
return Ok(IDENTITY);
};
match transformation {
Transformation::Frame(frame_value) => frame(frame_value),
Transformation::Items { item_1, item_2, .. } => {
let first = frame(item_1)?;
let second = frame(item_2)?;
let child_is_first =
child_reps.contains(&link.rep_1) || !child_reps.contains(&link.rep_2);
// Row-vector convention: a child in rep_1 is placed by
// `item_1⁻¹ · item_2`; a child in rep_2 by `item_2⁻¹ · item_1`.
let (from, to) = if child_is_first {
(&first, &second)
} else {
(&second, &first)
};
let inverse_from = inverse(from)
.ok_or_else(|| format!("singular occurrence placement #{}", link.entity))?;
Ok(concat(&inverse_from, to))
}
}
}
fn frame(value: &Frame) -> Result<Matrix4, String> {
match value {
Frame::Placement(Placement {
origin,
axis,
ref_direction,
}) => frame_matrix(*origin, *axis, *ref_direction)
.ok_or_else(|| "degenerate placement".to_string()),
Frame::Operator(operator) => operator_matrix(
operator.axis1,
operator.axis2,
operator.axis3,
operator.origin,
operator.scale,
operator.scale2,
operator.scale3,
)
.ok_or_else(|| "degenerate transformation operator".to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use solidcore_record::step::geom::Placement;
use solidcore_record::step::product::{
BodyKind, Definition, Item, OccurrenceKind, Representation,
};
use solidcore_record::step::units::{Context, Units};
fn translation(x: f64, y: f64, z: f64) -> Frame {
Frame::Placement(Placement {
origin: [x, y, z],
axis: None,
ref_direction: None,
})
}
fn body(entity: u64) -> Item {
Item {
entity,
name: String::new(),
kind: ItemKind::Body(BodyKind::ManifoldSolidBrep),
}
}
fn representation(entity: u64, items: Vec<Item>) -> Representation {
Representation {
entity,
kind: "SHAPE_REPRESENTATION".into(),
name: String::new(),
items,
context: None,
units: Context {
units: Units { len: 1.0, ang: 1.0 },
uncertainty: None,
},
}
}
fn definition(entity: u64, shape: usize) -> Definition {
Definition {
entity,
id: format!("def{entity}"),
description: String::new(),
formation: 0,
product: 0,
shapes: vec![shape],
}
}
fn structure() -> ProductStructure {
ProductStructure {
products: Vec::new(),
definitions: vec![definition(1, 0), definition(2, 1)],
representations: vec![
representation(10, Vec::new()),
representation(20, vec![body(200)]),
],
maps: Vec::new(),
links: Vec::new(),
occurrences: Vec::new(),
roots: vec![0],
styles: Vec::new(),
}
}
#[test]
fn a_placed_child_body_is_transformed_by_its_occurrence() {
let mut structure = structure();
structure.representations[0] = representation(10, Vec::new());
structure.occurrences.push(Occurrence {
entity: 30,
kind: OccurrenceKind::NextAssemblyUsage,
id: "occ".into(),
name: String::new(),
description: String::new(),
parent: 0,
child: 1,
reference_designator: None,
placements: vec![0],
});
structure.links.push(Link {
entity: 40,
rep_1: 1,
rep_2: 0,
transformation: Some(Transformation::Items {
entity: 41,
item_1: translation(0.0, 0.0, 0.0),
item_2: translation(2.0, 3.0, 0.0),
}),
});
let placed = placed_bodies(&structure).unwrap();
assert_eq!(placed.len(), 1);
assert_eq!(placed[0].entity, 200);
assert_eq!(
placed[0].transform,
[
[1.0, 0.0, 0.0, 0.0],
[0.0, 1.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
[2.0, 3.0, 0.0, 1.0],
]
);
}
#[test]
fn mapped_items_place_the_map_origin_onto_their_target() {
let mut structure = structure();
structure.representations[0] = representation(10, Vec::new());
structure.representations[0].items.push(Item {
entity: 100,
name: String::new(),
kind: ItemKind::Mapped {
map: 0,
target: translation(5.0, 0.0, 0.0),
},
});
structure
.maps
.push(solidcore_record::step::product::RepresentationMap {
entity: 50,
origin: translation(1.0, 0.0, 0.0),
representation: 1,
});
let placed = placed_bodies(&structure).unwrap();
assert_eq!(placed.len(), 1);
assert_eq!(placed[0].transform[3], [4.0, 0.0, 0.0, 1.0]);
}
#[test]
fn nested_occurrence_placements_compose() {
let mut structure = structure();
structure.definitions.push(definition(3, 2));
structure
.representations
.push(representation(30, vec![body(300)]));
structure.occurrences.extend([
Occurrence {
entity: 31,
kind: OccurrenceKind::NextAssemblyUsage,
id: "a".into(),
name: String::new(),
description: String::new(),
parent: 0,
child: 1,
reference_designator: None,
placements: vec![0],
},
Occurrence {
entity: 32,
kind: OccurrenceKind::NextAssemblyUsage,
id: "b".into(),
name: String::new(),
description: String::new(),
parent: 1,
child: 2,
reference_designator: None,
placements: vec![1],
},
]);
structure.links.extend([
Link {
entity: 40,
rep_1: 1,
rep_2: 0,
transformation: Some(Transformation::Items {
entity: 41,
item_1: translation(0.0, 0.0, 0.0),
item_2: translation(1.0, 0.0, 0.0),
}),
},
Link {
entity: 42,
rep_1: 2,
rep_2: 1,
transformation: Some(Transformation::Items {
entity: 43,
item_1: translation(0.0, 0.0, 0.0),
item_2: translation(0.0, 2.0, 0.0),
}),
},
]);
let placed = placed_bodies(&structure).unwrap();
let grandchild = placed
.iter()
.find(|body| body.entity == 300)
.expect("grandchild body");
assert_eq!(grandchild.transform[3], [1.0, 2.0, 0.0, 1.0]);
}
#[test]
fn a_cycle_is_refused() {
let mut structure = structure();
structure.occurrences.push(Occurrence {
entity: 30,
kind: OccurrenceKind::NextAssemblyUsage,
id: "occ".into(),
name: String::new(),
description: String::new(),
parent: 0,
child: 0,
reference_designator: None,
placements: Vec::new(),
});
assert!(placed_bodies(&structure).is_err());
}
}
+223
View File
@@ -0,0 +1,223 @@
//! Tangent chains on record order: the edges a pick runs on into tangentially, and the faces
//! joined to picked faces across smooth edges. The walks are here; every measure they stop
//! on is the kernel's: whether an edge's faces meet tangentially all along it
//! ([`solidcore::query::tangent_edge`]), how far one edge turns from running straight on into
//! another at a vertex and whether an edge is convex there ([`solidcore::query::edge_turn`]).
//!
//! The caller's `angle` (radians) is the one tolerance: edges meeting at less than it continue
//! one another, and faces meeting at less than it all along an edge make it smooth. Nothing
//! here picks one (SolidCraft passes its shaded view's half a degree).
//!
//! - [`tangent_chain_ordered`]: the picked edges, then every edge that continues one of them
//! within `angle` at a shared vertex, and so on. An edge does not join when it is not
//! between two faces (a one-face edge), when it is smooth (its faces meet tangentially all
//! along it, a seam among them), or when it turns over (convex where the edge it continues
//! is concave, or the reverse).
//! - [`tangent_face_chain_ordered`]: the picked faces, then every face across a smooth edge
//! from one of them, and so on.
//!
//! An edge whose smoothness the kernel leaves undecided (a blend's net within its margin of
//! the angle, [`solidcore::query::tangent_edge::Tangency::Undecided`]) joins nothing and is
//! named in the answer.
use solidcore::query::edge_turn::{convex_at, turn_at};
use solidcore::query::tangent_edge::{tangent_all_along, Tangency};
use solidcore::topology::edge;
use solidcore::{Brep, EdgeId, FaceId, Kernel};
use crate::session::with_body;
use crate::{Body, Error, Order, Result};
/// What [`tangent_chain_ordered`] and [`tangent_face_chain_ordered`] answer, in record
/// indices.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Chain {
/// The picks (each once, in order), then what joined them, in the order it joined.
pub members: Vec<usize>,
/// Edges the walk met whose smoothness the kernel left undecided: they joined nothing.
pub undecided: Vec<usize>,
}
/// A kernel edge's smoothness, decided once.
struct Smooth {
known: Vec<(EdgeId, Option<Tangency>)>,
}
impl Smooth {
fn of(
&mut self,
k: &Kernel,
b: &mut Brep,
e: EdgeId,
angle: f64,
) -> solidcore::Result<Option<Tangency>> {
if let Some((_, t)) = self.known.iter().find(|(f, _)| *f == e) {
return Ok(*t);
}
let t = tangent_all_along(k, b, e, angle)?;
self.known.push((e, t));
Ok(t)
}
}
/// An angle a chain takes: finite, strictly between 0 and a right angle.
fn angle_ok(angle: f64) -> bool {
angle > 0.0 && angle < std::f64::consts::FRAC_PI_2
}
fn index_of<T: PartialEq + Copy>(list: &[T], x: T) -> Option<usize> {
list.iter().position(|&y| y == x)
}
/// The tangent chain of `body`'s edges at record indices `edges` (through `order`, its
/// record's [`Order`]), as the module documentation describes, at the caller's `angle`;
/// `passed_over` names edges a pick passes over, which never join.
///
/// # Errors
/// [`Error::Argument`] for no edges, an angle not finite and strictly between 0 and a right
/// angle, or an index past the order or naming an edge the body does not have; the kernel's
/// refusals (a face with no NURBS form, an edge use with no pcurve, an edge too costly to
/// decide).
pub fn tangent_chain_ordered(
body: &Body,
order: &Order,
edges: &[usize],
passed_over: &[usize],
angle: f64,
) -> Result<Chain> {
if edges.is_empty() || !angle_ok(angle) {
return Err(Error::Argument);
}
let (copy, mapped) = body.copy_with_order(order)?;
with_body("tangent_chain_ordered", &copy, |k, b| {
let list = mapped.edges.clone();
let mut chain: Vec<EdgeId> = Vec::new();
for &i in edges {
match list.get(i) {
Some(&e) if b.edge_list().contains(&e) => {
if !chain.contains(&e) {
chain.push(e);
}
}
_ => return Ok(Err(Error::Argument)),
}
}
let skip: Vec<EdgeId> = passed_over
.iter()
.filter_map(|&i| list.get(i).copied())
.collect();
let mut smooth = Smooth { known: Vec::new() };
let mut undecided: Vec<EdgeId> = Vec::new();
let mut next = 0;
while let Some(&e) = chain.get(next) {
next += 1;
let ends = [edge::start_vertex(b, e)?, edge::end_vertex(b, e)?];
for v in ends.into_iter().flatten() {
for &f in &list {
if chain.contains(&f) || skip.contains(&f) {
continue;
}
let meets = [edge::start_vertex(b, f)?, edge::end_vertex(b, f)?];
if !meets.contains(&Some(v)) {
continue;
}
match smooth.of(k, b, f, angle)? {
None | Some(Tangency::Tangent) => continue,
Some(Tangency::Undecided { .. }) => {
if !undecided.contains(&f) {
undecided.push(f);
}
continue;
}
Some(Tangency::NotTangent) => {}
}
let turns_over = matches!(
(convex_at(k, b, e, v)?, convex_at(k, b, f, v)?),
(Some(a), Some(c)) if a != c
);
if turns_over {
continue;
}
if turn_at(k, b, e, f, v)?.is_some_and(|turn| turn < angle) {
chain.push(f);
}
}
}
}
let indices = |of: &[EdgeId]| -> Option<Vec<usize>> {
of.iter().map(|&e| index_of(&list, e)).collect()
};
Ok(match (indices(&chain), indices(&undecided)) {
(Some(members), Some(undecided)) => Ok(Chain { members, undecided }),
_ => Err(Error::Exception),
})
})?
}
/// The faces of `body` at record indices `faces` (through `order`), then every face across a
/// smooth edge from one of them, and so on (module documentation), at the caller's `angle`.
///
/// # Errors
/// [`Error::Argument`] for no faces, an angle as for [`tangent_chain_ordered`], or an index
/// past the order or naming a face the body does not have; the kernel's refusals as for
/// [`tangent_chain_ordered`].
pub fn tangent_face_chain_ordered(
body: &Body,
order: &Order,
faces: &[usize],
angle: f64,
) -> Result<Chain> {
if faces.is_empty() || !angle_ok(angle) {
return Err(Error::Argument);
}
let (copy, mapped) = body.copy_with_order(order)?;
with_body("tangent_face_chain_ordered", &copy, |k, b| {
let face_list = mapped.faces.clone();
let edge_list = mapped.edges.clone();
let mut chain: Vec<FaceId> = Vec::new();
for &i in faces {
match face_list.get(i) {
Some(&f) if b.face_list().contains(&f) => {
if !chain.contains(&f) {
chain.push(f);
}
}
_ => return Ok(Err(Error::Argument)),
}
}
let mut smooth = Smooth { known: Vec::new() };
let mut undecided: Vec<EdgeId> = Vec::new();
let mut next = 0;
while let Some(&f) = chain.get(next) {
next += 1;
for &e in &edge_list {
let across = solidcore::query::edge::edge_faces(b, e)?;
if !across.contains(&f) {
continue;
}
match smooth.of(k, b, e, angle)? {
Some(Tangency::Tangent) => {}
Some(Tangency::Undecided { .. }) => {
if !undecided.contains(&e) {
undecided.push(e);
}
continue;
}
None | Some(Tangency::NotTangent) => continue,
}
for g in across {
if !chain.contains(&g) {
chain.push(g);
}
}
}
}
let members: Option<Vec<usize>> = chain.iter().map(|&f| index_of(&face_list, f)).collect();
let undecided: Option<Vec<usize>> =
undecided.iter().map(|&e| index_of(&edge_list, e)).collect();
Ok(match (members, undecided) {
(Some(members), Some(undecided)) => Ok(Chain { members, undecided }),
_ => Err(Error::Exception),
})
})?
}
+71 -3
View File
@@ -1,8 +1,12 @@
//! The boolean family on boxes whose answers are known.
#![allow(clippy::unwrap_used)]
//! The boolean family on bodies whose answers are known.
#![allow(clippy::unwrap_used, clippy::indexing_slicing)] // test
use std::io::Read as _;
use serde_json::Value;
use solidcore_ops::boolean::{boolean, imprint, union_all};
use solidcore_ops::{make_box, session, Body, BooleanOp, Error};
use solidcore_ops::{make_box, session, Body, BooleanOp, CheckLevel, Error};
use solidcore_record::brep::Brep as Record;
fn all_fixes() {
session::set_thread_fixes(solidcore::kernel::UpstreamFixes::NAMES.iter().copied()).unwrap();
@@ -76,3 +80,67 @@ fn an_imprint_cuts_the_faces_the_tool_crosses() {
assert_eq!(r.counts().unwrap(), [6, 12, 8]);
assert!(near(volume(&r), 1.0), "{}", volume(&r));
}
/// The kernel rig's case `booleans/<case>`: its entry, and its operand bodies by name, read
/// under the corpus's fix set.
fn corpus_case(case: &str) -> (Value, impl Fn(&str) -> Body) {
let corpus = format!(
"{}/../solidcore/tests/kernel_cases",
env!("CARGO_MANIFEST_DIR")
);
let fixes: Vec<String> =
serde_json::from_str(&std::fs::read_to_string(format!("{corpus}/fixes.json")).unwrap())
.unwrap();
session::set_fixes(fixes.iter().map(String::as_str)).unwrap();
let entry: Value = serde_json::from_str(
&std::fs::read_to_string(format!("{corpus}/booleans/{case}.json")).unwrap(),
)
.unwrap();
let gz = std::fs::File::open(format!("{corpus}/booleans/{case}.bodies.json.gz")).unwrap();
let mut text = String::new();
flate2::read::GzDecoder::new(gz)
.read_to_string(&mut text)
.unwrap();
let records: Value = serde_json::from_str(&text).unwrap();
let trusted = entry["bodies"].clone();
let body = move |name: &str| {
let record: Record = serde_json::from_value(records[name].clone()).unwrap();
Body::from_record(&record, trusted[name]["trusted"].as_bool().unwrap())
.unwrap()
.0
};
(entry, body)
}
/// A rod cut by two one-turn screw sweeps of one channel profile, one of each hand, from its
/// top face: the profile's radial walls meet the rod's cylinder, so each sweep's rail there is
/// a helix on it, and the two rails cross. The crossing is found more than once, a few 1e-12
/// apart in the rail's parameter; the new edge along the second rail took the interval its
/// UV curve's trim had snapped onto a knot of its own, 2.4e-12 before the 3D curve's end
/// (`SmEdge` test 2). Cut in turn and cut by the two sweeps' union, the result is one valid
/// solid of the case's closed form (its `expected.method`).
#[test]
fn a_rod_cut_by_two_counter_wound_channel_sweeps_is_valid() {
let (entry, body) = corpus_case("13462-rod-cut-by-two-counter-wound-channel-coils");
let want = entry["expected"]["volume_mm3"].as_f64().unwrap();
let rel = entry["expected"]["tolerance"]["rel"].as_f64().unwrap();
// b6 the rod, b4 and b5 the right- and left-handed sweeps, b14 the rod cut by b4 as the
// case's run read it back from its record
let in_turn = boolean(body("b14"), body("b5"), BooleanOp::Subtract)
.unwrap()
.unwrap();
let both = union_all(vec![body("b4"), body("b5")]).unwrap();
let at_once = boolean(body("b6"), both, BooleanOp::Subtract)
.unwrap()
.unwrap();
for (how, r) in [("in turn", &in_turn), ("by the union", &at_once)] {
let report = r.check(CheckLevel::All).unwrap();
assert!(report.is_valid(), "cut {how}: {}", report.text);
assert_eq!(r.solid_count().unwrap(), 1, "cut {how}");
let v = volume(r);
assert!(
(v - want).abs() <= rel * want,
"cut {how}: volume {v}, closed form {want}"
);
}
}
+95
View File
@@ -0,0 +1,95 @@
//! Contact between bodies on closed forms: meets, meets against several, the boundary
//! distance, and the refusals.
#![allow(clippy::unwrap_used)]
use solidcore_ops::session;
use solidcore_ops::{make_box, make_sphere, Error};
fn all_fixes() {
session::set_thread_fixes(solidcore::kernel::UpstreamFixes::NAMES.iter().copied()).unwrap();
}
#[test]
fn boxes_sharing_a_face_meet() {
all_fixes();
let a = make_box([0.0, 0.0, 0.0], [1.0, 1.0, 1.0]).unwrap();
let b = make_box([1.0, 0.0, 0.0], [1.0, 1.0, 1.0]).unwrap();
assert!(a.meets(&b, 1e-6).unwrap());
assert!(b.meets(&a, 1e-6).unwrap());
}
#[test]
fn boxes_one_apart_do_not_meet_and_their_distance_is_one() {
all_fixes();
let a = make_box([0.0, 0.0, 0.0], [1.0, 1.0, 1.0]).unwrap();
let b = make_box([2.0, 0.0, 0.0], [1.0, 1.0, 1.0]).unwrap();
assert!(!a.meets(&b, 1e-3).unwrap());
let g = a.boundary_distance(&b, 1e-9).unwrap();
assert!(g.lower <= 1.0 && 1.0 <= g.upper, "{g:?}");
assert!(g.upper - g.lower <= 1e-9 * g.upper.max(1.0), "{g:?}");
let [p, q] = g.witness.unwrap();
assert!(
(p[0] - 1.0).abs() <= 1e-12 && (q[0] - 2.0).abs() <= 1e-12,
"{g:?}"
);
}
#[test]
fn a_body_meets_itself_and_one_it_encloses() {
all_fixes();
let a = make_box([0.0, 0.0, 0.0], [3.0, 3.0, 3.0]).unwrap();
let inner = make_box([1.0, 1.0, 1.0], [1.0, 1.0, 1.0]).unwrap();
assert!(a.meets(&a, 1e-6).unwrap());
assert!(a.meets(&inner, 1e-6).unwrap());
assert!(inner.meets(&a, 1e-6).unwrap());
// neither body changes
assert_eq!(a.counts().unwrap(), [6, 12, 8]);
assert_eq!(inner.counts().unwrap(), [6, 12, 8]);
}
#[test]
fn meets_each_answers_each_with_its_own_tolerance() {
all_fixes();
let a = make_sphere([0.0, 0.0, 0.0], 1.0).unwrap();
let touching = make_box([1.0, -1.0, -1.0], [1.0, 2.0, 2.0]).unwrap();
let near = make_sphere([2.5, 0.0, 0.0], 1.0).unwrap(); // 0.5 apart
let far = make_box([5.0, 0.0, 0.0], [1.0, 1.0, 1.0]).unwrap();
let got = a
.meets_each(&[(&touching, 1e-6), (&near, 0.1), (&near, 0.6), (&far, 1.0)])
.unwrap();
assert_eq!(got, vec![true, false, true, false]);
}
/// The refusal is the kernel's own bracket: a tolerance equal to a gap the kernel brackets
/// to its rounding is inside the bracket, and neither answer is given.
#[test]
fn a_tolerance_inside_the_bracket_is_refused_with_it() {
all_fixes();
let a = make_box([0.0, 0.0, 0.0], [1.0, 1.0, 1.0]).unwrap();
let b = make_box([2.0, 0.0, 0.0], [1.0, 1.0, 1.0]).unwrap();
let far = make_box([5.0, 0.0, 0.0], [1.0, 1.0, 1.0]).unwrap();
match a.meets(&b, 1.0) {
Err(Error::ContactUndecided {
other: 0,
lower,
upper,
}) => assert!(lower <= 1.0 && 1.0 <= upper, "[{lower}, {upper}]"),
r => panic!("{r:?}"),
}
match a.meets_each(&[(&far, 1e-3), (&b, 1.0)]) {
Err(Error::ContactUndecided { other: 1, .. }) => {}
r => panic!("{r:?}"),
}
}
#[test]
fn a_tolerance_or_width_not_finite_and_positive_is_refused() {
all_fixes();
let a = make_box([0.0, 0.0, 0.0], [1.0, 1.0, 1.0]).unwrap();
let b = make_box([2.0, 0.0, 0.0], [1.0, 1.0, 1.0]).unwrap();
let invalid = Error::Status(solidcore::error::SM_ERR_INVALID_INPUT);
for bad in [0.0, -1.0, f64::NAN, f64::INFINITY] {
assert_eq!(a.meets(&b, bad).unwrap_err(), invalid, "{bad}");
assert_eq!(a.boundary_distance(&b, bad).unwrap_err(), invalid, "{bad}");
}
}
+90 -23
View File
@@ -32,7 +32,9 @@
//! - `KERNEL_CASES_TIMEOUT_S`: a case's time limit, 180 s by default; one past it is a hang, and
//! the cases after it are not run (it holds the process kernel);
//! - `KERNEL_CASES_REPORT`: a file to write the per-area table and the lists to;
//! - `KERNEL_CASES_REPORT_ONLY`: set, the test reports without failing.
//! - `KERNEL_CASES_REPORT_ONLY`: set, the test reports without failing;
//! - `KERNEL_CASES_SMB`: a directory to write each run case's bodies to as `<case>.smb`, for
//! its render (`solidcore-diag`): the final bodies, or the inputs of the refusal that ends it.
#![allow(
clippy::unwrap_used,
@@ -231,31 +233,38 @@ fn f64s<const N: usize>(v: &Value) -> Option<[f64; N]> {
(a.len() == N).then_some(out)
}
/// A pick's record indices, optionally bounded by its record order's length.
/// A pick's record indices, optionally bounded by its record order's length. Every pick's shape
/// and range is checked first, so bad data fails wherever it sits; only then is a traced miss
/// reported as unsupported (#2993).
fn picked(op: &str, selection: &Value, length: Option<usize>) -> Result<Vec<usize>, Stop> {
let bad = |what: &str| Stop::Fail(format!("{op}: parameter {what}"));
selection["picked"]
let picks = selection["picked"]
.as_array()
.ok_or_else(|| bad("picked"))?
.iter()
.map(|pick| {
if let Some(miss) = pick.get("miss") {
let why = miss.as_str().ok_or_else(|| bad("miss"))?;
if pick.get("index").is_some() {
return Err(bad("pick must hit or miss"));
}
return Err(Stop::Unsupported(format!("{op}: pick missed: {why}")));
.ok_or_else(|| bad("picked"))?;
let mut indices = Vec::with_capacity(picks.len());
let mut missed = None;
for pick in picks {
if let Some(miss) = pick.get("miss") {
let why = miss.as_str().ok_or_else(|| bad("miss"))?;
if pick.get("index").is_some() {
return Err(bad("pick must hit or miss"));
}
let index = pick["index"]
.as_u64()
.and_then(|i| usize::try_from(i).ok())
.ok_or_else(|| bad("index"))?;
if length.is_some_and(|length| index >= length) {
return Err(bad("pick outside record order"));
}
Ok(index)
})
.collect()
missed.get_or_insert(why);
continue;
}
let index = pick["index"]
.as_u64()
.and_then(|i| usize::try_from(i).ok())
.ok_or_else(|| bad("index"))?;
if length.is_some_and(|length| index >= length) {
return Err(bad("pick outside record order"));
}
indices.push(index);
}
match missed {
Some(why) => Err(Stop::Unsupported(format!("{op}: pick missed: {why}"))),
None => Ok(indices),
}
}
/// Parses typed record picks without reordering them: misses are unsupported; bad data fails.
@@ -1197,6 +1206,21 @@ fn grade_seams(result: &[Slot], expected: &Value) -> Result<(), Stop> {
Ok(())
}
/// With `KERNEL_CASES_SMB` set, writes `bodies` there as `<case>.smb`, `<case>-1.smb`, ...
fn write_smb(e: &Value, bodies: &[Slot]) {
let Some(dir) = std::env::var_os("KERNEL_CASES_SMB") else {
return;
};
let case = e["case"].as_str().unwrap();
for (i, s) in bodies.iter().enumerate() {
let name = match i {
0 => format!("{case}.smb"),
_ => format!("{case}-{i}.smb"),
};
std::fs::write(Path::new(&dir).join(name), s.body.to_smb(true).unwrap()).unwrap();
}
}
/// Runs a case's calls and grades the result against `rig` (or, for a hand-written case
/// without one, `expected`): `Ok(deviation)` for a match.
fn run_case(entry: &Path, e: &Value) -> Result<Option<f64>, Stop> {
@@ -1298,7 +1322,10 @@ fn run_case_with_records(
// A handled refusal is an attempt, not the case's outcome. Keep checking the
// later operations and final geometry; an unmarked refusal still ends the case.
(Err(_), Some(_)) if continued => {}
(Err(_), Some(_)) => return Ok(None),
(Err(_), Some(_)) => {
write_smb(e, &ins);
return Ok(None);
}
(Err(x), None) => return Err(Stop::Fail(format!("call {i} ({op}) refused: {x}"))),
(Ok(_), Some(want)) => {
return Err(Stop::Fail(format!(
@@ -1321,6 +1348,7 @@ fn run_case_with_records(
.ok_or_else(|| Stop::Fail(format!("no final body {n}")))?,
);
}
write_smb(e, &result);
let got = measure(&result).map_err(Stop::Fail)?;
if let Some(seams) = e["expected"].get("seams") {
grade_seams(&result, seams)?;
@@ -1422,7 +1450,16 @@ fn seam_gaps_are_limited_independently_of_stored_tolerances() {
let body = solidcore_ops::make_box([0.0; 3], [10.0; 3]).unwrap();
let (mut record, _) = body.to_record().unwrap();
// every stored tolerance inflated, not the body's alone: the import keeps each edge's and
// vertex's own and takes the body's as the least of them (#3187, as SolidCraft's flat
// import does), so a body tolerance alone no longer reaches the edges the grade reads
record.tolerance = 0.01;
for edge in &mut record.edges {
edge.tolerance = 0.01;
}
for vertex in &mut record.vertices {
vertex.tolerance = 0.01;
}
for face in &mut record.faces {
match &mut face.surface {
Surface::Plane { origin, .. } => {
@@ -3907,6 +3944,36 @@ fn record_picks_preserve_order_and_duplicate_parameter_alignment() {
));
}
/// Bad pick data fails wherever it sits in the list; a traced miss is unsupported only when
/// every other pick is well formed (#2993).
#[test]
fn a_traced_miss_is_unsupported_only_among_well_formed_picks() {
let list = |picked: Value| serde_json::json!({"kind": "edge", "picked": picked});
for bad in [
serde_json::json!([{"miss": "none"}, {"index": 999}]),
serde_json::json!([{"index": 999}, {"miss": "none"}]),
serde_json::json!([{"miss": "none"}, {}]),
serde_json::json!([{"miss": "none"}, {"miss": "none", "index": 0}]),
] {
assert!(
matches!(
record_picks("blend", &list(bad.clone()), "edge", 2),
Err(Stop::Fail(_))
),
"{bad}"
);
}
assert!(matches!(
record_picks(
"blend",
&list(serde_json::json!([{"index": 0}, {"miss": "none"}])),
"edge",
2
),
Err(Stop::Unsupported(_))
));
}
/// Captured split followed by merging its redundant faces, with the original grading.
#[test]
fn split_body_replays_face_plane() {
+235
View File
@@ -0,0 +1,235 @@
//! `tangent::tangent_chain_ordered` and `tangent_face_chain_ordered` (#3141), on record order.
//!
//! A stadium prism: two straight walls and two half-cylinders, 4 high. Its top outline is
//! four edges each running on tangentially into the next, so a pick of one top edge chains
//! all four and nothing else (the walls meet each other smoothly, so the vertical edges
//! between them are smooth, and the bottom outline turns square from the top's). Its walls
//! meet across smooth edges, so a pick of one wall chains the four walls and not the caps. A
//! box's edges and faces all meet square: a pick chains itself alone.
//!
//! Ported from SolidCraft (`crates/kernel/tests/chamfer_curved_holder.rs`,
//! `one_chamfer_pick_follows_the_tangent_chain_at_any_part_size`, its #2672): a plate of side
//! 140, 145 and 250, 10 thick, with a 50 x 10 stadium boss 10 tall on top. One pick on the
//! boss's top straight edge chains the boss's whole top rim, its four edges at z = 20, at
//! every plate size (SolidCraft's walk once stopped at the straight edge past a 200 mm part).
#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing
)] // test helpers
use solidcore_ops::boolean::{boolean, BooleanOp};
use solidcore_ops::sweep::{extrude, Curve, DraftCorner};
use solidcore_ops::tangent::{tangent_chain_ordered, tangent_face_chain_ordered};
use solidcore_ops::{make_box, Body};
use solidcore_record::brep::{Brep as Record, Surface};
use std::f64::consts::{FRAC_PI_2, PI};
const H: f64 = 4.0;
/// SolidCraft's smooth-edge angle, the caller's: half a degree.
const ANGLE: f64 = 0.5 * PI / 180.0;
fn stadium() -> Body {
let profile = [
Curve::Line {
start: [0.0, 0.0, 0.0],
end: [20.0, 0.0, 0.0],
},
Curve::Arc {
center: [20.0, 5.0, 0.0],
axis: [0.0, 0.0, 1.0],
reference: [1.0, 0.0, 0.0],
radius: 5.0,
start: -FRAC_PI_2,
end: FRAC_PI_2,
},
Curve::Line {
start: [20.0, 10.0, 0.0],
end: [0.0, 10.0, 0.0],
},
Curve::Arc {
center: [0.0, 5.0, 0.0],
axis: [0.0, 0.0, 1.0],
reference: [1.0, 0.0, 0.0],
radius: 5.0,
start: FRAC_PI_2,
end: 1.5 * PI,
},
];
extrude(
&profile,
[0.0, 0.0, 1.0],
H,
0.0,
DraftCorner::default(),
true,
)
.unwrap()
}
/// The record index of the edge between the vertices at `a` and `b`.
fn edge(r: &Record, a: [f64; 3], b: [f64; 3]) -> usize {
let near = |i: u32, p: [f64; 3]| {
let q = r.vertices[i as usize].point;
(0..3).all(|j| (q[j] - p[j]).abs() < 1e-9)
};
r.edges
.iter()
.position(|e| {
let [s, t] = e.vertices;
(near(s, a) && near(t, b)) || (near(s, b) && near(t, a))
})
.expect("an edge between the points")
}
/// Whether record edge `i` lies in the top, both its ends at z = H.
fn on_top(r: &Record, i: usize) -> bool {
r.edges[i]
.vertices
.iter()
.all(|&v| (r.vertices[v as usize].point[2] - H).abs() < 1e-9)
}
/// The record index of the planar face whose normal is along `n` (up to sign).
fn plane_along(r: &Record, n: [f64; 3]) -> Vec<usize> {
let cross = |a: [f64; 3], b: [f64; 3]| {
[
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
]
};
let along = |m: [f64; 3]| {
let c = cross(m, n);
c.iter().all(|x| x.abs() < 1e-9) && m.iter().any(|x| x.abs() > 1e-9)
};
(0..r.faces.len())
.filter(|&i| match r.faces[i].surface {
Surface::Plane { u_axis, v_axis, .. } => along(cross(u_axis, v_axis)),
_ => false,
})
.collect()
}
#[test]
fn a_stadium_s_top_outline_is_one_chain() {
let b = stadium();
let (r, order) = b.to_record().unwrap();
let front = edge(&r, [0.0, 0.0, H], [20.0, 0.0, H]);
let chain = tangent_chain_ordered(&b, &order, &[front], &[], ANGLE).unwrap();
assert!(chain.undecided.is_empty(), "{chain:?}");
assert_eq!(chain.members.len(), 4, "{chain:?}");
assert_eq!(chain.members[0], front);
assert!(chain.members.iter().all(|&e| on_top(&r, e)), "{chain:?}");
}
#[test]
fn a_passed_over_edge_stops_the_chain() {
let b = stadium();
let (r, order) = b.to_record().unwrap();
let front = edge(&r, [0.0, 0.0, H], [20.0, 0.0, H]);
let back = edge(&r, [20.0, 10.0, H], [0.0, 10.0, H]);
let chain = tangent_chain_ordered(&b, &order, &[front], &[back], ANGLE).unwrap();
assert_eq!(chain.members.len(), 3, "{chain:?}");
assert!(!chain.members.contains(&back));
}
#[test]
fn a_box_edge_chains_alone() {
let b = make_box([0.0; 3], [10.0, 6.0, H]).unwrap();
let (r, order) = b.to_record().unwrap();
let front = edge(&r, [0.0, 0.0, H], [10.0, 0.0, H]);
let chain = tangent_chain_ordered(&b, &order, &[front], &[], ANGLE).unwrap();
assert_eq!(chain.members, vec![front]);
assert!(chain.undecided.is_empty());
}
#[test]
fn a_stadium_s_walls_are_one_face_chain() {
let b = stadium();
let (r, order) = b.to_record().unwrap();
let walls = plane_along(&r, [0.0, 1.0, 0.0]);
assert_eq!(walls.len(), 2);
let chain = tangent_face_chain_ordered(&b, &order, &walls[..1], ANGLE).unwrap();
assert!(chain.undecided.is_empty(), "{chain:?}");
assert_eq!(chain.members.len(), 4, "{chain:?}");
for &f in &chain.members {
let caps = plane_along(&r, [0.0, 0.0, 1.0]);
assert!(!caps.contains(&f), "a cap joined: {chain:?}");
}
assert!(walls.iter().all(|w| chain.members.contains(w)), "{chain:?}");
}
#[test]
fn a_box_face_chains_alone() {
let b = make_box([0.0; 3], [10.0, 6.0, H]).unwrap();
let (r, order) = b.to_record().unwrap();
let top = plane_along(&r, [0.0, 0.0, 1.0]);
let chain = tangent_face_chain_ordered(&b, &order, &top[..1], ANGLE).unwrap();
assert_eq!(chain.members, vec![top[0]]);
}
/// The plate of side `plate` with the stadium boss on top.
fn plate_with_stadium(plate: f64) -> Body {
let base = make_box([-plate / 2.0, -plate / 2.0, 0.0], [plate, plate, 10.0]).unwrap();
let z = 10.0;
let profile = [
Curve::Line {
start: [-20.0, -5.0, z],
end: [20.0, -5.0, z],
},
Curve::Arc {
center: [20.0, 0.0, z],
axis: [0.0, 0.0, 1.0],
reference: [1.0, 0.0, 0.0],
radius: 5.0,
start: -FRAC_PI_2,
end: FRAC_PI_2,
},
Curve::Line {
start: [20.0, 5.0, z],
end: [-20.0, 5.0, z],
},
Curve::Arc {
center: [-20.0, 0.0, z],
axis: [0.0, 0.0, 1.0],
reference: [1.0, 0.0, 0.0],
radius: 5.0,
start: FRAC_PI_2,
end: 1.5 * PI,
},
];
let boss = extrude(
&profile,
[0.0, 0.0, 1.0],
10.0,
0.0,
DraftCorner::default(),
true,
)
.unwrap();
boolean(base, boss, BooleanOp::Union).unwrap().unwrap()
}
#[test]
fn one_pick_follows_the_boss_rim_at_any_part_size() {
for plate in [140.0, 145.0, 250.0] {
let b = plate_with_stadium(plate);
let (r, order) = b.to_record().unwrap();
let back = edge(&r, [20.0, 5.0, 20.0], [-20.0, 5.0, 20.0]);
let chain = tangent_chain_ordered(&b, &order, &[back], &[], ANGLE).unwrap();
assert!(chain.undecided.is_empty(), "plate {plate}: {chain:?}");
assert_eq!(chain.members.len(), 4, "plate {plate}: {chain:?}");
let at_top = |i: usize| {
r.edges[i]
.vertices
.iter()
.all(|&v| (r.vertices[v as usize].point[2] - 20.0).abs() < 1e-9)
};
assert!(
chain.members.iter().all(|&e| at_top(e)),
"plate {plate}: {chain:?}"
);
}
}
+2 -1
View File
@@ -2,7 +2,8 @@
//! and structural validation ([`brep`]), its history and metadata records, the native body
//! file ([`native`]), and the plain data of vectors ([`Vec2`], [`Vec3`]), sketch planes
//! ([`Plane`]) and planar profiles ([`Seg2`], [`Loop2`], [`Region2`]), and STEP's Part 21
//! exchange structure as parsed from a file ([`step`]).
//! exchange structure as parsed from a file with the units of its representation contexts and
//! its Part 42 curves and surfaces ([`step`]).
//!
//! The record computes no geometric quantity: no square roots, trigonometry, dot or cross
//! products, normalising or solves (clippy `disallowed-methods`, this crate's `clippy.toml`). It
+7 -1
View File
@@ -1,3 +1,9 @@
//! STEP (ISO 10303) as data: the exchange structure a file is read into ([`p21`]). Building
//! STEP (ISO 10303) as data: the exchange structure a file is read into ([`p21`]), the units
//! of its representation contexts ([`units`]), its Part 42 curves and surfaces ([`geom`])
//! as the entity reader ([`read`]) maps them, and its product structure ([`product`]). Building
//! bodies from it is `solidcore-ops`' job (docs/layers.md §2, "Importers are solidcore's").
pub mod geom;
pub mod p21;
pub mod product;
pub mod read;
pub mod units;
+444
View File
@@ -0,0 +1,444 @@
//! STEP geometry (ISO 10303-42) as plain data: the curves and surfaces a file defines, exactly
//! as it defines them, with lengths in millimetres and angles in radians. Nothing is
//! approximated, bounded, normalised or turned into another kind on the way in: a circle stays a
//! circle with its placement, an unbounded cylinder stays unbounded, a B-spline keeps its own
//! knots and form, and a direction keeps the ratios the file writes. Every curve and surface keeps
//! STEP's own parameterisation, so a 2D curve in a surface's parameter space means the same thing
//! here as in the file.
//!
//! Entities the file shares (one surface under many edges' pcurves) are shared here too, through
//! `Arc`, so reading is linear in the file however often an entity is referenced.
//!
//! The record computes no geometric quantity (docs/layers.md §1, §4): a direction is not made a
//! unit vector, a placement's reference direction is not made orthogonal to its axis (nor chosen
//! when the file omits it), its y axis is not formed, a vector's length is not taken, and nothing
//! is evaluated. Those are the kernel's, on its own types, when it builds from this data (#2578,
//! slice 4). The formulas on the variants below say what the data means, in ISO 10303-42's
//! terms; they are not computed here.
//!
//! Origin: SolidCraft's `crates/step/src/geom.rs` at `a5b71ca2` (MIT OR Apache-2.0).
//!
//! Deviations from SolidCraft: [`Placement`] keeps `axis` and `ref_direction` as the file gives
//! them (each optional, neither normalised; was: both unit and orthogonal, a missing reference
//! direction chosen perpendicular to the axis), and [`Axis1::direction`] likewise. A `VECTOR` is
//! a [`Vector`] of the written orientation, magnitude and unit factors (was: a unit direction
//! and the vector's length), in [`Curve::Line`] and [`Surface::Extrusion`]. Removed, to come back
//! as kernel primitives in slice 4: `Placement::y`, `Placement::at` and the vector helpers `add`,
//! `sub`, `scale`, `dot`, `cross`, `norm`. Doc comments on every public item and field.
use std::sync::Arc;
/// A point or vector: `[x, y, z]` (z = 0 in a 2D parameter space).
pub type P3 = [f64; 3];
/// `AXIS2_PLACEMENT_3D`, or `AXIS2_PLACEMENT_2D` (no `axis`): an origin and the directions of
/// its local Z and X axes, as the file writes them.
///
/// ISO 10303-42 makes the local axes from these: Z is `axis` (+Z when absent), X is
/// `ref_direction` made orthogonal to Z (a chosen perpendicular when absent), Y is Z × X. That
/// construction, with its checks (a zero or parallel reference direction), is the kernel's.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Placement {
/// The location, in millimetres (or the parameter space's units).
pub origin: P3,
/// Local Z as the file gives it (not normalised); `None` when the file omits it and for an
/// `AXIS2_PLACEMENT_2D`, which ISO 10303-42 defines as +Z.
pub axis: Option<P3>,
/// Local X as the file gives it (not normalised, not made orthogonal to `axis`); `None` when
/// the file omits it.
pub ref_direction: Option<P3>,
}
/// `AXIS1_PLACEMENT`: an origin and a direction, as the file writes them.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Axis1 {
/// The location, in millimetres.
pub origin: P3,
/// The direction as the file gives it (not normalised); `None` when the file omits it, which
/// ISO 10303-42 defines as +Z.
pub direction: Option<P3>,
}
/// `VECTOR`: an orientation and a magnitude, as the file writes them, and the unit factors of
/// the space they are in.
///
/// In millimetres and radians, component `i` of the vector is
/// `scale[i] · magnitude · orientation[i] / |orientation|`. The record does not form it; the
/// kernel does (slice 4).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Vector {
/// The direction ratios as written (not normalised; never all zero).
pub orientation: P3,
/// The magnitude as written, in file units (positive).
pub magnitude: f64,
/// The unit factor of each component: the length unit in model space, the parameter's unit
/// (length, angle, or 1) in a surface's parameter space.
pub scale: P3,
}
/// A STEP LOGICAL.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Logical {
/// `.T.`
True,
/// `.F.`
False,
/// `.U.`
Unknown,
}
/// `b_spline_curve_form`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CurveForm {
/// `.POLYLINE_FORM.`
PolylineForm,
/// `.CIRCULAR_ARC.`
CircularArc,
/// `.ELLIPTIC_ARC.`
EllipticArc,
/// `.PARABOLIC_ARC.`
ParabolicArc,
/// `.HYPERBOLIC_ARC.`
HyperbolicArc,
/// `.UNSPECIFIED.`, or anything else.
Unspecified,
}
/// `b_spline_surface_form`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SurfaceForm {
/// `.PLANE_SURF.`
PlaneSurf,
/// `.CYLINDRICAL_SURF.`
CylindricalSurf,
/// `.CONICAL_SURF.`
ConicalSurf,
/// `.SPHERICAL_SURF.`
SphericalSurf,
/// `.TOROIDAL_SURF.`
ToroidalSurf,
/// `.SURF_OF_REVOLUTION.`
SurfOfRevolution,
/// `.RULED_SURF.`
RuledSurf,
/// `.GENERALISED_CONE.`
GeneralisedCone,
/// `.QUADRIC_SURF.`
QuadricSurf,
/// `.SURF_OF_LINEAR_EXTRUSION.`
SurfOfLinearExtrusion,
/// `.UNSPECIFIED.`, or anything else.
Unspecified,
}
/// `knot_type`. A B-spline given without knots (`BEZIER_CURVE`, `UNIFORM_CURVE`,
/// `QUASI_UNIFORM_CURVE` and their surfaces) has them laid out on reading, and keeps the
/// matching type here.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum KnotSpec {
/// `.UNIFORM_KNOTS.`
UniformKnots,
/// `.QUASI_UNIFORM_KNOTS.`
QuasiUniformKnots,
/// `.PIECEWISE_BEZIER_KNOTS.`
PiecewiseBezierKnots,
/// `.UNSPECIFIED.`, or anything else.
Unspecified,
}
/// A B-spline curve: distinct knots with their multiplicities, as STEP writes them.
#[derive(Clone, Debug, PartialEq)]
pub struct BSplineCurve {
/// The degree (1 to `read::MAX_DEGREE`).
pub degree: usize,
/// The control points.
pub points: Vec<P3>,
/// Present for a rational curve: one positive weight per point.
pub weights: Option<Vec<f64>>,
/// The distinct knots, increasing.
pub knots: Vec<f64>,
/// Each knot's multiplicity (1 to degree + 1); they add up to points + degree + 1.
pub multiplicities: Vec<usize>,
/// The curve form.
pub form: CurveForm,
/// `closed_curve`.
pub closed: Logical,
/// `self_intersect`.
pub self_intersect: Logical,
/// The knot type.
pub knot_spec: KnotSpec,
}
/// A B-spline surface; `points[i][j]` runs along u with `i`, along v with `j`.
#[derive(Clone, Debug, PartialEq)]
pub struct BSplineSurface {
/// The degree in u.
pub u_degree: usize,
/// The degree in v.
pub v_degree: usize,
/// The control net, rows along u, every row the same length.
pub points: Vec<Vec<P3>>,
/// Present for a rational surface: one positive weight per control point.
pub weights: Option<Vec<Vec<f64>>>,
/// The distinct u knots, increasing.
pub u_knots: Vec<f64>,
/// Each u knot's multiplicity.
pub u_multiplicities: Vec<usize>,
/// The distinct v knots, increasing.
pub v_knots: Vec<f64>,
/// Each v knot's multiplicity.
pub v_multiplicities: Vec<usize>,
/// The surface form.
pub form: SurfaceForm,
/// `u_closed`.
pub u_closed: Logical,
/// `v_closed`.
pub v_closed: Logical,
/// `self_intersect`.
pub self_intersect: Logical,
/// The knot type.
pub knot_spec: KnotSpec,
}
/// One end of a `TRIMMED_CURVE`: a point on the basis curve or its parameter (a trim lists one
/// or both).
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Trim {
/// A `CARTESIAN_POINT`.
Point(P3),
/// A `PARAMETER_VALUE` (radians on a circle or an ellipse).
Parameter(f64),
}
/// `trimming_preference`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TrimPreference {
/// `.CARTESIAN.`
Cartesian,
/// `.PARAMETER.`
Parameter,
/// `.UNSPECIFIED.`, or anything else.
Unspecified,
}
/// `preferred_surface_curve_representation`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PreferredRep {
/// `.CURVE_3D.`, or anything else.
Curve3d,
/// `.PCURVE_S1.`
PcurveS1,
/// `.PCURVE_S2.`
PcurveS2,
}
/// Which `SURFACE_CURVE` subtype.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SurfaceCurveKind {
/// `SURFACE_CURVE`.
Surface,
/// `SEAM_CURVE`.
Seam,
/// `INTERSECTION_CURVE`.
Intersection,
/// `BOUNDED_SURFACE_CURVE`.
BoundedSurface,
}
/// `PCURVE`: a 2D curve in the parameter space of `surface`. Its coordinates are the surface's
/// own parameters (lengths in mm, angles in radians).
#[derive(Clone, Debug, PartialEq)]
pub struct Pcurve {
/// The surface.
pub surface: Arc<Surface>,
/// A 2D curve: every point and placement has z = 0 and no placement has an axis.
pub curve: Arc<Curve>,
}
/// An item of a `SURFACE_CURVE`'s `associated_geometry`.
#[derive(Clone, Debug, PartialEq)]
pub enum Associated {
/// A `PCURVE`.
Pcurve(Pcurve),
/// A surface.
Surface(Arc<Surface>),
}
/// A STEP curve.
#[derive(Clone, Debug, PartialEq)]
pub enum Curve {
/// `LINE`: `origin + t·v`, with `v` the [`Vector`].
Line {
/// `pnt`.
origin: P3,
/// `dir`.
vector: Vector,
},
/// `CIRCLE`. Parameter: the angle from the placement's X axis.
Circle {
/// The placement.
position: Placement,
/// The radius (positive).
radius: f64,
},
/// `ELLIPSE`: `origin + a·cos t·X + b·sin t·Y`.
Ellipse {
/// The placement.
position: Placement,
/// `a`, along X (positive).
semi_axis_1: f64,
/// `b`, along Y (positive).
semi_axis_2: f64,
},
/// `HYPERBOLA`: `origin + a·cosh t·X + b·sinh t·Y`.
Hyperbola {
/// The placement.
position: Placement,
/// `a` (positive).
semi_axis: f64,
/// `b` (positive).
semi_imag_axis: f64,
},
/// `PARABOLA`: `origin + f·t²·X + 2f·t·Y`.
Parabola {
/// The placement.
position: Placement,
/// `f` (not zero).
focal_dist: f64,
},
/// `B_SPLINE_CURVE` and its subtypes.
BSpline(Box<BSplineCurve>),
/// `POLYLINE`: segment `i` (from 0) is the parameters `[i, i + 1]`.
Polyline(Vec<P3>),
/// `TRIMMED_CURVE`.
Trimmed {
/// `basis_curve`.
basis: Arc<Curve>,
/// `trim_1` (one or two items).
trim_1: Vec<Trim>,
/// `trim_2` (one or two items).
trim_2: Vec<Trim>,
/// `sense_agreement`.
sense: bool,
/// `master_representation`.
preference: TrimPreference,
},
/// `SURFACE_CURVE`, `SEAM_CURVE`, `INTERSECTION_CURVE`, `BOUNDED_SURFACE_CURVE`.
SurfaceCurve {
/// Which subtype.
kind: SurfaceCurveKind,
/// `curve_3d`.
curve_3d: Arc<Curve>,
/// `associated_geometry` (at most two items).
associated: Vec<Associated>,
/// `master_representation`.
master: PreferredRep,
},
/// `OFFSET_CURVE_3D`.
OffsetCurve3d {
/// `basis_curve`.
basis: Arc<Curve>,
/// `distance`, in millimetres.
distance: f64,
/// `self_intersect`.
self_intersect: Logical,
/// `ref_direction` as the file gives it (not normalised).
ref_direction: P3,
},
}
/// A STEP surface.
#[derive(Clone, Debug, PartialEq)]
pub enum Surface {
/// `PLANE`: `origin + u·X + v·Y`.
Plane {
/// The placement.
position: Placement,
},
/// `CYLINDRICAL_SURFACE`: `origin + r·(cos u·X + sin u·Y) + v·Z`.
Cylinder {
/// The placement.
position: Placement,
/// `r` (positive).
radius: f64,
},
/// `CONICAL_SURFACE`: `origin + (r + v·tan a)·(cos u·X + sin u·Y) + v·Z`.
Cone {
/// The placement.
position: Placement,
/// `r` (not negative).
radius: f64,
/// `a`, in radians, its size in (0, π/2).
semi_angle: f64,
},
/// `SPHERICAL_SURFACE`: `origin + r·cos v·(cos u·X + sin u·Y) + r·sin v·Z`.
Sphere {
/// The placement.
position: Placement,
/// `r` (positive).
radius: f64,
},
/// `TOROIDAL_SURFACE`: `origin + (R + r·cos v)·(cos u·X + sin u·Y) + r·sin v·Z`.
Torus {
/// The placement.
position: Placement,
/// `R` (may be negative: some writers use that for a spindle torus).
major_radius: f64,
/// `r` (positive).
minor_radius: f64,
},
/// `DEGENERATE_TOROIDAL_SURFACE`: a torus whose minor radius reaches the axis;
/// `select_outer` keeps the outer part.
DegenerateTorus {
/// The placement.
position: Placement,
/// `R`.
major_radius: f64,
/// `r` (positive).
minor_radius: f64,
/// `select_outer`.
select_outer: bool,
},
/// `B_SPLINE_SURFACE` and its subtypes.
BSpline(Box<BSplineSurface>),
/// `SURFACE_OF_REVOLUTION`: the profile at `v` turned by the angle `u` about the axis.
Revolution {
/// `swept_curve`.
profile: Arc<Curve>,
/// `axis_position`.
axis: Axis1,
},
/// `SURFACE_OF_LINEAR_EXTRUSION`: `profile(u) + v·w`, with `w` the [`Vector`].
Extrusion {
/// `swept_curve`.
profile: Arc<Curve>,
/// `extrusion_axis`.
vector: Vector,
},
/// `OFFSET_SURFACE`.
Offset {
/// `basis_surface`.
basis: Arc<Surface>,
/// `distance`, in millimetres.
distance: f64,
/// `self_intersect`.
self_intersect: Logical,
},
/// `RECTANGULAR_TRIMMED_SURFACE`, its bounds in the basis surface's parameter units.
RectangularTrimmed {
/// `basis_surface`.
basis: Arc<Surface>,
/// `u1`.
u1: f64,
/// `u2`.
u2: f64,
/// `v1`.
v1: f64,
/// `v2`.
v2: f64,
/// `usense`.
u_sense: bool,
/// `vsense`.
v_sense: bool,
},
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,713 @@
//! The product structure, one case at a time: a part, an assembly with a shared sub-assembly,
//! mapped items, colours, names with escapes, and what is refused. Lengths are the file's
//! numbers times the context's unit, written as the same products, so every comparison is
//! exact.
//!
//! Origin: the data cases of SolidCraft's `crates/kernel/src/step_in/tests.rs` at `a5b71ca2`
//! (`hand_written_part_in_inches_with_colour`, `two_instance_assembly`,
//! `assembly_cycles_are_bounded`, `exponential_assemblies_are_bounded`), checking the data the
//! import builds from instead of the bodies it builds.
use std::fmt::Write as _;
use super::{
BodyKind, Colour, Definition, Frame, Instance, Item, ItemKind, Link, MAX_ASSEMBLY_DEPTH,
MAX_VISITS, Operator, Product, ProductStructure, Representation, RepresentationMap, Side,
StyledItem, SurfaceStyle, Transformation, read,
};
use crate::step::geom::Placement;
use crate::step::p21::{self, Exchange};
use crate::step::units::{Context, Units};
fn file(data: &str) -> Exchange {
let t = format!(
"ISO-10303-21;\nHEADER;FILE_SCHEMA(('AUTOMOTIVE_DESIGN'));ENDSEC;\nDATA;\n{data}\nENDSEC;\nEND-ISO-10303-21;\n"
);
match p21::parse(&t) {
Ok(ex) if ex.duplicates.is_empty() => ex,
Ok(ex) => panic!("ids defined twice: {:?}\n{t}", ex.duplicates),
Err(e) => panic!("{e}\n{t}"),
}
}
fn structure(data: &str) -> ProductStructure {
read(&file(data)).unwrap_or_else(|e| panic!("{e}"))
}
fn refused(data: &str) -> String {
match read(&file(data)) {
Ok(s) => panic!("read: {s:?}"),
Err(e) => e,
}
}
/// The application contexts and a body: #8, #9, the solid #101 'Cube' (its shell #100 empty:
/// the product structure does not read topology).
const BASE: &str = "#8=PRODUCT_DEFINITION_CONTEXT('part definition',#9,'design');\
#9=APPLICATION_CONTEXT('');#100=CLOSED_SHELL('',());#101=MANIFOLD_SOLID_BREP('Cube',#100);\n";
/// A geometric context `#id` in the length unit `unit` (#id+2), radians (#id+3) and an
/// uncertainty of 1e-6 in that unit (#id+1).
fn context(id: u64, unit: &str) -> String {
format!(
"#{id}=(GEOMETRIC_REPRESENTATION_CONTEXT(3)GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#{}))GLOBAL_UNIT_ASSIGNED_CONTEXT((#{},#{}))REPRESENTATION_CONTEXT('',''));\n#{}=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-6),#{},'','');\n#{}={unit};\n#{}=(NAMED_UNIT(*)PLANE_ANGLE_UNIT()SI_UNIT($,.RADIAN.));\n",
id + 1,
id + 2,
id + 3,
id + 1,
id + 2,
id + 2,
id + 3
)
}
const MM: &str = "(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.))";
const CM: &str = "(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.CENTI.,.METRE.))";
/// Millimetres per centimetre as `units` forms it (prefix times metre).
const CM_MM: f64 = 1e-2 * 1000.0;
/// A product `name` and its definition `#pd`: `PRODUCT` #pd+1, `PRODUCT_CONTEXT` #pd+2, formation
/// #pd+3, `PRODUCT_DEFINITION_SHAPE` #pd+4.
fn product(pd: u64, name: &str) -> String {
format!(
"#{}=PRODUCT('{name}','{name}','',(#{}));\n#{}=PRODUCT_CONTEXT('',#9,'mechanical');\n#{}=PRODUCT_DEFINITION_FORMATION('','',#{});\n#{pd}=PRODUCT_DEFINITION('design','',#{},#8);\n#{}=PRODUCT_DEFINITION_SHAPE('','',#{pd});\n",
pd + 1,
pd + 2,
pd + 2,
pd + 3,
pd + 1,
pd + 3,
pd + 4
)
}
fn mm(len: f64) -> Context {
Context {
units: Units { len, ang: 1.0 },
uncertainty: Some(1.0E-6 * len),
}
}
fn product_of(entity: u64, name: &str) -> Product {
Product {
entity,
id: name.into(),
name: name.into(),
description: String::new(),
}
}
fn placement(origin: [f64; 3], axis: Option<[f64; 3]>, ref_direction: Option<[f64; 3]>) -> Frame {
Frame::Placement(Placement {
origin,
axis,
ref_direction,
})
}
/// A part in inches with a colour: SolidCraft's `hand_written_part_in_inches_with_colour`. The
/// definition's shape is an empty representation linked to the one with the solid.
#[test]
fn a_part() {
let mut t = String::from(BASE);
t += &context(
20,
"(CONVERSION_BASED_UNIT('INCH',#30)LENGTH_UNIT()NAMED_UNIT(*));\n#30=LENGTH_MEASURE_WITH_UNIT(LENGTH_MEASURE(25.4),#31);\n#31=(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.))",
);
t += &product(50, "Widget");
t += "#60=ADVANCED_BREP_SHAPE_REPRESENTATION('brep',(#101,#65),#20);\n#61=SHAPE_DEFINITION_REPRESENTATION(#54,#62);\n#62=SHAPE_REPRESENTATION('',(),#20);\n#63=SHAPE_REPRESENTATION_RELATIONSHIP('','',#62,#60);\n";
t += "#65=AXIS2_PLACEMENT_3D('',#66,$,$);\n#66=CARTESIAN_POINT('',(0.,0.,0.));\n";
t += "#70=STYLED_ITEM('',(#71),#101);\n#71=PRESENTATION_STYLE_ASSIGNMENT((#72));\n#72=SURFACE_STYLE_USAGE(.BOTH.,#73);\n#73=SURFACE_SIDE_STYLE('',(#74));\n#74=SURFACE_STYLE_FILL_AREA(#75);\n#75=FILL_AREA_STYLE('',(#76));\n#76=FILL_AREA_STYLE_COLOUR('',#77);\n#77=COLOUR_RGB('',1.,0.5,0.);\n";
let inch = Context {
units: Units {
len: 25.4 * 1.0,
ang: 1.0,
},
uncertainty: Some(1.0E-6 * (25.4 * 1.0)),
};
assert_eq!(
structure(&t),
ProductStructure {
products: vec![product_of(51, "Widget")],
definitions: vec![Definition {
entity: 50,
id: "design".into(),
description: String::new(),
formation: 53,
product: 0,
shapes: vec![1],
}],
representations: vec![
Representation {
entity: 60,
kind: "ADVANCED_BREP_SHAPE_REPRESENTATION".into(),
name: "brep".into(),
items: vec![
Item {
entity: 101,
name: "Cube".into(),
kind: ItemKind::Body(BodyKind::ManifoldSolidBrep),
},
Item {
entity: 65,
name: String::new(),
kind: ItemKind::Other("AXIS2_PLACEMENT_3D".into()),
},
],
context: Some(20),
units: inch,
},
Representation {
entity: 62,
kind: "SHAPE_REPRESENTATION".into(),
name: String::new(),
items: Vec::new(),
context: Some(20),
units: inch,
},
],
maps: Vec::new(),
links: vec![Link {
entity: 63,
rep_1: 1,
rep_2: 0,
transformation: None,
}],
occurrences: Vec::new(),
roots: vec![0],
styles: vec![StyledItem {
entity: 70,
name: String::new(),
item: 101,
overrides: None,
surface: vec![SurfaceStyle {
side: Side::Both,
fill: Some(Colour::Rgb {
name: String::new(),
rgb: [1.0, 0.5, 0.0],
}),
rendering: None,
transparency: None,
}],
curve: Vec::new(),
}],
}
);
}
/// A solid outside any product: its representation is still read, and nothing else.
#[test]
fn a_loose_solid() {
let mut t = String::from(BASE);
t += &context(20, MM);
t += "#60=MANIFOLD_SURFACE_SHAPE_REPRESENTATION('',(#101),#20);\n";
let s = structure(&t);
assert_eq!(
(s.products.len(), s.definitions.len(), s.roots.len()),
(0, 0, 0)
);
assert_eq!(s.representations.len(), 1);
assert_eq!(s.representations[0].entity, 60);
assert_eq!(s.representations[0].units, mm(1e-3 * 1000.0));
}
/// The occurrence `#n` of `child` in `parent` placed by an `ITEM_DEFINED_TRANSFORMATION` of
/// `item_1` (in `rep_1`) onto `item_2` (in `rep_2`): NAUO #n, its PDS #n+1, the transformation
/// #n+2, the relationship #n+3, the CDSR #n+4.
fn occurrence(
n: u64,
name: &str,
parent: u64,
child: u64,
reps: (u64, u64),
items: (u64, u64),
) -> String {
let mut t = String::new();
let _ = write!(
t,
"#{n}=NEXT_ASSEMBLY_USAGE_OCCURRENCE('{n}','{name}','',#{parent},#{child},$);\n\
#{}=PRODUCT_DEFINITION_SHAPE('','',#{n});\n\
#{}=ITEM_DEFINED_TRANSFORMATION('','',#{},#{});\n\
#{}=(REPRESENTATION_RELATIONSHIP('','',#{},#{})REPRESENTATION_RELATIONSHIP_WITH_TRANSFORMATION(#{})SHAPE_REPRESENTATION_RELATIONSHIP());\n\
#{}=CONTEXT_DEPENDENT_SHAPE_REPRESENTATION(#{},#{});\n",
n + 1,
n + 2,
items.0,
items.1,
n + 3,
reps.0,
reps.1,
n + 2,
n + 4,
n + 3,
n + 1
);
t
}
/// Top uses Sub twice and Part once; Sub uses Part once. Sub's placements are in centimetres,
/// Top's and Part's in millimetres. Four occurrences, six instances; the transformation is the
/// two placements as written, each in its representation's unit.
#[test]
fn an_assembly_with_a_shared_sub_assembly() {
let mut t = String::from(BASE);
t += &context(20, CM);
t += &context(30, MM);
t += &product(50, "Top");
t += &product(70, "Sub");
t += &product(90, "Part");
// Top (mm): its own frame #161 and the places of its three components #163, #165, #167.
t += "#160=SHAPE_REPRESENTATION('Top',(#161,#163,#165,#167),#30);\n#162=SHAPE_DEFINITION_REPRESENTATION(#54,#160);\n";
t += "#161=AXIS2_PLACEMENT_3D('',#1610,$,$);\n#1610=CARTESIAN_POINT('',(0.,0.,0.));\n";
t += "#163=AXIS2_PLACEMENT_3D('',#1630,#1631,#1632);\n#1630=CARTESIAN_POINT('',(100.,0.,0.));\n#1631=DIRECTION('',(0.,0.,1.));\n#1632=DIRECTION('',(0.,2.,0.));\n";
t += "#165=AXIS2_PLACEMENT_3D('',#1650,$,$);\n#1650=CARTESIAN_POINT('',(0.,100.,0.));\n";
t += "#167=AXIS2_PLACEMENT_3D('',#1670,$,$);\n#1670=CARTESIAN_POINT('',(0.,0.,100.));\n";
// Sub (cm): its frame #171 and the place of its part #173.
t += "#170=SHAPE_REPRESENTATION('Sub',(#171,#173),#20);\n#172=SHAPE_DEFINITION_REPRESENTATION(#74,#170);\n";
t += "#171=AXIS2_PLACEMENT_3D('',#1710,$,$);\n#1710=CARTESIAN_POINT('',(1.,2.,3.));\n";
t += "#173=AXIS2_PLACEMENT_3D('',#1730,$,$);\n#1730=CARTESIAN_POINT('',(5.,0.,0.));\n";
// Part (mm): the solid and its frame.
t += "#180=ADVANCED_BREP_SHAPE_REPRESENTATION('Part',(#101,#181),#30);\n#182=SHAPE_DEFINITION_REPRESENTATION(#94,#180);\n";
t += "#181=AXIS2_PLACEMENT_3D('',#1810,$,$);\n#1810=CARTESIAN_POINT('',(0.,0.,0.));\n";
t += &occurrence(600, "Sub:1", 50, 70, (170, 160), (171, 163));
t += &occurrence(610, "Sub:2", 50, 70, (170, 160), (171, 165));
t += &occurrence(620, "Part:1", 70, 90, (180, 170), (181, 173));
t += &occurrence(630, "Part:2", 50, 90, (180, 160), (181, 167));
let s = structure(&t);
assert_eq!(
s.products,
[
product_of(51, "Top"),
product_of(71, "Sub"),
product_of(91, "Part")
]
);
// Representations in id order: #160 Top, #170 Sub, #180 Part.
let shapes: Vec<_> = s
.definitions
.iter()
.map(|d| (d.entity, d.product, d.shapes.clone()))
.collect();
assert_eq!(
shapes,
[(50, 0, vec![0]), (70, 1, vec![1]), (90, 2, vec![2])]
);
assert_eq!(s.roots, [0]);
let occ: Vec<_> = s
.occurrences
.iter()
.map(|o| {
(
o.entity,
o.id.as_str(),
o.name.as_str(),
o.parent,
o.child,
o.placements.clone(),
)
})
.collect();
assert_eq!(
occ,
[
(600, "600", "Sub:1", 0, 1, vec![0]),
(610, "610", "Sub:2", 0, 1, vec![1]),
(620, "620", "Part:1", 1, 2, vec![2]),
(630, "630", "Part:2", 0, 2, vec![3]),
]
);
assert_eq!(s.occurrences[0].reference_designator, None);
// Sub:1: Sub's frame (cm: 1, 2, 3 → 10, 20, 30 mm) onto Top's place for it, as written.
assert_eq!(
s.links[0],
Link {
entity: 603,
rep_1: 1,
rep_2: 0,
transformation: Some(Transformation::Items {
entity: 602,
item_1: placement([1.0 * CM_MM, 2.0 * CM_MM, 3.0 * CM_MM], None, None),
item_2: placement(
[100.0, 0.0, 0.0],
Some([0.0, 0.0, 1.0]),
Some([0.0, 2.0, 0.0])
),
}),
}
);
// Part:1: Part's frame (mm) onto Sub's place for it (cm: 5 → 50 mm).
assert_eq!(
s.links[2].transformation,
Some(Transformation::Items {
entity: 622,
item_1: placement([0.0; 3], None, None),
item_2: placement([5.0 * CM_MM, 0.0, 0.0], None, None),
})
);
// Expanded: Top; Sub:1 with its Part:1; Sub:2 with its Part:1; Part:2.
let inst = s.instances().unwrap();
assert_eq!(
inst,
[
Instance {
definition: 0,
occurrence: None,
parent: None
},
Instance {
definition: 1,
occurrence: Some(0),
parent: Some(0)
},
Instance {
definition: 2,
occurrence: Some(2),
parent: Some(1)
},
Instance {
definition: 1,
occurrence: Some(1),
parent: Some(0)
},
Instance {
definition: 2,
occurrence: Some(2),
parent: Some(3)
},
Instance {
definition: 2,
occurrence: Some(3),
parent: Some(0)
},
]
);
}
/// SolidCraft's `exponential_assemblies_are_bounded`: P0 uses P1 twice, P1 uses P2 twice, … 30
/// levels. Reading is linear (31 definitions, 60 occurrences); expanding stops at
/// [`MAX_VISITS`]. Three levels expand to 1 + 2 + 4 + 8 instances.
#[test]
fn a_sub_assembly_shared_at_every_level_is_read_once() {
let levels = |n: u64| {
let mut t = String::from(BASE);
for l in 0..=n {
t += &product(1000 + l * 10, &format!("P{l}"));
if l < n {
for k in 0..2 {
let _ = writeln!(
t,
"#{}=NEXT_ASSEMBLY_USAGE_OCCURRENCE('','','',#{},#{},$);",
2000 + l * 10 + k,
1000 + l * 10,
1000 + (l + 1) * 10
);
}
}
}
t
};
let s = structure(&levels(30));
assert_eq!(
(s.definitions.len(), s.occurrences.len(), s.roots.clone()),
(31, 60, vec![0])
);
assert_eq!(
s.instances(),
Err(format!("more than {MAX_VISITS} assembly instances"))
);
assert_eq!(structure(&levels(3)).instances().map(|i| i.len()), Ok(15));
}
/// A representation placed twice by `MAPPED_ITEM`s: once by a placement, once by a
/// non-uniform operator. The map's origin is in the mapped representation's unit (inches), each
/// target in the unit of the representation that lists it (millimetres).
#[test]
fn mapped_items() {
let mut t = String::from(BASE);
t += &context(20, MM);
t += &context(
30,
"(CONVERSION_BASED_UNIT('INCH',#40)LENGTH_UNIT()NAMED_UNIT(*));\n#40=LENGTH_MEASURE_WITH_UNIT(LENGTH_MEASURE(25.4),#41);\n#41=(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.))",
);
t += &product(50, "Holder");
t += "#60=SHAPE_REPRESENTATION('',(#110,#115),#20);\n#61=SHAPE_DEFINITION_REPRESENTATION(#54,#60);\n";
t += "#110=MAPPED_ITEM('first',#111,#112);\n#111=REPRESENTATION_MAP(#113,#120);\n";
t += "#112=AXIS2_PLACEMENT_3D('',#1120,#1121,$);\n#1120=CARTESIAN_POINT('',(5.,0.,0.));\n#1121=DIRECTION('',(0.,0.,-1.));\n";
t += "#113=AXIS2_PLACEMENT_3D('',#1130,$,$);\n#1130=CARTESIAN_POINT('',(1.,0.,0.));\n";
t += "#115=MAPPED_ITEM('second',#111,#116);\n";
t += "#116=CARTESIAN_TRANSFORMATION_OPERATOR_3D_NON_UNIFORM('','',#1161,$,#1160,2.,$,$,3.);\n#1160=CARTESIAN_POINT('',(0.,7.,0.));\n#1161=DIRECTION('',(0.,1.,0.));\n";
t += "#120=ADVANCED_BREP_SHAPE_REPRESENTATION('',(#101,#113),#30);\n";
let s = structure(&t);
assert_eq!(
s.representations[0].items,
[
Item {
entity: 110,
name: "first".into(),
kind: ItemKind::Mapped {
map: 0,
target: placement([5.0, 0.0, 0.0], Some([0.0, 0.0, -1.0]), None),
},
},
Item {
entity: 115,
name: "second".into(),
kind: ItemKind::Mapped {
map: 0,
target: Frame::Operator(Operator {
axis1: Some([0.0, 1.0, 0.0]),
axis2: None,
axis3: None,
origin: [0.0, 7.0, 0.0],
scale: Some(2.0),
scale2: None,
scale3: Some(3.0),
}),
},
},
]
);
assert_eq!(
s.maps,
[RepresentationMap {
entity: 111,
origin: placement([1.0 * (25.4 * 1.0), 0.0, 0.0], None, None),
representation: 1,
}]
);
assert_eq!(s.representations[1].entity, 120);
assert_eq!(
s.representations[1].items[0].kind,
ItemKind::Body(BodyKind::ManifoldSolidBrep)
);
}
/// Colours on a body and on a face: a fill colour, a rendering colour with a transparency, a
/// pre-defined colour, one ISO 10303-46 does not name, a curve style's colour, and an override.
#[test]
fn colours_of_bodies_and_faces() {
let mut t = String::from(BASE);
t += "#102=ADVANCED_FACE('',(),#103,.T.);\n#103=PLANE('',$);\n";
// The body: fill colour and the curve colour of its edges.
t += "#70=STYLED_ITEM('body',(#71,#78),#101);\n#71=PRESENTATION_STYLE_ASSIGNMENT((#72));\n#72=SURFACE_STYLE_USAGE(.POSITIVE.,#73);\n#73=SURFACE_SIDE_STYLE('',(#74));\n#74=SURFACE_STYLE_FILL_AREA(#75);\n#75=FILL_AREA_STYLE('',(#76));\n#76=FILL_AREA_STYLE_COLOUR('',#77);\n#77=COLOUR_RGB('steel',0.2,0.25,1.5);\n";
t += "#78=PRESENTATION_STYLE_ASSIGNMENT((#79));\n#79=CURVE_STYLE('',#80,POSITIVE_LENGTH_MEASURE(0.1),#81);\n#80=DRAUGHTING_PRE_DEFINED_CURVE_FONT('continuous');\n#81=DRAUGHTING_PRE_DEFINED_COLOUR('black');\n";
// The face, over the body's style: rendered red, a quarter transparent, filled 'mauve'.
t += "#90=OVER_RIDING_STYLED_ITEM('face',(#91),#102,#70);\n#91=PRESENTATION_STYLE_ASSIGNMENT((#92));\n#92=SURFACE_STYLE_USAGE(.BOTH.,#93);\n#93=SURFACE_SIDE_STYLE('',(#94,#97));\n";
t += "#94=SURFACE_STYLE_RENDERING_WITH_PROPERTIES(.NORMAL_SHADING.,#95,(#96));\n#95=DRAUGHTING_PRE_DEFINED_COLOUR('red');\n#96=SURFACE_STYLE_TRANSPARENT(0.25);\n";
t += "#97=SURFACE_STYLE_FILL_AREA(#98);\n#98=FILL_AREA_STYLE('',(#99));\n#99=FILL_AREA_STYLE_COLOUR('',#89);\n#89=DRAUGHTING_PRE_DEFINED_COLOUR('mauve');\n";
let s = structure(&t);
assert_eq!(
s.styles,
[
StyledItem {
entity: 70,
name: "body".into(),
item: 101,
overrides: None,
surface: vec![SurfaceStyle {
side: Side::Positive,
fill: Some(Colour::Rgb {
name: "steel".into(),
rgb: [0.2, 0.25, 1.5],
}),
rendering: None,
transparency: None,
}],
curve: vec![Colour::PreDefined {
name: "black".into(),
rgb: Some([0.0, 0.0, 0.0]),
}],
},
StyledItem {
entity: 90,
name: "face".into(),
item: 102,
overrides: Some(70),
surface: vec![SurfaceStyle {
side: Side::Both,
fill: Some(Colour::PreDefined {
name: "mauve".into(),
rgb: None,
}),
rendering: Some(Colour::PreDefined {
name: "red".into(),
rgb: Some([1.0, 0.0, 0.0]),
}),
transparency: Some(0.25),
}],
curve: Vec::new(),
},
]
);
}
/// ISO 10303-46's eight pre-defined colours.
#[test]
fn pre_defined_colours() {
let names = [
"red", "green", "blue", "yellow", "magenta", "cyan", "black", "white",
];
let rgb: Vec<_> = names.iter().map(|n| super::pre_defined_rgb(n)).collect();
assert_eq!(
rgb,
[
Some([1.0, 0.0, 0.0]),
Some([0.0, 1.0, 0.0]),
Some([0.0, 0.0, 1.0]),
Some([1.0, 1.0, 0.0]),
Some([1.0, 0.0, 1.0]),
Some([0.0, 1.0, 1.0]),
Some([0.0, 0.0, 0.0]),
Some([1.0, 1.0, 1.0]),
]
);
assert_eq!(super::pre_defined_rgb("Red"), None);
}
/// Names keep their escapes decoded, and are not trimmed.
#[test]
fn names_with_escapes() {
let mut t = String::from(BASE);
t += "#51=PRODUCT('P-\\X2\\00E9\\X0\\',' B\\X\\E4r ''7'' ','\\X2\\5706\\X0\\',(#52));\n#52=PRODUCT_CONTEXT('',#9,'mechanical');\n#53=PRODUCT_DEFINITION_FORMATION('','',#51);\n#50=PRODUCT_DEFINITION('d\\\\1',$,#53,#8);\n";
t += &product(70, "Child");
t += "#600=NEXT_ASSEMBLY_USAGE_OCCURRENCE('NAUO1','Gr\\X\\FC\\X\\DFe:1','',#50,#70,'R\\X2\\00E9\\X0\\f');\n";
let s = structure(&t);
assert_eq!(
s.products[0],
Product {
entity: 51,
id: "P-\u{e9}".into(),
name: " B\u{e4}r '7' ".into(),
description: "\u{5706}".into(),
}
);
assert_eq!(
(
s.definitions[0].id.as_str(),
s.definitions[0].description.as_str()
),
("d\\1", "")
);
let o = &s.occurrences[0];
assert_eq!(
(
o.id.as_str(),
o.name.as_str(),
o.reference_designator.as_deref()
),
("NAUO1", "Gr\u{fc}\u{df}e:1", Some("R\u{e9}f"))
);
}
/// A chain of `n` + 1 definitions, each the only component of the one before.
fn chain(n: u64) -> String {
let mut t = String::from(BASE);
for l in 0..=n {
t += &product(1000 + l * 10, &format!("P{l}"));
if l < n {
let _ = writeln!(
t,
"#{}=NEXT_ASSEMBLY_USAGE_OCCURRENCE('','','',#{},#{},$);",
5000 + l,
1000 + l * 10,
1000 + (l + 1) * 10
);
}
}
t
}
#[test]
fn nesting_is_bounded() {
let depth = MAX_ASSEMBLY_DEPTH as u64;
let s = structure(&chain(depth));
assert_eq!(s.instances().map(|i| i.len()), Ok(MAX_ASSEMBLY_DEPTH + 1));
assert_eq!(refused(&chain(depth + 1)), "assembly nested too deeply");
}
/// What is refused, with its message.
#[test]
fn refusals_say_why() {
let two = || {
let mut t = String::from(BASE);
t += &product(50, "A");
t += &product(70, "B");
t
};
// A contains B contains A (SolidCraft's `assembly_cycles_are_bounded`).
let cycle = two()
+ "#90=NEXT_ASSEMBLY_USAGE_OCCURRENCE('1','','',#50,#70,$);\n#91=NEXT_ASSEMBLY_USAGE_OCCURRENCE('2','','',#70,#50,$);\n";
assert_eq!(refused(&cycle), "assembly cycle");
let itself = two() + "#90=NEXT_ASSEMBLY_USAGE_OCCURRENCE('1','','',#50,#50,$);\n";
assert_eq!(refused(&itself), "assembly cycle");
let missing_child = two() + "#90=NEXT_ASSEMBLY_USAGE_OCCURRENCE('1','','',#50,#999,$);\n";
assert_eq!(
refused(&missing_child),
"occurrence #90: #999: expected a PRODUCT_DEFINITION"
);
let not_a_definition = two() + "#90=NEXT_ASSEMBLY_USAGE_OCCURRENCE('1','','',#50,#51,$);\n";
assert_eq!(
refused(&not_a_definition),
"occurrence #90: #51: expected a PRODUCT_DEFINITION"
);
let missing_product = String::from(BASE)
+ "#53=PRODUCT_DEFINITION_FORMATION('','',#51);\n#50=PRODUCT_DEFINITION('design','',#53,#8);\n";
assert_eq!(refused(&missing_product), "#51: expected a PRODUCT");
let missing_rep = two() + "#61=SHAPE_DEFINITION_REPRESENTATION(#54,#999);\n";
assert_eq!(refused(&missing_rep), "missing entity #999");
let not_a_rep = two() + "#61=SHAPE_DEFINITION_REPRESENTATION(#54,#101);\n";
assert_eq!(
refused(&not_a_rep),
"#101: expected a representation, found MANIFOLD_SOLID_BREP"
);
let missing_item = two()
+ "#60=SHAPE_REPRESENTATION('',(#101,#999),$);\n#61=SHAPE_DEFINITION_REPRESENTATION(#54,#60);\n";
assert_eq!(refused(&missing_item), "missing entity #999");
let mapped = |rest: &str| {
two()
+ "#60=SHAPE_REPRESENTATION('',(#110),$);\n#61=SHAPE_DEFINITION_REPRESENTATION(#54,#60);\n#110=MAPPED_ITEM('',#111,#112);\n#112=AXIS2_PLACEMENT_3D('',#113,$,$);\n#113=CARTESIAN_POINT('',(0.,0.,0.));\n"
+ rest
};
assert_eq!(
refused(&mapped("#111=REPRESENTATION_MAP(#112,#60);\n")),
"mapped item cycle"
);
assert_eq!(
refused(&mapped("#111=SHAPE_REPRESENTATION('',(),$);\n")),
"#110: mapping source #111 is a SHAPE_REPRESENTATION, not a REPRESENTATION_MAP"
);
assert_eq!(
refused(&mapped("#111=REPRESENTATION_MAP(#112,#999);\n")),
"missing entity #999"
);
assert_eq!(
refused(&mapped(
"#111=REPRESENTATION_MAP(#112,#120);\n#120=SHAPE_REPRESENTATION('',(#101),$);\n"
)
.replace(
"#110=MAPPED_ITEM('',#111,#112);",
"#110=MAPPED_ITEM('',#111,#114);#114=CARTESIAN_TRANSFORMATION_OPERATOR_3D('','',$,$,#113,0.,$);"
)),
"mapped item #110: operator scale: expected a number other than zero"
);
assert_eq!(
refused(&mapped(
"#111=REPRESENTATION_MAP(#113,#120);\n#120=SHAPE_REPRESENTATION('',(#101),$);\n"
)),
"representation map #111: #113: expected a placement, found CARTESIAN_POINT"
);
let styled = |rest: &str| two() + rest;
assert_eq!(
refused(&styled("#170=STYLED_ITEM('',(),#999);\n")),
"styled item #170: missing entity #999"
);
assert_eq!(
refused(&styled(
"#170=STYLED_ITEM('',(#171),#101);\n#171=PRESENTATION_STYLE_ASSIGNMENT((#172));\n#172=SURFACE_STYLE_USAGE(.BOTH.,#173);\n#173=SURFACE_SIDE_STYLE('',(#174));\n#174=SURFACE_STYLE_RENDERING(.NORMAL_SHADING.,#175);\n#175=COLOUR_RGB('',1.,$,0.);\n"
)),
"styled item #170: green: expected a number"
);
let placed = two()
+ "#60=SHAPE_REPRESENTATION('',(),$);\n#80=SHAPE_REPRESENTATION('',(),$);\n#90=NEXT_ASSEMBLY_USAGE_OCCURRENCE('1','','',#50,#70,$);\n#91=PRODUCT_DEFINITION_SHAPE('','',#90);\n#93=(REPRESENTATION_RELATIONSHIP('','',#60,#80)REPRESENTATION_RELATIONSHIP_WITH_TRANSFORMATION(#92)SHAPE_REPRESENTATION_RELATIONSHIP());\n#94=CONTEXT_DEPENDENT_SHAPE_REPRESENTATION(#93,#91);\n";
assert_eq!(refused(&placed), "relationship #93: missing entity #92");
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+466
View File
@@ -0,0 +1,466 @@
//! The units of a representation context (ISO 10303-41 measures and units, ISO 10303-43
//! representation contexts): `GLOBAL_UNIT_ASSIGNED_CONTEXT`'s length and plane angle units and
//! `GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT`'s distance uncertainty, read from a parsed
//! [`Exchange`]. Missing or malformed units read as millimetres and radians; nothing here fails.
//!
//! A unit is a factor: millimetres per file length unit, radians per file plane angle unit. An
//! SI unit is its prefix times its base (`METRE` = 1000 mm, `RADIAN` = 1 rad); a
//! `CONVERSION_BASED_UNIT` is the value of its measure times the factor of the measure's own unit
//! (an inch is `LENGTH_MEASURE(25.4)` of millimetres, a degree `PLANE_ANGLE_MEASURE(0.01745…)`
//! of radians). Only multiplications: the factor of a degree is the number the file writes, not
//! a π computed here (the record computes no geometric quantity, docs/layers.md §4).
//!
//! Origin: SolidCraft's `crates/step/src/units.rs` and the `Units` type of
//! `crates/step/src/read.rs` at `a5b71ca2` (MIT OR Apache-2.0), carried over with their
//! behaviour unchanged.
//!
//! Deviations from SolidCraft: [`Units`] lives here (it was in `read.rs`, which is not carried
//! over yet). The nesting bound of a `CONVERSION_BASED_UNIT` chain is the named
//! `MAX_CONVERSION_DEPTH` (was the literal 4). Otherwise only formatting and doc comments on every
//! public item.
use super::p21::{Exchange, Param};
/// Most `CONVERSION_BASED_UNIT` links followed from one unit (a unit defined from a unit defined
/// from …); a deeper chain, or a cycle, reads as no unit.
const MAX_CONVERSION_DEPTH: usize = 4;
/// The units of a representation context.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Units {
/// Millimetres per file length unit.
pub len: f64,
/// Radians per file plane angle unit.
pub ang: f64,
}
impl Default for Units {
/// Millimetres and radians.
fn default() -> Self {
Units { len: 1.0, ang: 1.0 }
}
}
/// What a representation context says about its numbers.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Context {
/// The length and plane angle units (millimetres and radians where the context names none
/// that can be read).
pub units: Units,
/// The context's distance uncertainty in millimetres (the last `UNCERTAINTY_MEASURE_WITH_UNIT`
/// it lists), if any.
pub uncertainty: Option<f64>,
}
/// The factor of an SI prefix (`$` or an unknown prefix: 1).
fn si_prefix(p: Option<&Param>) -> f64 {
match p.and_then(Param::as_enum) {
Some("EXA") => 1e18,
Some("PETA") => 1e15,
Some("TERA") => 1e12,
Some("GIGA") => 1e9,
Some("MEGA") => 1e6,
Some("KILO") => 1e3,
Some("HECTO") => 1e2,
Some("DECA") => 1e1,
Some("DECI") => 1e-1,
Some("CENTI") => 1e-2,
Some("MILLI") => 1e-3,
Some("MICRO") => 1e-6,
Some("NANO") => 1e-9,
Some("PICO") => 1e-12,
Some("FEMTO") => 1e-15,
Some("ATTO") => 1e-18,
_ => 1.0,
}
}
/// What a unit measures.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum UnitKind {
/// A `LENGTH_UNIT`.
Length,
/// A `PLANE_ANGLE_UNIT`.
Angle,
/// Any other unit (solid angle, mass, …).
Other,
}
/// A unit's kind and size: millimetres for lengths, radians for plane angles (1 for anything
/// else, or a unit that cannot be read). `None` when `#id` is not an entity, or is a
/// `CONVERSION_BASED_UNIT` whose measure cannot be read or is not finite and positive.
pub fn unit_factor(ex: &Exchange, id: u64) -> Option<(UnitKind, f64)> {
factor(ex, id, 0)
}
fn factor(ex: &Exchange, id: u64, depth: usize) -> Option<(UnitKind, f64)> {
if depth > MAX_CONVERSION_DEPTH {
return None;
}
let e = ex.get(id)?;
let kind = if e.has("LENGTH_UNIT") {
UnitKind::Length
} else if e.has("PLANE_ANGLE_UNIT") {
UnitKind::Angle
} else {
UnitKind::Other
};
if let Some(si) = e.record("SI_UNIT") {
let base = match si.get(1).and_then(Param::as_enum) {
Some("METRE") => 1000.0,
Some("RADIAN") => 1.0,
_ => return Some((kind, 1.0)),
};
return Some((kind, si_prefix(si.first()) * base));
}
if let Some(cb) = e.record("CONVERSION_BASED_UNIT") {
let m = ex.get(cb.get(1)?.as_ref_id()?)?;
let mp = m.params();
let v = mp.first()?.as_f64()?;
let (_, f) = factor(ex, mp.get(1)?.as_ref_id()?, depth + 1)?;
let x = v * f;
return (x.is_finite() && x > 0.0).then_some((kind, x));
}
Some((kind, 1.0))
}
/// The units and uncertainty of the representation context `#id` (the defaults when `#id` is
/// not an entity or lists no readable unit).
///
/// Each unit `GLOBAL_UNIT_ASSIGNED_CONTEXT` lists that reads as a finite, positive length or
/// plane angle factor sets that unit; a later one wins. Each `UNCERTAINTY_MEASURE_WITH_UNIT` that
/// `GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT` lists sets the uncertainty to its value times the factor
/// of its unit (the context's length unit when that cannot be read), or clears it when the
/// product is not finite and positive; the last one wins.
pub fn context(ex: &Exchange, id: u64) -> Context {
let mut c = Context::default();
let Some(e) = ex.get(id) else { return c };
if let Some(units) = e
.record("GLOBAL_UNIT_ASSIGNED_CONTEXT")
.and_then(<[Param]>::first)
.and_then(Param::as_list)
{
for uid in units.iter().filter_map(Param::as_ref_id) {
match unit_factor(ex, uid) {
Some((UnitKind::Length, f)) if f.is_finite() && f > 0.0 => c.units.len = f,
Some((UnitKind::Angle, f)) if f.is_finite() && f > 0.0 => c.units.ang = f,
_ => {}
}
}
}
if let Some(unc) = e
.record("GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT")
.and_then(<[Param]>::first)
.and_then(Param::as_list)
{
for uid in unc.iter().filter_map(Param::as_ref_id) {
if let Some(m) = ex.get(uid)
&& let Some(v) = m.params().first().and_then(Param::as_f64)
{
let f = m
.params()
.get(1)
.and_then(Param::as_ref_id)
.and_then(|x| unit_factor(ex, x))
.map(|x| x.1);
c.uncertainty =
Some(v * f.unwrap_or(c.units.len)).filter(|t| t.is_finite() && *t > 0.0);
}
}
}
c
}
#[cfg(test)]
mod tests {
use super::*;
use crate::step::p21;
use std::f64::consts::PI;
use std::fmt::Write as _;
/// Millimetres, radians and steradians as SolidCraft's writer and its topology tests
/// (`crates/step/src/topology_tests.rs`, `file`) write them.
const MM: &str = "#1=(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.));\
#2=(NAMED_UNIT(*)PLANE_ANGLE_UNIT()SI_UNIT($,.RADIAN.));\
#3=(NAMED_UNIT(*)SI_UNIT($,.STERADIAN.)SOLID_ANGLE_UNIT());";
fn parse(data: &str) -> Exchange {
let t = format!(
"ISO-10303-21;\nHEADER;FILE_SCHEMA(('AUTOMOTIVE_DESIGN'));ENDSEC;\nDATA;\n{data}\nENDSEC;\nEND-ISO-10303-21;\n"
);
match p21::parse(&t) {
Ok(ex) => ex,
Err(e) => panic!("{e}\n{t}"),
}
}
/// A context `#100` with the units `units` and the uncertainties `unc` (entity ids).
fn ctx(data: &str, units: &str, unc: &str) -> Context {
let c = format!(
"#100=(GEOMETRIC_REPRESENTATION_CONTEXT(3)GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT(({unc}))\
GLOBAL_UNIT_ASSIGNED_CONTEXT(({units}))REPRESENTATION_CONTEXT('',''));"
);
context(&parse(&format!("{data}{c}")), 100)
}
#[test]
fn solidcraft_file_context() {
// SolidCraft's topology test file: mm, rad, sr and an uncertainty of 1e-7 mm.
let c = ctx(
&format!(
"{MM}#4=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-07),#1,'distance_accuracy_value','');"
),
"#1,#2,#3",
"#4",
);
assert_eq!(c.units, Units { len: 1.0, ang: 1.0 });
// 1e-7 times MILLI (1e-3) times METRE (1000 mm).
assert_eq!(c.uncertainty, Some(1e-7 * (1e-3 * 1000.0)));
}
#[test]
fn si_prefixes() {
for (prefix, mm) in [
(".MILLI.", 1e-3 * 1000.0),
("$", 1000.0),
(".MICRO.", 1e-6 * 1000.0),
(".CENTI.", 1e-2 * 1000.0),
(".KILO.", 1e3 * 1000.0),
] {
let c = ctx(
&format!("#1=(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT({prefix},.METRE.));"),
"#1",
"",
);
assert_eq!(c.units.len, mm, "{prefix}");
assert_eq!(c.units.ang, 1.0, "{prefix}");
assert_eq!(c.uncertainty, None, "{prefix}");
}
// The products the table defines: a millimetre is exactly 1, a metre 1000.
assert_eq!(1e-3 * 1000.0, 1.0);
let ex = parse("#1=(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MICRO.,.METRE.));");
assert_eq!(unit_factor(&ex, 1), Some((UnitKind::Length, 1e-6 * 1000.0)));
// A milliradian.
let ex = parse("#1=(NAMED_UNIT(*)PLANE_ANGLE_UNIT()SI_UNIT(.MILLI.,.RADIAN.));");
assert_eq!(unit_factor(&ex, 1), Some((UnitKind::Angle, 1e-3)));
}
#[test]
fn inches_and_degrees() {
// An inch as 25.4 mm, a degree as π/180 rad (written as the shortest decimal of
// PI / 180, so it reads as exactly that double).
let data = format!(
"{MM}#10=LENGTH_MEASURE_WITH_UNIT(LENGTH_MEASURE(25.4),#1);\
#11=(CONVERSION_BASED_UNIT('INCH',#10)LENGTH_UNIT()NAMED_UNIT(#12));\
#12=DIMENSIONAL_EXPONENTS(1.,0.,0.,0.,0.,0.,0.);\
#13=PLANE_ANGLE_MEASURE_WITH_UNIT(PLANE_ANGLE_MEASURE(0.017453292519943295),#2);\
#14=(CONVERSION_BASED_UNIT('DEGREE',#13)NAMED_UNIT(#15)PLANE_ANGLE_UNIT());\
#15=DIMENSIONAL_EXPONENTS(0.,0.,0.,0.,0.,0.,0.);"
);
let c = ctx(&data, "#11,#14,#3", "");
assert_eq!(
c.units,
Units {
len: 25.4,
ang: PI / 180.0
}
);
// An inch defined from metres: 0.0254 times 1000 mm.
let c = ctx(
"#1=(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT($,.METRE.));\
#10=LENGTH_MEASURE_WITH_UNIT(LENGTH_MEASURE(0.0254),#1);\
#11=(CONVERSION_BASED_UNIT('INCH',#10)LENGTH_UNIT()NAMED_UNIT(*));",
"#11",
"",
);
assert_eq!(c.units.len, 0.0254 * 1000.0);
// A foot defined from the inch: a chain of two conversions, 12 times 25.4 mm.
let c = ctx(
&format!(
"{data}#20=LENGTH_MEASURE_WITH_UNIT(LENGTH_MEASURE(12.),#11);\
#21=(CONVERSION_BASED_UNIT('FOOT',#20)LENGTH_UNIT()NAMED_UNIT(*));"
),
"#21",
"",
);
assert_eq!(c.units.len, 12.0 * 25.4);
}
#[test]
fn missing_or_broken_units_read_as_mm_and_rad() {
let mm_rad = Units { len: 1.0, ang: 1.0 };
// No such context, or a context without units.
assert_eq!(context(&parse(MM), 100), Context::default());
assert_eq!(context(&parse(MM), 1), Context::default());
assert_eq!(Context::default().units, mm_rad);
for (data, units) in [
// Dangling references.
("", "#7,#8"),
// An SI unit with an unknown base: factor 1.
(
"#1=(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.FURLONG.));",
"#1",
),
// A conversion whose measure is missing, unreadable, zero, negative or infinite.
(
"#1=(CONVERSION_BASED_UNIT('INCH',#9)LENGTH_UNIT()NAMED_UNIT(*));",
"#1",
),
(
"#2=(NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.)LENGTH_UNIT());\
#3=LENGTH_MEASURE_WITH_UNIT($,#2);\
#1=(CONVERSION_BASED_UNIT('INCH',#3)LENGTH_UNIT()NAMED_UNIT(*));",
"#1",
),
(
"#2=(NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.)LENGTH_UNIT());\
#3=LENGTH_MEASURE_WITH_UNIT(LENGTH_MEASURE(0.),#2);\
#1=(CONVERSION_BASED_UNIT('INCH',#3)LENGTH_UNIT()NAMED_UNIT(*));",
"#1",
),
(
"#2=(NAMED_UNIT(*)PLANE_ANGLE_UNIT()SI_UNIT($,.RADIAN.));\
#3=PLANE_ANGLE_MEASURE_WITH_UNIT(PLANE_ANGLE_MEASURE(-1.),#2);\
#1=(CONVERSION_BASED_UNIT('DEGREE',#3)PLANE_ANGLE_UNIT()NAMED_UNIT(*));",
"#1",
),
(
"#2=(NAMED_UNIT(*)SI_UNIT(.EXA.,.METRE.)LENGTH_UNIT());\
#3=LENGTH_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E300),#2);\
#1=(CONVERSION_BASED_UNIT('BIG',#3)LENGTH_UNIT()NAMED_UNIT(*));",
"#1",
),
// A conversion that refers to itself: the depth bound ends it.
(
"#3=LENGTH_MEASURE_WITH_UNIT(LENGTH_MEASURE(2.),#1);\
#1=(CONVERSION_BASED_UNIT('LOOP',#3)LENGTH_UNIT()NAMED_UNIT(*));",
"#1",
),
// Units that are neither lengths nor plane angles.
(MM, "#3"),
// An empty list.
(MM, ""),
] {
let c = ctx(data, units, "");
assert_eq!(c.units, mm_rad, "{data} / {units}");
}
// A unit list that is not a list.
let ex = parse(&format!(
"{MM}#100=(GEOMETRIC_REPRESENTATION_CONTEXT(3)GLOBAL_UNIT_ASSIGNED_CONTEXT(#1)REPRESENTATION_CONTEXT('',''));"
));
assert_eq!(context(&ex, 100), Context::default());
// A broken length unit leaves the readable angle unit in place.
let c = ctx(
"#2=(NAMED_UNIT(*)PLANE_ANGLE_UNIT()SI_UNIT(.MILLI.,.RADIAN.));\
#1=(CONVERSION_BASED_UNIT('INCH',#9)LENGTH_UNIT()NAMED_UNIT(*));",
"#1,#2",
"",
);
assert_eq!(
c.units,
Units {
len: 1.0,
ang: 1e-3
}
);
}
#[test]
fn conversion_depth_is_bounded() {
// #1 is millimetres; #2..=#7 each 2 of the one before, so #(1+k) is 2^k mm and
// MAX_CONVERSION_DEPTH links are followed from the unit asked for.
let mut data = String::from("#1=(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.));");
for k in 2..=7 {
write!(
data,
"#{m}=LENGTH_MEASURE_WITH_UNIT(LENGTH_MEASURE(2.),#{p});\
#{k}=(CONVERSION_BASED_UNIT('U',#{m})LENGTH_UNIT()NAMED_UNIT(*));",
m = 100 + k,
p = k - 1
)
.unwrap();
}
let ex = parse(&data);
assert_eq!(unit_factor(&ex, 5), Some((UnitKind::Length, 16.0)));
assert_eq!(unit_factor(&ex, 6), None);
}
#[test]
fn uncertainty_in_file_units() {
let inch = format!(
"{MM}#10=LENGTH_MEASURE_WITH_UNIT(LENGTH_MEASURE(25.4),#1);\
#11=(CONVERSION_BASED_UNIT('INCH',#10)LENGTH_UNIT()NAMED_UNIT(*));"
);
// 1e-4 inch is 1e-4 * 25.4 mm.
let c = ctx(
&format!("{inch}#20=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-04),#11,'d','');"),
"#11,#2",
"#20",
);
assert_eq!(c.units.len, 25.4);
assert_eq!(c.uncertainty, Some(1e-4 * 25.4));
// A measure in metres in an inch context: its own unit, 1e-6 * 1000 mm.
let c = ctx(
&format!(
"{inch}#12=(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT($,.METRE.));\
#20=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-06),#12,'d','');"
),
"#11",
"#20",
);
assert_eq!(c.uncertainty, Some(1e-6 * 1000.0));
// A measure whose unit cannot be read is in the context's length unit.
let c = ctx(
&format!("{inch}#20=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-04),$,'d','');"),
"#11",
"#20",
);
assert_eq!(c.uncertainty, Some(1e-4 * 25.4));
// The last one listed wins.
let c = ctx(
&format!(
"{MM}#20=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-04),#1,'d','');\
#21=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(2.E-04),#1,'d','');"
),
"#1",
"#20,#21",
);
assert_eq!(c.uncertainty, Some(2e-4 * (1e-3 * 1000.0)));
}
#[test]
fn uncertainty_zero_negative_or_infinite_is_none() {
for v in ["0.", "-1.E-04", "1.E300"] {
// In exametres (1e21 mm): 0, negative, and 1e300 * 1e21 mm, which is infinite.
let c = ctx(
&format!(
"{MM}#12=(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.EXA.,.METRE.));\
#20=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE({v}),#12,'d','');"
),
"#1",
"#20",
);
assert_eq!(c.uncertainty, None, "{v}");
}
// A bad one after a good one clears it (the last one wins).
let c = ctx(
&format!(
"{MM}#20=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-04),#1,'d','');\
#21=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(0.),#1,'d','');"
),
"#1",
"#20,#21",
);
assert_eq!(c.uncertainty, None);
// One without a readable value, or a dangling reference, is skipped.
let c = ctx(
&format!(
"{MM}#20=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-04),#1,'d','');\
#21=UNCERTAINTY_MEASURE_WITH_UNIT($,#1,'d','');"
),
"#1",
"#20,#21,#99",
);
assert_eq!(c.uncertainty, Some(1e-4 * (1e-3 * 1000.0)));
}
}
+644 -1
View File
@@ -1,4 +1,5 @@
//! The Part 21 parser over a STEP corpus: every file parses.
//! The Part 21 parser over a STEP corpus: every file parses, the units of its representation
//! contexts, and its Part 42 curves and surfaces.
//!
//! Ignored by default; the corpus is not in the repository. Run it with the directory of
//! `.stp` / `.step` files (read in place, recursively) in `SOLIDCORE_STEP_CORPUS`:
@@ -13,6 +14,31 @@
//! caller holding the file's bytes would. The test fails when any file does not parse. The
//! STEP files committed to the repository are parsed by `repository_files_parse`, which always
//! runs.
//!
//! `corpus_units` (ignored too, same variable and command with `corpus_units` as the filter)
//! reads every geometric representation context with assigned units in each file and prints
//! one line per file and a table of how often each length and plane angle unit occurs, and
//! how many contexts state an uncertainty. It fails when a file does not parse. Files with no
//! context whose length unit can be read are listed and counted, not failed: a file written
//! with Part 21 short names (`SUNT`, `LNGUNT`, `GMRPCN`, …) or one without geometry has none.
//!
//! `corpus_geometry` (ignored too, same variable, `corpus_geometry` as the filter) reads every
//! curve and surface entity of each file with `step::read::Reader`, in the units of the file's
//! first geometric context: curves in model space with `curve`, the curves of a parameter space
//! through their `PCURVE` with `pcurve`, surfaces with `surface`. It prints one line per file
//! and, per group and entity kind, how many were read and how many refused, with the reasons.
//! It also lists the entities where a check the record no longer makes (a direction shorter than
//! 1e-12, unit factors equal within 1e-12) would have decided otherwise. It fails when a file
//! does not parse; refusals are counted, not failed.
//!
//! `corpus_product` (ignored too, same variable, `corpus_product` as the filter) reads the
//! product structure of each file with `step::product::read` and expands its assembly tree. It
//! prints one line per file and the files with assemblies, and checks the structure against the
//! file's own entity counts: products, definitions, occurrences, body items (every solid the
//! file has is an item of a representation the structure holds), relationships with a
//! transformation and styled items must all be there; a mapped item it does not hold must be
//! listed only by representations it does not reach (counted by the kind that lists them). It
//! fails when a file does not parse, is refused, or a count differs.
#![allow(
clippy::unwrap_used,
@@ -21,9 +47,11 @@
clippy::indexing_slicing
)] // test
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use solidcore_record::step::p21;
use solidcore_record::step::units::{self, UnitKind};
fn files(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
@@ -109,6 +137,116 @@ fn corpus_parses() {
);
}
/// A length factor's name (millimetres per unit).
fn length_name(f: f64) -> String {
match f {
1.0 => "mm".into(),
25.4 => "inch".into(),
1000.0 => "m".into(),
10.0 => "cm".into(),
304.8 => "foot".into(),
_ => format!("{f} mm"),
}
}
/// A plane angle factor's name (radians per unit). A degree is the number the file writes:
/// `degree` when it is π/180 to 12 digits or more, else the written factor, so a file that
/// rounds π/180 shorter (`0.0174532925`) shows in the table as what it is.
fn angle_name(f: f64) -> String {
if f == 1.0 {
"rad".into()
} else if (f - std::f64::consts::PI / 180.0).abs() <= 1e-14 {
"degree".into()
} else {
format!("{f} rad")
}
}
#[test]
#[ignore = "needs a STEP corpus in SOLIDCORE_STEP_CORPUS"]
fn corpus_units() {
let Some(dir) = std::env::var_os("SOLIDCORE_STEP_CORPUS") else {
eprintln!("SOLIDCORE_STEP_CORPUS is not set");
return;
};
let root = Path::new(&dir);
let mut paths = Vec::new();
files(root, &mut paths);
assert!(!paths.is_empty(), "no STEP files under {dir:?}");
let mut failures = Vec::new();
let mut unread = Vec::new();
let mut lengths: BTreeMap<String, usize> = BTreeMap::new();
let mut angles: BTreeMap<String, usize> = BTreeMap::new();
let (mut contexts, mut with_uncertainty, mut files_read) = (0usize, 0usize, 0usize);
for path in &paths {
let name = path
.strip_prefix(root)
.unwrap_or(path)
.display()
.to_string();
let ex = match std::fs::read(path)
.map_err(|e| e.to_string())
.and_then(|b| p21::parse(&String::from_utf8_lossy(&b)).map_err(|e| e.to_string()))
{
Ok(ex) => ex,
Err(e) => {
println!("{name}: ERROR {e}");
failures.push(format!("{name}: {e}"));
continue;
}
};
let mut here = Vec::new();
let mut readable = false;
for (&id, e) in &ex.entities {
if !(e.has("GEOMETRIC_REPRESENTATION_CONTEXT") && e.has("GLOBAL_UNIT_ASSIGNED_CONTEXT"))
{
continue;
}
// Whether the context names a length unit that reads as a factor, rather than
// falling back to millimetres.
readable |= e
.record("GLOBAL_UNIT_ASSIGNED_CONTEXT")
.and_then(<[p21::Param]>::first)
.and_then(p21::Param::as_list)
.is_some_and(|l| {
l.iter().filter_map(p21::Param::as_ref_id).any(|u| {
matches!(units::unit_factor(&ex, u),
Some((UnitKind::Length, f)) if f.is_finite() && f > 0.0)
})
});
let c = units::context(&ex, id);
contexts += 1;
with_uncertainty += usize::from(c.uncertainty.is_some());
let (l, a) = (length_name(c.units.len), angle_name(c.units.ang));
*lengths.entry(l.clone()).or_default() += 1;
*angles.entry(a.clone()).or_default() += 1;
here.push(format!("#{id} {l}/{a} uncertainty {:?}", c.uncertainty));
}
println!("{name}: {} contexts: {}", here.len(), here.join(", "));
if readable {
files_read += 1;
} else {
println!("{name}: no context with a readable length unit");
unread.push(name);
}
}
println!(
"{files_read} of {} files have a context with a readable length unit; {contexts} contexts, {with_uncertainty} with an uncertainty",
paths.len()
);
println!("length units (contexts): {lengths:?}");
println!("plane angle units (contexts): {angles:?}");
println!(
"no context with a readable length unit: {} files {unread:?}",
unread.len()
);
assert!(
failures.is_empty(),
"files that did not parse:\n{}",
failures.join("\n")
);
}
/// The STEP files in the repository parse to every instance they hold, each id once: a parser
/// that dropped or merged instances would change the counts (the instance lines in the files).
#[test]
@@ -130,3 +268,508 @@ fn repository_files_parse() {
assert!(ex.duplicates.is_empty(), "{path}: {:?}", ex.duplicates);
}
}
/// Curve entity names (simple instances) the corpus count looks at: every one the reader maps,
/// and the other ISO 10303-42 curves a file may hold, which it refuses.
const CURVES: &[&str] = &[
"B_SPLINE_CURVE",
"B_SPLINE_CURVE_WITH_KNOTS",
"BEZIER_CURVE",
"BOUNDED_SURFACE_CURVE",
"CIRCLE",
"CLOTHOID",
"COMPOSITE_CURVE",
"COMPOSITE_CURVE_ON_SURFACE",
"CURVE_REPLICA",
"ELLIPSE",
"HYPERBOLA",
"INTERSECTION_CURVE",
"LINE",
"OFFSET_CURVE_2D",
"OFFSET_CURVE_3D",
"PARABOLA",
"POLYLINE",
"QUASI_UNIFORM_CURVE",
"SEAM_CURVE",
"SURFACE_CURVE",
"TRIMMED_CURVE",
"UNIFORM_CURVE",
];
/// Surface entity names, likewise.
const SURFACES: &[&str] = &[
"B_SPLINE_SURFACE",
"B_SPLINE_SURFACE_WITH_KNOTS",
"BEZIER_SURFACE",
"CONICAL_SURFACE",
"CURVE_BOUNDED_SURFACE",
"CYLINDRICAL_SURFACE",
"DEGENERATE_TOROIDAL_SURFACE",
"OFFSET_SURFACE",
"ORIENTED_SURFACE",
"PLANE",
"QUASI_UNIFORM_SURFACE",
"RECTANGULAR_COMPOSITE_SURFACE",
"RECTANGULAR_TRIMMED_SURFACE",
"SPHERICAL_SURFACE",
"SURFACE_OF_LINEAR_EXTRUSION",
"SURFACE_OF_REVOLUTION",
"SURFACE_REPLICA",
"TOROIDAL_SURFACE",
"UNIFORM_SURFACE",
];
/// Whether `e` is a curve or a surface for the count, and its kind: the entity name, or for a
/// complex instance `(B_SPLINE_CURVE …)` with `rational` when it is.
fn geometry_kind(e: &p21::Entity) -> Option<(bool, String)> {
if e.is_complex() {
let rational = if e.has("RATIONAL_B_SPLINE_CURVE") || e.has("RATIONAL_B_SPLINE_SURFACE") {
" rational"
} else {
""
};
let is_curve =
e.has("B_SPLINE_CURVE") || e.records.iter().any(|r| CURVES.contains(&r.name.as_str()));
let is_surface = e.has("B_SPLINE_SURFACE")
|| e.records
.iter()
.any(|r| SURFACES.contains(&r.name.as_str()));
let names: Vec<&str> = e
.records
.iter()
.map(|r| r.name.as_str())
.filter(|n| CURVES.contains(n) || SURFACES.contains(n))
.collect();
let kind = format!("({}){rational}", names.join(" "));
return match (is_curve, is_surface) {
(true, false) => Some((true, kind)),
(false, true) => Some((false, kind)),
_ => None,
};
}
let n = e.name();
if CURVES.contains(&n) {
Some((true, n.to_string()))
} else if SURFACES.contains(&n) {
Some((false, n.to_string()))
} else {
None
}
}
/// An error message with its entity ids replaced by `#n`, so equal reasons count together.
fn reason(e: &str) -> String {
let mut out = String::new();
let mut chars = e.chars().peekable();
while let Some(c) = chars.next() {
out.push(c);
if c == '#' && chars.peek().is_some_and(char::is_ascii_digit) {
out.push('n');
while chars.peek().is_some_and(char::is_ascii_digit) {
chars.next();
}
}
}
out
}
/// The curves of a file that lie in a parameter space: the items of its
/// `DEFINITIONAL_REPRESENTATION`s and the curves they refer to (a 2D trimmed curve's basis …).
fn curves_2d(ex: &p21::Exchange) -> std::collections::BTreeSet<u64> {
fn refs(p: &p21::Param, out: &mut Vec<u64>) {
match p {
p21::Param::Ref(r) => out.push(*r),
p21::Param::List(l) | p21::Param::Typed(_, l) => l.iter().for_each(|x| refs(x, out)),
_ => {}
}
}
let mut stack = Vec::new();
for e in ex.entities.values() {
if e.name() == "DEFINITIONAL_REPRESENTATION"
&& let Some(items) = e.params().get(1)
{
refs(items, &mut stack);
}
}
let mut seen = std::collections::BTreeSet::new();
while let Some(id) = stack.pop() {
let Some(e) = ex.get(id) else { continue };
if !matches!(geometry_kind(e), Some((true, _))) || !seen.insert(id) {
continue;
}
for r in &e.records {
r.params.iter().for_each(|p| refs(p, &mut stack));
}
}
seen
}
/// Read or refused, by reason.
#[derive(Default)]
struct Tally {
read: usize,
refused: BTreeMap<String, usize>,
}
impl Tally {
fn add<T>(&mut self, r: &Result<T, String>) {
match r {
Ok(_) => self.read += 1,
Err(e) => *self.refused.entry(reason(e)).or_default() += 1,
}
}
fn refused(&self) -> usize {
self.refused.values().sum()
}
}
#[test]
#[ignore = "needs a STEP corpus in SOLIDCORE_STEP_CORPUS"]
fn corpus_geometry() {
use solidcore_record::step::read::Reader;
let Some(dir) = std::env::var_os("SOLIDCORE_STEP_CORPUS") else {
eprintln!("SOLIDCORE_STEP_CORPUS is not set");
return;
};
let root = Path::new(&dir);
let mut paths = Vec::new();
files(root, &mut paths);
assert!(!paths.is_empty(), "no STEP files under {dir:?}");
let mut failures = Vec::new();
// (group, kind) -> tally, groups "curve", "surface", "pcurve" (by its 2D curve's kind).
let mut kinds: BTreeMap<(&str, String), Tally> = BTreeMap::new();
let mut mixed_units = Vec::new();
let mut removed = Vec::new();
for path in &paths {
let name = path
.strip_prefix(root)
.unwrap_or(path)
.display()
.to_string();
let ex = match std::fs::read(path)
.map_err(|e| e.to_string())
.and_then(|b| p21::parse(&String::from_utf8_lossy(&b)).map_err(|e| e.to_string()))
{
Ok(ex) => ex,
Err(e) => {
println!("{name}: ERROR {e}");
failures.push(format!("{name}: {e}"));
continue;
}
};
// The units of the file's first geometric context (the topology reader, which comes
// later, reads each representation in its own context).
let contexts: Vec<units::Units> = ex
.entities
.iter()
.filter(|(_, e)| {
e.has("GEOMETRIC_REPRESENTATION_CONTEXT") && e.has("GLOBAL_UNIT_ASSIGNED_CONTEXT")
})
.map(|(&id, _)| units::context(&ex, id).units)
.collect();
let u = contexts.first().copied().unwrap_or_default();
if contexts.iter().any(|c| *c != u) {
mixed_units.push(name.clone());
}
// Where a removed check would have decided otherwise: SolidCraft refused a direction
// whose length is at most 1e-12 (here: only one that is exactly zero), and took the two
// unit factors of a conic's parameter space as equal within 1e-12 relative (here: only
// when equal).
for (&id, e) in &ex.entities {
if e.name() == "DIRECTION"
&& let Some(r) = e.params().get(1).and_then(p21::Param::as_list)
{
let sq: f64 = r.iter().filter_map(p21::Param::as_f64).map(|x| x * x).sum();
if sq > 0.0 && sq <= 1e-24 {
removed.push(format!("{name}: #{id} a direction of length at most 1e-12"));
}
}
}
if u.len != u.ang && (u.len - u.ang).abs() <= 1e-12 * u.len.abs().max(u.ang.abs()) {
removed.push(format!(
"{name}: length and angle factors equal within 1e-12"
));
}
let flat = curves_2d(&ex);
let mut reader = Reader::new(&ex, u);
let mut here = (Tally::default(), Tally::default(), Tally::default());
for (&id, e) in &ex.entities {
if e.name() == "PCURVE" {
let kind = e
.params()
.get(2)
.and_then(p21::Param::as_ref_id)
.and_then(|r| ex.get(r))
.and_then(|r| r.params().get(1)?.as_list()?.first()?.as_ref_id())
.and_then(|c| ex.get(c))
.and_then(geometry_kind)
.map_or_else(|| "?".to_string(), |k| k.1);
let r = reader.pcurve(id);
here.2.add(&r);
kinds.entry(("pcurve", kind)).or_default().add(&r);
continue;
}
let Some((is_curve, kind)) = geometry_kind(e) else {
continue;
};
if is_curve {
if flat.contains(&id) {
continue;
}
let r = reader.curve(id);
here.0.add(&r);
kinds.entry(("curve", kind)).or_default().add(&r);
} else {
let r = reader.surface(id);
here.1.add(&r);
kinds.entry(("surface", kind)).or_default().add(&r);
}
}
println!(
"{name}: curves {} read, {} refused; surfaces {} read, {} refused; pcurves {} read, {} refused",
here.0.read,
here.0.refused(),
here.1.read,
here.1.refused(),
here.2.read,
here.2.refused()
);
}
println!("by kind (group, kind: read / refused, with reasons):");
let mut totals: BTreeMap<&str, (usize, usize)> = BTreeMap::new();
for ((group, kind), t) in &kinds {
println!(" {group} {kind}: {} / {}", t.read, t.refused());
for (why, n) in &t.refused {
println!(" {n} x {why}");
}
let g = totals.entry(group).or_default();
g.0 += t.read;
g.1 += t.refused();
}
println!("totals (read / refused): {totals:?}");
println!(
"files whose contexts have different units (read in the first): {} {mixed_units:?}",
mixed_units.len()
);
println!(
"entities a removed check would have decided otherwise: {} {removed:?}",
removed.len()
);
assert!(
failures.is_empty(),
"files that did not parse:\n{}",
failures.join("\n")
);
}
/// The entity types counted by `corpus_product` against what the product structure holds.
const BODY_KINDS: [&str; 4] = [
"MANIFOLD_SOLID_BREP",
"BREP_WITH_VOIDS",
"FACETED_BREP",
"SHELL_BASED_SURFACE_MODEL",
];
const OCCURRENCE_KINDS: [&str; 3] = [
"NEXT_ASSEMBLY_USAGE_OCCURRENCE",
"ASSEMBLY_COMPONENT_USAGE",
"SPECIFIED_HIGHER_USAGE_OCCURRENCE",
];
const STYLED_KINDS: [&str; 3] = [
"STYLED_ITEM",
"OVER_RIDING_STYLED_ITEM",
"CONTEXT_DEPENDENT_OVER_RIDING_STYLED_ITEM",
];
#[test]
#[ignore = "needs a STEP corpus in SOLIDCORE_STEP_CORPUS"]
fn corpus_product() {
use solidcore_record::step::product::{self, ItemKind};
let Some(dir) = std::env::var_os("SOLIDCORE_STEP_CORPUS") else {
eprintln!("SOLIDCORE_STEP_CORPUS is not set");
return;
};
let root = Path::new(&dir);
let mut paths = Vec::new();
files(root, &mut paths);
assert!(!paths.is_empty(), "no STEP files under {dir:?}");
let mut failures = Vec::new();
let mut assemblies = Vec::new();
// Totals: name -> (held, census).
let mut totals: BTreeMap<&str, (usize, usize)> = BTreeMap::new();
let mut coloured_by_kind: BTreeMap<String, usize> = BTreeMap::new();
let mut instances_total = 0usize;
let mut unreached_mapped: BTreeMap<String, usize> = BTreeMap::new();
for path in &paths {
let name = path
.strip_prefix(root)
.unwrap_or(path)
.display()
.to_string();
let ex = match std::fs::read(path)
.map_err(|e| e.to_string())
.and_then(|b| p21::parse(&String::from_utf8_lossy(&b)).map_err(|e| e.to_string()))
{
Ok(ex) => ex,
Err(e) => {
println!("{name}: ERROR {e}");
failures.push(format!("{name}: {e}"));
continue;
}
};
let census = |names: &[&str]| {
ex.entities
.values()
.filter(|e| names.contains(&e.name()))
.count()
};
let s = match product::read(&ex) {
Ok(s) => s,
Err(e) => {
println!("{name}: REFUSED {e}");
failures.push(format!("{name}: refused: {e}"));
continue;
}
};
let mut bodies = std::collections::BTreeSet::new();
let mut mapped = std::collections::BTreeSet::new();
for r in &s.representations {
for i in &r.items {
match i.kind {
ItemKind::Body(_) => {
bodies.insert(i.entity);
}
ItemKind::Mapped { .. } => {
mapped.insert(i.entity);
}
ItemKind::Other(_) => {}
}
}
}
let placed = s
.links
.iter()
.filter(|l| l.transformation.is_some())
.count();
let coloured: Vec<_> = s
.styles
.iter()
.filter(|st| {
!st.curve.is_empty()
|| st
.surface
.iter()
.any(|f| f.fill.is_some() || f.rendering.is_some())
})
.collect();
for st in &coloured {
let kind = ex.get(st.item).map_or("?", p21::Entity::name);
*coloured_by_kind.entry(kind.to_string()).or_default() += 1;
}
let instances = match s.instances() {
Ok(i) => i.len(),
Err(e) => {
failures.push(format!("{name}: instances: {e}"));
0
}
};
instances_total += instances;
let rows = [
("products", s.products.len(), census(&["PRODUCT"])),
(
"definitions",
s.definitions.len(),
census(&[
"PRODUCT_DEFINITION",
"PRODUCT_DEFINITION_WITH_ASSOCIATED_DOCUMENTS",
]),
),
(
"occurrences",
s.occurrences.len(),
census(&OCCURRENCE_KINDS),
),
("body items", bodies.len(), census(&BODY_KINDS)),
(
"placed links",
placed,
ex.entities
.values()
.filter(|e| e.has("REPRESENTATION_RELATIONSHIP_WITH_TRANSFORMATION"))
.count(),
),
("styled items", s.styles.len(), census(&STYLED_KINDS)),
];
// A mapped item the structure does not hold is listed only by representations it does
// not reach (the draughting models of PMI views): checked, and counted apart.
let reached: std::collections::BTreeSet<u64> =
s.representations.iter().map(|r| r.entity).collect();
let mut listers: BTreeMap<u64, Vec<(u64, &str)>> = BTreeMap::new();
for (&id, e) in &ex.entities {
for r in &e.records {
for p in &r.params {
for m in p
.as_list()
.unwrap_or_default()
.iter()
.filter_map(p21::Param::as_ref_id)
{
if ex.get(m).is_some_and(|m| m.name() == "MAPPED_ITEM") {
listers.entry(m).or_default().push((id, e.name()));
}
}
}
}
}
let all_mapped = census(&["MAPPED_ITEM"]);
for (&id, e) in &ex.entities {
if e.name() != "MAPPED_ITEM" || mapped.contains(&id) {
continue;
}
let by = listers.get(&id).cloned().unwrap_or_default();
if by.iter().any(|(l, _)| reached.contains(l)) {
failures.push(format!(
"{name}: mapped item #{id} of a reached representation not held"
));
}
for (_, kind) in by {
*unreached_mapped.entry(kind.to_string()).or_default() += 1;
}
}
let t = totals.entry("mapped items").or_default();
t.0 += mapped.len();
t.1 += all_mapped;
let mut line = format!("{name}: mapped items {}/{all_mapped};", mapped.len());
for (what, held, n) in rows {
let _ = std::fmt::Write::write_fmt(&mut line, format_args!(" {what} {held}/{n};"));
let t = totals.entry(what).or_default();
t.0 += held;
t.1 += n;
if held != n {
failures.push(format!("{name}: {what}: {held} held, {n} in the file"));
}
}
println!(
"{line} coloured items {}; roots {}; instances {instances}",
coloured.len(),
s.roots.len()
);
if !s.occurrences.is_empty() {
assemblies.push(format!(
"{name}: {} definitions, {} occurrences, {} roots, {instances} instances, {} mapped items",
s.definitions.len(),
s.occurrences.len(),
s.roots.len(),
mapped.len()
));
}
}
println!("totals (held / in the files): {totals:?}");
println!("coloured items by kind: {coloured_by_kind:?}");
println!("mapped items not held, by the kind of entity listing them: {unreached_mapped:?}");
println!("instances: {instances_total}");
println!("files with assemblies: {}", assemblies.len());
for a in &assemblies {
println!(" {a}");
}
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
+14 -4
View File
@@ -2298,16 +2298,26 @@ fn plan_face(
}
// Whatever the layout, no triangle leaves the surface by more than the tolerance.
if !planar {
if sag::refine(s, tol, reach, &fixed, &mut all, &mut triangles, MAX_REFINE)
.map_err(|e| sag_stop(e, all.len()))?
// The kernel's classifier is asked once per node over the three passes.
let mut poles = sag::Poles::new(s, reach);
if sag::refine(
s,
tol,
&mut poles,
&fixed,
&mut all,
&mut triangles,
MAX_REFINE,
)
.map_err(|e| sag_stop(e, all.len()))?
{
caps.push(Cap::Refine);
}
sag::flip(s, tol, reach, &fixed, &all, &mut triangles)
sag::flip(s, tol, &mut poles, &fixed, &all, &mut triangles)
.map_err(|e| sag_stop(e, all.len()))?;
// A face a bound limited is named with the sag its triangles reach.
if !caps.is_empty() {
let (sag, unmeasured) = sag::worst(s, reach, &fixed, &all, &triangles)
let (sag, unmeasured) = sag::worst(s, &mut poles, &fixed, &all, &triangles)
.map_err(|e| sag_stop(e, all.len()))?;
stats.over_tolerance.push(OverTolerance {
face: fi as usize,
+15 -2
View File
@@ -379,7 +379,14 @@ fn the_flips_name_a_middle_the_surface_does_not_evaluate_at() {
let uv = vec![[0.0, 0.0], [0.75, -0.5], [0.75, 0.5], [0.0, 1.0]];
let fixed: Vec<Vec3> = uv.iter().map(|q| point(&s, *q)).collect();
let mut tris = vec![[0, 1, 2], [0, 2, 3]];
match super::sag::flip(&s, 0.01, 1e-9, &fixed, &uv, &mut tris) {
match super::sag::flip(
&s,
0.01,
&mut super::sag::Poles::new(&s, 1e-9),
&fixed,
&uv,
&mut tris,
) {
Err(super::sag::Stop::NoValue(NoValue(q))) => assert_eq!(q[0], 0.5, "{q:?}"),
other => panic!("{other:?}, flipped to {tris:?}"),
}
@@ -544,7 +551,13 @@ fn a_step_inwards_the_surface_has_no_point_at_refuses_the_face() {
fn the_worst_sag_is_measured_where_the_surface_evaluates() {
let s = cylinder_without(0.5);
let uv = [[0.0, 0.0], [0.75, -0.5], [0.75, 0.5]];
let got = super::sag::worst(&s, 1e-9, &[], &uv, &[[0, 1, 2]]);
let got = super::sag::worst(
&s,
&mut super::sag::Poles::new(&s, 1e-9),
&[],
&uv,
&[[0, 1, 2]],
);
// Measured at its three sides' middles; its middle, unmeasured, is counted, so the face is
// never taken as within its tolerance over it.
assert!(
+197 -81
View File
@@ -18,8 +18,9 @@ use super::{NoValue, Unclassified};
/// Split the triangles `tris` of a face (indices into `uv`; the first `fixed.len()` points sit
/// at `fixed`, the rest on the surface at their parameters) until none of them lies farther
/// than `tol` from the surface `s`, adding at most `max_points` points to `uv`. `reach`: how near
/// a pole a point is taken as one (see [`solidcore::eval::uv::degenerate_within`]).
/// than `tol` from the surface `s`, adding at most `max_points` points to `uv`. `poles`: the
/// classifier of the face's nodes, shared by [`refine`], [`flip`] and [`worst`] (how near a pole a
/// point is taken as one: see [`solidcore::eval::uv::degenerate_within`]).
///
/// A point the surface does not evaluate at (a node, or a middle the check needs) is the
/// evaluator refusing the face: the error names it, and the caller leaves the face out whole.
@@ -50,7 +51,7 @@ impl From<Unclassified> for Stop {
pub(crate) fn refine(
s: &EvalSurface,
tol: f64,
reach: f64,
poles: &mut Poles,
fixed: &[Vec3],
uv: &mut Vec<[f64; 2]>,
tris: &mut Vec<[u32; 3]>,
@@ -64,7 +65,6 @@ pub(crate) fn refine(
.map(|d| kernel::record_vec3(d.0))
.ok_or(NoValue(q))
};
let mean = |qs: &[[f64; 2]]| mean(s, reach, qs);
// How far the flat point `c` lies from the surface, measured along the normal at the surface
// point of the same parameters: those parameters need not be the nearest (a straight line in
// the parameters is no geodesic near a pole), and only the departure across the surface
@@ -103,10 +103,7 @@ pub(crate) fn refine(
continue;
};
let p = |i: u32| pos.get(i as usize).copied();
let q = |i: u32| uv.get(i as usize).copied();
let (Some(pa), Some(pb), Some(pc), Some(qa), Some(qb), Some(qc)) =
(p(t[0]), p(t[1]), p(t[2]), q(t[0]), q(t[1]), q(t[2]))
else {
let (Some(pa), Some(pb), Some(pc)) = (p(t[0]), p(t[1]), p(t[2])) else {
continue;
};
// Sides inside the face (two triangles on them), long enough to split, with their sag.
@@ -117,7 +114,7 @@ pub(crate) fn refine(
if sides.get(&e).is_none_or(|ts| ts.len() != 2) {
continue;
}
let (Some(xa), Some(xb), Some(ua), Some(ub)) = (p(a), p(b), q(a), q(b)) else {
let (Some(xa), Some(xb)) = (p(a), p(b)) else {
continue;
};
let len = xa.dist(xb);
@@ -127,7 +124,7 @@ pub(crate) fn refine(
let sag = if let Some(&sag) = sag_of.get(&e) {
sag
} else {
let sag = off(mean(&[ua, ub])?, (xa + xb) * 0.5)?;
let sag = off(poles.mean(uv, &[a, b])?, (xa + xb) * 0.5)?;
sag_of.insert(e, sag);
sag
};
@@ -138,7 +135,7 @@ pub(crate) fn refine(
// inside the face divides by four. A triangle whose middle is off splits its side inside
// the face that sags most, while that side still contributes; what is left comes from
// its boundary sides, which the edges' own sampling keeps within the tolerance.
let centroid = mean(&[qa, qb, qc])?;
let centroid = poles.mean(uv, &t)?;
let worst = inner.iter().max_by(|x, y| x.0.total_cmp(&y.0)).copied();
let split = match worst {
Some((sag, _, e)) if sag > tol => e,
@@ -174,14 +171,11 @@ pub(crate) fn refine(
if from != ti {
queue.push(ti);
}
let (Some(ua), Some(ub)) = (q(split.0), q(split.1)) else {
continue;
};
// A split is wanted and there is no room for its point.
if uv.len() >= limit {
return Ok(true);
}
let m = mean(&[ua, ub])?;
let m = poles.mean(uv, &[split.0, split.1])?;
let pm = at(m)?;
// Proven: `plan_face` checks its nodes and the MAX_REFINE more it lets this add are at most
// MAX_FACE_NODES, so the index fits a u32.
@@ -194,29 +188,82 @@ pub(crate) fn refine(
Ok(false)
}
/// The mean of parameter points, as the surface point between them: at a pole the parameter
/// along the degenerate direction is free (the pole copy carries a neighbour's), so there the
/// mean takes it from the other points alone.
/// A point whose poles the kernel's classifier cannot tell is the error.
fn mean(s: &EvalSurface, reach: f64, qs: &[[f64; 2]]) -> Result<[f64; 2], Unclassified> {
let mut out = [0.0; 2];
// Proven: `d` < 2 enumerates `out` and indexes the `[f64; 2]` parameters `qs`.
#[allow(clippy::indexing_slicing)]
for (d, slot) in out.iter_mut().enumerate() {
let mut free: Vec<f64> = Vec::new();
for q in qs {
if !super::pole_at(s, *q, d, reach)? {
free.push(q[d]);
/// Which parameter directions the surface is collapsed along at each node of a face's
/// triangulation, as the kernel's classifier says (within `reach`): a property of the node's
/// parameters, so it is asked once per node however many triangles and rounds look at it.
pub(crate) struct Poles<'a> {
s: &'a EvalSurface,
reach: f64,
/// Per node index; `None` until asked.
flags: Vec<Option<[bool; 2]>>,
/// Calls of the kernel's classifier made, for the tests.
#[cfg(test)]
asked: usize,
}
impl<'a> Poles<'a> {
pub(crate) fn new(s: &'a EvalSurface, reach: f64) -> Self {
Poles {
s,
reach,
flags: Vec::new(),
#[cfg(test)]
asked: 0,
}
}
/// Whether each direction is collapsed at node `i`, whose parameters are `q`.
fn at(&mut self, i: u32, q: [f64; 2]) -> Result<[bool; 2], Unclassified> {
let i = i as usize;
if let Some(Some(f)) = self.flags.get(i) {
return Ok(*f);
}
#[cfg(test)]
{
self.asked += 2;
}
let f = [
super::pole_at(self.s, q, 0, self.reach)?,
super::pole_at(self.s, q, 1, self.reach)?,
];
if self.flags.len() <= i {
self.flags.resize(i + 1, None);
}
if let Some(slot) = self.flags.get_mut(i) {
*slot = Some(f);
}
Ok(f)
}
/// The mean of nodes' parameters, as the surface point between them: at a pole the
/// parameter along the degenerate direction is free (the pole copy carries a neighbour's),
/// so there the mean takes it from the other nodes alone. A node whose poles the kernel's
/// classifier cannot tell is the error.
fn mean(&mut self, uv: &[[f64; 2]], nodes: &[u32]) -> Result<[f64; 2], Unclassified> {
let mut qs: Vec<([f64; 2], [bool; 2])> = Vec::new();
for &i in nodes {
if let Some(&q) = uv.get(i as usize) {
qs.push((q, self.at(i, q)?));
}
}
let pick: Vec<f64> = if free.is_empty() {
qs.iter().map(|q| q[d]).collect()
} else {
free
};
*slot = pick.iter().sum::<f64>() / pick.len().max(1) as f64;
let mut out = [0.0; 2];
// Proven: `d` < 2 enumerates `out` and indexes the `[f64; 2]` parameters and flags.
#[allow(clippy::indexing_slicing)]
for (d, slot) in out.iter_mut().enumerate() {
let free: Vec<f64> = qs
.iter()
.filter(|(_, f)| !f[d])
.map(|(q, _)| q[d])
.collect();
let pick: Vec<f64> = if free.is_empty() {
qs.iter().map(|(q, _)| q[d]).collect()
} else {
free
};
*slot = pick.iter().sum::<f64>() / pick.len().max(1) as f64;
}
Ok(out)
}
Ok(out)
}
/// The farthest the triangles `tris` (as for [`refine`]) lie from the surface, measured as
@@ -229,7 +276,7 @@ fn mean(s: &EvalSurface, reach: f64, qs: &[[f64; 2]]) -> Result<[f64; 2], Unclas
/// classifier cannot tell.
pub(crate) fn worst(
s: &EvalSurface,
reach: f64,
poles: &mut Poles,
fixed: &[Vec3],
uv: &[[f64; 2]],
tris: &[[u32; 3]],
@@ -245,8 +292,8 @@ pub(crate) fn worst(
})
.collect();
// How far the flat point `c` lies from the surface at the mean of `qs`, as `refine`'s `off`.
let off = |qs: &[[f64; 2]], c: Vec3| -> Result<Option<f64>, Unclassified> {
let m = mean(s, reach, qs)?;
let mut off = |nodes: &[u32], c: Vec3| -> Result<Option<f64>, Unclassified> {
let m = poles.mean(uv, nodes)?;
let Some((x, su, sv)) = s.derivs(m[0], m[1]).map(kernel::record_triple) else {
return Ok(None);
};
@@ -259,7 +306,7 @@ pub(crate) fn worst(
let (mut worst, mut unmeasured) = (0.0f64, 0usize);
for (k, t) in tris.iter().enumerate() {
let corner = |i: u32| Some((*uv.get(i as usize)?, *pos.get(i as usize)?));
let (Some((qa, pa)), Some((qb, pb)), Some((qc, pc))) =
let (Some((_, pa)), Some((_, pb)), Some((_, pc))) =
(corner(t[0]), corner(t[1]), corner(t[2]))
else {
return Err(Stop::Corner(k));
@@ -268,16 +315,16 @@ pub(crate) fn worst(
// corner they need).
let mut measured: Vec<Option<f64>> = Vec::new();
measured.push(match (pa, pb, pc) {
(Some(a), Some(b), Some(c)) => off(&[qa, qb, qc], (a + b + c) * (1.0 / 3.0))?,
(Some(a), Some(b), Some(c)) => off(t, (a + b + c) * (1.0 / 3.0))?,
_ => None,
});
for ((qx, px), (qy, py)) in [
((qa, pa), (qb, pb)),
((qb, pb), (qc, pc)),
((qc, pc), (qa, pa)),
for ((nx, px), (ny, py)) in [
((t[0], pa), (t[1], pb)),
((t[1], pb), (t[2], pc)),
((t[2], pc), (t[0], pa)),
] {
measured.push(match (px, py) {
(Some(x), Some(y)) => off(&[qx, qy], (x + y) * 0.5)?,
(Some(x), Some(y)) => off(&[nx, ny], (x + y) * 0.5)?,
_ => None,
});
}
@@ -388,7 +435,7 @@ const MIN_FLIP_ANGLE: f64 = 1e-3;
pub(crate) fn flip(
s: &EvalSurface,
tol: f64,
reach: f64,
poles: &mut Poles,
fixed: &[Vec3],
uv: &[[f64; 2]],
tris: &mut [[u32; 3]],
@@ -396,7 +443,6 @@ pub(crate) fn flip(
if !(tol.is_finite() && tol > 0.0) || tris.len() < 2 {
return Ok(());
}
let mean = |qs: &[[f64; 2]]| mean(s, reach, qs);
let derivs = |q: [f64; 2]| {
s.derivs(q[0], q[1])
.map(kernel::record_triple)
@@ -441,13 +487,11 @@ pub(crate) fn flip(
angle(a, b, c).min(angle(b, c, a)).min(angle(c, a, b))
};
// How far a facet's normal turns from the surface normal at its middle (radians, 0 to pi/2).
let deviation = |t: [u32; 3]| -> Result<f64, Stop> {
let (Some(a), Some(b), Some(c), Some(qa), Some(qb), Some(qc)) =
(p(t[0]), p(t[1]), p(t[2]), q(t[0]), q(t[1]), q(t[2]))
else {
let deviation = |poles: &mut Poles, t: [u32; 3]| -> Result<f64, Stop> {
let (Some(a), Some(b), Some(c)) = (p(t[0]), p(t[1]), p(t[2])) else {
return Ok(0.0);
};
let m = mean(&[qa, qb, qc])?;
let m = poles.mean(uv, &t)?;
let facet = (b - a).cross(c - a).normalized();
let (_, su, sv) = derivs(m)?;
Ok(match (facet, su.cross(sv).normalized()) {
@@ -458,20 +502,16 @@ pub(crate) fn flip(
};
// Whether the facet's normal lies on the side of the surface normal that its (u, v)
// orientation `sign` says.
let upright = |t: [u32; 3], sign: f64| -> Result<bool, Stop> {
let (Some(a), Some(b), Some(c), Some(qa), Some(qb), Some(qc)) =
(p(t[0]), p(t[1]), p(t[2]), q(t[0]), q(t[1]), q(t[2]))
else {
let upright = |poles: &mut Poles, t: [u32; 3], sign: f64| -> Result<bool, Stop> {
let (Some(a), Some(b), Some(c)) = (p(t[0]), p(t[1]), p(t[2])) else {
return Ok(false);
};
let (_, su, sv) = derivs(mean(&[qa, qb, qc])?)?;
let (_, su, sv) = derivs(poles.mean(uv, &t)?)?;
Ok((b - a).cross(c - a).dot(su.cross(sv)) * sign > 0.0)
};
let centroid_off = |t: [u32; 3]| -> Result<f64, Stop> {
match (p(t[0]), p(t[1]), p(t[2]), q(t[0]), q(t[1]), q(t[2])) {
(Some(a), Some(b), Some(c), Some(qa), Some(qb), Some(qc)) => {
Ok(off(mean(&[qa, qb, qc])?, (a + b + c) * (1.0 / 3.0))?)
}
let centroid_off = |poles: &mut Poles, t: [u32; 3]| -> Result<f64, Stop> {
match (p(t[0]), p(t[1]), p(t[2])) {
(Some(a), Some(b), Some(c)) => Ok(off(poles.mean(uv, &t)?, (a + b + c) * (1.0 / 3.0))?),
_ => Ok(f64::INFINITY),
}
};
@@ -523,20 +563,20 @@ pub(crate) fn flip(
// The pair the other way must lie closer along the surface (its facets turn less from
// the surface normal), by more than a degree: a pair within a degree of the surface is
// left as the layout made it.
let mut cached = |ti: u32, t: [u32; 3]| -> Result<f64, Stop> {
let mut cached = |poles: &mut Poles, ti: u32, t: [u32; 3]| -> Result<f64, Stop> {
if let Some(d) = dev_of.get(ti as usize).copied().flatten() {
Ok(d)
} else {
let d = deviation(t)?;
let d = deviation(poles, t)?;
if let Some(slot) = dev_of.get_mut(ti as usize) {
*slot = Some(d);
}
Ok(d)
}
};
let worse = cached(t1, ta)?.max(cached(t2, tb)?);
let worse = cached(poles, t1, ta)?.max(cached(poles, t2, tb)?);
let degree = 1f64.to_radians();
if worse < degree || deviation(n1)?.max(deviation(n2)?) >= worse - degree {
if worse < degree || deviation(poles, n1)?.max(deviation(poles, n2)?) >= worse - degree {
continue;
}
// ... and have area in 3D (three corners on one line of a cone have none, however they
@@ -544,21 +584,19 @@ pub(crate) fn flip(
if min_angle(n1).min(min_angle(n2)) < MIN_FLIP_ANGLE {
continue;
}
if !(upright(n1, sign)? && upright(n2, sign)?) {
if !(upright(poles, n1, sign)? && upright(poles, n2, sign)?) {
continue;
}
let (Some(pa), Some(pb), Some(pc), Some(pd), Some(qa), Some(qb), Some(qc), Some(qd)) =
(p(a), p(b), p(c), p(d), q(a), q(b), q(c), q(d))
else {
let (Some(pa), Some(pb), Some(pc), Some(pd)) = (p(a), p(b), p(c), p(d)) else {
continue;
};
let allowed = tol
.max(off(mean(&[qa, qb])?, (pa + pb) * 0.5)?)
.max(centroid_off(ta)?)
.max(centroid_off(tb)?);
if off(mean(&[qc, qd])?, (pc + pd) * 0.5)? > allowed
|| centroid_off(n1)? > allowed
|| centroid_off(n2)? > allowed
.max(off(poles.mean(uv, &[a, b])?, (pa + pb) * 0.5)?)
.max(centroid_off(poles, ta)?)
.max(centroid_off(poles, tb)?);
if off(poles.mean(uv, &[c, d])?, (pc + pd) * 0.5)? > allowed
|| centroid_off(poles, n1)? > allowed
|| centroid_off(poles, n2)? > allowed
{
continue;
}
@@ -607,7 +645,15 @@ mod tests {
assert_eq!(fixed.len(), 4);
let mut tris = vec![[0, 1, 2], [0, 2, 3]];
assert_eq!(
refine(&s, 0.01, 1e-9, &fixed, &mut uv, &mut tris, 1000),
refine(
&s,
0.01,
&mut Poles::new(&s, 1e-9),
&fixed,
&mut uv,
&mut tris,
1000
),
Err(Stop::NoValue(NoValue([0.5, 0.5])))
);
assert_eq!(tris, vec![[0, 1, 2], [0, 2, 3]]);
@@ -628,11 +674,19 @@ mod tests {
assert_eq!(fixed.len(), 4);
let mut tris = vec![[0, 1, 4], [1, 2, 4], [2, 3, 4], [3, 0, 4]];
assert_eq!(
refine(&s, 0.01, 1e-9, &fixed, &mut uv, &mut tris, 1000),
refine(
&s,
0.01,
&mut Poles::new(&s, 1e-9),
&fixed,
&mut uv,
&mut tris,
1000
),
Err(Stop::NoValue(NoValue([0.5, 0.5])))
);
assert_eq!(
flip(&s, 0.01, 1e-9, &fixed, &uv, &mut tris),
flip(&s, 0.01, &mut Poles::new(&s, 1e-9), &fixed, &uv, &mut tris),
Err(Stop::NoValue(NoValue([0.5, 0.5])))
);
}
@@ -661,7 +715,7 @@ mod tests {
};
let uv = [[0.5, -0.1], [0.5, 0.1], [0.6, 0.0]];
let side = 3.0 * (1.0 - 0.1f64.cos());
let got = worst(&s, 0.0, &[], &uv, &[[0, 1, 2]]);
let got = worst(&s, &mut Poles::new(&s, 0.0), &[], &uv, &[[0, 1, 2]]);
assert!(
got.as_ref()
.is_ok_and(|(w, unmeasured)| (w - side).abs() < 1e-9 * side && *unmeasured == 0),
@@ -669,8 +723,70 @@ mod tests {
);
// A corner past the nodes is the triangle's index.
assert_eq!(
worst(&s, 0.0, &[], &uv, &[[0, 1, 2], [0, 1, 3]]),
worst(
&s,
&mut Poles::new(&s, 0.0),
&[],
&uv,
&[[0, 1, 2], [0, 1, 3]]
),
Err(Stop::Corner(1))
);
}
/// Refining, flipping and measuring a coarse patch of a cylinder (every sag over the
/// tolerance, so the nodes are looked at by many triangles, sides and rounds) asks the
/// kernel's classifier once per node and direction, not once per look.
#[test]
fn the_classifier_is_asked_once_per_node() {
use solidcore::eval::EvalCurve;
let s = EvalSurface::Revolved {
profile: EvalCurve::new(
1,
vec![0.0, 0.0, 1.0, 1.0],
vec![[3.0, 0.0, 0.0, 1.0], [3.0, 0.0, 4.0, 1.0]],
),
origin: solidcore::Vec3::ZERO,
axis: solidcore::Vec3::Z,
transform: [
[1.0, 0.0, 0.0, 0.0],
[0.0, 1.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
],
swap: false,
};
let n = 6u32;
let mut uv: Vec<[f64; 2]> = Vec::new();
for i in 0..=n {
for j in 0..=n {
uv.push([
f64::from(i) / f64::from(n),
3.0 * f64::from(j) / f64::from(n),
]);
}
}
let mut tris: Vec<[u32; 3]> = Vec::new();
for i in 0..n {
for j in 0..n {
let a = i * (n + 1) + j;
let (b, c, d) = (a + 1, a + n + 1, a + n + 2);
tris.push([a, c, d]);
tris.push([a, d, b]);
}
}
let mut poles = Poles::new(&s, 1e-9);
let start = uv.len();
// Refinement is capped (it may or may not run out of room): the count holds either way.
refine(&s, 5e-2, &mut poles, &[], &mut uv, &mut tris, 300).unwrap();
flip(&s, 5e-2, &mut poles, &[], &uv, &mut tris).unwrap();
worst(&s, &mut poles, &[], &uv, &tris).unwrap();
assert!(uv.len() > 2 * start, "{} nodes: not refined", uv.len());
assert!(poles.asked > 0);
assert!(
poles.asked <= 2 * uv.len(),
"{} calls for {} nodes",
poles.asked,
uv.len()
);
}
}
+18 -2
View File
@@ -61,12 +61,28 @@ pub(crate) fn crossing(
if n < 3 {
continue;
}
// Each node is on a pole or not whichever two chords it ends: asked once.
let mut on_pole: Vec<Option<bool>> = vec![None; n];
let mut node_pole = |k: usize, q: [f64; 2]| -> Result<bool, super::Unclassified> {
if let Some(p) = on_pole.get(k).copied().flatten() {
return Ok(p);
}
let p = pole(q)?;
if let Some(slot) = on_pole.get_mut(k) {
*slot = Some(p);
}
Ok(p)
};
for k in 0..n {
// Proven: n = l.uv.len() >= 3, checked above
let (Some(&a), Some(&b)) = (l.uv.get(k), l.uv.get((k + 1) % n)) else {
let next = (k + 1) % n;
let (Some(&a), Some(&b)) = (l.uv.get(k), l.uv.get(next)) else {
continue;
};
if !(a.iter().chain(&b).all(|x| x.is_finite())) || pole(a)? || pole(b)? {
if !(a.iter().chain(&b).all(|x| x.is_finite()))
|| node_pole(k, a)?
|| node_pole(next, b)?
{
continue;
}
// The loop's closing chord of a loop that winds round the surface ends at the copy of
+27 -2
View File
@@ -1345,7 +1345,16 @@ fn sag_refinement_across_an_inflection_ends_without_slivers() {
let fixed: Vec<Vec3> = Vec::new();
let tol = 0.5;
let start = uv.len();
sag::refine(&s, tol, 1e-9, &fixed, &mut uv, &mut tris, 20_000).unwrap();
sag::refine(
&s,
tol,
&mut sag::Poles::new(&s, 1e-9),
&fixed,
&mut uv,
&mut tris,
20_000,
)
.unwrap();
assert!(
uv.len() - start < 1_000,
"{} points added: refinement ran to its budget",
@@ -1400,7 +1409,7 @@ fn triangles_standing_across_a_curved_sheet_are_flipped_along_it() {
.sum()
};
let before = area(&tris);
sag::flip(&s, 0.1, 1e-9, &[], &uv, &mut tris).unwrap();
sag::flip(&s, 0.1, &mut sag::Poles::new(&s, 1e-9), &[], &uv, &mut tris).unwrap();
let after = area(&tris);
let sides: Vec<(u32, u32)> = tris
.iter()
@@ -2138,3 +2147,19 @@ fn an_edge_stopped_at_its_depth_bound_is_named_within_its_tolerance() {
assert!(capped.within && capped.sag <= capped.tol, "{capped:?}");
assert!(capped.excess > 1.0, "{capped:?}");
}
/// Refining a spline face moves no triangle count: the memo of the pole classifier only drops the
/// repeated question (the coil took 1,089,434 / 1,091,452 calls at display / export before it).
#[test]
fn the_spline_coil_keeps_its_triangles() {
let name = "smlib_coil_lower_half";
let (b, size) = (fixture(name), size_of(name));
for (t, want) in [
(MeshTolerance::display_of(size), 30_776),
(MeshTolerance::export_of(size), 30_808),
] {
let (p, _) = plan(&b, &t, size).unwrap();
let (m, _) = p.evaluate();
assert_eq!(m.triangles.len(), want);
}
}
+2
View File
@@ -25,6 +25,7 @@
//! | `FilletBrep::calc_join_section` | `SmFilletVertex::CalcJoinSection` (543d25a) |
//! | `FilletBrep::calc_rail_int_end_surface` | `SmFilletVertex::CalcRailIntEndSurface` (`SmBlendCornerEnd.cpp`, 3e26f76) |
//! | [`mixed`] | the Mixed corner (`ClassifyMixed`, `MakeMixedTopology`, `CalcMixedVertGeom`, `CalcMixedEdgeGeom`, `CreateMixedPatches`; 6fd0411, 6e79de5, e1a2153) |
//! | [`on_face`] | none: a Setback's spring curve kept inside its trimmed face (solidcore#2343 (d)) |
//! | [`setback`] | the Setback (`ClassifySetBack`, `MakeSetBackTopology`, `BuildSetBackFill`, `CreateSetBackPatches`; 61547af, 1506e2f, f1d81f4) |
//! | [`swept`] | the Swept turn (`Classify`'s Swept part, `BuildSweptSurface`, `CreateSweptPatch`) |
//! | [`BlendChamferCorner`], [`chamfer`] | not the fork's: chamfers meeting at a vertex, closed by their planes (WR-716, #2345) |
@@ -69,6 +70,7 @@ pub mod ball;
pub mod chamfer;
pub mod end;
pub mod mixed;
pub mod on_face;
pub mod setback;
pub mod swept;
+2 -1
View File
@@ -1423,7 +1423,8 @@ pub fn calc_mixed_edge_geom(
Point3::new(to.0, to.1, 0.0),
)?;
let side_i = *nth(if turn { &which_t } else { &which }, kk, OP_EDGE)?;
*nth_mut(of2m(&mut side_curves, f), side_i, OP_EDGE)? = Some(iso.clone());
*nth_mut(of2m(&mut side_curves, f).as_mut_slice(), side_i, OP_EDGE)? =
Some(iso.clone());
of2m(&mut state.curves_3d, f).push(Curve::from(iso));
of2m(&mut state.uv_curves, f).push(Curve::from(uv));
of2m(&mut state.orients, f).push(*nth(&orients, kk, OP_EDGE)?);
@@ -0,0 +1,383 @@
//! Whether a Setback's spring curve stays inside its trimmed face: it comes within no gap of
//! any of the face's other edges (solidcore#2343 (d), the owner's ruling B there).
//!
//! Upstream: none. The fork's ruling on smlib#85 part C asked that the spring curve's
//! parameter-plane control triangle `(P^, X^, Q^)` lie inside the trimmed face; that is the
//! convex-hull bound for the curve itself, sound but loose (it refuses setbacks whose spring
//! curve stays on the face). Ruling B on solidcore#2343 checks the curve.
//!
//! | Rust | Upstream |
//! |-------------------|----------|
//! | [`Clearance`] | none |
//! | [`bezier_pieces`] | none |
//! | [`clear_of`] | none |
//!
//! Decided on control points, never at sample points (`docs/definitions-and-certificates.md`
//! §1): a rational Bézier piece with positive weights lies in the convex hull of its
//! control points (Farin, *Curves and Surfaces for CAGD*). Two pieces whose hulls are
//! proved farther apart than the gap and both pieces' rounding (their boxes', or their gap
//! along the line between their centroids: `apart`) come no nearer than the gap. Otherwise the wider of the two is halved (de Casteljau on its
//! homogeneous points) and the halves are tried in turn. A piece halved [`DEPTH`] times is
//! halved no further, and a pair still undecided then, or once [`PAIRS`] pairs have been
//! tried, is [`Clearance::Near`]: an undecided spring curve is not taken as clear. The
//! rounding is Higham's `gamma` of the operations each point has been through, as
//! `blend_setback::containment` bounds its patches.
//!
//! Deviations: no recursion: the pairs are kept on an explicit stack, each piece halved at
//! most [`DEPTH`] times, at most [`PAIRS`] pairs per curve and piece list.
use std::borrow::Cow;
use crate::curve::curve::{Curve, CurveKind};
use crate::error::Result;
use crate::geom::vector3d::sqrt;
use crate::kernel::Kernel;
use crate::math::Vec3;
use crate::nurbs::b_spline_curve::BSplineCurve;
use crate::nurbs::math::{gamma, NOW, NOZ};
const OP: &str = "blend::blend_corner::on_face::clear_of";
/// A homogeneous point `(x w, y w, z w, w)`.
pub type Hom = [f64; 4];
/// How many times a piece is halved at most: a piece of a part's size, tens of millimetres,
/// is then under 1e-10 long, the rounding of the part's coordinates (§4.4 of the
/// definitions doc).
pub const DEPTH: usize = 40;
/// How many pairs of pieces [`clear_of`] tries at most for one curve against one list of
/// pieces. Near a closest approach a few pairs stay at each halving, so a decided case needs
/// a few hundred.
pub const PAIRS: usize = 4096;
/// Whether one curve comes within a gap of another ([`clear_of`]).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Clearance {
/// Proved to stay farther than the gap from the other everywhere.
Clear,
/// Within the gap somewhere, or not shown not to be within [`DEPTH`] and [`PAIRS`].
Near,
}
/// The rational Bézier pieces of `curve` as homogeneous points, one piece per knot span: of
/// the B-spline a line, a conic or a B-spline curve is built on (the whole of it, which holds
/// an edge it is trimmed to; a line's or an ellipse's made on a copy where it holds none
/// yet). `None` for a curve of another kind, a planar (2D) one, one whose B-spline is not
/// made, or one whose weights are not all positive and finite: such a curve has no hull to
/// bound it by.
///
/// # Errors
///
/// Those of the B-spline's decomposition into its Bézier pieces; a cancellation while an
/// ellipse's is made.
pub fn bezier_pieces(k: &Kernel, curve: &Curve) -> Result<Option<Vec<Vec<Hom>>>> {
let base: Cow<'_, BSplineCurve> = match curve.kind() {
CurveKind::BSpline(c) => Cow::Borrowed(c),
CurveKind::Line(c) => c.base_with_nurb()?,
CurveKind::Ellipse(c) if c.base().nurb.is_none() => {
let mut copy = c.clone();
match copy.make_nurb(k) {
Err(e) if e.is_unported_or_cancelled() => return Err(e),
Err(_) => return Ok(None),
Ok(()) => Cow::Owned(copy.base().clone()),
}
}
CurveKind::Ellipse(c) => Cow::Borrowed(c.base()),
CurveKind::Hyperbola(c) => Cow::Borrowed(&c.base),
CurveKind::Parabola(c) => Cow::Borrowed(c.base()),
_ => return Ok(None),
};
let Some(nurb) = base.nurb.as_ref() else {
return Ok(None);
};
let mut pieces = Vec::new();
for bez in nurb.decompose_bez()? {
let mut piece = Vec::new();
for cp in bez.cpts() {
if cp.z == NOZ {
return Ok(None);
}
let h = if cp.w == NOW {
[cp.x, cp.y, cp.z, 1.0]
} else {
[cp.x, cp.y, cp.z, cp.w]
};
if !h.iter().all(|c| c.is_finite()) || h[3] <= 0.0 {
return Ok(None);
}
piece.push(h);
}
pieces.push(piece);
}
Ok(Some(pieces))
}
/// A piece on the stack: its homogeneous points, how many more times it may be halved, and
/// the rounding bound of its Euclidean points (that of the piece it was halved from).
#[derive(Clone)]
struct Piece {
points: Vec<Hom>,
depth: usize,
rounding: f64,
}
impl Piece {
/// A piece as given: its Euclidean points are within `rounding` of the exact ones after
/// [`DEPTH`] halvings of `degree` averaging steps each and the division by the weight:
/// `3 gamma(ops + 2)` of its largest homogeneous coordinate over its least weight (shrunk
/// by the weights' own rounding), `ops = 8 + DEPTH degree` (the 8 for the knot insertions
/// that cut a B-spline into its pieces and the division). `None` where a weight is not
/// positive or a coordinate not finite.
fn new(points: &[Hom]) -> Option<Self> {
let degree = points.len().checked_sub(1)?;
let (mut most, mut w_min) = (0.0f64, f64::INFINITY);
for h in points {
if !h.iter().all(|c| c.is_finite()) {
return None;
}
most = most.max(h[0].abs()).max(h[1].abs()).max(h[2].abs());
w_min = w_min.min(h[3]);
}
let ops = 8 + DEPTH * degree;
let shrink = 1.0 - gamma(ops);
if !(w_min > 0.0 && shrink > 0.0) {
return None;
}
Some(Self {
points: points.to_vec(),
depth: DEPTH,
rounding: 3.0 * gamma(ops + 2) * most / (w_min * shrink),
})
}
/// Its Euclidean points; `None` where a weight is not positive.
fn euclidean(&self) -> Option<Vec<Vec3>> {
let mut out = Vec::new();
for h in &self.points {
if h[3].is_nan() || h[3] <= 0.0 {
return None;
}
out.push(Vec3::new(h[0] / h[3], h[1] / h[3], h[2] / h[3]));
}
Some(out)
}
/// Its two halves at the middle of its parameter (de Casteljau), each halvable once less.
fn halves(&self) -> (Self, Self) {
let d = self.points.len().saturating_sub(1);
let mut work = self.points.clone();
let mut left = Vec::new();
let mut right = Vec::new();
for k in 0..=d {
if let Some(&h) = work.first() {
left.push(h);
}
if let Some(&h) = work.get(d - k) {
right.push(h);
}
for i in 0..d - k {
if let (Some(&a), Some(&b)) = (work.get(i), work.get(i + 1)) {
if let Some(w) = work.get_mut(i) {
*w = [
0.5 * (a[0] + b[0]),
0.5 * (a[1] + b[1]),
0.5 * (a[2] + b[2]),
0.5 * (a[3] + b[3]),
];
}
}
}
}
right.reverse();
let depth = self.depth.saturating_sub(1);
(
Self {
points: left,
depth,
rounding: self.rounding,
},
Self {
points: right,
depth,
rounding: self.rounding,
},
)
}
}
/// The widest extent of `points` along an axis.
fn width(points: &[Vec3]) -> f64 {
let (lo, hi) = extent(points);
(hi.x - lo.x).max(hi.y - lo.y).max(hi.z - lo.z)
}
/// The box of `points`, its least and greatest corners.
fn extent(points: &[Vec3]) -> (Vec3, Vec3) {
let mut lo = Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
let mut hi = Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
for p in points {
lo = Vec3::new(lo.x.min(p.x), lo.y.min(p.y), lo.z.min(p.z));
hi = Vec3::new(hi.x.max(p.x), hi.y.max(p.y), hi.z.max(p.z));
}
(lo, hi)
}
/// A lower bound on the distance between the convex hulls of `a` and `b`, less its own
/// rounding: the larger of the distance between their boxes and their gap along the line
/// between their centroids (any unit direction `u` separates them by `min u . b - max u . a`;
/// that one closes on the hulls' distance as near-parallel pieces shrink, where the boxes'
/// does not). The boxes' gaps are rounded by `2 gamma(1)` of the largest coordinate `s`, their
/// sum of squares by `gamma(6)`; each projection by `gamma(3) sqrt 3 s |u|`, and the quotient
/// by `|u|` by `gamma(4)`.
fn apart(a: &[Vec3], b: &[Vec3]) -> f64 {
let ((alo, ahi), (blo, bhi)) = (extent(a), extent(b));
let size = [alo, ahi, blo, bhi].iter().fold(0.0f64, |m, c| {
m.max(c.x.abs()).max(c.y.abs()).max(c.z.abs())
});
let gap = |l: f64, h: f64| l - h;
let mut squares = 0.0;
for d in [
gap(blo.x, ahi.x).max(gap(alo.x, bhi.x)),
gap(blo.y, ahi.y).max(gap(alo.y, bhi.y)),
gap(blo.z, ahi.z).max(gap(alo.z, bhi.z)),
] {
let d = (d - 2.0 * gamma(1) * size).max(0.0);
squares += d * d;
}
let boxes = sqrt(squares * (1.0 - gamma(6))) * (1.0 - gamma(2));
let centroid = |p: &[Vec3]| {
let (sum, count) = p
.iter()
.fold((Vec3::ZERO, 0.0), |(s, c), &q| (s + q, c + 1.0));
sum * (1.0 / count)
};
let w = centroid(b) - centroid(a);
// the line between the centroids, and its parts square to each piece's chord (for
// near-parallel pieces side by side, whatever their shift along each other)
let square_to = |p: &[Vec3]| match (p.first(), p.last()) {
(Some(&first), Some(&last)) => {
let t = last - first;
let tt = t.dot(t);
if tt > 0.0 {
w - t * (w.dot(t) / tt)
} else {
w
}
}
_ => w,
};
let mut best = boxes;
for u in [w, square_to(a), square_to(b)] {
let length = u.length();
if length.is_nan() || length <= 0.0 {
continue;
}
let reach_a = a.iter().fold(f64::NEG_INFINITY, |m, p| m.max(u.dot(*p)));
let reach_b = b.iter().fold(f64::INFINITY, |m, p| m.min(u.dot(*p)));
let along = (reach_b - reach_a) / length * (1.0 - gamma(4))
- 2.0 * gamma(3) * sqrt(3.0) * size * (1.0 + gamma(4));
best = best.max(along);
}
best
}
/// Whether the rational Bézier curve `curve` (its homogeneous Bernstein coefficients) comes
/// within `gap` of any of `pieces` (each such a curve too): [`Clearance::Clear`] only where
/// that is proved on the pieces' control points (module docs).
///
/// # Errors
///
/// The kernel's cancellation.
pub fn clear_of(k: &Kernel, curve: &[Hom], pieces: &[Vec<Hom>], gap: f64) -> Result<Clearance> {
let Some(a) = Piece::new(curve) else {
return Ok(Clearance::Near);
};
let mut stack = Vec::new();
for p in pieces {
let Some(b) = Piece::new(p) else {
return Ok(Clearance::Near);
};
stack.push((a.clone(), b));
}
let mut tried = 0;
while let Some((a, b)) = stack.pop() {
k.check_cancelled(OP)?;
tried += 1;
if tried > PAIRS {
return Ok(Clearance::Near);
}
let (Some(pa), Some(pb)) = (a.euclidean(), b.euclidean()) else {
return Ok(Clearance::Near);
};
if apart(&pa, &pb) > gap + a.rounding + b.rounding {
continue;
}
let (wa, wb) = (width(&pa), width(&pb));
// the wider is halved, where it may be; neither: undecided
let halve_a = a.depth > 0 && (wa >= wb || b.depth == 0);
if halve_a {
let (a0, a1) = a.halves();
stack.push((a1, b.clone()));
stack.push((a0, b));
} else if b.depth > 0 {
let (b0, b1) = b.halves();
stack.push((a.clone(), b1));
stack.push((a, b0));
} else {
return Ok(Clearance::Near);
}
}
Ok(Clearance::Clear)
}
#[cfg(test)]
mod tests {
use super::*;
/// A quarter circle of radius `r` about `(cx, cy)` in z = 0, from angle 0 to 90 degrees:
/// the rational quadratic with middle weight `cos 45`.
fn quarter(cx: f64, cy: f64, r: f64) -> Vec<Hom> {
let w = 0.5f64.sqrt();
vec![
[cx + r, cy, 0.0, 1.0],
[(cx + r) * w, (cy + r) * w, 0.0, w],
[cx, cy + r, 0.0, 1.0],
]
}
/// A segment from `a` to `b`, degree 1.
fn segment(a: [f64; 3], b: [f64; 3]) -> Vec<Hom> {
vec![[a[0], a[1], a[2], 1.0], [b[0], b[1], b[2], 1.0]]
}
#[test]
fn a_segment_near_a_circle_is_clear_by_more_than_the_gap_and_near_within_it() {
let k = Kernel::default();
let arc = vec![quarter(0.0, 0.0, 10.0)];
// the line x + y = c outside the circle is closest to the arc at 45 degrees, c / sqrt 2
// - 10 away, and runs along it within twice that for a hundredth of a turn
let c = 2.0f64.sqrt() * (10.0 + 1e-3);
let chord = segment([c, 0.0, 0.0], [0.0, c, 0.0]);
assert_eq!(
clear_of(&k, &chord, &arc, 0.9e-3).unwrap(),
Clearance::Clear
);
assert_eq!(clear_of(&k, &chord, &arc, 1.1e-3).unwrap(), Clearance::Near);
// inside it, crossing it twice: near at any gap
let c = 2.0f64.sqrt() * (10.0 - 1e-3);
let chord = segment([c, 0.0, 0.0], [0.0, c, 0.0]);
assert_eq!(clear_of(&k, &chord, &arc, 0.0).unwrap(), Clearance::Near);
}
#[test]
fn pieces_far_apart_are_clear_at_once_and_a_bad_weight_is_near() {
let k = Kernel::default();
let far = vec![quarter(100.0, 0.0, 1.0)];
let chord = segment([0.0, 0.0, 0.0], [1.0, 1.0, 0.0]);
assert_eq!(clear_of(&k, &chord, &far, 1e-6).unwrap(), Clearance::Clear);
let mut bad = quarter(100.0, 0.0, 1.0);
bad[1][3] = 0.0;
assert_eq!(clear_of(&k, &chord, &[bad], 1e-6).unwrap(), Clearance::Near);
}
}
+333 -2
View File
@@ -44,6 +44,7 @@
//! | `curved_least` (private) | `sm_SbCurvedLeast` (37c9e1d) |
//! | `gather_ends` (private) | `BuildSetBackFill`'s `gather` |
//! | `grow_curved` (private) | `BuildSetBackFill`'s growth beside curved faces (37c9e1d) |
//! | `springs_on_faces`, `springs_refusal` (private) | none (solidcore#2343 (d)) |
//!
//! Deviations: the Setback's own members are kept in [`SetbackCorner`] on the corner
//! ([`FilletCorner::setback`]) from its creation (`SetFillOrder`) until its patches are
@@ -60,19 +61,26 @@
//! the vertex there, from the edge's direction at the setback, not the end of its rails
//! nearer the vertex as the fork's `gather` takes it: a round on an arc, extended past a far
//! corner that does not blend, comes back round nearer the vertex than a long setback's end
//! section (solidcore#2835).
//! section (solidcore#2835). Beside a curved face each spring curve is checked to stay inside
//! its trimmed face before the fill is made, and again at setbacks the growth finds; a
//! setback that takes one off its face is refused, naming the largest that keeps every one
//! on its face, or none where none down to the rails' crossing does or the growth's
//! setbacks take one off (solidcore#2343 (d); the fork's ruling checked the curve's parameter-plane
//! control triangle instead, and built no check).
//!
//! [`FilletCorner::setback`]: crate::blend::fillet_corner::p3::FilletCorner::setback
use std::sync::Arc;
use crate::blend::blend_corner::ball::{solver_of_edgeuse_or_mate, BallSolvers};
use crate::blend::blend_corner::on_face::{self, Clearance};
use crate::blend::blend_corner::swept::fmt_g;
use crate::blend::blend_corner::{BlendCorner, BlendCornerEvent};
use crate::blend::blend_refusal::BlendRefusal;
use crate::blend::blend_setback::{
bisect_thousandths, certified_scale, containment, distances, fill_hole_counted,
fill_hole_probe, Edge, Face, FaceKind, Fill, FillStats, Probe, Round,
fill_hole_probe, spring_curve, Edge, Face, FaceKind, Fill, FillStats, Probe, Round,
SpringCurve,
};
use crate::blend::fillet_corner::p1::{face_by_two_edgeuses, test_g1_continuity};
use crate::blend::fillet_corner::p3::{CornerPatchEnv, FilletCorner, FilletCornerKind};
@@ -92,6 +100,7 @@ use crate::error::{EntityRef, Result, SolidError};
use crate::geom::extent1d::Extent1d;
use crate::geom::extent2d::Extent2d;
use crate::geom::global_solver::SolverOperation;
use crate::geom::vector3d::sqrt;
use crate::intersect::point_class::PointClassType;
use crate::kernel::Kernel;
use crate::math::{Point2, Point3, Vec3, BIG_DOUBLE, EFF_ZERO, EFF_ZERO_SQRT};
@@ -105,6 +114,7 @@ use crate::surface::surface::Surface;
use crate::topology::brep::p7::{make_face_with_curves, FaceCurvesInput};
use crate::topology::edgeuse::p1 as eu1;
use crate::topology::face::p2 as face2;
use crate::topology::face::p4 as face4;
use crate::topology::{edge, vertex, Brep, EdgeId, EdgeuseId, FaceId, VertexId};
const OP_CLASSIFY: &str = "blend::blend_corner::setback::classify_setback";
@@ -129,6 +139,10 @@ pub struct SetbackCorner {
/// curved face the length is the edge's arc length, as the setbacks are measured along
/// it; between planes (straight edges) the distance between its ends.
pub room: Vec<f64>,
/// Not the fork (solidcore#2343 (d)): each blended edge's least setback, where the
/// rails of the rounds beside it cross (`sm_SbSetBacks`'s `rvLeast`); a setback below it
/// is too short.
pub least: Vec<f64>,
/// `m_pFill`: the fill, from the edge geometry until the patches take it.
pub fill: Option<Fill>,
/// `m_bSetBackOpen` (the fork's ef57570, smlib#125): an open G1 corner, its spring
@@ -149,6 +163,7 @@ impl Default for SetbackCorner {
convex: true,
overflows: false,
room: Vec::new(),
least: Vec::new(),
fill: None,
open: false,
definition: None,
@@ -1234,6 +1249,7 @@ pub fn make_setback_topology(
}
sb.overflows = !fits;
sb.room.clone_from(&room);
sb.least.clone_from(&least);
// a G1 fill is taken only certified convex: the least scale of the setbacks at which it
// is (CertifiedScale). The rule's grow to it, each held at its room less the rule's
// quarter radius (the others growing on); refused, naming them, where none within the
@@ -1783,6 +1799,27 @@ fn grow_curved<S: SetbackSolvers>(
let Some(ends) = growth.place(pseudo, corner, solvers, hi)? else {
return Err(err());
};
// the setbacks the fill needs keep every spring curve on its face, or none is taken
// (#2343 (d)): the growth finds no smaller one certified, and a spring curve off its face
// at these is taken to stay off it at every larger one (as the growth takes
// certification, checked on the cases, not proved)
let springs = springs_on_faces(
growth.k,
growth.target,
pseudo,
corner,
growth.planes,
growth.tol,
)?;
if springs == Springs::Leave {
return Ok(Err(SetbackRefusal::no_room(
format!(
"Fillet Error: the setback corner's fill stays no more concave than its faces only from {} times its setbacks, where a spring curve is not shown to stay on its face: no setback builds",
fmt_g(hi)
),
Some(0.0),
)));
}
if let Some(a) = asked {
let room = &corner.setback.as_ref().ok_or_else(err)?.room;
let fits_room = room.iter().all(|&r| hi * a.setback + a.ease < r);
@@ -1805,6 +1842,292 @@ fn grow_curved<S: SetbackSolvers>(
Ok(Ok(ends))
}
/// Where a Setback's spring curves stand on its faces ([`springs_on_faces`]).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Springs {
/// Each proved to keep clear of every other edge of its face.
Clear,
/// One comes within its gap of another edge of its face, or is not shown not to.
Leave,
/// One is not made: [`spring_curve`] refuses the rails' ends (the fill, which makes it
/// from the same ends, says why).
Unmade,
}
/// The derivative along `v` of `face`'s unit normal field ([`Face::distance`]'s normal, out
/// of the material, at any point) at `x`: zero on a plane; a sphere's `side (v - (v . u) u) /
/// |x - c|` (u the unit direction from its centre); a cylinder's or cone's `side cos v_t /
/// rho`, `v_t` the part of `v` along the circle about the axis through `x`, rho that circle's
/// radius and `cos` that of the cone's half angle (1 on a cylinder). `None` on a quadric's
/// centre or axis.
fn normal_turn(face: &Face, x: Point3, v: Vec3) -> Option<Vec3> {
let u = x - face.point;
match face.kind {
FaceKind::Plane => Some(Vec3::ZERO),
FaceKind::Sphere => {
let len = u.length();
let d = u.unitize().ok()?;
Some((v - d * v.dot(d)) * (face.side / len))
}
FaceKind::Cylinder | FaceKind::Cone => {
let r = u - face.axis * u.dot(face.axis);
let rho = r.length();
let d = r.unitize().ok()?;
let along = v - d * v.dot(d) - face.axis * v.dot(face.axis);
let cos = 1.0 / sqrt(1.0 + face.slope * face.slope);
Some(along * (face.side * cos / rho))
}
}
}
/// The unit direction of the rail on `face` of the round of radius `r` along `edge_id`, at
/// its end `p` on `face` whose other end is `q` on `other` (the round's two contact points in
/// one section), turned toward the corner's vertex (`sign` `+1` at a convex corner, `-1` at a
/// concave one). In closed form, not from the rail as traced, which runs only from the
/// setback the corner was made at outward: the ball's centre is `c = p - sign r n(p)` and
/// runs along `n(p) x m(q)` (n, m the faces' normals, constant along their normal lines), the
/// rail is `c + sign r n(c)`, so its direction is that of the centre's plus `sign r` times the
/// normal's turn along it ([`normal_turn`]). Toward the vertex: the side the edge runs on to
/// the vertex at its setback (as `gather_ends` takes the corner's side of an end section).
/// `None` where the faces' normals are parallel there, or a normal's turn is not defined.
#[allow(clippy::too_many_arguments)]
fn rail_toward_vertex(
k: &Kernel,
target: &Brep,
corner: &FilletCorner,
edge_id: EdgeId,
face: &Face,
p: Point3,
other: &Face,
q: Point3,
r: f64,
sign: f64,
) -> Result<Option<Vec3>> {
let nul = || SolidError::null_pointer(OP_FILL);
let vtx = corner.vertex.ok_or_else(nul)?;
let (_, n) = face.distance(p);
let (_, m) = other.distance(q);
let centre = p - n * (sign * r);
let along = n.cross(m);
let Some(turn) = normal_turn(face, centre, along) else {
return Ok(None);
};
let Ok(t) = (along + turn * (sign * r)).unitize() else {
return Ok(None);
};
let setback = corner.set_back_dist_along(target, edge_id)?;
let (_, edge_dir) = set_back_frame(k, target, edge_id, Some(vtx), setback, OP_FILL)?;
let from_start = edge::start_vertex(target, edge_id)? == Some(vtx);
let side = t.dot(if from_start { -edge_dir } else { edge_dir });
if !side.is_finite() || side.abs() <= 0.0 {
return Ok(None);
}
Ok(Some(if side > 0.0 { t } else { -t }))
}
/// The corner's spring curves, one per face, at the setbacks its vertices are placed at, as
/// the fill makes them ([`spring_curve`]): on face `F_i`, from `S_i`, the end of the blend
/// after `F_i`, leaving its rail toward the vertex, to `M_i`, the end of the blend before,
/// tangent to both rails ([`rail_toward_vertex`], each with the blend's other end, on its
/// other face, the mate of its end on `F_i`). [`Springs::Leave`] where a rail's direction is
/// not found (not shown to stay on its face); [`Springs::Unmade`] where [`spring_curve`]
/// refuses the rails' ends.
fn corner_springs(
k: &Kernel,
target: &Brep,
pseudo: &FilletBrep,
corner: &FilletCorner,
planes: &CornerPlanes,
) -> Result<core::result::Result<Vec<SpringCurve>, Springs>> {
let nul = || SolidError::null_pointer(OP_FILL);
let all = corner.all_eus.as_ref().ok_or_else(nul)?;
let sign = if corner.setback.as_ref().ok_or_else(nul)?.convex {
1.0
} else {
-1.0
};
let n = planes.faces.len();
let point = |i: usize| -> Result<Point3> {
pseudo.vertex_point(*corner.vertices.get(i).ok_or_else(nul)?)
};
let mut springs = Vec::new();
for (f, plane) in planes.planes.iter().enumerate() {
// (no face: no corner, refused by the checked remainders)
let before = (f + n - 1).checked_rem(n).ok_or_else(nul)?;
let after = (f + 1).checked_rem(n).ok_or_else(nul)?;
let mut ends = [(Point3::ZERO, None); 2];
// S_f with its mate M_(f+1) on F_(f+1); M_f with its mate S_(f-1) on F_(f-1)
for (slot, (blend, at, mate, other)) in ends.iter_mut().zip([
(f, 3 * f + 2, 3 * after, after),
(before, 3 * f, 3 * before + 2, before),
]) {
let e = eu1::edge(target, *all.get(blend).ok_or_else(nul)?)?.ok_or_else(nul)?;
let p = point(at)?;
let tangent = rail_toward_vertex(
k,
target,
corner,
e,
plane,
p,
planes.planes.get(other).ok_or_else(nul)?,
point(mate)?,
planes.edges.get(blend).ok_or_else(nul)?.radius,
sign,
)?;
*slot = (p, tangent);
}
let [(p, Some(tp)), (q, Some(tq))] = ends else {
return Ok(Err(Springs::Leave));
};
let Ok(spring) = spring_curve(plane, p, tp, q, -tq) else {
return Ok(Err(Springs::Unmade));
};
springs.push(spring);
}
Ok(Ok(springs))
}
/// solidcore#2343 (d), the owner's ruling B there: whether the corner's spring curves
/// ([`corner_springs`]) stay inside their trimmed faces. Each starts inside its face, so it
/// stays inside where it keeps clear of every edge of the face but the two blended edges at
/// the vertex ([`on_face::clear_of`]: by more than the corner's tolerance and the edge's). The
/// two blended edges are left out whole: between them the spring curve lies past the rails of
/// their rounds, which run inside the face along them. An edge without a curve, or whose
/// curve is not built on a B-spline, gives no hull to bound it by: the spring curve is not
/// shown clear of it.
fn springs_on_faces(
k: &Kernel,
target: &Brep,
pseudo: &FilletBrep,
corner: &FilletCorner,
planes: &CornerPlanes,
tol: f64,
) -> Result<Springs> {
let nul = || SolidError::null_pointer(OP_FILL);
let vtx = corner.vertex.ok_or_else(nul)?;
let springs = match corner_springs(k, target, pseudo, corner, planes)? {
Ok(springs) => springs,
Err(verdict) => return Ok(verdict),
};
for (&face, spring) in planes.faces.iter().zip(&springs) {
let mut seen = Vec::new();
for e in face4::edges(target, face, None, false, None)? {
if seen.contains(&e) {
continue;
}
seen.push(e);
if edge::start_vertex(target, e)? == Some(vtx)
|| edge::end_vertex(target, e)? == Some(vtx)
{
continue;
}
let Some(curve) = edge::curve(target, e)? else {
return Ok(Springs::Leave);
};
let Some(pieces) = on_face::bezier_pieces(k, curve)? else {
return Ok(Springs::Leave);
};
let gap = tol + edge::tolerance(target, e)?;
for half in [&spring.from_p.curve, &spring.from_q.curve] {
if on_face::clear_of(k, half, &pieces, gap)? == Clearance::Near {
return Ok(Springs::Leave);
}
}
}
}
Ok(Springs::Clear)
}
/// What a setback that takes a spring curve off its face does, for [`setback_too_long`].
const SPRING_LEAVES: &str =
"takes a setback corner's spring curve off its face (it comes within the tolerances of another edge of the face, or is not shown not to)";
/// solidcore#2343 (d): where a spring curve leaves its face at the corner's setbacks
/// ([`springs_on_faces`]), the refusal naming the largest common scale of them on the grid of
/// thousandths at which every spring curve stays inside its face. It is found by the growth's
/// bisection ([`bisect_thousandths`], each trial placing the setback vertices again as the
/// growth does), between 1 and the least scale at which every setback reaches its edge's
/// least, where the rails cross; the search assumes a spring curve on its face at a scale
/// stays on it at every smaller one (the curve shrinks toward the vertex with its setbacks),
/// as the growth assumes certification monotone: checked on the cases, not proved. One asked
/// for is refused as [`BlendRefusal::SetbackTooLong`] naming that scale times it; the
/// rule's, naming those setbacks. None at or above the least: no setback builds. A trial
/// whose vertices are not placed counts as off its face, so the scale named is at worst
/// smaller than need be, never one off its face; whether the fill certifies there is the
/// fill's to say when that setback is asked. `None` where every spring curve stays on its
/// face, or one is not made (the fill says why).
#[allow(clippy::too_many_arguments)]
fn springs_refusal<S: SetbackSolvers>(
k: &Kernel,
target: &Brep,
pseudo: &mut FilletBrep,
corner: &mut FilletCorner,
solvers: &mut S,
planes: &CornerPlanes,
fills: &[SetBackFill],
tol: f64,
) -> Result<Option<SetbackRefusal>> {
let err = || SolidError::sm_err(OP_FILL);
let at_base = springs_on_faces(k, target, pseudo, corner, planes, tol)?;
if at_base != Springs::Leave {
return Ok(None);
}
let vtx = corner.vertex.ok_or_else(err)?;
let asked = set_back_fill_of(fills, vtx);
let sb = corner.setback.as_ref().ok_or_else(err)?;
let base = sb.setbacks.clone();
if sb.least.len() != base.len() {
return Err(err());
}
let mut least_scale: f64 = 0.0;
for (&l, &s) in sb.least.iter().zip(&base) {
least_scale = smax(least_scale, l / s);
}
let growth = Growth {
k,
target,
planes,
tol,
most: vec![BIG_DOUBLE; base.len()],
base,
};
let mut leaves = |i: i32| -> Result<bool> {
Ok(
match growth.place(pseudo, corner, solvers, f64::from(i) / 1000.0)? {
Some(_) => {
springs_on_faces(k, target, pseudo, corner, planes, tol)? != Springs::Clear
}
None => true,
},
)
};
let lo = (1..1000).find(|&i| f64::from(i) >= 1000.0 * least_scale);
let fit = match lo {
Some(lo) if !leaves(lo)? => Some(bisect_thousandths(lo, 1000, &mut leaves)? - 1),
_ => None,
};
let Some(fit) = fit.map(|i| f64::from(i) / 1000.0) else {
return Ok(Some(SetbackRefusal::no_room(
"Fillet Error: no setback at or above where the rails cross keeps the setback corner's spring curves on their faces",
Some(0.0),
)));
};
if let Some(a) = asked {
return Ok(Some(setback_too_long(
a,
vtx,
fit * a.setback,
SPRING_LEAVES,
)));
}
let list: Vec<String> = growth.base.iter().map(|&s| fmt_g(fit * s)).collect();
Ok(Some(SetbackRefusal::not_built(format!(
"Fillet Error: the rule's setbacks take a setback corner's spring curve off its face (it comes within the tolerances of another edge of the face, or is not shown not to): setbacks of {} keep every spring curve on its face",
list.join(", ")
))))
}
/// A setback asked at `vtx` that, with its ease, does not fit (`why`): refused, naming
/// `largest`, below which one fits ([`BlendRefusal::SetbackTooLong`]).
fn setback_too_long(a: SetBackFill, vtx: VertexId, largest: f64, why: &str) -> SetbackRefusal {
@@ -1907,6 +2230,14 @@ pub fn build_setback_fill<S: SetbackSolvers>(
if open && order == 1 {
return open_setback_springs(pseudo, corner, &planes.planes, &planes.edges);
}
// beside a curved face, the spring curves stay inside their trimmed faces (#2343 (d))
if curved {
if let Some(refusal) =
springs_refusal(k, target, pseudo, corner, solvers, &planes, fills, tol)?
{
return Ok(Err(refusal));
}
}
let mut stats = FillStats::default();
let first = fill_hole_counted(
k,
+9 -4
View File
@@ -1229,10 +1229,15 @@ pub fn check_nx1_degeneracy_status(
| SolidError::Cancelled { .. }
| SolidError::NotFound { .. }),
) => Err(e),
Err(
e @ (SolidError::InvalidInput { reason, .. }
| SolidError::PortInvariant { reason, .. }),
) if reason == NULL_DEREF => Err(e),
Err(e)
if matches!(
&e,
SolidError::InvalidInput { reason, .. }
| SolidError::PortInvariant { reason, .. } if *reason == NULL_DEREF
) =>
{
Err(e)
}
Err(e) => Ok(Err(e)),
}
};
+49 -58
View File
@@ -2521,46 +2521,11 @@ fn keep_definition(
let Some(surface) = pseudo.geom_mut(g)?.fillet_surface_mut(None)? else {
return Err(SolidError::null_pointer(OP));
};
keep_round_if_proved(k, surface, round, inputs.tol)?;
round.recertify(k, surface)?;
surface.set_exact_round(Some(round));
Ok(())
}
/// `round` kept on `net` where it is proved ([`ExactRound::recertify`]), returning whether it
/// was. The class is read within `tol`, the body's tolerance, and the certificate to rounding.
/// Supports only that close to the class (a cylinder's axis 1e-13 off square to a plane, as
/// read from a record) make a net that is the round but not provably on the class's surface:
/// no bound of 0, so it keeps no definition. A net further off the
/// class than `tol` ([`ExactRound::miss`]) is built wrongly, and stays an error.
///
/// # Errors
///
/// The certificate's refusal of a net more than `tol` off the class, or of supports that
/// give the class no surface; those of the certificate and of [`ExactRound::miss`].
pub fn keep_round_if_proved(
k: &Kernel,
net: &mut Surface,
round: ExactRound,
tol: f64,
) -> Result<bool> {
match round.recertify(k, net) {
Ok(_) => {
net.set_exact_round(Some(round));
Ok(true)
}
Err(e) => {
let off = matches!(
&e,
SolidError::InvalidInput { operation, .. }
if *operation == crate::surface::implicit::OP_EXACT
);
match off.then(|| round.miss(k, net)).transpose()?.flatten() {
Some(miss) if miss <= tol => Ok(false),
_ => Err(e),
}
}
}
}
/// [`calc_exact_fillet_geom`]'s build by the class (`CalcExactFilletGeom`), without the
/// definition kept.
#[allow(clippy::too_many_arguments)]
@@ -2719,7 +2684,8 @@ fn exact_fillet_geom_of_class(
mod tests {
use super::*;
/// A box's edge rounded 2: the round's net, without its definition, and the definition.
/// A cylinder R 10, 20 high, its top rim rounded 2 (a round of the circle class): the
/// round's net, without its definition, and the definition.
fn kept_round() -> (Kernel, Surface, ExactRound) {
use crate::blend::fillet_executive::session::FilletSession;
use crate::kernel::UpstreamFixes;
@@ -2729,24 +2695,51 @@ mod tests {
fixes: UpstreamFixes::all(),
..Kernel::default()
};
let mut b = crate::primitive::api_primitives::create_box(
let mut b = crate::primitive::api_primitives::create_cylinder(
&mut k,
Point3::new(0.0, 0.0, 0.0),
30.0,
20.0,
10.0,
20.0,
)
.unwrap();
let edge = b.edge_list()[0];
// the top rim: the edge through (10, 0, 20)
let rim = *b
.edge_list()
.iter()
.min_by(|&&x, &&y| {
let d = |e| {
crate::query::api_queries::edge_closest_point(
&k,
&b,
e,
Point3::new(10.0, 0.0, 20.0),
)
.unwrap()
.closest
.distance
+ crate::query::api_queries::edge_closest_point(
&k,
&b,
e,
Point3::new(-10.0, 0.0, 20.0),
)
.unwrap()
.closest
.distance
};
d(x).total_cmp(&d(y))
})
.unwrap();
{
let mut s = FilletSession::new(&mut k, &mut b).unwrap();
s.prepare_constant_radius_edges(&mut k, &[edge], 2.0, 1, 1, 1.0)
s.prepare_constant_radius_edges(&mut k, &[rim], 2.0, 1, 1, 1.0)
.unwrap();
s.do_fillet(&mut k).unwrap();
}
for &f in b.face_list() {
let s = face::surface(&b, f).unwrap().unwrap();
if let Some(BlendRef::Exact { round, .. }) = s.procedural_blend() {
assert_eq!(round.class, ExactClass::Circle);
let round = round.clone();
let mut net = s.clone();
net.set_exact_round(None);
@@ -2769,23 +2762,21 @@ mod tests {
net
}
/// The class is read within 2e-5 here. A net on it keeps its definition; one off it by
/// less keeps none (the round of supports that close to the class); one further off is
/// built wrongly, and stays an error.
/// A circle-class round's definition is the torus its net is on, a stated distance from
/// the supports' class torus within the tolerance the class was read at (2e-5 here): the
/// net as built is at 0, the net moved by (1, 1, 1) 1e-7 is at sqrt 3 1e-7, and the net
/// moved by (1, 1, 1) 1e-3 is refused.
#[test]
fn a_net_off_its_class_keeps_no_definition_within_the_tolerance_only() {
let tol = 2e-5;
fn a_circle_rounds_net_is_certified_on_its_own_torus_near_the_class() {
let (k, net, round) = kept_round();
let mut on = net.clone();
assert!(keep_round_if_proved(&k, &mut on, round.clone(), tol).unwrap());
assert!(on.procedural_blend().is_some());
let mut near = moved(&k, &net, 1e-7);
assert!(!keep_round_if_proved(&k, &mut near, round.clone(), tol).unwrap());
assert!(near.procedural_blend().is_none());
let mut far = moved(&k, &net, 1e-3);
let refused = keep_round_if_proved(&k, &mut far, round, tol);
assert_eq!(round.recertify(&k, &net).unwrap(), 0.0);
assert!(round.supports_distance(&k, &net).unwrap() < 1e-12);
let near = moved(&k, &net, 1e-7);
assert_eq!(round.recertify(&k, &near).unwrap(), 0.0);
let d = round.supports_distance(&k, &near).unwrap();
assert!((d - 3.0_f64.sqrt() * 1e-7).abs() < 1e-12, "{d}");
let far = moved(&k, &net, 1e-3);
let refused = round.recertify(&k, &far);
assert!(
matches!(refused, Err(SolidError::InvalidInput { .. })),
"{refused:?}"
@@ -1304,7 +1304,7 @@ fn rational_spine_parts(
#[cfg(test)]
mod tests {
use super::*;
use crate::blend::fillet_exact::{classify_supports, keep_round_if_proved, ClassInputs};
use crate::blend::fillet_exact::{classify_supports, ClassInputs};
use crate::curve::curve::CurveKind;
use crate::geom::axis2_placement::Axis2Placement;
use crate::surface::cone::Cone;
@@ -1779,10 +1779,10 @@ mod tests {
inputs,
)
.unwrap();
assert!(
keep_round_if_proved(&k, &mut net, round, inputs.tol).unwrap(),
"{offsets:?}, {contact:?}"
);
if let Err(e) = round.recertify(&k, &net) {
panic!("{offsets:?}, {contact:?}: {e}");
}
net.set_exact_round(Some(round));
assert_eq!(
net.procedural_blend().unwrap().exact().unwrap().bound(),
0.0
@@ -308,6 +308,61 @@ pub fn nurbs_partials_over_piece(
va: f64,
vb: f64,
lk: usize,
) -> Result<Partials<Ivl>> {
partials_over_piece(cn, ku, kv, ua, ub, va, vb, lk, None)
}
/// [`nurbs_partials_over_piece`] for a box inside the piece's closed knot span (not the
/// fork's): there the weight is a convex combination of the piece's control weights, so it
/// lies between their least and greatest, and its enclosure is cut to that range before
/// the quotient. Where the Taylor enclosure of the weight alone holds 0 (a rational piece of
/// high degree over a large box) this proves it positive. A box past the span is enclosed
/// as [`nurbs_partials_over_piece`] encloses it.
///
/// # Errors
///
/// As [`nurbs_partials_over_piece`].
#[allow(clippy::too_many_arguments)]
pub fn nurbs_partials_within_span(
cn: &Nurbs,
ku: usize,
kv: usize,
ua: f64,
ub: f64,
va: f64,
vb: f64,
lk: usize,
) -> Result<Partials<Ivl>> {
let inside = |u: &[f64], k: usize, a: f64, b: f64| {
knot(u, k as isize) <= a && b <= knot(u, k as isize + 1)
};
let hull = if inside(&cn.u, ku, ua, ub) && inside(&cn.v, kv, va, vb) {
let mut h: Option<Ivl> = None;
for i in (ku + 1).saturating_sub(cn.p + 1)..=ku {
for j in (kv + 1).saturating_sub(cn.q + 1)..=kv {
let w = Ivl::from(pw_at(&cn.pw, 4 * (i * cn.nv + j) + 3));
h = Some(h.map_or(w, |x| x.hull(w)));
}
}
h
} else {
None
};
partials_over_piece(cn, ku, kv, ua, ub, va, vb, lk, hull)
}
/// [`nurbs_partials_over_piece`], the weight's enclosure cut to `weights` where given.
#[allow(clippy::too_many_arguments)]
fn partials_over_piece(
cn: &Nurbs,
ku: usize,
kv: usize,
ua: f64,
ub: f64,
va: f64,
vb: f64,
lk: usize,
weights: Option<Ivl>,
) -> Result<Partials<Ivl>> {
let um = 0.5 * (ua + ub);
let vm = 0.5 * (va + vb);
@@ -377,6 +432,10 @@ pub fn nurbs_partials_over_piece(
}
}
let w00 = at2(&wv, 0, 0, IZ);
let w00 = match weights {
Some(h) => Ivl::new(w00.lo.max(h.lo), w00.hi.min(h.hi)),
None => w00,
};
let positive = w00.lo > 0.0;
if !positive {
// "SmGnSectionFSG: a face's weights are not positive over a box"
@@ -2160,6 +2219,23 @@ mod tests {
/// A box across a knot line: the piecewise enclosure holds every partial of the face on
/// both sides (the third u-partial jumps at the knot), which one piece continued over the
/// box does not; a knot of too low a continuity, and a box past the knots, are refused.
#[test]
fn a_box_past_its_span_keeps_the_unclipped_weight() {
// the control weights bound the weight only inside the span: a box across the knot at
// u = 1 is enclosed as `nurbs_partials_over_piece` encloses it
let mut n = cubic_with_knot(1);
// weights from 1 to 4.5: the weight continued past the span leaves their range
for (i, w) in n.pw.chunks_mut(4).enumerate() {
let k = 1.0 + 0.5 * i as f64;
for c in w.iter_mut() {
*c *= k;
}
}
let a = nurbs_partials_within_span(&n, 3, 1, 0.9, 1.1, 0.2, 0.3, 3).unwrap();
let b = nurbs_partials_over_piece(&n, 3, 1, 0.9, 1.1, 0.2, 0.3, 3).unwrap();
assert_eq!(a, b);
}
#[test]
fn a_box_across_a_knot_line_is_enclosed_piece_by_piece() {
let n = cubic_with_knot(1);
+8 -1
View File
@@ -81,7 +81,14 @@
//! - **Length.** Spans of degree above 1 use the ported `SmIntegrator` (unit
//! `mass.integrator`; with `Kernel::fixes.integrator_propagates_failure`, WR-94,
//! failed span integrals propagate instead of contributing zero). `Length` needs the
//! kernel (the cache tessellation reads its tolerances).
//! kernel (the cache tessellation reads its tolerances). Not upstream: the accuracy
//! is held over the whole curve, not per span. Upstream integrates each span to
//! `dDesiredAccuracy / span count` relative to that span's integral so far, so each of
//! a span's steps may err by nearly that and the steps together by up to their number
//! times it; here each step is held to half the accuracy relative to its share of the
//! estimated length plus its own increment (`OdeOptions::rate`), shares that add up to
//! at most twice the length ([`Curve::length`];
//! `plan/findings/curve.curve_p5--curve-length-global-error-moves-cases-2026-11-08.md`).
//! - **Out-params.** Flags with outputs are `Option`s (`IsLine`: point and vector;
//! `IsArc`: frame, radius, start and end angle; the guesses); `IsLinear`'s
//! optional outputs are the fields of [`Linearity`] (`want_deviation` stands for a
+34
View File
@@ -2330,6 +2330,40 @@ mod tests {
assert_eq!(c.base().form(), BSplineCurveForm::EllipticArc);
}
#[test]
fn issue_1518_short_arc_inverts_each_point_to_itself() {
// The 70.9232 x 16 ellipse of #1518 on an arc 7e-5 degrees long, shorter than
// the angular tolerance (1e-4 degrees); atan2 puts its points a period up.
let k = kernel();
let (d0, d1) = (-10.0, -10.0 + 7.0e-5);
let e = Ellipse::new(
&k,
Vec3::ZERO,
Vec3::X,
Vec3::Y,
Extent1d::from_min_max(d0, d1),
70.9232,
16.0,
&EllipseOptions::default(),
)
.expect("ellipse");
let fractions = [0.25, 0.5, 0.75, 1.0];
let points: Vec<(f64, Point3)> = fractions
.iter()
.map(|f| {
let a = d0 + f * (d1 - d0);
(a, e.evaluate_step_point(a).expect("step point"))
})
.collect();
// each point inverts to its own angle, not to the arc's start (the pinned C++
// returns -10 for all four)
for &(a, p) in &points {
let t = e.step_inversion(&k, p, false, 0.0).0;
assert!((t - a).abs() <= 1e-9 * a.abs(), "{a}: {t}");
}
}
#[test]
fn step_and_nurb_parameters_agree() {
let k = kernel();
+71 -19
View File
@@ -190,19 +190,33 @@ pub fn conic_on_form(k: &Kernel, conic: &Record, form: &Record) -> Result<Curve>
Ok(Curve::from(e))
}
/// A pcurve's numbers as the kernel's UV trim of an edge over `interval` (the edge's interval
/// in the kernel's parameter): the record's pcurve runs along the edge over the edge's own
/// interval in the record, and the kernel's trim runs along the edge over the edge's kernel
/// interval, so its knots are mapped affinely from the pcurve's domain onto `interval` (copied
/// as they are where the two are the same). Its points are scaled by `scale` in u and v: a
/// plane face's kernel parameters are the record's times its axes' lengths (`[1, 1]`
/// elsewhere, copied).
/// Map a pcurve's entire natural support onto `interval` and scale its UV
/// coordinates. This is for callers that intend to remap the whole support.
/// A B-rep edge may occupy only part of that support: its importer must use
/// [`uv_trim_on_edge`] with the record edge's actual interval instead.
///
/// # Errors
///
/// `InvalidInput` for a spline whose knots do not fit its points or a pcurve or interval of no
/// length; those of the constructors.
pub fn uv_trim(c: &Record2, interval: &Extent1d, scale: [f64; 2]) -> Result<Curve> {
let source = c
.interval()
.ok_or_else(|| SolidError::invalid_input(OP_TRIM, "invalid pcurve interval"))?;
uv_trim_on_edge(c, source, interval, scale)
}
/// Rebuild a pcurve while mapping the record edge parameter to the kernel edge
/// parameter. The pcurve's own support may extend beyond either edge endpoint.
///
/// # Errors
/// Refuses invalid edge intervals or malformed UV spline data.
pub fn uv_trim_on_edge(
c: &Record2,
source: [f64; 2],
interval: &Extent1d,
scale: [f64; 2],
) -> Result<Curve> {
let bad = |reason: &'static str| SolidError::invalid_input(OP_TRIM, reason);
let [su, sv] = scale;
let same = su == 1.0 && sv == 1.0;
@@ -238,14 +252,35 @@ pub fn uv_trim(c: &Record2, interval: &Extent1d, scale: [f64; 2]) -> Result<Curv
return Err(bad("too few knots"));
};
let (lo, hi) = (interval.min(), interval.max());
if !(s_hi > s_lo) {
let [from_lo, from_hi] = source;
if !(s_lo.is_finite()
&& s_hi.is_finite()
&& s_hi > s_lo
&& from_lo.is_finite()
&& from_hi.is_finite()
&& from_hi > from_lo
&& lo.is_finite()
&& hi.is_finite()
&& hi > lo)
{
return Err(bad("the pcurve's domain has no length"));
}
let mapped: Vec<f64> = if (s_lo, s_hi) == (lo, hi) {
let mapped: Vec<f64> = if (from_lo, from_hi) == (lo, hi) {
knots.to_vec()
} else {
let by = (hi - lo) / (s_hi - s_lo);
knots.iter().map(|s| lo + (s - s_lo) * by).collect()
let by = (hi - lo) / (from_hi - from_lo);
knots
.iter()
.map(|s| {
if *s == from_lo {
lo
} else if *s == from_hi {
hi
} else {
lo + (s - from_lo) * by
}
})
.collect()
};
Ok(Curve::from(spline(&mapped, &rows, 2, OP_TRIM)?))
}
@@ -273,9 +308,11 @@ pub struct RecordCurve {
/// The record of the kernel's edge curve `c` over `interval` (its kernel parameter), the
/// inverse of [`from_parameters`] and [`conic_on_form`]:
///
/// - **Line:** the line through the curve's points at the interval's ends, over `[0, 1]`. A
/// kernel line is affine in its parameter (its point plus the parameter times its vector),
/// so this is the same line, the record's parameter an affine image of the kernel's.
/// - **Line:** the line as the kernel holds it, its point and its point plus its vector, over
/// the STEP parameters of the interval's ends (`ConvertTFromNURBSToSTEP`); where those run
/// down, the vector and the parameters are negated so the interval increases. This is what
/// SolidCraft's flat export wrote (`FlatFromBrep`'s line origin and direction, its range in
/// STEP parameters), so a line comes back as it went in, not reparameterised onto `[0, 1]`.
/// - **Circle, ellipse:** the label on the conic's own frame (copied, not rebuilt by a cross
/// product), its y axis reversed where the conic's NURBS runs against its angle (its own
/// `inside_out`, no sampling), with that NURBS as its form, over the kernel's interval.
@@ -298,15 +335,30 @@ pub fn record_of(k: &Kernel, c: &Curve, interval: &Extent1d) -> Result<RecordCur
return Err(bad("the interval is not finite and increasing"));
}
match c.kind() {
CurveKind::Line(_) => {
let (a, b) = (c.evaluate_point(k, lo)?, c.evaluate_point(k, hi)?);
CurveKind::Line(l) => {
let can = l.canonical();
let (mut s0, mut s1) = (
c.convert_t_from_nurbs_to_step(k, lo)?,
c.convert_t_from_nurbs_to_step(k, hi)?,
);
let mut v = can.vector;
// a line whose parameter runs against its vector over the edge: the vector
// reversed, the parameters negated, so the record's interval increases
if s0 > s1 {
(s0, s1) = (-s0, -s1);
v = -v;
}
if !(s0.is_finite() && s1.is_finite() && s1 > s0) {
return Err(bad("a line's parameters over the edge are not increasing"));
}
let p = can.point;
return Ok(RecordCurve {
curve: Record::Line {
start: [a.x, a.y, a.z],
end: [b.x, b.y, b.z],
start: [p.x, p.y, p.z],
end: [p.x + v.x, p.y + v.y, p.z + v.z],
},
nurbs: None,
interval: [0.0, 1.0],
interval: [s0, s1],
});
}
CurveKind::Ellipse(e) => {
@@ -232,10 +232,11 @@ fn a_trim_runs_over_its_edges_kernel_interval() {
assert_eq!((p.x, p.y), (1.0, 2.0));
}
/// A kernel line goes to the record by its points at its interval's ends, over [0, 1]: the
/// same line, its parameter an affine image of the kernel's, and back again unchanged.
/// A kernel line goes to the record as the kernel holds it, its point and its point plus its
/// vector, over the STEP parameters of its interval (#3137: as SolidCraft's flat export wrote
/// it), and back again unchanged.
#[test]
fn a_line_goes_back_by_its_two_end_points() {
fn a_line_goes_back_as_the_kernel_holds_it() {
let k = k();
let line = Curve::from(Line::create_canonical(
Point3::new(1.0, 2.0, 3.0),
@@ -245,11 +246,44 @@ fn a_line_goes_back_by_its_two_end_points() {
assert_eq!(
r.curve,
Record::Line {
start: [2.0, 2.0, 3.0],
end: [5.0, 2.0, 3.0],
start: [1.0, 2.0, 3.0],
end: [3.0, 2.0, 3.0],
}
);
assert_eq!((&r.nurbs, r.interval), (&None, [0.0, 1.0]));
assert_eq!((&r.nurbs, r.interval), (&None, [0.5, 2.0]));
let back = from_parameters(&k, &r.curve, r.interval).unwrap();
assert_eq!(record_of(&k, &back.curve, &back.interval).unwrap(), r);
}
/// A line whose STEP parameter runs against its NURBS parameter (`inside_out`: `s = -t`) goes to
/// the record with its vector reversed and its interval negated, so the interval increases; the
/// record's point at each end of it is the kernel's at that end, bit for bit, and the round
/// trip is the identity.
#[test]
fn a_reversed_line_goes_back_with_its_vector_turned() {
let k = k();
let line = Curve::from(Line::infinite(
Point3::new(1.0, 2.0, 3.0),
Vec3::new(2.0, 0.0, 0.0),
3,
true,
));
let (lo, hi) = (0.5, 2.0);
let r = record_of(&k, &line, &Extent1d::from_raw(lo, hi)).unwrap();
let Record::Line { start, end } = r.curve else {
panic!("not a line: {:?}", r.curve)
};
assert!(r.interval[0] < r.interval[1], "{:?}", r.interval);
let on = |s: f64| {
Point3::new(
start[0] + s * (end[0] - start[0]),
start[1] + s * (end[1] - start[1]),
start[2] + s * (end[2] - start[2]),
)
};
// the record's interval runs the kernel's ends in order
assert_eq!(on(r.interval[0]), at(&k, &line, lo));
assert_eq!(on(r.interval[1]), at(&k, &line, hi));
let back = from_parameters(&k, &r.curve, r.interval).unwrap();
assert_eq!(record_of(&k, &back.curve, &back.interval).unwrap(), r);
}
+8 -1
View File
@@ -172,8 +172,11 @@ pub enum Unsoundness {
GeometryUnchecked,
/// The input was a manifold solid and the body is not.
NotManifoldSolid,
/// The body is a manifold solid whose volume is not positive.
/// The body is a manifold solid whose volume's enclosure lies below 0.
VolumeNotPositive,
/// The body is a manifold solid whose volume's enclosure still holds 0 after its
/// budget: its sign is not proved.
VolumeUndecided,
}
/// How loops of an offset face touch without crossing ([`OffsetFailure::WallsTouch`]).
@@ -449,6 +452,10 @@ impl core::fmt::Display for OffsetFailure {
"the local operation's body is a manifold solid whose volume is not \
positive: refused, the input unchanged",
),
Unsoundness::VolumeUndecided => f.write_str(
"the local operation's body is a manifold solid whose volume's sign \
could not be proved: refused, the input unchanged",
),
},
Self::ExactCurveUncut => f.write_str(
"the extended faces beside an edge meet on an exact curve that could not be \
+1
View File
@@ -29,6 +29,7 @@ pub mod polar_box;
pub mod polynomial;
pub mod pseudo_box;
pub mod solution_array;
pub mod step_matrix;
pub mod tol;
pub mod tools;
pub mod transform;
+42 -6
View File
@@ -72,6 +72,9 @@
//! prefix and zero-fills, unbounded dimensions are not written, counts are
//! incremented); arrays shorter than the dimension (undefined behaviour upstream)
//! are rejected with [`SolidError::InvalidInput`].
//! - `ContainsPeriodicValue` moves a value within the tolerance of the top of the period
//! above the start down by the period, and keeps it where that lies in the interval,
//! instead of setting it to the start (WR-509, #1518; tangency-labs/smlib#46).
//! - `SM_ASSERT*` debug checks and the error-log side effect of `SE(...)` are not
//! ported (they do not change results). The destructor's "undefined" sentinel has
//! no Rust counterpart; [`Extent1d::is_defined`] still checks for it.
@@ -844,12 +847,16 @@ impl Extent1d {
contained = map > self.min - zero && map < self.max + zero;
if !contained
&& are_same_to_tol(map, ivl.max(), zero)
&& are_same_to_tol(map - period, self.min, zero)
{
map = self.min;
contained = true;
let mut kept_down = false;
if !contained && are_same_to_tol(map, ivl.max(), zero) {
// a period down, kept where it lies in the interval; the C++ sets it to the
// start instead (WR-509, #1518; tangency-labs/smlib#46)
let down = map - period;
if down > self.min - zero && down < self.max + zero {
map = down;
contained = true;
kept_down = true;
}
}
let periodic_value =
@@ -861,6 +868,12 @@ impl Extent1d {
map
};
let mut dist_to_min = map - self.min;
if kept_down {
// it can lie up to the tolerance below the start: its distance from it, not a
// signed offset (assert_valid takes min2 of the two). Only this new case: other
// inputs keep the signed value upstream gives them.
dist_to_min = dist_to_min.abs();
}
if dist_to_min > period / 2.0 {
dist_to_min = period - dist_to_min;
}
@@ -1150,6 +1163,29 @@ mod tests {
.is_err());
}
#[test]
fn issue_1518_value_past_start_moves_down_a_period() {
// An interval shorter than the tolerance (1e-4 degrees), and a value 3e-5 past
// its start given a period up: the C++ returns the start (-10).
let q = Extent1d::from_min_max(-10.0, -10.0 + 7.0e-5);
let v = 350.0 + 3.0e-5;
let c = q.contains_periodic_value(v, 360.0, -1.0);
assert!(c.contained);
assert_eq!(c.periodic_value, v - 360.0);
assert!((c.dist_to_min - 3.0e-5).abs() < 1e-12);
assert_eq!(q.clamp_periodic_value(v, 360.0, -1.0), v - 360.0);
// a value within the tolerance below the start keeps its place a period down
// (the C++: the start, 0)
let r = Extent1d::from_min_max(0.0, 90.0);
let w = 360.0 - 5.0e-5;
let c = r.contains_periodic_value(w, 360.0, -1.0);
assert!(c.contained);
assert_eq!(c.periodic_value, w - 360.0);
// its distance to the start is a distance, not the signed offset -5e-5
assert!((c.dist_to_min - 5.0e-5).abs() < 1e-12, "{}", c.dist_to_min);
}
#[test]
fn clamp_vector() {
let mut nd = ExtentNd::new(3);
+6 -4
View File
@@ -1004,10 +1004,12 @@ mod tests {
);
assert!(!e1.is_point_on_boundary(p(1.1, 2.1), EFF_ZERO).0);
assert_eq!(
e1.contains_periodic_point(p(8.9, 3.0), 4.0, 0.0, 0.2),
(true, p(1.0, 3.0))
);
// u = 8.9 is 0.9 a period down, within the tolerance of [1, 3]: it keeps 0.9
// (WR-509, #1518; upstream's MyTest expects the start, 1.0)
let (inside, q) = e1.contains_periodic_point(p(8.9, 3.0), 4.0, 0.0, 0.2);
assert!(inside);
assert!((q.x - 0.9).abs() <= 1e-12, "{}", q.x);
assert_eq!(q.y, 3.0);
assert!(!e1.contains_periodic_point(p(8.9, 3.0), 4.0, 0.0, 0.0).0);
assert_eq!(e1.evaluate(0.5, 0.5), p(2.0, 3.0));
+183
View File
@@ -0,0 +1,183 @@
//! Affine matrices for STEP placements and transformations.
//!
//! STEP keeps placements as frames and transformation operators as directions and
//! scales. Building their matrices and composing or inverting them is geometric
//! work, so it lives here rather than in `solidcore-record` or `solidcore-ops`.
//!
//! The matrix convention is the kernel's: rows 0–2 are the images of X, Y and Z,
//! row 3 is the translation (`p' = p·A + t`).
use crate::geom::vector3d::Vec3;
/// A 4×4 affine matrix in the kernel's row-major convention.
pub type Matrix4 = [[f64; 4]; 4];
/// The identity matrix.
pub const IDENTITY: Matrix4 = [
[1.0, 0.0, 0.0, 0.0],
[0.0, 1.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
[0.0, 0.0, 0.0, 1.0],
];
/// `a · b` in the row-vector convention.
#[must_use]
#[allow(clippy::indexing_slicing)] // 4×4 loops over fixed-size arrays
pub fn concat(a: &Matrix4, b: &Matrix4) -> Matrix4 {
let mut out = [[0.0; 4]; 4];
for (row, out_row) in out.iter_mut().enumerate() {
for (col, cell) in out_row.iter_mut().enumerate() {
*cell = (0..4).map(|k| a[row][k] * b[k][col]).sum();
}
}
out
}
/// The inverse of an affine matrix, or `None` when its linear part is singular or
/// the entries are not finite.
#[must_use]
pub fn inverse(m: &Matrix4) -> Option<Matrix4> {
let (a, b, c) = (
Vec3::new(m[0][0], m[0][1], m[0][2]),
Vec3::new(m[1][0], m[1][1], m[1][2]),
Vec3::new(m[2][0], m[2][1], m[2][2]),
);
let det = a.dot(b.cross(c));
if !det.is_finite() || det == 0.0 {
return None;
}
let x = b.cross(c);
let y = c.cross(a);
let z = a.cross(b);
// The inverse's columns are the cofactor cross products; its translation is
// `-(t · A⁻¹)` in the row-vector convention.
let linear = [
[x.x / det, y.x / det, z.x / det],
[x.y / det, y.y / det, z.y / det],
[x.z / det, y.z / det, z.z / det],
];
let t = Vec3::new(m[3][0], m[3][1], m[3][2]);
let translation = Vec3::new(
-(t.x * linear[0][0] + t.y * linear[1][0] + t.z * linear[2][0]),
-(t.x * linear[0][1] + t.y * linear[1][1] + t.z * linear[2][1]),
-(t.x * linear[0][2] + t.y * linear[1][2] + t.z * linear[2][2]),
);
let out = [
[linear[0][0], linear[0][1], linear[0][2], 0.0],
[linear[1][0], linear[1][1], linear[1][2], 0.0],
[linear[2][0], linear[2][1], linear[2][2], 0.0],
[translation.x, translation.y, translation.z, 1.0],
];
out.iter().flatten().all(|v| v.is_finite()).then_some(out)
}
fn unit(value: [f64; 3]) -> Option<Vec3> {
let v = Vec3::from(value);
v.unitize()
.ok()
.filter(|v| v.x.is_finite() && v.y.is_finite() && v.z.is_finite())
}
/// The matrix of an `AXIS2_PLACEMENT`: origin, Z axis and X reference direction.
///
/// An omitted axis is +Z; an omitted reference direction chooses any X perpendicular
/// to Z. The reference direction is projected onto the plane normal to Z.
#[must_use]
pub fn frame_matrix(
origin: [f64; 3],
axis: Option<[f64; 3]>,
ref_direction: Option<[f64; 3]>,
) -> Option<Matrix4> {
let z = unit(axis.unwrap_or([0.0, 0.0, 1.0]))?;
let x = if let Some(raw) = ref_direction {
let v = Vec3::from(raw);
let projected = v - z * v.dot(z);
unit([projected.x, projected.y, projected.z])?
} else {
let candidate = if z.x.abs() < 0.9 {
Vec3::new(1.0, 0.0, 0.0)
} else {
Vec3::new(0.0, 1.0, 0.0)
};
let v = candidate - z * candidate.dot(z);
unit([v.x, v.y, v.z])?
};
let y = z.cross(x);
Some([
[x.x, x.y, x.z, 0.0],
[y.x, y.y, y.z, 0.0],
[z.x, z.y, z.z, 0.0],
[origin[0], origin[1], origin[2], 1.0],
])
}
/// The matrix of a `CARTESIAN_TRANSFORMATION_OPERATOR_3D`.
///
/// An omitted direction is the matching global axis; an omitted scale is 1 (or
/// `scale` for the second and third scale).
#[must_use]
pub fn operator_matrix(
axis1: Option<[f64; 3]>,
axis2: Option<[f64; 3]>,
axis3: Option<[f64; 3]>,
origin: [f64; 3],
scale: Option<f64>,
scale2: Option<f64>,
scale3: Option<f64>,
) -> Option<Matrix4> {
let x = unit(axis1.unwrap_or([1.0, 0.0, 0.0]))?;
let y = unit(axis2.unwrap_or([0.0, 1.0, 0.0]))?;
let z = unit(axis3.unwrap_or([0.0, 0.0, 1.0]))?;
let sx = scale.unwrap_or(1.0);
let sy = scale2.or(scale).unwrap_or(1.0);
let sz = scale3.or(scale).unwrap_or(1.0);
if ![sx, sy, sz].iter().all(|v| v.is_finite() && *v != 0.0) {
return None;
}
Some([
[x.x * sx, x.y * sx, x.z * sx, 0.0],
[y.x * sy, y.y * sy, y.z * sy, 0.0],
[z.x * sz, z.y * sz, z.z * sz, 0.0],
[origin[0], origin[1], origin[2], 1.0],
])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_identity_inverse_is_the_identity() {
assert_eq!(inverse(&IDENTITY), Some(IDENTITY));
}
#[test]
fn a_translation_inverse_moves_back() {
let mut translation = IDENTITY;
translation[3] = [2.0, -3.0, 0.5, 1.0];
let inverse = inverse(&translation).expect("translation is regular");
assert_eq!(concat(&translation, &inverse), IDENTITY);
}
#[test]
fn an_omitted_frame_axis_is_z_and_omitted_x_is_chosen() {
let frame = frame_matrix([1.0, 2.0, 3.0], None, None).expect("default frame");
assert_eq!(frame[2], [0.0, 0.0, 1.0, 0.0]);
assert_eq!(frame[3], [1.0, 2.0, 3.0, 1.0]);
assert!(
(frame[0][0] * frame[0][0] + frame[0][1] * frame[0][1] + frame[0][2] * frame[0][2]
- 1.0)
.abs()
< 1e-12
);
}
#[test]
fn an_operator_applies_its_scales() {
let matrix = operator_matrix(None, None, None, [0.0; 3], Some(2.0), Some(3.0), None)
.expect("operator");
assert_eq!(matrix[0], [2.0, 0.0, 0.0, 0.0]);
assert_eq!(matrix[1], [0.0, 3.0, 0.0, 0.0]);
assert_eq!(matrix[2], [0.0, 0.0, 2.0, 0.0]);
}
}
+8 -4
View File
@@ -2374,10 +2374,14 @@ impl<O: LoopObject> FaceProps<O> {
// (the status is only reported upstream; a handle type that cannot name the
// loop bisector's objects is a limitation of the caller, not a status)
match classify_result {
Err(
e @ (SolidError::InvalidInput { reason, .. }
| SolidError::PortInvariant { reason, .. }),
) if reason == crate::query::face::p3::NO_CURVE_HANDLES => {
Err(e)
if matches!(
&e,
SolidError::InvalidInput { reason, .. }
| SolidError::PortInvariant { reason, .. }
if *reason == crate::query::face::p3::NO_CURVE_HANDLES
) =>
{
return Err(e);
}
r => Ok(ignored(r)?),
+1
View File
@@ -7,6 +7,7 @@
//! unification and the API entry points) and [`p2`] (`DoStitching`, `StitchIntoSolid`,
//! `StitchIntoShell`, `SplitEdgesWithVertices`, the one-list `StitchVertexPairs`).
mod bounds;
pub mod p1;
pub mod p2;
+546
View File
@@ -0,0 +1,546 @@
//! Outward-rounded bounds for the geometry a stitch closure identifies.
//!
//! No upstream counterpart. The curve-pair bound is a bound on a parameter
//! correspondence, hence on both directed Hausdorff distances. B-splines are
//! enclosed on every knot span by homogeneous interval de Boor evaluation; no
//! rounded control-polygon conversion or sampled distance enters the proof.
//!
//! Deviations: no recursion. Work is bounded by the stored knot count and the
//! kernel's NURBS degree limit. Curves without an implemented exact enclosure,
//! non-positive weights, extrapolation and non-finite bounds are refused.
use crate::blend::fillet_section::interval::Ivl;
use crate::curve::curve::{Curve, CurveKind};
use crate::curve::line::Line;
use crate::geom::extent1d::Extent1d;
use crate::math::Point3;
use crate::nurbs::crv_basic::Curve as Nurbs;
use crate::nurbs::knots::MAX_DEGREE;
use crate::nurbs::math::{CPoint, NOW, NOZ};
use crate::{Result, SolidError};
const OP: &str = "heal::stitch::bounds";
type Box3 = [Ivl; 3];
type Box4 = [Ivl; 4];
fn invalid(reason: &'static str) -> SolidError {
SolidError::invalid_input(OP, reason)
}
fn unsupported() -> SolidError {
SolidError::Unsupported {
operation: OP,
reason: "stitch gap enclosure is implemented for lines and positive-weight B-splines only",
}
}
fn at<T: Copy>(values: &[T], index: usize) -> Result<T> {
values
.get(index)
.copied()
.ok_or_else(|| invalid("invalid spline storage"))
}
fn finite(interval: Ivl) -> bool {
interval.lo.is_finite() && interval.hi.is_finite() && interval.lo <= interval.hi
}
fn norm(v: Box3) -> Result<f64> {
if !v.into_iter().all(finite) {
return Err(invalid("gap enclosure is not finite"));
}
let scale = v.into_iter().map(Ivl::mag).fold(0.0_f64, f64::max);
if scale == 0.0 {
return Ok(0.0);
}
// Scale before squaring: small distances must not underflow to a zero bound.
let mut sum = Ivl::from(0.0);
for coordinate in v {
// Direct endpoint division avoids overflowing 1/scale for subnormals.
let scaled = Ivl::checked(coordinate.lo / scale, coordinate.hi / scale);
sum = sum + scaled * scaled;
}
let length = sum.sqrt() * Ivl::from(scale);
if !finite(length) {
return Err(invalid("gap enclosure overflows"));
}
Ok(length.hi.max(0.0))
}
fn point_box(p: Point3) -> Result<Box3> {
let coordinates = [p.x, p.y, p.z];
if !coordinates.into_iter().all(f64::is_finite) {
return Err(invalid("non-finite point"));
}
Ok(coordinates.map(Ivl::from))
}
fn difference(a: Box3, b: Box3) -> Box3 {
core::array::from_fn(|i| {
let x = a.get(i).copied().unwrap_or(Ivl::whole());
let y = b.get(i).copied().unwrap_or(Ivl::whole());
if x.lo == x.hi && x == y {
Ivl::from(0.0)
} else {
x - y
}
})
}
/// Upper bound on the exact Euclidean distance between the stored points.
pub(super) fn points(a: Point3, b: Point3) -> Result<f64> {
norm(difference(point_box(a)?, point_box(b)?))
}
/// Upper bound on the positive increase between two stored tolerances.
pub(super) fn growth(before: f64, after: f64) -> Result<f64> {
if ![before, after]
.into_iter()
.all(|v| v.is_finite() && v >= 0.0)
{
return Err(invalid("non-finite or negative tolerance"));
}
if after <= before {
return Ok(0.0);
}
let increase = Ivl::from(after) - Ivl::from(before);
if !finite(increase) {
return Err(invalid("tolerance growth overflows"));
}
Ok(increase.hi)
}
fn checked_range(range: [f64; 2]) -> Result<Ivl> {
if !range.into_iter().all(f64::is_finite) {
return Err(invalid("non-finite curve interval"));
}
Ok(Ivl::new(range[0].min(range[1]), range[0].max(range[1])))
}
fn homogeneous(c: CPoint) -> Result<Box4> {
let coordinates = [
c.x,
c.y,
if c.z == NOZ { 0.0 } else { c.z },
if c.w == NOW { 1.0 } else { c.w },
];
if !coordinates.into_iter().all(f64::is_finite) || coordinates.last().is_none_or(|w| *w <= 0.0)
{
return Err(invalid("non-finite spline data or non-positive weight"));
}
Ok(coordinates.map(Ivl::from))
}
fn validate(n: &Nurbs, range: Ivl) -> Result<()> {
let p = n.degree();
let count = n.cpts().len();
if p > MAX_DEGREE + 1 || count <= p || count.checked_add(p + 1) != Some(n.knots().len()) {
return Err(invalid("invalid spline degree or storage"));
}
if !n.knots().iter().all(|k| k.is_finite())
|| n.knots().windows(2).any(|w| matches!(w, [a, b] if a > b))
{
return Err(invalid("invalid knot vector"));
}
if at(n.knots(), p)? >= at(n.knots(), count)?
|| range.lo < at(n.knots(), p)?
|| range.hi > at(n.knots(), count)?
{
return Err(invalid("curve gap enclosure does not extrapolate"));
}
let first = at(n.cpts(), 0)?;
for &c in n.cpts() {
if (c.w == NOW) != (first.w == NOW) || (c.z == NOZ) != (first.z == NOZ) {
return Err(invalid("mixed homogeneous coordinate storage"));
}
homogeneous(c)?;
}
Ok(())
}
/// The exact rational polynomial on one nonempty knot span, evaluated in intervals.
fn piece(n: &Nurbs, span: usize, t: Ivl) -> Result<Box3> {
let p = n.degree();
let first = span
.checked_sub(p)
.ok_or_else(|| invalid("invalid spline span"))?;
let mut d = Vec::new();
let mut min_weight = f64::INFINITY;
for j in 0..=p {
let h = homogeneous(at(n.cpts(), first + j)?)?;
min_weight = min_weight.min(at(&h, 3)?.lo);
d.push(h);
}
for r in 1..=p {
for j in (r..=p).rev() {
let i = first + j;
let denominator =
Ivl::from(at(n.knots(), i + p + 1 - r)?) - Ivl::from(at(n.knots(), i)?);
if denominator.lo <= 0.0 {
return Err(invalid("unproved knot denominator"));
}
let alpha = (t - Ivl::from(at(n.knots(), i)?)) / denominator;
// On the selected span, the exact alpha is in [0, 1]. Intersecting
// with that proved range removes rounding spill, not geometric error.
let alpha = Ivl::new(alpha.lo.max(0.0), alpha.hi.min(1.0));
if !finite(alpha) {
return Err(invalid("invalid spline parameter enclosure"));
}
let left = at(&d, j - 1)?;
let right = at(&d, j)?;
let value = core::array::from_fn(|axis| {
let a = left.get(axis).copied().unwrap_or(Ivl::whole());
let b = right.get(axis).copied().unwrap_or(Ivl::whole());
(Ivl::from(1.0) - alpha) * a + alpha * b
});
*d.get_mut(j)
.ok_or_else(|| invalid("invalid spline workspace"))? = value;
}
}
let h = at(&d, p)?;
// Positive basis functions sum to one, so W is at least the least active weight.
let w = at(&h, 3)?;
let w = Ivl::new(w.lo.max(min_weight), w.hi);
if !finite(w) || w.lo <= 0.0 {
return Err(invalid("unproved spline denominator"));
}
let result = [at(&h, 0)? / w, at(&h, 1)? / w, at(&h, 2)? / w];
if !result.into_iter().all(finite) {
return Err(invalid("curve enclosure overflows"));
}
Ok(result)
}
fn spline_box(n: &Nurbs, range: Ivl) -> Result<Box3> {
validate(n, range)?;
let mut result: Option<Box3> = None;
for (span, pair) in n.knots().windows(2).enumerate() {
let [a, b] = pair else {
continue;
};
if span < n.degree() || span >= n.cpts().len() || a >= b || *b < range.lo || *a > range.hi {
continue;
}
let t = Ivl::new(range.lo.max(*a), range.hi.min(*b));
let part = piece(n, span, t)?;
result = Some(match result {
None => part,
Some(old) => core::array::from_fn(|i| {
old.get(i)
.copied()
.unwrap_or(Ivl::whole())
.hull(part.get(i).copied().unwrap_or(Ivl::whole()))
}),
});
}
result.ok_or_else(|| invalid("no knot span covers the curve interval"))
}
fn line_box(line: &Line, t: Ivl) -> Result<Box3> {
let natural = line.natural_interval();
let step = line.step_interval();
if !finite(t)
|| !finite(checked_range([natural.min(), natural.max()])?)
|| !finite(checked_range([step.min(), step.max()])?)
{
return Err(invalid("non-finite line parameterization"));
}
if t.lo < natural.min() || t.hi > natural.max() {
return Err(invalid("curve gap enclosure does not extrapolate"));
}
let u = if line.is_bounded() {
let length = Ivl::from(natural.max()) - Ivl::from(natural.min());
if length.lo <= 0.0 || crate::geom::tol::is_zero(natural.length()) {
return Err(invalid("degenerate line parameterization"));
}
let (start, direction) = if line.inside_out() {
(natural.max(), -1.0)
} else {
(natural.min(), 1.0)
};
Ivl::from(step.min())
+ (t - Ivl::from(start)) / length
* Ivl::from(direction)
* (Ivl::from(step.max()) - Ivl::from(step.min()))
} else if line.inside_out() {
Ivl::from(0.0) - t
} else {
t
};
let p = point_box(line.line_point())?;
let v = point_box(line.line_vector())?;
if !line.line_scale().is_finite() {
return Err(invalid("non-finite line scale"));
}
let result = core::array::from_fn(|i| {
p.get(i).copied().unwrap_or(Ivl::whole())
+ u * v.get(i).copied().unwrap_or(Ivl::whole()) * Ivl::from(line.line_scale())
});
if !result.into_iter().all(finite) {
return Err(invalid("line enclosure overflows"));
}
Ok(result)
}
fn curve_box(curve: &Curve, range: Ivl) -> Result<Box3> {
match curve.kind() {
CurveKind::Line(l) => line_box(l, range),
CurveKind::BSpline(b) => spline_box(
b.gw_nurb().ok_or_else(|| invalid("missing spline data"))?,
range,
),
_ => Err(unsupported()),
}
}
/// An upper bound for point-to-curve distance using an exact point of the curve.
pub(super) fn point_at_curve(point: Point3, curve: &Curve, parameter: f64) -> Result<f64> {
norm(difference(
point_box(point)?,
curve_box(curve, checked_range([parameter, parameter])?)?,
))
}
/// Bounds the whole curve's distance from a point (the geometry a squeeze removes).
pub(super) fn curve_to_point(curve: &Curve, interval: &Extent1d, point: Point3) -> Result<f64> {
norm(difference(
curve_box(curve, checked_range([interval.min(), interval.max()])?)?,
point_box(point)?,
))
}
/// Bounds both directed curve gaps under the stated endpoint correspondence.
pub(super) fn curves(first: &Curve, a: [f64; 2], second: &Curve, b: [f64; 2]) -> Result<f64> {
let ar = checked_range(a)?;
let br = checked_range(b)?;
// With a common positive rational basis the difference is a convex combination
// of the dehomogenized control-point differences. This also proves identity
// exactly, without subtracting independently widened position boxes.
if a == b {
if let (CurveKind::BSpline(x), CurveKind::BSpline(y)) = (first.kind(), second.kind()) {
let x = x.gw_nurb().ok_or_else(|| invalid("missing spline data"))?;
let y = y.gw_nurb().ok_or_else(|| invalid("missing spline data"))?;
validate(x, ar)?;
validate(y, br)?;
if x.degree() == y.degree()
&& x.knots() == y.knots()
&& x.cpts().iter().zip(y.cpts()).all(|(p, q)| p.w == q.w)
{
let mut upper = 0.0_f64;
for (&p, &q) in x.cpts().iter().zip(y.cpts()) {
let p = homogeneous(p)?;
let q = homogeneous(q)?;
let delta = difference(
[at(&p, 0)?, at(&p, 1)?, at(&p, 2)?],
[at(&q, 0)?, at(&q, 1)?, at(&q, 2)?],
);
let w = at(&p, 3)?;
upper = upper.max(norm(delta.map(|d| {
if d == Ivl::from(0.0) {
d
} else {
d / w
}
}))?);
}
return Ok(upper);
}
}
}
// Affine curves attain the maximum norm of their difference at an endpoint.
// Otherwise enclose every parameter cell, never only its sample stations.
let cells = if matches!(
(first.kind(), second.kind()),
(CurveKind::Line(_), CurveKind::Line(_))
) {
1_u32
} else {
64_u32
};
let mut upper = 0.0_f64;
for i in 0..cells {
let s = Ivl::new(
f64::from(i) / f64::from(cells),
f64::from(i + 1) / f64::from(cells),
);
let mapped =
|range: [f64; 2]| Ivl::from(range[0]) * (Ivl::from(1.0) - s) + Ivl::from(range[1]) * s;
if cells == 1 {
for j in [0, 1] {
let t = at(&a, j)?;
let u = at(&b, j)?;
upper = upper.max(norm(difference(
curve_box(first, Ivl::from(t))?,
curve_box(second, Ivl::from(u))?,
))?);
}
} else {
// Clip only the rounding spill of the affine parameter map.
let x = mapped(a);
let y = mapped(b);
upper = upper.max(norm(difference(
curve_box(first, Ivl::new(x.lo.max(ar.lo), x.hi.min(ar.hi)))?,
curve_box(second, Ivl::new(y.lo.max(br.lo), y.hi.min(br.hi)))?,
))?);
}
}
Ok(upper)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::curve::hermite_curve::HermiteCurve;
use crate::nurbs::b_spline_curve::BSplineCurve;
use crate::{Kernel, Vec3};
/// Retain the raw data: an ordinary clone/owned constructor tunes the knots.
fn spline(points: Vec<CPoint>, degree: usize, knots: Vec<f64>) -> Curve {
BSplineCurve::from_nurb(Nurbs::new(points, degree, knots).unwrap(), 3, true).into()
}
fn cp(x: f64, y: f64, w: f64) -> CPoint {
CPoint { x, y, z: 0.0, w }
}
#[test]
fn whole_curve_bound_catches_the_interior_bump_five_samples_miss() {
// A degree-one hat on [1/16, 3/16], peaking at 1/8. Every old sample
// at 0, 1/4, 1/2, 3/4, 1 is exactly on the flat curve.
let knots = vec![0.0, 0.0, 0.0625, 0.125, 0.1875, 1.0, 1.0];
let flat = spline(
vec![
cp(0.0, 0.0, NOW),
cp(0.0625, 0.0, NOW),
cp(0.125, 0.0, NOW),
cp(0.1875, 0.0, NOW),
cp(1.0, 0.0, NOW),
],
1,
knots.clone(),
);
let bump = spline(
vec![
cp(0.0, 0.0, NOW),
cp(0.0625, 0.0, NOW),
cp(0.125, 1.0, NOW),
cp(0.1875, 0.0, NOW),
cp(1.0, 0.0, NOW),
],
1,
knots,
);
let k = Kernel::default();
for t in [0.0, 0.25, 0.5, 0.75, 1.0] {
assert_eq!(
flat.evaluate_point(&k, t).unwrap(),
bump.evaluate_point(&k, t).unwrap()
);
}
assert_eq!(bump.evaluate_point(&k, 0.125).unwrap().y, 1.0);
let bound = curves(&flat, [0.0, 1.0], &bump, [0.0, 1.0]).unwrap();
assert!((1.0..1.0 + 1e-12).contains(&bound), "{bound}");
let squeezed =
curve_to_point(&bump, &Extent1d::from_min_max(0.0, 1.0), Vec3::ZERO).unwrap();
assert!(squeezed >= 1.0);
}
#[test]
fn positive_rational_weights_are_enclosed_without_a_polynomial_substitute() {
// Y(t) = 4t(1-t)/(1+2t(1-t)), maximum 2/3 at 1/2.
let rational = spline(
vec![cp(0.0, 0.0, 1.0), cp(1.0, 2.0, 2.0), cp(1.0, 0.0, 1.0)],
2,
vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
);
let line: Curve = Line::from_points(Vec3::ZERO, Vec3::new(1.0, 0.0, 0.0), 3).into();
let bound = curves(&rational, [0.0, 1.0], &line, [0.0, 1.0]).unwrap();
assert!((2.0 / 3.0..1.0).contains(&bound), "{bound}");
let at_mid = point_at_curve(Vec3::new(0.5, 0.0, 0.0), &rational, 0.5).unwrap();
assert!((2.0 / 3.0..2.0 / 3.0 + 1e-12).contains(&at_mid), "{at_mid}");
assert_eq!(
curves(&rational, [0.0, 1.0], &rational, [0.0, 1.0]).unwrap(),
0.0
);
}
#[test]
fn reversed_and_subdomain_correspondences_cover_every_parameter() {
let forward = spline(
vec![cp(0.0, 0.0, NOW), cp(1.0, 0.0, NOW)],
1,
vec![0.0, 0.0, 1.0, 1.0],
);
let reverse = spline(
vec![cp(1.0, 0.0, NOW), cp(0.0, 0.0, NOW)],
1,
vec![0.0, 0.0, 1.0, 1.0],
);
let bound = curves(&forward, [0.25, 0.75], &reverse, [0.75, 0.25]).unwrap();
assert!(bound.is_finite() && bound < 0.1, "{bound}");
let endpoint_bound = point_at_curve(Vec3::ZERO, &forward, 0.75).unwrap();
assert!((0.75..0.75 + 1e-12).contains(&endpoint_bound));
assert!(curves(&forward, [-0.1, 0.75], &reverse, [0.75, 0.25]).is_err());
}
#[test]
fn point_and_growth_bounds_do_not_underflow_or_accept_nonfinite_results() {
assert_eq!(points(Vec3::ZERO, Vec3::ZERO).unwrap(), 0.0);
for tiny in [1e-200, f64::from_bits(1)] {
let bound = points(Vec3::ZERO, Vec3::new(tiny, 0.0, 0.0)).unwrap();
assert!(bound.is_finite() && bound >= tiny && bound > 0.0, "{bound}");
}
assert!(points(Vec3::ZERO, Vec3::new(1e300, 0.0, 0.0)).unwrap() >= 1e300);
assert!(points(
Vec3::new(-f64::MAX, 0.0, 0.0),
Vec3::new(f64::MAX, 0.0, 0.0)
)
.is_err());
assert!(points(Vec3::ZERO, Vec3::new(f64::NAN, 0.0, 0.0)).is_err());
assert_eq!(growth(1.0, 1.0).unwrap(), 0.0);
assert_eq!(growth(2.0, 1.0).unwrap(), 0.0);
assert!(growth(1.0, 2.0).unwrap() >= 1.0);
assert!(growth(0.0, f64::from_bits(1)).unwrap() > 0.0);
assert!(growth(-1.0, 0.0).is_err());
assert!(growth(0.0, f64::INFINITY).is_err());
}
#[test]
fn malformed_weights_knots_storage_and_unsupported_kinds_fail_explicitly() {
for weight in [0.0, -1.0, f64::NAN, f64::INFINITY] {
let c = spline(
vec![cp(0.0, 0.0, weight), cp(1.0, 0.0, 1.0)],
1,
vec![0.0, 0.0, 1.0, 1.0],
);
assert!(point_at_curve(Vec3::ZERO, &c, 0.5).is_err());
}
for (degree, knots) in [
(1, vec![0.0, 1.0]),
(1, vec![0.0; 4]),
(1, vec![0.0, 1.0, 0.0, 1.0]),
(usize::MAX, vec![0.0, 0.0, 1.0, 1.0]),
] {
let c = spline(vec![cp(0.0, 0.0, NOW), cp(1.0, 0.0, NOW)], degree, knots);
assert!(point_at_curve(Vec3::ZERO, &c, 0.5).is_err());
}
let mixed = spline(
vec![cp(0.0, 0.0, NOW), cp(1.0, 0.0, 1.0)],
1,
vec![0.0, 0.0, 1.0, 1.0],
);
assert!(point_at_curve(Vec3::ZERO, &mixed, 0.5).is_err());
let hermite: Curve = HermiteCurve::new(
Vec3::ZERO,
Vec3::new(1.0, 0.0, 0.0),
Vec3::new(1.0, 0.0, 0.0),
Vec3::new(1.0, 0.0, 0.0),
3,
)
.into();
assert!(matches!(
point_at_curve(Vec3::ZERO, &hermite, 0.5),
Err(SolidError::Unsupported { .. })
));
}
}
+44
View File
@@ -111,6 +111,8 @@ use crate::topology::{
};
use crate::{Result, SolidError};
use super::p2::{ClosedGap, ClosedGapKind};
const OP: &str = "heal::stitch::p1";
/// The operation of the `SM_API` entry points' `SM_ERR_INVALID_INPUT` (null arguments).
const API_OP: &str = "heal::stitch::p1::api";
@@ -932,6 +934,47 @@ pub struct Stitch<'c> {
/// Remove sliver faces smaller than tolerance first (`m_bRemoveLaminarSlivers`,
/// default `FALSE`).
pub remove_laminar_slivers: bool,
/// When `Some`, the stitch records what it closed: the entities, the gap it
/// enclosed, and the tolerance growth its editing applied (not upstream,
/// solidcore#2975). Nothing else changes.
pub report_gaps: Option<Vec<ClosedGap>>,
}
impl Stitch<'_> {
/// The collected gap records, taking them out (`Ok(vec![])` when the caller did
/// not ask for them).
pub(crate) fn take_gaps(&mut self) -> Vec<ClosedGap> {
self.report_gaps.take().unwrap_or_default()
}
/// Records one closed gap when the caller asked for the report. The growth is
/// an outward-rounded bound on how much the surviving entity's tolerance rose
/// over its own tolerance before the closure, including inherited tolerance.
pub(crate) fn record_gap(
&mut self,
kind: ClosedGapKind,
gap: f64,
before: f64,
after: f64,
) -> Result<()> {
if let Some(gaps) = self.report_gaps.as_mut() {
let growth = super::bounds::growth(before, after)?;
if !gap.is_finite() || gap < 0.0 {
return Err(SolidError::invalid_input(
"heal::stitch::bounds",
"non-finite or negative gap bound",
));
}
gaps.push(ClosedGap {
kind,
gap,
growth,
tolerance_before: before,
tolerance_after: after,
});
}
Ok(())
}
}
impl core::fmt::Debug for Stitch<'_> {
@@ -1151,6 +1194,7 @@ impl<'c> Stitch<'c> {
split_edges_with_vertices: true,
making_manifold_solid: true,
remove_laminar_slivers: false,
report_gaps: None,
}
}
+186
View File
@@ -351,6 +351,62 @@ pub struct VertexList {
pub min_unstitched_vert_gap: f64,
}
/// What the stitcher closed, as the entities it glued (solidcore#2975).
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ClosedGapKind {
/// A vertex pair glued into the kept vertex.
Vertex {
/// The vertex the glue kept.
kept: VertexId,
/// The vertex the glue deleted.
deleted: VertexId,
},
/// An edge pair glued into the kept edge.
Edge {
/// The edge the glue kept.
kept: EdgeId,
/// The edge the glue deleted.
deleted: EdgeId,
},
/// A vertex pair one edge was squeezed into (the kept vertex stays).
Squeezed {
/// The vertex the squeeze kept.
kept: VertexId,
/// The vertex the squeeze closed over.
deleted: VertexId,
},
/// A vertex dropped onto an edge and split there, with the vertex its split created.
Split {
/// The edge the vertex was dropped onto and split.
edge: EdgeId,
/// The vertex the split created.
created: VertexId,
},
}
/// A closure's certified geometry bound and tolerance accounting (solidcore#2975).
/// The bound includes floating-point rounding. For edges it bounds both directed
/// Hausdorff distances via an endpoint-to-endpoint parameter correspondence; it
/// need not be the tightest Hausdorff bound. Unsupported enclosures return an
/// error from the reporting operation, never a sampled estimate as a certificate.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ClosedGap {
/// What the stitcher closed.
pub kind: ClosedGapKind,
/// Certified upper bound on the gap, in model units. A squeeze bounds the
/// whole removed curve's distance to the kept point; a split bounds the source
/// point's distance to the original curve at the split parameter.
pub gap: f64,
/// Upper bound on `max(tolerance_after - tolerance_before, 0)`, rounded up.
/// Exactly zero when the surviving entity's stored tolerance did not grow.
pub growth: f64,
/// The surviving entity's own stored tolerance before this closure. For a
/// split this is the new vertex's tolerance before its explicit adjustment.
pub tolerance_before: f64,
/// The surviving entity's stored tolerance after this closure.
pub tolerance_after: f64,
}
/// The result of [`Stitch::split_edges_with_vertices`].
#[derive(Debug, Clone, PartialEq)]
pub struct SplitEdges {
@@ -479,9 +535,36 @@ impl Stitch<'_> {
}
let surv_v = surv_v.ok_or_else(|| deref("edge without vertex"))?;
let deleted_report = del_v.unwrap_or(surv_v);
// Bound all the removed geometry, not its sampled length or
// merely the distance between its endpoints.
let squeeze_report = if self.report_gaps.is_some() {
Some((
vertex::tolerance(b, surv_v)?,
super::bounds::curve_to_point(
curve_of(b, e)?,
&edge::interval(b, e)?,
vertex::point(b, surv_v)?,
)?,
))
} else {
None
};
// remove and delete edge from topology graph by squeezing
// [vertex-edge-vertex] into [vertex]
self.edit_squeeze_edge(k, b, Some(e), surv_v)?;
if let Some((pre, gap)) = squeeze_report {
let after = vertex::tolerance(b, surv_v)?;
self.record_gap(
ClosedGapKind::Squeezed {
kept: surv_v,
deleted: deleted_report,
},
gap,
pre,
after,
)?;
}
if candidate_vertices.is_some() {
add_unique(&mut cand_vertices, surv_v); // This becomes a candidate.
@@ -570,7 +653,42 @@ impl Stitch<'_> {
// candidates
let new_tol =
smos_max(vertex::tolerance(b, v)?, vertex::tolerance(b, new_v)?);
let split_report = if self.report_gaps.is_some() {
let range = edge::interval(b, e)?;
let parameter = if edge::start_vertex(b, e)? == Some(new_v) {
range.min()
} else if edge::end_vertex(b, e)? == Some(new_v) {
range.max()
} else {
return Err(SolidError::invalid_input(
"heal::stitch::bounds",
"split vertex is not on either resulting edge end",
));
};
Some((
vertex::tolerance(b, new_v)?,
super::bounds::point_at_curve(
vertex::point(b, v)?,
curve_of(b, e)?,
parameter,
)?,
))
} else {
None
};
vertex::set_tolerance(b, new_v, zone_tol3d(k, new_tol), true, true)?;
if let Some((pre, bound)) = split_report {
let after = vertex::tolerance(b, new_v)?;
self.record_gap(
ClosedGapKind::Split {
edge: e,
created: new_v,
},
bound,
pre,
after,
)?;
}
more_work_needed = true;
cand_vertices.push(new_v);
@@ -734,6 +852,43 @@ impl Stitch<'_> {
continue;
}
// (not upstream, #2975) capture the closure record's operands
// before nothing else deflects them, and read the surviving
// entity's tolerance after.
let kept = if choice.keep_first { v } else { cv };
let deleted = if choice.keep_first { cv } else { v };
let pair_report = if self.report_gaps.is_some() && (do_glue || do_squeeze) {
Some((
vertex::tolerance(b, kept)?,
if do_squeeze {
if let Some(e) = squeeze_edge {
super::bounds::curve_to_point(
curve_of(b, e)?,
&edge::interval(b, e)?,
vertex::point(b, kept)?,
)?
} else {
super::bounds::points(
vertex::point(b, kept)?,
vertex::point(b, deleted)?,
)?
}
} else {
super::bounds::points(
vertex::point(b, kept)?,
vertex::point(b, deleted)?,
)?
},
if do_glue {
ClosedGapKind::Vertex { kept, deleted }
} else {
ClosedGapKind::Squeezed { kept, deleted }
},
))
} else {
None
};
// squeeze or glue vertices together (make pV the retained vertex)
if choice.keep_first {
if do_squeeze {
@@ -749,6 +904,10 @@ impl Stitch<'_> {
}
v = cv;
}
if let Some((pre, bound, kind)) = pair_report {
let after = vertex::tolerance(b, v)?;
self.record_gap(kind, bound, pre, after)?;
}
// The surviving vertex (which is pV) should be a candidate.
if candidate_vertices.is_some() {
@@ -1057,6 +1216,21 @@ impl Stitch<'_> {
for f in edge::faces(b, delete, None)? {
add_unique(modified_faces, f);
}
// (not upstream, #2975) capture the closure's operands before the
// glue, and the kept edge's tolerance after.
let edge_report = if self.report_gaps.is_some() {
Some((
edge::tolerance(b, keep)?,
super::bounds::curves(
curve_of(b, e)?,
[e_ivl.min(), e_ivl.max()],
curve_of(b, test_e)?,
[t_start, t_end],
)?,
))
} else {
None
};
match self.edit_glue_edges(
k,
b,
@@ -1076,6 +1250,18 @@ impl Stitch<'_> {
}
set_none(&mut v_edges, kk)?;
done_with_vertex = false;
if let Some((pre, bound)) = edge_report {
let after = edge::tolerance(b, e)?;
self.record_gap(
ClosedGapKind::Edge {
kept: e,
deleted: delete,
},
bound,
pre,
after,
)?;
}
}
Err(err) if err.is_unported_or_cancelled() => return Err(err),
Err(_) if !self.ignore_problems => return Err(sm_err()),
+1
View File
@@ -5,4 +5,5 @@
//! `SmVertexProps`, `SmObjProps` in `source/SMLib`, and the area/volume queries of
//! `SM_API/SmApiQueries`.
pub mod enclosure;
pub mod integrator;
File diff suppressed because it is too large Load Diff
+71 -4
View File
@@ -1354,10 +1354,8 @@ mod tests {
assert!(error.abs() <= 1e-6, "{error}");
}
/// The final step's last node is the interval's end itself: `-0.1 + (0.3 - -0.1)`
/// rounds to `0.30000000000000004`, past the end, where an integrand whose end is a
/// knot (a curve's span, a face's iso-u piece) would read the span beyond it. Every
/// node lies within `[x1, x2]`, and the step that ends the integration ends at `x2`.
/// A rate that is NaN, infinite or negative is refused before the integrand is
/// evaluated, and nothing is written.
#[test]
fn a_rate_that_is_not_a_finite_nonnegative_number_is_refused() {
for rate in [
@@ -1424,6 +1422,75 @@ mod tests {
}
}
/// `rate` on closed forms, as the curve length uses it: a range cut into `n` equal
/// pieces, each integrated to half the accuracy with the range's mean as its rate.
/// Each step is held to half the accuracy relative to its share of the range plus
/// its own increment, shares that add up to twice the integral, so the pieces' sum is
/// within the accuracy of the whole integral however many pieces and steps there are.
#[test]
fn a_rate_holds_a_range_cut_into_pieces_to_the_accuracy_of_the_whole() {
use core::f64::consts::PI;
type Case = (fn(f64) -> f64, f64, f64, f64);
let cases: [Case; 3] = [
// e^t on [0, 4]: e^4 - 1
(f64::exp, 0.0, 4.0, 4.0_f64.exp() - 1.0),
// Runge's 1 / (1 + 25 t^2) on [-1, 1]: 2/5 atan 5
(
|t| 1.0 / (1.0 + 25.0 * t * t),
-1.0,
1.0,
0.4 * 5.0_f64.atan(),
),
// 2 + sin 20t on [0, pi]: 2 pi (the sine's ten periods cancel)
(|t| 2.0 + (20.0 * t).sin(), 0.0, PI, 2.0 * PI),
];
for (g, a, b, exact) in cases {
let rate = exact / (b - a);
for accuracy in [1e-4, 1e-6, 1e-8, 1e-10] {
for n in [1_u32, 8, 64] {
let piece = (b - a) / f64::from(n);
let mut sum = 0.0;
for j in 0..n {
let x1 = a + f64::from(j) * piece;
let x2 = if j + 1 == n { b } else { x1 + piece };
let mut y = [0.0];
OdeIntegrator::default()
.integrate(
&mut y,
x1,
x2,
OdeOptions {
accuracy: accuracy / 2.0,
initial_step: (x2 - x1) / 10.0,
minimum_step: (x2 - x1) / 1e8,
scale: None,
quadrature: true,
rounding: None,
rate: Some(rate),
},
|t, _, dy| {
dy[0] = g(t);
Ok(())
},
)
.unwrap();
sum += y[0];
}
let error = (sum - exact).abs() / exact;
assert!(
error <= accuracy,
"[{a}, {b}] in {n} pieces at {accuracy:e}: {sum} against {exact}, \
relative error {error:e}"
);
}
}
}
}
/// The final step's last node is the interval's end itself: `-0.1 + (0.3 - -0.1)`
/// rounds to `0.30000000000000004`, past the end, where an integrand whose end is a
/// knot (a curve's span, a face's iso-u piece) would read the span beyond it. Every
/// node lies within `[x1, x2]`, and the step that ends the integration ends at `x2`.
#[test]
fn the_final_step_ends_at_the_interval_end_exactly() {
let (x1, x2) = (-0.1_f64, 0.3_f64);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+3
View File
@@ -6,8 +6,10 @@
pub mod api_queries;
pub mod brep;
pub mod contact;
pub mod distance_bound;
pub mod edge;
pub mod edge_turn;
#[cfg(test)]
pub(crate) mod exact;
pub mod face;
@@ -16,6 +18,7 @@ pub mod r#loop;
pub mod normal;
pub mod poly;
pub mod poly_properties;
pub mod tangent_edge;
pub mod tess;
pub use face_surface::face_surface_intersect;
+732
View File
@@ -0,0 +1,732 @@
//! Not upstream: whether two bodies meet (share material or touch within a tolerance),
//! decided only where it is proved: [`meets`] and its distance, [`boundary_distance`].
//!
//! | Rust | Upstream |
//! |------|----------|
//! | [`meets`], [`boundary_distance`], [`Prepared`] | none (`SmApiBrepRelationship`, ported as [`crate::query::api_queries::brep_relationship`], is a Newton solve: its gap is a point pair, never a lower bound, and its containment counts samples) |
//!
//! **The bracket.** The distance between the two bodies' boundaries is enclosed in `[lower,
//! upper]`:
//!
//! - `lower`: the least, over pairs of pieces of the two bodies' faces, of the distance
//! between their hulls' boxes. The pieces are those of
//! [`crate::query::distance_bound`] (each face's NURBS form over its UV box, its Bézier
//! patches about one common point, every coefficient's error carried and each box widened
//! by it), so no point of either boundary is nearer the other than `lower`. Pairs wait on a
//! heap, nearest first; the nearest is split (its larger piece, in four), so `lower`
//! rises. A piece whose UV rectangle is proved off its face (no trim curve's box meets it,
//! and its centre classifies outside the face) is dropped: a face's hole is no material.
//! - `upper` and its witness: a pair of points, one on each body, each on its face's surface
//! at parameters the face classifies decisively inside (`face_classify_uv`, the face
//! itself: neither on its boundary nor undecided), their distance rounded up. The
//! candidates are the centres of the pairs taken from the heap, each moved to the other
//! surface's closest point and back ([`crate::query::api_queries::surface_closest_point`],
//! a few times), so two surfaces that cross reach a distance of their rounding.
//!
//! **Meets.** At a tolerance `tol`: `Yes` when `upper <= tol` (with the witness); when
//! `lower > tol`, the boundaries do not touch, and the bodies meet only if one encloses the
//! other: a point of each boundary is classified in the other body, `Yes` when one is
//! inside its material, `No` when both are outside. A classification on the other's boundary
//! or not decided is no answer, and neither is a bracket that still straddles `tol` when
//! the pairs reach [`MAX_PAIRS`]: [`Verdict::Undecided`], with the bracket, which names
//! the size that would decide.
//!
//! Deviations: none (no upstream counterpart). No recursion: the pairs wait on a heap.
use std::cmp::Ordering;
use std::collections::BinaryHeap;
use crate::geom::certified::{gamma, Verdict};
use crate::geom::extent1d::Extent1d;
use crate::geom::tol::{brep_zone_tol_3d, SmallTopoType};
use crate::geom::vector2d::Point2;
use crate::intersect::point_class::PointClassType;
use crate::kernel::Kernel;
use crate::mass::integrator::properties::region_material;
use crate::math::Point3;
use crate::query::api_queries::{
edge_closest_point, face_bounding_box, face_classify_uv, surface_closest_point,
};
use crate::query::brep::BrepClassifier;
use crate::query::distance_bound::{pieces_about, Piece};
use crate::surface::surface::Surface;
use crate::topology::{edgeuse::p1 as eu1, loopuse, Brep, FaceId};
use crate::{EntityRef, Result, SolidError};
const OP: &str = "query::contact::meets";
/// The most piece pairs made for one answer.
pub const MAX_PAIRS: usize = 1 << 16;
/// The times a witness is moved to the other surface's closest point and back.
const PROJECTIONS: usize = 6;
/// How two bodies stand: the verdict at the tolerance asked, and the boundary distance's
/// bracket.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Contact {
/// `Yes` (they share material or their boundaries come within the tolerance), `No`, or
/// `Undecided` (the bracket straddles the tolerance at the work's limit, or a
/// classification was not decided).
pub verdict: Verdict,
/// A certified lower bound of the distance between the boundaries.
pub lower: f64,
/// An upper bound (the witness's distance, rounded up); infinite without a witness.
pub upper: f64,
/// Two points, one on each boundary, `upper` apart.
pub witness: Option<[Point3; 2]>,
}
/// A face's pieces and what decides whether a piece is on it.
struct FacePieces {
face: FaceId,
surface: Surface,
/// Its form's certified distance from its definition, lost from every bound.
slack: f64,
pieces: Vec<Piece>,
/// The boxes of its own UV trim curves `[u0, u1, v0, v1]`; `None` when a trim curve or
/// its box is missing (then no piece is dropped).
trims: Option<Vec<[f64; 4]>>,
}
/// A body made ready for contact tests: its faces' pieces about a common point.
pub struct Prepared {
origin: Point3,
faces: Vec<FacePieces>,
}
/// A pair of pieces, one of each body, with its lower bound.
struct Pair {
lower: f64,
a: (usize, Piece),
b: (usize, Piece),
}
impl PartialEq for Pair {
fn eq(&self, o: &Self) -> bool {
self.lower.total_cmp(&o.lower) == Ordering::Equal
}
}
impl Eq for Pair {}
impl PartialOrd for Pair {
fn partial_cmp(&self, o: &Self) -> Option<Ordering> {
Some(self.cmp(o))
}
}
impl Ord for Pair {
// the heap pops the smallest bound first
fn cmp(&self, o: &Self) -> Ordering {
o.lower.total_cmp(&self.lower)
}
}
/// The box of body `b`'s faces, `(lo, hi)`.
fn body_box(k: &Kernel, b: &Brep) -> Result<(Point3, Point3)> {
let mut lo = Point3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
let mut hi = Point3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
for &f in b.face_list() {
let e = face_bounding_box(k, b, f, false)?;
let (l, h) = (e.min(), e.max());
lo = Point3::new(lo.x.min(l.x), lo.y.min(l.y), lo.z.min(l.z));
hi = Point3::new(hi.x.max(h.x), hi.y.max(h.y), hi.z.max(h.z));
}
if !(lo.x <= hi.x && lo.y <= hi.y && lo.z <= hi.z) {
return Err(SolidError::invalid_input(OP, "a body without faces"));
}
Ok((lo, hi))
}
/// The common point two bodies' pieces are made about: the centre of their boxes.
fn common_origin(k: &Kernel, a: &Brep, b: &Brep) -> Result<Point3> {
let ((al, ah), (bl, bh)) = (body_box(k, a)?, body_box(k, b)?);
let mid = |x: f64, y: f64, z: f64, w: f64| 0.5 * (x.min(z) + y.max(w));
Ok(Point3::new(
mid(al.x, ah.x, bl.x, bh.x),
mid(al.y, ah.y, bl.y, bh.y),
mid(al.z, ah.z, bl.z, bh.z),
))
}
/// The parts each Bézier segment of a trim curve is cut into for its boxes: a rectangle
/// that no part's box meets is proved clear of the curve.
const TRIM_PARTS: usize = 16;
/// The boxes, `[u0, u1, v0, v1]`, of a UV trim curve's NURBS form: each Bézier segment cut in
/// [`TRIM_PARTS`] by de Casteljau on its homogeneous points, each part in the box of its
/// Euclidean points (its hull: positive weights), widened by the subdivision's rounding;
/// `None` for a form whose weights are not positive.
fn nurb_boxes(c: &crate::nurbs::crv_basic::Curve) -> Result<Option<Vec<[f64; 4]>>> {
use crate::nurbs::math::NOW;
let mut out = Vec::new();
for seg in c.decompose_bez()? {
let pts: Vec<[f64; 3]> = seg
.cpts()
.iter()
.map(|q| {
let w = if q.w == NOW { 1.0 } else { q.w };
[q.x, q.y, w]
})
.collect();
if pts
.iter()
.any(|q| !(q[2] > 0.0 && q.iter().all(|v| v.is_finite())))
{
return Ok(None);
}
let size = pts.iter().flatten().fold(0.0_f64, |m, v| m.max(v.abs()));
let wmin = pts.iter().fold(f64::INFINITY, |m, q| m.min(q[2]));
// the right part from t: de Casteljau at t on the homogeneous points
let cut = |p: &[[f64; 3]], t: f64| -> (Vec<[f64; 3]>, Vec<[f64; 3]>) {
let mut level = p.to_vec();
let (mut left, mut right) = (Vec::new(), Vec::new());
while let (Some(&f), Some(&l)) = (level.first(), level.last()) {
left.push(f);
right.push(l);
level = level
.windows(2)
.filter_map(|w| match w {
[a, b] => Some([
(1.0 - t) * a[0] + t * b[0],
(1.0 - t) * a[1] + t * b[1],
(1.0 - t) * a[2] + t * b[2],
]),
_ => None,
})
.collect();
}
right.reverse();
(left, right)
};
let degree = pts.len().saturating_sub(1);
// TRIM_PARTS levels of convex combinations at most: their rounding, of the size
let err = gamma(3 * degree * TRIM_PARTS + 3) * size;
let mut rest = pts;
for i in 0..TRIM_PARTS {
let t = 1.0 / (TRIM_PARTS - i) as f64;
let (part, next) = if i + 1 == TRIM_PARTS {
(rest.clone(), Vec::new())
} else {
cut(&rest, t)
};
rest = next;
let (mut lo, mut hi) = ([f64::INFINITY; 2], [f64::NEG_INFINITY; 2]);
for q in &part {
for (k, v) in [q[0] / q[2], q[1] / q[2]].into_iter().enumerate() {
if let (Some(l), Some(h)) = (lo.get_mut(k), hi.get_mut(k)) {
*l = l.min(v);
*h = h.max(v);
}
}
}
// a quotient of coefficients within err: within err (1 + |x|) / (w - err)
let widen = err * (1.0 + size / wmin) / (wmin - err).max(f64::MIN_POSITIVE)
+ gamma(2) * size / wmin;
out.push([lo[0] - widen, hi[0] + widen, lo[1] - widen, hi[1] + widen]);
}
}
Ok(Some(out))
}
/// The boxes of face `f`'s own UV trim curves (each its NURBS form's parts,
/// [`nurb_boxes`], or the curve's whole box where it has none); `None` where a trim curve or
/// its box is missing.
fn trim_boxes(k: &Kernel, b: &Brep, f: FaceId) -> Result<Option<Vec<[f64; 4]>>> {
use crate::curve::curve::CurveKind;
let Some(fu) = b.face(f)?.faceuse else {
return Ok(None);
};
let mut out = Vec::new();
for lu in eu1::faceuse_loopuses(b, fu, false, None)? {
for eu in loopuse::edgeuses(b, lu, None, None)? {
// its own, or its mate's on the face's other side (the same face's parameters)
let Some(c) = eu1::uv_trim_curve(b, eu)?.0 else {
return Ok(None);
};
let nurb = match c.kind() {
CurveKind::BSpline(x) => x.gw_nurb(),
CurveKind::Ellipse(x) => x.base().gw_nurb(),
CurveKind::Hyperbola(x) => x.base().gw_nurb(),
CurveKind::Parabola(x) => x.base().gw_nurb(),
CurveKind::Line(x) => x.base().gw_nurb(),
_ => None,
};
if let Some(n) = nurb {
if let Some(boxes) = nurb_boxes(n)? {
out.extend(boxes);
continue;
}
}
let interval: Extent1d = c.natural_interval();
let boxes = c.calculate_bounding_box(
k,
&interval,
crate::nurbs::b_spline_curve::BoundingBoxRequest {
normal: true,
..Default::default()
},
true,
)?;
let Some(e) = boxes.normal else {
return Ok(None);
};
let (l, h) = (e.min(), e.max());
// the box's own rounding, a few ulps of its size
let r = 16.0 * f64::EPSILON * (l.x.abs() + h.x.abs() + l.y.abs() + h.y.abs());
out.push([l.x - r, h.x + r, l.y - r, h.y + r]);
}
}
Ok(Some(out))
}
impl Prepared {
/// Body `b`'s faces' pieces about `origin`.
///
/// # Errors
///
/// `InvalidInput` for a body without faces or a face without a surface; those of the
/// pieces ([`crate::query::distance_bound`]) and the trim curves' boxes.
pub fn new(k: &Kernel, b: &Brep, origin: Point3) -> Result<Self> {
let mut faces = Vec::new();
for &f in b.face_list() {
let face = b.face(f)?;
let surface = face
.surface
.as_ref()
.ok_or_else(|| SolidError::invalid_input(OP, "a face without a surface"))?
.clone();
let (pieces, slack) = pieces_about(k, &surface, Some(&face.uv_domain), origin)?;
let trims = trim_boxes(k, b, f)?;
faces.push(FacePieces {
face: f,
surface,
slack,
pieces,
trims,
});
}
if faces.is_empty() {
return Err(SolidError::invalid_input(OP, "a body without faces"));
}
Ok(Self { origin, faces })
}
/// Body `b`'s faces' pieces about the centre of its own box: the preparation of one
/// body for tests against several ([`meets_prepared`]).
///
/// # Errors
///
/// Those of [`Prepared::new`] and of the faces' boxes.
pub fn about(k: &Kernel, b: &Brep) -> Result<Self> {
Self::new(k, b, common_origin(k, b, b)?)
}
}
/// The certified lower bound of the distance between two pieces about the same point: the
/// larger of the distance of their widened boxes and, along a unit direction `axis` (the
/// witness's, when there is one), the gap between their hulls' extents
/// ([`Piece::extent_along`]), each rounded down. The axis steers which bound is tight, never
/// whether either holds: any direction separates the hulls by at most their distance.
fn pair_lower(a: &Piece, b: &Piece, axis: Option<[f64; 3]>) -> Result<f64> {
let by_box = box_lower(a, b)?;
let Some(u) = axis else {
return Ok(by_box);
};
let len = (u[0] * u[0] + u[1] * u[1] + u[2] * u[2]).sqrt() * (1.0 + gamma(4));
if !(len > 0.0 && len.is_finite()) {
return Ok(by_box);
}
let ((a0, a1), (b0, b1)) = (a.extent_along(u)?, b.extent_along(u)?);
let gap = (b0 - a1).max(a0 - b1).max(0.0);
// (the difference rounded down by gamma_1 of its terms, then over the length)
let gap = (gap - gamma(1) * (a0.abs() + a1.abs() + b0.abs() + b1.abs())).max(0.0);
let by_axis = gap / len * (1.0 - gamma(2));
Ok(by_box.max(by_axis))
}
/// The distance of two pieces' widened boxes, rounded down.
fn box_lower(a: &Piece, b: &Piece) -> Result<f64> {
let ((al, ah), (bl, bh)) = (a.widened_box()?, b.widened_box()?);
let mut sum = 0.0_f64;
for i in 0..3 {
let (Some(&x0), Some(&x1), Some(&y0), Some(&y1)) =
(al.get(i), ah.get(i), bl.get(i), bh.get(i))
else {
return Err(SolidError::sm_err(OP));
};
let gap = (y0 - x1).max(x0 - y1).max(0.0);
// (the difference rounded up by at most gamma_1 of its terms)
let gap = (gap - gamma(1) * (x0.abs() + x1.abs() + y0.abs() + y1.abs())).max(0.0);
sum += gap * gap;
}
Ok(sum.sqrt() * (1.0 - gamma(5)))
}
/// Where a piece stands against its face: `Some(true)` proved on it and decisively inside
/// at its centre, `Some(false)` proved off it (no trim curve's box meets its rectangle and
/// its centre is outside), `None` not decided.
fn piece_on_face(k: &Kernel, b: &mut Brep, fp: &FacePieces, p: &Piece) -> Result<Option<bool>> {
let [u0, u1, v0, v1] = p.uv;
let crosses = match &fp.trims {
None => true,
Some(boxes) => boxes
.iter()
.any(|t| !(t[1] < u0 || t[0] > u1 || t[3] < v0 || t[2] > v1)),
};
let centre = Point2::new(0.5 * (u0 + u1), 0.5 * (v0 + v1));
let class = match face_classify_uv(k, b, fp.face, centre) {
Ok(c) => c,
// a classification that fails decides nothing
Err(e) if e.is_cancelled() => return Err(e),
Err(_) => return Ok(None),
};
Ok(match class {
PointClassType::Unknown if !crosses => Some(false),
PointClassType::Face => Some(true),
_ => None,
})
}
/// A point of body `b` for face `fp` at `uv`: the surface's point when the face classifies
/// `uv` decisively inside it; when it classifies it on the face's boundary (within its
/// tolerance, either side), the nearest point of the face's edges to the surface's point,
/// which is on the body whichever side `uv` was; `None` outside or not decided.
fn on_face(k: &Kernel, b: &mut Brep, fp: &FacePieces, uv: Point2) -> Result<Option<Point3>> {
match face_classify_uv(k, b, fp.face, uv) {
Ok(PointClassType::Face) => Ok(Some(fp.surface.evaluate_point(k, uv)?)),
Ok(PointClassType::Edge | PointClassType::Vertex) => {
let x = fp.surface.evaluate_point(k, uv)?;
nearest_edge_point(k, b, fp.face, x)
}
Err(e) if e.is_cancelled() => Err(e),
_ => Ok(None),
}
}
/// The nearest point to `x` of face `f`'s edges (each solved on its edge, so on the body);
/// `None` when no edge's solve succeeds.
fn nearest_edge_point(k: &Kernel, b: &Brep, f: FaceId, x: Point3) -> Result<Option<Point3>> {
let Some(fu) = b.face(f)?.faceuse else {
return Ok(None);
};
let mut best: Option<(f64, Point3)> = None;
for lu in eu1::faceuse_loopuses(b, fu, false, None)? {
for eu in loopuse::edgeuses(b, lu, None, None)? {
let Some(e) = b.edgeuse(eu)?.edge else {
continue;
};
let Ok(c) = edge_closest_point(k, b, e, x) else {
continue;
};
let d = c.closest.point.distance(x);
if best.is_none_or(|(m, _)| d < m) {
best = Some((d, c.closest.point));
}
}
}
Ok(best.map(|(_, p)| p))
}
/// The distance of two points rounded up: `|p - q|` within `gamma_5` of the terms' size.
fn distance_up(p: Point3, q: Point3) -> f64 {
let size = p.x.abs() + p.y.abs() + p.z.abs() + q.x.abs() + q.y.abs() + q.z.abs();
p.distance(q) * (1.0 + gamma(5)) + gamma(5) * size
}
/// The closest point of `fp`'s surface to `x`, kept when its face classifies it decisively
/// inside; `None` for a solve that fails or a point off the face.
fn closest_on(
k: &Kernel,
b: &mut Brep,
fp: &FacePieces,
x: Point3,
) -> Result<Option<(Point3, Point2)>> {
let Ok(c) = surface_closest_point(k, &fp.surface, x) else {
return Ok(None);
};
let Some(&uv) = c.uvs.first() else {
return Ok(None);
};
Ok(on_face(k, b, fp, uv)?.map(|p| (p, uv)))
}
/// The bracket between `a` and `b` (as [`Prepared`]), refined until `stop` says done or the
/// pairs reach [`MAX_PAIRS`]: `stop(lower, upper)`.
fn bracket(
k: &Kernel,
a: &mut Brep,
pa: &Prepared,
b: &mut Brep,
pb: &Prepared,
seed: f64,
stop: &dyn Fn(f64, f64) -> bool,
) -> Result<(f64, f64, Option<[Point3; 2]>, bool)> {
let mut heap = BinaryHeap::new();
let mut made = 0_usize;
for (ia, fa) in pa.faces.iter().enumerate() {
for (ib, fb) in pb.faces.iter().enumerate() {
for x in &fa.pieces {
for y in &fb.pieces {
let lower = (pair_lower(x, y, None)? - fa.slack - fb.slack).max(0.0);
heap.push(Pair {
lower,
a: (ia, x.clone()),
b: (ib, y.clone()),
});
made += 1;
}
}
}
}
let (mut upper, mut witness) = (f64::INFINITY, None);
// witnesses first from every face pair whose bound is within `seed`, nearest first (the
// pairs tie closely where two bodies lie along each other; each is visited once here)
let mut first: Vec<&Pair> = heap.iter().filter(|p| p.lower <= seed).collect();
first.sort_by(|x, y| x.lower.total_cmp(&y.lower));
for pair in first {
let (Some(fa), Some(fb)) = (pa.faces.get(pair.a.0), pb.faces.get(pair.b.0)) else {
return Err(SolidError::sm_err(OP));
};
if pair.lower < upper {
witness_from(
k,
(&mut *a, fa, &pair.a.1),
(&mut *b, fb, &pair.b.1),
&mut upper,
&mut witness,
)?;
}
if stop(0.0, upper) {
break;
}
}
loop {
k.check_cancelled(OP)?;
let Some(pair) = heap.pop() else {
// every piece proved off its face: no boundary is there to bound
return Ok((f64::INFINITY, upper, witness, true));
};
let lower = pair.lower.min(upper);
if stop(lower, upper) {
return Ok((lower, upper, witness, true));
}
if made >= MAX_PAIRS {
return Ok((lower, upper, witness, false));
}
let (ia, x) = &pair.a;
let (ib, y) = &pair.b;
let (Some(fa), Some(fb)) = (pa.faces.get(*ia), pb.faces.get(*ib)) else {
return Err(SolidError::sm_err(OP));
};
// a witness from the pair's centres (none can be nearer than the pair's bound, so a
// pair past the witness so far is skipped)
if pair.lower < upper {
witness_from(
k,
(&mut *a, fa, x),
(&mut *b, fb, y),
&mut upper,
&mut witness,
)?;
}
// split the larger piece
let size = |p: &Piece| -> Result<f64> {
let (l, h) = p.widened_box()?;
Ok((0..3)
.map(|i| h.get(i).copied().unwrap_or(0.0) - l.get(i).copied().unwrap_or(0.0))
.fold(0.0_f64, f64::max))
};
let split_a = size(x)? >= size(y)?;
let (from, keep) = if split_a { (x, y) } else { (y, x) };
// the witness's direction, for the hulls' extents along it
let axis = witness.map(|[p, q]| [q.x - p.x, q.y - p.y, q.z - p.z]);
for q in from.quarters()? {
let (fp, brep) = if split_a {
(fa, &mut *a)
} else {
(fb, &mut *b)
};
if piece_on_face(k, brep, fp, &q)? == Some(false) {
continue;
}
let (qa, qb) = if split_a { (&q, keep) } else { (keep, &q) };
let lower = (pair_lower(qa, qb, axis)? - fa.slack - fb.slack).max(0.0);
heap.push(Pair {
lower,
a: (*ia, qa.clone()),
b: (*ib, qb.clone()),
});
made += 1;
}
}
}
/// A witness from a pair's centres, each moved to the other's surface's closest point and
/// back ([`PROJECTIONS`] times), kept where both points are decisively on their faces and
/// nearer than `upper`.
fn witness_from(
k: &Kernel,
(a, fa, x): (&mut Brep, &FacePieces, &Piece),
(b, fb, y): (&mut Brep, &FacePieces, &Piece),
upper: &mut f64,
witness: &mut Option<[Point3; 2]>,
) -> Result<()> {
let (_, ua) = x.centre();
let (_, ub) = y.centre();
let mut pa_pt = on_face(k, a, fa, Point2::new(ua[0], ua[1]))?;
let mut pb_pt = on_face(k, b, fb, Point2::new(ub[0], ub[1]))?;
for _ in 0..PROJECTIONS {
if let Some(p) = pa_pt {
if let Some((q, _)) = closest_on(k, b, fb, p)? {
pb_pt = Some(q);
}
}
if let Some(q) = pb_pt {
if let Some((p, _)) = closest_on(k, a, fa, q)? {
pa_pt = Some(p);
}
}
if let (Some(p), Some(q)) = (pa_pt, pb_pt) {
let d = distance_up(p, q) + fa.slack + fb.slack;
if d < *upper {
*upper = d;
*witness = Some([p, q]);
}
}
}
Ok(())
}
/// A point of `inner`'s boundary classified in `outer`: `Some(true)` inside its material,
/// `Some(false)` outside it, `None` when none of `inner`'s points tried (its pieces'
/// centres, where their faces hold them: [`on_face`]) is proved more than twice the
/// classification's tolerance from `outer`'s boundary
/// ([`crate::query::distance_bound::face_distance_lower_bound`] over `outer`'s faces), or
/// its classification is not decided.
fn point_in(
k: &Kernel,
inner: &mut Brep,
p_inner: &Prepared,
outer: &Brep,
) -> Result<Option<bool>> {
use crate::query::distance_bound::face_distance_lower_bound;
let tol = brep_zone_tol_3d(k, outer, SmallTopoType::NormalSize);
let clear = 2.0 * tol;
for fp in &p_inner.faces {
for piece in &fp.pieces {
let (_, uv) = piece.centre();
let Some(p) = on_face(k, inner, fp, Point2::new(uv[0], uv[1]))? else {
continue;
};
let mut far = true;
for &g in outer.face_list() {
if face_distance_lower_bound(k, outer, g, p, clear)? <= clear {
far = false;
break;
}
}
if !far {
continue;
}
let mut classifier = BrepClassifier::of_brep(outer);
let pc = match classifier.point_3d_classify(k, p, tol, true) {
Ok(pc) => pc,
Err(e) if e.is_cancelled() => return Err(e),
Err(_) => return Ok(None),
};
return Ok(match (pc.point_class(), pc.object()) {
(PointClassType::Region, Some(EntityRef::Region(r))) => {
Some(region_material(classifier.brep(), r)?)
}
_ => None,
});
}
}
Ok(None)
}
/// Whether `a` and `b` meet at `tol` (module docs), `a` prepared once for several `b`s.
///
/// # Errors
///
/// `InvalidInput` for a tolerance not finite and positive, or a body without faces; those
/// of the pieces, the classifications' cancellation and the closest-point solves' setup.
pub fn meets_prepared(
k: &Kernel,
a: &mut Brep,
pa: &Prepared,
b: &mut Brep,
tol: f64,
) -> Result<Contact> {
if !(tol.is_finite() && tol > 0.0) {
return Err(SolidError::invalid_input(
OP,
"the tolerance is finite and positive",
));
}
let pb = Prepared::new(k, b, pa.origin)?;
let stop = |lower: f64, upper: f64| upper <= tol || lower > tol;
let (lower, upper, witness, done) = bracket(k, a, pa, b, &pb, tol, &stop)?;
let verdict = if upper <= tol {
Verdict::Yes
} else if !done {
Verdict::Undecided
} else {
// the boundaries are more than tol apart: they meet only if one encloses the other
match (point_in(k, b, &pb, a)?, point_in(k, a, pa, b)?) {
(Some(true), _) | (_, Some(true)) => Verdict::Yes,
(Some(false), Some(false)) => Verdict::No,
_ => Verdict::Undecided,
}
};
Ok(Contact {
verdict,
lower,
upper,
witness,
})
}
/// [`meets_prepared`] for one pair: `a` prepared about the centre of both bodies' boxes.
///
/// # Errors
///
/// Those of [`meets_prepared`].
pub fn meets(k: &Kernel, a: &mut Brep, b: &mut Brep, tol: f64) -> Result<Contact> {
let origin = common_origin(k, a, b)?;
let pa = Prepared::new(k, a, origin)?;
meets_prepared(k, a, &pa, b, tol)
}
/// The bracket of the distance between `a`'s and `b`'s boundaries, refined until it is at
/// most `width` wide (relative to the upper end, or absolute below 1) or the pairs reach
/// [`MAX_PAIRS`]; the verdict is `Yes` when it is that narrow, `Undecided` when the work
/// ran out first. The bracket always holds the distance; it can stay wider than `width`
/// where a nearest point lies on a face's trim, since a piece straddling the trim carries
/// surface off the face and is cut finer only along the trim.
///
/// # Errors
///
/// `InvalidInput` for a width not finite and positive; those of [`meets`].
pub fn boundary_distance(k: &Kernel, a: &mut Brep, b: &mut Brep, width: f64) -> Result<Contact> {
if !(width.is_finite() && width > 0.0) {
return Err(SolidError::invalid_input(
OP,
"the width is finite and positive",
));
}
let origin = common_origin(k, a, b)?;
let pa = Prepared::new(k, a, origin)?;
let pb = Prepared::new(k, b, origin)?;
let stop = |lower: f64, upper: f64| upper - lower <= width * upper.max(1.0);
let (lower, upper, witness, done) = bracket(k, a, &pa, b, &pb, f64::INFINITY, &stop)?;
Ok(Contact {
verdict: if done {
Verdict::Yes
} else {
Verdict::Undecided
},
lower,
upper,
witness,
})
}
+181 -72
View File
@@ -44,15 +44,18 @@ pub const MAX_PATCHES: usize = 4096;
/// A piece of a face's surface: its homogeneous coordinates about the point, as Bernstein
/// polynomials on the unit square, and the absolute error each coefficient may carry.
#[derive(Debug, Clone)]
struct Piece {
pub(crate) struct Piece {
/// `X W - p W`, `Y W - p W`, `Z W - p W`, `W`.
h: [Bernstein2; 4],
pub(crate) h: [Bernstein2; 4],
/// The bound on every coefficient's error.
err: f64,
pub(crate) err: f64,
/// The certified lower bound of the piece's distance from the point.
lower: f64,
pub(crate) lower: f64,
/// Its subdivision depth.
depth: usize,
pub(crate) depth: usize,
/// Its rectangle in the surface's NURB parameters, `[u0, u1, v0, v1]` (to the rounding
/// of the splits that made it).
pub(crate) uv: [f64; 4],
}
impl PartialEq for Piece {
@@ -74,21 +77,62 @@ impl Ord for Piece {
}
impl Piece {
/// The piece with its bound, the coefficients `h` within `err` each.
fn new(h: [Bernstein2; 4], err: f64, depth: usize) -> Result<Self> {
/// The piece with its bound, the coefficients `h` within `err` each, over `uv`.
fn new(h: [Bernstein2; 4], err: f64, depth: usize, uv: [f64; 4]) -> Result<Self> {
let lower = hull_bound(&h, err)?;
Ok(Self {
h,
err,
lower,
depth,
uv,
})
}
/// The box of its Euclidean control points about the point, each coordinate widened
/// by the error a quotient of coefficients within `err` can carry: every point of the
/// piece lies in it (the hull, with positive weights).
pub(crate) fn widened_box(&self) -> Result<([f64; 3], [f64; 3])> {
let (lo, hi, widen) = euclid_box(&self.h, self.err)?;
Ok((lo.map(|l| l - widen), hi.map(|u| u + widen)))
}
/// Its centre's Euclidean point about the point, and the centre's parameters.
pub(crate) fn centre(&self) -> ([f64; 3], [f64; 2]) {
let [x, y, z, w] = &self.h;
let wc = w.eval(0.5, 0.5);
let [u0, u1, v0, v1] = self.uv;
(
[
x.eval(0.5, 0.5) / wc,
y.eval(0.5, 0.5) / wc,
z.eval(0.5, 0.5) / wc,
],
[0.5 * (u0 + u1), 0.5 * (v0 + v1)],
)
}
/// The certified range of `x . u` over the piece, for a direction `u`: the least and
/// largest of its Euclidean control points' (its hull), each moved out by the widening
/// along `u` and the products' rounding.
pub(crate) fn extent_along(&self, u: [f64; 3]) -> Result<(f64, f64)> {
let (lo, hi, widen) = euclid_box(&self.h, self.err)?;
let size = lo.iter().chain(&hi).fold(0.0_f64, |m, v| m.max(v.abs())) + widen;
let norm1 = u[0].abs() + u[1].abs() + u[2].abs();
let (mut least, mut most) = (f64::INFINITY, f64::NEG_INFINITY);
for e in euclid_points(&self.h) {
let d = e[0] * u[0] + e[1] * u[1] + e[2] * u[2];
least = least.min(d);
most = most.max(d);
}
let slack = widen * norm1 + gamma(3) * size * norm1;
Ok((least - slack, most + slack))
}
/// Its four quarters: one de Casteljau level each way, which adds the rounding of a
/// convex combination per level (`gamma` of twice the degree plus two) of the largest
/// coefficient.
fn quarters(&self) -> Result<[Self; 4]> {
pub(crate) fn quarters(&self) -> Result<[Self; 4]> {
let largest = self
.h
.iter()
@@ -108,14 +152,23 @@ impl Piece {
split(&self.h[2]),
split(&self.h[3]),
];
let [u0, u1, v0, v1] = self.uv;
let (um, vm) = (0.5 * (u0 + u1), 0.5 * (v0 + v1));
let rects = [
[u0, um, v0, vm],
[u0, um, vm, v1],
[um, u1, v0, vm],
[um, u1, vm, v1],
];
let mut out = Vec::new();
for i in 0..4 {
for (i, uv) in rects.into_iter().enumerate() {
let pick =
|a: &[Bernstein2; 4]| a.get(i).cloned().ok_or_else(|| SolidError::sm_err(OP));
out.push(Self::new(
[pick(&x)?, pick(&y)?, pick(&z)?, pick(&w)?],
err,
self.depth + 1,
uv,
)?);
}
out.try_into().map_err(|_| SolidError::sm_err(OP))
@@ -130,42 +183,15 @@ impl Piece {
/// least the least P . u, and |X| >= X . u / |u|), which, unlike the box, tightens with the
/// square of the piece's size on a piece tilted to the axes.
fn hull_bound(h: &[Bernstein2; 4], err: f64) -> Result<f64> {
let [x, y, z, w] = h;
let (xs, ys, zs, ws) = (
x.coefficients(),
y.coefficients(),
z.coefficients(),
w.coefficients(),
);
let w_min = ws.iter().copied().fold(f64::INFINITY, f64::min);
if !above(w_min - err, 0.0) {
return Err(SolidError::Unsupported {
operation: OP,
reason: "a patch's weights are not proved positive",
});
}
let mut points = Vec::new();
let mut lo = [f64::INFINITY; 3];
let mut hi = [f64::NEG_INFINITY; 3];
let mut size = 0.0_f64;
let (lo, hi, widen) = euclid_box(h, err)?;
let points = euclid_points(h);
let size = lo.iter().chain(&hi).fold(0.0_f64, |m, v| m.max(v.abs()));
let mut sum_points = [0.0_f64; 3];
for (((&a, &b), &c), &wi) in xs.iter().zip(ys).zip(zs).zip(ws) {
let e = [a / wi, b / wi, c / wi];
for (k, v) in e.into_iter().enumerate() {
size = size.max(v.abs());
if let (Some(l), Some(u), Some(t)) =
(lo.get_mut(k), hi.get_mut(k), sum_points.get_mut(k))
{
*l = l.min(v);
*u = u.max(v);
*t += v;
}
for e in &points {
for (t, v) in sum_points.iter_mut().zip(e) {
*t += v;
}
points.push(e);
}
// |(v + dv) / (w + dw) - v / w| <= err (1 + |v / w|) / (w - err), and the quotient's
// own rounding
let widen = err * (1.0 + size) / (w_min - err) + gamma(2) * size;
let mut sum = 0.0_f64;
for (l, u) in lo.into_iter().zip(hi) {
let out = (l - widen).max(-(u + widen)).max(0.0);
@@ -202,6 +228,61 @@ fn hull_bound(h: &[Bernstein2; 4], err: f64) -> Result<f64> {
Ok(bound.max(0.0))
}
/// The Euclidean control points of the homogeneous coefficients `h`.
fn euclid_points(h: &[Bernstein2; 4]) -> Vec<[f64; 3]> {
let [x, y, z, w] = h;
x.coefficients()
.iter()
.zip(y.coefficients())
.zip(z.coefficients())
.zip(w.coefficients())
.map(|(((&a, &b), &c), &wi)| [a / wi, b / wi, c / wi])
.collect()
}
/// The box of the Euclidean control points of `h`, and how far each coordinate is to be
/// widened for coefficients within `err` (and the quotients' own rounding): `(lo, hi,
/// widen)`.
fn euclid_box(h: &[Bernstein2; 4], err: f64) -> Result<([f64; 3], [f64; 3], f64)> {
let [x, y, z, w] = h;
let (xs, ys, zs, ws) = (
x.coefficients(),
y.coefficients(),
z.coefficients(),
w.coefficients(),
);
let w_min = ws.iter().copied().fold(f64::INFINITY, f64::min);
if !above(w_min - err, 0.0) {
return Err(SolidError::Unsupported {
operation: OP,
reason: "a patch's weights are not proved positive",
});
}
let mut lo = [f64::INFINITY; 3];
let mut hi = [f64::NEG_INFINITY; 3];
let mut size = 0.0_f64;
for (((&a, &b), &c), &wi) in xs.iter().zip(ys).zip(zs).zip(ws) {
let e = [a / wi, b / wi, c / wi];
for (k, v) in e.into_iter().enumerate() {
size = size.max(v.abs());
if let (Some(l), Some(u)) = (lo.get_mut(k), hi.get_mut(k)) {
*l = l.min(v);
*u = u.max(v);
}
}
}
// |(v + dv) / (w + dw) - v / w| <= err (1 + |v / w|) / (w - err), and the quotient's
// own rounding
let widen = err * (1.0 + size) / (w_min - err) + gamma(2) * size;
if !(widen.is_finite() && lo.iter().chain(&hi).all(|v| v.is_finite())) {
return Err(SolidError::Unsupported {
operation: OP,
reason: "a patch's box is not finite",
});
}
Ok((lo, hi, widen))
}
/// A certified lower bound of the distance from `p` to face `f` of `b`: no point of the face
/// is nearer to `p`. The face is bounded by its surface over the face's UV box (its trims
/// lie inside it): [`surface_distance_lower_bound`] on them.
@@ -320,6 +401,51 @@ pub fn surface_distance_lower_bound(
"the bound asked for is finite and not negative",
));
}
let (pieces, slack) = pieces_about(k, surf, uv_box, p)?;
let want_net = want + slack;
let mut made = pieces.len();
let mut heap: BinaryHeap<Piece> = pieces.into_iter().collect();
let off = |lower: f64| (lower - slack).max(0.0);
loop {
// (the box is inside the patches' range, so some patch overlaps it)
let nearest = heap.pop().ok_or_else(|| SolidError::sm_err(OP))?;
if nearest.lower >= want_net || made >= MAX_PATCHES {
return Ok(off(nearest.lower));
}
// the piece's centre is a point of the net over the box: nearer than the target, no
// bound reaches it
let (centre, _) = nearest.centre();
if (centre[0] * centre[0] + centre[1] * centre[1] + centre[2] * centre[2]).sqrt() < want_net
{
return Ok(off(nearest.lower));
}
for q in nearest.quarters()? {
heap.push(q);
}
made += 4;
}
}
/// The pieces of `surf` over `uv_box` (as [`surface_distance_lower_bound`] takes it) about
/// `p`: its NURBS form's Bézier patches restricted to the box, translated by `-p`, each
/// with its coefficients' error (the form's against its definition, the extraction's and
/// the translation's, module docs); and the slack every distance from them is to lose (a
/// net's certified bound from its definition, 0 for a surface that evaluates on its net).
/// The one construction of the face pieces that [`surface_distance_lower_bound`] and the
/// contact test between bodies ([`crate::query::contact`]) bound distances with.
///
/// # Errors
///
/// As [`surface_distance_lower_bound`]'s, but for `want`.
pub(crate) fn pieces_about(
k: &Kernel,
surf: &Surface,
uv_box: Option<&Extent2d>,
p: Point3,
) -> Result<(Vec<Piece>, f64)> {
if !(p.x.is_finite() && p.y.is_finite() && p.z.is_finite()) {
return Err(SolidError::invalid_input(OP, "point not finite"));
}
if !measurable(k, surf)? {
return Err(SolidError::Unsupported {
operation: OP,
@@ -424,9 +550,7 @@ pub fn surface_distance_lower_bound(
));
}
}
let want_net = want + slack;
let mut heap = BinaryHeap::new();
let mut made = 0_usize;
let mut pieces = Vec::new();
for patch in patches.iter().flatten() {
let (pu, pv) = patch.degrees();
let (uk, vk) = (patch.u_knots(), patch.v_knots());
@@ -496,33 +620,15 @@ pub fn surface_distance_lower_bound(
} else {
h
};
heap.push(Piece::new(h, err, 0)?);
made += 1;
}
let off = |lower: f64| (lower - slack).max(0.0);
loop {
// (the box is inside the patches' range, so some patch overlaps it)
let nearest = heap.pop().ok_or_else(|| SolidError::sm_err(OP))?;
if nearest.lower >= want_net || made >= MAX_PATCHES {
return Ok(off(nearest.lower));
}
// the piece's centre is a point of the net over the box: nearer than the target, no
// bound reaches it
let [x, y, z, w] = &nearest.h;
let wc = w.eval(0.5, 0.5);
let centre = (
x.eval(0.5, 0.5) / wc,
y.eval(0.5, 0.5) / wc,
z.eval(0.5, 0.5) / wc,
);
if (centre.0 * centre.0 + centre.1 * centre.1 + centre.2 * centre.2).sqrt() < want_net {
return Ok(off(nearest.lower));
}
for q in nearest.quarters()? {
heap.push(q);
}
made += 4;
let uv = [
u0 + a0 * (u1 - u0),
u0 + a1 * (u1 - u0),
v0 + b0 * (v1 - v0),
v0 + b1 * (v1 - v0),
];
pieces.push(Piece::new(h, err, 0, uv)?);
}
Ok((pieces, slack))
}
#[cfg(test)]
@@ -890,7 +996,10 @@ mod tests {
.unwrap()
});
let exact = h.each_ref().map(exact_quarters);
let quarters = Piece::new(h, 0.0, 0).unwrap().quarters().unwrap();
let quarters = Piece::new(h, 0.0, 0, [0.0, 1.0, 0.0, 1.0])
.unwrap()
.quarters()
.unwrap();
for (q, quarter) in quarters.iter().enumerate() {
for (axis, component) in quarter.h.iter().enumerate() {
for (rounded, reference) in
+139
View File
@@ -0,0 +1,139 @@
//! At a vertex where edges meet: the direction an edge leaves it in, how far one edge turns
//! from running straight on into another there, and whether an edge is convex there. The
//! measures a tangent-chain walk takes at each vertex.
//!
//! Upstream: none (not in SMLib).
//!
//! | Rust | Upstream |
//! |---------------|----------|
//! | [`leaving`] | (none) |
//! | [`turn_at`] | (none) |
//! | [`convex_at`] | (none) |
//!
//! Deviations: no upstream counterpart.
use crate::error::{Result, SolidError};
use crate::kernel::Kernel;
use crate::math::Vec3;
use crate::topology::edgeuse::p1 as eu1;
use crate::topology::edgeuse::p3::{evaluate_binormal, BinormalOptions};
use crate::topology::{edge, Brep, EdgeId, VertexId};
const OP: &str = "query::edge_turn";
fn invalid(reason: &'static str) -> SolidError {
SolidError::invalid_input(OP, reason)
}
/// Edge `e`'s parameter at its end `v`, and whether `v` is its start; `None` for a closed
/// edge (both ends `v`).
fn end_at(b: &Brep, e: EdgeId, v: VertexId) -> Result<Option<(f64, bool)>> {
let start = edge::start_vertex(b, e)?;
let end = edge::end_vertex(b, e)?;
if start == end {
return Ok(None);
}
let ivl = edge::interval(b, e)?;
if start == Some(v) {
Ok(Some((ivl.min(), true)))
} else if end == Some(v) {
Ok(Some((ivl.max(), false)))
} else {
Err(invalid("a vertex that is not an end of the edge"))
}
}
/// The unit direction edge `e` leaves its end `v` in: its curve's tangent there, turned to
/// point away from `v`. `None` for a closed edge, an edge without curve, or a tangent of no
/// length.
///
/// # Errors
///
/// [`SolidError::InvalidInput`] for a vertex that is not an end of `e`; those of the
/// evaluation.
pub fn leaving(k: &Kernel, b: &Brep, e: EdgeId, v: VertexId) -> Result<Option<Vec3>> {
let Some((t, at_start)) = end_at(b, e, v)? else {
return Ok(None);
};
let Some(curve) = edge::curve(b, e)? else {
return Ok(None);
};
// from inside the edge: from the right at its start, from the left at its end
let d = curve.evaluate(k, t, 1, !at_start, true)?;
let Some(&tangent) = d.get(1) else {
return Err(invalid("a curve's evaluation without its tangent"));
};
let Ok(unit) = tangent.unitize() else {
return Ok(None);
};
Ok(Some(if at_start { unit } else { unit * -1.0 }))
}
/// How far (radians, 0 to pi) edge `f` turns at their shared end `v` from running straight on
/// from edge `e`: the angle between the direction `e` arrives at `v` in and the one `f` leaves
/// it in. 0 where `f` continues `e` tangentially. `None` where either direction is undefined
/// ([`leaving`]).
///
/// # Errors
///
/// Those of [`leaving`].
pub fn turn_at(k: &Kernel, b: &Brep, e: EdgeId, f: EdgeId, v: VertexId) -> Result<Option<f64>> {
let (Some(arrives), Some(leaves)) = (leaving(k, b, e, v)?, leaving(k, b, f, v)?) else {
return Ok(None);
};
let arrives = arrives * -1.0;
Ok(Some(
arrives.cross(leaves).length().atan2(arrives.dot(leaves)),
))
}
/// Whether edge `e` is convex at its end `v` (its faces meet at less than 180 degrees inside
/// the material there): the direction from the edge into its second face, square to it,
/// points along its first face's inward normal (the face use normals of the edge use on the
/// material's side, its face use's region not void, and of its radial partner, which point
/// into the material). `None` for
/// an edge not between two face uses, a closed edge, an edge with no edge use on a material
/// region's side, or faces whose normal and binormal there are square to each other (tangent
/// faces: no convexity).
///
/// # Errors
///
/// [`SolidError::InvalidInput`] for a vertex that is not an end of `e`; those of the
/// evaluations.
pub fn convex_at(k: &Kernel, b: &mut Brep, e: EdgeId, v: VertexId) -> Result<Option<bool>> {
if !edge::is_manifold(b, e)? || eu1::edge_is_lamina(b, e)? {
return Ok(None);
}
let Some((t, _)) = end_at(b, e, v)? else {
return Ok(None);
};
// the edge use on the material's side: its face use and its radial partner's bound the
// material, and their normals point into it (the primary edge use may be on the void's,
// whose normals point out)
let mut eu0 = None;
for eu in edge::edgeuses(b, e, None, None)? {
let Some(fu) = eu1::faceuse(b, eu)? else {
continue;
};
if let Some(r) = eu1::faceuse_region(b, fu)? {
if b.region(r)?.is_void() == 0 {
eu0 = Some(eu);
break;
}
}
}
let Some(eu0) = eu0 else {
return Ok(None);
};
let Some(eu_r) = eu1::radial(b, eu0)? else {
return Ok(None);
};
let options = BinormalOptions::default();
let first = evaluate_binormal(k, b, eu0, t, false, &options)?;
let second = evaluate_binormal(k, b, eu_r, t, false, &options)?;
let side = second.binormal.dot(first.face_normal);
if side == 0.0 || !side.is_finite() {
return Ok(None);
}
Ok(Some(side > 0.0))
}
File diff suppressed because it is too large Load Diff
+1 -6
View File
@@ -1508,12 +1508,7 @@ impl Cone {
hv: usize,
flags: EvaluateFlags,
) -> Result<Vec<Vec3>> {
let rows = self.evaluate_rows(k, uv, hu, hv, flags)?;
let mut d = Vec::with_capacity((hu + 1) * (hv + 1));
for row in rows {
d.extend(row);
}
Ok(d)
Ok(self.evaluate_rows(k, uv, hu, hv, flags)?.concat())
}
/// [`Self::evaluate`] as `D[i][j]` rows: exactly `hu + 1` rows of `hv + 1` entries
+122 -7
View File
@@ -597,6 +597,13 @@ fn joint_removed(out: &Net, side: BoxSide, joint: f64) -> Result<Net> {
/// curve: every homogeneous control point in a plane normal to the axis (a parallel) or
/// through it (a meridian, a tube), within its rounding; a ruling is straight by its
/// degree. Refused as not exact otherwise.
///
/// The limit, `8 gamma_32` times the point's own size (`|c|` plus the frame's size times
/// `|w|`) plus the plane's height times `|w|`, has the shape of a rounding bound but its
/// factor is tuned, not derived: `8` and `32` count no operations of `extract_iso_crv`, the
/// dot product or the height. It is held to that size by a test (refused at twice it,
/// accepted at half), and measured well inside it (largest off / limit 0.0123 over the
/// offset and extension goldens); deriving it is open.
fn certify_iso(
s: &BSplineSurface,
side: BoxSide,
@@ -614,12 +621,6 @@ fn certify_iso(
(middle.x, Direction::V)
};
let curve = extract_iso_crv(net, t, dir)?;
// the iso is interpolated between the net's rows, so its points are known to the rounding
// at the size of those rows, not at their own: a short arc's middle iso between rulings
// extended 500 mm each way
let rows = net.cpts().iter().flatten().fold(0.0_f64, |m, p| {
m.max(p.x.abs()).max(p.y.abs()).max(p.z.abs())
});
let points: Vec<(Vec3, f64, f64)> = curve
.cpts()
.iter()
@@ -627,7 +628,7 @@ fn certify_iso(
let w = if p.w == NOW { 1.0 } else { p.w };
let h = Vec3::new(p.x, p.y, p.z);
// X W from the origin, and the size of its terms
let mag = rows + f.size * w.abs();
let mag = p.x.abs().max(p.y.abs()).max(p.z.abs()) + f.size * w.abs();
(h - f.origin * w, w, mag)
})
.collect();
@@ -921,3 +922,117 @@ fn slot_mut(cp: &mut [Vec<CPoint>], is_u: bool, a: usize, r: usize) -> Result<&m
.and_then(|x| x.get_mut(j))
.ok_or_else(|| SolidError::sm_err(OP))
}
#[cfg(test)]
mod tests {
#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing
)]
use super::*;
use crate::surface::cone::p1::Cone;
/// The limit `certify_iso` holds a parallel's homogeneous control point `c` to: the
/// rounding at the point's own size and the frame's, `8 gamma_32 (|c| + size |w| +
/// |height| |w|)` (written out here so that a change to the limit's size fails).
fn limit(c: &CPoint, size: f64, height: f64) -> f64 {
let w = if c.w == NOW { 1.0 } else { c.w };
let mag = c.x.abs().max(c.y.abs()).max(c.z.abs()) + size * w.abs();
8.0 * gamma(32) * (mag + height.abs() * w.abs())
}
/// A cylinder of radius 1 about z, 60 degrees round, 1000 long: rows far larger than
/// the turn's own points, so a limit taken at the rows' size would be about 1000 times
/// the one at the iso's.
fn long_cylinder() -> BSplineSurface {
let k = Kernel::default();
let s = Surface::from(Cone::new_cylinder_bounded(
Vec3::new(0.0, 0.0, 0.0),
Vec3::new(1.0, 0.0, 0.0),
Vec3::new(0.0, 1.0, 0.0),
1.0,
0.0,
60.0,
1000.0,
false,
false,
false,
));
crate::blend::fillet_section::station::as_b_spline(&k, &s)
.unwrap()
.unwrap()
.into_owned()
}
/// The net with every control point of index `i` along the iso across `side` moved
/// `delta` along the axis (its homogeneous `delta w`): the iso's point `i` moves by
/// `delta`, the rest stay.
fn moved(form: &BSplineSurface, side: BoxSide, i: usize, delta: f64) -> BSplineSurface {
let net = form.gw_nurb().unwrap();
let mut cpts = net.cpts();
for (a, row) in cpts.iter_mut().enumerate() {
for (b, c) in row.iter_mut().enumerate() {
let along = if side.is_u() { a } else { b };
if along == i {
let w = if c.w == NOW { 1.0 } else { c.w };
c.z += delta * w;
}
}
}
let (p, q) = net.degrees();
BSplineSurface::from_gw_nurb(
&Net::new(cpts, p, q, net.u_knots().to_vec(), net.v_knots().to_vec()).unwrap(),
)
}
/// `certify_iso` refuses a parallel whose control point is off its plane by twice the
/// limit at the point's own size and passes one off by half of it: the limit's size is
/// pinned, and a limit taken at the net's rows (1000 here) would pass both.
#[test]
fn the_turn_certificate_holds_the_iso_to_its_own_size() {
let form = long_cylinder();
let frame = Frame {
origin: Vec3::new(0.0, 0.0, 0.0),
axis: Vec3::new(0.0, 0.0, 1.0),
apex: None,
size: 1.0,
quarter: 0.0,
tube_left: 0.0,
};
let dom = knot_box(&form).unwrap();
let mut tested = 0;
for side in BoxSide::ALL {
let middle = side.point(&dom, 0.5);
let (t, dir) = if side.is_u() {
(middle.y, Direction::U)
} else {
(middle.x, Direction::V)
};
let iso = extract_iso_crv(form.gw_nurb().unwrap(), t, dir).unwrap();
let pts = iso.cpts();
// the parallel: every point at one height (the other side's iso is a ruling)
let z = |c: &CPoint| c.z / if c.w == NOW { 1.0 } else { c.w };
if pts.len() < 3 || pts.iter().any(|c| (z(c) - z(&pts[0])).abs() > 1e-9) {
continue;
}
assert!(certify_iso(&form, side, middle, &frame, Iso::Parallel).is_ok());
let lim = limit(&pts[1], frame.size, z(&pts[0]));
let w1 = if pts[1].w == NOW { 1.0 } else { pts[1].w };
let over = moved(&form, side, 1, 2.0 * lim / w1);
assert!(
certify_iso(&over, side, middle, &frame, Iso::Parallel).is_err(),
"{side:?}: twice the limit passes"
);
let under = moved(&form, side, 1, 0.5 * lim / w1);
assert!(
certify_iso(&under, side, middle, &frame, Iso::Parallel).is_ok(),
"{side:?}: half the limit is refused"
);
tested += 1;
}
assert!(tested > 0, "no side's iso is the parallel");
}
}
+334 -108
View File
@@ -11,7 +11,6 @@
use crate::error::{Result, SolidError};
use crate::geom::axis2_placement::Axis2Placement;
use crate::geom::extent2d::Extent2d;
use crate::geom::vector2d::Point2;
use crate::kernel::Kernel;
use crate::math::{Vec2, Vec3, PI};
use crate::nurbs::b_spline_curve::BSplineCurve;
@@ -69,42 +68,6 @@ pub enum Implicit {
},
}
impl Implicit {
/// How far `x` is from this surface.
#[must_use]
pub fn distance(&self, x: Vec3) -> f64 {
// the axial and radial parts of `x` about an axis through `o`
let about = |o: Vec3, axis: Vec3| {
let v = x - o;
let h = v.dot(axis);
(h, (v - axis * h).length())
};
match *self {
Self::Plane { origin, normal } => (x - origin).dot(normal).abs(),
Self::Cone {
origin,
axis,
radius,
tan_angle,
} => {
let (h, rho) = about(origin, axis);
(rho - (radius + tan_angle * h)).abs() / (1.0 + tan_angle * tan_angle).sqrt()
}
Self::Sphere { centre, radius } => ((x - centre).length() - radius).abs(),
Self::Torus {
centre,
axis,
major,
minor,
} => {
let (h, rho) = about(centre, axis);
let (a, b) = (rho - major, h);
((a * a + b * b).sqrt() - minor).abs()
}
}
}
}
/// A polynomial in Bernstein form over a patch, degrees `(p, q)`, `c[i (q + 1) + j]`.
#[derive(Debug, Clone, PartialEq)]
struct BPoly {
@@ -343,7 +306,7 @@ fn bezier_on<'a>(
f.c.iter().all(within)
}
pub(crate) const OP_EXACT: &str = "surface::implicit::check_exact_net";
const OP_EXACT: &str = "surface::implicit::check_exact_net";
/// A quadric `x.A.x + 2 b.x + c = 0` as `(A, b, c)`.
pub type QuadricEquation = ([[f64; 3]; 3], [f64; 3], f64);
@@ -439,46 +402,6 @@ pub fn check_exact_net(k: &Kernel, net: &Surface, check: &ExactNetCheck) -> Resu
Ok(())
}
/// The number of intervals [`net_miss`] samples a net at, each way.
const MISS_SAMPLES: u32 = 16;
/// How far `net` strays from the class's `surface`, sampled over its domain. A guard, not a
/// certificate: [`check_exact_net`] is the proof, and a net that passes only this keeps no
/// definition. It tells a net that the class's rounding leaves off the surface from one
/// built wrongly.
///
/// # Errors
///
/// Those of the evaluations.
pub fn net_miss(k: &Kernel, net: &Surface, surface: &ClassSurface) -> Result<f64> {
let dom = net.natural_uv_domain_with_nurb(k)?;
let (lo, hi) = (dom.min(), dom.max());
let mut worst = 0.0_f64;
for i in 0..=MISS_SAMPLES {
for j in 0..=MISS_SAMPLES {
let (fu, fv) = (
f64::from(i) / f64::from(MISS_SAMPLES),
f64::from(j) / f64::from(MISS_SAMPLES),
);
let uv = Point2::new(lo.x + (hi.x - lo.x) * fu, lo.y + (hi.y - lo.y) * fv);
let x = net.evaluate_point(k, uv)?;
let miss = match surface {
ClassSurface::Implicit(a) => a.distance(x),
ClassSurface::Canal {
spine, map, radius, ..
} => {
let w = if map.along_v { uv.y } else { uv.x };
let ivl = spine.natural_interval();
let s = (map.a + map.b * w).max(ivl.min()).min(ivl.max());
((x - spine.evaluate_point(s)?).length() - *radius).abs()
}
};
worst = worst.max(miss);
}
}
Ok(worst)
}
/// A Bezier segment of a rational curve, Bernstein polynomials of degree `(p, 0)`: the
/// homogeneous `(Y - O V)` components and the weight `V`.
type CurveSegment = ([BPoly; 3], BPoly);
@@ -1200,12 +1123,37 @@ impl AnalyticSupport {
// a surface of revolution: the one the kernel recognises in the form, which must be
// this one, take the form as its NURB and evaluate as the form at the form's own
// parameters
let not_form = || {
SolidError::invalid_input(
OP_BUILD,
"a support's analytic surface is not its NURBS form's parametrisation",
)
};
let surface = self.on_form(k, &form)?;
if !evaluates_as(k, &surface, &form)? {
return Err(not_form());
}
Ok(surface)
}
/// The largest ratio, over the form's Greville points, of this support's analytic
/// surface's distance from `form` to the rounding [`evaluates_as`] derives there (#3185):
/// at most 1 where [`Self::build`]'s parametrisation test passes. The surface is the one
/// `build` makes (the kernel's recognition of `form`, carrying this support's numbers);
/// `lies_on` and the placement's axes are not checked here. `None` for a plane, or a
/// surface the bound does not cover.
///
/// # Errors
///
/// `InvalidInput` where the kernel recognises no surface of this kind in `form`; those of
/// the recognisers, the conversions and the evaluations.
pub fn parametrisation_margin(&self, k: &Kernel, form: &BSplineSurface) -> Result<Option<f64>> {
if matches!(self, Self::Plane { .. }) {
return Ok(None);
}
let surface = self.on_form(k, form)?;
evaluation_margin(k, &surface, form)
}
/// The surface of revolution of this support's kind the kernel recognises in `form`,
/// carrying this support's own numbers and `form` as its NURB ([`Self::build`] after its
/// checks of the point set, before the parametrisation's).
fn on_form(&self, k: &Kernel, form: &BSplineSurface) -> Result<Surface> {
let form = form.copy();
let mut surface = recognise(k, self.kind(), &form)?.ok_or_else(not_form)?;
// the recognised surface must be of this support's kind; the numbers recognition
// recomputed are not compared with the support's, because it recomputes them from
@@ -1255,13 +1203,18 @@ impl AnalyticSupport {
}
_ => return Err(not_form()),
}
if !evaluates_as(k, &surface, &form)? {
return Err(not_form());
}
Ok(surface)
}
}
/// [`AnalyticSupport::build`]'s refusal of a surface that is not its form's parametrisation.
fn not_form() -> SolidError {
SolidError::invalid_input(
OP_BUILD,
"a support's analytic surface is not its NURBS form's parametrisation",
)
}
/// A cylinder's or cone's recognised parametrisation carried by the support's own
/// `placement`: its heights are measured along the axis from the placement's origin, so the
/// recognised heights move by how far along the axis the recognised origin lies from it.
@@ -1336,41 +1289,314 @@ fn recognise(k: &Kernel, kind: SupportKind, form: &BSplineSurface) -> Result<Opt
}
/// Whether `surface` evaluates as `form` at the form's Greville parameters (each control
/// point's), within the rounding of the two evaluations: `gamma_K` times the form's size,
/// `K = 16 (p + q + 2)` for the rational evaluation of degrees `(p, q)` and the analytic
/// one's conversion of the parameters and its trigonometry, a few ulps per operation.
/// point's): at each, the analytic surface's point and the form's agree within the bound
/// [`Rounding::at`] derives for that point, term by term below. The analytic side is the
/// **STEP** evaluation of the converted parameters ([`Surface::convert_uv_from_nurbs_to_step`]
/// and [`Surface::evaluate_step_point`]), which a cone, sphere, torus and surface of
/// revolution compute from their placement, radii, semi-angle and generator.
/// [`Surface::evaluate_point`] would not do: a sphere, a torus and a surface of revolution
/// take it from their NURBS base, and that base is the `form` the caller has just installed,
/// so it would compare the form with itself and read nothing of what the caller imposed
/// (#2588).
///
/// The analytic side is the **STEP** evaluation of the converted parameters
/// ([`Surface::convert_uv_from_nurbs_to_step`] and [`Surface::evaluate_step_point`]),
/// which a cone, sphere, torus and surface of revolution compute from their placement,
/// radii, semi-angle and generator. [`Surface::evaluate_point`] would not do: a sphere, a
/// torus and a surface of revolution take it from their NURBS base, and that base is the
/// `form` the caller has just installed, so it would compare the form with itself and read
/// nothing of what the caller imposed (#2588; the cone already converted and evaluated
/// analytically, which is why only the other kinds went unchecked).
/// What the bound forgives and what it tests (#3185): it forgives the rounding of both
/// evaluations and of the two parameter conversions, nothing else. It does not forgive the
/// label's frame turned about the axis against the converter's, its axis, origin or radii,
/// a form whose parameter is not the analytic surface's (a reparametrised arc), nor the
/// form's distance from the analytic surface: those are what the comparison proves.
///
/// `u` is the unit roundoff, `gamma_n = n u / (1 - n u)`. Stated assumption: the libm
/// `sin`, `cos`, `tan` and `atan2` are within one ulp (relative `2 u`).
///
/// 1. **The form** (`Surface::eval`, Cox-de Boor then two blends): each basis value is a
/// nonnegative combination over `p` levels of at most 6 operations (two knot differences,
/// a sum, a division, a product and a sum), so relative `gamma_6p`; the blends sum
/// `q + 1` then `p + 1` products. With positive weights the homogeneous point's error is
/// at most `gamma_(7 (p + q) + 4) W size` and the weight's `gamma_(7 (p + q) + 4) W`, so
/// the Euclidean point's, after the division, `E_form = (2 gamma_(7 (p + q) + 4) + u)
/// size` (`gamma_(7 (p + q) + 4) size` when polynomial), `size` the net's largest point.
/// 2. **The STEP point**, `O + m (cos u X + sin u Y) + h Z`, `m` and `h` the generator's
/// radial and axial parts (the torus `R + r cos v`, `r sin v`; the sphere `r cos v`,
/// `r sin v`; the cone `r0 + v tan a`, `v`). An angle `x` in degrees goes to radians in
/// two operations of the rounded `PI`: `gamma_3 |x|`; with one ulp of the cosine and sine,
/// each is off by `dc(x) = 2 u + gamma_3 |x|`. Per coordinate (unit axes) the error is at
/// most `gamma_3 (|O| + M + H) + M (gamma_2 + 2 dc(u)) + dm + dh`, `M`, `H` the sizes of
/// `m`, `h` and `dm`, `dh` their errors: the torus `gamma_3 (R + r) + 2 r dc(v)`, the
/// sphere `gamma_1 r + 2 r dc(v)`, the cone `gamma_2 (|r0| + |v t|) + |v| dt` (`dt =
/// (1 + t^2) gamma_3 |a| + 2 u |t|` for the tangent of the half angle `a`); a general
/// surface of revolution rotates its generator's point `G` (a B-spline: item 1's bound of
/// its own net, `E_gen`) about the axis, `gamma_8 (|O| + 3 D) + 2 D dc(u) + E_gen`, `D`
/// the net's reach from `O`. `E_step` is `sqrt 3` times the coordinate bound.
/// 3. **The generator's parameter**: a circle (torus, sphere) inverts its NURBS point by
/// `atan2` about its centre `c`: at its radius `r` the error is that point's (`E_form`)
/// plus `gamma_4 (size + |c|)` and `gamma_4 r |v|` of the angle's own rounding; a line
/// (cone) maps it affinely in 6 operations, `gamma_6 2 (|v0| + |v1|)` along the axis and
/// its slope, times `1 + |t|`; a B-spline generator's parameter is its NURBS's (none).
/// 4. **The sweep's parameter**: the polar converter evaluates its circle's NURBS at the
/// parameter (item 1's bound of that NURBS's own net, `E_polar`) and inverts the point by
/// `atan2` about the circle's centre `c` in its frame: at the circle's radius `rc` the
/// angle is off by `(E_polar + gamma_4 (rc + 2 |c|)) / rc`, plus `gamma_4 |s|` of the
/// angle's own rounding and the degrees' period (`gamma_1 2 pi`), times the STEP point's
/// distance from that axis (the lever). The converter's ends are its domain's, which
/// recognition takes from the form's own end meridians (#3199), so they are the inverted
/// ends to the same rounding.
///
/// A surface of another kind, or a general surface of revolution whose generator is not a
/// B-spline, has no bound here and is not proved.
///
/// # Errors
///
/// Those of the parameter conversion and the evaluations and of the form's knots.
fn evaluates_as(k: &Kernel, surface: &Surface, form: &BSplineSurface) -> Result<bool> {
Ok(evaluation_margin(k, surface, form)?.is_some_and(|m| m <= 1.0))
}
/// [`evaluates_as`]'s largest ratio of distance to bound over the Greville points (infinite
/// for a distance that is not a number); `None` for a surface the bound does not cover.
fn evaluation_margin(k: &Kernel, surface: &Surface, form: &BSplineSurface) -> Result<Option<f64>> {
let (net, p, q) = euclidean_net(form)?;
let size = net.iter().flatten().fold(0.0_f64, |a, x| a.max(x.length()));
let tol = gamma(16 * (p + q + 2)) * size;
let Some(r) = Rounding::of(surface, form.is_rational(), p, q, size, &net)? else {
return Ok(None);
};
let mut worst = 0.0_f64;
for u in greville(form, true)? {
for v in greville(form, false)? {
let uv = Vec2::new(u, v);
let step_uv = surface.convert_uv_from_nurbs_to_step(k, uv)?;
let off =
(surface.evaluate_step_point(k, step_uv)? - form.evaluate_point(uv)?).length();
if !matches!(
off.partial_cmp(&tol),
Some(core::cmp::Ordering::Less | core::cmp::Ordering::Equal)
) {
return Ok(false);
}
let s = surface.evaluate_step_point(k, step_uv)?;
let f = form.evaluate_point(uv)?;
let ratio = (s - f).length() / r.at(step_uv, s, f);
worst = if ratio.is_nan() {
f64::INFINITY
} else {
worst.max(ratio)
};
}
}
Ok(true)
Ok(Some(worst))
}
/// A surface of revolution's generator, for [`Rounding`]'s item 2 and 3.
#[derive(Debug, Clone, Copy)]
enum Generator {
/// `R + r cos v`, `r sin v` about the circle of centre `c`.
Torus { major: f64, minor: f64, c: f64 },
/// `r cos v`, `r sin v` about the circle of centre `c`.
Sphere { radius: f64, c: f64 },
/// `r0 + v t`, `v` (the half angle `a` in radians), its STEP `v` over `[v0, v1]`.
Cone {
r0: f64,
t: f64,
a: f64,
v0: f64,
v1: f64,
},
/// A B-spline generator, its own evaluation's bound.
Spline { e_gen: f64 },
}
/// The pieces of [`evaluates_as`]'s bound that do not depend on the point.
#[derive(Debug, Clone, Copy)]
struct Rounding {
e_form: f64,
origin: Vec3,
/// The net's reach from the origin.
reach: f64,
generator: Generator,
/// The sweep converter's circle: centre, axes.
polar: (Vec3, Vec3, Vec3, f64, f64),
}
/// `|v|`'s largest coordinate.
fn max_coord(v: Vec3) -> f64 {
v.x.abs().max(v.y.abs()).max(v.z.abs())
}
/// A rational (or polynomial) evaluation's bound of degrees summing to `n` over points of
/// size `size` ([`evaluates_as`] item 1).
fn eval_bound(rational: bool, n: usize, size: f64) -> f64 {
let g = gamma(7 * n + 4);
if rational {
(2.0 * g + crate::nurbs::math::UNIT) * size
} else {
g * size
}
}
impl Rounding {
/// The bound's data for `surface` (a cone, sphere, torus or surface of revolution with a
/// B-spline generator) on its form of degrees `(p, q)`, net `net`; `None` for any other.
fn of(
surface: &Surface,
rational: bool,
p: usize,
q: usize,
size: f64,
net: &[Vec<Vec3>],
) -> Result<Option<Self>> {
use crate::curve::curve::CurveKind;
let e_form = eval_bound(rational, p + q, size);
let circle_centre = |rev: &crate::surface::surf_of_revolution::SurfOfRevolution| match rev
.gen_curve
.as_ref()
.map(crate::curve::curve::Curve::kind)
{
Some(CurveKind::Ellipse(e)) => Some(e.position.origin().length()),
_ => None,
};
let (rev, generator) = match surface.kind() {
SurfaceKind::Torus(t) => {
let Some(c) = circle_centre(&t.base) else {
return Ok(None);
};
(
&t.base,
Generator::Torus {
major: t.major_radius,
minor: t.minor_radius,
c,
},
)
}
SurfaceKind::Sphere(sp) => {
let Some(c) = circle_centre(&sp.base) else {
return Ok(None);
};
(
&sp.base,
Generator::Sphere {
radius: sp.radius,
c,
},
)
}
SurfaceKind::Cone(c) => {
let a = c.semi_angle_deg * PI / 180.0;
let d = c.rev.anal_uv_domain;
(
&c.rev,
Generator::Cone {
r0: c.base_radius,
t: c.tan_semi_angle(),
a,
v0: d.v_min(),
v1: d.v_max(),
},
)
}
SurfaceKind::SurfOfRevolution(rev) => {
let Some(CurveKind::BSpline(g)) =
rev.gen_curve.as_ref().map(crate::curve::curve::Curve::kind)
else {
return Ok(None);
};
let pts = g.control_points()?;
let gsize = pts.iter().fold(0.0_f64, |m, c| {
let w = if c.w == NOW { 1.0 } else { c.w };
m.max((Vec3::new(c.x, c.y, c.z) * (1.0 / w)).length())
});
let rational = pts.iter().any(|c| c.w != NOW);
(
&**rev,
Generator::Spline {
e_gen: eval_bound(rational, g.degree()?, gsize),
},
)
}
_ => return Ok(None),
};
let Some(pc) = rev.polar_curve() else {
return Ok(None);
};
let e_polar = {
let pts = pc.base().control_points()?;
let psize = pts.iter().fold(0.0_f64, |m, c| {
let w = if c.w == NOW { 1.0 } else { c.w };
m.max((Vec3::new(c.x, c.y, c.z) * (1.0 / w)).length())
});
eval_bound(pts.iter().any(|c| c.w != NOW), pc.base().degree()?, psize)
};
let origin = rev.position.origin();
let reach = net
.iter()
.flatten()
.fold(0.0_f64, |m, x| m.max((*x - origin).length()));
Ok(Some(Self {
e_form,
origin,
reach,
generator,
polar: (
pc.position.origin(),
pc.position.x_axis(),
pc.position.y_axis(),
pc.radius_x,
e_polar,
),
}))
}
/// The bound at the STEP parameters `step` (degrees, and the generator's), the STEP
/// point `s` and the form's point `f` ([`evaluates_as`] items 1 to 4).
fn at(&self, step: Vec2, s: Vec3, f: Vec3) -> f64 {
let u = crate::nurbs::math::UNIT;
let rad = |deg: f64| deg * PI / 180.0;
let dc = |deg: f64| 2.0 * u + gamma(3) * rad(deg).abs();
let o = max_coord(self.origin);
let size_f = f.length();
// item 2 (per coordinate) and item 3 (as a length)
let (coord, generator) = match self.generator {
Generator::Torus { major, minor, c } => {
let (m, h) = (major + minor, minor);
let dm = gamma(3) * (major + minor) + 2.0 * minor * dc(step.y);
let dh = gamma(1) * minor + 2.0 * minor * dc(step.y);
(
gamma(3) * (o + m + h) + m * (gamma(2) + 2.0 * dc(step.x)) + dm + dh,
self.e_form + gamma(4) * (size_f + c) + gamma(4) * minor * rad(step.y).abs(),
)
}
Generator::Sphere { radius, c } => {
let dm = gamma(1) * radius + 2.0 * radius * dc(step.y);
(
gamma(3) * (o + 2.0 * radius)
+ radius * (gamma(2) + 2.0 * dc(step.x))
+ 2.0 * dm,
self.e_form + gamma(4) * (size_f + c) + gamma(4) * radius * rad(step.y).abs(),
)
}
Generator::Cone { r0, t, a, v0, v1 } => {
let v = step.y;
let m = r0.abs() + (v * t).abs();
let dt = (1.0 + t * t) * gamma(3) * a.abs() + 2.0 * u * t.abs();
let dm = gamma(2) * m + v.abs() * dt;
(
gamma(3) * (o + m + v.abs()) + m * (gamma(2) + 2.0 * dc(step.x)) + dm,
gamma(6) * 2.0 * (v0.abs() + v1.abs()) * (1.0 + t.abs()),
)
}
Generator::Spline { e_gen } => (
gamma(8) * (o + 3.0 * self.reach) + 2.0 * self.reach * dc(step.x) + e_gen,
0.0,
),
};
let e_step = 3.0_f64.sqrt() * coord;
// item 4: the polar converter's inversion of its own circle's point
let (c, x, y, rc, e_polar) = self.polar;
let z = x.cross(y);
let along = |v: Vec3| v - z * v.dot(z);
let lever = along(s - c).length();
let sweep = if rc > 0.0 {
lever
* ((e_polar + gamma(4) * (rc + 2.0 * c.length())) / rc
+ gamma(4) * rad(step.x).abs()
+ gamma(1) * 2.0 * PI)
} else {
// a circle of no radius has no angle: any, at the STEP point's lever
lever * PI
};
self.e_form + e_step + generator + sweep
}
}
const OP_DERIVE: &str = "surface::implicit::AnalyticSupport::derive";
@@ -39,11 +39,62 @@ use crate::geom::vector3d::{Point3, Vec3};
use crate::kernel::Kernel;
use crate::math::EFF_ZERO;
use crate::nurbs::b_spline_curve::BSplineCurve;
use crate::surface::implicit::{check_exact_net, net_miss, ClassSurface, ExactNetCheck, LeaderMap};
use crate::surface::implicit::{
check_exact_net, lies_on, AnalyticSupport, ClassSurface, ExactNetCheck, Implicit, LeaderMap,
};
use crate::surface::procedural_blend::chamfer::ChamferDefinition;
use crate::surface::surface::Surface;
const OP_TRANSFORM: &str = "surface::procedural_blend::exact::ExactRound::transform";
const OP_CIRCLE: &str = "surface::procedural_blend::exact::ExactRound::supports_distance";
/// The torus a round of the circle class is: a surface of revolution's, about its
/// placement's axis, of the circle its generating arc is on (the centre of a rational quadratic
/// arc from its first span's control points: the midpoint of the chord pushed away from the
/// middle point by `a^2 / h`, `a` the half chord, `h` the middle point's distance from the
/// chord's midpoint); or a torus label's own. `None` for any other surface.
///
/// # Errors
///
/// Those of reading the arc's control polygon.
fn own_torus(net: &Surface) -> Result<Option<Implicit>> {
use crate::curve::curve::{Curve, CurveKind};
use crate::surface::surface::SurfaceKind;
match net.kind() {
SurfaceKind::Torus(_) => Ok(AnalyticSupport::of(net)
.as_ref()
.map(AnalyticSupport::implicit)),
SurfaceKind::SurfOfRevolution(rev) => {
let Some(CurveKind::BSpline(arc)) = rev.gen_curve().map(Curve::kind) else {
return Ok(None);
};
let (points, _) = arc.control_polygon()?;
let [p0, p1, p2, ..] = points.as_slice() else {
return Ok(None);
};
let mid = (*p0 + *p2) * 0.5;
let (a, h) = ((*p0 - mid).length(), (mid - *p1).length());
if h.partial_cmp(&0.0) != Some(core::cmp::Ordering::Greater) {
return Ok(None);
}
let centre = mid + (mid - *p1) * (a * a / (h * h));
let position = rev.position();
let (o, axis) = (
position.origin(),
position.x_axis().cross(position.y_axis()),
);
let along = (centre - o).dot(axis);
let across = (centre - o) - axis * along;
Ok(Some(Implicit::Torus {
centre: o + axis * along,
axis,
major: across.length(),
minor: (*p0 - centre).length(),
}))
}
_ => Ok(None),
}
}
/// The exact class of a round whose net is its definition.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -263,7 +314,8 @@ impl ExactRound {
/// Re-certifies `net` against this definition: the supports classified again are of
/// this class, and the net lies on the class's surface derived from them
/// ([`check_exact_net`]). Returns the distance proved, 0.
/// ([`check_exact_net`]). A round of the circle class is certified on its own torus
/// instead ([`ExactRound::supports_distance`]). Returns the distance proved, 0.
///
/// # Errors
///
@@ -271,6 +323,10 @@ impl ExactRound {
/// surface from them, a net off it; those of reading the supports' quadrics and the
/// net's NURBS form.
pub fn recertify(&self, k: &Kernel, net: &Surface) -> Result<f64> {
if self.class == ExactClass::Circle {
self.supports_distance(k, net)?;
return Ok(0.0);
}
let [a, b] = &self.supports;
let blend = classify_supports(k, [a, b], &self.inputs)?;
// the chord (#2486): a line spine between planes, of sizes that give its offsets
@@ -302,19 +358,72 @@ impl ExactRound {
Ok(0.0)
}
/// How far `net` strays from the surface of the class its supports give
/// ([`net_miss`]); `None` where they give it none.
/// A round of the circle class: the torus `net` is ([`own_torus`]: its revolution's
/// axis and generating circle, or its torus label), proved to hold its form to rounding
/// ([`lies_on`]),
/// and how far that torus is from the class's torus derived from the supports
/// ([`class_surface`]): returned, refused past the tolerance the class was read within.
///
/// The class is read within the body's tolerance. Supports only that close to it (a plane
/// 1e-13 off square to a cylinder's axis, read from a record) have no torus touching both
/// exactly, so the round the solver builds, tangent to the supports as they are, is on a
/// torus of its own. Its definition is that torus, at bound 0, a stated distance from the
/// supports' class.
///
/// # Errors
///
/// Those of reading the supports' quadrics and of the evaluations.
pub fn miss(&self, k: &Kernel, net: &Surface) -> Result<Option<f64>> {
/// `InvalidInput` for supports of another class or with no torus, a net of no torus, a
/// net off its torus, or a torus further from the class's than the tolerance; those of
/// reading the supports' quadrics, the net's NURBS form and [`lies_on`].
pub fn supports_distance(&self, k: &Kernel, net: &Surface) -> Result<f64> {
let refused = |why| SolidError::invalid_input(OP_CIRCLE, why);
let [a, b] = &self.supports;
let blend = classify_supports(k, [a, b], &self.inputs)?;
match class_surface(&blend, self.leader.as_ref(), &self.map) {
Some(surface) => net_miss(k, net, &surface).map(Some),
None => Ok(None),
if ExactClass::of_spine(blend.spine) != Some(ExactClass::Circle) {
return Err(refused(
"the supports classified again are not of the class the definition names",
));
}
let Some(ClassSurface::Implicit(Implicit::Torus {
centre,
axis,
major,
minor,
})) = class_surface(&blend, self.leader.as_ref(), &self.map)
else {
return Err(refused("the supports give the class no torus"));
};
let form = crate::blend::fillet_section::station::as_b_spline(k, net)?
.ok_or_else(|| refused("the net has no NURBS form to check"))?;
let own = own_torus(net)?.ok_or_else(|| refused("the net is no torus"))?;
if !lies_on(&form, &own)? {
return Err(refused("the net is off its own torus"));
}
let Implicit::Torus {
centre: c,
axis: d,
major: big_r,
minor: r,
} = own
else {
return Err(refused("the net's own surface is no torus"));
};
// every point of the net's torus moves to the class's torus by at most: the turn of
// its axis (an angle, over the torus's reach from its centre), the move of its
// centre, and the change of its radii
let turn = d.cross(axis).length().atan2(d.dot(axis).abs());
let distance =
turn * (big_r + r) + (c - centre).length() + (big_r - major).abs() + (r - minor).abs();
let within = matches!(
distance.partial_cmp(&self.inputs.tol),
Some(core::cmp::Ordering::Less | core::cmp::Ordering::Equal)
);
if !within {
return Err(refused(
"the net's torus is further from the class's than the tolerance",
));
}
Ok(distance)
}
/// Moves the definition with its net (`Transform`): the supports, the leader and the
+72 -7
View File
@@ -110,6 +110,12 @@
//! WR-493, the SolidCraft fork's tangency-labs/smlib@7ea6a96): `IsNurbSurfaceSphere`
//! tests the half meridian within `180 + 360 * SM_EFF_ZERO` degrees and the sphere it
//! finds keeps the tested NURB (`SetFromGwNurb`). Off by default (upstream).
//! - **Far spheres** (#3131, by default): `IsNurbSurfaceSphere` and
//! `RebuildSTEPFromNURBParameters` add to their fixed half-meridian tolerances, and
//! `IsNurbSurfaceSphere` to its generator's STEP interval test, the angles' rounding at
//! the points' size ([`meridian_rounding`]): upstream's fixed degrees refused a sphere of
//! radius 2.5 at 1e5 (180 + 6.7e-10 degrees). Near the origin the default half-meridian
//! test widens from `1e-12` to about `1e-12 + 8e-12` degrees for radius 2.5.
//! - **Parent calls.** `Transform` finishes in `SmSurfOfRevolution::Transform` (status
//! ignored), its polar-converter rebuild given the sphere's iso circles as upstream's
//! virtual call does; `ReadFromDB` reads the parent record with
@@ -133,12 +139,15 @@ use crate::geom::tol::{are_same, are_same_to_tol, is_zero};
use crate::geom::vector2d::Point2;
use crate::geom::vector3d::{asin, sqrt, Point3, Vec3};
use crate::kernel::Kernel;
use crate::math::{max2, min2, EFF_ZERO, EFF_ZERO_PARAM, EFF_ZERO_SQRT, PI, UNDEF_DOUBLE};
use crate::math::{
max2, min2, rad_to_deg, EFF_ZERO, EFF_ZERO_PARAM, EFF_ZERO_SQRT, PI, UNDEF_DOUBLE,
};
use crate::nurbs::b_spline_curve::BSplineCurve;
use crate::nurbs::b_spline_surface::p2::EditNotify;
use crate::nurbs::b_spline_surface::{
BSplineSurface, BoundingBoxRequest, IsoCurveOptions, LocationType, SurfParamType,
};
use crate::nurbs::math::gamma;
use crate::nurbs::nurbs_crv::to_euclid;
use crate::nurbs::nurbs_srf::copy_nurb_surface;
use crate::surface::cone::Cone;
@@ -2029,7 +2038,11 @@ impl Sphere {
/// (WR-493) the half turn is tested within `180 + 360 * SM_EFF_ZERO` degrees and the
/// sphere then keeps a copy of `test`'s NURB, its analytic data rebuilt from it
/// (`SetFromGwNurb`); by default `180 + SM_EFF_ZERO` and the NURB made from the
/// fitted circles, as upstream.
/// fitted circles, as upstream. Not upstream: in both modes the half-meridian test, and
/// the test of the generator's STEP interval against the sphere's (within `SM_EFF_ZERO`
/// upstream), also allow the angles' rounding at the points' size,
/// [`meridian_rounding`] of the corner points' size and the circle's radius: `4
/// gamma_K (1 + |corners| + 2 r) / r` radians (#3131).
///
/// # Errors
///
@@ -2109,14 +2122,18 @@ impl Sphere {
} else {
EFF_ZERO
};
// not upstream: the angles' rounding at the points' size (`meridian_rounding`)
let corners = min_pnt.max_dimension().max(max_pnt.max_dimension());
let (p, q) = (test.degree(SurfParamType::U), test.degree(SurfParamType::V));
let rounding = |radius: Option<f64>| meridian_rounding(p, q, corners, radius);
let is_sphere_with_swap_uv = iso_u.parallel_planes
&& !iso_u.same_plane // [ 080625 ]
&& iso_v.coaxial_centers
&& angles_v.length() <= 180.0 + half_meridian_tol;
&& angles_v.length() <= 180.0 + half_meridian_tol + rounding(iso_v.radius);
let is_sphere_no_swap_uv = iso_v.parallel_planes
&& !iso_v.same_plane // [ 080625 ]
&& iso_u.coaxial_centers
&& angles_u.length() <= 180.0 + half_meridian_tol;
&& angles_u.length() <= 180.0 + half_meridian_tol + rounding(iso_u.radius);
// quit when not a properly oriented sphere
if !is_sphere_with_swap_uv && !is_sphere_no_swap_uv {
@@ -2224,9 +2241,10 @@ impl Sphere {
// If pCircle's STEP interval does not match the v-interval of sAnalUVDomain,
// then the Sphere constructor will complain and the resulting bad surface can
// lead to crashes. [B414]
// (not upstream: its angles to the same rounding as the half meridian's)
if !circle
.step_interval()
.are_equal(&anal_uv_domain.v_interval(), EFF_ZERO)
.are_equal(&anal_uv_domain.v_interval(), EFF_ZERO + rounding(Some(rad)))
{
// upstream: WARN("Bad result in IsNurbSurfaceSphere(): GenCurve STEP
// interval does not match sAnalUVDomain VInterval")
@@ -2333,16 +2351,23 @@ impl Sphere {
// see if we have a U or a V oriented sphere
// Tol: compensate for using degrees. [B279]
// (not upstream: and the angles' rounding at the points' size, `meridian_rounding`)
let corners = nurb_min_point
.max_dimension()
.max(nurb_max_point.max_dimension());
let (p, q) = (srf.degree(SurfParamType::U), srf.degree(SurfParamType::V));
let angles_u = iso_u.angles.unwrap_or(Extent1d::new());
let angles_v = iso_v.angles.unwrap_or(Extent1d::new());
let is_sphere_with_swap_uv = iso_u.parallel_planes
&& !iso_v.same_plane // [ 080625 ]
&& iso_v.coaxial_centers
&& angles_v.length() <= 180.0 + 360.0 * EFF_ZERO;
&& angles_v.length()
<= 180.0 + 360.0 * EFF_ZERO + meridian_rounding(p, q, corners, iso_v.radius);
let is_sphere_no_swap_uv = iso_v.parallel_planes
&& !iso_v.same_plane // [ 080625 ]
&& iso_u.coaxial_centers
&& angles_u.length() <= 180.0 + 360.0 * EFF_ZERO;
&& angles_u.length()
<= 180.0 + 360.0 * EFF_ZERO + meridian_rounding(p, q, corners, iso_u.radius);
// quit when not a properly oriented sphere
if !is_sphere_with_swap_uv && !is_sphere_no_swap_uv {
@@ -2985,6 +3010,34 @@ fn convert_uv_from_nurbs_to_step(
Ok(Point2::new(step_x, polar_param))
}
/// Not upstream: the rounding, in degrees, of the length of the half meridian (or of a
/// circle's STEP interval) that [`Sphere::is_nurb_surface_sphere`] and
/// [`Sphere::rebuild_step_from_nurb_parameters`] read off a form of degrees `(p, q)` whose
/// corner points are at most `corners` from the origin, its circle of radius `radius`; 0
/// without a radius.
///
/// The angles are read off points of the form: an evaluation is within `gamma_K |P|` of
/// the exact point (`K = 16 (p + q + 2)`, a few ulps for each of the rational evaluation's
/// operations), `|P| <= 1 + corners + 2 r` (a point of the sphere is at most its
/// diameter from a corner). The circle's centre is fitted to such points; its error is taken
/// to be the same again, which is measured, not derived (the fit's conditioning is not
/// bounded here: `far_sphere_recognised.rs` measures the excess over this allowance on
/// spheres of radius 1e-3 to 1e3, at 0 to 1e7 from the origin, turned off the axes: at most
/// 0.0055 of this allowance). An
/// angle at radius `r` is then known to `2 gamma_K |P| / r` radians at each end, and the half
/// turn's length to twice that: `4 gamma_K |P| / r`. As a position, `r` times that is
/// `4 gamma_K |P|`, below the iso-circle tests' `100 SM_EFF_ZERO (1 + |corners|)` for every
/// size, so it admits no form those tests refuse.
pub(crate) fn meridian_rounding(p: usize, q: usize, corners: f64, radius: Option<f64>) -> f64 {
match radius {
Some(r) if r > 0.0 => {
let size = 1.0 + corners + 2.0 * r;
rad_to_deg(4.0 * gamma(16 * (p + q + 2)) * size / r)
}
_ => 0.0,
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -3001,6 +3054,18 @@ mod tests {
Sphere::create_canonical(k, &frame(), radius).expect("sphere")
}
/// `meridian_rounding` for radius 2.5 against numbers recomputed independently of it:
/// 1.8e-11 degrees with corners at 12.5 (the origin) and 9.77e-8 degrees with corners at
/// 100012.5 (1e5 out), so a doubled or halved allowance fails here (#3131).
#[test]
fn the_meridian_allowance_is_its_closed_form() {
for (corners, degrees) in [(12.5, 1.8e-11), (100_012.5, 9.77e-8)] {
let got = meridian_rounding(2, 2, corners, Some(2.5));
assert!((got / degrees - 1.0).abs() < 0.02, "{corners}: {got:e}");
}
assert_eq!(meridian_rounding(2, 2, 1.0, None), 0.0);
}
#[test]
fn trim_with_domain_keeps_the_shape() {
let k = Kernel::default();
+99 -6
View File
@@ -1061,7 +1061,6 @@ impl Torus {
let ref_frame = ref_iso.reference_frame.ok_or_else(missing)?;
let other_frame = other_iso.reference_frame.ok_or_else(missing)?;
let min_rad = other_iso.radius.ok_or_else(missing)?;
let sweep_ivl = ref_iso.angles.ok_or_else(missing)?;
let common_point = other_iso.common_line_point.ok_or_else(missing)?;
let common_vec = other_iso.common_line_vec.ok_or_else(missing)?;
@@ -1069,6 +1068,17 @@ impl Torus {
let torus_origin =
common_point + common_vec.dot(other_frame.origin() - common_point) * common_vec;
// not upstream (#3199): the x axis and the sweep interval from the form's own first
// sweep row's ends about the axis (`sweep_frame`), not from `is_arc`'s fit of it
let Some((torus_x_axis, torus_y_axis, sweep_ivl)) = sweep_frame(
torus_origin,
ref_frame.z_axis(),
fitted_sides(&ref_frame, ref_iso.angles.ok_or_else(missing)?),
meridians(test, swap_uv)?,
) else {
return Ok(None);
};
// get torus orientation and size
// not upstream (WR-501, #1470, opt-in; tangency-labs/smlib@6136a04): the major
// radius runs from the torus origin, not from the first sweep circle's centre,
@@ -1079,9 +1089,7 @@ impl Torus {
other_frame.origin() - ref_frame.origin()
};
let maj_rad = major_vec.length();
let torus_x_axis = ref_frame.x_axis(); // the frame where all circles are parallel
let torus_y_axis = ref_frame.y_axis();
let torus_z_axis = ref_frame.z_axis();
let torus_z_axis = ref_frame.z_axis(); // the frame where all circles are parallel
// torus must have positive radii
if min_rad <= EFF_ZERO || maj_rad <= EFF_ZERO {
@@ -1294,7 +1302,6 @@ impl Torus {
let mut ref_frame = ref_iso.reference_frame.ok_or_else(missing)?;
let other_frame = other_iso.reference_frame.ok_or_else(missing)?;
let min_rad = other_iso.radius.ok_or_else(missing)?;
let sweep_ivl = ref_iso.angles.ok_or_else(missing)?;
let common_point = other_iso.common_line_point.ok_or_else(missing)?;
let common_vec = other_iso.common_line_vec.ok_or_else(missing)?;
@@ -1302,8 +1309,18 @@ impl Torus {
let torus_origin =
common_point + common_vec.dot(other_frame.origin() - common_point) * common_vec;
// not upstream (#3199): the x axis and the sweep interval from the form's own first
// sweep row's ends about the axis (`sweep_frame`), not from `is_arc`'s fit of it
let (x_axis, y_axis, sweep_ivl) = sweep_frame(
torus_origin,
ref_frame.z_axis(),
fitted_sides(&ref_frame, ref_iso.angles.ok_or_else(missing)?),
meridians(srf, swap_uv)?,
)
.ok_or_else(|| SolidError::sm_err(OP_REBUILD))?;
// Install the origin into pRefFrame. // [B328]
let _ = ref_frame.set_canonical(torus_origin, ref_frame.x_axis(), ref_frame.y_axis());
let _ = ref_frame.set_canonical(torus_origin, x_axis, y_axis);
// get torus orientation and size
let major_vec = other_frame.origin() - ref_frame.origin();
@@ -1587,6 +1604,82 @@ impl Revolved for Torus {
/// `SmTorus::CreateIsoParametricCurve` of the surface of revolution `rev` of a torus
/// of `shape` (also called from the base class's rebuilds, where it is virtual).
/// The control points, Euclidean, of the form's first and last meridians: the columns of its
/// net at the sweep's two ends (the sweep is along u, or along v with `swap_uv`). A clamped
/// end's column is that boundary curve's own control polygon, and a meridian lies in a plane
/// through the axis, so every one of its control points has the meridian's angle about the
/// axis.
fn meridians(srf: &BSplineSurface, swap_uv: bool) -> Result<[Vec<Point3>; 2]> {
let nurb = srf
.gw_nurb()
.ok_or_else(|| SolidError::null_pointer(OP_NURB_TORUS))?;
let (n, m) = (nurb.n(), nurb.m());
let column = |end: bool| -> Result<Vec<Point3>> {
if swap_uv {
let j = if end { m } else { 0 };
(0..=n).map(|i| Ok(nurb.cpt(i, j)?.to_euclid())).collect()
} else {
let i = if end { n } else { 0 };
(0..=m).map(|j| Ok(nurb.cpt(i, j)?.to_euclid())).collect()
}
};
Ok([column(false)?, column(true)?])
}
/// The directions, about the axis, of the fitted first sweep row's ends (`is_arc`'s frame and
/// angles): [`sweep_frame`] reads only their sides from them.
fn fitted_sides(frame: &Axis2Placement, angles: Extent1d) -> [Vec3; 2] {
let at = |deg: f64| {
let a = deg * PI / 180.0;
a.cos() * frame.x_axis() + a.sin() * frame.y_axis()
};
[at(angles.min()), at(angles.max())]
}
/// Not upstream (#3199): a recognised torus's x axis, y axis and sweep interval (degrees) from
/// its form's first and last meridians (`meridians`: their control points, exact data), about
/// the axis through `origin` along the unit `z`. A meridian lies in a plane through the axis,
/// so its control points share its angle on the side the tube's centre is on, and the
/// opposite angle past the axis (a spindle torus's tube crosses it); `sides` (the fitted
/// row's end directions, [`fitted_sides`]) says which side that is, and nothing else. Each
/// meridian's angle is read at its control point farthest from the axis on that side: x is
/// the first meridian's direction square to the axis (so the interval starts at 0), y `z x
/// x`, and the interval's end the last meridian's angle from x about z (`atan2` in that
/// frame), a closed sweep (within `SM_EFF_ZERO_SQRT` of 0) at 360, as `is_arc` maps its end.
/// `is_arc` fitted the first sweep row's circle through three samples, so on a short arc its
/// centre, and with it the x axis and the end angle, was off the axis by its conditioning
/// (1e-14 rad on #3185's grooves); the converter centres the sweep circle on the axis, so its
/// ends and its inside now agree to rounding. `None` when a meridian has no control point off
/// the axis on its side.
fn sweep_frame(
origin: Point3,
z: Vec3,
sides: [Vec3; 2],
[first, last]: [Vec<Point3>; 2],
) -> Option<(Vec3, Vec3, Extent1d)> {
let radial = |p: &Point3| {
let s = *p - origin;
s - s.dot(z) * z
};
let farthest = |ps: &[Point3], side: Vec3| {
ps.iter()
.map(radial)
.filter(|r| r.dot(side) > 0.0)
.max_by(|a, b| a.length().total_cmp(&b.length()))
};
let [first_side, last_side] = sides;
let x = farthest(&first, first_side)?.unitize().ok()?;
let y = z.cross(x);
let e = farthest(&last, last_side)?;
let mut end_deg = e.dot(y).atan2(e.dot(x)) * 180.0 / PI;
if end_deg.abs() < EFF_ZERO_SQRT {
end_deg = 360.0;
} else if end_deg < 0.0 {
end_deg += 360.0;
}
Some((x, y, Extent1d::from_min_max(0.0, end_deg)))
}
fn iso_curve(
rev: &SurfOfRevolution,
shape: Shape,
+9 -4
View File
@@ -1623,10 +1623,15 @@ pub(crate) fn ignore_read<T>(r: Result<T>) -> Result<Option<T>> {
| SolidError::Cancelled { .. }
| SolidError::Unsupported { .. }),
) => Err(e),
Err(
e
@ (SolidError::InvalidInput { reason, .. } | SolidError::PortInvariant { reason, .. }),
) if reason == NESTING_LIMIT_REASON => Err(e),
Err(e)
if matches!(
&e,
SolidError::InvalidInput { reason, .. } | SolidError::PortInvariant { reason, .. }
if *reason == NESTING_LIMIT_REASON
) =>
{
Err(e)
}
Err(_) => Ok(None),
}
}
+1
View File
@@ -12,6 +12,7 @@ pub mod directed;
pub mod extrude;
pub mod rotational_sweep_geometry;
pub mod screw;
pub mod section_blend;
pub mod segments;
pub mod topology_sweep;
pub mod trans_sweep_geometry;
+554
View File
@@ -0,0 +1,554 @@
//! A multi-section blend: one B-spline surface through ordered cross-sections that leaves the
//! first and the last of them with the continuity their frames ask for.
//!
//! Upstream: none (not in SMLib). SolidCraft's `loft_blend` (`crates/kernel/src/loft_blend.rs`
//! at 52afe21) and the Hermite rows of its `bridge::section`, moved into the kernel so no
//! application builds the surface.
//!
//! | Rust | Upstream |
//! |--------------------|----------|
//! | [`section_blend`] | (none; SolidCraft's `loft_blend`) |
//! | [`SectionFrame`] | (none; SolidCraft's `bridge::Frame`) |
//! | [`EndOrder`] | (none; SolidCraft's `Continuity`) |
//! | `end_rows` (crate) | (none; the rows of SolidCraft's `bridge::section`) |
//! | `third_derivative` (crate) | (none; SolidCraft's `bridge::third_derivative`) |
//!
//! **The surface.** The sections run along u, each sampled at the same stations along v. Section
//! `i` sits at `u = i / (n - 1)` and the surface passes through every station of it: each
//! control row is interpolated through its stations along v (degree `min(3, stations - 1)`, the
//! kernel's global interpolation at uniform parameters with averaged knots). Along u the knots are
//! clamped and repeated `degree` times at every interior section, so every span is one Bezier.
//! A span's end rows are the Hermite rows (`end_rows`) of its two sections: position, then
//! tangent, curvature and change of curvature as the section holds them, written as the Bezier's
//! leading control rows at the span's parameter interval. The u degree is the largest
//! `c + c' - 1` over the spans (`c`, `c'` the conditions at its two ends); a span with fewer is
//! degree-elevated exactly (by its own row elevation: the rows are vectors of points before any
//! surface exists, so the kernel's surface and single-curve elevations do not fit them).
//!
//! **What each section holds (the definition, SolidCraft's).**
//! - The first and the last section hold what their [`EndOrder`] asks (G0 to G3) from the
//! caller's frames: the tangent `leave * speed`, the curvature `bend * speed^2` and the change
//! of curvature from `bend_change` (`third_derivative`: only its part square to the tangent
//! holds the condition, the rest a reparametrisation). The speed is the station's chord to the
//! neighbouring section `c`, over 3, times `1 + crown`, over the span's interval `1 / (n - 1)`:
//! `d_u = leave * c / 3 * (1 + crown) * (n - 1)` for `n` sections, so the Bezier's first control
//! offset is `c (1 + crown) / (3 m)` at span degree `m`. The last section's frames point back
//! into the blend, so its odd derivatives turn round.
//! - An interior section holds its position and the run's derivatives from finite differences of
//! the neighbouring sections' stations: the central first and second differences, and a third
//! (central, or one-sided at the second and the last-but-one section) where four sections
//! reach. So the joint is C3 in u with four or more sections and C2 with three, at every
//! station.
//!
//! This is SolidCraft's definition of the blend, not an approximation of another surface: the
//! finite differences are the conditions the surface is built to, not estimates of a curve the
//! sections lie on, and no error against anything is claimed. What is exact: the surface passes
//! through every station; at every station of an end it holds the frames' conditions to rounding;
//! at every station of an interior section the spans on either side agree in their u derivatives
//! up to the order held. Between stations the end and joint curves are the v interpolants of the
//! stations' rows (the interpolation is linear and commutes with evaluation in u), not conditions
//! of their own.
//!
//! **Refused**, naming why: fewer than 2 or more than [`MAX_SECTIONS`] sections; sections of
//! different station counts, or a count outside [`MIN_STATIONS`] ..= [`MAX_STATIONS`]; a frame
//! that is not finite, or a `leave` of no length at an end that uses it; a crown outside
//! `-0.9 ..= 3`; two neighbouring sections that meet at a station (no room for a blend there).
//!
//! **The two floors.** Two tests guard divisions, and both refuse; neither adjusts an input or a
//! result to get past them. A tangent's length (an end's `leave`, and the first derivative in
//! `third_derivative`) is divided by: a length that is not finite and positive is refused,
//! never replaced by a default direction. Two neighbouring sections meeting at a station
//! (distance not positive) leave the span there no room: a speed of zero, and no tangent to
//! divide by at the next section; the blend is refused, never nudged apart. Both are exact tests,
//! not SolidCraft's `1e-12` and `1e-9` floors: a chord of `1e-12` is not refused, its speed is
//! about `1e-12` and the end rows collapse onto the section, a valid but degenerate surface.
//!
//! Deviations: no upstream counterpart. SolidCraft's `1e-12` floor on a tangent's length and its
//! `1e-9` floor on the distance between neighbouring sections are exact tests here (a length that
//! is not positive): what the floors protect is the division, and an exact test protects it
//! without a scale-dependent constant. Its one-sided third difference at the last-but-one section
//! has its sign turned (`f(i-2) - 3 f(i-1) + 3 f(i) - f(i+1)`, the negative of the change of
//! curvature it estimates); here it is the backward difference, exact on a cubic like the other
//! two. Its batched row solves are one solve per row. No
//! recursion.
use crate::error::{Result, SolidError};
use crate::math::{Point3, Vec3};
use crate::nurbs::b_spline_curve::KnotType;
use crate::nurbs::b_spline_surface::{BSplineSurface, BSplineSurfaceForm, CanonicalSurfaceData};
use crate::nurbs::crv_fit::p1::{interp_given_params, interp_knot_vector};
use crate::nurbs::knots::KnotVector;
use crate::nurbs::math::NOW;
const OP: &str = "sweep::section_blend";
/// Most cross-sections one blend runs through.
pub const MAX_SECTIONS: usize = 50;
/// Fewest stations a cross-section is sampled at (a straight line across it).
pub const MIN_STATIONS: usize = 2;
/// Most stations a cross-section is sampled at.
pub const MAX_STATIONS: usize = 257;
/// One station of a cross-section: its point and, at the first and last section, how the blend
/// leaves it.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SectionFrame {
/// The point.
pub p: Point3,
/// The direction the blend leaves in, towards the next section (at the last section, back
/// into the blend, towards the section before). Its length is ignored.
pub leave: Vec3,
/// The curvature vector the blend takes along `leave` (zero for a straight start).
pub bend: Vec3,
/// The arc-length derivative of `bend` along `leave`.
pub bend_change: Vec3,
}
/// The continuity a blend holds at its first or last section.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum EndOrder {
/// Position.
G0,
/// Position and tangent direction.
G1,
/// And curvature.
G2,
/// And change of curvature.
G3,
}
impl EndOrder {
/// The conditions held: 1 to 4 (position, tangent, curvature, change of curvature).
#[must_use]
pub const fn conditions(self) -> usize {
match self {
Self::G0 => 1,
Self::G1 => 2,
Self::G2 => 3,
Self::G3 => 4,
}
}
}
fn invalid(reason: &'static str) -> SolidError {
SolidError::invalid_input(OP, reason)
}
/// The derivatives a section holds along the run at each station: `d[k]` is the `(k + 1)`-th,
/// as many as its conditions less one.
struct Held {
p: Vec<Point3>,
d: [Vec<Vec3>; 3],
}
/// The Bezier control rows at one end of a span of degree `m` over the parameter interval
/// `delta`, from the end's position `p` and its derivatives `d` (the first `conditions - 1` of
/// them used), as the span leaves the end (`backward` false: rows `0, 1, ..`) or arrives at it
/// (`backward` true: rows `m, m - 1, ..`, the odd derivatives read the other way): the leading
/// rows of a Bezier whose derivatives at that end are `d`. Row `k` comes back at index `k` from
/// the end.
///
/// # Errors
/// `InvalidInput` for more conditions than the degree carries or than `d` gives.
pub(crate) fn end_rows(
p: Point3,
d: &[Vec3],
conditions: usize,
m: usize,
delta: f64,
backward: bool,
) -> Result<Vec<Point3>> {
if conditions == 0 || conditions > 4 || conditions > m + 1 || d.len() + 1 < conditions {
return Err(invalid(
"a span's end carries more conditions than its degree or its data",
));
}
#[allow(clippy::cast_precision_loss)] // a degree, at most 32
let k = m as f64;
let sign = if backward { -1.0 } else { 1.0 };
let one = delta / k;
let two = if m >= 2 {
delta * delta / (k * (k - 1.0))
} else {
0.0
};
let three = if m >= 3 {
delta * delta * delta / (k * (k - 1.0) * (k - 2.0))
} else {
0.0
};
let mut rows = vec![p];
if conditions >= 2 {
let d1 = *d.first().ok_or_else(|| invalid("a missing tangent"))?;
let q1 = p + d1 * (sign * one);
rows.push(q1);
if conditions >= 3 {
let d2 = *d.get(1).ok_or_else(|| invalid("a missing curvature"))?;
let q2 = d2 * two + q1 * 2.0 - p;
rows.push(q2);
if conditions >= 4 {
let d3 = *d
.get(2)
.ok_or_else(|| invalid("a missing change of curvature"))?;
rows.push(d3 * (sign * three) + q2 * 3.0 - q1 * 3.0 + p);
}
}
}
Ok(rows)
}
/// The third derivative a curve needs at an end so that the arc-length derivative of its
/// curvature vector is `target`, from its first two derivatives `d1`, `d2` there: only its part
/// square to the tangent holds the condition, the rest is a reparametrisation and is left zero
/// (SolidCraft's `bridge::third_derivative`).
///
/// # Errors
/// `InvalidInput` for a tangent of no length or a result that is not finite.
pub(crate) fn third_derivative(d1: Vec3, d2: Vec3, target: Vec3) -> Result<Vec3> {
let sigma = d1.length();
// the floor on the tangent's length, divided by below: refused, never adjusted
if !(sigma.is_finite() && sigma > 0.0) {
return Err(invalid(
"no room for the change of curvature: the tangent has no length",
));
}
let t = d1 * (1.0 / sigma);
let tp = d2 * (1.0 / sigma) - d1 * (d1.dot(d2) / (sigma * sigma * sigma));
let nn = d2 - t * d2.dot(t);
let r = tp * (-d2.dot(t)) - t * d2.dot(tp);
let x = (target * sigma.powi(5) - r * (sigma * sigma) + nn * (2.0 * d1.dot(d2)))
* (1.0 / (sigma * sigma));
if !(x.x.is_finite() && x.y.is_finite() && x.z.is_finite()) {
return Err(invalid("the change of curvature is not finite"));
}
Ok(x - t * x.dot(t))
}
fn finite(v: Vec3) -> bool {
v.x.is_finite() && v.y.is_finite() && v.z.is_finite()
}
/// One exact degree elevation of a Bezier net of rows: the same curve in one row more.
fn elevate(rows: &[Vec<Point3>]) -> Vec<Vec<Point3>> {
let degree = rows.len().saturating_sub(1);
#[allow(clippy::cast_precision_loss)] // a degree, at most 32
let up = degree as f64 + 1.0;
let mut out: Vec<Vec<Point3>> = Vec::new();
if let Some(first) = rows.first() {
out.push(first.clone());
}
for i in 1..=degree {
if let (Some(before), Some(here)) = (rows.get(i - 1), rows.get(i)) {
#[allow(clippy::cast_precision_loss)] // a row index, at most 32
let a = i as f64 / up;
out.push(
before
.iter()
.zip(here)
.map(|(x, y)| *x * a + *y * (1.0 - a))
.collect(),
);
}
}
if let Some(last) = rows.last() {
out.push(last.clone());
}
out
}
/// The blend surface through `sections` (each a cross-section of the same stations, in order),
/// holding `orders[0]` at the first section and `orders[1]` at the last, `crown` scaling how fast
/// it leaves each (`-0.9 ..= 3`, 0 the plain blend). See the module's notes for the definition.
///
/// # Errors
/// `InvalidInput` for the refusals the module names; those of the interpolation and the surface's
/// construction.
#[allow(clippy::too_many_lines)] // one construction, step by step
pub fn section_blend(
sections: &[Vec<SectionFrame>],
orders: [EndOrder; 2],
crown: [f64; 2],
) -> Result<BSplineSurface> {
let run = sections.len();
if !(2..=MAX_SECTIONS).contains(&run) {
return Err(invalid("a blend runs through 2 to 50 cross-sections"));
}
let samples = sections.first().map_or(0, Vec::len);
if !(MIN_STATIONS..=MAX_STATIONS).contains(&samples) {
return Err(invalid("a blend's cross-sections take 2 to 257 stations"));
}
if crown
.iter()
.any(|c| !(c.is_finite() && (-0.9..=3.0).contains(c)))
{
return Err(invalid("a blend's crown is between -0.9 and 3"));
}
for section in sections {
if section.len() != samples {
return Err(invalid(
"every cross-section of a blend has the same stations",
));
}
if section
.iter()
.any(|f| !(finite(f.p) && finite(f.leave) && finite(f.bend) && finite(f.bend_change)))
{
return Err(invalid("a blend's frame is not finite"));
}
}
let point = |i: usize, s: usize| -> Result<Point3> {
sections
.get(i)
.and_then(|sec| sec.get(s))
.map(|f| f.p)
.ok_or_else(|| invalid("a cross-section is missing a station"))
};
let frame = |i: usize, s: usize| -> Result<SectionFrame> {
sections
.get(i)
.and_then(|sec| sec.get(s))
.copied()
.ok_or_else(|| invalid("a cross-section is missing a station"))
};
for i in 0..run - 1 {
for s in 0..samples {
// the floor on coincident sections (a zero speed, no tangent to divide by next):
// refused, never nudged apart
if point(i, s)?.distance(point(i + 1, s)?) <= 0.0 {
return Err(invalid(
"two neighbouring cross-sections meet at a station: there is no room for a blend",
));
}
}
}
// the conditions at each section: the ends' orders; an interior one four where a fourth
// section reaches for the third difference, else three
let mut conditions = vec![3usize; run];
if let Some(c) = conditions.first_mut() {
*c = orders[0].conditions();
}
if let Some(c) = conditions.last_mut() {
*c = orders[1].conditions();
}
for (i, c) in conditions.iter_mut().enumerate().take(run - 1).skip(1) {
if i >= 2 || i + 2 < run {
*c = 4;
}
}
let degree = conditions
.windows(2)
.map(|w| match w {
[a, b] => a + b - 1,
_ => 0,
})
.max()
.unwrap_or(1)
.max(1);
#[allow(clippy::cast_precision_loss)] // at most 50 sections
let step = 1.0 / (run - 1) as f64;
// what each section holds along the run
let mut held: Vec<Held> = Vec::new();
for (i, &c) in conditions.iter().enumerate() {
let p: Vec<Point3> = (0..samples).map(|s| point(i, s)).collect::<Result<_>>()?;
let mut d: [Vec<Vec3>; 3] = [Vec::new(), Vec::new(), Vec::new()];
let first = i == 0;
let last = i + 1 == run;
for s in 0..samples {
let (d1, d2, d3) = if first || last {
let f = frame(i, s)?;
let (other, crown_here, sign) = if first {
(point(1, s)?, crown[0], 1.0)
} else {
(point(i - 1, s)?, crown[1], -1.0)
};
let length = f.leave.length();
// the floor on the tangent's length, divided by below: refused, never defaulted
if c >= 2 && !(length.is_finite() && length > 0.0) {
return Err(invalid("a blend's leave direction has no length"));
}
let leave = if length > 0.0 {
f.leave * (1.0 / length)
} else {
f.leave
};
// a third of the station's chord to its neighbour, the crown, over the interval
let speed = f.p.distance(other) / 3.0 * (1.0 + crown_here) / step;
let d1 = leave * (sign * speed);
let d2 = f.bend * (speed * speed);
let d3 = if c >= 4 {
// the last section's frames point back into the blend: read it backwards
if first {
third_derivative(d1, d2, f.bend_change)?
} else {
third_derivative(-d1, d2, f.bend_change)? * -1.0
}
} else {
Vec3::ZERO
};
(d1, d2, d3)
} else {
let before = point(i - 1, s)?;
let here = point(i, s)?;
let after = point(i + 1, s)?;
let d1 = (after - before) * (1.0 / (2.0 * step));
let d2 = (after - here * 2.0 + before) * (1.0 / (step * step));
let d3 = if c >= 4 {
let cube = step * step * step;
match (i.checked_sub(2), i + 2 < run) {
(Some(k), true) => {
(point(i + 2, s)? - point(i + 1, s)? * 2.0 + point(i - 1, s)? * 2.0
- point(k, s)?)
* (1.0 / (2.0 * cube))
}
(None, true) => {
(point(i + 2, s)? - point(i + 1, s)? * 3.0 + here * 3.0 - before)
* (1.0 / cube)
}
// (the backward stencil f(i+1) - 3 f(i) + 3 f(i-1) - f(i-2): SolidCraft's
// has its sign turned, see Deviations)
(Some(k), false) => {
(after - here * 3.0 + before * 3.0 - point(k, s)?) * (1.0 / cube)
}
(None, false) => {
return Err(invalid(
"no fourth section for an interior change of curvature",
))
}
}
} else {
Vec3::ZERO
};
(d1, d2, d3)
};
if !(finite(d1) && finite(d2) && finite(d3)) {
return Err(invalid(
"a section's derivatives along the run are not finite",
));
}
let [a, b, e] = &mut d;
a.push(d1);
b.push(d2);
e.push(d3);
}
held.push(Held { p, d });
}
// the spans' control rows, each elevated to the run's degree; a span's first row is the one
// before's last
let mut rows: Vec<Vec<Point3>> = Vec::new();
for j in 0..run - 1 {
let (Some(left), Some(right)) = (held.get(j), held.get(j + 1)) else {
return Err(invalid("a missing section"));
};
let (Some(&kl), Some(&kr)) = (conditions.get(j), conditions.get(j + 1)) else {
return Err(invalid("a missing section"));
};
let m = kl + kr - 1;
let mut span = vec![vec![Point3::ZERO; samples]; m + 1];
for s in 0..samples {
let take = |h: &Held| -> Result<(Point3, Vec<Vec3>)> {
let p = *h.p.get(s).ok_or_else(|| invalid("a missing station"))?;
let d =
h.d.iter()
.map(|v| {
v.get(s)
.copied()
.ok_or_else(|| invalid("a missing station"))
})
.collect::<Result<Vec<_>>>()?;
Ok((p, d))
};
let (pl, dl) = take(left)?;
let (pr, dr) = take(right)?;
for (k, q) in end_rows(pl, &dl, kl, m, step, false)?
.into_iter()
.enumerate()
{
if let Some(slot) = span.get_mut(k).and_then(|r| r.get_mut(s)) {
*slot = q;
}
}
for (k, q) in end_rows(pr, &dr, kr, m, step, true)?
.into_iter()
.enumerate()
{
if let Some(slot) = span.get_mut(m - k).and_then(|r| r.get_mut(s)) {
*slot = q;
}
}
}
while span.len() < degree + 1 {
span = elevate(&span);
}
rows.extend(span.into_iter().skip(usize::from(j > 0)));
}
// each row interpolated through its stations along v
#[allow(clippy::cast_precision_loss)] // at most 257 stations
let params: Vec<f64> = (0..samples)
.map(|s| s as f64 / (samples - 1) as f64)
.collect();
let degree_v = 3.min(samples - 1);
let knots_v = interp_knot_vector(&params, degree_v)?;
let kv = KnotVector::from_slice(&knots_v)?;
let count_u = rows.len();
let mut net = vec![Point3::ZERO; count_u * samples];
for (i, row) in rows.iter().enumerate() {
let curve = interp_given_params(row, &params, &kv, degree_v)?;
for (s, c) in curve.cpts().iter().take(samples).enumerate() {
// (exact tests: NOW is the stored "no weight" mark of a polynomial row, assigned,
// never computed, and 1.0 an assigned unit weight; any other weight is a weighted
// point)
let q = if c.w == NOW || c.w == 1.0 {
Point3::new(c.x, c.y, c.z)
} else {
Point3::new(c.x / c.w, c.y / c.w, c.z / c.w)
};
if let Some(slot) = net.get_mut(i * samples + s) {
*slot = q;
}
}
}
// the run's knots: clamped, every interior section `degree` times
let mut u_knots = vec![0.0];
let mut u_mults = vec![degree + 1];
for i in 1..run - 1 {
#[allow(clippy::cast_precision_loss)] // at most 50 sections
u_knots.push(i as f64 * step);
u_mults.push(degree);
}
u_knots.push(1.0);
u_mults.push(degree + 1);
let (v_knots, v_mults) = unique(&knots_v);
BSplineSurface::create_canonical(&CanonicalSurfaceData {
u_degree: degree,
v_degree: degree_v,
control_points: &net,
form: BSplineSurfaceForm::Unspecified,
u_multiplicities: &u_mults,
v_multiplicities: &v_mults,
u_knots: &u_knots,
v_knots: &v_knots,
knot_type: KnotType::Unspecified,
weights: None,
uv_domain: None,
})
}
/// A knot vector as its distinct knots and their multiplicities.
fn unique(knots: &[f64]) -> (Vec<f64>, Vec<usize>) {
let mut values: Vec<f64> = Vec::new();
let mut mults: Vec<usize> = Vec::new();
for &u in knots {
match (values.last(), mults.last_mut()) {
(Some(&last), Some(m)) if last == u => *m += 1,
_ => {
values.push(u);
mults.push(1);
}
}
}
(values, mults)
}
+18 -12
View File
@@ -64,28 +64,33 @@ pub use p8::{
};
pub use p9::{make_vertex_loop, orient_sheet_surfaces};
/// The relative accuracy [`is_outward_solid`] measures the volume's sign at: the sign of a
/// solid's volume is its whole volume away from zero.
const SIGN_ACCURACY: f64 = 1e-3;
/// Whether `b` is a valid closed solid facing out (not upstream): one closed manifold solid
/// ([`is_manifold_solid`]), passed by the kernel's validity check (its pointer check and
/// `AssertValid`, [`validation::check_brep`] at `Level0`), and its integrated volume positive.
/// `AssertValid`, [`validation::check_brep`] at `Level0`), and its volume proved positive by
/// its certified enclosure ([`crate::mass::enclosure::volume_sign`]; an undecided sign is
/// not).
///
/// The two guard the faceuse orientation together. A body with every face flipped
/// (`FlipFaceOrientation`, which leaves a face invalid) is still manifold and keeps a
/// positive volume (the integrator reads the loops and surfaces, not the uses' orientation):
/// the pointer check is what refuses it. A valid body turned inside out (its loops and
/// surfaces reversed with its uses) has a negative volume. The sign's margin is the whole
/// volume, so it is measured at a relative accuracy of `1e-3`.
/// surfaces reversed with its uses) has a negative volume.
///
/// A sign the enclosure leaves undecided (its interval still holds 0 when refinement ends:
/// a gap between faces as large as the volume, or the cell budget spent) is "not a solid" on
/// purpose: a solid is what is proved one, so ops' `finish` refuses the body as `NotSolid`.
/// A body the enclosure cannot read at all is an error, not `false`: a face with no NURBS form
/// (an offset surface, as the offset of a round keeps; a curve-bounded or in-volume surface),
/// a face without trim loops, or weights not proved positive.
///
/// A region the kernel is unsure of is [`crate::SolidError::UnsureRegion`], not "not a solid":
/// the volume's sign would read it as void.
///
/// # Errors
///
/// Those of the manifold test, the validity check and the volume;
/// [`crate::SolidError::UnsureRegion`].
/// Those of the manifold test, the validity check and the volume
/// ([`crate::mass::enclosure::volume_sign`]: [`crate::SolidError::Unsupported`] for a body
/// it cannot read); [`crate::SolidError::UnsureRegion`].
pub fn is_outward_solid(
k: &mut crate::kernel::Kernel,
b: &mut crate::topology::Brep,
@@ -123,9 +128,10 @@ fn is_valid_and_outward(
return Ok(false);
}
crate::mass::integrator::properties::no_unsure_region(b)?;
let volume = crate::mass::integrator::properties::brep_volume_default(k, b, SIGN_ACCURACY)?;
// (false for a NaN too)
Ok(volume > 0.0)
Ok(matches!(
crate::mass::enclosure::volume_sign(k, b)?,
crate::mass::enclosure::VolumeSign::Positive(_)
))
}
#[cfg(test)]
@@ -34,6 +34,7 @@ use crate::error::{EntityRef, Failure, Result, SolidError};
use crate::geom::attribute::ObjectKey;
use crate::geom::axis2_placement::Axis2Placement;
use crate::kernel::Kernel;
use crate::mass::enclosure::VolumeSign;
use crate::math::Vec3;
use crate::nurbs::b_spline_surface::p1::SurfParamType;
use crate::nurbs::knots::Side;
@@ -311,8 +312,12 @@ fn local_operation_on(
/// - the body's pointer check failed, so its geometry was not checked (`AssertValid` is
/// skipped then), even where the input's pointer check failed alike;
/// - the input was a manifold solid and the body is not;
/// - the body is a manifold solid whose volume is not positive (the input's volume is
/// not measured).
/// - the body is a manifold solid whose volume is not proved positive: its certified
/// enclosure lies below 0, or still holds 0 after its budget
/// ([`validation::volume_sign`]; the input's volume is not measured). A face whose net
/// is certified within ε > 0 of its definition (a Gⁿ or law round, a sweep, a Mixed
/// corner) is read on its net, so the gate decides on the body as stored; widening the
/// enclosure by ε needs bounds on the definition's `S_u` and `S_v` (#3170).
///
/// The owners the check tests the geometry against are the identities the body's naming
/// scheme names its entities by ([`validation::standing`]), as `move_faces` sets them.
@@ -357,10 +362,12 @@ fn refuse_unsound(k: &mut Kernel, b: &Brep, input: &validation::Standing) -> Res
Unsoundness::GeometryUnchecked
} else if let Some(lost) = validation::lost_manifold(found.manifold, || Ok(input.manifold))? {
lost
} else if found.manifold
&& validation::volume(k, &mut copy)?.partial_cmp(&0.0) != Some(std::cmp::Ordering::Greater)
{
Unsoundness::VolumeNotPositive
} else if found.manifold {
match validation::volume_sign(k, &mut copy)? {
VolumeSign::Positive(_) => return Ok(()),
VolumeSign::Negative(_) => Unsoundness::VolumeNotPositive,
VolumeSign::Undecided(_) => Unsoundness::VolumeUndecided,
}
} else {
return Ok(());
};
@@ -2085,6 +2092,36 @@ mod tests {
);
}
/// A body whose volume's sign is not proved is refused as such: the quarter shell R 10,
/// t 1e-6 passes the check, but its faces' boundaries lie apart by about its thickness,
/// so the strips between them hold 0 in its enclosure whatever its cells become.
#[test]
fn the_gate_refuses_a_body_whose_sign_it_cannot_prove() {
let mut k = Kernel::default();
let b = crate::primitive::api_primitives::create_cylindrical_box(
&mut k,
Point3::ZERO,
10.0,
10.0,
10.0 + 1e-6,
0.0,
90.0,
)
.unwrap();
let input =
validation::standing(&mut k, &mut b.clone_standing_for_self().unwrap()).unwrap();
let e = refuse_unsound(&mut k, &b, &input).unwrap_err();
assert!(
matches!(
e.failure().map(|f| &f.reason),
Some(crate::error::Reason::Offset(OffsetFailure::Unsound {
what: crate::error::offset::Unsoundness::VolumeUndecided
}))
),
"{e}"
);
}
/// A body whose pointer check fails is not checked further (`AssertValid` is skipped):
/// the gate refuses it even where its input failed the pointer check alike, rather
/// than finding nothing beyond the input's failures.
@@ -2502,13 +2539,11 @@ mod tests {
}
/// The same cube, its top given a roof with a ridge: a degree-1 B-spline patch whose
/// interior knot is a crease. The roof is a bare net beside the sides, and the reach is
/// bounded where it meets them (across the crease too: the net is continuous there), so
/// a body is built; it fails more of the check than the cube did (the sides along the
/// ridge meet the roof in a kinked line), so the gate refuses it as `Unsound`, and the
/// cube is as it came in, with the input's own failures.
/// interior knot is a crease. The roof has no exact class, so the reach of the moved
/// top has no certified bound: refused as `ReachUncertified` at the top, before any
/// body is built, the cube as it came in with the input's own failures.
#[test]
fn a_creased_roof_on_a_defective_input_is_refused_as_unsound() {
fn a_creased_roof_on_a_defective_input_is_refused_as_reach_uncertified() {
use crate::nurbs::b_spline_surface::BSplineSurface;
use crate::nurbs::math::{CPoint, NOW};
use crate::nurbs::srf_basic::Surface as Net;
@@ -2529,15 +2564,7 @@ mod tests {
.unwrap();
let roof = Surface::from(BSplineSurface::from_gw_nurb(&roof));
let why = refused_unchanged_or_returned(&mut k, &mut b, &[top], vec![roof]);
assert!(
matches!(
why,
Some(OffsetFailure::Unsound {
what: crate::error::offset::Unsoundness::Check { .. }
})
),
"{why:?}"
);
assert_eq!(why, Some(OffsetFailure::ReachUncertified { face: top }));
assert_eq!(failure_classes(&mut k, &b), had);
}
+56 -1
View File
@@ -95,7 +95,7 @@ use crate::geom::extent1d::Extent1d;
use crate::geom::extent2d::Extent2d;
use crate::geom::object::{NotifyOperation, ObjectRef};
use crate::heal::stitch::p1::{DefaultStitchCallback, Stitch, StitchCallback};
use crate::heal::stitch::p2::MinGapRequest;
use crate::heal::stitch::p2::{ClosedGap, MinGapRequest};
use crate::kernel::Kernel;
use crate::math::{Point2, Vec3, EFF_ZERO};
use crate::nurbs::b_spline_curve_n_lib::TrimMode;
@@ -1900,6 +1900,61 @@ pub(crate) fn stitch_faces_with_callback<'a, C: StitchCallback>(
)
}
/// [`stitch_faces_with_callback`] that additionally returns each gap the stitch
/// closed, with the tolerance growth its closure applied (not upstream,
/// solidcore#2975). The caller supplies the rollback boundary; certificate errors
/// propagate regardless of the legacy edit-status flags.
///
/// # Errors
///
/// Those of [`stitch_faces`] and geometry enclosure failures (unsupported curve
/// kinds, invalid data or non-finite bounds).
pub(crate) fn stitch_faces_with_gap_report(
k: &mut Kernel,
b: &mut Brep,
xsect_tol_3d: f64,
faces: Option<&[Option<FaceId>]>,
allow_spine_edges: bool,
identity: Option<crate::query::brep::BrepIdentity<'_>>,
) -> Result<(StitchOutcome, Vec<ClosedGap>)> {
// lock brep bounding box, until goes out of scope
let mut lockit = LockBrepBox::new(k, b)?;
let b = lockit.brep();
// construct Stitch manager with the default keep/delete logic for glue pairs,
// recording each closure
let mut callback = DefaultStitchCallback::new();
let mut stitch = Stitch::new(&mut callback, xsect_tol_3d, true, true);
stitch.query_identity = identity;
stitch.fast_edge_compare = true;
stitch.ignore_problems = true;
stitch.squeeze_small_edges = true;
stitch.making_manifold_solid = !allow_spine_edges;
stitch.remove_laminar_slivers = true;
stitch.report_gaps = Some(Vec::new());
let mut lists = None;
if let Some(faces) = faces {
let mut vertices = Vec::new();
let mut edges = Vec::new();
for f in faces.iter().flatten() {
vertices.extend(face_vertices(b, *f)?);
edges.extend(face_edges(b, *f)?);
}
lists = Some((vertices, edges));
}
// pass the stitch call along (SER: its status is returned as is)
let outcome = do_stitching(
k,
b,
&mut stitch,
lists.as_ref().map(|(v, _)| v.as_slice()),
lists.as_ref().map(|(_, e)| e.as_slice()),
)?;
let gaps = stitch.take_gaps();
Ok((outcome, gaps))
}
// ---- ShrinkGeometry ------------------------------------------------------------------------
/// `SmBrep::ShrinkGeometry`: shrinks each face's UV domain to its trim curves (snapped
+98
View File
@@ -78,6 +78,7 @@ use crate::geom::extent1d::Extent1d;
use crate::geom::extent3d::Extent3d;
use crate::geom::object::{NotifyOperation, ObjectRef};
use crate::geom::tol::ZONE_TOL_3D;
use crate::heal::stitch::p2::ClosedGap;
use crate::kernel::Kernel;
use crate::math::{max2, Point3, Vec3};
use crate::nurbs::b_spline_curve::BSplineCurve;
@@ -2662,6 +2663,103 @@ fn stitch_faces(
Ok(())
}
/// [`stitch_faces`] that also returns what the stitch closed: each gap, the
/// entities, and the tolerance growth its closure applied (`solidcore#2975`).
///
/// # Errors
///
/// Those of [`stitch_faces`].
fn stitch_faces_reported(
k: &mut Kernel,
b: &mut Brep,
tolerance: f64,
allow_spine: bool,
lamina: &mut usize,
gaps: &mut Vec<ClosedGap>,
) -> Result<()> {
let (out, pass_gaps) = crate::topology::brep::p4::stitch_faces_with_gap_report(
k,
b,
tolerance,
None,
allow_spine,
None,
)?;
gaps.extend(pass_gaps);
*lamina = usize::try_from(out.lamina_edges)
.map_err(|_| SolidError::invalid_input(OP, "lamina edge count"))?;
Ok(())
}
/// [`stitch_and_orient`] that additionally reports what the stitch closed
/// (`solidcore#2975`): for every gap it closed, the entities it glued, the gap it
/// enclosed, and the tolerance growth its closure applied. Geometry bounds are
/// independent of the stitcher's sampled acceptance test and do not feed back
/// into the construction. A caller decides whether the returned bounds suffice.
/// On any error the B-rep is restored, including when certification fails after
/// earlier closures. Errors are never suppressed by legacy status flags.
///
/// # Errors
///
/// As [`stitch_and_orient`], plus [`SolidError::Unsupported`] for curve kinds
/// without a proved enclosure and [`SolidError::InvalidInput`] for invalid data
/// or a non-finite enclosure. No partial report or partial edit is returned.
pub fn stitch_and_orient_reported(
k: &mut Kernel,
b: &mut Brep,
options: &StitchAndOrientOptions,
) -> Result<Vec<ClosedGap>> {
k.check_cancelled(OP)?;
b.with_rollback(
|b| stitch_and_orient_reported_inner(k, b, options),
Result::is_ok,
)
}
fn stitch_and_orient_reported_inner(
k: &mut Kernel,
b: &mut Brep,
options: &StitchAndOrientOptions,
) -> Result<Vec<ClosedGap>> {
let mut lamina = 0;
let mut gaps = Vec::new();
let mut tolerance = initial_stitch_tolerance(b.tolerance(), options.stitch_tolerance);
stitch_faces_reported(
k,
b,
tolerance,
options.allow_spine_edges,
&mut lamina,
&mut gaps,
)?;
if lamina > 1 {
tolerance = if options.stitch_tolerance <= 0.0 {
tolerance * 20.0
} else {
tolerance * 10.0
};
stitch_faces_reported(
k,
b,
tolerance,
options.allow_spine_edges,
&mut lamina,
&mut gaps,
)?;
}
plain_edit(k, b, |k, b, env| {
orient_trimmed_surfaces(
k,
b,
Some(env),
true,
options.just_unify.unwrap_or(lamina > 0),
options.faces_to_swap.as_deref(),
)
})?;
Ok(gaps)
}
/// `SmBrep::OrientTrimmedSurfaces(bNormalsOutward, bJustUnifyNormals, pFacesToSwapUV)`
/// ([`crate::topology::brep::p3::orient_trimmed_surfaces`]), notifying as
/// [`with_p3_notifications`].
+1 -1
View File
@@ -34,7 +34,7 @@ pub use coincidence::{contains_coincident_topology, CoincidenceResult};
pub(crate) use pointers::validate_geometry;
pub use pointers::validate_pointers;
pub(crate) use soundness::lost_manifold;
pub use soundness::{soundness, standing, volume, Soundness, Standing};
pub use soundness::{soundness, standing, volume_sign, Soundness, Standing};
use crate::error::{EntityRef, Result, SolidError};
use crate::geom::assert_array::{AssertArray, AssertReport, ObjectKey, ReportSpec};
@@ -1,7 +1,7 @@
//! (not upstream) A body's soundness: its check at `SM_LEVEL_2`, and for a closed body,
//! whether it is a manifold solid of positive volume. The local operation measures its
//! input's [`standing`] (the check with the body's own owner naming, and the manifold
//! test) and its result's, and the result's [`volume`] only to test it is positive
//! test) and its result's, and the result's [`volume_sign`] only to test it is positive
//! ([`crate::topology::brep::local_operation::local_operation`]); the goldens soundness
//! probe (`goldens::soundness`) measures result bodies with [`soundness`] (the geometry's
//! recorded owners).
@@ -15,10 +15,7 @@ use crate::kernel::Kernel;
use crate::topology::Brep;
use super::assert_valid::{check_brep_with_keys, BrepCheck, CheckLevel};
/// The relative accuracy the volume is integrated at: its sign is a verdict on the body,
/// which a closed solid's whole volume holds away from zero.
const VOLUME_ACCURACY: f64 = 1e-6;
use crate::mass::enclosure::VolumeSign;
/// What [`soundness`] measured of one body.
#[derive(Debug, Clone)]
@@ -28,16 +25,17 @@ pub struct Soundness {
pub check: BrepCheck,
/// Whether the body is a manifold solid ([`crate::topology::brep::is_manifold_solid`]).
pub manifold: bool,
/// Its volume, when it is a manifold solid.
pub volume: Option<f64>,
/// Its volume's sign, when it is a manifold solid ([`volume_sign`]).
pub volume: Option<VolumeSign>,
}
impl Soundness {
/// Whether the check is clean and, when the body is a manifold solid, its volume is
/// positive (a NaN volume is not).
/// proved positive (an undecided sign is not).
#[must_use]
pub fn sound(&self) -> bool {
self.check.is_valid() && (!self.manifold || self.volume.is_some_and(|v| v > 0.0))
self.check.is_valid()
&& (!self.manifold || matches!(self.volume, Some(VolumeSign::Positive(_))))
}
}
@@ -99,7 +97,11 @@ pub fn soundness(k: &mut Kernel, b: &mut Brep) -> Result<Soundness> {
};
let check = check_brep_with_keys(k, b, CheckLevel::Level2, &keys)?;
let manifold = crate::topology::brep::p2::is_manifold_solid(b)?;
let volume = if manifold { Some(volume(k, b)?) } else { None };
let volume = if manifold {
Some(volume_sign(k, b)?)
} else {
None
};
Ok(Soundness {
check,
manifold,
@@ -111,7 +113,7 @@ pub fn soundness(k: &mut Kernel, b: &mut Brep) -> Result<Soundness> {
/// geometry's owner the identity `b`'s naming scheme names the entity by
/// ([`Brep::owner_identity`], as the operations that bind geometry set it), so a curve or
/// surface owned under another entity's identity fails the owner tests; then the manifold
/// test. Its volume is not measured ([`volume`]).
/// test. Its volume is not measured ([`volume_sign`]).
///
/// As for [`soundness`], the check edits what it measures: measure a private copy.
///
@@ -151,11 +153,15 @@ pub(crate) fn lost_manifold(
Ok(Some(crate::error::offset::Unsoundness::NotManifoldSolid))
}
/// The volume of the manifold solid `b`, integrated at [`VOLUME_ACCURACY`].
/// The sign of the manifold solid `b`'s volume, from its certified enclosure
/// ([`crate::mass::enclosure::volume_sign`]): positive or negative where the enclosure
/// proves it, undecided where it still holds 0 after its budget. A face whose net is
/// certified within ε > 0 of its definition is read on its net: the sign is the stored
/// body's (#3170 widens it by ε).
///
/// # Errors
///
/// Those of [`crate::mass::integrator::properties::brep_volume_default`].
pub fn volume(k: &mut Kernel, b: &mut Brep) -> Result<f64> {
crate::mass::integrator::properties::brep_volume_default(k, b, VOLUME_ACCURACY)
/// Those of [`crate::mass::enclosure::volume_sign`].
pub fn volume_sign(k: &Kernel, b: &mut Brep) -> Result<VolumeSign> {
crate::mass::enclosure::volume_sign(k, b)
}
+45 -12
View File
@@ -107,6 +107,10 @@
//! `Kernel::fixes.imprint_extends_edge_to_proto_vertex`, [`ProtoTopologyManager::imprint_edge_definition`]
//! first fits each end of the definition's interval to its proto vertex (`imprint_ends`: the SMLib
//! fork's thread-groove fixes, tangency-labs/smlib b43876f, a8caccd).
//! - **Not upstream: one trim interval** (WR-767, tangency-labs/solidcore#3214).
//! [`ProtoTopologyManager::create_proto_edge`] trims the 3D curve and its uv curves to the
//! one interval none of their trims snaps (`exact_pieces::common_trim_interval`); upstream
//! trims them in turn with one interval, and the edge can end past its 3D curve.
//! - `UpdateEdges` trims a definition at the split point; its alternative of splitting
//! the proto edge (`SmProtoEdge::Split`) sits behind `static constexpr SmBoolean
//! cbiDoSplit = FALSE` and is not ported.
@@ -140,6 +144,7 @@ use crate::math::{max2, max3, max4, min2, Point2, Point3, Vec3, EFF_ZERO};
use crate::nurbs::b_spline_curve_n_lib::TrimMode;
use crate::nurbs::b_spline_surface_n_lib::p2::OrientType;
use crate::query::face::p2::proto_vertex_containment_radius;
use crate::surface::surface::exact_pieces::{common_trim_interval, trim_unsnapped};
use crate::topology::brep::p5::{make_edge_in_face_in, EdgeInFace};
use crate::topology::VertexId;
@@ -2211,26 +2216,54 @@ impl<O: ClassObject> ProtoTopologyManager<O> {
let p3d_curve = cc1.curve();
let _ = cc2.curve() == p3d_curve;
// Create a copy of the input curve and trim it to this CurveInterval.
// Create a copy of the input curve and of its uv curves (copy statuses ignored), and
// trim them to this CurveInterval.
let mut copy = curve_of(brep, p3d_curve)?.copy(k)?;
let mut domain = ci1.interval;
copy.trim(k, &mut domain, TrimMode::Notify)?;
// See about uv curves (copy statuses ignored; the trims' passed on with
// `ssi_proto_topology_statuses_pass_on`, fork bcc6f01).
let pass = k.fixes.ssi_proto_topology_statuses_pass_on;
let mut uv1 = None;
if let Some(c) = cc1.uv_curve1() {
uv1 = ignore_soft(curve_of(brep, Some(c))?.copy(k))?;
if let Some(u) = uv1.as_mut() {
ignore_soft(passed_on(u.trim(k, &mut domain, TrimMode::Notify), pass)?)?;
}
}
let mut uv2 = None;
if let Some(c) = cc2.uv_curve1() {
uv2 = ignore_soft(curve_of(brep, Some(c))?.copy(k))?;
if let Some(u) = uv2.as_mut() {
ignore_soft(passed_on(u.trim(k, &mut domain, TrimMode::Notify), pass)?)?;
}
// The uv curves' trim statuses are ignored, and passed on with
// `ssi_proto_topology_statuses_pass_on` (fork bcc6f01). Not upstream (WR-767): the
// 3D curve and the uv curves whose trims accept the interval are trimmed to the one
// interval none of their trims snaps ([`common_trim_interval`]). Upstream trims them
// in turn with one interval, each trim snapping it onto a knot of its own curve, and
// the edge takes the interval the last trim left: a uv curve's knot a few ulps from
// the 3D curve's trim parameter leaves the edge's interval outside its 3D curve's
// (`SmEdge` test 2). A uv curve whose trim refuses the interval is trimmed alone, as
// upstream.
let pass = k.fixes.ssi_proto_topology_statuses_pass_on;
let mut joins = [false; 2];
for (u, join) in [&uv1, &uv2].into_iter().zip(&mut joins) {
if let Some(u) = u {
let mut probe = ci1.interval;
let trimmed = u.copy(k)?.trim(k, &mut probe, TrimMode::IntervalOnly);
*join = ignore_soft(passed_on(trimmed, pass)?)?.is_some();
}
}
let joined: Vec<&Curve> = std::iter::once(&copy)
.chain(
[&uv1, &uv2]
.into_iter()
.zip(joins)
.filter_map(|(u, join)| u.as_ref().filter(|_| join)),
)
.collect();
let domain = common_trim_interval(k, &joined, ci1.interval, OP_CREATE_PE)?;
trim_unsnapped(k, &mut copy, domain, OP_CREATE_PE)?;
for (u, join) in [&mut uv1, &mut uv2].into_iter().zip(joins) {
if let Some(u) = u.as_mut() {
let trimmed = if join {
trim_unsnapped(k, u, domain, OP_CREATE_PE)
} else {
u.trim(k, &mut domain.clone(), TrimMode::Notify)
};
ignore_soft(passed_on(trimmed, pass)?)?;
}
}
@@ -0,0 +1,200 @@
//! #3144: whether two bodies meet, decided by a certified bracket of their boundary distance
//! (`query::contact`), against closed forms: boxes touching on a face, apart by 1e-9,
//! overlapping in a face, an edge resting on a face, interpenetrating, one inside the other,
//! far apart; spheres apart and crossing; a cylinder crossing a box's face and one against a
//! side along a line; a peg through a hole that it clears.
#![allow(clippy::unwrap_used, clippy::panic, clippy::float_cmp)] // test
use solidcore::boolean::api_brep::boolean_difference;
use solidcore::geom::certified::Verdict;
use solidcore::kernel::Kernel;
use solidcore::primitive::api_primitives::{create_box, create_cylinder, create_sphere};
use solidcore::query::contact::{boundary_distance, meets};
use solidcore::topology::brep::mirror::transform_matrix;
use solidcore::{Brep, Point3};
fn k() -> Kernel {
Kernel {
fixes: solidcore::kernel::UpstreamFixes::all(),
..Kernel::default()
}
}
fn cube(k: &mut Kernel, at: [f64; 3], size: f64) -> Brep {
create_box(k, Point3::new(at[0], at[1], at[2]), size, size, size).unwrap()
}
/// `b` turned by `angle` about the x axis, then moved by `by`.
fn turned_about_x(k: &mut Kernel, b: &mut Brep, angle: f64, by: [f64; 3]) {
let (c, s) = (angle.cos(), angle.sin());
// row vectors (`p' = p A + t`): A's rows are the images of x, y and z, t the fourth row
let m = [
[1.0, 0.0, 0.0, 0.0],
[0.0, c, s, 0.0],
[0.0, -s, c, 0.0],
[by[0], by[1], by[2], 1.0],
];
transform_matrix(k, b, &m, true).unwrap();
}
/// Asserts the verdict at `tol`, the bracket shown on failure.
fn verdict_is(k: &Kernel, a: &mut Brep, b: &mut Brep, tol: f64, want: Verdict) {
let c = meets(k, a, b, tol).unwrap();
assert!(c.lower <= c.upper, "{c:?}");
assert_eq!(c.verdict, want, "at {tol}: {c:?}");
}
/// The bracket of the boundary distance, asserted to hold `exact`.
fn holds(k: &Kernel, a: &mut Brep, b: &mut Brep, exact: f64) {
let c = boundary_distance(k, a, b, 1e-6).unwrap();
assert_eq!(c.verdict, Verdict::Yes, "{c:?}");
assert!(c.lower <= exact && exact <= c.upper, "{exact} not in {c:?}");
assert!(c.upper - c.lower <= 1e-6 * c.upper.max(1.0), "{c:?}");
}
#[test]
fn boxes_touching_on_a_face_meet_at_any_tolerance() {
let mut k = k();
let mut a = cube(&mut k, [0.0; 3], 1.0);
let mut b = cube(&mut k, [1.0, 0.0, 0.0], 1.0);
let c = meets(&k, &mut a, &mut b, 1e-12).unwrap();
assert_eq!(c.verdict, Verdict::Yes, "{c:?}");
let [p, q] = c.witness.unwrap();
assert!(
p.distance(q) <= 1e-12 && (p.x - 1.0).abs() <= 1e-12,
"{c:?}"
);
}
#[test]
fn boxes_apart_by_a_nanometre_meet_only_at_a_tolerance_past_it() {
let mut k = k();
// the gap as the doubles have it
let gap = (1.0 + 1e-9) - 1.0;
let mut a = cube(&mut k, [0.0; 3], 1.0);
let mut b = cube(&mut k, [1.0 + 1e-9, 0.0, 0.0], 1.0);
verdict_is(&k, &mut a, &mut b, 1e-8, Verdict::Yes);
verdict_is(&k, &mut a, &mut b, 1e-10, Verdict::No);
holds(&k, &mut a, &mut b, gap);
}
#[test]
fn boxes_overlapping_in_a_face_meet() {
let mut k = k();
let mut a = cube(&mut k, [0.0; 3], 1.0);
let mut b = cube(&mut k, [1.0, 0.5, 0.5], 1.0);
verdict_is(&k, &mut a, &mut b, 1e-12, Verdict::Yes);
}
#[test]
fn an_edge_resting_on_a_face_meets_it() {
let mut k = k();
let mut a = cube(&mut k, [0.0; 3], 1.0);
// a cube centred on the x axis, turned 45 degrees about it (either way: it is square), is
// a diamond whose lowest edge is at z = -sqrt(2)/2; moved onto a's top face
let mut b = cube(&mut k, [0.0, -0.5, -0.5], 1.0);
let half = std::f64::consts::FRAC_1_SQRT_2;
turned_about_x(
&mut k,
&mut b,
std::f64::consts::FRAC_PI_4,
[0.0, 0.5, 1.0 + half],
);
let c = meets(&k, &mut a, &mut b, 1e-12).unwrap();
assert_eq!(c.verdict, Verdict::Yes, "{c:?}");
let [p, _] = c.witness.unwrap();
assert!((p.z - 1.0).abs() <= 1e-9, "{c:?}");
}
#[test]
fn interpenetrating_boxes_meet() {
let mut k = k();
let mut a = cube(&mut k, [0.0; 3], 1.0);
let mut b = cube(&mut k, [0.5; 3], 1.0);
verdict_is(&k, &mut a, &mut b, 1e-12, Verdict::Yes);
}
#[test]
fn a_box_inside_another_meets_it_and_their_walls_are_one_apart() {
let mut k = k();
let mut a = cube(&mut k, [0.0; 3], 3.0);
let mut b = cube(&mut k, [1.0; 3], 1.0);
verdict_is(&k, &mut a, &mut b, 1e-3, Verdict::Yes);
verdict_is(&k, &mut b, &mut a, 1e-3, Verdict::Yes);
holds(&k, &mut a, &mut b, 1.0);
}
#[test]
fn boxes_far_apart_do_not_meet_and_their_gap_is_bracketed() {
let mut k = k();
let mut a = cube(&mut k, [0.0; 3], 1.0);
let mut b = cube(&mut k, [5.0, 0.0, 0.0], 1.0);
verdict_is(&k, &mut a, &mut b, 0.1, Verdict::No);
holds(&k, &mut a, &mut b, 4.0);
}
#[test]
fn spheres_apart_are_their_centres_gap_less_their_radii() {
let mut k = k();
let mut a = create_sphere(&mut k, Point3::new(0.0, 0.0, 0.0), 1.0).unwrap();
let mut b = create_sphere(&mut k, Point3::new(3.0, 0.0, 0.0), 1.0).unwrap();
verdict_is(&k, &mut a, &mut b, 0.5, Verdict::No);
verdict_is(&k, &mut a, &mut b, 2.0, Verdict::Yes);
holds(&k, &mut a, &mut b, 1.0);
}
#[test]
fn crossing_spheres_meet_where_their_surfaces_cross() {
let mut k = k();
let mut a = create_sphere(&mut k, Point3::new(0.0, 0.0, 0.0), 1.0).unwrap();
let mut b = create_sphere(&mut k, Point3::new(1.5, 0.0, 0.0), 1.0).unwrap();
let c = meets(&k, &mut a, &mut b, 1e-9).unwrap();
assert_eq!(c.verdict, Verdict::Yes, "{c:?}");
// the witness is on the circle where they cross: x = 0.75, |p| = 1
let [p, _] = c.witness.unwrap();
assert!((p.x - 0.75).abs() <= 1e-6, "{c:?}");
}
#[test]
fn a_cylinder_crossing_a_box_face_meets_the_box() {
let mut k = k();
let mut a = cube(&mut k, [0.0; 3], 1.0);
let mut b = create_cylinder(&mut k, Point3::new(0.5, 0.5, 0.5), 0.3, 1.0).unwrap();
verdict_is(&k, &mut a, &mut b, 1e-9, Verdict::Yes);
}
/// A cylinder touching a box side along a line meets it; moved 0.25 off, its bracket holds
/// 0.25 and the verdicts either side of it are exact.
///
/// The bracket is not 1e-6 wide here: trimmed rim pieces stall `lower` at 0.25 - 0.0013 (the
/// end caps' pieces at the rim straddle the circular trim, and each hull carries a sliver of
/// the cap's plane outside the disk toward the box; their subdivision runs out of pairs
/// first). The bracket contains the truth; verdicts outside [0.2487, 0.25] are exact. The
/// width asserted is what the pair budget certifies, not a target.
#[test]
fn a_cylinder_against_a_box_side_meets_it_along_a_line() {
let mut k = k();
let mut a = cube(&mut k, [0.0; 3], 1.0);
let mut b = create_cylinder(&mut k, Point3::new(1.5, 0.5, 0.0), 0.5, 1.0).unwrap();
verdict_is(&k, &mut a, &mut b, 1e-9, Verdict::Yes);
let mut far = create_cylinder(&mut k, Point3::new(1.75, 0.5, 0.0), 0.5, 1.0).unwrap();
verdict_is(&k, &mut a, &mut far, 0.24, Verdict::No);
verdict_is(&k, &mut a, &mut far, 0.26, Verdict::Yes);
let c = boundary_distance(&k, &mut a, &mut far, 1e-6).unwrap();
assert!(c.lower <= 0.25 && 0.25 <= c.upper, "{c:?}");
assert!(c.lower >= 0.2487 && c.upper - 0.25 <= 1e-12, "{c:?}");
}
#[test]
fn a_peg_through_a_hole_it_clears_does_not_meet_the_plate() {
let mut k = k();
// a 4 x 4 x 1 plate with a hole of radius 1 through its middle, and a peg of radius 0.75
// down the hole's axis: the peg clears the hole's wall by 0.25
let plate = create_box(&mut k, Point3::new(0.0, 0.0, 0.0), 4.0, 4.0, 1.0).unwrap();
let hole = create_cylinder(&mut k, Point3::new(2.0, 2.0, -1.0), 1.0, 3.0).unwrap();
let mut plate = boolean_difference(&mut k, plate, hole).unwrap();
let mut peg = create_cylinder(&mut k, Point3::new(2.0, 2.0, -2.0), 0.75, 5.0).unwrap();
verdict_is(&k, &mut plate, &mut peg, 0.1, Verdict::No);
holds(&k, &mut plate, &mut peg, 0.25);
}
@@ -1,8 +1,8 @@
//! A cylinder wall of a short arc far from the origin, extended 1500 mm along its rulings and
//! round its axis. The iso that certifies the turn is interpolated between rulings 3 m long.
//! At 500 mm the certificate took its rounding at the iso's own size and refused; at 1500 mm a
//! weight ratio per row, differing by the continuation's rounding, took the iso out of its
//! plane by 1.8e-10. Every point of the result stays on the cylinder, radius 80 about its axis.
//! A cylinder wall of a short arc far from the origin, extended 500 and 1500 mm along its
//! rulings and round its axis. The iso that certifies the turn is interpolated between rulings
//! up to 3 m long: a weight ratio per row, differing by the continuation's rounding, took it out
//! of its plane by 1.8e-10, and the certificate refused the extension as not exact. Every point
//! of the result stays on the cylinder, radius 80 about its axis.
#![allow(
clippy::unwrap_used,
clippy::expect_used,
@@ -16,9 +16,11 @@ use solidcore::kernel::UpstreamFixes;
use solidcore::math::{Point2, Point3, Vec3};
use solidcore::primitive::api_curves as curves;
use solidcore::primitive::api_primitives::create_box;
use solidcore::surface::cone::p1::Cone;
use solidcore::surface::extend::Continuity;
use solidcore::surface::implicit::AnalyticSupport;
use solidcore::surface::side::BoxSide;
use solidcore::surface::surface::Surface;
use solidcore::sweep::api_brep::create_linear_sweep;
use solidcore::topology::brep::p1 as bp1;
use solidcore::{Brep, Kernel};
@@ -108,3 +110,46 @@ fn a_short_arc_extends_along_long_rulings_on_its_cylinder() {
}
}
}
/// A cylinder of radius 1, 60 degrees round and 1 high at the origin, extended along its
/// rulings and round its axis by up to 1e5: the frame's size stays the source's (about 1)
/// while the rows grow to the extension, so this is where a limit at the iso's own size
/// could fall under its rounding. Every point stays on the cylinder.
#[test]
fn a_small_arc_extends_far_beyond_its_size_on_its_cylinder() {
let k = Kernel::default();
let wall = Surface::from(Cone::new_cylinder_bounded(
Vec3::new(0.0, 0.0, 0.0),
Vec3::new(1.0, 0.0, 0.0),
Vec3::new(0.0, 1.0, 0.0),
1.0,
0.0,
60.0,
1.0,
false,
false,
false,
));
for d in [1e2, 1e3, 1e4, 1e5] {
let grown = wall
.extended_sides_keeping_parameters(&k, &BoxSide::ALL, Continuity::Ruled, d)
.unwrap_or_else(|e| panic!("{d}: {e}"))
.surface;
let dom = grown.natural_uv_domain_with_nurb(&k).unwrap();
let (u, v) = (dom.u_interval(), dom.v_interval());
for i in 0..=8 {
for j in 0..=8 {
let uv = Point2::new(
u.min() + u.length() * f64::from(i) / 8.0,
v.min() + v.length() * f64::from(j) / 8.0,
);
let x = grown.evaluate_point(&k, uv).unwrap();
let r = x.x.hypot(x.y);
assert!(
(r - 1.0).abs() < 1e-9 * (1.0 + x.z.abs()),
"{d} {i} {j} {r}"
);
}
}
}
}
@@ -0,0 +1,213 @@
//! A sphere's NURBS form is recognised as its sphere far from the origin, at any radius and
//! orientation, and the half-meridian test allows the angles' rounding at the points' size,
//! no more (#3131). A sphere of radius 2.5 at 1e5 reads 180 + 6.7e-10 degrees, past the fixed
//! half-meridian tolerance, and was not recognised.
#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing
)] // test helpers
use solidcore::blend::fillet_section::station::as_b_spline;
use solidcore::curve::curve::Curve;
use solidcore::geom::axis2_placement::Axis2Placement;
use solidcore::geom::extent2d::Extent2d;
use solidcore::math::{Point3, Vec3};
use solidcore::nurbs::b_spline_curve::{BSplineCurve, NurbCircleParam};
use solidcore::nurbs::b_spline_surface::BSplineSurface;
use solidcore::nurbs::math::gamma;
use solidcore::nurbs::srf_basic::Surface as Net;
use solidcore::primitive::api_primitives::create_sphere;
use solidcore::surface::implicit::AnalyticSupport;
use solidcore::surface::sphere::Sphere;
use solidcore::surface::surf_of_revolution::SurfOfRevolution;
use solidcore::surface::surface::Surface;
use solidcore::topology::brep::p1 as bp1;
use solidcore::Kernel;
/// The allowance the recogniser adds, as a position: `4 gamma_K (1 + |corners| + 2 r)`,
/// `K = 16 (2 + 2 + 2)` for a form of degrees (2, 2) (written out so that a change to the
/// allowance fails). In degrees it is this over `r`, turned to degrees.
fn allowance_position(form: &BSplineSurface, r: f64) -> f64 {
let d = form.natural_uv_domain();
let corners = form
.evaluate_point(d.min())
.unwrap()
.max_dimension()
.max(form.evaluate_point(d.max()).unwrap().max_dimension());
4.0 * gamma(16 * 6) * (1.0 + corners + 2.0 * r)
}
/// The NURBS form of a sphere of radius `r` at `c`, turned by `turn` degrees about the axis
/// (1, 1, 1) when it is not 0, and its label.
fn form_at(k: &mut Kernel, c: Point3, r: f64, turn: f64) -> (BSplineSurface, AnalyticSupport) {
let b = create_sphere(k, Point3::new(0.0, 0.0, 0.0), r).unwrap();
let f = bp1::faces(&b, None).unwrap()[0];
let mut s = b
.face(f)
.unwrap()
.surface
.as_ref()
.unwrap()
.copy(k)
.unwrap();
// turned about (1, 1, 1) by `turn`, then moved to `c`
let a = turn.to_radians();
let n = Vec3::new(1.0, 1.0, 1.0) * (1.0 / 3.0_f64.sqrt());
let rot = |v: Vec3| v * a.cos() + n.cross(v) * a.sin() + n * (n.dot(v) * (1.0 - a.cos()));
let mut place = Axis2Placement::default();
place
.set_canonical(
c,
rot(Vec3::new(1.0, 0.0, 0.0)),
rot(Vec3::new(0.0, 1.0, 0.0)),
)
.unwrap();
s.transform(k, &place, None).unwrap();
let form = as_b_spline(k, &s).unwrap().unwrap().into_owned();
(form, AnalyticSupport::of(&s).unwrap())
}
/// Recognised at radii 1e-3 to 1e3, 0 to 1e7 from the origin (at most 1e9 radii out), on the
/// axes and turned off them, with its radius and centre within the allowance as a position.
#[test]
fn a_far_sphere_is_recognised_on_its_own_numbers() {
let mut k = Kernel::default();
for r in [1e-3, 1.0, 2.5, 1e3] {
for off in [0.0, 1e3, 1e5, 1e6, 1e7] {
// a sphere of radius 1e-3 at 1e7, ten orders apart: upstream's iso-circle test
// fails with a status there (1001), before any angle is read; unchanged here
if off > 1e9 * r {
continue;
}
for turn in [0.0, 37.0] {
let c = Point3::new(off, off, off + 10.0);
let (form, _) = form_at(&mut k, c, r, turn);
let found = Sphere::is_nurb_surface_sphere(&k, &form, 1.0)
.unwrap()
.unwrap_or_else(|| panic!("not recognised: r {r} at {off}, turned {turn}"));
let AnalyticSupport::Sphere { placement, radius } =
AnalyticSupport::of(&Surface::from(found)).unwrap()
else {
panic!("not a sphere");
};
let tol = allowance_position(&form, r);
assert!((radius - r).abs() <= tol, "r {r} at {off}: radius {radius}");
assert!(
(placement.origin() - c).length() <= tol,
"r {r} at {off}: centre {:e} past {tol:e}",
(placement.origin() - c).length()
);
}
}
}
}
/// A half meridian of `180 + extra` degrees, radius `r`, at `c`: a circle arc from -90 to
/// `90 + extra` degrees in the plane y = c.y, turned a quarter turn about the vertical.
fn long_meridian(k: &Kernel, c: Point3, r: f64, extra: f64) -> BSplineSurface {
let mut frame = Axis2Placement::default();
frame
.set_canonical(c, Vec3::new(1.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 1.0))
.unwrap();
let generator = BSplineCurve::create_circle_segment(
3,
&frame,
r,
-90.0,
90.0 + extra,
NurbCircleParam::Quadratic,
)
.unwrap();
let mut sor = SurfOfRevolution::new(
k,
Curve::from(generator),
c,
Vec3::new(1.0, 0.0, 0.0),
Vec3::new(0.0, 1.0, 0.0),
Extent2d::from_bounds(0.0, -90.0, 90.0, 90.0 + extra),
false,
)
.unwrap();
sor.make_nurb(k).unwrap();
as_b_spline(k, &Surface::from(sor))
.unwrap()
.unwrap()
.into_owned()
}
/// The half meridian is allowed `1e-12` degrees (upstream) plus the angles' rounding at the
/// points' size. A meridian `d` past 180 degrees also folds the generator's STEP interval
/// against the sphere's latitudes by `2 d` (a point past the pole reads latitude `90 - d`),
/// held to the same allowance. So a meridian 0.4 of the allowance past is recognised (both
/// tests pass; halve the allowance and the fold, 0.8 of it, is refused) and one twice the
/// allowance past is refused, at the origin and at 1e5. A doubled allowance is caught by
/// `sphere.rs`'s own unit test of `meridian_rounding` against its closed form.
#[test]
fn the_half_meridian_is_allowed_its_rounding_and_no_more() {
let k = Kernel::default();
let r = 2.5;
for off in [0.0, 1e5] {
let c = Point3::new(off, off, off + 10.0);
let exact = long_meridian(&k, c, r, 0.0);
let allowed = 1e-12 + (allowance_position(&exact, r) / r).to_degrees();
let over = long_meridian(&k, c, r, 2.0 * allowed);
assert!(
Sphere::is_nurb_surface_sphere(&k, &over, 1.0)
.unwrap()
.is_none(),
"at {off}: 180 + {:e} degrees recognised",
2.0 * allowed
);
let under = long_meridian(&k, c, r, 0.4 * allowed);
assert!(
Sphere::is_nurb_surface_sphere(&k, &under, 1.0)
.unwrap()
.is_some(),
"at {off}: 180 + {:e} degrees refused",
0.4 * allowed
);
}
}
/// A form or a label 1e-3 off the sphere is refused at every offset to 1e6: the label by
/// `lies_on` (`AnalyticSupport::build`), the form by the iso-circle tests. Neither reaches
/// the angle allowance; they show the allowance did not open what those tests close. At 1e7
/// the iso-circle tests' own tolerance, `100 SM_EFF_ZERO (1 + |corners|)` (about 2e-3),
/// admits a 1e-3 move: upstream's, unchanged here.
#[test]
fn a_form_or_label_1e3_off_is_refused_to_1e6() {
let mut k = Kernel::default();
for off in [0.0, 1e3, 1e4, 1e5, 1e6] {
let c = Point3::new(off, off, off + 10.0);
let (form, support) = form_at(&mut k, c, 2.5, 0.0);
let AnalyticSupport::Sphere { placement, radius } = support else {
panic!("a sphere");
};
let off_label = AnalyticSupport::Sphere {
placement,
radius: radius + 1e-3,
};
assert!(off_label.build(&k, form.copy()).is_err(), "label at {off}");
let net = form.gw_nurb().unwrap();
let mut cpts = net.cpts();
let (i, j) = (cpts.len() / 2, cpts[0].len() / 2);
let w = cpts[i][j].w;
cpts[i][j].x += 1e-3
* if w == solidcore::nurbs::math::NOW {
1.0
} else {
w
};
let (p, q) = net.degrees();
let moved = Net::new(cpts, p, q, net.u_knots().to_vec(), net.v_knots().to_vec()).unwrap();
let moved = BSplineSurface::from_gw_nurb(&moved);
assert!(
Sphere::is_nurb_surface_sphere(&k, &moved, 1.0)
.unwrap()
.is_none(),
"form at {off}"
);
}
}
@@ -1363,3 +1363,82 @@ fn d_prism_asked_past_the_rails_names_the_setback_that_fits() {
}
}
}
/// The least distance from the prism's axis of the top face's spring curve at the corner of
/// [`d_prism_eight_case`] when every setback is `s`, by its closed form: the top is a plane,
/// so the spring curve is the parabola (P, X, Q) itself, P on the chord's rail x = 7, `s`
/// along the chord from V, Q on the arc's rail rho = 8, `s` along the arc from V, and X where
/// the rails' tangents there cross. The least over 20000 even steps of the parabola's
/// parameter (each a few millionths long).
fn top_spring_least_rho(s: f64) -> f64 {
let p = (7.0, 6.0 - s);
let psi = 6.0f64.atan2(8.0) + s / 10.0;
let q = (8.0 * psi.cos(), 8.0 * psi.sin());
let along = (7.0 - q.0) / psi.sin();
let x = (7.0, q.1 - along * psi.cos());
(0..=20000)
.map(|i| {
let t = f64::from(i) / 20000.0;
let (a, b, c) = ((1.0 - t) * (1.0 - t), 2.0 * t * (1.0 - t), t * t);
(a * p.0 + b * x.0 + c * q.0).hypot(a * p.1 + b * x.1 + c * q.1)
})
.fold(f64::INFINITY, f64::min)
}
/// #2343 (d), "a thin flat strip beside a cylinder": the bored D prism (bore R 6.9) asked 8.5
/// at V. By the closed form above its top face's spring curve dips into the bore from asked
/// 7.8068; main built asked 7.8 (the spring curve 6e-4 clear of the bore) and refused 8 to
/// 9.5 late, "a round kept round a feature does not close on the body", naming nothing. The
/// corner checks what it takes out of the top: refused as `SetbackTooLong` at V, naming the
/// largest setback on 8.5's grid of thousandths whose spring curve stays on the face, 0.918 x
/// 8.5 = 7.803 (3.3e-4 clear of the bore; the next, 7.8115, 4.1e-4 into it). Asked that, the
/// corner builds, graded as the corners above.
#[test]
fn bored_d_prism_spring_curve_leaving_the_top_names_the_setback_that_keeps_it() {
let (bore, asked, step) = (6.9, 8.5, 8.5 / 1000.0);
let (mut k, mut b, edges, case) = d_prism_eight_case(Some(bore), 14);
let v = vertex_at(&b, case.v);
let r = round_asked(
&mut k,
&mut b,
&edges,
&[1.0, 1.5, 2.0],
case.v,
Some(asked),
);
assert_eq!(r.event, Some(BlendCornerEvent::Setback), "{}", r.reason);
assert!(!r.ok, "asked {asked}: built");
let named = match r.refusal {
Some(BlendRefusal::SetbackTooLong { vertex, largest }) => {
assert_eq!(vertex, v);
largest
}
other => panic!("{other:?}, {}", r.reason),
};
// on the grid, its spring curve clear of the bore, the next one's not
let grid = (named / step).round();
assert!((grid * step - named).abs() <= 1e-12, "{named} off the grid");
assert!(
top_spring_least_rho(named) > bore,
"{named}: {}",
top_spring_least_rho(named)
);
assert!(
top_spring_least_rho(named + step) < bore,
"{}: {}",
named + step,
top_spring_least_rho(named + step)
);
let (mut k, mut b, edges, case) = d_prism_eight_case(Some(bore), 14);
let r = round_asked(
&mut k,
&mut b,
&edges,
&[1.0, 1.5, 2.0],
case.v,
Some(named),
);
assert!(r.ok, "asked {named}: {}", r.reason);
graded(&mut k, &mut b, &case, 3);
kept_definitions(&k, &b, &case, 3);
}
@@ -0,0 +1,322 @@
//! Regression tests for tangency-labs/solidcore#2972: a body's volume sign is read from a
//! certified enclosure `[lo, hi]` of its volume, not from a quadrature estimate.
//!
//! - A thin quarter shell (radius 1e4, thickness 1e-3, length 10) is a valid solid whose
//! volume, 157.08, lies below the estimate's error at the sign test's accuracy (the
//! estimate at 1e-3 reads -180.05): its enclosure holds the closed form and is positive,
//! and `is_outward_solid` calls it a solid.
//! - A box with one edge rounded (radius 3) is enclosed to a millionth of its volume,
//! around the closed form `24000 - 40 * 9 * (1 - pi / 4)`, on the default kernel and with
//! the rounds' definitions on.
//! - A box, and the same box with its material outside (every face's material side
//! swapped), decide positive and negative with enclosures holding +120 and -120; a
//! sphere (seam and poles) holds `4/3 pi r^3`.
//! - The same box with one side's surface moved out by 1e-3 and its trims kept: the faces
//! no longer meet, and the closed surface the enclosure bounds (the faces and the strips
//! across their gaps) holds 120 + 30e-3, which the faces alone do not.
//! - Two unit cubes touching along an edge (their union: four face sides at that edge,
//! paired by sense) are enclosed around 2.
//! - A G2 round of the box edge (its net rational of degree 7 across, whose weights an
//! interval proves positive only over small boxes) decides, around the round's volume:
//! as a bare net, and as a procedural blend whose net is certified within a bound above 0
//! of its definition, read on its net (#3170 widens by that bound).
//! - The quarter shell R 10, t 1e-6 passes the check, but its faces' boundaries lie apart
//! by about its thickness: the sign is undecided, early (the strips between them hold 0
//! whatever the cells become), and `is_outward_solid` reads that as "not a solid". A box
//! with an offset-surface face cannot be read at all: an error, not a sign.
//! - `nurb_fillet.smb` (a sphere with a pocket) decides positive: its sphere face's loop
//! closes along the pole, from trim ends a unit in the last place off the net's last
//! knot, a segment whose 3D length is of the order of rounding.
#![allow(clippy::unwrap_used, clippy::panic)]
use solidcore::blend::fillet_executive::api::fillet_edges;
use solidcore::blend::fillet_section::section::GN_THUMBWEIGHT_EQUAL_AREA;
use solidcore::boolean::api_brep::boolean_union;
use solidcore::geom::axis2_placement::Axis2Placement;
use solidcore::kernel::{KernelOptions, UpstreamFixes};
use solidcore::mass::enclosure::{
enclose_volume, volume_sign, VolumeEnclosure, VolumeSign, CELL_BUDGET,
};
use solidcore::math::{Point3, Vec3, PI};
use solidcore::primitive::api_primitives::{create_box, create_cylindrical_box, create_sphere};
use solidcore::query::api_queries::edge_closest_point;
use solidcore::surface::offset_surface::OffsetSurface;
use solidcore::surface::surface::Surface;
use solidcore::topology::brep::is_outward_solid;
use solidcore::topology::brep::p1::{faces, regions};
use solidcore::topology::brep::validation::{check_brep, CheckLevel};
use solidcore::SolidError;
use solidcore::{Brep, Kernel};
/// The quarter shell between radii `r` and `r + t` about Z, length 10.
fn quarter_shell(k: &mut Kernel, r: f64, t: f64) -> Brep {
create_cylindrical_box(k, Point3::new(0.0, 0.0, 0.0), 10.0, r, r + t, 0.0, 90.0).unwrap()
}
fn shell_volume(r: f64, t: f64) -> f64 {
PI / 4.0 * ((r + t) * (r + t) - r * r) * 10.0
}
/// The box 40 x 30 x 20 with its edge through (20, 30, 20) rounded at radius 3.
fn rounded_box(k: &mut Kernel) -> Brep {
rounded_box_by(k, 1, 1, 1.0)
}
/// [`rounded_box`] with `fillet_edges`' section kind, order and thumbweight.
fn rounded_box_by(k: &mut Kernel, kind: u32, order: u32, weight: f64) -> Brep {
let mut b = create_box(k, Point3::new(0.0, 0.0, 0.0), 40.0, 30.0, 20.0).unwrap();
let q = Point3::new(20.0, 30.0, 20.0);
let e = b
.edge_list()
.iter()
.copied()
.find(|&e| edge_closest_point(k, &b, e, q).is_ok_and(|c| c.closest.distance < 1e-9))
.unwrap();
fillet_edges(k, &mut b, &[e], 3.0, kind, order, weight).unwrap();
b
}
/// Every fix on but the G2 one, as SolidCraft runs: the round is an exact round, a
/// definition with bound 0.
fn definitions_kernel() -> Kernel {
let mut k = Kernel::new(&KernelOptions::default());
let mut f = UpstreamFixes::default();
for &name in UpstreamFixes::NAMES {
if name != "geometric_g2_continuity" {
*f.flag_mut(name).unwrap() = true;
}
}
k.fixes = f;
k
}
fn holds(e: &VolumeEnclosure, v: f64) -> bool {
e.lo <= v && v <= e.hi
}
#[test]
fn a_thin_shell_below_the_estimates_error_decides_positive() {
for (r, t) in [(1e4, 1e-3), (10.0, 1e-4)] {
let mut k = Kernel::default();
let mut b = quarter_shell(&mut k, r, t);
let v = shell_volume(r, t);
match volume_sign(&k, &mut b).unwrap() {
VolumeSign::Positive(e) => assert!(holds(&e, v), "r {r} t {t}: {e:?} misses {v}"),
other => panic!("r {r} t {t}: {other:?}, volume {v}"),
}
// the solid test reads the same sign: on the estimate (-180.05 at r 1e4) it was false
let mut b = quarter_shell(&mut k, r, t);
assert!(is_outward_solid(&mut k, &mut b).unwrap(), "r {r} t {t}");
}
}
#[test]
fn a_rounded_box_is_enclosed_around_its_closed_form() {
let v = 24000.0 - 40.0 * 9.0 * (1.0 - PI / 4.0);
for (name, mut k) in [
("default", Kernel::default()),
("definitions", definitions_kernel()),
] {
let mut b = rounded_box(&mut k);
let e = enclose_volume(&k, &mut b, 1e-6 * v).unwrap();
assert!(holds(&e, v), "{name}: {e:?} misses {v}");
assert!(e.width() <= 1e-6 * v, "{name}: {e:?} wider than 1e-6 V");
let mut b = rounded_box(&mut k);
let s = volume_sign(&k, &mut b).unwrap();
assert!(
matches!(s, VolumeSign::Positive(e) if holds(&e, v)),
"{name}: {s:?}"
);
}
}
#[test]
fn a_box_and_its_inside_out_twin_decide_their_signs() {
let mut k = Kernel::default();
let mut b = create_box(&mut k, Point3::new(1.0, 2.0, 3.0), 4.0, 5.0, 6.0).unwrap();
let e = enclose_volume(&k, &mut b, 1e-9).unwrap();
assert!(holds(&e, 120.0) && e.width() <= 1e-9, "{e:?}");
// the material outside: every face's material side is the other one
for r in regions(&b, None).unwrap() {
let g = b.region_mut(r).unwrap();
g.is_void = 1 - g.is_void;
}
match volume_sign(&k, &mut b).unwrap() {
VolumeSign::Negative(e) => assert!(holds(&e, -120.0), "{e:?}"),
other => panic!("{other:?}"),
}
}
#[test]
fn a_sphere_is_enclosed_across_its_seam_and_poles() {
let mut k = Kernel::default();
let mut b = create_sphere(&mut k, Point3::new(0.0, 0.0, 0.0), 2.0).unwrap();
let v = 4.0 / 3.0 * PI * 8.0;
let e = enclose_volume(&k, &mut b, 1e-6).unwrap();
assert!(holds(&e, v) && e.width() <= 1e-6, "{e:?} misses {v}");
}
#[test]
fn a_gap_between_faces_is_closed_by_its_strips() {
let mut k = Kernel::default();
let mut b = create_box(&mut k, Point3::new(1.0, 2.0, 3.0), 4.0, 5.0, 6.0).unwrap();
// the side x = 5 (5 x 6) moved out by delta, its trims and its neighbours kept
let delta = 1e-3;
let side = faces(&b, None)
.unwrap()
.into_iter()
.find(|&f| {
let face = b.face(f).unwrap();
let s = face.surface.as_ref().unwrap();
s.evaluate_point(&k, face.uv_domain.mid())
.is_ok_and(|p| (p.x - 5.0).abs() < 1e-12)
})
.unwrap();
let mut shift = Axis2Placement::default();
shift.translate(Vec3::new(delta, 0.0, 0.0));
b.face_mut(side)
.unwrap()
.surface
.as_mut()
.unwrap()
.transform(&k, &shift, None)
.unwrap();
let v = 120.0 + 30.0 * delta;
let e = enclose_volume(&k, &mut b, 1.0).unwrap();
assert!(holds(&e, v) && e.counts.strips > 0, "{e:?} misses {v}");
assert!(matches!(
volume_sign(&k, &mut b).unwrap(),
VolumeSign::Positive(_)
));
}
/// `upstream/TestFiles/pt_TestFiles/Solids/nurb_fillet.smb`, read and healed; `None` where
/// the upstream test files are not present.
fn nurb_fillet(k: &mut Kernel) -> Option<Brep> {
use solidcore::geom::attribute::{AttributeStore, ObjectKey};
use solidcore::geom::object::ObjectRef;
use solidcore::topology::brep::p10::TopologyEnv;
use solidcore::topology::brep::p7::read_from_file;
use solidcore::topology::database_io::FileType;
use solidcore::topology::topology::NotifyRecords;
use solidcore::EntityRef;
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../upstream/TestFiles/pt_TestFiles/Solids/nurb_fillet.smb");
let Ok(bytes) = std::fs::read(&path) else {
assert!(
std::env::var_os("SOLIDCORE_REQUIRE_UPSTREAM").is_none(),
"SOLIDCORE_REQUIRE_UPSTREAM is set and the upstream test file is missing: {}",
path.display()
);
return None;
};
let mut b = Brep::new_with_infinite_region().unwrap();
let mut records = NotifyRecords::new();
let mut attributes = AttributeStore::default();
let key = |_: EntityRef| ObjectRef {
key: ObjectKey(0),
type_code: 0,
};
let mut env = TopologyEnv {
all_attributes: &[],
attributes: &mut attributes,
key_of: &key,
records: &mut records,
next_key: 1 << 50,
};
read_from_file(k, &mut b, bytes, FileType::Ascii, false, &mut env).unwrap();
b.use_arena_naming().unwrap();
Some(b)
}
#[test]
fn a_sphere_closed_along_its_pole_decides() {
let mut k = Kernel::default();
let Some(mut b) = nurb_fillet(&mut k) else {
return;
};
let s = volume_sign(&k, &mut b).unwrap();
assert!(matches!(s, VolumeSign::Positive(_)), "{s:?}");
}
#[test]
fn solids_touching_along_an_edge_pair_their_sides() {
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 c = create_box(&mut k, Point3::new(1.0, 1.0, 0.0), 1.0, 1.0, 1.0).unwrap();
let mut b = boolean_union(&mut k, a, c).unwrap();
let e = enclose_volume(&k, &mut b, 1e-9).unwrap();
assert!(holds(&e, 2.0) && e.width() <= 1e-9, "{e:?}");
}
#[test]
fn a_g2_round_decides_around_its_volume() {
// the G^n section's flag and the one its plane rails need: the round a bare net
let mut bare = Kernel::default();
bare.fixes.fillet_gn_section_generator = true;
bare.fixes.fillet_surface_plane_curve_on_plane_equation = true;
let v = 24000.0 - 40.0 * 9.0 * (1.0 - PI / 4.0);
for (name, mut k, certified) in [
("bare net", bare, false),
("definition", definitions_kernel(), true),
] {
let mut b = rounded_box_by(&mut k, 2, 2, GN_THUMBWEIGHT_EQUAL_AREA);
// with the definitions on, the round is a procedural blend whose net is certified
// within a bound above 0: it is read on its net
let bounds: Vec<f64> = faces(&b, None)
.unwrap()
.into_iter()
.filter_map(|f| {
let s = b.face(f).unwrap().surface.as_ref().unwrap();
s.procedural_blend().map(|d| d.bound())
})
.collect();
if certified {
assert!(bounds.iter().any(|&e| e > 0.0), "{name}: {bounds:?}");
} else {
assert!(bounds.is_empty(), "{name}: {bounds:?}");
}
let e = enclose_volume(&k, &mut b, 1e-3 * v).unwrap();
assert!(
holds(&e, v) && e.width() <= 1e-3 * v,
"{name}: {e:?} misses {v}"
);
let mut b = rounded_box_by(&mut k, 2, 2, GN_THUMBWEIGHT_EQUAL_AREA);
let s = volume_sign(&k, &mut b).unwrap();
assert!(matches!(s, VolumeSign::Positive(_)), "{name}: {s:?}");
}
}
#[test]
fn a_gap_as_large_as_the_volume_leaves_the_sign_undecided() {
let mut k = Kernel::default();
let shell = |k: &mut Kernel| quarter_shell(k, 10.0, 1e-6);
let mut b = shell(&mut k);
let c = check_brep(&mut k, &mut b, CheckLevel::Level2).unwrap();
assert!(c.lines().is_empty(), "{:?}", c.lines());
let v = shell_volume(10.0, 1e-6);
let mut b = shell(&mut k);
match volume_sign(&k, &mut b).unwrap() {
// undecided long before the budget: the strips settle
VolumeSign::Undecided(e) => {
assert!(holds(&e, v) && e.counts.cells < CELL_BUDGET / 4, "{e:?}");
}
other => panic!("{other:?}"),
}
// undecided is "not a solid" on purpose
let mut b = shell(&mut k);
assert!(!is_outward_solid(&mut k, &mut b).unwrap());
}
#[test]
fn a_face_without_a_nurbs_form_is_an_error_not_a_sign() {
let mut k = Kernel::default();
let mut b = create_box(&mut k, Point3::new(0.0, 0.0, 0.0), 4.0, 5.0, 6.0).unwrap();
// one side's plane as an offset surface at distance 0: the same box
let f = faces(&b, None).unwrap()[0];
let base = b.face(f).unwrap().surface.as_ref().unwrap().clone();
let off = OffsetSurface::new(&k, 0.0, base, true, 0).unwrap();
b.face_mut(f).unwrap().surface = Some(Surface::from(off));
let r = volume_sign(&k, &mut b);
assert!(matches!(r, Err(SolidError::Unsupported { .. })), "{r:?}");
}
@@ -0,0 +1,308 @@
//! tangency-labs/solidcore#2975: the stitch reports, for every gap it closes, a
//! certified bound on the gap and the tolerance growth its closure applied; an exact
//! match's closure applies no growth.
#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing
)]
use solidcore::geom::extent2d::Extent2d;
use solidcore::geom::tol::USE_DEFAULT;
use solidcore::math::{Point2, Vec3};
use solidcore::surface::plane::Plane;
use solidcore::surface::surface::Surface;
use solidcore::topology::brep::p2::ArenaNotifyState;
use solidcore::topology::brep::p3::new_brep;
use solidcore::topology::brep::p5::{stitch_and_orient_reported, StitchAndOrientOptions};
use solidcore::topology::brep::p8::{create_face_from_surface, FaceBuildEnv};
use solidcore::topology::brep_cache::{arena_identity, CacheObject};
use solidcore::topology::topology_traverser::Marks;
use solidcore::{Brep, Kernel};
/// Compare stored tolerance accounting independently of the reported bound.
fn check_accounting(entry: &solidcore::heal::stitch::p2::ClosedGap) {
let increase = (entry.tolerance_after - entry.tolerance_before).max(0.0);
assert!(
entry.growth.is_finite() && entry.growth >= increase,
"{entry:?}"
);
if entry.tolerance_after <= entry.tolerance_before {
assert_eq!(entry.growth, 0.0);
}
}
/// Adds one square planar face on the plane `z = 0` over the 1 x 1 domain whose low
/// corner is `x` on x and `y` on y.
fn add_square(k: &mut Kernel, b: &mut Brep, x: f64, y: f64) {
let surface = Surface::from(Plane::from_normal(
Vec3::new(0.0, 0.0, 0.0),
Vec3::new(0.0, 0.0, 1.0),
));
let mut state = ArenaNotifyState::new(b);
let mut env = state.env(None);
let mut marks = Marks::default();
let this_face = |face| arena_identity(CacheObject::Face(face));
let mut fenv = FaceBuildEnv {
env: &mut env,
marks: &mut marks,
this_face: &this_face,
};
let domain = Extent2d::from_points(Point2::new(x, y), Point2::new(x + 1.0, y + 1.0));
create_face_from_surface(k, b, Some(surface), &domain, &mut fenv).unwrap();
}
#[test]
fn a_known_gap_reports_its_bound_and_the_growth_its_closure_applied() {
let gap = 1e-5;
let mut k = Kernel::default();
let mut b = new_brep(&mut k, USE_DEFAULT, USE_DEFAULT).unwrap();
add_square(&mut k, &mut b, 0.0, 0.0);
add_square(&mut k, &mut b, 1.0 + gap, 0.0);
let gaps = stitch_and_orient_reported(&mut k, &mut b, &StitchAndOrientOptions::default())
.expect("the stitch runs");
for entry in &gaps {
check_accounting(entry);
}
// The two corners glue: each reports the gap it held (the known offset the two
// squares put between them) and the growth the glue applied — the survivor's
// tolerance rose by the same amount, the glue's rule keeping the closed distance
// inside the surviving tolerance (solidcore#2975).
let corners: Vec<_> = gaps
.iter()
.filter(|entry| {
matches!(
entry.kind,
solidcore::heal::stitch::p2::ClosedGapKind::Vertex { .. }
)
})
.collect();
assert_eq!(corners.len(), 2, "{gaps:?}");
for entry in corners {
assert!(entry.gap >= (1.0 + gap) - 1.0, "{entry:?}");
assert!(
(entry.gap - gap).abs() <= 1e-9 * gap.max(1.0),
"reported {entry:?} against the known {gap:.1e}"
);
assert!(
(entry.growth - gap).abs() <= 1e-9 * gap.max(1.0),
"growth {entry:?} against the known {gap:.1e}"
);
}
// Gluing the vertices does not move the two underlying edge curves. Their
// whole-curve gap is still the original horizontal separation.
let shared = gaps
.iter()
.find(|entry| {
matches!(
entry.kind,
solidcore::heal::stitch::p2::ClosedGapKind::Edge { .. }
)
})
.expect("the shared edge closure");
assert!(
shared.gap >= (1.0 + gap) - 1.0 && shared.gap < gap + 1e-12,
"{shared:?}"
);
}
#[test]
fn an_exact_match_reports_no_growth() {
let mut k = Kernel::default();
let mut b = new_brep(&mut k, USE_DEFAULT, USE_DEFAULT).unwrap();
add_square(&mut k, &mut b, 1.0, 0.0);
add_square(&mut k, &mut b, 0.0, 0.0);
let gaps = stitch_and_orient_reported(&mut k, &mut b, &StitchAndOrientOptions::default())
.expect("the stitch runs");
assert_eq!(
gaps.len(),
3,
"two endpoint glues and their shared edge: {gaps:?}"
);
for gap in gaps {
// Independently enclosing the two analytic line evaluations adds a
// conservative rounding floor, even though the geometric boundary agrees.
assert!(
gap.gap.is_finite() && (0.0..=1e-12).contains(&gap.gap),
"{gap:?}"
);
check_accounting(&gap);
assert!(
gap.growth == 0.0,
"the exact match's closure applied no growth: {gap:?}"
);
}
}
#[test]
fn an_interior_vertex_split_reports_the_original_curve_gap() {
use solidcore::heal::stitch::p2::ClosedGapKind;
let mut k = Kernel::default();
let mut b = new_brep(&mut k, USE_DEFAULT, USE_DEFAULT).unwrap();
let gap = 1e-5;
add_square(&mut k, &mut b, 0.0, 0.0);
add_square(&mut k, &mut b, 0.5, 1.0 + gap);
let records =
stitch_and_orient_reported(&mut k, &mut b, &StitchAndOrientOptions::default()).unwrap();
let splits: Vec<_> = records
.iter()
.filter(|r| matches!(r.kind, ClosedGapKind::Split { .. }))
.collect();
assert_eq!(
splits.len(),
2,
"both ends of the partial shared boundary: {records:?}"
);
for entry in splits {
assert!(
entry.gap >= (1.0 + gap) - 1.0 && entry.gap < gap + 1e-12,
"{entry:?}"
);
check_accounting(entry);
}
}
/// Two adjacent squares, with one shared edge represented by an exact cubic
/// Hermite line. Stitching can compare it; this certificate does not support it.
fn unsupported_shared_edge() -> (Kernel, Brep) {
use solidcore::curve::hermite_curve::HermiteCurve;
let mut k = Kernel::default();
let mut b = new_brep(&mut k, USE_DEFAULT, USE_DEFAULT).unwrap();
add_square(&mut k, &mut b, 0.0, 0.0);
add_square(&mut k, &mut b, 1.0, 0.0);
b.refresh_cached_topology();
let ends: Vec<_> = b
.edge_list()
.iter()
.copied()
.map(|e| {
let data = b.edge(e).unwrap();
let c = data.curve.as_ref().unwrap();
let a = c.evaluate_point(&k, data.interval.min()).unwrap();
let z = c.evaluate_point(&k, data.interval.max()).unwrap();
(e, a, z)
})
.collect();
let edge = ends
.iter()
.find(|(e, a, z)| {
ends.iter()
.any(|(other, p, q)| e != other && ((a == p && z == q) || (a == q && z == p)))
})
.expect("the exactly shared boundary")
.0;
let data = b.edge(edge).unwrap();
let old = data.curve.as_ref().unwrap();
let a = old.evaluate_point(&k, data.interval.min()).unwrap();
let z = old.evaluate_point(&k, data.interval.max()).unwrap();
let mut curve: solidcore::curve::curve::Curve = HermiteCurve::new(a, z - a, z, z - a, 3).into();
curve.set_owner(
old.owner(),
Some(solidcore::topology::brep::p2::brep_ref(&b)),
);
b.edge_mut(edge).unwrap().curve = Some(curve);
b.edge_mut(edge).unwrap().interval =
solidcore::geom::extent1d::Extent1d::from_min_max(0.0, 1.0);
(k, b)
}
/// A deterministic binary record checks topology, geometry and stored tolerances;
/// explicit id-list assertions below also cover the original arena handles.
fn binary(k: &Kernel, b: &Brep) -> Vec<u8> {
use solidcore::geom::attribute::{AttributeStore, ObjectKey};
use solidcore::topology::brep_data_write::FromBrepEnv;
use solidcore::topology::topology::NotifyRecords;
let attributes = AttributeStore::new();
let records = NotifyRecords::new();
let env = FromBrepEnv {
attributes: &attributes,
records: &records,
key_of: &solidcore::topology::loopuse::notify_ref,
curve_key: &|_| ObjectKey(0),
uv_curve_key: &|_| ObjectKey(0),
surface_key: &|_| ObjectKey(0),
};
solidcore::topology::api_files::write_brep_to_file(k, Some(b), &env, false).unwrap()
}
#[test]
fn certificate_failure_propagates_and_rolls_back_earlier_closures_with_flags_off_or_on() {
use solidcore::heal::stitch::p1::{DefaultStitchCallback, Stitch};
use solidcore::heal::stitch::p2::{ClosedGapKind, MinGapRequest};
use solidcore::SolidError;
// The unwrapped stitch demonstrates that failure really occurs AFTER the two
// vertex glues. This is not merely a no-edit input-validation test.
let (mut k, mut b) = unsupported_shared_edge();
let mut callback = DefaultStitchCallback::new();
let mut stitch = Stitch::new(&mut callback, b.tolerance() * 4.0, true, true);
stitch.fast_edge_compare = true;
stitch.report_gaps = Some(Vec::new());
let failure = stitch
.do_stitching(&mut k, &mut b, None, None, MinGapRequest::default())
.unwrap_err();
assert!(
matches!(
failure,
SolidError::Unsupported {
operation: "heal::stitch::bounds",
..
}
),
"{failure:?}"
);
let records = stitch.report_gaps.as_ref().unwrap();
assert_eq!(
records
.iter()
.filter(|r| matches!(r.kind, ClosedGapKind::Vertex { .. }))
.count(),
2,
"{records:?}"
);
b.refresh_cached_topology();
assert_eq!(
b.vertex_list().len(),
6,
"the direct stitch has already edited the operand"
);
for statuses in [false, true] {
let (mut k, mut b) = unsupported_shared_edge();
k.fixes.brep_edit_statuses_pass_on = statuses;
let before = binary(&k, &b);
let ids = (
b.face_list().to_vec(),
b.edge_list().to_vec(),
b.vertex_list().to_vec(),
);
let result = stitch_and_orient_reported(&mut k, &mut b, &StitchAndOrientOptions::default());
assert!(
matches!(
result,
Err(SolidError::Unsupported {
operation: "heal::stitch::bounds",
..
})
),
"flag={statuses}: {result:?}"
);
assert_eq!(
binary(&k, &b),
before,
"flag={statuses}: the full operand must be restored"
);
assert_eq!(
(
b.face_list().to_vec(),
b.edge_list().to_vec(),
b.vertex_list().to_vec()
),
ids
);
}
}
@@ -1,318 +0,0 @@
//! The local operation's reach beside bare B-spline nets: planes moved beside the walls of
//! `Solids/extrusion.smb`, two of which are extrusions with no analytic label (of a cubic
//! B-spline and of a rational curve of lines and an arc; the reader makes its other faces
//! planes and cylinders), build sound bodies of their closed-form volumes instead of being
//! refused as `ReachUncertified`; so does the base of `Solids/pyramid.smb` moved up,
//! beside sides stored as bare B-spline nets; and
//! the planes moved over the curved nets of `Solids/nurb_fillet.smb` (the cases whose
//! extension can leave a body that is no manifold solid) are built sound or refused with
//! the input left as it came in, never returned unsound.
//!
//! Each case is a goldens case of `topology.brep_local_operation`, made as its runner makes
//! it: a face's surface replaced by the plane through the point at the middle of its UV
//! domain, moved `offset` along the surface's normal there, the normal tilted by `tilt`.
//!
//! The solids are the upstream test files, read when the pinned upstream checkout is
//! present (the tests are skipped otherwise, as in CI's test job).
#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing
)] // test helpers
#[path = "support/offset_fixture.rs"]
mod offset_fixture;
use offset_fixture::Notifications;
use solidcore::geom::attribute::{AttributeStore, ObjectKey};
use solidcore::geom::object::ObjectRef;
use solidcore::mass::integrator::properties::{face_area, face_centroid};
use solidcore::math::{Point3, Vec3};
use solidcore::nurbs::knots::Side;
use solidcore::surface::plane::Plane;
use solidcore::surface::surface::Surface;
use solidcore::topology::brep::local_operation::local_operation;
use solidcore::topology::brep::p1 as bp1;
use solidcore::topology::brep::p10::TopologyEnv;
use solidcore::topology::brep::p7::read_from_file;
use solidcore::topology::brep::validation::volume;
use solidcore::topology::database_io::FileType;
use solidcore::topology::face::p2 as face_ops;
use solidcore::topology::topology::NotifyRecords;
use solidcore::topology::FaceId;
use solidcore::{Brep, EntityRef, Kernel};
/// The upstream test file `Solids/<name>`, or `None` without the upstream checkout.
fn solid(k: &mut Kernel, name: &str) -> Option<Brep> {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../upstream/TestFiles/pt_TestFiles/Solids")
.join(name);
let Ok(bytes) = std::fs::read(&path) else {
assert!(
std::env::var_os("SOLIDCORE_REQUIRE_UPSTREAM").is_none(),
"SOLIDCORE_REQUIRE_UPSTREAM is set and the upstream test file is missing: {}",
path.display()
);
eprintln!("unavailable upstream test file: {}", path.display());
return None;
};
let mut b = Brep::new_with_infinite_region().unwrap();
let mut records = NotifyRecords::new();
let mut attributes = AttributeStore::default();
let key = |_: EntityRef| ObjectRef {
key: ObjectKey(0),
type_code: 0,
};
let mut env = TopologyEnv {
all_attributes: &[],
attributes: &mut attributes,
key_of: &key,
records: &mut records,
next_key: 1 << 50,
};
read_from_file(k, &mut b, bytes, FileType::Ascii, false, &mut env).unwrap();
b.use_arena_naming().unwrap();
Some(b)
}
/// The plane the goldens runner gives face `index` (`GetFaces` order): through the point
/// at the middle of its UV domain moved `offset` along the normal there, the normal plus
/// `tilt`: the face, and the plane's origin and normal.
fn plane_for(
k: &Kernel,
b: &Brep,
index: usize,
offset: f64,
tilt: [f64; 3],
) -> (FaceId, (Point3, Vec3)) {
let face = bp1::faces(b, None).unwrap()[index];
let surface = face_ops::surface(b, face).unwrap().unwrap();
let uv = b.face(face).unwrap().uv_domain.evaluate(0.5, 0.5);
let p = surface.evaluate_point(k, uv).unwrap();
let n = surface
.evaluate_normal(k, uv, Side::Left, Side::Left)
.unwrap();
let origin = Point3::new(p.x + n.x * offset, p.y + n.y * offset, p.z + n.z * offset);
let normal = Vec3::new(n.x + tilt[0], n.y + tilt[1], n.z + tilt[2]);
(face, (origin, normal))
}
/// `b`'s faces `moves` (index, offset, tilt) given their planes by a local operation; the
/// planes' origins and normals.
fn moved(
k: &mut Kernel,
b: &mut Brep,
moves: &[(usize, f64, [f64; 3])],
) -> solidcore::Result<Vec<(Point3, Vec3)>> {
let (faces, planes): (Vec<FaceId>, Vec<(Point3, Vec3)>) = moves
.iter()
.map(|&(i, offset, tilt)| plane_for(k, b, i, offset, tilt))
.unzip();
let surfaces = planes
.iter()
.map(|&(o, n)| Surface::from(Plane::from_normal(o, n)))
.collect();
let mut notifications = Notifications::new(b);
local_operation(k, b, &faces, surfaces, &mut notifications.context())?;
Ok(planes)
}
/// The face of `b` whose surface's point at the middle of its domain is at height `z`, and
/// whose normal there is along z (a cap of the extrusion).
fn cap_at(k: &Kernel, b: &Brep, z: f64) -> FaceId {
let caps: Vec<FaceId> = bp1::faces(b, None)
.unwrap()
.into_iter()
.filter(|&f| {
let s = face_ops::surface(b, f).unwrap().unwrap();
let uv = b.face(f).unwrap().uv_domain.evaluate(0.5, 0.5);
let p = s.evaluate_point(k, uv).unwrap();
let n = s.evaluate_normal(k, uv, Side::Left, Side::Left).unwrap();
(p.z - z).abs() < 1e-9 && n.x.abs() < 1e-9 && n.y.abs() < 1e-9
})
.collect();
assert_eq!(caps.len(), 1, "{caps:?}");
caps[0]
}
/// The integrators' accuracy, and the agreement asked of two integrals at it.
const ACCURACY: f64 = 1e-6;
const AGREE: f64 = 1e-5;
/// Walls of the extrusion (z from 0 to 10) moved onto planes, the caps kept: the result
/// is the prism over its bottom cap, ten times the cap's area, and sound, and each new wall
/// stands on its plane (at least four of the body's vertices on it, two at each cap).
fn walls_moved_build_their_prism(moves: &[(usize, f64, [f64; 3])]) {
let mut k = Kernel::default();
let Some(mut b) = solid(&mut k, "extrusion.smb") else {
return;
};
let faces = bp1::faces(&b, None).unwrap().len();
let planes = moved(&mut k, &mut b, moves).unwrap();
for (o, n) in planes {
let n = n * (1.0 / n.length());
let on: Vec<f64> = b
.vertex_list()
.iter()
.map(|&v| solidcore::topology::vertex::point(&b, v).unwrap())
.filter(|p| (*p - o).dot(n).abs() <= b.tolerance())
.map(|p| p.z)
.collect();
let at = |z: f64| {
on.iter()
.filter(|&&h| (h - z).abs() <= b.tolerance())
.count()
};
assert!(at(0.0) >= 2 && at(10.0) >= 2, "{on:?}");
}
assert!(solidcore::topology::brep::p2::is_manifold_solid(&b).unwrap());
assert_eq!(bp1::faces(&b, None).unwrap().len(), faces);
let v = volume(&mut k, &mut b).unwrap();
let bottom = cap_at(&k, &b, 0.0);
let want = 10.0 * face_area(&k, &mut b, bottom, ACCURACY).unwrap();
assert!((v - want).abs() <= AGREE * want, "{v} vs {want}");
}
/// gen0000: the quarter cylinder wall (face 4) given its tangent plane moved out 1.14, and
/// the cubic wall (face 2) its tangent plane at its middle. Each new plane meets the caps,
/// the flat wall beside them and the rational wall (face 5, an extrusion), whose ruled
/// extension past its end the moved planes reach.
#[test]
fn two_walls_given_planes_out_build_the_prism_of_their_profile() {
walls_moved_build_their_prism(&[(4, 1.143_604_136_567_873_7, [0.0; 3]), (2, 0.0, [0.0; 3])]);
}
/// gen0021: the same two walls, both moved out (0.20 and 0.55).
#[test]
fn two_walls_moved_out_build_the_prism_of_their_profile() {
walls_moved_build_their_prism(&[
(4, 0.202_101_138_781_204_4, [0.0; 3]),
(2, 0.548_365_023_570_791_1, [0.0; 3]),
]);
}
/// gen0048: the cubic wall (face 2) given its tangent plane at its middle.
#[test]
fn a_cubic_wall_given_its_tangent_plane_builds_the_prism_of_its_profile() {
walls_moved_build_their_prism(&[(2, 0.0, [0.0; 3])]);
}
/// gen0041: the top cap (face 1) moved down 0.16 and tilted. The walls keep their surfaces
/// and meet it along their rulings (the two extrusions as nets); the body is the solid over the
/// bottom cap under the tilted plane, whose volume is the cap's area times the plane's
/// height over the cap's centroid (the height is linear), and sound.
#[test]
fn a_top_moved_down_and_tilted_holds_its_height_over_the_centroid() {
let mut k = Kernel::default();
let Some(mut b) = solid(&mut k, "extrusion.smb") else {
return;
};
let bottom = cap_at(&k, &b, 0.0);
let (area, centroid) = face_centroid(&k, &mut b, bottom, ACCURACY).unwrap();
let tilt = [
-0.095_716_453_310_687_6,
0.014_193_158_954_830_198,
-0.155_603_183_282_545_88,
];
let planes = moved(&mut k, &mut b, &[(1, -0.163_195_178_504_810_47, tilt)]).unwrap();
assert!(solidcore::topology::brep::p2::is_manifold_solid(&b).unwrap());
// the plane's height over the centroid: (x − o)·n = 0 solved for z
let (o, n) = planes[0];
let height = o.z - ((centroid.x - o.x) * n.x + (centroid.y - o.y) * n.y) / n.z;
let want = area * height;
let v = volume(&mut k, &mut b).unwrap();
assert!((v - want).abs() <= AGREE * want, "{v} vs {want}");
}
/// gen0076: the base of `Solids/pyramid.smb` (a plane) moved up 1.31 toward the apex. Two
/// of the sides are nets off their planes by the rounding of their stored points (single
/// precision: 1.9e-6); the base meets them on their nets. What is left is the pyramid above
/// the new base, similar to the old one: the volume times `((h − d) / h)³`, `h` the apex's
/// height over the old base, `d` the move.
#[test]
fn a_pyramid_base_moved_up_leaves_the_similar_pyramid_above_it() {
let mut k = Kernel::default();
let Some(mut b) = solid(&mut k, "pyramid.smb") else {
return;
};
let before = volume(&mut k, &mut b).unwrap();
let h = b
.vertex_list()
.iter()
.map(|&v| solidcore::topology::vertex::point(&b, v).unwrap().z)
.fold(f64::NEG_INFINITY, f64::max);
let planes = moved(&mut k, &mut b, &[(0, 1.308_567_886_431_627_2, [0.0; 3])]).unwrap();
assert!(solidcore::topology::brep::p2::is_manifold_solid(&b).unwrap());
let d = planes[0].0.z;
assert!(d > 1.3 && d < 1.31, "{d}");
let want = before * ((h - d) / h).powi(3);
let v = volume(&mut k, &mut b).unwrap();
assert!((v - want).abs() <= AGREE * want, "{v} vs {want}");
}
/// The cases of `Solids/nurb_fillet.smb` that can leave a non-manifold body once the plane
/// beside its curved nets has a reach: built only as a manifold solid of positive volume,
/// or refused with the input as it came in (its volume, faces and edges).
fn sound_or_refused_unchanged(moves: &[(usize, f64, [f64; 3])]) {
let mut k = Kernel::default();
let Some(mut b) = solid(&mut k, "nurb_fillet.smb") else {
return;
};
let before = (
volume(&mut k, &mut b).unwrap(),
bp1::faces(&b, None).unwrap().len(),
b.edge_list().len(),
);
match moved(&mut k, &mut b, moves) {
Ok(_) => {
assert!(solidcore::topology::brep::p2::is_manifold_solid(&b).unwrap());
assert!(volume(&mut k, &mut b).unwrap() > 0.0);
}
Err(e) => {
let after = (
volume(&mut k, &mut b).unwrap(),
bp1::faces(&b, None).unwrap().len(),
b.edge_list().len(),
);
assert_eq!(after, before, "{e}");
}
}
}
/// `nurb_fillet_face0_same` (and its `attributed_` twin): face 0 given its tangent plane.
#[test]
fn a_curved_net_given_its_tangent_plane_is_sound_or_refused_unchanged() {
sound_or_refused_unchanged(&[(0, 0.0, [0.0; 3])]);
}
/// `nurb_fillet_face4_same` (and its `attributed_` twin): face 4 given its tangent plane.
#[test]
fn another_curved_net_given_its_tangent_plane_is_sound_or_refused_unchanged() {
sound_or_refused_unchanged(&[(4, 0.0, [0.0; 3])]);
}
/// `fc0004`: faces 16 and 0 given tilted planes, 16's moved in 1.21.
#[test]
fn two_curved_nets_given_tilted_planes_are_sound_or_refused_unchanged() {
sound_or_refused_unchanged(&[
(
16,
-1.212_618_021_690_697_2,
[
-0.227_499_561_725_195_12,
-0.119_235_175_885_283_43,
0.212_040_256_859_765_76,
],
),
(
0,
0.0,
[
0.195_822_407_983_586_08,
-0.180_816_552_666_528_46,
-0.250_316_266_099_896_8,
],
),
]);
}
@@ -0,0 +1,595 @@
{
"area": "booleans",
"bodies": {
"b14": {
"keeping_splines": false,
"trusted": true
},
"b4": {
"keeping_splines": false,
"trusted": true
},
"b5": {
"keeping_splines": false,
"trusted": true
},
"b6": {
"keeping_splines": false,
"trusted": true
}
},
"calls": [
{
"inputs": [],
"op": "extrude",
"outputs": [
"b1"
],
"params": {
"cap": true,
"corner": "curvature",
"direction": [
0.0,
0.0,
1.0
],
"distance": 124.0,
"draft": 0.0,
"profile": [
{
"arc": {
"axis": [
0.0,
0.0,
1.0
],
"center": [
0.0,
0.0,
0.0
],
"end": 6.283185307179586,
"radius": 16.5,
"reference": [
1.0,
0.0,
0.0
],
"start": 0.0
}
}
]
}
},
{
"inputs": [],
"op": "screw_sweep",
"outputs": [
"b2"
],
"params": {
"direction": [
0.0,
0.0,
1.0
],
"origin": [
0.0,
0.0,
0.0
],
"pitch": -30.0,
"profile": [
{
"line": {
"end": [
15.500000000033173,
1.9916037001588853,
124.0
],
"start": [
16.082051957790647,
2.5654660460962315,
124.0
]
}
},
{
"arc": {
"axis": [
-0.0,
-0.0,
-1.0
],
"center": [
0.0,
0.0,
124.0
],
"end": 0.1277903655261984,
"radius": 15.627427341040864,
"reference": [
1.0,
0.0,
0.0
],
"start": -0.12779036552232945
}
},
{
"line": {
"end": [
16.08205195777746,
-2.5654660462046173,
124.0
],
"start": [
15.50000000002603,
-1.9916037002189337,
124.0
]
}
},
{
"line": {
"end": [
16.222606424802095,
-3.0128127705345857,
124.0
],
"start": [
16.08205195777746,
-2.5654660462046173,
124.0
]
}
},
{
"line": {
"end": [
17.205794692955955,
-3.1954074838129767,
124.0
],
"start": [
16.222606424802095,
-3.0128127705345857,
124.0
]
}
},
{
"arc": {
"axis": [
0.0,
0.0,
1.0
],
"center": [
0.0,
0.0,
124.0
],
"end": 0.1836248887672353,
"radius": 17.50000000010735,
"reference": [
1.0,
0.0,
0.0
],
"start": -0.18362488876723534
}
},
{
"line": {
"end": [
16.222606424880645,
3.012812770469558,
124.0
],
"start": [
17.205794692955955,
3.1954074838129767,
124.0
]
}
},
{
"line": {
"end": [
16.082051957790647,
2.5654660460962315,
124.0
],
"start": [
16.222606424880645,
3.012812770469558,
124.0
]
}
}
],
"tolerance": 0.003,
"turns": 1.0
}
},
{
"inputs": [],
"op": "screw_sweep",
"outputs": [
"b3"
],
"params": {
"direction": [
-0.0,
-0.0,
-1.0
],
"origin": [
0.0,
0.0,
0.0
],
"pitch": 30.0,
"profile": [
{
"line": {
"end": [
15.500000000033173,
1.9916037001588853,
124.0
],
"start": [
16.082051957790647,
2.5654660460962315,
124.0
]
}
},
{
"arc": {
"axis": [
-0.0,
-0.0,
-1.0
],
"center": [
0.0,
0.0,
124.0
],
"end": 0.1277903655261984,
"radius": 15.627427341040864,
"reference": [
1.0,
0.0,
0.0
],
"start": -0.12779036552232945
}
},
{
"line": {
"end": [
16.08205195777746,
-2.5654660462046173,
124.0
],
"start": [
15.50000000002603,
-1.9916037002189337,
124.0
]
}
},
{
"line": {
"end": [
16.222606424802095,
-3.0128127705345857,
124.0
],
"start": [
16.08205195777746,
-2.5654660462046173,
124.0
]
}
},
{
"line": {
"end": [
17.205794692955955,
-3.1954074838129767,
124.0
],
"start": [
16.222606424802095,
-3.0128127705345857,
124.0
]
}
},
{
"arc": {
"axis": [
0.0,
0.0,
1.0
],
"center": [
0.0,
0.0,
124.0
],
"end": 0.1836248887672353,
"radius": 17.50000000010735,
"reference": [
1.0,
0.0,
0.0
],
"start": -0.18362488876723534
}
},
{
"line": {
"end": [
16.222606424880645,
3.012812770469558,
124.0
],
"start": [
17.205794692955955,
3.1954074838129767,
124.0
]
}
},
{
"line": {
"end": [
16.082051957790647,
2.5654660460962315,
124.0
],
"start": [
16.222606424880645,
3.012812770469558,
124.0
]
}
}
],
"tolerance": 0.003,
"turns": 1.0
}
},
{
"inputs": [
"b6"
],
"op": "copy",
"outputs": [
"b7"
],
"params": {}
},
{
"inputs": [
"b7"
],
"op": "copy",
"outputs": [
"b8"
],
"params": {}
},
{
"inputs": [
"b4"
],
"op": "copy",
"outputs": [
"b9"
],
"params": {}
},
{
"inputs": [
"b9"
],
"op": "copy",
"outputs": [
"b10"
],
"params": {}
},
{
"inputs": [
"b8"
],
"op": "tagged",
"outputs": [
"b11"
],
"params": {
"operand": 0
}
},
{
"inputs": [
"b10"
],
"op": "tagged",
"outputs": [
"b12"
],
"params": {
"operand": 1
}
},
{
"inputs": [
"b11",
"b12"
],
"op": "boolean",
"outputs": [
"b13"
],
"params": {
"op": "subtract"
}
},
{
"inputs": [
"b14"
],
"op": "copy",
"outputs": [
"b15"
],
"params": {}
},
{
"inputs": [
"b15"
],
"op": "copy",
"outputs": [
"b16"
],
"params": {}
},
{
"inputs": [
"b5"
],
"op": "copy",
"outputs": [
"b17"
],
"params": {}
},
{
"inputs": [
"b17"
],
"op": "copy",
"outputs": [
"b18"
],
"params": {}
},
{
"inputs": [
"b16"
],
"op": "tagged",
"outputs": [
"b19"
],
"params": {
"operand": 0
}
},
{
"inputs": [
"b18"
],
"op": "tagged",
"outputs": [
"b20"
],
"params": {
"operand": 1
}
},
{
"inputs": [
"b19",
"b20"
],
"op": "boolean",
"outputs": [
"b21"
],
"params": {
"op": "subtract"
}
}
],
"case": "13462-rod-cut-by-two-counter-wound-channel-coils",
"expected": {
"body_count": 1,
"method": "Each horizontal section of a screw sweep of a profile D in a plane normal to the axis is one rotated copy of D. With D_in = D n disk(16.5), A_in its area, one sweep removes A_in*|p|; the two sweeps' sections at one height are D_in turned by +t and -t, whose overlap integrates over the turn to (|p|/2pi)*Int s(r)^2 r dr, s(r) the angle D_in covers on the circle of radius r. s(r) found exactly from the circle's crossings with the profile's lines and axis-centred arcs, integrated by adaptive Gauss-Kronrod: A_in = 4.214046963094, Int s^2 r dr = 1.279023422464. V = pi*16.5^2*124 - 2*A_in*30 + (30/2pi)*Int s^2 r dr. The one-sweep form agrees with a single cut to 3e-12. Tolerance 1e-6: the sweeps' walls are within 3e-3 of the screw surfaces (|p|*1e-4), which moves the two sweeps' overlap by about 2e-3 mm^3 (1.6e-8 here)",
"source": "analytic",
"sources": [
"tangency-labs/solidcraft#13462",
"tangency-labs/solidcore#3214"
],
"tolerance": {
"rel": 1e-06
},
"volume_mm3": 105810.29046175262,
"what": "a rod (r 16.5, 124 long) cut by two one-turn screw sweeps of one channel profile, one right-handed and one left-handed, pitch 30, both starting on the rod's top face: the channel's two radial walls meet the rod's surface, so each sweep's rail on it is a helix there and the two rails cross. The cut's new edge along a crossing rail took an interval its UV curve's trim had snapped 2.4e-12 past its 3D curve's end ('SmEdge test 2, Bad Domain')"
},
"expressible": true,
"operation_expressible": true,
"outcome": {
"ran": true,
"raw_counts": {
"edges": 60,
"faces": 30,
"vertices": 32
}
},
"records": "13462-rod-cut-by-two-counter-wound-channel-coils.bodies.json.gz",
"result": [
{
"body": "Rod",
"made": "b21"
}
],
"rig": {
"error": null,
"known_failing": null,
"measured": {
"area_mm2": 14815.578874890229,
"body_count": 1,
"edges": 55,
"faces": 25,
"vertices": 32,
"volume_mm3": 105810.28872357338
},
"refused_with": null,
"status": "pass"
},
"sources": [
"tangency-labs/solidcraft#13462",
"tangency-labs/solidcore#3214"
],
"traced_ops": [
"extrude",
"screw_sweep",
"screw_sweep",
"copy",
"copy",
"copy",
"copy",
"tagged",
"tagged",
"boolean",
"copy",
"copy",
"copy",
"copy",
"tagged",
"tagged",
"boolean"
],
"ungraded": [],
"what": "a rod (r 16.5, 124 long) cut by two one-turn screw sweeps of one channel profile, one right-handed and one left-handed, pitch 30, both starting on the rod's top face: the channel's two radial walls meet the rod's surface, so each sweep's rail on it is a helix there and the two rails cross. The cut's new edge along a crossing rail took an interval its UV curve's trim had snapped 2.4e-12 past its 3D curve's end ('SmEdge test 2, Bad Domain')",
"why_not": []
}

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