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
193 changed files with 24294 additions and 4037 deletions
+2 -2
View File
@@ -448,8 +448,8 @@ jobs:
run: python3 -I scripts/ci/test_affected.py
- name: Merge-of-main detection self-test
run: python3 -I scripts/ci/test_main_merge.py
- name: Claim, triage and PR-check bot tests
run: node --test scripts/claim/*.test.js scripts/triage/*.test.js scripts/pr-checks/*.test.js
- name: Claim, triage and kernel-paused bot tests
run: node --test scripts/claim/*.test.js scripts/triage/*.test.js scripts/kernel-paused/*.test.js scripts/pr-checks/*.test.js
# Fast feedback for a pull request that has not been approved for the full CI yet.
quick-compile:
+43
View File
@@ -0,0 +1,43 @@
# While a new geometry kernel is evaluated, new issues and discussions about kernel work, and new
# pull requests that change the kernel directories, are labelled `kernel-paused` for a maintainer to
# answer (CONTRIBUTING.md, "Kernel work is paused"). The logic is in scripts/kernel-paused/ (tested
# in ci.yml).
name: Kernel paused
on:
issues:
types: [opened]
# pull_request_target so PRs from forks can be labelled. It runs the base branch's
# scripts/kernel-paused and only lists the PR's changed paths; the PR's code is never checked out.
pull_request_target:
types: [opened]
discussion:
types: [created]
permissions:
contents: read # to check out scripts/kernel-paused
issues: write
pull-requests: write # to list a PR's changed paths and add its label
discussions: write
concurrency:
group: kernel-paused-${{ github.event.issue.number || github.event.pull_request.number || github.event.discussion.number }}
cancel-in-progress: false
jobs:
paused:
name: Kernel paused bot
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
sparse-checkout: scripts/kernel-paused
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const paused = require('./scripts/kernel-paused/kernel-paused.js');
const done = context.eventName === 'discussion'
? await paused.handleDiscussion({ github, context })
: await paused.handleOpened({ github, context });
core.info(done);
+8 -25
View File
@@ -17,28 +17,15 @@ Before choosing a task, check the [command inventory](docs/features.md) and
Questions, ideas or want to say what you're working on? Join the [Discord](https://discord.gg/FtU4NQNVDH).
## Kernel work goes in solidcore/
## Kernel work is paused
Kernel and geometry work is welcome: booleans, fillets, chamfers, shell, offsets, sweeps and lofts,
threads and holes, STEP/IGES import and export, tessellation. It goes in `solidcore/`, the vendored
solidcore kernel, following [`solidcore/docs/layers.md`](solidcore/docs/layers.md) and
`skills/solidcraft-layers/SKILL.md`. SolidCraft's own `crates/kernel`, `crates/smlib` and
`crates/geom` only shrink toward the seam (ops, tess, record); please don't grow them.
A kernel-only pull request targets the `kernel` branch. A change that touches the kernel and the
app goes to `main` as one pull request, with a code owner approving the `solidcore/` part.
Crashes and data loss are still taken first.
### Updating a PR that was moved from solidcore
solidcore's open PRs were re-opened here on `moved/solidcore-N` branches in tangency-labs, so
contributors without write access can't push to them. To update one:
1. `git fetch https://github.com/tangency-labs/solidcraft moved/solidcore-N:moved/solidcore-N`, then
`git switch moved/solidcore-N`.
2. Commit your changes on it and push it to your fork: `git push <your-fork> moved/solidcore-N`.
3. Open a PR against `kernel` that says "Replaces #M" (M is the moved PR's number). The moved one
gets closed.
We're moving to a new geometry kernel. Until that's done, please don't open pull requests that
change `crates/kernel/`, `crates/geom/` or the vendored kernel in `solidcore/`, or issues and discussions about kernel
work: booleans, fillets, chamfers, shell, offsets, sweeps and lofts, threads and holes, STEP/IGES
import and export, or tessellation. The pinned issue on the tracker explains it. New ones get the
`kernel-paused` label (`.github/workflows/kernel-paused.yml`) and a maintainer answers them. Crashes
and data loss are still taken as issues. Pull requests outside those directories carry on as normal,
including the dialogs and tools for these features.
## Get it running (about 10 minutes on a fast machine)
@@ -442,10 +429,6 @@ owning one area, with a human setting direction and reporting bugs. Their workin
[AGENTS.md](AGENTS.md). Human contributors are just as welcome. Use whatever tools you like, as
long as the change meets the rules above.
If you run an agent, keep one long-running session rather than restarting it, don't `--resume` one
whose prompt cache has gone cold (it re-reads the whole history at full price), and act on CodeRabbit
comments only when they're about crashes, wrong results, data loss or security.
Two quick checks run on every pull request (`.github/workflows/pr-checks.yml`, not required): it
fails when a lower-numbered open PR by someone else already closes the same issue (the label
`not-a-duplicate` skips it), or when the description lacks the `Changes a default: no` / `Changes a default: yes — <what>, old behaviour kept behind preference <name>` line (docs-only and bot PRs are exempt).
+4 -4
View File
@@ -150,16 +150,16 @@ pub(crate) fn curved_arrow(painter: &egui::Painter, at: Pos2, clockwise: bool, h
}
/// The pointer target around a compass end: larger than the drawn disc, so it is easy to hit.
pub(crate) const AXIS_RADIUS: f32 = 15.0;
pub(crate) fn axis_tip(painter: &egui::Painter, c: Pos2, name: &str, d: [f64; 3], at: Pos2, depth: f64, hovered: bool, t: Tokens) {
pub(crate) fn axis_tip(painter: &egui::Painter, c: Pos2, name: &str, d: &Vec3, at: Pos2, depth: f64, hovered: bool, t: Tokens) {
axis_tip_scaled(painter, c, name, d, at, depth, hovered, t, 1.0);
}
/// The same compass artwork at a uniform scale, including its lettering and strokes.
#[allow(clippy::too_many_arguments)]
pub(crate) fn axis_tip_scaled(painter: &egui::Painter, c: Pos2, name: &str, d: [f64; 3], at: Pos2, depth: f64, hovered: bool, t: Tokens, scale: f32) {
let col = if d[0] != 0.0 {
pub(crate) fn axis_tip_scaled(painter: &egui::Painter, c: Pos2, name: &str, d: &Vec3, at: Pos2, depth: f64, hovered: bool, t: Tokens, scale: f32) {
let col = if d.x != 0.0 {
t.axis_x
} else if d[1] != 0.0 {
} else if d.y != 0.0 {
t.axis_y
} else {
t.axis_z
+8 -144
View File
@@ -1163,9 +1163,6 @@ pub struct Model {
placement_local: bool,
/// A failed sketch shown while it is edited ([`Model::hold_failed_sketch`]).
held: Option<HeldSketch>,
/// The [`placement_digest`] each [`pose_only`] feature was last evaluated at, by feature id.
/// Their fingerprints leave it out, so a pose change doesn't reach the features after them.
poses: std::collections::HashMap<u64, u64>,
}
/// A failed sketch built for its editor (#12085): `shown` is the final state `base` with that
@@ -1482,10 +1479,7 @@ fn chain_fingerprint(
steps: &ImportDigests,
st: Option<&ModelState>,
) -> u64 {
// A pose-only feature's placement is checked on its own ([`Model::poses`]), so moving the
// parts it reads doesn't change the fingerprint of every feature after it (#12139).
let placed = if pose_only(f) { 0 } else { placement_digest(doc, f, st) };
let mut fp = fingerprint(doc, prev, f, vals, rolled_back, steps) ^ placed;
let mut fp = fingerprint(doc, prev, f, vals, rolled_back, steps) ^ placement_digest(doc, f, st);
// Sheet metal features also depend on the rules (and the parameters they use).
if f.kind.is_sheet() {
let mut h = std::collections::hash_map::DefaultHasher::new();
@@ -1799,70 +1793,6 @@ fn mirror_placements_digest(doc: &Document, f: &Feature, placements: &[Vec<u64>]
h.finish()
}
/// Whether only the bodies `f` makes follow where parts are placed: a mirror of placed bodies
/// into new bodies. A feature after it reads its copies only by naming them, so a pose change
/// need not evaluate features that don't (#12139).
fn pose_only(f: &Feature) -> bool {
matches!(&f.kind, FeatureKind::Mirror { features, bodies, placements, combine: false, .. }
if features.is_empty() && !bodies.is_empty() && !placements.is_empty())
}
/// The new bodies of pose-only `f`, evaluated again (to `state`) for its pose alone: the result
/// `old` was on the same definitions and made bodies of the same names, and nothing besides.
fn pose_moved(old: Option<&FeatureResult>, f: &Feature, fp: u64, state: &ModelState) -> Option<Vec<ModelBody>> {
let old = old.filter(|o| o.id == f.id && o.fingerprint == fp && !o.stale && !o.skipped && !o.unkept && o.error.is_none())?;
let made = |st: &ModelState| st.bodies.iter().filter(|b| b.feature == f.id).cloned().collect::<Vec<_>>();
let (before, after) = (made(&old.state), made(state));
let names = |bodies: &[ModelBody]| bodies.iter().map(|b| b.name.clone()).collect::<std::collections::BTreeSet<_>>();
let same = names(&before) == names(&after)
&& old.state.bodies.len() == state.bodies.len()
&& old.state.threads.len() == state.threads.len()
&& old.state.sheets.len() == state.sheets.len()
&& old.state.construct.len() == state.construct.len();
same.then_some(after)
}
/// `old` (a result after the moved pose-only features) with their new bodies in place of theirs.
fn with_moved(old: &Arc<ModelState>, moved: &[(u64, Vec<ModelBody>)]) -> Arc<ModelState> {
let mut st = (**old).clone();
for b in &mut st.bodies {
if let Some(new) = moved.iter().filter(|(id, _)| *id == b.feature).flat_map(|(_, bodies)| bodies).find(|n| n.name == b.name) {
*b = new.clone();
}
}
Arc::new(st)
}
/// Whether `f` (starting from `st`) reads none of the bodies of `moved`: it reads no bodies, or
/// only bodies it names or would cut or join, none of them those. Anything else (a picked face,
/// a projection, To Object, ...) is taken as reading them.
fn reads_none_of(f: &Feature, st: &ModelState, moved: &[(u64, Vec<ModelBody>)], components: &std::collections::HashMap<u64, u64>) -> bool {
if !f.face_names.is_empty() || !f.edge_names.is_empty() || !f.edge_refs.is_empty() {
return false;
}
let reads: Vec<String> = match &f.kind {
_ if placement_reuse::local_geometry(f, components) => Vec::new(),
FeatureKind::Sketch { plane, sketch } if sketch.links.is_empty() && sketch.wires.is_empty() && placement_reuse::local_plane(plane, 0) => {
Vec::new()
}
FeatureKind::Mirror { features, bodies, plane, .. } if features.is_empty() && placement_reuse::local_plane(plane, 0) => {
bodies.iter().map(|b| b.0.clone()).collect()
}
FeatureKind::Extrude { extent, .. } if extent.to.is_some() || extent.from.is_some() => return false,
FeatureKind::Revolve { to: Some(_), .. } | FeatureKind::Revolve { axis: AxisRef::Model { .. }, .. } => return false,
kind if drawn_tools_change_bodies(kind) => {
let mut reads = PlacementReads::default();
reads.drawn_targets(Some(st), f);
if reads.all {
return false;
}
reads.bodies.into_iter().collect()
}
_ => return false,
};
!moved.iter().flat_map(|(_, bodies)| bodies).any(|b| reads.contains(&b.name))
}
fn placement_digest(doc: &Document, f: &Feature, st: Option<&ModelState>) -> u64 {
// No shortcut for a design without placements: its first component (placed where the
// design is) must not change what its features read, or creating it evaluates them all.
@@ -2263,11 +2193,10 @@ impl Model {
let mut state = self.empty.clone();
doc.features.iter().zip(&self.results).enumerate().all(|(i, (f, r))| {
prev = chain_fingerprint(doc, &vals, f, prev, i >= marker, &steps, Some(&state));
let posed = !pose_only(f) || self.poses.get(&f.id) == Some(&placement_digest(doc, f, Some(&state)));
if !r.skipped {
state = r.state.clone();
}
r.id == f.id && r.fingerprint == prev && posed
r.id == f.id && r.fingerprint == prev
})
}
@@ -2335,10 +2264,6 @@ impl Model {
feature_component.entry(f.id).or_insert(f.component);
}
let watched = crate::progress::begin(doc.features.len());
let mut poses = std::collections::HashMap::new();
// Pose-only features evaluated again for a pose change alone: their new bodies, which
// replace the old ones in the results reused after them (#12139).
let mut moved: Vec<(u64, Vec<ModelBody>)> = Vec::new();
// The features that failed so far in this pass, by id: their names and errors (#9075).
let mut failed: std::collections::HashMap<u64, (String, String)> = std::collections::HashMap::new();
for (i, f) in doc.features.iter().enumerate() {
@@ -2367,12 +2292,7 @@ impl Model {
let cancelled = watched.step(i);
let fp = chain_fingerprint(doc, &vals, f, prev_fp, rolled_back, &self.import_digests, Some(&state));
prev_fp = fp;
let pose = pose_only(f).then(|| placement_digest(doc, f, Some(&state)));
if let Some(pose) = pose {
poses.insert(f.id, pose);
}
let posed = pose.is_none_or(|pose| self.poses.get(&f.id) == Some(&pose));
let current = |old: &&FeatureResult| old.id == f.id && old.fingerprint == fp && !old.stale && posed;
let current = |old: &&FeatureResult| old.id == f.id && old.fingerprint == fp && !old.stale;
// A state the saved model did not keep is evaluated again from the one before it; the
// results after it that the model did keep still stand (#3220).
let unkept = reuse && self.results.get(i).filter(current).is_some_and(|old| old.unkept);
@@ -2383,28 +2303,14 @@ impl Model {
// has a result only while those picks are what they were.
if reuse
&& !unkept
&& let Some(old) = self
.results
.get(i)
.filter(current)
.filter(|old| moved.is_empty() || old.skipped || reads_none_of(f, &state, &moved, &feature_component))
.filter(|old| old.skipped || legacy_current(doc, f, &state, &old.state))
&& let Some(old) = self.results.get(i).filter(current).filter(|old| old.skipped || legacy_current(doc, f, &state, &old.state))
{
if moved.is_empty() {
if !old.skipped {
state = old.state.clone();
}
out.push(old.clone());
} else {
if !old.skipped {
state = with_moved(&old.state, &moved);
}
out.push(FeatureResult { state: state.clone(), ..old.clone() });
if !old.skipped {
state = old.state.clone();
}
out.push(old.clone());
continue;
}
// Reuse goes on past a pose-only feature evaluated again for its pose alone.
let reusing = reuse && !unkept && pose.is_some();
reuse = unkept && !cancelled;
if f.suppressed || rolled_back || cancelled {
out.push(FeatureResult {
@@ -2480,14 +2386,6 @@ impl Model {
}
// A kept result after a failed one would not be this evaluation's.
reuse &= c.error.is_none();
if reusing
&& c.error.is_none()
&& let Some(bodies) = pose_moved(self.results.get(i), f, fp, &state)
{
moved.retain(|(id, _)| *id != f.id);
moved.push((f.id, bodies));
reuse = true;
}
out.push(FeatureResult {
id: f.id,
name: f.name.clone(),
@@ -2509,9 +2407,8 @@ impl Model {
// (the same chain of definitions from the start). Changed inputs never reuse it.
if let Some(r) = rolled
.as_ref()
.filter(|_| moved.is_empty())
.filter(current)
.or_else(|| primed.as_ref().filter(|_| moved.is_empty()).and_then(|p| p.get(&f.id)).filter(current))
.or_else(|| primed.as_ref().and_then(|p| p.get(&f.id)).filter(current))
.filter(|r| r.error.is_some() || legacy_current(doc, f, &state, &r.state))
{
restored += 1;
@@ -2652,14 +2549,6 @@ impl Model {
}
}
reuse &= error.is_none();
if reusing
&& error.is_none()
&& let Some(bodies) = pose_moved(self.results.get(i), f, fp, &state)
{
moved.retain(|(id, _)| *id != f.id);
moved.push((f.id, bodies));
reuse = true;
}
out.push(FeatureResult {
id: f.id,
name: f.name.clone(),
@@ -2677,7 +2566,6 @@ impl Model {
});
}
self.placement_local = self.placement_local && out.iter().all(|r| !r.skipped && !r.stale && !r.unkept && r.error.is_none());
self.poses = poses;
self.results = out;
self.last_recomputed = recomputed;
self.recomputed += recomputed;
@@ -7071,30 +6959,6 @@ fn replay(
}
continue;
}
// A thread is cut again on the copy of its face. A copy keeps the thread's hand: the
// mirror of a right-hand M8 hole is a right-hand M8 hole, not a left-hand one.
if matches!(src.kind, FeatureKind::Thread { .. }) {
for m in mats {
let mut copy = (*src).clone();
copy.id = f.id;
copy.face_names.clear();
if let FeatureKind::Thread { face, from, up_to, .. } = &mut copy.kind {
face.0 = crate::apply_point(m, face.0);
if let Some(q) = from {
q.0 = crate::apply_point(m, q.0);
}
match up_to {
Some(crate::ThreadLimit::Face(q)) => q.0 = crate::apply_point(m, q.0),
Some(crate::ThreadLimit::Plane(_)) => {
return Err(DocError::Invalid(format!("{} ends at a plane, so it cannot be patterned or mirrored yet", src.name)));
}
None => {}
}
}
thread::eval(doc, vals, &copy, st, warning)?;
}
continue;
}
let (op, targets) = feature_op(src);
// A primitive's or a hole's targets are its participants (#1108): the copies change
// those bodies only, like the feature itself (#2755).
+1 -10
View File
@@ -917,10 +917,7 @@ impl Model {
}
let mut model = Model { results, param_errors, import_digests, context: ctx, frame_bodies, validation, ..Model::default() };
let unkept = model.results.iter().filter(|r| r.unkept).count();
// A pose-only feature's fingerprint leaves its pose out, so its saved result may be from
// another pose than `doc`'s: evaluated again, with what reads none of it kept (#12139).
let posed = doc.features.iter().zip(&model.results).any(|(f, r)| !r.skipped && pose_only(f));
if unkept > 0 || posed {
if unkept > 0 {
// The features whose state the file did not keep, each evaluated again from the state
// before it: nothing else in the model is missing then.
model.evaluate(doc);
@@ -936,12 +933,6 @@ impl Model {
let mut before = Arc::new(ModelState::default());
if let Ok(mut cache) = model.cache.lock() {
for (f, r) in doc.features.iter().zip(&model.results).filter(|(_, r)| !r.skipped && r.error.is_none()) {
// A pose-only feature's fingerprint leaves its pose out, so its saved result may be
// from another pose than `doc`'s: it is no result for `doc`'s key (#12139).
if pose_only(f) {
before = r.state.clone();
continue;
}
let local = fingerprint(doc, 0, f, &vals, false, &model.import_digests) ^ placement_digest(doc, f, Some(&before));
cache.put(
cache_key(f, r.fingerprint, local, ctx, &before),
+1 -1
View File
@@ -55,7 +55,7 @@ impl Model {
}
}
pub(super) fn local_plane(plane: &PlaneRef, depth: usize) -> bool {
fn local_plane(plane: &PlaneRef, depth: usize) -> bool {
if depth > crate::MAX_PLACEMENT_DEPTH {
return false;
}
+2 -9
View File
@@ -18,7 +18,6 @@ use crate::schema::features::{
FeatureNotice, FeatureResult, FilletParams, FilletResult, NoticeAction, OffsetPlaneParams, PipeParams, PlaneAtAngleParams, ScaleParams,
SphereParams, TorusParams,
};
use crate::schema::split::{SplitBodyParams, SplitFaceParams};
use crate::schema::{TypedContract, output_schema_for};
use crate::{EngineError, Result, Session};
@@ -37,8 +36,6 @@ const OFFSET_PLANE_CONTRACT: TypedContract<OffsetPlaneParams> = TypedContract::n
const PLANE_AT_ANGLE_CONTRACT: TypedContract<PlaneAtAngleParams> = TypedContract::new();
const FILLET_CONTRACT: TypedContract<FilletParams> = TypedContract::new();
const CHAMFER_CONTRACT: TypedContract<ChamferParams> = TypedContract::new();
const SPLIT_FACE_CONTRACT: TypedContract<SplitFaceParams> = TypedContract::new();
const SPLIT_BODY_CONTRACT: TypedContract<SplitBodyParams> = TypedContract::new();
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("solid.extrude", "Extrude", crate::checked!("solid.extrude", extrude))
@@ -176,19 +173,15 @@ pub static COMMANDS: &[CommandSpec] = &[
.contract(&PLANE_AT_ANGLE_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params("base: XY|XZ|YZ|plane name, or a planar face {face: [x,y,z], body?, index?, path?}; axis: X|Y|Z|construction axis name (where it is now)|{origin, dir} | {edge|face: [x,y,z]} (a straight or circular edge, or a cylinder, cone or torus face: about its axis, followed as the model changes); angle: expr; direction_only?: bool (default false: turn about the axis as a line; true: project the axis direction onto base and turn about the line through pivot, or the base origin); pivot?: point ref (only with direction_only; vertex, sketch point, construction point); name?"),
CommandSpec::new("solid.split_face", "Split Face", crate::checked!("solid.split_face", split_face))
CommandSpec::new("solid.split_face", "Split Face", split_face)
.at("SOLID", "MODIFY")
.icon("split_face")
.contract(&SPLIT_FACE_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params(
"faces: [[x,y,z] points on the faces to split, or face names from model.faces]; body?; and the splitting tool: plane: XY|XZ|YZ|plane name|{origin, normal} — or tool: {body, point} (a face of a body, its surface extended) — or sketch: id or name (its curves, along the sketch's normal)",
),
CommandSpec::new("solid.split_body", "Split Body", crate::checked!("solid.split_body", split_body))
CommandSpec::new("solid.split_body", "Split Body", split_body)
.at("SOLID", "MODIFY")
.icon("split_body")
.contract(&SPLIT_BODY_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params("body: name; plane: XY|XZ|YZ|plane name|{origin, normal} — or tool: {body, point} (a face of a body, its surface extended) — or sketch: id, line: curve id (a straight sketch line, along the sketch's normal)"),
CommandSpec::new("solid.press_pull", "Press Pull", press_pull)
.at("SOLID", "MODIFY")
+1 -4
View File
@@ -63,11 +63,9 @@ pub static COMMANDS: &[CommandSpec] = &[
.noundo()
.params("id (from doc.recovery_list); dir?; discard?: bool (delete the entry once open) — opens it with its file path, unsaved"),
CommandSpec::new("doc.recovery_discard", "Discard Recovered Design", recovery_discard).icon("delete").noundo().params("id, or all: true; dir?"),
CommandSpec::new("file.export", "Export", crate::checked!("file.export", export))
CommandSpec::new("file.export", "Export", export)
.icon("export")
.noundo()
.contract(&EXPORT_CONTRACT)
.output(crate::schema::output_schema_for::<crate::schema::file::FileExportResult>)
.params("path; format?: stl|stla|obj|step|3mf|3mf-tagged|urdf|json (default from extension; 3mf-tagged: 3MF whose triangles carry their B-rep face, at full precision, for meshing and analysis tools; docs/formats/3mf-face-tags.md; obj: the colours of the bodies go in a material library (`.mtl`) written next to the mesh file; urdf: the whole assembly as a robot, with STL meshes written next to it; docs/urdf.md); bodies?: [names] (JSON and URDF export the complete design); urdf only: mesh_prefix? (before each mesh file name, e.g. package://robot/meshes/), effort? (default 1000), velocity? (default 10); quality?: standard|fine|finest (STL, OBJ and 3MF: how closely the mesh follows curved faces, a chord tolerance of 2e-4, 5e-5 or 1e-5 of each body's size; default standard); objects?: body|component (3MF only: one object per body, the default, or one object for each placed component with two or more bodies, with those bodies as its parts, so a slicer loads them as one object with parts)"),
// One command per format, so each File › Export item can have a key of its own; started
// from a key or command search, the app opens the save picker (the mesh dialog for STL and
@@ -130,7 +128,6 @@ fn new_doc(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"name": name}))
}
const EXPORT_CONTRACT: crate::schema::TypedContract<crate::schema::file::ExportParams> = crate::schema::TypedContract::new();
const SAVE_CONTRACT: TypedContract<SaveParams> = TypedContract::new();
const SAVE_AS_CONTRACT: TypedContract<SaveAsParams> = TypedContract::new();
const EXPORT_STEP_CONTRACT: crate::schema::TypedContract<crate::schema::file::ExportStepParams> = crate::schema::TypedContract::new();
-28
View File
@@ -1239,31 +1239,3 @@ fn circular_pattern_of_a_moved_copy_adds_one_body_per_instance() {
let far = s.world_state().unwrap().bodies.iter().map(|b| b.mesh().bounds().max.x).fold(f64::MIN, f64::max);
assert!(far > 20.0, "the repeats follow the redefined Move: {far}");
}
/// #13278: a mirror of a Thread repeats it on the mirrored hole, whether the hole was mirrored
/// earlier or is mirrored together with the thread.
#[test]
fn mirror_of_a_thread_threads_the_mirrored_hole() {
let plane = json!({"origin": [20, 0, 0], "normal": [1, 0, 0]});
for (together, modeled) in [(false, false), (true, false), (false, true), (true, true)] {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "body_name": "Plate"}));
run(&mut s, "solid.hole", json!({"position": [10, 15, 10], "diameter": 6.8, "depth": 10, "name": "Hole1"}));
if !together {
let r = run(&mut s, "solid.mirror", json!({"features": ["Hole1"], "plane": plane}));
assert!(r["error"].is_null(), "{r}");
}
run(&mut s, "solid.thread", json!({"face": [13.4, 15, 5], "designation": "M8", "modeled": modeled, "name": "Thread1"}));
let features = if together { json!(["Hole1", "Thread1"]) } else { json!(["Thread1"]) };
let r = run(&mut s, "solid.mirror", json!({"features": features, "plane": plane}));
assert!(r["error"].is_null(), "together {together}: {r}");
let state = s.world_state().unwrap();
if modeled {
assert_eq!(state.bodies.len(), 1, "{r}");
let threaded = volume(&mut s);
assert!(threaded < 40.0 * 30.0 * 10.0 - 2.0 * std::f64::consts::PI * 3.4 * 3.4 * 10.0 - 1.0, "both holes are threaded: {threaded}");
} else {
assert_eq!(state.threads.len(), 2, "together {together}: the source and its mirror");
}
}
}
+1 -5
View File
@@ -15,7 +15,6 @@ use crate::schema::sketch::{
MidpointParams, ParallelParams, PerpendicularParams, SymmetryParams, SymmetryResult, TangentMade, TangentParams, TangentResult,
};
use crate::schema::sketch_dimension::{DimensionResult, SketchDimensionParams};
use crate::schema::sketch_dimensioned::{DrawDimensionedParams, DrawDimensionedResult};
use crate::schema::sketch_session::{
Activated, Attachment, ChainParams, ChainResult, ConstructionParams, ConstructionResult, CreateParams, CreateResult, DeleteParams, DeleteResult,
DimensionTextParams, DimensionTextResult, DragEndParams, DragEndResult, DragParams, DragResult, Frame, MovePointParams, MovePointResult,
@@ -64,7 +63,6 @@ const DIMENSION_TEXT_CONTRACT: TypedContract<DimensionTextParams> = TypedContrac
const OPTIONS_CONTRACT: TypedContract<OptionsParams> = TypedContract::new();
const CHAIN_CONTRACT: TypedContract<ChainParams> = TypedContract::new();
const SOLVE_CONTRACT: TypedContract<SolveParams> = TypedContract::new();
const DRAW_DIMENSIONED_CONTRACT: TypedContract<DrawDimensionedParams> = TypedContract::new();
const EDIT_CONTRACT: TypedContract<EditParams> = TypedContract::new();
const EXIT_CONTRACT: TypedContract<ExitParams> = TypedContract::new();
const HORIZONTAL_VERTICAL_CONTRACT: TypedContract<HorizontalVerticalParams> = TypedContract::new();
@@ -125,10 +123,8 @@ pub static COMMANDS: &[CommandSpec] = &[
.contract(&LINE_CONTRACT)
.output(output_schema_for::<DrawResult>)
.params(concat!("points: [[x,y] | \"l1.end\" | {at, ref}…] (x and y each a number or an expression over the design's parameters, \"W / 2\": such a point is fixed where it comes to and follows the parameters; likewise wherever a point argument also takes a point id), closed?: bool, construction?: bool, ids?: [..], infer?: bool (add horizontal/vertical)", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.draw.dimensioned", "Draw Dimensioned Sketch Geometry", crate::checked!("sketch.draw.dimensioned", draw_dimensioned)).icon("dimension")
CommandSpec::new("sketch.draw.dimensioned", "Draw Dimensioned Sketch Geometry", draw_dimensioned).icon("dimension")
.enabled(in_sketch)
.contract(&DRAW_DIMENSIONED_CONTRACT)
.output(output_schema_for::<DrawDimensionedResult>)
.params(concat!("command: sketch creation command, params: object, dimensions: object of role to numeric or expression value, or `name = expression` (also `#name = expression`), which defines the user parameter `name` as sketch.dimension does (the reply's `defined` lists the names) (optional with as_drawn or locate), as_drawn?: bool (also dimension every other role of the shape at its size as drawn; a radius or diameter given takes the place of the other, as an edge polygon's side length does its radius, a circle otherwise takes its diameter, and a size the sketch already determines, such as the angle of a line inferred horizontal, is left out), locate?: bool (then place the shape from the sketch origin until nothing of it is left free: horizontal and vertical distances from the origin to its points, centres first, each kept only where it removes freedom, so a shape anchored to the origin or to placed geometry gets none; a point on an axis goes onto that axis instead of a 0 distance; the reply's located lists them); creates geometry and all driving dimensions atomically in one undo step", created_doc!())),
CommandSpec::new("sketch.rectangle.two_point", "2-Point Rectangle", crate::checked!("sketch.rectangle.two_point", rect_two_point))
.at("SKETCH", "CREATE")
+12 -7
View File
@@ -17,7 +17,6 @@ use solidcraft_doc::expr::{Kind, definition};
use crate::{
Result, Session,
params::{bad, bool_, expr, str_},
schema::sketch_dimensioned::DrawDimensionedResult,
};
/// The dimension roles of a sketch creator, in the order `as_drawn` adds them, or None when
@@ -136,13 +135,13 @@ pub(super) fn draw(s: &mut Session, p: &Value) -> Result<Value> {
}
}
let mut scratch = s.scratch();
let out = scratch.execute(create, &params)?;
let mut out = scratch.execute(create, &params)?;
let curves = out.get("curves").and_then(Value::as_array).cloned().unwrap_or_default();
let curve = |index: usize| -> Result<Value> {
curves.get(index).filter(|v| v.is_string()).cloned().ok_or_else(|| bad(cmd, "creator did not return the expected curve"))
};
let mut added = serde_json::Map::new();
let mut defined: Vec<String> = Vec::new();
let mut defined = Vec::new();
for (role, value) in wanted(roles, dimensions, as_drawn) {
let (entities, ty) = match (create, role) {
("sketch.line" | "sketch.line.midpoint", "length") => (vec![curve(0)?], "length"),
@@ -188,8 +187,8 @@ pub(super) fn draw(s: &mut Session, p: &Value) -> Result<Value> {
dimension["value"] = value.clone();
dimension["if_redundant"] = json!("driven");
let result = scratch.execute("sketch.dimension", &dimension)?;
if let Some(name) = result.get("defined").and_then(Value::as_str) {
defined.push(name.to_string());
if let Some(name) = result.get("defined").filter(|v| v.is_string()) {
defined.push(name.clone());
}
added.insert(role.to_string(), result);
}
@@ -213,8 +212,14 @@ pub(super) fn draw(s: &mut Session, p: &Value) -> Result<Value> {
// Session::execute records the wrapper's before snapshot. Nested history must not leak.
scratch.undo.clear();
s.accept_computed(scratch);
let reply = DrawDimensionedResult::new(out, added, defined, locate.then_some(located));
serde_json::to_value(reply).map_err(|e| bad(cmd, e.to_string()))
out["dimensions"] = Value::Object(added);
if !defined.is_empty() {
out["defined"] = Value::Array(defined);
}
if locate {
out["located"] = Value::Array(located);
}
Ok(out)
}
/// The sketch's degrees of freedom as a command reported them.
+1 -7
View File
@@ -331,13 +331,7 @@ pub fn run_checked(s: &mut Session, p: &Value, id: &str, contract: &dyn Contract
}
match result {
Ok(Value::Object(mut o)) if level == ParamCheck::Warn && !issues.is_empty() => {
// A command that runs another checked command keeps that one's warnings too.
let mut warnings = match o.remove("param_warnings") {
Some(Value::Array(inner)) => inner,
_ => Vec::new(),
};
warnings.extend(issues.iter().map(|i| json!(i.message)));
o.insert("param_warnings".into(), Value::Array(warnings));
o.insert("param_warnings".into(), json!(issues.iter().map(|i| i.message.as_str()).collect::<Vec<_>>()));
Ok(Value::Object(o))
}
other => other,
+6 -152
View File
@@ -1,14 +1,10 @@
//! Contracts and result types for the commands in `cmd/file.rs`: so far `file.export` and the
//! export command of each format (`file.export_step`, `file.export_stl`, `file.export_3mf`,
//! `file.export_obj`, `file.export_urdf`), which take what `file.export` takes for that format,
//! without `format`.
//! Contracts and result types for the commands in `cmd/file.rs`: so far the export command of
//! each format (`file.export_step`, `file.export_stl`, `file.export_3mf`, `file.export_obj`,
//! `file.export_urdf`), which take what `file.export` takes for that format, without `format`.
use schemars::{JsonSchema, Schema};
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::{Map, Value};
use solidcraft_io::Format;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use super::check::whole;
use super::types::StringList;
/// How closely an exported mesh follows curved faces.
@@ -97,137 +93,6 @@ pub struct ExportUrdfParams {
pub velocity: Option<f64>,
}
/// Export bodies, or the whole design, to a file. `format` names the format; without it the
/// path's extension does. STL, OBJ and 3MF take `quality` (and 3MF `objects`); URDF takes
/// `mesh_prefix`, `effort` and `velocity` and exports the whole assembly; design JSON the
/// complete document.
#[derive(Debug, JsonSchema)]
#[schemars(deny_unknown_fields, transform = export_required)]
pub struct ExportParams {
// `path` is optional in the type and required in the schema: left out, the handler refuses it
// with its own message.
/// The file to write.
#[serde(default)]
pub path: Option<String>,
/// The format: stl, stla, obj, step, 3mf, 3mf-tagged, urdf or json, or a file name whose
/// extension names it. Default: from the path's extension.
#[serde(default)]
pub format: Option<String>,
/// The bodies to export, by name. Default: every body. Design JSON and URDF export the
/// complete design, so they refuse a list.
#[serde(default)]
pub bodies: Option<StringList>,
/// How closely the mesh follows curved faces (STL, OBJ and 3MF). Default: standard. Not read
/// for URDF.
#[serde(default)]
pub quality: Option<MeshQualityParam>,
/// What each object in a 3MF file holds. Default: body; `component` is refused for the other
/// formats. Not read for URDF.
#[serde(default)]
pub objects: Option<ObjectsParam>,
/// URDF only: put before each mesh file name, such as `package://robot/meshes/`. Default: none.
#[serde(default)]
pub mesh_prefix: Option<String>,
/// URDF only: each joint's effort limit, a positive number. Default: 1000.
#[serde(default)]
pub effort: Option<f64>,
/// URDF only: each joint's velocity limit, a positive number. Default: 10.
#[serde(default)]
pub velocity: Option<f64>,
}
fn export_required(schema: &mut Schema) {
super::motion::require(schema, "path");
}
/// The handler's own message for a key, whole.
fn refuse<E: serde::de::Error>(message: impl std::fmt::Display) -> E {
E::custom(whole(message))
}
/// A key the handler reads as text (`str_`): any other value but `null` is refused, where the
/// handler would read it as left out.
fn text<E: serde::de::Error>(m: &Map<String, Value>, key: &str) -> Result<Option<String>, E> {
match m.get(key) {
None | Some(Value::Null) => Ok(None),
Some(Value::String(s)) => Ok(Some(s.clone())),
Some(_) => Err(E::custom(format!("`{key}` must be text"))),
}
}
/// `bodies` as the handler reads it (`params::string_list`), with its messages: `null` is left out.
fn names<E: serde::de::Error>(m: &Map<String, Value>) -> Result<Option<StringList>, E> {
match m.get("bodies") {
None | Some(Value::Null) => Ok(None),
Some(v) => {
let p = Value::Object(Map::from_iter([("bodies".to_string(), v.clone())]));
match crate::params::string_list("file.export", &p, "bodies") {
Ok(list) => Ok(Some(StringList(list))),
Err(crate::EngineError::BadParams { msg, .. }) => Err(refuse(msg)),
Err(e) => Err(refuse(e)),
}
}
}
}
impl<'de> Deserialize<'de> for ExportParams {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let m = Map::<String, Value>::deserialize(d)?;
let mut out =
ExportParams { path: None, format: None, bodies: None, quality: None, objects: None, mesh_prefix: None, effort: None, velocity: None };
// The handler reads `path` first and refuses anything but a name it can write to; the
// other keys are read only once it has one, and the format is known.
out.path = match m.get("path") {
None => return Ok(out),
Some(Value::String(s)) if !s.trim().is_empty() && s.len() < 4096 => Some(s.clone()),
Some(_) => return Err(refuse::<D::Error>("`path` is required")),
};
out.format = text::<D::Error>(&m, "format")?;
let Some(format) = out.format.as_deref().or(out.path.as_deref()).and_then(|n| Format::from_name(n).ok()) else {
// The handler refuses a format it does not know before it reads anything else.
return Ok(out);
};
out.bodies = names::<D::Error>(&m)?;
if format == Format::Urdf {
// URDF reads its own keys; `quality` and `objects` are not read.
out.mesh_prefix = text::<D::Error>(&m, "mesh_prefix")?;
let positive = |key: &str| -> Result<Option<f64>, D::Error> {
match m.get(key) {
None | Some(Value::Null) => Ok(None),
Some(v) => v
.as_f64()
.filter(|x| x.is_finite() && x.abs() < 1e9 && *x > 0.0)
.map(Some)
.ok_or_else(|| refuse(format!("`{key}` must be a positive number"))),
}
};
out.effort = positive("effort")?;
out.velocity = positive("velocity")?;
return Ok(out);
}
// `mesh_prefix`, `effort` and `velocity` are not read for the other formats.
out.quality = match m.get("quality") {
None | Some(Value::Null) => None,
Some(v) => Some(match v.as_str().and_then(solidcraft_io::MeshQuality::from_name) {
Some(solidcraft_io::MeshQuality::Standard) => MeshQualityParam::Standard,
Some(solidcraft_io::MeshQuality::Fine) => MeshQualityParam::Fine,
Some(solidcraft_io::MeshQuality::Finest) => MeshQualityParam::Finest,
None => return Err(refuse(format!("`quality` must be standard, fine or finest, not {v}"))),
}),
};
out.objects = match m.get("objects") {
None | Some(Value::Null) => None,
Some(v) => match v.as_str() {
Some("body") => Some(ObjectsParam::Body),
Some("component") if matches!(format, Format::ThreeMf | Format::ThreeMfTagged) => Some(ObjectsParam::Component),
Some("component") => return Err(refuse("`objects: component` is for 3MF: other formats have no parts")),
_ => return Err(refuse(format!("`objects` must be body or component, not {v}"))),
},
};
Ok(out)
}
}
/// What `file.export_step`, `file.export_stl`, `file.export_3mf` and `file.export_obj` return.
#[derive(Debug, Serialize, JsonSchema)]
pub struct ExportResult {
@@ -235,8 +100,7 @@ pub struct ExportResult {
pub path: String,
/// Its size in bytes.
pub bytes: usize,
/// The format written: Step, StlBinary, ThreeMf or Obj (and for `file.export` also StlAscii,
/// ThreeMfTagged or Design).
/// The format written: Step, StlBinary, ThreeMf or Obj.
pub format: String,
/// OBJ only: every file written, the material library first and the mesh file last.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
@@ -266,16 +130,6 @@ pub struct UrdfExportResult {
pub param_warnings: Vec<String>,
}
/// What `file.export` returns: what the export command of the format returns.
#[derive(Debug, Serialize, JsonSchema)]
#[serde(untagged)]
pub enum FileExportResult {
/// STL, OBJ, STEP, 3MF or design JSON.
File(ExportResult),
/// URDF.
Urdf(UrdfExportResult),
}
#[cfg(test)]
#[path = "file_tests.rs"]
mod tests;
+4 -150
View File
@@ -1,9 +1,9 @@
use serde_json::{Value, json};
use serde_json::json;
use super::{Export3mfParams, ExportMeshParams, ExportParams, ExportStepParams, ExportUrdfParams, FileExportResult};
use super::{Export3mfParams, ExportMeshParams, ExportStepParams, ExportUrdfParams};
use crate::Session;
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
use crate::schema::tests::assert_keys;
use crate::schema::{ParamCheck, input_schema_for};
#[test]
fn schemas_and_the_key_check_agree() {
@@ -11,7 +11,6 @@ fn schemas_and_the_key_check_agree() {
assert_keys(input_schema_for::<ExportMeshParams>(), &["bodies", "path", "quality"]);
assert_keys(input_schema_for::<Export3mfParams>(), &["bodies", "objects", "path", "quality"]);
assert_keys(input_schema_for::<ExportUrdfParams>(), &["effort", "mesh_prefix", "path", "velocity"]);
assert_keys(input_schema_for::<ExportParams>(), &["bodies", "effort", "format", "mesh_prefix", "objects", "path", "quality", "velocity"]);
}
#[test]
@@ -37,148 +36,3 @@ fn bad_calls_say_what_is_wrong_and_write_nothing() {
assert_eq!(std::fs::read_dir(&dir).map(|d| d.count()).unwrap_or(0), 0, "nothing written");
let _ = std::fs::remove_dir_all(&dir);
}
/// A fresh folder for one test's files.
fn folder(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("solidcraft-export-schema-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap_or_else(|e| panic!("{e}"));
dir
}
fn run(s: &mut Session, id: &str, p: Value) -> Value {
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
}
/// 10 mm cubes in components A (grounded) and B, so a URDF and a 3MF with parts can be written.
fn two_cubes() -> Session {
let mut s = Session::default();
for (name, x) in [("A", 0), ("B", 20)] {
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "component.create", json!({"name": name}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0]}));
run(&mut s, "solid.box", json!({"length": 2, "width": 2, "height": 2, "corner": [x, 0, 10], "operation": "new"}));
}
let a = s.doc.find_component("A").and_then(|c| s.doc.occurrence_of(c)).map(|o| o.id).unwrap_or(0);
run(&mut s, "occurrence.ground", json!({"occurrence": a, "grounded": true}));
s
}
#[test]
fn strict_keeps_todays_errors() {
let dir = folder("strict");
let at = |name: &str| dir.join(name).to_string_lossy().to_string();
assert_strict_keeps_todays_errors(&[
("file.export", json!({})),
("file.export", json!({"path": ""})),
("file.export", json!({"path": " "})),
("file.export", json!({"path": 5})),
("file.export", json!({"path": null})),
("file.export", json!({"path": "x".repeat(4096)})),
("file.export", json!({"path": at("a.xyz")})),
("file.export", json!({"path": at("a.stl"), "format": "xyz"})),
("file.export", json!({"path": at("a.xyz"), "quality": "rough"})),
("file.export", json!({"path": at("a.stl"), "quality": "rough"})),
("file.export", json!({"path": at("a.stl"), "quality": 3})),
("file.export", json!({"path": at("a.stl"), "objects": "component"})),
("file.export", json!({"path": at("a.3mf"), "objects": "parts"})),
("file.export", json!({"path": at("a.stl"), "bodies": [1]})),
("file.export", json!({"path": at("a.stl"), "bodies": 1})),
("file.export", json!({"path": at("a.json"), "bodies": ["Body1"]})),
("file.export", json!({"path": at("a.urdf"), "bodies": ["Body1"]})),
("file.export", json!({"path": at("a.urdf"), "effort": -1})),
("file.export", json!({"path": at("a.urdf"), "effort": 0})),
("file.export", json!({"path": at("a.urdf"), "velocity": "fast"})),
("file.export", json!({"path": at("a"), "format": "urdf", "velocity": 1e9})),
]);
assert_eq!(std::fs::read_dir(&dir).map(|d| d.count()).unwrap_or(0), 0, "nothing written");
let _ = std::fs::remove_dir_all(&dir);
}
/// Each format's keys are read only for that format, as the handler reads them: a key another
/// format takes is not checked, and `format` picks the format before the path's extension does.
#[test]
fn the_export_type_reads_each_key_only_for_its_format() {
let read = |p: Value| serde_json::from_value::<ExportParams>(p).map_err(|e| e.to_string());
let ok = |p: Value| read(p.clone()).unwrap_or_else(|e| panic!("{p}: {e}"));
let p = ok(json!({"path": "a.urdf", "quality": "rough", "objects": 3, "effort": 5}));
assert!(p.quality.is_none() && p.objects.is_none() && p.effort == Some(5.0), "{p:?}");
let p = ok(json!({"path": "a.stl", "effort": -1, "velocity": "fast", "mesh_prefix": 3, "quality": " Fine "}));
assert!(p.effort.is_none() && p.velocity.is_none() && p.mesh_prefix.is_none() && p.quality.is_some(), "{p:?}");
assert!(ok(json!({"path": "a.stl", "format": "3mf", "objects": "component"})).objects.is_some());
assert!(read(json!({"path": "a.3mf", "format": "stl", "objects": "component"})).is_err());
assert!(read(json!({"path": "a", "format": "urdf", "effort": 0})).is_err());
// An unknown format, or no path to write to, is the handler's to refuse: nothing else is read.
assert!(ok(json!({"path": "a.xyz", "quality": "rough", "effort": -1})).quality.is_none());
assert!(ok(json!({"quality": "rough"})).path.is_none());
assert!(ok(json!({"path": "a.stl", "bodies": null, "format": null, "quality": null, "objects": null})).bodies.is_none());
}
/// (`conforms` does not read `oneOf`, so `quality` and `objects` values are checked by the type,
/// in `the_export_type_reads_each_key_only_for_its_format`.)
#[test]
fn the_export_schema_takes_what_the_handler_reads() {
let schema = input_schema_for::<ExportParams>();
let schema = schema.as_value();
for (p, takes) in [
(json!({"path": "a.stl"}), true),
(json!({"path": "a.3mf", "format": "3mf-tagged", "bodies": ["A", "B"], "quality": "fine", "objects": "component"}), true),
(json!({"path": "robot.urdf", "mesh_prefix": "package://robot/meshes/", "effort": 50, "velocity": 2.5}), true),
(json!({"path": "a.step", "bodies": "Body1"}), true),
(json!({}), false),
(json!({"path": "a.stl", "bodies": [1]}), false),
(json!({"path": "a.stl", "colour": true}), false),
] {
assert_eq!(conforms(schema, schema, &p), takes, "{p}");
}
}
/// Real exports of every format in warn: no `param_warnings`, and each reply matches the output
/// schema. The same calls go through in strict.
#[test]
fn export_replies_match_the_output_schema() {
let dir = folder("replies");
let at = |name: &str| dir.join(name).to_string_lossy().to_string();
let schema = output_schema_for::<FileExportResult>();
let schema = schema.as_value();
let calls = [
json!({"path": at("a.stl")}),
json!({"path": at("b.stl"), "format": "stla", "quality": "finest"}),
json!({"path": at("c.obj"), "quality": "fine"}),
json!({"path": at("d.step")}),
json!({"path": at("e.3mf"), "objects": "component"}),
json!({"path": at("f.bin"), "format": "3mf-tagged"}),
json!({"path": at("g.json")}),
json!({"path": at("h.urdf"), "mesh_prefix": "package://robot/meshes/", "effort": 50, "velocity": 2}),
];
for level in [ParamCheck::Warn, ParamCheck::Strict] {
let mut s = two_cubes();
s.param_check = level;
for p in &calls {
let reply = run(&mut s, "file.export", p.clone());
assert!(reply.get("param_warnings").is_none(), "{p}: {reply}");
assert!(conforms(schema, schema, &reply), "{p}: {reply} doesn't match {schema}");
}
}
let _ = std::fs::remove_dir_all(&dir);
}
/// A misspelt key is reported in warn and refused in strict, before anything is written; a key of
/// the wrong type the handler reads as left out is reported too.
#[test]
fn a_misspelt_or_wrong_typed_export_key_is_reported() {
let dir = folder("misspelt");
let at = |name: &str| dir.join(name).to_string_lossy().to_string();
let mut s = two_cubes();
s.param_check = ParamCheck::Warn;
let reply = run(&mut s, "file.export", json!({"path": at("a.stl"), "qualty": "fine"}));
let warnings = reply["param_warnings"].to_string();
assert!(warnings.contains("qualty"), "{reply}");
let reply = run(&mut s, "file.export", json!({"path": at("b.stl"), "format": 3}));
assert!(reply["param_warnings"].to_string().contains("format"), "{reply}");
s.param_check = ParamCheck::Strict;
let e = s.execute("file.export", &json!({"path": at("c.stl"), "qualty": "fine"})).unwrap_err().to_string();
assert!(e.contains("qualty"), "{e}");
assert!(!dir.join("c.stl").exists(), "nothing written");
let _ = std::fs::remove_dir_all(&dir);
}
-2
View File
@@ -115,7 +115,6 @@ pub mod sketch_clipboard;
pub mod sketch_constraints;
pub mod sketch_create;
pub mod sketch_dimension;
pub mod sketch_dimensioned;
pub mod sketch_ends;
pub mod sketch_equation;
pub mod sketch_import;
@@ -124,7 +123,6 @@ pub mod sketch_modify;
pub mod sketch_project;
pub mod sketch_session;
pub mod sketch_shapes;
pub mod split;
pub mod surface;
pub mod surface_analysis;
pub mod surface_boundary;
@@ -1,278 +0,0 @@
//! Contract and result type for `sketch.draw.dimensioned` in `cmd/sketch_dimensioned.rs` (#13313).
//!
//! The handler reads its parameters from the raw JSON, so each key here reads as the handler
//! reads it and fails with the handler's message. `command` and `params` are optional in the
//! type and required in the schema, so that the handler's message, not the check's "is required",
//! is what a caller sees when they are left out. Which roles `dimensions` may name depends on the
//! creator, and whether a value can be evaluated or is in range depends on the design: both stay
//! the handler's to check. A call with several problems may get a different one first in strict
//! than with the check off (the check goes key by key, the handler in its own order).
use std::borrow::Cow;
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
use serde::de::Error as _;
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::{Map, Value, json};
use super::check::whole;
use super::motion::require;
use super::types::{AngleExpr, LengthExpr};
use crate::params;
/// The sketch creators `sketch.draw.dimensioned` can dimension: those `dimension_roles` lists.
pub const CREATORS: &[&str] = &[
"sketch.line",
"sketch.line.midpoint",
"sketch.rectangle.two_point",
"sketch.rectangle.center",
"sketch.rectangle.three_point",
"sketch.rectangle.rounded",
"sketch.circle.center",
"sketch.circle.two_point",
"sketch.circle.three_point",
"sketch.arc.center_point",
"sketch.ellipse",
"sketch.slot.center_to_center",
"sketch.slot.overall",
"sketch.slot.center_point",
"sketch.slot.arc_center",
"sketch.slot.arc_three_point",
"sketch.polygon.inscribed",
"sketch.polygon.circumscribed",
"sketch.polygon.edge",
];
/// A sketch creator as `command` names it: one `dimension_roles` knows.
#[derive(Clone, Debug, PartialEq)]
pub struct Creator(pub String);
impl<'de> Deserialize<'de> for Creator {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
match Value::deserialize(d)? {
Value::String(s) if crate::dimension_roles(&s).is_some() => Ok(Creator(s)),
Value::String(_) => Err(D::Error::custom(whole("unsupported sketch creator"))),
_ => Err(D::Error::custom(whole("`command` must name a supported sketch creator"))),
}
}
}
impl JsonSchema for Creator {
fn schema_name() -> Cow<'static, str> {
"Creator".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "A sketch creation command whose shape can be dimensioned.",
"type": "string",
"enum": CREATORS
})
}
}
/// The creator's own parameters: an object (`null` is refused).
#[derive(Clone, Debug, PartialEq)]
pub struct CreatorParams(pub Map<String, Value>);
impl<'de> Deserialize<'de> for CreatorParams {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
match Value::deserialize(d)? {
Value::Object(o) => Ok(CreatorParams(o)),
_ => Err(D::Error::custom(whole("`params` must be an object"))),
}
}
}
impl JsonSchema for CreatorParams {
fn schema_name() -> Cow<'static, str> {
"CreatorParams".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "The parameters of the creator `command` names, as that command takes them. The creator checks them.",
"type": "object"
})
}
}
/// The sizes to place, by role. Each value is a number or an expression of at most 4096
/// characters, as `params::expr` reads it (`null` is refused).
#[derive(Clone, Debug, PartialEq)]
pub struct DimensionValues(pub Map<String, Value>);
impl<'de> Deserialize<'de> for DimensionValues {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let Value::Object(o) = Value::deserialize(d)? else {
return Err(D::Error::custom(whole("`dimensions` must be an object")));
};
for value in o.values() {
if params::expr(&json!({"value": value}), "value").is_none_or(|e| e.len() > 4096) {
return Err(D::Error::custom(whole("dimension values must be bounded expressions or finite numbers")));
}
}
Ok(DimensionValues(o))
}
}
impl JsonSchema for DimensionValues {
fn schema_name() -> Cow<'static, str> {
"DimensionValues".into()
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
let length = generator.subschema_for::<LengthExpr>();
let angle = generator.subschema_for::<AngleExpr>();
json_schema!({
"description": "The sizes to place, each by its role: a number (in the design's unit) or an expression, or `name = expression`, which also defines the user parameter `name`. The roles a creator takes are its own: a line or midpoint line takes `length` and `angle`; a rectangle `width` and `height`; a circle `diameter` or `radius` (not both); an arc by centre `radius` and `sweep`; an ellipse `major_radius` and `minor_radius`; a centre-to-centre, overall or centre-point slot `length` and `width`; a centre arc slot `radius`, `sweep` and `width`; a three-point arc slot `width`; an inscribed or circumscribed polygon `radius`; an edge polygon `radius` or `length` (not both). A role the creator does not take is refused.",
"type": "object",
"properties": {
"length": length,
"width": length,
"height": length,
"diameter": length,
"radius": length,
"major_radius": length,
"minor_radius": length,
"angle": angle,
"sweep": angle
},
"additionalProperties": length
})
}
}
/// Draw a shape and dimension it in one undo step. `command` names the sketch creator and
/// `params` its parameters; `dimensions` gives the sizes to place, by role, as driving
/// dimensions. With `as_drawn` every other size of the shape is placed at its size as drawn
/// (a size the sketch already determines is left out); with `locate` the shape is then placed
/// from the sketch origin with horizontal and vertical distances, only where each takes freedom
/// away. Either one makes `dimensions` optional. A size the sketch already has is placed as a
/// driven (reference) dimension rather than refused.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields, transform = draw_required)]
pub struct DrawDimensionedParams {
/// The sketch creation command to run: one of the listed creators.
#[serde(default, deserialize_with = "command")]
#[schemars(with = "Creator")]
pub command: Option<Creator>,
/// The creator's own parameters, as it takes them (`sketch` among them, to draw in a sketch
/// other than the active one). A `sweep` dimension replaces the creator's `sweep` and removes
/// its `end`.
#[serde(default, deserialize_with = "creator_params")]
#[schemars(with = "CreatorParams")]
pub params: Option<CreatorParams>,
/// The sizes to place, by role. Required unless `as_drawn` or `locate` is true.
#[serde(default, deserialize_with = "dimension_values")]
#[schemars(with = "DimensionValues")]
pub dimensions: Option<DimensionValues>,
/// Also dimension every other size of the shape at its size as drawn. A radius or diameter
/// given takes the place of the other (as an edge polygon's side length does its radius); a
/// circle otherwise takes its diameter. Default: false.
#[serde(default)]
pub as_drawn: Option<bool>,
/// Then place the shape from the sketch origin until nothing of it is left free: horizontal
/// and vertical distances from the origin to its points, centres first, each kept only where
/// it removes freedom, so a shape anchored to the origin or to placed geometry gets none. A
/// point on an axis goes onto that axis instead of getting a distance of 0. Default: false.
#[serde(default)]
pub locate: Option<bool>,
}
fn command<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Creator>, D::Error> {
Creator::deserialize(d).map(Some)
}
fn creator_params<'de, D: Deserializer<'de>>(d: D) -> Result<Option<CreatorParams>, D::Error> {
CreatorParams::deserialize(d).map(Some)
}
fn dimension_values<'de, D: Deserializer<'de>>(d: D) -> Result<Option<DimensionValues>, D::Error> {
DimensionValues::deserialize(d).map(Some)
}
/// `command` and `params` are always needed; `dimensions` unless `as_drawn` or `locate` is true.
fn draw_required(schema: &mut Schema) {
require(schema, "command");
require(schema, "params");
schema.insert(
"anyOf".into(),
json!([
{"required": ["dimensions"]},
{"required": ["as_drawn"], "properties": {"as_drawn": {"enum": [true]}}},
{"required": ["locate"], "properties": {"locate": {"enum": [true]}}}
]),
);
}
/// What `sketch.draw.dimensioned` returns: what the creator returned, then the dimensions placed.
#[derive(Debug, Serialize, JsonSchema)]
#[schemars(transform = creator_reply)]
pub struct DrawDimensionedResult {
/// What the creator returned, as that command returns it (`curves`, `constraints`, `sketch`,
/// `created`, and for some creators `sides`, `corners`, `centre_arc` or `support_circle`).
#[serde(flatten)]
pub drawn: Map<String, Value>,
/// The `sketch.dimension` reply of each dimension placed, by role.
pub dimensions: Map<String, Value>,
/// The names of the user parameters a typed `name = expression` defined. Only when there
/// are any.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub defined: Vec<String>,
/// With `locate`: the `sketch.dimension` or `sketch.constraint.coincident` replies of what
/// placed the shape, in order (empty when nothing was left to place).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub located: Option<Vec<Value>>,
/// In the warn parameter-check mode: what the call's parameters got wrong. When the call
/// itself is fine, the creator's own warnings about `params` are kept here.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
impl DrawDimensionedResult {
/// The creator's reply `drawn` with the dimensions placed. A key the creator's reply and this
/// result both have takes this result's value, as the handler always set them over it.
pub fn new(drawn: Value, dimensions: Map<String, Value>, defined: Vec<String>, located: Option<Vec<Value>>) -> Self {
let mut drawn = match drawn {
Value::Object(o) => o,
_ => Map::new(),
};
let mut param_warnings = Vec::new();
if let Some(Value::Array(warnings)) = drawn.get("param_warnings")
&& warnings.iter().all(Value::is_string)
{
param_warnings = warnings.iter().filter_map(|w| w.as_str().map(str::to_string)).collect();
drawn.remove("param_warnings");
}
drawn.remove("dimensions");
if !defined.is_empty() {
drawn.remove("defined");
}
if located.is_some() {
drawn.remove("located");
}
DrawDimensionedResult { drawn, dimensions, defined, located, param_warnings }
}
}
/// The keys most creators' replies have, which depend on the creator (none is always there).
fn creator_reply(schema: &mut Schema) {
let strings = json!({"type": "array", "items": {"type": "string"}});
let properties = json!({
"curves": strings,
"constraints": strings,
"sketch": {"type": "object", "description": "The sketch's solve: `{solved, dof}`."},
"created": {"type": "object", "description": "What the creator added: `{points, curves, constraints, dimensions}`."},
"sides": {"type": "object", "description": "A rectangle's sides {bottom, right, top, left}, line ids."},
"corners": {"type": "object", "description": "A rectangle's corners {bottom_left, bottom_right, top_right, top_left}, point ids."},
"centre_arc": {"type": "string", "description": "A centre arc slot's middle arc."},
"support_circle": {"type": "string", "description": "A polygon's construction circle."}
});
if let (Some(Value::Object(existing)), Value::Object(extra)) = (schema.get_mut("properties"), properties) {
for (k, v) in extra {
existing.entry(k).or_insert(v);
}
}
}
#[cfg(test)]
#[path = "sketch_dimensioned_tests.rs"]
mod tests;
@@ -1,234 +0,0 @@
use serde_json::{Value, json};
use super::{CREATORS, DrawDimensionedParams, DrawDimensionedResult};
use crate::Session;
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
use crate::schema::{ParamCheck, input_schema_for, issues, output_schema_for};
const ID: &str = "sketch.draw.dimensioned";
fn run(s: &mut Session, id: &str, p: Value) -> Value {
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
}
fn scene(level: ParamCheck) -> Session {
let mut s = Session { param_check: ParamCheck::Off, ..Session::default() };
run(&mut s, "sketch.create", json!({"plane": "XY"}));
s.param_check = level;
s
}
fn matches(reply: &Value) {
let schema = output_schema_for::<DrawDimensionedResult>();
let schema = schema.as_value();
assert!(conforms(schema, schema, reply), "{reply} doesn't match {schema}");
assert!(reply.get("param_warnings").is_none(), "{reply}");
}
#[test]
fn schemas_and_the_key_check_agree() {
assert_keys(input_schema_for::<DrawDimensionedParams>(), &["as_drawn", "command", "dimensions", "locate", "params"]);
}
/// The creators in the schema are exactly the ones `dimension_roles` knows.
#[test]
fn the_creator_list_is_the_one_dimension_roles_has() {
for c in crate::cmd::command_specs() {
assert_eq!(crate::dimension_roles(c.id).is_some(), CREATORS.contains(&c.id), "{}", c.id);
}
for creator in CREATORS {
assert!(crate::cmd::find_command(creator).is_some(), "{creator}");
assert!(crate::dimension_roles(creator).is_some(), "{creator}");
}
}
#[test]
fn strict_keeps_todays_errors() {
let line = json!({"points": [[0, 0], [10, 0]]});
assert_strict_keeps_todays_errors(&[
(ID, json!({})),
(ID, json!({"command": null, "params": line, "dimensions": {"length": 5}})),
(ID, json!({"command": 5, "params": line, "dimensions": {"length": 5}})),
(ID, json!({"command": "sketch.point", "params": line, "dimensions": {"length": 5}})),
(ID, json!({"command": "", "params": line, "dimensions": {"length": 5}})),
(ID, json!({"command": "sketch.line", "dimensions": {"length": 5}})),
(ID, json!({"command": "sketch.line", "params": null, "dimensions": {"length": 5}})),
(ID, json!({"command": "sketch.line", "params": [1], "dimensions": {"length": 5}})),
(ID, json!({"command": "sketch.line", "params": line})),
(ID, json!({"command": "sketch.line", "params": line, "dimensions": null})),
(ID, json!({"command": "sketch.line", "params": line, "dimensions": null, "locate": true})),
(ID, json!({"command": "sketch.line", "params": line, "dimensions": [1]})),
(ID, json!({"command": "sketch.line", "params": line, "dimensions": {"length": null}})),
(ID, json!({"command": "sketch.line", "params": line, "dimensions": {"length": true}})),
(ID, json!({"command": "sketch.line", "params": line, "dimensions": {"length": ""}})),
(ID, json!({"command": "sketch.line", "params": line, "dimensions": {"length": "x".repeat(4097)}})),
(ID, json!({"command": "sketch.line", "params": line, "dimensions": {"length": [5]}})),
]);
}
#[test]
fn the_check_names_what_is_wrong_with_a_key() {
let said = |p: Value| issues(ID, &p).into_iter().map(|i| i.message).collect::<Vec<_>>();
let line = json!({"points": [[0, 0], [10, 0]]});
assert_eq!(said(json!({"command": "sketch.point", "params": line})), ["unsupported sketch creator"]);
assert_eq!(said(json!({"command": 3, "params": line})), ["`command` must name a supported sketch creator"]);
assert_eq!(said(json!({"command": "sketch.line", "params": 1})), ["`params` must be an object"]);
assert_eq!(said(json!({"command": "sketch.line", "params": line, "dimensions": 1})), ["`dimensions` must be an object"]);
assert_eq!(
said(json!({"command": "sketch.line", "params": line, "dimensions": {"length": []}})),
["dimension values must be bounded expressions or finite numbers"]
);
assert_eq!(said(json!({"command": "sketch.line", "params": line, "dimensions": {"length": 5}, "lenght": 1})).len(), 1);
// Left out, the handler's message is what a caller gets, not the check's.
assert!(said(json!({})).is_empty());
}
/// The types read each key as the handler does, and the schema takes what the handler takes.
#[test]
fn the_schema_takes_what_the_handler_reads() {
let schema = input_schema_for::<DrawDimensionedParams>();
let schema = schema.as_value();
let line = json!({"points": [[0, 0], [10, 0]]});
for (p, takes) in [
(json!({"command": "sketch.line", "params": line, "dimensions": {"length": 5, "angle": "30 deg"}}), true),
(json!({"command": "sketch.circle.center", "params": {"center": [0, 0], "radius": 2}, "dimensions": {"diameter": "width = 20"}}), true),
(json!({"command": "sketch.line", "params": line, "as_drawn": true}), true),
(json!({"command": "sketch.line", "params": line, "locate": true}), true),
(json!({"command": "sketch.line", "params": line, "as_drawn": false, "locate": true}), true),
(json!({"command": "sketch.line", "params": line}), false),
(json!({"command": "sketch.line", "params": line, "as_drawn": false}), false),
(json!({"params": line, "as_drawn": true}), false),
(json!({"command": "sketch.line", "as_drawn": true}), false),
(json!({"command": "sketch.point", "params": line, "as_drawn": true}), false),
(json!({"command": "sketch.line", "params": line, "dimensions": {"length": []}}), false),
] {
assert_eq!(conforms(schema, schema, &p), takes, "{p}");
// The type agrees with the schema about the keys it reads.
if takes {
assert!(issues(ID, &p).is_empty(), "{p}");
}
}
}
/// Every creator, in every form the tests use (typed, as drawn, located), in Warn: the reply
/// matches the output schema and carries no warning, so the contract accepts what works today.
#[test]
fn replies_match_the_output_schema() {
let calls = [
("sketch.line", json!({"points": [[0, 0], [10, 3]]}), json!({"length": 12, "angle": "20"})),
("sketch.line.midpoint", json!({"mid": [3, 4], "end": [9, 12]}), json!({"length": 12})),
("sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 5]}), json!({"width": 20, "height": "h = 8"})),
("sketch.rectangle.center", json!({"center": [0, 0], "corner": [5, 5]}), json!({"width": 20})),
("sketch.rectangle.rounded", json!({"p0": [0, 0], "p1": [20, 10], "radius": 2}), json!({"width": 30})),
("sketch.circle.center", json!({"center": [0, 0], "radius": 3}), json!({"diameter": 20})),
("sketch.circle.center", json!({"center": [0, 0], "radius": 3}), json!({"radius": "r = 4"})),
("sketch.ellipse", json!({"center": [0, 0], "major": [10, 6], "minor_radius": 4}), json!({"major_radius": 12})),
("sketch.polygon.inscribed", json!({"center": [0, 0], "radius": 5, "sides": 6}), json!({"radius": 8})),
("sketch.polygon.edge", json!({"p0": [0, 0], "p1": [5, 0], "sides": 6}), json!({"length": 7})),
];
for (command, params, dimensions) in calls {
for extra in [json!({}), json!({"as_drawn": true}), json!({"as_drawn": true, "locate": true}), json!({"locate": true})] {
let mut s = scene(ParamCheck::Warn);
let mut p = json!({"command": command, "params": params, "dimensions": dimensions});
p.as_object_mut().into_iter().for_each(|o| o.extend(extra.as_object().cloned().unwrap_or_default()));
let reply = s.execute(ID, &p).unwrap_or_else(|e| panic!("{p}: {e}"));
matches(&reply);
assert!(reply["dimensions"].is_object(), "{reply}");
assert_eq!(reply.get("located").is_some(), p.get("locate") == Some(&json!(true)), "{reply}");
}
}
// Typed values alone (no flags), a definition and `locate`: `defined` and `located` appear.
let mut s = scene(ParamCheck::Warn);
let reply = run(
&mut s,
ID,
json!({"command": "sketch.circle.center", "params": {"center": [3, 4], "radius": 3}, "dimensions": {"diameter": "w = 20"}, "locate": true}),
);
matches(&reply);
assert_eq!(reply["defined"], json!(["w"]), "{reply}");
assert!(reply["located"].as_array().is_some_and(|l| !l.is_empty()), "{reply}");
}
/// A drawing for each creator, as the handler's own tests use.
fn sample(creator: &str) -> Value {
match creator {
"sketch.line" => json!({"points":[[0,0],[6,8]]}),
"sketch.line.midpoint" => json!({"mid":[3,4],"end":[9,12]}),
"sketch.rectangle.two_point" => json!({"p0":[0,0],"p1":[30,20]}),
"sketch.rectangle.center" => json!({"center":[0,0],"corner":[15,10]}),
"sketch.rectangle.three_point" => json!({"p0":[0,0],"p1":[30,10],"p2":[25,25]}),
"sketch.rectangle.rounded" => json!({"p0":[0,0],"p1":[30,20],"radius":3}),
"sketch.circle.center" => json!({"center":[0,0],"radius":5}),
"sketch.circle.two_point" => json!({"p0":[0,0],"p1":[10,0]}),
"sketch.circle.three_point" => json!({"p0":[0,0],"p1":[10,0],"p2":[5,5]}),
"sketch.arc.center_point" => json!({"center":[0,0],"start":[10,0],"end":[0,10]}),
"sketch.ellipse" => json!({"center":[0,0],"major":[10,6],"minor_radius":4}),
"sketch.slot.center_to_center" | "sketch.slot.overall" => json!({"p0":[2,3],"p1":[22,3],"width":4}),
"sketch.slot.center_point" => json!({"center":[12,3],"end":[22,3],"width":4}),
"sketch.slot.arc_center" => json!({"center":[0,0],"start":[20,0],"end":[0,20],"width":6}),
"sketch.slot.arc_three_point" => json!({"start":[20,0],"through":[14.142136,14.142136],"end":[0,20],"width":6}),
"sketch.polygon.inscribed" | "sketch.polygon.circumscribed" => json!({"center":[4,5],"radius":10,"sides":6}),
"sketch.polygon.edge" => json!({"p0":[0,0],"p1":[10,0],"sides":6}),
other => panic!("{other}: add a sample drawing for it"),
}
}
/// Every creator, as drawn and located, in Warn: a reply that matches, with no warning.
#[test]
fn every_creator_replies_as_drawn_and_located() {
for creator in CREATORS {
for flags in [json!({"as_drawn": true}), json!({"locate": true}), json!({"as_drawn": true, "locate": true})] {
let mut s = scene(ParamCheck::Warn);
let mut p = json!({"command": creator, "params": sample(creator)});
p.as_object_mut().into_iter().for_each(|o| o.extend(flags.as_object().cloned().unwrap_or_default()));
let reply = s.execute(ID, &p).unwrap_or_else(|e| panic!("{p}: {e}"));
matches(&reply);
assert!(reply["dimensions"].is_object() && reply["curves"].is_array(), "{reply}");
}
}
}
#[test]
fn every_creator_passes_the_check() {
for creator in CREATORS {
// The creator refuses empty parameters in its own words; the check says nothing.
assert!(issues(ID, &json!({"command": creator, "params": {}, "as_drawn": true})).is_empty(), "{creator}");
}
}
/// A misspelt key still runs in Warn and says so, with a reply that matches; strict refuses it.
#[test]
fn a_misspelt_key_is_reported_in_warn() {
let p = json!({"command": "sketch.circle.center", "params": {"center": [0, 0], "radius": 3}, "dimensions": {"diameter": 10}, "as_draw": true});
let mut s = scene(ParamCheck::Warn);
let reply = run(&mut s, ID, p.clone());
assert!(reply["param_warnings"].as_array().is_some_and(|w| w.len() == 1), "{reply}");
let schema = output_schema_for::<DrawDimensionedResult>();
assert!(conforms(schema.as_value(), schema.as_value(), &reply), "{reply}");
assert!(scene(ParamCheck::Strict).execute(ID, &p).is_err());
}
/// A misspelt key of the creator and one of the call itself are both reported in Warn.
#[test]
fn warn_keeps_the_creators_warnings_beside_its_own() {
let p = json!({"command": "sketch.circle.center", "params": {"center": [0, 0], "radius": 3, "constructon": true}, "dimensions": {"diameter": 10}, "as_draw": true});
let reply = run(&mut scene(ParamCheck::Warn), ID, p);
let warnings = reply["param_warnings"].as_array().unwrap_or_else(|| panic!("{reply}"));
assert_eq!(warnings.len(), 2, "{reply}");
assert!(warnings.iter().any(|w| w.as_str().is_some_and(|w| w.contains("constructon"))), "{reply}");
assert!(warnings.iter().any(|w| w.as_str().is_some_and(|w| w.contains("as_draw"))), "{reply}");
}
/// The reply is what the handler returned before: the creator's keys, `dimensions`, `defined`,
/// `located`, and the creator's own `param_warnings` kept.
#[test]
fn the_reply_keeps_the_creators_keys() {
let creator = json!({"curves": ["c1"], "sketch": {"solved": true, "dof": 1}, "param_warnings": ["a"], "dimensions": "old", "defined": ["x"]});
let none = DrawDimensionedResult::new(creator.clone(), serde_json::Map::new(), Vec::new(), None);
let json = serde_json::to_value(&none).unwrap_or_default();
assert_eq!(json, json!({"curves": ["c1"], "sketch": {"solved": true, "dof": 1}, "param_warnings": ["a"], "dimensions": {}, "defined": ["x"]}));
let all = DrawDimensionedResult::new(creator, serde_json::Map::new(), vec!["w".into()], Some(vec![json!(1)]));
let json = serde_json::to_value(&all).unwrap_or_default();
assert_eq!(json["defined"], json!(["w"]));
assert_eq!(json["located"], json!([1]));
}
-116
View File
@@ -1,116 +0,0 @@
//! Contracts for Split Body and Split Face in `cmd/features.rs` (#13127). Both reply with the plain
//! feature reply, [`super::features::FeatureResult`].
//!
//! Each takes one cutting tool, and the handler reads the first it finds in this order: a face of
//! a body (`tool`), a sketch, then a plane. A key that is `null` counts as not given. The
//! contracts check the form of every tool key a call gives, so in strict a call that gives a good
//! `tool` and a malformed `plane` is refused, where today the plane is ignored. A call that gives
//! no tool is the handler's to refuse, with its message for a missing `plane`; whether a body,
//! sketch, line or plane exists is the handler's to check too.
use std::borrow::Cow;
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
use serde::de::Error as _;
use serde::{Deserialize, Deserializer};
use serde_json::Value;
use super::check::whole;
use super::types::{FaceRefs, FeatureCommon, PlaneParam, SketchRef, ToolFace, feature_common};
/// Cut a body into pieces along a plane, a face of another body (its surface extended), or a
/// straight sketch line (along the sketch's normal). Each piece becomes a body.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct SplitBodyParams {
/// The body to split, by name.
#[serde(deserialize_with = "body")]
pub body: String,
/// A face of a body to split along, its surface extended. Read when neither `sketch` nor
/// `line` is given.
#[serde(default)]
pub tool: Option<ToolFace>,
/// With `line`: the sketch, by feature id (not its name), whose line splits the body.
#[serde(default)]
pub sketch: Option<SketchFeatureId>,
/// With `sketch`: a straight line's id; the body is split by the plane through it along the
/// sketch's normal, which follows the line when the sketch changes.
#[serde(default, deserialize_with = "line")]
pub line: Option<String>,
/// The plane to split across. Read when none of `tool`, `sketch` and `line` is given; then it
/// is required.
#[serde(default)]
pub plane: Option<PlaneParam>,
#[serde(flatten, deserialize_with = "feature_common")]
pub common: FeatureCommon,
}
/// Divide faces of a body along a plane, a face of another body (its surface extended), or a
/// sketch's curves (along the sketch's normal), without cutting the body: each face becomes
/// several, and the body keeps its shape.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct SplitFaceParams {
/// The faces to split.
pub faces: FaceRefs,
/// The body the faces are on, by name: where faces of several bodies meet at a point, whose
/// face it is. Default: the body each point or name finds.
#[serde(default)]
pub body: Option<String>,
/// A face of a body to split along, its surface extended.
#[serde(default)]
pub tool: Option<ToolFace>,
/// A sketch, by id or name, whose curves split the faces along its normal. Read when no
/// `tool` is given.
#[serde(default)]
pub sketch: Option<SketchRef>,
/// The plane to split along. Read when neither `tool` nor `sketch` is given; then it is
/// required.
#[serde(default)]
pub plane: Option<PlaneParam>,
#[serde(flatten, deserialize_with = "feature_common")]
pub common: FeatureCommon,
}
/// Split Body's `body`, with the handler's message for anything that isn't text.
fn body<'de, D: Deserializer<'de>>(d: D) -> Result<String, D::Error> {
match Value::deserialize(d)? {
Value::String(s) => Ok(s),
_ => Err(D::Error::custom(whole("`body` is required"))),
}
}
/// Split Body's `line`, with the handler's message for anything that isn't text. `null` is no
/// line, as the handler reads it.
fn line<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
match Value::deserialize(d)? {
Value::Null => Ok(None),
Value::String(s) => Ok(Some(s)),
_ => Err(D::Error::custom(whole("`line` must name a line of the sketch"))),
}
}
/// Split Body's `sketch`: a sketch's feature id, a whole number from 0 (read with
/// `Value::as_u64`). Unlike Split Face's [`SketchRef`], a name is refused, with the handler's
/// message. Whether a sketch has that id is the handler's to check.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SketchFeatureId(pub u64);
impl<'de> Deserialize<'de> for SketchFeatureId {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
Value::deserialize(d)?.as_u64().map(SketchFeatureId).ok_or_else(|| D::Error::custom(whole("`sketch` must be a sketch feature id")))
}
}
impl JsonSchema for SketchFeatureId {
fn schema_name() -> Cow<'static, str> {
"SketchFeatureId".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({"description": "A sketch's feature id.", "type": "integer", "minimum": 0, "maximum": u64::MAX})
}
}
#[cfg(test)]
#[path = "split_tests.rs"]
mod tests;
-210
View File
@@ -1,210 +0,0 @@
use std::collections::BTreeSet;
use serde_json::{Value, json};
use super::{SplitBodyParams, SplitFaceParams};
use crate::Session;
use crate::schema::features::FeatureResult;
use crate::schema::tests::{assert_keys, conforms};
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
#[test]
fn schemas_and_the_key_check_agree() {
assert_keys(
input_schema_for::<SplitBodyParams>(),
&["body", "body_name", "body_names", "line", "name", "participants", "plane", "sketch", "tool"],
);
assert_keys(
input_schema_for::<SplitFaceParams>(),
&["body", "body_name", "body_names", "faces", "name", "participants", "plane", "sketch", "tool"],
);
}
/// The design the split calls work on, at `level`, and the sketch ids `Cuts` and `Ring`:
/// - `B`, a 40 x 30 x 20 mm box from the origin (its top at z = 20);
/// - `Tool`, a box whose face at y = 10, extended, crosses `B`'s top (the point [20, 10, 27] is
/// on it);
/// - the sketch `Cuts` on XY, two lines across `B` at x = 10 (id `cut`) and x = 30;
/// - the sketch `Ring` on XY, a circle `c` beside `B`, so no straight line;
/// - the construction plane `Mid`, 20 mm along X from YZ, through the middle of `B`.
fn design(level: ParamCheck) -> (Session, Value, Value, Value) {
let mut s = Session { param_check: level, ..Session::default() };
let mut run = |id: &str, p: Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
run("solid.box", json!({"length": 40, "width": 30, "height": 20, "body_name": "B"}));
run("solid.box", json!({"corner": [-5, 10, 25], "length": 50, "width": 5, "height": 5, "body_name": "Tool"}));
let cuts = run("sketch.create", json!({"plane": "XY", "name": "Cuts"}))["sketch"].clone();
run("sketch.line", json!({"points": [[10, -5], [10, 35]], "ids": ["cut"]}));
run("sketch.line", json!({"points": [[30, -5], [30, 35]]}));
run("sketch.finish", json!({}));
let ring = run("sketch.create", json!({"plane": "XY", "name": "Ring"}))["sketch"].clone();
run("sketch.circle.center", json!({"center": [60, 60], "radius": 2, "id": "c"}));
run("sketch.finish", json!({}));
let mid = run("construct.plane.offset", json!({"base": "YZ", "offset": 20, "name": "Mid"}))["feature"].clone();
assert!(cuts.is_u64() && ring.is_u64() && mid.is_u64(), "{cuts} {ring} {mid}");
(s, cuts, ring, mid)
}
/// Calls that fail today give the same message in strict, each on a fresh [`design`]: what the
/// contracts check (the body, the faces, each tool's form) and what they leave to the handler (a
/// body, sketch, line, plane or face the design lacks, a sketch line that isn't straight, a tool
/// point off the tool, and no tool at all). Split Face without `faces` is left out: today it says
/// "`faces` must be a list of points or face names", and strict "`faces` is required", as for
/// every missing key.
#[test]
fn strict_keeps_todays_errors() {
let (_, cuts, ring, _) = design(ParamCheck::Off);
let cases = [
("solid.split_body", json!({})),
("solid.split_body", json!({"plane": "XY"})),
("solid.split_body", json!({"body": 5, "plane": "XY"})),
("solid.split_body", json!({"body": null, "plane": "XY"})),
("solid.split_body", json!({"body": "Nope", "plane": "XY"})),
("solid.split_body", json!({"body": "B"})),
("solid.split_body", json!({"body": "B", "plane": null, "tool": null, "sketch": null, "line": null})),
("solid.split_body", json!({"body": "B", "plane": true})),
("solid.split_body", json!({"body": "B", "plane": 7.5})),
("solid.split_body", json!({"body": "B", "plane": -1})),
("solid.split_body", json!({"body": "B", "plane": "Nowhere"})),
("solid.split_body", json!({"body": "B", "plane": "Cuts"})),
("solid.split_body", json!({"body": "B", "plane": {"origin": [0, 0, 0]}})),
("solid.split_body", json!({"body": "B", "plane": {"origin": "here", "normal": [1, 0, 0]}})),
("solid.split_body", json!({"body": "B", "plane": {"origin": [0, 0, 0], "normal": [1, 0, 0]}})),
("solid.split_body", json!({"body": "B", "tool": "Tool"})),
("solid.split_body", json!({"body": "B", "tool": {"point": [20, 10, 27]}})),
("solid.split_body", json!({"body": "B", "tool": {"body": 5, "point": [20, 10, 27]}})),
("solid.split_body", json!({"body": "B", "tool": {"body": "Tool"}})),
("solid.split_body", json!({"body": "B", "tool": {"body": "Tool", "point": [20, 10]}})),
("solid.split_body", json!({"body": "B", "tool": {"body": "Nope", "point": [20, 10, 27]}})),
("solid.split_body", json!({"body": "B", "tool": {"body": "Tool", "point": [500, 500, 500]}})),
("solid.split_body", json!({"body": "B", "sketch": "Cuts", "line": "cut"})),
("solid.split_body", json!({"body": "B", "sketch": -1, "line": "cut"})),
("solid.split_body", json!({"body": "B", "sketch": 999, "line": "cut"})),
("solid.split_body", json!({"body": "B", "sketch": cuts})),
("solid.split_body", json!({"body": "B", "sketch": cuts, "line": null})),
("solid.split_body", json!({"body": "B", "sketch": cuts, "line": 5})),
("solid.split_body", json!({"body": "B", "sketch": cuts, "line": "nope"})),
("solid.split_body", json!({"body": "B", "sketch": ring, "line": "c"})),
("solid.split_body", json!({"body": "B", "line": "cut"})),
("solid.split_face", json!({"faces": 5, "plane": "XY"})),
("solid.split_face", json!({"faces": [], "plane": "XY"})),
("solid.split_face", json!({"faces": [[10, 15]], "plane": "XY"})),
("solid.split_face", json!({"faces": [[500, 500, 500]], "plane": "Mid"})),
("solid.split_face", json!({"faces": ["NoSuchFace"], "plane": "Mid"})),
("solid.split_face", json!({"faces": [[10, 15, 20]], "body": "Nope", "plane": "Mid"})),
("solid.split_face", json!({"faces": [[10, 15, 20]]})),
("solid.split_face", json!({"faces": [[10, 15, 20]], "plane": null, "tool": null, "sketch": null})),
("solid.split_face", json!({"faces": [[10, 15, 20]], "plane": true})),
("solid.split_face", json!({"faces": [[10, 15, 20]], "plane": "Nowhere"})),
("solid.split_face", json!({"faces": [[10, 15, 20]], "plane": {"normal": [0, 0, 0]}})),
("solid.split_face", json!({"faces": [[10, 15, 20]], "tool": "Tool"})),
("solid.split_face", json!({"faces": [[10, 15, 20]], "tool": {"body": "Tool", "point": "here"}})),
("solid.split_face", json!({"faces": [[10, 15, 20]], "tool": {"body": "Nope", "point": [20, 10, 27]}})),
("solid.split_face", json!({"faces": [[10, 15, 20]], "sketch": true})),
("solid.split_face", json!({"faces": [[10, 15, 20]], "sketch": "Nope"})),
];
for (id, p) in cases {
let error = |level: ParamCheck| design(level).0.execute(id, &p).err().map(|e| e.to_string());
let off = error(ParamCheck::Off);
assert!(off.is_some(), "{id} {p} should fail today");
assert_eq!(error(ParamCheck::Strict), off, "{id} {p}");
}
}
/// #13127: each contract takes what its input schema allows: each call's check finds no issue
/// exactly when the call matches the schema. A call that gives no tool matches it; the handler
/// refuses it.
#[test]
fn the_check_takes_what_the_schema_allows() {
let body = input_schema_for::<SplitBodyParams>();
let face = input_schema_for::<SplitFaceParams>();
for (id, schema, p, takes) in [
("solid.split_body", &body, json!({"body": "B", "plane": "XY"}), true),
("solid.split_body", &body, json!({"body": "B", "plane": 3}), true),
(
"solid.split_body",
&body,
json!({"body": "B", "plane": {"origin": [0, "w", 0], "normal": [1, 0, 0]}, "name": "S", "body_names": ["L", "R"]}),
true,
),
("solid.split_body", &body, json!({"body": "B", "tool": {"body": "T", "point": [0, 0, 0]}}), true),
("solid.split_body", &body, json!({"body": "B", "tool": {"body": "T", "point": {"x": 0, "y": 0, "z": 0}, "side": 1}}), true),
("solid.split_body", &body, json!({"body": "B", "sketch": 4, "line": "cut", "participants": ["*"]}), true),
("solid.split_body", &body, json!({"body": "B", "sketch": null, "line": null, "tool": null, "plane": null}), true),
("solid.split_body", &body, json!({"body": "B", "line": "cut"}), true),
("solid.split_body", &body, json!({"body": ""}), true),
("solid.split_body", &body, json!({}), false),
("solid.split_body", &body, json!({"body": 5, "plane": "XY"}), false),
("solid.split_body", &body, json!({"body": "B", "plane": true}), false),
("solid.split_body", &body, json!({"body": "B", "plane": -1}), false),
("solid.split_body", &body, json!({"body": "B", "plane": {"origin": [0, 0, 0]}}), false),
("solid.split_body", &body, json!({"body": "B", "tool": "T"}), false),
("solid.split_body", &body, json!({"body": "B", "tool": {"body": "T"}}), false),
("solid.split_body", &body, json!({"body": "B", "tool": {"body": 1, "point": [0, 0, 0]}}), false),
("solid.split_body", &body, json!({"body": "B", "sketch": "Cuts", "line": "cut"}), false),
("solid.split_body", &body, json!({"body": "B", "sketch": -2, "line": "cut"}), false),
("solid.split_body", &body, json!({"body": "B", "sketch": 4, "line": 7}), false),
("solid.split_body", &body, json!({"body": "B", "plane": "XY", "faces": [[0, 0, 0]]}), false),
("solid.split_body", &body, json!({"body": "B", "plane": "XY", "body_name": 3}), false),
("solid.split_face", &face, json!({"faces": [[1, 2, 3]], "plane": "XY"}), true),
(
"solid.split_face",
&face,
json!({"faces": ["F1", {"point": [1, 2, 3]}], "body": "B", "plane": {"x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}}),
true,
),
("solid.split_face", &face, json!({"faces": [[1, 2, 3]], "tool": {"body": "T", "point": [1, 2, 3]}}), true),
("solid.split_face", &face, json!({"faces": [[1, 2, 3]], "sketch": "Cuts"}), true),
("solid.split_face", &face, json!({"faces": [[1, 2, 3]], "sketch": 7, "name": "Split", "participants": ["*"]}), true),
("solid.split_face", &face, json!({"faces": [[1, 2, 3]], "tool": null, "sketch": null, "plane": null, "body": null}), true),
("solid.split_face", &face, json!({"faces": [[1, 2, 3]]}), true),
("solid.split_face", &face, json!({"plane": "XY"}), false),
("solid.split_face", &face, json!({"faces": [], "plane": "XY"}), false),
("solid.split_face", &face, json!({"faces": [[1, 2]], "plane": "XY"}), false),
("solid.split_face", &face, json!({"faces": [[1, 2, 3]], "body": 5, "plane": "XY"}), false),
("solid.split_face", &face, json!({"faces": [[1, 2, 3]], "sketch": true}), false),
("solid.split_face", &face, json!({"faces": [[1, 2, 3]], "tool": {"point": [1, 2, 3]}}), false),
("solid.split_face", &face, json!({"faces": [[1, 2, 3]], "plane": "XY", "line": "cut"}), false),
] {
let schema = schema.as_value();
let issues = crate::schema::issues(id, &p);
assert_eq!(issues.is_empty(), takes, "{id} {p}: {issues:?}");
assert_eq!(conforms(schema, schema, &p), takes, "{id} {p}");
}
}
/// #13127: both commands reply with `FeatureResult`, with the keys they gave before, for each
/// tool: in Warn, without warnings, so their contracts take each call. Split Face changes `B` and
/// makes nothing; Split Body cuts `B` in two, so two bodies are made or changed between them.
#[test]
fn split_body_and_split_face_reply_with_feature_result() {
let schema = output_schema_for::<FeatureResult>();
let schema = schema.as_value();
let before = BTreeSet::from(["feature", "name", "bodies", "created_bodies", "changed_bodies", "removed_bodies", "warning", "ms"]);
let (_, cuts, _, mid) = design(ParamCheck::Warn);
let calls = [
("solid.split_face", json!({"faces": [[10, 15, 20]], "plane": {"origin": [20, 0, 0], "normal": [1, 0, 0]}})),
("solid.split_face", json!({"faces": [[10, 15, 20]], "body": "B", "plane": "Mid", "name": "Halves"})),
("solid.split_face", json!({"faces": [[20, 5, 20]], "body": "B", "tool": {"body": "Tool", "point": [20, 10, 27]}})),
("solid.split_face", json!({"faces": [[20, 15, 20]], "sketch": "Cuts", "tool": null, "plane": null})),
("solid.split_face", json!({"faces": [[20, 15, 20]], "sketch": cuts.clone(), "participants": ["B"]})),
("solid.split_body", json!({"body": "B", "plane": {"origin": [20, 0, "0 mm"], "normal": [1, 0, 0]}})),
("solid.split_body", json!({"body": "B", "plane": mid, "name": "Halves"})),
("solid.split_body", json!({"body": "B", "plane": "Mid", "tool": null, "sketch": null, "line": null})),
("solid.split_body", json!({"body": "B", "tool": {"body": "Tool", "point": [20, 10, 27]}})),
("solid.split_body", json!({"body": "B", "sketch": cuts, "line": "cut"})),
];
for (id, p) in calls {
let (mut s, ..) = design(ParamCheck::Warn);
let r = s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
assert!(conforms(schema, schema, &r), "{r} doesn't match {schema}");
assert!(r.get("param_warnings").is_none(), "{id} {p}: {r}");
let keys: BTreeSet<&str> = r.as_object().into_iter().flatten().map(|(k, _)| k.as_str()).collect();
assert_eq!(keys, before, "{id} {p}: {r}");
let count = |key: &str| r[key].as_array().map_or(0, Vec::len);
if id == "solid.split_face" {
assert_eq!((count("created_bodies"), r["changed_bodies"].clone()), (0, json!(["B"])), "{id} {p}: {r}");
} else {
assert!(count("created_bodies") >= 1 && count("created_bodies") + count("changed_bodies") == 2, "{id} {p}: {r}");
}
}
}
-36
View File
@@ -974,42 +974,6 @@ pick_list!(
"The edges, 1 to 1000."
);
/// A face of a body whose surface, extended, cuts like a plane: the `tool` of `solid.split_body`,
/// `solid.split_face` and `surface.trim`, which read `tool.body` and then `tool.point` with these
/// messages. Other keys in it are ignored, as the handlers ignore them. Whether the body exists
/// and the point is on one of its faces is the handler's to check.
#[derive(Clone, Debug, PartialEq)]
pub struct ToolFace {
pub body: String,
pub point: Vec3,
}
impl<'de> Deserialize<'de> for ToolFace {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let v = Value::deserialize(d)?;
// In the handlers' order: the body (anything but an object has none), then the point.
let body = v.get("body").and_then(Value::as_str).ok_or_else(|| D::Error::custom(whole("`tool.body` must name a body")))?;
let point =
v.get("point").and_then(params::vec3).ok_or_else(|| D::Error::custom(whole("`tool.point` must be [x, y, z] on the tool face")))?;
Ok(ToolFace { body: body.to_string(), point })
}
}
impl JsonSchema for ToolFace {
fn schema_name() -> Cow<'static, str> {
"ToolFace".into()
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
let point = generator.subschema_for::<Point3>();
json_schema!({
"description": "A face of a body, its surface extended: the body's name and a point in mm on the face.",
"type": "object",
"properties": {"body": {"type": "string"}, "point": point},
"required": ["body", "point"]
})
}
}
/// A sketch referred to by its feature id (a number) or its name, as `features::sketch_id` reads
/// it, with its message for a top-level key (`path_sketch`, `solid.split_face`'s `sketch`…);
/// for a nested one (a section's `sketch`) the message names the key as `sections[0].sketch`
+4
View File
@@ -7,6 +7,7 @@ doc.recover
doc.recovery_discard
doc.recovery_list
face.delete
file.export
file.insert_mesh
file.insert_step
file.new
@@ -23,6 +24,7 @@ render.preflight
render.scene
render.start
sheet.export_dxf
sketch.draw.dimensioned
solid.align
solid.coil
solid.draft
@@ -39,6 +41,8 @@ solid.press_pull
solid.replace_face
solid.revolve
solid.shell
solid.split_body
solid.split_face
solid.thread
surface.blend
surface.extend
-121
View File
@@ -400,124 +400,3 @@ fn an_insert_after_a_mirror_keeps_its_meshes_through_a_drag() {
}
assert_as_evaluated_afresh(&s);
}
/// The model of `s` against a fresh evaluation of its design: each body's name, volume, bounds
/// and reference mesh. Not the shown meshes: a copy's display triangles can tie differently by
/// the pose they were made at, with or without reuse (as in the drag test above).
fn assert_model_as_evaluated_afresh(s: &Session) {
let mut fresh = solidcraft_doc::Model::new();
fresh.no_cache = true;
fresh.evaluate(&s.doc);
let errors: Vec<_> = fresh.results.iter().filter_map(|r| r.error.as_ref()).collect();
assert!(errors.is_empty(), "{errors:?}");
let (got, want) = (s.model.state(), fresh.state());
assert_eq!(got.bodies.len(), want.bodies.len());
for (a, b) in got.bodies.iter().zip(&want.bodies) {
assert_eq!(a.name, b.name);
let (va, vb) = (a.measure().unwrap().volume, b.measure().unwrap().volume);
assert!((va - vb).abs() < 1e-6, "{}: {va} != {vb}", a.name);
assert_eq!(a.mesh().triangles, b.mesh().triangles, "{}", a.name);
let (ma, mb) = (a.mesh().bounds(), b.mesh().bounds());
assert!(ma.min.dist(mb.min) < 1e-6 && ma.max.dist(mb.max) < 1e-6, "{}: {ma:?} != {mb:?}", a.name);
}
}
/// Driving a joint evaluates a mirror of the part it moves again, and the features after it
/// only when they read its copy: a box, a sketch and its extrusion, and a cut into another
/// body are kept (#12139).
#[test]
fn a_joint_drive_evaluates_only_what_reads_the_mirrored_copy() {
for dependent in [false, true] {
let mut s = Session::default();
moved_arm(&mut s);
run(
&mut s,
"joint.as_built",
json!({"type": "revolute", "name": "Pan", "a": 0, "b": "Arm", "at": {"occurrence": 0, "point": [0, 0, 0], "z": [0, 0, 1]}}),
);
let copy = created(&run(&mut s, "solid.mirror", json!({"bodies": ["ArmBody"], "plane": "YZ"})));
run(&mut s, "solid.box", json!({"corner": [0, -100, 0], "length": 10, "width": 10, "height": 10, "body_name": "Later"}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-100, -100], "p1": [-90, -90]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 4, "operation": "new"}));
run(&mut s, "solid.box", json!({"corner": [2, -98, 5], "length": 2, "width": 2, "height": 10, "operation": "cut", "targets": ["Later"]}));
if dependent {
run(&mut s, "solid.move", json!({"bodies": [copy.clone()], "translate": [0, 0, 5]}));
}
for angle in [20, -35, 60] {
run(&mut s, "joint.drive", json!({"joint": "Pan", "value": angle, "quiet": true}));
let recomputed = s.model.last_recomputed + s.model.last_restored;
if dependent {
assert_eq!(recomputed, 2, "the mirror and the move of its copy at {angle}°");
} else {
assert_eq!(recomputed, 1, "only the mirror at {angle}°");
}
assert_box(world_box(&s, &copy), {
let (lo, hi) = mirrored_yz(&s, "ArmBody");
if dependent { (lo + Vec3::new(0., 0., 5.), hi + Vec3::new(0., 0., 5.)) } else { (lo, hi) }
});
assert_model_as_evaluated_afresh(&s);
}
for command in ["edit.undo", "edit.redo"] {
run(&mut s, command, json!({}));
assert_model_as_evaluated_afresh(&s);
}
}
}
/// A saved model's pose-only mirror was made at the pose the design had when it was saved: one
/// adopted for a design posed otherwise is evaluated again, not taken from the cache (#12139).
#[test]
fn an_adopted_model_mirrors_at_the_designs_own_pose() {
let mut s = Session::default();
moved_arm(&mut s);
run(
&mut s,
"joint.as_built",
json!({"type": "revolute", "name": "Pan", "a": 0, "b": "Arm", "at": {"occurrence": 0, "point": [0, 0, 0], "z": [0, 0, 1]}}),
);
run(&mut s, "solid.mirror", json!({"bodies": ["ArmBody"], "plane": "YZ"}));
run(&mut s, "solid.box", json!({"corner": [0, -100, 0], "length": 10, "width": 10, "height": 10, "body_name": "Later"}));
let saved = s.model.saved(&s.doc).unwrap();
run(&mut s, "joint.drive", json!({"joint": "Pan", "value": 40, "quiet": true}));
// As opening installs it, before any evaluation; and once evaluated.
s.model = solidcraft_doc::Model::adopt(&s.doc, &saved).unwrap();
assert_model_as_evaluated_afresh(&s);
let before = s.model.recomputed;
s.model.evaluate(&s.doc);
assert_eq!(s.model.recomputed, before, "the adopted model is current");
assert_model_as_evaluated_afresh(&s);
}
/// A cut revolved about an edge of a mirrored copy turns about it where the copy now is, when
/// a joint drive moves the copy: the axis is a pick of the model, not a body by name (#12139).
/// (Not checked against a fresh evaluation: one without the cache finds no edge at the pick.)
#[test]
fn a_revolve_about_a_mirrored_copys_edge_follows_a_joint_drive() {
let mut s = Session::default();
moved_arm(&mut s);
run(
&mut s,
"joint.as_built",
json!({"type": "revolute", "name": "Pan", "a": 0, "b": "Arm", "at": {"occurrence": 0, "point": [0, 0, 0], "z": [0, 0, 1]}}),
);
let copy = created(&run(&mut s, "solid.mirror", json!({"bodies": ["ArmBody"], "plane": "YZ"})));
// An edge of the copy along X, on its lowest Y and Z.
let (lo, hi) = world_box(&s, &copy);
let edge = [(lo.x + hi.x) / 2., lo.y, lo.z];
let (x, y) = (edge[0], lo.y - 30.);
run(&mut s, "solid.box", json!({"corner": [x - 50., y - 30., -40], "length": 100, "width": 60, "height": 80, "body_name": "Block"}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [x - 5., y], "p1": [x + 5., y + 10.]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.revolve", json!({"axis": {"edge": edge}, "angle": "360 deg", "operation": "cut", "targets": ["Block"]}));
let block = |s: &Session| s.model.state().body("Block").unwrap().mesh().positions.clone();
let before = block(&s);
for angle in [5, -5] {
run(&mut s, "joint.drive", json!({"joint": "Pan", "value": angle, "quiet": true}));
assert_eq!(s.model.last_recomputed + s.model.last_restored, 2, "the mirror and the revolve at {angle}°");
let after = block(&s);
assert_ne!(after, before, "the cut turned about the old axis at {angle}°");
}
}
+1 -1
View File
@@ -93,7 +93,7 @@ impl State {
State::Failed => "failed",
}
}
pub(crate) fn label(self) -> &'static str {
fn label(self) -> &'static str {
tr(match self {
State::Off => "ui.agent_chat_state_off",
State::Starting => "ui.agent_chat_state_starting",
+24 -115
View File
@@ -6,7 +6,6 @@ use crate::fonts::RichTextExt as _;
use crate::ui_kit::BackingTooltipExt as _;
use crate::ui_kit::{Container, Edge, Surface};
use egui::{Align, Align2, Color32, Id, Layout, Rect, RichText, Sense, Stroke, pos2, vec2};
use solidcraft_engine::panels::Flow;
use super::*;
use crate::ui_kit;
@@ -241,7 +240,7 @@ pub(crate) fn finish_shared(app: &mut SolveApp, ctx: &egui::Context) {
}
/// Existing Assistant controls and conversation, inside the shared host's body.
pub(crate) fn shared_content(app: &mut SolveApp, ui: &mut egui::Ui, flow: Flow) {
pub(crate) fn shared_content(app: &mut SolveApp, ui: &mut egui::Ui) {
let rect = ui.max_rect().intersect(ui.clip_rect());
if !shown(app) || !app.agent_chat.open || !rect.is_finite() || !rect.is_positive() {
return;
@@ -249,7 +248,7 @@ pub(crate) fn shared_content(app: &mut SolveApp, ui: &mut egui::Ui, flow: Flow)
app.agent_chat.panel_rect = Some(rect);
app.agent_chat.drop_rect = Some(rect);
crate::scenario::publish_rect("agent_chat:panel", rect);
ui_kit::scope(ui, |ui| content(app, ui, flow));
ui_kit::scope(ui, |ui| content(app, ui));
}
/// Horizontal room reserved before the history and browser when Assistant is on the left.
@@ -905,12 +904,10 @@ fn dock_drag(app: &mut SolveApp, ui: &egui::Ui, drag: &egui::Response, left: boo
fn body(app: &mut SolveApp, ui: &mut egui::Ui, left: bool) {
header(app, ui, left);
content(app, ui, Flow::Tall);
content(app, ui);
}
fn content(app: &mut SolveApp, ui: &mut egui::Ui, flow: Flow) {
#[cfg(target_arch = "wasm32")]
let _ = flow;
fn content(app: &mut SolveApp, ui: &mut egui::Ui) {
#[cfg(not(target_arch = "wasm32"))]
if !crate::panel_runtime::shared_content_measurement(ui.ctx()) {
ui_kit::assistant_controls(ui, |ui| switcher(app, ui));
@@ -920,61 +917,17 @@ fn content(app: &mut SolveApp, ui: &mut egui::Ui, flow: Flow) {
ui.label(tr("ui.agent_chat_desktop_only"));
}
#[cfg(not(target_arch = "wasm32"))]
desktop_body(app, ui, flow);
desktop_body(app, ui);
}
#[cfg(not(target_arch = "wasm32"))]
fn desktop_body(app: &mut SolveApp, ui: &mut egui::Ui, flow: Flow) {
fn desktop_body(app: &mut SolveApp, ui: &mut egui::Ui) {
let top = ui.cursor().top();
let shared = crate::panel_runtime::shared_content_measurement(ui.ctx());
if app.agent_chat.detection.is_none() && app.agent_chat.detecting.is_none() {
refresh(app, ui.ctx());
}
let state = app.agent_chat.state();
let bounds = ui.available_rect_before_wrap();
let gap = Tokens::get().space_8;
// Compact wide cards keep the existing stack until both regions fit.
let wide = shared && flow == Flow::Wide && bounds.width() >= MIN_WIDTH * 2.0 + gap;
let (interaction_bounds, transcript_bounds) = if wide {
let width = (bounds.width() - gap) / 2.0;
let left = Rect::from_min_size(bounds.min, vec2(width, bounds.height()));
let right = Rect::from_min_max(pos2(bounds.right() - width, bounds.top()), bounds.max);
if crate::i18n::panel_direction(&app.preferences.language) == solidcraft_engine::panels::Direction::Rtl {
(left, Some(right))
} else {
(right, Some(left))
}
} else {
(bounds, None)
};
let root = ui.scope_id();
// Orientation changes rectangles, never the retained region identities.
let mut interaction = shared.then(|| {
let mut child = ui.new_child(egui::UiBuilder::new().scope_id(root.with("assistant-interaction")).max_rect(interaction_bounds));
child.set_clip_rect(ui.clip_rect().intersect(interaction_bounds));
child
});
let (top_height, outcome, remaining) = {
let column = interaction.as_mut().unwrap_or(&mut *ui);
let top_height = top_controls(app, column, state, shared);
let outcome = composer(app, column, state);
(top_height, outcome, column.available_rect_before_wrap())
};
let mut transcript = shared.then(|| {
let bounds = transcript_bounds.unwrap_or(remaining);
let mut child = ui.new_child(egui::UiBuilder::new().scope_id(root.with("assistant-transcript")).max_rect(bounds));
child.set_clip_rect(ui.clip_rect().intersect(bounds));
child
});
let transcript_height = transcript_body(app, transcript.as_mut().unwrap_or(&mut *ui));
let interaction_height = top_height + outcome.height;
let height = if wide { interaction_height.max(transcript_height) } else { interaction_height + transcript_height };
crate::panel_runtime::report_content_extent(ui, top, height, false);
finish_composer(app, ui.ctx(), outcome);
}
#[cfg(not(target_arch = "wasm32"))]
fn new_chat_confirmation(app: &mut SolveApp, ui: &mut egui::Ui) {
if app.agent_chat.confirm_new {
ui_kit::scope(ui, |ui| {
inset(ui, |ui| {
@@ -982,7 +935,7 @@ fn new_chat_confirmation(app: &mut SolveApp, ui: &mut egui::Ui) {
ui_kit::text(ui, tr("ui.agent_chat_new_confirm"), ui_kit::Text::Report("new_chat", false));
ui.horizontal(|ui| {
let yes = ui_kit::button(ui, tr("ui.agent_chat_new"), ui_kit::Button::Normal);
crate::scenario::publish_target("agent_chat:new_confirm", yes.rect);
crate::scenario::publish_handle("agent_chat:new_confirm", yes.rect.center());
if yes.clicked() {
new_chat(app);
}
@@ -993,23 +946,6 @@ fn new_chat_confirmation(app: &mut SolveApp, ui: &mut egui::Ui) {
});
});
}
}
#[cfg(not(target_arch = "wasm32"))]
fn top_controls(app: &mut SolveApp, ui: &mut egui::Ui, state: State, shared: bool) -> f32 {
let top = ui.cursor().top();
let reveal_confirmation = shared && {
let id = ui.scope_id().with("assistant-confirm-visible");
let visible = app.agent_chat.confirm_new;
ui.data_mut(|data| {
let previous = data.get_temp::<bool>(id).unwrap_or(false);
data.insert_temp(id, visible);
visible && !previous
})
};
if !shared {
new_chat_confirmation(app, ui);
}
// Shared top controls must yield scroll space to the pinned composer, including
// when discovery lists several agents or a long message grows the editor.
let picker_max = if shared {
@@ -1020,41 +956,24 @@ fn top_controls(app: &mut SolveApp, ui: &mut egui::Ui, state: State, shared: boo
} else {
ui.available_height() / 3.0
};
let provider_scroll = egui::ScrollArea::vertical()
let picker = egui::ScrollArea::vertical()
.id_salt("sc_agent_chat_picker_scroll")
.min_scrolled_height(0.0)
.max_height(picker_max)
.auto_shrink([false, true]);
let provider_scroll = if reveal_confirmation { provider_scroll.vertical_scroll_offset(0.0) } else { provider_scroll };
let picker = provider_scroll.show(ui, |ui| {
crate::scenario::publish_rect("agent_chat:providers", ui.clip_rect());
if shared {
new_chat_confirmation(app, ui);
ui_kit::assistant_controls(ui, |ui| switcher(app, ui));
}
picker(app, ui);
if shared && state == State::NeedsAuth {
inset(ui, |ui| auth_box(app, ui));
}
});
.auto_shrink([false, true])
.show(ui, |ui| {
if shared {
ui_kit::assistant_controls(ui, |ui| switcher(app, ui));
}
picker(app, ui);
if shared && state == State::NeedsAuth {
inset(ui, |ui| auth_box(app, ui));
}
});
if !shared && state == State::NeedsAuth {
inset(ui, |ui| auth_box(app, ui));
}
ui.cursor().top() - top - picker.inner_rect.height().min(picker.content_size.y) + picker.content_size.y
}
#[cfg(not(target_arch = "wasm32"))]
struct ComposerOutcome {
height: f32,
send_clicked: bool,
stop_clicked: bool,
enter: bool,
steer: bool,
esc: bool,
}
#[cfg(not(target_arch = "wasm32"))]
fn composer(app: &mut SolveApp, ui: &mut egui::Ui, state: State) -> ComposerOutcome {
let top_height = ui.cursor().top() - top - picker.inner_rect.height().min(picker.content_size.y) + picker.content_size.y;
// The input sits at the bottom of the panel and grows upwards: a long message makes its box
// taller up to `edit_max`, then it scrolls, and the conversation takes what is left above. It
// is a panel of its own at the bottom, as high as its content this frame, so the buttons under
@@ -1229,11 +1148,6 @@ fn composer(app: &mut SolveApp, ui: &mut egui::Ui, state: State) -> ComposerOutc
if pick_clicked {
start_pick(app);
}
ComposerOutcome { height: composer.inner, send_clicked, stop_clicked, enter, steer, esc }
}
#[cfg(not(target_arch = "wasm32"))]
fn transcript_body(app: &mut SolveApp, ui: &mut egui::Ui) -> f32 {
let conversation = egui::ScrollArea::vertical()
.id_salt("sc_agent_chat_scroll")
.min_scrolled_height(0.0)
@@ -1249,12 +1163,7 @@ fn transcript_body(app: &mut SolveApp, ui: &mut egui::Ui) -> f32 {
scroll_while_selecting(ui);
});
crate::scenario::publish_rect("agent_chat:conversation", conversation.inner_rect);
conversation.content_size.y
}
#[cfg(not(target_arch = "wasm32"))]
fn finish_composer(app: &mut SolveApp, ctx: &egui::Context, outcome: ComposerOutcome) {
let ComposerOutcome { send_clicked, stop_clicked, enter, steer, esc, .. } = outcome;
crate::panel_runtime::report_content_extent(ui, top, top_height + composer.inner + conversation.content_size.y, false);
if esc || stop_clicked {
cancel(app);
}
@@ -1265,13 +1174,13 @@ fn finish_composer(app: &mut SolveApp, ctx: &egui::Context, outcome: ComposerOut
// Enter waits with the button.
} else if (send_clicked || enter || steer) && something {
let text = std::mem::take(&mut app.agent_chat.input).trim_end_matches('\n').to_string();
send(app, ctx, text);
send(app, ui.ctx(), text);
if steer {
send_now(app, ctx);
send_now(app, ui.ctx());
}
} else if steer {
// Nothing typed: Ctrl+Enter sends what is already queued.
send_now(app, ctx);
send_now(app, ui.ctx());
}
}
@@ -2641,7 +2550,7 @@ mod tests {
ui.set_width(width);
ui_kit::scope(ui, |ui| {
header(&mut app, ui, false);
desktop_body(&mut app, ui, Flow::Tall);
desktop_body(&mut app, ui);
});
});
output.textures_delta.clear();
-246
View File
@@ -4762,249 +4762,3 @@ fn a_picture_only_prompt_and_a_thought_have_no_copy_message_action() {
assert!(crate::scenario::handle_at("agent_chat:copy:1").is_none(), "a thought has no copy action");
assert!(h.widget("Copy message").is_none(), "nothing to click");
}
fn shared_assistant_harness() -> (crate::scenario::Harness, Script) {
let mut h = narrow_chat();
h.app.home.open = false;
h.app.ui.show_browser = false;
h.app.ui.show_timeline = false;
h.app.preferences.ai_features = true;
h.app.preferences.agent_chat_popped = false;
h.app.preferences.language = "en".into();
let script = Script::default();
h.app.agent_chat.backend = Some(Box::new(script.clone()));
h.app.agent_chat.state = Some(State::Ready);
h.app.agent_chat.input = "Retained responsive composer draft".into();
h.app.agent_chat.items.push(Item::Agent { thought: false, text: "A retained conversation".into() });
h.app.run("sketch.create", json!({"plane":"XY","name":"Retained profile"})).unwrap();
h.app.run("sketch.finish", json!({})).unwrap();
let feature = h.app.session.doc.features[0].id;
h.app.run("select.set", json!({"items":[solidcraft_engine::Sel::Feature { id: feature }]})).unwrap();
h.app.preferences.shared_panel_layout = true;
h.frames(6);
assert!(h.app.panel_runtime().is_some());
assert!(h.app.agent_chat.detecting.is_none(), "the deterministic discovery fixture starts no worker");
assert!(script.calls().is_empty());
(h, script)
}
fn shared_assistant_float(h: &mut crate::scenario::Harness, width: f32, height: f32) {
use solidcraft_engine::panels::{Destination, InstanceId, LayoutAction, Mutation, Point, SectionId, SectionLocation, Size};
let instance = InstanceId::new("assistant").unwrap();
let source = h.app.panel_runtime().unwrap().layout().layout().containing(&instance).unwrap();
let origin = Point { x: 300.0, y: 200.0 };
let size = Size { width, height };
let mutation = if matches!(source.location, SectionLocation::Floating { .. }) {
Mutation::SetFloatingRect { section: source.id.clone(), origin, size }
} else {
Mutation::Move {
instance,
destination: Destination::NewSection {
section: SectionId::new("responsive-assistant").unwrap(),
index: 0,
location: SectionLocation::Floating { origin },
size,
},
}
};
h.app.dispatch_panel_layout(LayoutAction::Apply { expected_revision: None, mutations: vec![mutation] }).unwrap();
h.frames(6);
}
fn shared_assistant_body(h: &crate::scenario::Harness) -> egui::Rect {
let body = h.app.universal_panel_frame().unwrap().layout.sections.iter().find(|panel| panel.instance.0 == "assistant").unwrap().body.unwrap();
egui::Rect::from_min_size(egui::pos2(body.origin.x, body.origin.y), egui::vec2(body.size.width, body.size.height))
}
#[test]
fn shared_assistant_wide_and_compact_present_real_regions_without_losing_the_composer_selection() {
use solidcraft_engine::panels::{Destination, InstanceId, LayoutAction, Mutation, SectionId, SectionLocation, Size, Zone};
let (mut h, script) = shared_assistant_harness();
let input = egui::Id::unique("sc_agent_chat_input");
h.ctx.memory_mut(|memory| memory.request_focus(input));
h.frames(2);
let mut editor = egui::TextEdit::load_state(&h.ctx, input).unwrap();
editor.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(2), egui::text::CCursor::new(10))));
editor.store(&h.ctx, input);
h.frame();
let caret = egui::TextEdit::load_state(&h.ctx, input).unwrap().cursor.char_range();
assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(input), "the docked composer starts focused");
let response = h.ctx.read_response(input).unwrap();
assert!(response.enabled() && response.interact_rect.is_positive(), "the focused composer is live and interactable");
assert!(!caret.unwrap().is_empty(), "exercise a real selection before moving the panel");
let draft = h.app.agent_chat.input.clone();
let document = h.app.session.doc.clone();
let model = h.app.session.model.state();
let selection = h.app.session.selection.clone();
let camera = h.app.cam;
let session = h.app.session.uid;
let revision = h.app.session.revision;
let undo = h.app.session.undo.len();
let dirty = h.app.session.is_dirty();
let controllers = h.app.panel_runtime().unwrap().controller_count();
for (width, height, wide) in [(900.0, 420.0, true), (500.0, 340.0, false)] {
shared_assistant_float(&mut h, width, height);
let body = shared_assistant_body(&h);
assert!(body.width() > body.height(), "both cases exercise the Wide host flow");
let conversation = crate::scenario::rect_of("agent_chat:conversation").unwrap();
let composer = crate::scenario::rect_of("agent_chat:composer_footer").unwrap();
let switcher = crate::scenario::rect_of("agent_chat:switcher").unwrap();
assert!(body.expand(1.0).contains_rect(conversation));
assert!(body.expand(1.0).contains_rect(composer));
assert!(body.expand(1.0).contains_rect(switcher));
if wide {
assert!(conversation.right() <= composer.left(), "a viable Wide body puts the real transcript beside its composer");
assert!(conversation.right() <= switcher.left(), "provider controls share the composer's column");
assert!(conversation.height() > composer.height(), "the transcript can use the full height of its own column");
} else {
assert!(conversation.bottom() <= composer.top() + 1.0, "a compact Wide body falls back to the existing vertical composition");
assert!(conversation.left() < composer.right() && composer.left() < conversation.right());
}
assert_eq!(h.app.agent_chat.input, draft);
assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(input));
assert_eq!(egui::TextEdit::load_state(&h.ctx, input).unwrap().cursor.char_range(), caret);
}
h.app.preferences.language = "ar".into();
shared_assistant_float(&mut h, 900.0, 420.0);
let conversation = crate::scenario::rect_of("agent_chat:conversation").unwrap();
let composer = crate::scenario::rect_of("agent_chat:composer_footer").unwrap();
assert!(composer.right() <= conversation.left(), "Wide mirrors the two real regions in RTL");
assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(input), "RTL keeps the composer focused");
assert_eq!(egui::TextEdit::load_state(&h.ctx, input).unwrap().cursor.char_range(), caret);
h.app.preferences.language = "en".into();
h.app
.dispatch_panel_layout(LayoutAction::Apply {
expected_revision: None,
mutations: vec![Mutation::Move {
instance: InstanceId::new("assistant").unwrap(),
destination: Destination::NewSection {
section: SectionId::new("responsive-assistant-side").unwrap(),
index: 0,
location: SectionLocation::Dock { zone: Zone::Right },
size: Size { width: 360.0, height: 420.0 },
},
}],
})
.unwrap();
h.frames(6);
let conversation = crate::scenario::rect_of("agent_chat:conversation").unwrap();
let composer = crate::scenario::rect_of("agent_chat:composer_footer").unwrap();
assert!(conversation.bottom() <= composer.top() + 1.0);
assert_eq!(h.app.agent_chat.input, draft);
assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(input));
assert_eq!(egui::TextEdit::load_state(&h.ctx, input).unwrap().cursor.char_range(), caret);
assert_eq!(h.app.session.uid, session);
assert!(Arc::ptr_eq(&h.app.session.doc, &document));
assert!(Arc::ptr_eq(&h.app.session.model.state(), &model));
assert_eq!(h.app.session.selection, selection);
assert_eq!(h.app.cam, camera);
assert_eq!(h.app.session.revision, revision);
assert_eq!(h.app.session.undo.len(), undo);
assert_eq!(h.app.session.is_dirty(), dirty);
assert_eq!(h.app.panel_runtime().unwrap().controller_count(), controllers);
assert!(script.calls().is_empty(), "changing presentation must not submit, cancel or restart the retained service");
}
#[test]
fn shared_assistant_transcript_keeps_its_scroll_position_through_responsive_width_changes() {
use solidcraft_engine::panels::{Destination, InstanceId, LayoutAction, Mutation, SectionId, SectionLocation, Size, Zone};
let (mut h, script) = shared_assistant_harness();
h.app.agent_chat.items = (0..80).map(|n| Item::Agent { thought: false, text: format!("Parts list row {n}") }).collect();
shared_assistant_float(&mut h, 900.0, 420.0);
assert_eq!(rows_in_view(&h).last().unwrap().0, 79);
let conversation = crate::scenario::rect_of("agent_chat:conversation").unwrap();
h.call("ui.scroll", json!({"x":conversation.center().x,"y":conversation.center().y,"delta":1200}));
h.frames(30);
let before = rows_in_view(&h).first().unwrap().0;
assert!(before > 0 && before < 70, "exercise a middle position, not an automatic endpoint");
for width in [1040.0, 900.0] {
shared_assistant_float(&mut h, width, 420.0);
let after = rows_in_view(&h).first().unwrap().0;
assert!(before.abs_diff(after) <= 1, "fixed-height short messages keep their scroll anchor across a width change: {before} → {after}");
assert!(!rows_in_view(&h).iter().any(|row| row.0 == 79), "a reparent must not reset stick-to-bottom");
}
h.app
.dispatch_panel_layout(LayoutAction::Apply {
expected_revision: None,
mutations: vec![Mutation::Move {
instance: InstanceId::new("assistant").unwrap(),
destination: Destination::NewSection {
section: SectionId::new("scrolled-assistant-side").unwrap(),
index: 0,
location: SectionLocation::Dock { zone: Zone::Right },
size: Size { width: 360.0, height: 420.0 },
},
}],
})
.unwrap();
h.frames(6);
let after = rows_in_view(&h).first().unwrap().0;
assert!(before.abs_diff(after) <= 1, "returning to Tall retains the same transcript scroll anchor: {before} → {after}");
assert!(script.calls().is_empty());
assert!(h.app.agent_chat.backend.is_some());
}
#[test]
fn shared_assistant_header_uses_live_status_and_one_click_starts_one_new_chat() {
use crate::panel_runtime::{HostControlTarget, HostPointerTarget};
use solidcraft_engine::panels::{ControlId, InstanceId};
let (mut h, script) = shared_assistant_harness();
shared_assistant_float(&mut h, 900.0, 420.0);
let assistant = InstanceId::new("assistant").unwrap();
for state in [State::Ready, State::Busy, State::NeedsAuth, State::Exited, State::Failed] {
h.app.agent_chat.state = Some(state);
h.frames(2);
let presentation = h.app.panel_runtime().unwrap().instance_presentation(&assistant).unwrap();
assert_eq!(presentation.status.as_deref(), Some(state.label()));
assert_eq!(presentation.attention, matches!(state, State::NeedsAuth | State::Exited | State::Failed));
}
h.app.agent_chat.state = Some(State::Ready);
h.app.agent_chat.items.push(Item::Permission { key: 42, title: "Review this local request".into(), options: Vec::new(), answer: None });
h.frames(2);
let presentation = h.app.panel_runtime().unwrap().instance_presentation(&assistant).unwrap();
assert_eq!(presentation.status.as_deref(), Some(trf("ui.assistant_waiting", &[("count", "1")]).as_str()));
assert!(presentation.attention);
assert_eq!(h.app.agent_chat.waiting(), 1, "rendering status never answers the pending request");
h.app.agent_chat.items.pop();
h.app.agent_chat.pending.push(Pending {
text: "Keep this queued request until confirmation".into(),
image: false,
context: Context::default(),
files: Vec::new(),
});
h.app.preferences.agents =
(0..24).map(|n| agents::AgentConfig { id: format!("review-{n}"), name: format!("Review provider {n}"), ..Default::default() }).collect();
h.app.agent_chat.picking = Some(true);
h.frames(4);
let providers = crate::scenario::rect_of("agent_chat:providers").unwrap();
assert!(providers.contains(crate::scenario::handle_at("agent_chat:pick:review-0").unwrap()));
h.call("ui.scroll", json!({"x":providers.center().x,"y":providers.center().y,"delta":-900}));
h.frames(30);
let providers = crate::scenario::rect_of("agent_chat:providers").unwrap();
let switcher = crate::scenario::rect_of("agent_chat:switcher").unwrap();
assert!(
switcher.bottom() < providers.top() - solidcraft_theme::Tokens::get().control_height * 4.0,
"the real provider scroller is far from its top before New Chat"
);
assert!(script.calls().is_empty(), "scrolling the deterministic provider list starts no agent");
let target = HostControlTarget::Pointer(HostPointerTarget::HeaderAction { instance: assistant, action: ControlId::new("new-chat").unwrap() });
let control = h.app.universal_panel_frame().unwrap().controls.iter().find(|control| control.target == target).unwrap();
let at = egui::pos2(control.bounds.origin.x + control.bounds.size.width / 2.0, control.bounds.origin.y + control.bounds.size.height / 2.0);
click_at(&mut h, at, egui::PointerButton::Primary);
h.frames(4);
assert!(h.app.agent_chat.confirm_new);
assert_eq!(h.app.agent_chat.pending.len(), 1);
assert!(script.calls().is_empty(), "opening confirmation cannot invoke New Chat early");
let confirm = crate::scenario::rect_of("agent_chat:new_confirm").unwrap();
let providers = crate::scenario::rect_of("agent_chat:providers").unwrap();
let composer = crate::scenario::rect_of("agent_chat:composer_footer").unwrap();
assert!(providers.expand(1.0).contains_rect(confirm), "a newly requested confirmation is fully visible inside the actual provider clip");
assert!(shared_assistant_body(&h).contains_rect(confirm));
assert!(confirm.left() >= composer.left(), "the real confirmation remains reachable in the interaction column");
click_at(&mut h, confirm.center(), egui::PointerButton::Primary);
h.frames(4);
assert_eq!(script.calls().iter().filter(|call| call.as_str() == "new_session").count(), 1);
assert!(!script.calls().iter().any(|call| call.starts_with("prompt:") || call == "cancel"));
assert_eq!(h.app.agent_chat.input, "Retained responsive composer draft");
assert!(h.app.agent_chat.items.is_empty());
}
+1 -1
View File
@@ -97,7 +97,7 @@ fn every_axis_is_drawn_in_one_set_of_colours() {
for (name, d, want) in [("x", Vec3::X, t.axis_x), ("y", Vec3::Y, t.axis_y), ("z", Vec3::Z, t.axis_z)] {
let at = c + vec2(32.0, 0.0);
let mut out =
ctx.run_ui(egui::RawInput::default(), |ui| crate::icons::navigation::axis_tip(ui.painter(), c, name, d.into(), at, 0.5, false, t));
ctx.run_ui(egui::RawInput::default(), |ui| crate::icons::navigation::axis_tip(ui.painter(), c, name, &d, at, 0.5, false, t));
out.textures_delta.clear();
let end = out.shapes.iter().find_map(|s| match &s.shape {
egui::Shape::Circle(e) if e.center == at => Some(e.fill),
-2
View File
@@ -192,7 +192,6 @@ mod file_load_ui_tests;
#[cfg(test)]
mod fillet_spline_handle_tests;
pub mod mouse_gesture;
mod nav_bar;
pub(crate) mod navigation;
pub mod navigation_bar;
mod navigation_bar_view;
@@ -319,7 +318,6 @@ mod ui_kit_guard_tests;
pub mod update;
mod value_step;
pub(crate) mod view_angles;
pub mod view_compass;
mod view_confirm;
pub mod view_gizmo;
pub mod view_pick;
-386
View File
@@ -1,386 +0,0 @@
//! The navigation bar as a component. The app measures the row, describes each slot and each
//! menu as plain data ([`Bar`], [`SlotView`], [`Menu`]) and runs the [`NavAction`] the bar
//! returns; the bar keeps which menu is open and reads nothing of the app. Every slot, menu row
//! and context item publishes its rect to the widget registry while it is drawn.
use crate::ui_kit::{self, BackingTooltipExt as _, Container, NavigationEdge, NavigationIdle, NavigationKind, NavigationSize, Surface};
use egui::{Align2, Rect, pos2, vec2};
/// One slot as the bar draws it.
#[derive(Clone, Debug)]
pub struct SlotView<'a> {
/// Where the slot is in the stored list (what Remove removes).
pub index: usize,
pub id: &'a str,
pub kind: NavigationKind,
pub icon: &'a str,
/// The text on the button (or beside it, for a menu showing its value).
pub text: String,
/// The slot's name for assistive technology.
pub name: String,
/// Hover text, and the shortcut shown with it.
pub tip: String,
pub key: Option<String>,
/// Its tool or toggle is on.
pub selected: bool,
/// Inert for now (drawn faded, still clickable).
pub enabled: bool,
/// Editing only: the slot is not shown on this canvas.
pub hidden: bool,
/// The button shows its text.
pub show_text: bool,
/// Where it is drawn (unused for a slot in the More menu).
pub rect: Rect,
/// A second registry name its handle is published under, wherever it is drawn.
pub alias: Option<&'a str>,
}
/// Labels of the bar's own controls, in the app's language.
#[derive(Clone, Debug, Default)]
pub struct BarLabels {
pub more: String,
pub customise: String,
pub hide: String,
pub reset: String,
pub remove: String,
}
/// A measured bar and its slots.
#[derive(Clone, Debug)]
pub struct Bar<'a> {
pub rect: Rect,
pub size: NavigationSize,
pub edge: NavigationEdge,
pub idle: NavigationIdle,
/// The app's reasons to show the bar at full strength (the view is being turned, the pointer
/// is over the view compass); the pointer over the bar and an open menu count on their own.
pub active: bool,
pub slots: Vec<SlotView<'a>>,
/// The slots folded into More, in order.
pub overflow: Vec<SlotView<'a>>,
/// The More button (`Rect::NOTHING`: nothing folded).
pub more: Rect,
pub labels: BarLabels,
}
/// A menu a slot opens.
#[derive(Clone, Debug, Default)]
pub struct Menu {
pub entries: Vec<MenuEntry>,
/// A pick closes the menu (otherwise it stays open for the next).
pub close_on_pick: bool,
}
#[derive(Clone, Debug)]
pub enum MenuEntry {
Heading(String),
Separator,
Row(MenuRow),
}
/// One row of a slot's menu.
#[derive(Clone, Debug, Default)]
pub struct MenuRow {
pub id: String,
pub label: String,
pub icon: String,
pub selected: bool,
pub enabled: bool,
/// Text at the row's end (a current value).
pub detail: Option<String>,
pub tip: Option<String>,
/// The registry name its rect is published under.
pub target: String,
/// Rows of the menu this row opens (empty: the row is picked itself).
pub submenu: Vec<MenuRow>,
}
impl MenuRow {
pub fn new(id: impl Into<String>, label: impl Into<String>, icon: impl Into<String>, target: impl Into<String>) -> MenuRow {
MenuRow { id: id.into(), label: label.into(), icon: icon.into(), enabled: true, target: target.into(), ..MenuRow::default() }
}
}
/// An item of the bar's context menu.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ContextItem {
Customise,
Hide,
Reset,
/// Remove the slot at this index of the stored list.
Remove(usize),
}
/// What the user chose on the bar this frame.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum NavAction {
/// A slot that is not a menu was clicked (on the bar or in More).
Slot(String),
/// Row `item` of the menu of slot `menu` (or of its submenu) was picked.
Item {
menu: String,
item: String,
},
Context(ContextItem),
}
/// Paint the bar's surface and its slots; the slots' responses in order. The live bar and the
/// Preferences editor (`editing`: the selected slot's index, if any) share it.
pub fn paint_slots(
ui: &mut egui::Ui,
rect: Rect,
size: NavigationSize,
edge: NavigationEdge,
slots: &[SlotView<'_>],
editing: Option<Option<usize>>,
) -> Vec<egui::Response> {
ui_kit::navigation_surface(ui, rect, size, editing.is_some());
let mut responses = Vec::new();
for slot in slots {
let response = ui
.push_id(slot.index, |ui| {
ui.scope(|ui| {
if slot.hidden {
ui.set_opacity(ui_kit::NAV_HIDDEN_OPACITY);
} else if !slot.enabled {
ui.set_opacity(ui.visuals().disabled_alpha);
}
if slot.kind == NavigationKind::Separator {
ui_kit::navigation_separator(ui, slot.rect);
ui.interact(
slot.rect,
ui.scope_id().with("separator"),
if editing.is_some() { egui::Sense::click_and_drag() } else { egui::Sense::click() },
)
} else {
let button = ui_kit::NavigationButton {
id: slot.id,
kind: slot.kind,
icon: slot.icon,
label: &slot.text,
selected: slot.selected,
labels: slot.show_text,
menu: slot.kind == NavigationKind::Menu,
};
if editing.is_some() {
ui_kit::navigation_button_editable(ui, slot.rect, button, true)
} else {
ui_kit::navigation_button(ui, slot.rect, button)
}
}
})
.inner
})
.inner;
let chosen = editing.map_or(slot.selected, |selected| selected == Some(slot.index));
response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), chosen, &slot.name));
if editing.is_some() {
ui_kit::navigation_edit_outline(ui, slot.rect, slot.hidden, chosen);
}
ui_kit::navigation_tooltip(&response, rect, edge, &slot.tip, slot.key.as_deref());
responses.push(response);
}
responses
}
/// Draw the live bar, its More button, its context menu and the open menu (`menu` gives a menu
/// slot's rows by its id); what the user chose, for the app to run.
pub fn show(ui: &mut egui::Ui, bar: &Bar<'_>, menu: impl FnOnce(&str) -> Menu) -> Option<NavAction> {
if !bar.rect.is_positive() {
return None;
}
solidcraft_ui_registry::publish_rect("nav:outer", bar.rect);
let menu_id = ui.scope_id().with("navigation_bar_menu");
let mut open = ui.data(|d| d.get_temp::<String>(menu_id)).unwrap_or_default();
let mut action = None;
let active = bar.active || ui.rect_contains_pointer(bar.rect) || !open.is_empty();
let opacity = ui_kit::navigation_opacity(bar.idle, active);
let background = ui.interact(bar.rect, ui.scope_id().with("navigation_bar_context"), egui::Sense::click());
let opacity = if bar.idle == NavigationIdle::Hide {
ui.ctx().animate_value_with_time(menu_id.with("opacity"), opacity, if active { ui_kit::NAV_FADE_SECONDS } else { 0.0 })
} else {
opacity
};
let mut anchor = bar.more;
ui.scope(|ui| {
ui.set_opacity(opacity);
let shown: Vec<_> = bar.slots.iter().map(|s| SlotView { selected: s.selected || open == s.id, ..s.clone() }).collect();
for (slot, response) in shown.iter().zip(paint_slots(ui, bar.rect, bar.size, bar.edge, &shown, None)) {
solidcraft_ui_registry::publish_target(&format!("nav:slot:{}", slot.id), response.rect);
if let Some(alias) = slot.alias {
solidcraft_ui_registry::publish_handle(alias, response.rect.center());
}
context_menu(&response, Some(slot.index), &bar.labels, &mut action);
if response.clicked() && slot.kind != NavigationKind::Separator {
if slot.kind == NavigationKind::Menu {
open = if open == slot.id { String::new() } else { slot.id.to_owned() };
} else {
action = Some(NavAction::Slot(slot.id.to_owned()));
}
}
if open == slot.id {
anchor = response.rect;
}
}
if bar.more.is_positive() {
let response = ui_kit::navigation_button(
ui,
bar.more,
ui_kit::NavigationButton {
id: "more",
kind: NavigationKind::Menu,
icon: "dots",
label: &bar.labels.more,
selected: open == "more" || bar.overflow.iter().any(|slot| slot.id == open),
labels: false,
menu: false,
},
);
ui_kit::navigation_tooltip(&response, bar.rect, bar.edge, &bar.labels.more, None);
solidcraft_ui_registry::publish_target("nav:more", response.rect);
context_menu(&response, None, &bar.labels, &mut action);
if response.clicked() {
open = if open == "more" { String::new() } else { "more".to_owned() };
}
}
});
context_menu(&background, None, &bar.labels, &mut action);
if !open.is_empty() && anchor.is_positive() {
let child_popup_open = egui::Popup::is_any_open(ui.ctx());
let ctx = ui.ctx().clone();
// Menus open into the view: up from a bar at the bottom, down from one at the top.
let (pivot, at) = match bar.edge {
NavigationEdge::Bottom => (Align2::RIGHT_BOTTOM, anchor.right_top() - vec2(0.0, ui_kit::NAV_POPUP_GAP)),
NavigationEdge::Top => (Align2::RIGHT_TOP, anchor.right_bottom() + vec2(0.0, ui_kit::NAV_POPUP_GAP)),
};
let contents = if open == "more" { None } else { Some(menu(&open)) };
let popup = Container::new(&ctx, Surface::Popup).area(menu_id.with("popup")).order(egui::Order::Foreground).pivot(pivot).fixed_pos(at).show(
&ctx,
|ui| {
ui_kit::navigation_menu(ui, if open == "more" { ui_kit::NAV_MORE_WIDTH } else { ui_kit::NAV_POPUP_WIDTH }, |ui| match &contents {
None => overflow_menu(ui, bar, &mut open, &mut action),
Some(m) if m.entries.is_empty() => open.clear(),
Some(m) => {
if let Some(item) = menu_rows(ui, &m.entries) {
action = Some(NavAction::Item { menu: open.clone(), item });
if m.close_on_pick {
open.clear();
}
}
}
});
},
);
let outside = !child_popup_open
&& ui.input(|i| {
i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|p| !popup.response.rect.contains(p) && !bar.rect.contains(p))
});
if outside || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
open.clear();
}
}
ui.data_mut(|d| d.insert_temp(menu_id, open));
action
}
/// The context menu of the bar (`index`: `None`) or of a slot: Customise, Hide and Reset on the
/// bar, Remove on a slot.
fn context_menu(response: &egui::Response, index: Option<usize>, labels: &BarLabels, action: &mut Option<NavAction>) {
ui_kit::navigation_context(response, |ui| {
let items: Vec<(&str, &str, ContextItem)> = match index {
None => vec![
("customise", &labels.customise, ContextItem::Customise),
("hide", &labels.hide, ContextItem::Hide),
("reset", &labels.reset, ContextItem::Reset),
],
Some(index) => vec![("remove", &labels.remove, ContextItem::Remove(index))],
};
for (id, label, item) in items {
let row = ui_kit::NavigationMenuRow::new(id, label, "").show(ui);
solidcraft_ui_registry::publish_target(&format!("nav:context:{id}"), row.rect);
if row.clicked() {
*action = Some(NavAction::Context(item));
ui.close();
}
}
});
}
/// More: the folded slots as rows. A menu slot opens its menu in place of More.
fn overflow_menu(ui: &mut egui::Ui, bar: &Bar<'_>, open: &mut String, action: &mut Option<NavAction>) {
for slot in &bar.overflow {
if slot.kind == NavigationKind::Separator {
let start = ui.next_widget_position();
ui_kit::navigation_menu_separator(ui);
let bounds = Rect::from_min_max(start, pos2(start.x + ui.available_width(), ui.next_widget_position().y));
solidcraft_ui_registry::publish_target(&format!("nav:slot:{}", slot.id), bounds);
continue;
}
let mut item = ui_kit::NavigationMenuRow::new(slot.id, &slot.name, slot.icon);
if matches!(slot.kind, NavigationKind::Tool | NavigationKind::Toggle) {
item.selected = slot.selected;
}
let response = item.show(ui);
context_menu(&response, Some(slot.index), &bar.labels, action);
solidcraft_ui_registry::publish_target(&format!("nav:slot:{}", slot.id), response.rect);
if let Some(alias) = slot.alias {
solidcraft_ui_registry::publish_handle(alias, response.rect.center());
}
ui_kit::navigation_tooltip(&response, response.rect, NavigationEdge::Bottom, &slot.tip, slot.key.as_deref());
if response.clicked() {
if slot.kind == NavigationKind::Menu {
*open = slot.id.to_owned();
} else {
*action = Some(NavAction::Slot(slot.id.to_owned()));
open.clear();
}
}
}
}
/// A menu's rows; the id of the row picked, if any.
fn menu_rows(ui: &mut egui::Ui, entries: &[MenuEntry]) -> Option<String> {
let mut picked = None;
for entry in entries {
match entry {
MenuEntry::Heading(title) => ui_kit::navigation_menu_heading(ui, title),
MenuEntry::Separator => ui_kit::navigation_menu_separator(ui),
MenuEntry::Row(row) => {
let response = menu_row(ui, row);
if row.submenu.is_empty() {
if response.clicked() {
picked = Some(row.id.clone());
}
continue;
}
Container::new(ui.ctx(), Surface::Popup).menu(&response).frame(ui_kit::navigation_popup_frame()).show(|ui| {
ui.set_width(ui_kit::NAV_POPUP_WIDTH - 2.0 * (f32::from(ui_kit::NAV_POPUP_PADDING) + ui_kit::NAV_BORDER));
ui.spacing_mut().item_spacing.y = 0.0;
for sub in &row.submenu {
if menu_row(ui, sub).clicked() {
picked = Some(sub.id.clone());
ui.close();
}
}
});
}
}
}
picked
}
fn menu_row(ui: &mut egui::Ui, row: &MenuRow) -> egui::Response {
let mut item = ui_kit::NavigationMenuRow::new(&row.id, &row.label, &row.icon);
item.selected = row.selected;
item.enabled = row.enabled;
item.detail = row.detail.as_deref();
item.submenu = !row.submenu.is_empty();
let response = item.show(ui);
let response = match &row.tip {
Some(tip) => response.backing_on_hover_text(tip),
None => response,
};
if !row.target.is_empty() {
solidcraft_ui_registry::publish_target(&row.target, response.rect);
}
response
}
-183
View File
@@ -622,186 +622,3 @@ fn navigation_board_drops_menu_value_text_before_folding_slots() {
assert!(!folded.values && !folded.overflow.is_empty());
assert_eq!(folded.more.size(), vec2(32.0, 32.0));
}
/// The bar component driven with plain data, no app: what each click returns.
mod component {
use super::*;
use crate::nav_bar::{Bar, ContextItem, Menu, MenuEntry, MenuRow, NavAction, SlotView, show};
use egui::{Event, PointerButton, RawInput};
fn slot(index: usize, id: &'static str, kind: NavigationKind, x: f32) -> SlotView<'static> {
SlotView {
index,
id,
kind,
icon: id,
text: id.to_owned(),
name: id.to_owned(),
tip: id.to_owned(),
key: None,
selected: false,
enabled: true,
hidden: false,
show_text: false,
rect: Rect::from_min_size(pos2(x, 304.0), vec2(26.0, 26.0)),
alias: None,
}
}
/// Fit and Styles on the bar; Zoom and View folded into More.
fn bar() -> Bar<'static> {
Bar {
rect: Rect::from_min_size(pos2(90.0, 300.0), vec2(110.0, 34.0)),
size: Default::default(),
edge: NavigationEdge::Bottom,
idle: NavigationIdle::Always,
active: false,
slots: vec![
slot(0, "fit", NavigationKind::Action, 100.0),
SlotView { alias: Some("test:styles"), ..slot(1, "styles", NavigationKind::Menu, 134.0) },
],
overflow: vec![slot(2, "zoom", NavigationKind::Action, 0.0), slot(3, "view", NavigationKind::Menu, 0.0)],
more: Rect::from_min_size(pos2(168.0, 304.0), vec2(26.0, 26.0)),
labels: Default::default(),
}
}
/// Styles closes on a pick; View stays open and has a submenu.
fn menu(id: &str) -> Menu {
match id {
"styles" => Menu { entries: vec![MenuEntry::Row(MenuRow::new("wire", "Wire", "", "test:wire"))], close_on_pick: true },
"view" => {
let mut preset = MenuRow::new("preset", "Preset", "", "test:preset");
preset.submenu = vec![MenuRow::new("fast", "Fast", "", "test:fast")];
Menu {
entries: vec![
MenuEntry::Heading("Show".into()),
MenuEntry::Row(MenuRow::new("grid", "Grid", "", "test:grid")),
MenuEntry::Separator,
MenuEntry::Row(preset),
],
close_on_pick: false,
}
}
_ => Menu::default(),
}
}
struct Rig {
ctx: egui::Context,
bar: Bar<'static>,
}
impl Rig {
fn new() -> Rig {
let rig = Rig { ctx: crate::fonts::test_context(), bar: bar() };
rig.frame(Vec::new());
rig
}
fn frame(&self, events: Vec<Event>) -> Option<NavAction> {
solidcraft_ui_registry::clear_handles();
let mut action = None;
crate::test_support::frame(
&self.ctx,
RawInput {
screen_rect: Some(Rect::from_min_size(egui::Pos2::ZERO, vec2(400.0, 400.0))),
events,
focused: true,
..Default::default()
},
|ui| action = show(ui, &self.bar, menu),
);
action
}
/// A press and release of `button` at `at`, then two quiet frames so a popup it opened is
/// measured and placed: what the release returned.
fn click_with(&self, at: egui::Pos2, button: PointerButton) -> Option<NavAction> {
let press = |pressed| vec![Event::PointerMoved(at), Event::PointerButton { pos: at, button, pressed, modifiers: Default::default() }];
self.frame(vec![Event::PointerMoved(at)]);
let action = self.frame(press(true)).or(self.frame(press(false)));
self.frame(Vec::new());
self.frame(Vec::new());
action
}
fn click(&self, at: egui::Pos2) -> Option<NavAction> {
self.click_with(at, PointerButton::Primary)
}
fn click_target(&self, name: &str) -> Option<NavAction> {
let at = solidcraft_ui_registry::rect_of(name).expect("the target is drawn").center();
self.click(at)
}
fn drawn(&self, name: &str) -> bool {
solidcraft_ui_registry::rect_of(name).is_some()
}
}
#[test]
fn a_slot_or_a_row_of_its_menu_is_returned_and_a_closing_menu_closes() {
let rig = Rig::new();
assert_eq!(rig.click(pos2(113.0, 317.0)), Some(NavAction::Slot("fit".into())), "an action slot is returned, not run");
assert_eq!(rig.click(pos2(147.0, 317.0)), None, "a menu slot opens its menu");
assert_eq!(solidcraft_ui_registry::handle_at("test:styles"), Some(pos2(147.0, 317.0)), "a slot's alias names it too");
assert_eq!(rig.click_target("test:wire"), Some(NavAction::Item { menu: "styles".into(), item: "wire".into() }));
assert!(!rig.drawn("test:wire"), "a pick closes a menu that asks for it");
}
#[test]
fn more_returns_a_folded_slot_and_opens_a_folded_menu_that_stays_open() {
let rig = Rig::new();
assert_eq!(rig.click_target("nav:more"), None);
assert_eq!(rig.click_target("nav:slot:zoom"), Some(NavAction::Slot("zoom".into())), "a folded slot is returned");
assert!(!rig.drawn("nav:slot:zoom"), "and More closes");
rig.click_target("nav:more");
assert_eq!(rig.click_target("nav:slot:view"), None, "a folded menu slot opens its menu");
assert!(rig.drawn("test:grid") && !rig.drawn("nav:slot:zoom"), "in place of More");
assert_eq!(rig.click_target("test:grid"), Some(NavAction::Item { menu: "view".into(), item: "grid".into() }));
assert!(rig.drawn("test:grid"), "a menu that does not close on a pick stays open");
assert_eq!(rig.click_target("test:preset"), None, "a row with a submenu opens it");
assert_eq!(rig.click_target("test:fast"), Some(NavAction::Item { menu: "view".into(), item: "fast".into() }), "a submenu row by its own id");
}
#[test]
fn the_context_menus_return_customise_hide_reset_and_remove_with_the_slot_index() {
let rig = Rig::new();
for (id, item) in [("customise", ContextItem::Customise), ("hide", ContextItem::Hide), ("reset", ContextItem::Reset)] {
rig.click_with(pos2(94.0, 317.0), PointerButton::Secondary);
assert!(!rig.drawn("nav:context:remove"), "the bar's own menu");
assert_eq!(rig.click_target(&format!("nav:context:{id}")), Some(NavAction::Context(item)));
}
rig.click_with(pos2(147.0, 317.0), PointerButton::Secondary);
assert!(!rig.drawn("nav:context:hide"), "a slot's menu");
assert_eq!(rig.click_target("nav:context:remove"), Some(NavAction::Context(ContextItem::Remove(1))), "removes the slot at its stored index");
rig.click_target("nav:more");
rig.frame(vec![Event::PointerMoved(pos2(0.0, 0.0))]);
let folded = solidcraft_ui_registry::rect_of("nav:slot:view").expect("More is open").center();
rig.click_with(folded, PointerButton::Secondary);
assert_eq!(rig.click_target("nav:context:remove"), Some(NavAction::Context(ContextItem::Remove(3))), "a folded slot too");
}
}
#[test]
fn the_view_menu_slot_is_named_nav_view_on_the_bar_and_in_more() {
let mut h = crate::scenario::Harness::new();
h.app.ui.show_browser = false;
h.app.ui.show_timeline = false;
h.call("ui.prefs", json!({"navigation_bar":{"slots":["display","fit","home"]}}));
h.settle();
let on_bar = solidcraft_ui_registry::handle_at("nav:slot:display").expect("the View slot is on the bar");
assert_eq!(solidcraft_ui_registry::handle_at("nav:view"), Some(on_bar));
// Many slots before it in a narrow view fold the View slot into More.
h.call(
"ui.prefs",
json!({"navigation_bar":{"slots":["pan","fit","home","visual_style","grid","ground_shadow","ambient_occlusion","projection","spin","orbit","zoom","display"]}}),
);
h.call("ui.resize", json!({"width":300,"height":600}));
h.settle();
assert!(solidcraft_ui_registry::handle_at("nav:slot:display").is_none() && solidcraft_ui_registry::handle_at("nav:view").is_none(), "folded");
h.step(&json!({"click":{"handle":"nav:more"}})).unwrap();
let in_more = solidcraft_ui_registry::handle_at("nav:slot:display").expect("More lists the View slot");
assert_eq!(solidcraft_ui_registry::handle_at("nav:view"), Some(in_more));
}
+319 -207
View File
@@ -1,10 +1,10 @@
//! Measured single-row navigation bar. Commands own all view changes.
use crate::nav_bar::{self, Bar, BarLabels, ContextItem, Menu, MenuEntry, MenuRow, NavAction, SlotView};
use crate::navigation::NavCaps;
use crate::navigation_bar::{self, Labels, MenuDisplay, NavigationBar, Position, Slot};
use crate::ui_kit::{self, NavigationEdge, NavigationKind};
use crate::ui_kit::BackingTooltipExt as _;
use crate::ui_kit::{self, Container, NavigationEdge, NavigationIdle, NavigationKind, Surface};
use crate::{SolveApp, viewport::NavMode};
use egui::{Rect, pos2, vec2};
use egui::{Align2, Rect, pos2, vec2};
use serde_json::json;
use solidcraft_theme::Tokens;
@@ -259,62 +259,21 @@ fn activate(app: &mut SolveApp, slot: &Slot) {
}
}
/// The slot's hover text: a hidden slot's reason, the current style, the sketch's lock, or its name.
fn tip(app: &SolveApp, slot: &Slot, hidden: Option<&str>, key: bool) -> String {
if let Some(hidden) = hidden {
fn tooltip(app: &SolveApp, response: egui::Response, slot: &Slot, hidden: Option<&str>, along: Rect, edge: NavigationEdge) -> egui::Response {
let key = slot.command.and_then(|id| crate::keymap::display_for(app, id));
let label = if let Some(hidden) = hidden {
app.tr(hidden).to_owned()
} else if slot.id == "visual_style" {
solidcraft_ui_text::format("navbar.value_tip", &[("name", app.tr(slot.label)), ("value", current_style(app))])
} else if slot.id == "projection" && projection_locked(app) {
app.tr("nav.perspective.sketch").to_owned()
} else if slot.id == "zoom" && key {
} else if slot.id == "zoom" && key.is_some() {
app.tr("navbar.zoom_hold_tip").to_owned()
} else {
app.tr(slot.label).to_owned()
}
}
/// Slot `slot`, at `index` of the stored list, as the bar component draws it.
fn slot_view(app: &SolveApp, bar: &BarLayout, slot: &'static Slot, index: usize, rect: Rect, editing: Option<(NavCaps, bool)>) -> SlotView<'static> {
let hidden = editing
.filter(|(caps, _)| !slot.available(*caps))
.map(|(_, render)| if render { "navbar.custom.hidden_render" } else { "navbar.custom.hidden_design" });
let key = slot.command.and_then(|id| crate::keymap::display_for(app, id));
let value = slot.kind == NavigationKind::Menu
&& slot.id == "visual_style"
&& app.preferences.navigation_bar.menu_display(slot.id) == MenuDisplay::IconAndValue;
SlotView {
index,
id: slot.id,
kind: slot.kind,
icon: slot.current_icon(app.ui.visual_style),
text: slot_text(app, slot).to_owned(),
name: app.tr(slot.label).to_owned(),
tip: tip(app, slot, hidden, key.is_some()),
key,
selected: editing.is_none() && selected(app, slot.id),
enabled: editing.is_some() || !(slot.id == "projection" && projection_locked(app)),
hidden: hidden.is_some(),
show_text: if slot.kind == NavigationKind::Menu { bar.values && value } else { bar.labels },
rect,
// The View menu's slot, by the name older scenarios use.
alias: (slot.id == "display").then_some("nav:view"),
}
}
/// The measured row's slots, each with its index in the stored list (a slot the list no longer
/// holds is left out).
fn slot_views(app: &SolveApp, bar: &BarLayout, editing: Option<(NavCaps, bool)>) -> Vec<SlotView<'static>> {
let mut cursor = 0;
let mut views = Vec::new();
for (slot, rect) in &bar.slots {
let Some((index, _)) = app.preferences.navigation_bar.slots.iter().enumerate().skip(cursor).find(|(_, id)| id.as_str() == slot.id) else {
continue;
};
cursor = index + 1;
views.push(slot_view(app, bar, slot, index, *rect, editing));
}
views
};
ui_kit::navigation_tooltip(&response, along, edge, &label, key.as_deref());
response
}
pub(crate) fn show(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
@@ -322,73 +281,107 @@ pub(crate) fn show(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
return;
}
let Some(layout) = app.viewport.layout.as_ref().filter(|l| l.navigation.is_positive()) else { return };
let measured = measured(app, ui.ctx(), layout.content);
if !measured.rect.is_positive() {
let bar = measured(app, ui.ctx(), layout.content);
if !bar.rect.is_positive() {
return;
}
let active = ui.rect_contains_pointer(crate::viewport::axis_rect(app, rect))
crate::scenario::publish_rect("nav:outer", bar.rect);
let menu_id = ui.scope_id().with("navigation_bar_menu");
let mut open = ui.data(|d| d.get_temp::<String>(menu_id)).unwrap_or_default();
let active = ui.rect_contains_pointer(bar.rect)
|| ui.rect_contains_pointer(crate::viewport::axis_rect(app, rect))
|| app.viewport.axis_drag.is_some()
|| app.viewport.primary_nav.is_some()
|| app.viewport.orbit_from.is_some()
|| (app.viewport.mouse.is_some() && ui.input(|i| i.pointer.middle_down()))
|| app.viewport.modifier_nav.is_some()
|| app.viewport.spin;
let prefs = &app.preferences.navigation_bar;
let bar = Bar {
rect: measured.rect,
size: prefs.size,
edge: prefs.edge,
idle: prefs.idle,
active,
slots: slot_views(app, &measured, None),
overflow: measured
.overflow
.iter()
.filter_map(|slot| Some(slot_view(app, &measured, slot, prefs.slots.iter().position(|id| id == slot.id)?, Rect::NOTHING, None)))
.collect(),
more: measured.more,
labels: BarLabels {
more: app.tr("navbar.more").to_owned(),
customise: app.tr("navbar.custom.customise").to_owned(),
hide: app.tr("navbar.custom.hide").to_owned(),
reset: app.tr("navbar.custom.reset").to_owned(),
remove: app.tr("navbar.custom.remove_bar").to_owned(),
},
|| app.viewport.spin
|| !open.is_empty();
let opacity = ui_kit::navigation_opacity(app.preferences.navigation_bar.idle, active);
let background = ui.interact(bar.rect, ui.scope_id().with("navigation_bar_context"), egui::Sense::click());
let opacity = if app.preferences.navigation_bar.idle == NavigationIdle::Hide {
ui.ctx().animate_value_with_time(menu_id.with("opacity"), opacity, if active { ui_kit::NAV_FADE_SECONDS } else { 0.0 })
} else {
opacity
};
let action = nav_bar::show(ui, &bar, |id| menu(app, id));
if let Some(action) = action {
run(app, action);
// Rows of a menu that stays open show the new state on the next frame, without new input.
ui.ctx().request_repaint();
}
}
/// Run what the bar returned.
fn run(app: &mut SolveApp, action: NavAction) {
match action {
NavAction::Slot(id) => {
if let Some(slot) = navigation_bar::find(&id) {
activate(app, slot);
let mut anchor = bar.more;
ui.scope(|ui| {
ui.set_opacity(opacity);
for (index, slot, response) in paint_row(app, ui, &bar, &open, None) {
let bounds = response.rect;
crate::scenario::publish_target(&format!("nav:slot:{}", slot.id), bounds);
context_menu(app, &response, Some(index));
if response.clicked() && slot.kind != NavigationKind::Separator {
if slot.kind == NavigationKind::Menu {
open = if open == slot.id { String::new() } else { slot.id.to_owned() };
} else {
activate(app, slot);
}
}
if slot.id == "display" {
crate::scenario::publish_handle("nav:view", bounds.center());
}
if open == slot.id {
anchor = bounds;
}
}
NavAction::Context(ContextItem::Customise) => {
let _ = crate::prefs::control(app, &json!({"open":"Interface/Navigation bar"}));
if bar.more.is_positive() {
let response = ui_kit::navigation_button(
ui,
bar.more,
ui_kit::NavigationButton {
id: "more",
kind: NavigationKind::Menu,
icon: "dots",
label: app.tr("navbar.more"),
selected: open == "more" || bar.overflow.iter().any(|slot| slot.id == open),
labels: false,
menu: false,
},
);
ui_kit::navigation_tooltip(&response, bar.rect, app.preferences.navigation_bar.edge, app.tr("navbar.more"), None);
crate::scenario::publish_target("nav:more", response.rect);
context_menu(app, &response, None);
if response.clicked() {
open = if open == "more" { String::new() } else { "more".to_owned() };
}
}
NavAction::Context(ContextItem::Hide) => {
let _ = app.run("view.navigation_bar", json!({"visible":false}));
});
context_menu(app, &background, None);
if !open.is_empty() && anchor.is_positive() {
let child_popup_open = egui::Popup::is_any_open(ui.ctx());
let ctx = ui.ctx().clone();
// Menus open into the view: up from a bar at the bottom, down from one at the top.
let (pivot, at) = match app.preferences.navigation_bar.edge {
NavigationEdge::Bottom => (Align2::RIGHT_BOTTOM, anchor.right_top() - vec2(0.0, ui_kit::NAV_POPUP_GAP)),
NavigationEdge::Top => (Align2::RIGHT_TOP, anchor.right_bottom() + vec2(0.0, ui_kit::NAV_POPUP_GAP)),
};
let menu = Container::new(&ctx, Surface::Popup).area(menu_id.with("popup")).order(egui::Order::Foreground).pivot(pivot).fixed_pos(at).show(
&ctx,
|ui| {
ui_kit::navigation_menu(ui, if open == "more" { ui_kit::NAV_MORE_WIDTH } else { ui_kit::NAV_POPUP_WIDTH }, |ui| {
match open.as_str() {
"visual_style" => {
if style_menu(app, ui) {
open.clear();
}
}
"display" => display_menu(app, ui),
"more" => overflow_menu(app, ui, &bar.overflow, &mut open),
_ => open.clear(),
}
});
},
);
let outside = !child_popup_open
&& ui.input(|i| {
i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|p| !menu.response.rect.contains(p) && !bar.rect.contains(p))
});
if outside || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
open.clear();
}
NavAction::Context(ContextItem::Reset) => {
app.preferences.navigation_bar = NavigationBar::default();
let _ = app.run("view.navigation_bar", json!({"visible":true}));
}
NavAction::Context(ContextItem::Remove(index)) => {
let _ = app.preferences.navigation_bar.remove(index);
}
NavAction::Item { menu, item } if menu == "visual_style" => {
let _ = app.run("view.visual_style", json!({"style":item}));
}
NavAction::Item { item, .. } => display_pick(app, &item),
}
ui.data_mut(|d| d.insert_temp(menu_id, open));
}
pub(crate) fn current_style(app: &SolveApp) -> &str {
@@ -416,131 +409,250 @@ pub(crate) fn paint_row(
open: &str,
editing: Option<(Option<usize>, NavCaps, bool)>,
) -> Vec<(usize, &'static Slot, egui::Response)> {
let mut views = slot_views(app, bar, editing.map(|(_, caps, render)| (caps, render)));
for view in &mut views {
view.selected |= editing.is_none() && open == view.id;
ui_kit::navigation_surface(ui, bar.rect, app.preferences.navigation_bar.size, editing.is_some());
let mut responses = Vec::new();
let mut cursor = 0;
for (slot, bounds) in &bar.slots {
let Some((index, _)) = app.preferences.navigation_bar.slots.iter().enumerate().skip(cursor).find(|(_, id)| id.as_str() == slot.id) else {
continue;
};
cursor = index + 1;
let response = ui
.push_id(index, |ui| {
let hidden = editing.is_some_and(|(_, caps, _)| !slot.available(caps));
ui.scope(|ui| {
if hidden {
ui.set_opacity(ui_kit::NAV_HIDDEN_OPACITY);
} else if editing.is_none() && slot.id == "projection" && projection_locked(app) {
ui.set_opacity(ui.visuals().disabled_alpha);
}
if slot.kind == NavigationKind::Separator {
ui_kit::navigation_separator(ui, *bounds);
ui.interact(
*bounds,
ui.scope_id().with("separator"),
if editing.is_some() { egui::Sense::click_and_drag() } else { egui::Sense::click() },
)
} else {
let button = ui_kit::NavigationButton {
id: slot.id,
kind: slot.kind,
icon: slot.current_icon(app.ui.visual_style),
label: slot_text(app, slot),
selected: if editing.is_some() { false } else { selected(app, slot.id) || open == slot.id },
labels: if slot.kind == NavigationKind::Menu {
bar.values
&& slot.id == "visual_style"
&& app.preferences.navigation_bar.menu_display(slot.id) == MenuDisplay::IconAndValue
} else {
bar.labels
},
menu: slot.kind == NavigationKind::Menu,
};
if editing.is_some() {
ui_kit::navigation_button_editable(ui, *bounds, button, true)
} else {
ui_kit::navigation_button(ui, *bounds, button)
}
}
})
.inner
})
.inner;
let mut hidden = None;
if let Some((selected, caps, render)) = editing {
response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), selected == Some(index), app.tr(slot.label)));
ui_kit::navigation_edit_outline(ui, *bounds, !slot.available(caps), selected == Some(index));
if !slot.available(caps) {
hidden = Some(if render { "navbar.custom.hidden_render" } else { "navbar.custom.hidden_design" });
}
} else {
response.widget_info(|| {
egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), selected(app, slot.id) || open == slot.id, app.tr(slot.label))
});
}
// The settings' preview keeps its tips above, wherever the bar sits in the view.
let edge = if editing.is_some() { NavigationEdge::Bottom } else { app.preferences.navigation_bar.edge };
responses.push((index, *slot, tooltip(app, response, slot, hidden, bar.rect, edge)));
}
// The settings' preview keeps its tips above, wherever the bar sits in the view.
let edge = if editing.is_some() { NavigationEdge::Bottom } else { app.preferences.navigation_bar.edge };
let responses = nav_bar::paint_slots(ui, bar.rect, app.preferences.navigation_bar.size, edge, &views, editing.map(|(selected, _, _)| selected));
views.iter().zip(responses).filter_map(|(view, response)| Some((view.index, navigation_bar::find(view.id)?, response))).collect()
responses
}
/// The rows of the menu slot `id` opens (none for an unknown one, which closes it).
fn menu(app: &SolveApp, id: &str) -> Menu {
match id {
"visual_style" => style_menu(app),
"display" => display_menu(app),
_ => Menu::default(),
}
pub(crate) fn context_menu(app: &mut SolveApp, response: &egui::Response, index: Option<usize>) {
ui_kit::navigation_context(response, |ui| {
for id in if index.is_none() { &["customise", "hide", "reset"][..] } else { &[][..] } {
let key = format!("navbar.custom.{id}");
let item = ui_kit::NavigationMenuRow::new(id, app.tr(&key), "").show(ui);
crate::scenario::publish_target(&format!("nav:context:{id}"), item.rect);
if item.clicked() {
match *id {
"customise" => {
let _ = crate::prefs::control(app, &json!({"open":"Interface/Navigation bar"}));
}
"hide" => {
let _ = app.run("view.navigation_bar", json!({"visible":false}));
}
_ => {
app.preferences.navigation_bar = NavigationBar::default();
let _ = app.run("view.navigation_bar", json!({"visible":true}));
}
}
ui.close();
}
}
if let Some(index) = index {
let item = ui_kit::NavigationMenuRow::new("remove", app.tr("navbar.custom.remove_bar"), "").show(ui);
crate::scenario::publish_target("nav:context:remove", item.rect);
if item.clicked() {
let _ = app.preferences.navigation_bar.remove(index);
ui.close();
}
}
});
}
/// Visual style: the four styles, then Translucent (later); a pick closes the menu.
fn style_menu(app: &SolveApp) -> Menu {
let mut entries: Vec<MenuEntry> = navigation_bar::VISUAL_STYLES
.iter()
.enumerate()
.map(|(index, (style, _))| {
let mut row = MenuRow::new(
*style,
app.tr(&format!("navbar.style.{style}")),
navigation_bar::visual_style_icon(index as u8),
format!("nav:style:{style}"),
);
row.selected = app.ui.visual_style == index as u8;
MenuEntry::Row(row)
})
.collect();
entries.push(MenuEntry::Separator);
let mut later = MenuRow::new("translucent", app.tr("navbar.translucent"), "style_translucent", "");
fn style_menu(app: &mut SolveApp, ui: &mut egui::Ui) -> bool {
let mut chosen = false;
for (index, style) in navigation_bar::VISUAL_STYLES.iter().map(|(style, _)| *style).enumerate() {
let label = format!("navbar.style.{style}");
let mut item = ui_kit::NavigationMenuRow::new(style, app.tr(&label), navigation_bar::visual_style_icon(index as u8));
item.selected = app.ui.visual_style == index as u8;
let response = item.show(ui);
crate::scenario::publish_target(&format!("nav:style:{style}"), response.rect);
if response.clicked() {
let _ = app.run("view.visual_style", json!({"style":style}));
chosen = true;
}
}
ui_kit::navigation_menu_separator(ui);
let mut later = ui_kit::NavigationMenuRow::new("translucent", app.tr("navbar.translucent"), "style_translucent");
later.enabled = false;
later.detail = Some(app.tr("navbar.later").to_owned());
entries.push(MenuEntry::Row(later));
Menu { entries, close_on_pick: true }
later.detail = Some(app.tr("navbar.later"));
later.show(ui);
chosen
}
/// The View menu: graphics preset, what the view shows, projection, cursor and the assistant.
fn display_menu(app: &SolveApp) -> Menu {
let row = |id: &str, label: &str, icon: &str, target: &str, selected: bool| {
let mut row = MenuRow::new(id, label, icon, target);
row.selected = selected;
row
};
let mut entries = vec![MenuEntry::Heading(app.tr("navbar.graphics").to_owned())];
fn overflow_menu(app: &mut SolveApp, ui: &mut egui::Ui, slots: &[&Slot], open: &mut String) {
for slot in slots {
if slot.kind == NavigationKind::Separator {
let start = ui.next_widget_position();
ui_kit::navigation_menu_separator(ui);
let bounds = Rect::from_min_max(start, pos2(start.x + ui.available_width(), ui.next_widget_position().y));
crate::scenario::publish_target(&format!("nav:slot:{}", slot.id), bounds);
continue;
}
let mut item = ui_kit::NavigationMenuRow::new(slot.id, app.tr(slot.label), slot.current_icon(app.ui.visual_style));
if matches!(slot.kind, NavigationKind::Tool | NavigationKind::Toggle) {
item.selected = selected(app, slot.id);
}
let response = item.show(ui);
let index = app.preferences.navigation_bar.slots.iter().position(|id| id == slot.id);
context_menu(app, &response, index);
crate::scenario::publish_target(&format!("nav:slot:{}", slot.id), response.rect);
if slot.id == "display" {
crate::scenario::publish_handle("nav:view", response.rect.center());
}
if tooltip(app, response.clone(), slot, None, response.rect, NavigationEdge::Bottom).clicked() {
if slot.kind == NavigationKind::Menu {
*open = slot.id.to_owned();
} else {
activate(app, slot);
open.clear();
}
}
}
}
fn display_menu(app: &mut SolveApp, ui: &mut egui::Ui) {
use ui_kit::{NavigationMenuRow, navigation_menu_heading as heading, navigation_menu_separator as separator};
heading(ui, app.tr("navbar.graphics"));
let current = crate::prefs::graphics_preset(&app.preferences);
let mut preset = MenuRow::new("preset", app.tr("navbar.preset"), "", "view:graphics_preset");
preset.detail = Some(app.tr(&format!("ui.graphics_{current}")).to_owned());
preset.submenu = [("performance", "ui.graphics_performance"), ("quality", "ui.graphics_quality")]
.into_iter()
.map(|(id, label)| row(id, app.tr(label), "", &format!("view:graphics_preset:{id}"), current == id))
.collect();
entries.extend([MenuEntry::Row(preset), MenuEntry::Separator, MenuEntry::Heading(app.tr("navbar.show").to_owned())]);
let detail = format!("ui.graphics_{current}");
let mut preset = NavigationMenuRow::new("preset", app.tr("navbar.preset"), "");
preset.detail = Some(app.tr(&detail));
preset.submenu = true;
let response = preset.show(ui);
crate::scenario::publish_target("view:graphics_preset", response.rect);
Container::new(ui.ctx(), Surface::Popup).menu(&response).frame(ui_kit::navigation_popup_frame()).show(|ui| {
ui.set_width(ui_kit::NAV_POPUP_WIDTH - 2.0 * (f32::from(ui_kit::NAV_POPUP_PADDING) + ui_kit::NAV_BORDER));
ui.spacing_mut().item_spacing.y = 0.0;
for (id, label) in [("performance", "ui.graphics_performance"), ("quality", "ui.graphics_quality")] {
let mut item = NavigationMenuRow::new(id, app.tr(label), "");
item.selected = current == id;
let response = item.show(ui);
crate::scenario::publish_target(&format!("view:graphics_preset:{id}"), response.rect);
if response.clicked() {
let _ = app.run("view.graphics_preset", json!({"preset":id}));
ui.close();
}
}
});
separator(ui);
heading(ui, app.tr("navbar.show"));
for id in ["grid", "ground_shadow", "ambient_occlusion"] {
let Some(slot) = navigation_bar::find(id) else { continue };
entries.push(MenuEntry::Row(row(
slot.id,
app.tr(slot.label),
slot.current_icon(app.ui.visual_style),
&format!("view:{id}"),
selected(app, slot.id),
)));
let mut item = NavigationMenuRow::new(slot.id, app.tr(slot.label), slot.current_icon(app.ui.visual_style));
item.selected = selected(app, slot.id);
let response = item.show(ui);
crate::scenario::publish_target(&format!("view:{id}"), response.rect);
if response.clicked() {
activate(app, slot);
}
}
entries.extend([MenuEntry::Separator, MenuEntry::Heading(app.tr("navbar.projection_heading").to_owned())]);
separator(ui);
heading(ui, app.tr("navbar.projection_heading"));
for (perspective, key) in [(false, "navbar.orthographic"), (true, "navbar.perspective")] {
let mut item = row(
key,
app.tr(key),
"perspective",
if perspective { "view:projection" } else { "view:orthographic" },
app.ui.perspective == perspective,
);
let mut item = NavigationMenuRow::new(key, app.tr(key), "perspective");
item.selected = app.ui.perspective == perspective;
item.enabled = !projection_locked(app);
item.tip = projection_locked(app).then(|| app.tr("nav.perspective.sketch").to_owned());
entries.push(MenuEntry::Row(item));
let response = item.show(ui);
let response = if projection_locked(app) { response.backing_on_hover_text(app.tr("nav.perspective.sketch")) } else { response };
crate::scenario::publish_target(if perspective { "view:projection" } else { "view:orthographic" }, response.rect);
if response.clicked() && app.ui.perspective != perspective {
toggle_projection(app);
}
}
entries.push(MenuEntry::Separator);
separator(ui);
for (id, label, on) in [
("auto_ortho_faces", "Perspective while orbiting, ortho on faces", app.ui.auto_ortho_faces),
("tool_cursor", "Show tool at cursor", app.ui.tool_cursor),
] {
entries.push(MenuEntry::Row(row(id, solidcraft_ui_text::tr_label(label), "select", &format!("view:{id}"), on)));
}
// The View angles readout under the orientation gizmo (#6032).
let mut angles = row("view_angles", app.tr("ui.view_angles"), "select", "view:view_angles", app.preferences.show_view_angles);
angles.tip = Some(app.tr("ui.view_angles_help").to_owned());
entries.extend([MenuEntry::Row(angles), MenuEntry::Separator, MenuEntry::Heading(app.tr("navbar.assistant").to_owned())]);
entries.push(MenuEntry::Row(row("agent_cursor", app.tr("navbar.agent_cursor"), "select", "view:agent_cursor", app.ui.agent_cursor.show)));
if crate::agent_chat::shown(app) {
entries.push(MenuEntry::Row(row("agent_chat", app.tr("navbar.agent_chat"), "agent_chat", "view:agent_chat", app.agent_chat.open)));
}
Menu { entries, close_on_pick: false }
}
/// A View menu pick.
fn display_pick(app: &mut SolveApp, item: &str) {
match item {
"performance" | "quality" => {
let _ = app.run("view.graphics_preset", json!({"preset":item}));
}
"navbar.orthographic" | "navbar.perspective" => {
if app.ui.perspective != (item == "navbar.perspective") {
toggle_projection(app);
let mut row = NavigationMenuRow::new(id, solidcraft_ui_text::tr_label(label), "select");
row.selected = on;
let response = row.show(ui);
crate::scenario::publish_target(&format!("view:{id}"), response.rect);
if response.clicked() {
match id {
"auto_ortho_faces" => app.ui.auto_ortho_faces = !on,
_ => app.ui.tool_cursor = !on,
}
}
"auto_ortho_faces" => app.ui.auto_ortho_faces = !app.ui.auto_ortho_faces,
"tool_cursor" => app.ui.tool_cursor = !app.ui.tool_cursor,
"view_angles" => {
let _ = app.run("view.angle_readout", json!({"enabled": !app.preferences.show_view_angles}));
}
"agent_cursor" => app.ui.agent_cursor.show = !app.ui.agent_cursor.show,
"agent_chat" => {
}
// The View angles readout under the orientation gizmo (#6032).
let mut angles = NavigationMenuRow::new("view_angles", app.tr("ui.view_angles"), "select");
angles.selected = app.preferences.show_view_angles;
let response = angles.show(ui).backing_on_hover_text(app.tr("ui.view_angles_help"));
crate::scenario::publish_target("view:view_angles", response.rect);
if response.clicked() {
let _ = app.run("view.angle_readout", json!({"enabled": !app.preferences.show_view_angles}));
}
separator(ui);
heading(ui, app.tr("navbar.assistant"));
let mut cursor = NavigationMenuRow::new("agent_cursor", app.tr("navbar.agent_cursor"), "select");
cursor.selected = app.ui.agent_cursor.show;
let response = cursor.show(ui);
crate::scenario::publish_target("view:agent_cursor", response.rect);
if response.clicked() {
app.ui.agent_cursor.show = !app.ui.agent_cursor.show;
}
if crate::agent_chat::shown(app) {
let mut chat = NavigationMenuRow::new("agent_chat", app.tr("navbar.agent_chat"), "agent_chat");
chat.selected = app.agent_chat.open;
let response = chat.show(ui);
crate::scenario::publish_target("view:agent_chat", response.rect);
if response.clicked() {
app.agent_chat.open = !app.agent_chat.open;
app.preferences.agent_chat_minimized &= !app.agent_chat.open;
}
id => {
if let Some(slot) = navigation_bar::find(id) {
activate(app, slot);
}
}
}
}
+1 -3
View File
@@ -682,9 +682,7 @@ impl AppPanelRuntime {
if (self.runtime.environment() != &before || self.runtime.layout().inspect().revision != revision)
&& let Some(host) = &mut self.host
{
// Presentation metadata can revise an unrelated panel.
// Scope and focused-owner checks decide input ownership.
host.invalidate_geometry();
host.invalidate_context();
host.reconcile_scope(&self.runtime);
}
}
+1 -12
View File
@@ -110,17 +110,6 @@ impl Product {
}
fn presentation(self, app: &crate::SolveApp, play_button: bool) -> PanelPresentation {
let mut presentation = self.empty_presentation();
if self == Self::Assistant {
let waiting = app.agent_chat.waiting();
let state = app.agent_chat.state();
presentation.status = Some(if waiting > 0 {
solidcraft_ui_text::format("ui.assistant_waiting", &[("count", &waiting.to_string())])
} else {
state.label().into()
});
presentation.attention = waiting > 0
|| matches!(state, crate::agent_chat::State::NeedsAuth | crate::agent_chat::State::Exited | crate::agent_chat::State::Failed);
}
presentation.actions = match self {
Self::History => crate::ui_kit::HistoryTransport::shown(play_button)
.iter()
@@ -865,7 +854,7 @@ impl FramePanelContent for ProductionFrame<'_> {
Product::Browser | Product::RenderBrowser => crate::browser::panel_contents(self.app, ui),
Product::History if context.flow == Flow::Wide => crate::timeline::panel_inline(self.app, ui),
Product::History => crate::timeline_left::panel_contents(self.app, ui),
Product::Assistant => crate::agent_chat::shared_content(self.app, ui, context.flow),
Product::Assistant => crate::agent_chat::shared_content(self.app, ui),
}
let capture = ui.ctx().data_mut(|data| data.remove_temp::<Option<FocusCapture>>(focus_capture_id()).flatten());
if context.document.as_ref().map(|document| document.0.as_str()) != Some(self.app.session.document_id.as_str()) {
-86
View File
@@ -12893,92 +12893,6 @@ fn content_focus_bookmark_restores_caret_after_explicit_collapse_and_focus() {
}
}
#[test]
fn editor_focus_survives_floating_sizing_unless_a_live_foreign_control_takes_it() {
for scale in [1.0, 2.0] {
for foreign_focus in [false, true] {
let (mut content, trace) = review_cohort_host(|_| {});
content.dispatch(vec![fixture_open("fixture.editor", "wrapping-editor", "wrapping", Zone::Left, 0).unwrap()]).unwrap();
let mut raw = RawPanelHost::new(content, scale);
raw.frame(Vec::new(), false);
raw.frame(Vec::new(), false);
if foreign_focus {
raw.prime_foreign_window();
}
let editor = trace.borrow().editors.last().unwrap().0;
raw.ctx.memory_mut(|memory| memory.request_focus(editor));
raw.frame(Vec::new(), false);
raw.frame_late(vec![egui::Event::Text(" retained reparent selection".into())], false, |_, _| {});
raw.frame(Vec::new(), false);
let mut state = egui::TextEdit::load_state(&raw.ctx, editor).unwrap();
state.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(1), egui::text::CCursor::new(4))));
state.store(&raw.ctx, editor);
raw.frame(Vec::new(), false);
assert_eq!(raw.ctx.memory(|memory| memory.focused()), Some(editor));
let caret = egui::TextEdit::load_state(&raw.ctx, editor).unwrap().cursor.char_range();
assert!(!caret.unwrap().is_empty());
let draft = trace.borrow().editors.last().unwrap().3.clone();
let key = ControllerKey { instance: id("wrapping-editor"), owner: raw.content.runtime.instance_owner(&id("wrapping-editor")).unwrap() };
let renders = raw.content.probe.state(&key).unwrap().renders;
if foreign_focus {
raw.foreign_cover = Some(egui::Rect::from_min_size(egui::pos2(1000.0, 600.0), egui::vec2(180.0, 80.0)));
raw.foreign_takes_focus = true;
}
raw.content
.dispatch(vec![Mutation::Move {
instance: id("wrapping-editor"),
destination: Destination::NewSection {
section: section("reparented-editor"),
index: 0,
location: SectionLocation::Floating { origin: Point { x: 400.0, y: 180.0 } },
size: Size { width: 400.0, height: 320.0 },
},
}])
.unwrap();
raw.frame(Vec::new(), false);
assert_eq!(raw.content.probe.state(&key).unwrap().renders, renders, "the new Area sizing pass must not run a hidden body");
let foreign = foreign_focus.then(|| raw.foreign_responses.last().unwrap().id);
assert_eq!(
raw.ctx.memory(|memory| memory.focused()),
foreign,
"sizing removes the editor; only the real late foreign widget may take focus"
);
// Stop requesting focus: subsequent passes must retain the person's
// choice without repeatedly forcing it after host restoration.
raw.foreign_takes_focus = false;
for _ in 0..2 {
raw.frame(Vec::new(), false);
assert_eq!(raw.ctx.memory(|memory| memory.focused()), foreign.or(Some(editor)));
}
assert!(raw.content.probe.state(&key).unwrap().renders > renders);
assert_eq!(trace.borrow().editors.last().unwrap().3, draft);
if foreign_focus {
// A later deliberate focus command may return to the retained
// selection. It is separate from the Move tested above.
let reply = raw.content.dispatch(vec![Mutation::Focus { instance: id("wrapping-editor") }]).unwrap();
raw.host.note_reply(&reply);
raw.frame(Vec::new(), false);
raw.frame(Vec::new(), false);
}
assert_eq!(raw.ctx.memory(|memory| memory.focused()), Some(editor));
assert_eq!(egui::TextEdit::load_state(&raw.ctx, editor).unwrap().cursor.char_range(), caret);
assert_eq!(trace.borrow().editors.last().unwrap().3, draft);
assert_eq!(raw.content.commands.calls, 0);
// A retained bookmark must not keep its editor focused after the
// owning content becomes unavailable, even before another paint.
trace.borrow_mut().unavailable.insert(key.clone());
raw.content.tick().unwrap();
assert_ne!(raw.content.runtime.layout().availability(&key.instance), Availability::Available);
assert_eq!(raw.ctx.memory(|memory| memory.focused()), Some(editor));
raw.host.reconcile_scope(&raw.content.runtime);
assert_ne!(raw.ctx.memory(|memory| memory.focused()), Some(editor));
assert_eq!(egui::TextEdit::load_state(&raw.ctx, editor).unwrap().cursor.char_range(), caret);
assert_eq!(trace.borrow().editors.last().unwrap().3, draft);
}
}
}
#[test]
fn custom_header_action_reports_enabled_and_pressed_state_from_retained_presentation() {
let (mut content, trace) = review_cohort_host(|_| {});
+8 -42
View File
@@ -226,17 +226,6 @@ struct ContentFocus {
selection: Option<egui::text::CCursorRange>,
}
impl ContentFocus {
fn logically_presented(&self, runtime: &PanelRuntime) -> bool {
let service = runtime.layout();
self.scope == runtime_scope(runtime)
&& runtime.instance_owner(&self.key.instance).is_ok_and(|owner| owner == self.key.owner)
&& service.availability(&self.key.instance) == Availability::Available
&& service.layout().instance(&self.key.instance).is_ok_and(|instance| instance.requested_open)
&& service.layout().containing(&self.key.instance).is_ok_and(|section| {
let availability: AvailabilityMap = section.tabs.iter().map(|instance| (instance.clone(), service.availability(instance))).collect();
!section.collapsed && presented_tab(section, &availability) == Some(&self.key.instance)
})
}
fn restore_selection(&self, ctx: &egui::Context) {
if let Some(selection) = self.selection
&& let Some(mut state) = egui::TextEdit::load_state(ctx, self.id)
@@ -611,16 +600,6 @@ impl WorkspacePanelHost {
}
self.scope = Some(identity);
}
if let Some(bookmark) = &self.content_focus
&& self.owned_focus == Some(bookmark.id)
&& !bookmark.logically_presented(runtime)
{
self.restore_content_focus = false;
self.owned_focus = None;
if let Some(ctx) = &self.egui_context {
ctx.memory_mut(|memory| memory.surrender_focus(bookmark.id));
}
}
}
pub(crate) fn note_reply(&mut self, reply: &LayoutReply) {
for effect in &reply.effects {
@@ -956,7 +935,7 @@ impl WorkspacePanelHost {
// the intended resolved geometry, but never publish it as actual paint
// or an input/occlusion shield, even if no further egui pass is allowed.
let mut unpainted_instances = Vec::new();
for (layer, bounds, sizing) in &unpainted {
for (layer, bounds) in &unpainted {
let hidden_sections: Vec<_> = frame
.layout
.sections
@@ -967,16 +946,6 @@ impl WorkspacePanelHost {
.iter()
.flat_map(|section| std::iter::once(section.instance.clone()).chain(section.tabs.iter().map(|tab| tab.instance.clone())))
.collect();
// A new Area measures before it paints. Preserve only the editor
// that still owns focus; restore it after a fresh response returns.
if *sizing && ui.is_enabled() && ui.is_visible() && ui.input(|input| input.focused) && !ui_kit::Container::modal_input_blocked(ui.ctx()) {
self.restore_content_focus |= self.content_focus.as_ref().is_some_and(|bookmark| {
self.owned_focus == Some(bookmark.id)
&& ui.memory(|memory| memory.focused()) == Some(bookmark.id)
&& bookmark.logically_presented(runtime)
&& hidden_sections.iter().any(|section| section.instance == bookmark.key.instance && !section.collapsed)
});
}
unpainted_instances.extend(hidden_instances.iter().cloned());
let clipped = logical_rect(egui_rect(*bounds).intersect(egui_rect(frame.layout.capacity)));
// Remove one occurrence: a visible card can occupy the same rect.
@@ -1063,12 +1032,6 @@ impl WorkspacePanelHost {
}
}
}
let logical_focus_eligible = self.restore_content_focus
&& ui.input(|input| input.focused)
&& self.content_focus.as_ref().is_some_and(|bookmark| bookmark.logically_presented(runtime));
// A passive return cannot outlive deliberate hiding or owner/focus loss.
// A modal may still wait while that logical presentation remains valid.
self.restore_content_focus &= logical_focus_eligible;
if self.restore_content_focus && !ui_kit::Container::modal_input_blocked(ui.ctx()) {
let current = ui.memory(|memory| memory.focused());
let deliberate = current.is_some_and(|id| ui.ctx().read_response(id).is_some_and(|response| response.gained_focus()));
@@ -1077,8 +1040,11 @@ impl WorkspacePanelHost {
} else if current.is_none()
&& let Some(bookmark) = &self.content_focus
{
let eligible =
self.content_reports.get(&bookmark.id).is_some_and(|report| report.key == bookmark.key && report.scope == bookmark.scope);
let eligible = bookmark.scope == scope
&& runtime.instance_owner(&bookmark.key.instance).is_ok_and(|owner| owner == bookmark.key.owner)
&& runtime.layout().availability(&bookmark.key.instance) == Availability::Available
&& frame.layout.sections.iter().any(|section| section.instance == bookmark.key.instance && !section.collapsed)
&& self.content_reports.get(&bookmark.id).is_some_and(|report| report.key == bookmark.key && report.scope == bookmark.scope);
if eligible {
bookmark.restore_selection(ui.ctx());
ui.memory_mut(|memory| memory.request_focus(bookmark.id));
@@ -2230,7 +2196,7 @@ struct WorkspaceContentHost<'a> {
motion: Motion,
commands: &'a mut Vec<HostCommand>,
allow_actions: bool,
unpainted: &'a mut Vec<(egui::LayerId, LogicalRect, bool)>,
unpainted: &'a mut Vec<(egui::LayerId, LogicalRect)>,
}
impl PanelHost for WorkspaceContentHost<'_> {
@@ -2281,7 +2247,7 @@ impl PanelHost for WorkspaceContentHost<'_> {
matches!(appearance.contrast, ContrastOutcome::RetainReadableFallback { .. }).then(|| style.palette(appearance)),
|ui, content_clip| {
if (section.peek || section.zone == Zone::Floating) && (!ui.is_visible() || ui.is_sizing_pass()) {
self.unpainted.push((ui.layer_id(), section.outer, ui.is_sizing_pass()));
self.unpainted.push((ui.layer_id(), section.outer));
return;
}
// A surface response provides semantic focus and owns only the actual
@@ -350,14 +350,13 @@ pub(crate) fn paint_navigation_axes(p: &Painter, rect: Rect, axes: &[NavigatorAx
let t = Tokens::get();
let c = rect.center();
p.circle(c, 46.0 * scale, t.overlay.gamma_multiply(0.35), Stroke::new(scale, t.border.gamma_multiply(0.75)));
let mut tips: Vec<_> =
axes.iter().map(|axis| (axis, c + crate::view_compass::tip_offset([f64::from(axis.screen.x), f64::from(axis.screen.y)]) * scale)).collect();
let mut tips: Vec<_> = axes.iter().map(|axis| (axis, c + axis.screen * (38.0 * 0.84 * scale))).collect();
tips.sort_by(|a, b| a.0.depth.total_cmp(&b.0.depth));
for (index, (axis, at)) in tips.iter().enumerate() {
if index == 3 {
p.circle(c, 3.6 * scale, t.text.gamma_multiply(0.9), Stroke::new(1.4 * scale, t.panel));
}
crate::icons::navigation::axis_tip_scaled(&p, c, axis.name, axis.direction, *at, axis.depth, false, t, scale);
crate::icons::navigation::axis_tip_scaled(&p, c, axis.name, &axis.direction.into(), *at, axis.depth, false, t, scale);
}
}
-317
View File
@@ -1,317 +0,0 @@
//! The view compass as a component: the six signed world axes drawn as the camera's view axes
//! pose them, a Home chip, and a drag anywhere on it to orbit. It takes plain inputs
//! ([`Compass`], [`ViewAxes`]) and returns the [`ViewAction`] chosen, and [`orbit`] returns a drag's
//! [`OrbitStep`]; the app turns the camera.
//! Every tip publishes its handle (`axis_view:<axis>`) to the widget registry while it is drawn.
use crate::icons::navigation::AXIS_RADIUS;
use crate::ui_kit::BackingTooltipExt as _;
use egui::{Pos2, Rect, Sense, Stroke, pos2, vec2};
use solidcraft_theme::Tokens;
/// How far a tip is from the centre, before [`ART_SCALE`].
const AXIS_LENGTH: f64 = 38.0;
/// The artwork sits inside the pointer reach so the compass keeps breathing room in its circle.
const ART_SCALE: f64 = 0.84;
/// The compass circle's radius: anywhere inside drags the view.
const RADIUS: f32 = 49.5;
/// The six signed world axes, by name.
pub const AXES: [(&str, [f64; 3]); 6] = [
("x", [1.0, 0.0, 0.0]),
("-x", [-1.0, 0.0, 0.0]),
("y", [0.0, 1.0, 0.0]),
("-y", [0.0, -1.0, 0.0]),
("z", [0.0, 0.0, 1.0]),
("-z", [0.0, 0.0, -1.0]),
];
/// The camera's view axes in world space.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ViewAxes {
pub right: [f64; 3],
pub up: [f64; 3],
/// Toward the viewer.
pub back: [f64; 3],
}
impl ViewAxes {
/// World direction `d` as the camera poses it: its parts along the screen's right and down,
/// and its depth toward the viewer.
pub fn pose(&self, d: [f64; 3]) -> ([f64; 2], f64) {
([dot(d, self.right), -dot(d, self.up)], dot(d, self.back))
}
}
fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
/// One signed axis as drawn: its name (`"x"`, `"-x"`, ... `"-z"`), world direction, tip on screen
/// and depth along the camera's back.
pub type Tip = (&'static str, [f64; 3], Pos2, f64);
/// What the user chose on the compass: a tip or Home.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum ViewAction {
/// Look along an axis: a tip was clicked (`direction`: the tip's world direction).
Snap { axis: &'static str, direction: [f64; 3] },
/// The Home chip was clicked.
Home,
}
/// One frame's step of a drag on a view navigator: `(dx, dy)` screen points (`snap`: Shift was
/// held).
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct OrbitStep {
pub dx: f64,
pub dy: f64,
pub snap: bool,
}
/// One frame of the compass.
#[derive(Clone, Debug)]
pub struct Compass {
/// The area it may paint in.
pub clip: Rect,
pub centre: Pos2,
pub axes: ViewAxes,
/// The pointer, when it may act on the compass (the app leaves out a pointer over a floating
/// window or with the view disabled).
pub hover: Option<Pos2>,
/// A drag on it is turning the view (no tip lights while it does).
pub orbiting: bool,
/// The tip's hover text mentions holding Shift to snap.
pub snap_hint: bool,
pub home_label: String,
}
/// Where a tip is drawn from the centre, for a direction posed on screen as `[right, down]`
/// ([`ViewAxes::pose`]); a figure of the compass scales it.
pub fn tip_offset([x, y]: [f64; 2]) -> egui::Vec2 {
vec2((x * AXIS_LENGTH * ART_SCALE) as f32, (y * AXIS_LENGTH * ART_SCALE) as f32)
}
/// The six signed axes as `axes` poses them around `c`, back to front so overlapping tips have
/// one owner.
pub fn tips(axes: &ViewAxes, c: Pos2) -> Vec<Tip> {
let mut tips: Vec<Tip> = AXES
.into_iter()
.map(|(name, d)| {
let (screen, depth) = axes.pose(d);
(name, d, c + tip_offset(screen), depth)
})
.collect();
tips.sort_by(|a, b| a.3.total_cmp(&b.3));
tips
}
/// The front-most tip within reach of `p`.
pub fn hit(tips: &[Tip], p: Pos2) -> Option<usize> {
tips.iter().enumerate().rev().find(|(_, (_, _, at, _))| at.distance(p) <= AXIS_RADIUS).map(|(i, _)| i)
}
/// Draw the compass; a tip or Home clicked this frame (a drag on it is [`orbit`]'s).
pub fn show(ui: &mut egui::Ui, compass: &Compass) -> Option<ViewAction> {
let t = Tokens::get();
let c = compass.centre;
let tips = tips(&compass.axes, c);
let hover = compass.hover;
let hovered = if compass.orbiting { None } else { hover.and_then(|p| hit(&tips, p)) };
let painter = ui.painter_at(compass.clip);
let mut action = None;
// The circle turns fully opaque under the pointer.
let in_circle = hover.is_some_and(|p| p.distance(c) <= RADIUS);
let fill = if in_circle || compass.orbiting { 1.0 } else { 0.35 };
painter.circle(c, RADIUS, t.overlay.gamma_multiply(fill), Stroke::new(1.0, t.border.gamma_multiply(0.75)));
for (i, (name, d, at, depth)) in tips.iter().enumerate() {
if i == 3 {
// The origin dot sits behind the three front axes and over the three back ones.
painter.circle(c, 3.6, t.text.gamma_multiply(0.9), Stroke::new(1.4, t.panel));
}
crate::icons::navigation::axis_tip(&painter, c, name, *d, *at, *depth, hovered == Some(i), t);
solidcraft_ui_registry::publish_handle(&format!("axis_view:{name}"), *at);
}
solidcraft_ui_registry::publish_count("axis_view_highlights", f64::from(u8::from(hovered.is_some())));
if let Some(i) = hovered
&& let Some((name, d, _, _)) = tips.get(i)
{
solidcraft_ui_registry::publish_handle(&format!("axis_view_hover:{name}"), hover.unwrap_or(c));
ui.interact(Rect::from_center_size(hover.unwrap_or(c), vec2(2.0, 2.0)), ui.scope_id().with("axis_view_tip"), Sense::hover())
.backing_on_hover_text(format!(
"View from {}. Click again to reverse. Drag to orbit{}.",
name.to_uppercase(),
if compass.snap_hint { "; hold Shift to snap" } else { "" }
));
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
if ui.input(|i| i.pointer.primary_clicked()) {
action = Some(ViewAction::Snap { axis: name, direction: *d });
}
}
let home = Rect::from_center_size(pos2(c.x - 37.0, c.y - 37.0), vec2(18.0, 18.0));
solidcraft_ui_registry::publish_target("axis_view:home", home);
let hr = ui.interact(home, ui.scope_id().with("axis_view_home"), Sense::click());
// The home button sits in its own small round chip.
painter.circle(home.center(), 12.0, t.overlay.gamma_multiply(if hr.hovered() { 0.9 } else { 0.5 }), Stroke::new(1.0, t.border));
crate::icons::paint_icon(&painter, home, "home", f32::from(t.icon.a()) / 255.0);
// Cursors: a grabbing hand while orbiting, an open hand over the circle, a pointer on the home
// chip (the axis ends keep their pointing hand).
if hr.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
} else if compass.orbiting {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
} else if in_circle && hovered.is_none() {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
}
if hr.backing_on_hover_text(&compass.home_label).clicked() {
action = Some(ViewAction::Home);
}
action
}
/// Where a drag on a view navigator stands between frames: the pointer at the last step, and
/// where the cursor was warped back to (if it hit the screen's edge).
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Drag {
pub at: Option<Pos2>,
pub warp: Option<Pos2>,
}
/// One frame of a drag on a view navigator: whether it started or ended, whether it is turning
/// the view, and this frame's step.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Orbit {
/// A press landed on the navigator and holds it.
pub grabbed: bool,
/// The button was let go of a held navigator.
pub released: bool,
/// The drag is turning the view.
pub orbiting: bool,
pub step: Option<OrbitStep>,
}
/// Track a drag on a view navigator. A press may start one (`may_start`: the pointer is over the
/// view and free to act) where `starts_at` says the navigator is; once the drag is decided each
/// frame's motion is an [`OrbitStep`]. The cube and the custom body share it.
pub fn orbit(ui: &egui::Ui, drag: &mut Drag, may_start: bool, starts_at: impl Fn(Pos2) -> bool) -> Orbit {
let mut out = Orbit::default();
let (pressed, down, decided, origin, pos, shift) = ui.input(|i| {
let p = &i.pointer;
(p.primary_pressed(), p.primary_down(), p.is_decidedly_dragging(), p.press_origin(), p.interact_pos(), i.modifiers.shift)
});
if pressed && may_start {
drag.at = origin.filter(|p| starts_at(*p));
drag.warp = None;
out.grabbed = drag.at.is_some();
}
if !down && drag.at.take().is_some() {
drag.warp = None;
out.released = true;
}
let Some(last) = drag.at.filter(|_| decided) else { return out };
out.orbiting = true;
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
if let Some(p) = pos {
// While a warp is pending the pointer may still sit at the edge it hit (the OS has not
// echoed the warp, or cannot), have landed at the centre (the echo), or have moved past
// it (the echo plus a fast flick): measure each step from whichever reference the
// pointer is nearer to, so the view never jumps by the warp distance and a platform
// that ignores warps keeps orbiting from the real pointer instead of freezing.
let from = match drag.warp {
Some(w) if p.distance(w) <= p.distance(last) => {
drag.warp = None;
w
}
_ => last,
};
out.step = Some(OrbitStep { dx: f64::from(p.x - from.x), dy: f64::from(p.y - from.y), snap: shift });
drag.at = Some(p);
// The OS stops feeding movement once the cursor is pinned at the screen edge, so an
// orbit toward that edge would stall: warp the cursor back to the window centre and
// measure the next steps from there, keeping the orbit tracking while the button is held (#9943).
let screen = ui.ctx().viewport_rect();
let edge = 6.0;
if drag.warp.is_none()
&& (p.x <= screen.min.x + edge || p.x >= screen.max.x - edge || p.y <= screen.min.y + edge || p.y >= screen.max.y - edge)
{
let home = screen.center();
drag.warp = Some(home);
ui.ctx().send_viewport_cmd(egui::ViewportCommand::CursorPosition(home));
}
ui.ctx().request_repaint();
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use egui::{Event, RawInput};
/// Looking at the front: X to the right, Z up, -Y toward the viewer.
const FRONT: ViewAxes = ViewAxes { right: [1.0, 0.0, 0.0], up: [0.0, 0.0, 1.0], back: [0.0, -1.0, 0.0] };
fn run(ctx: &egui::Context, events: Vec<Event>, hover: Option<Pos2>, drag: &mut Drag) -> (Option<ViewAction>, Orbit) {
let mut out = (None, Orbit::default());
crate::test_support::frame(
ctx,
RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(400.0, 400.0))), events, focused: true, ..Default::default() },
|ui| {
let orbit = super::orbit(ui, drag, true, |p| p.distance(pos2(200.0, 200.0)) <= RADIUS);
let compass = Compass {
clip: ui.max_rect(),
centre: pos2(200.0, 200.0),
axes: FRONT,
hover,
orbiting: orbit.orbiting,
snap_hint: true,
home_label: "Home".into(),
};
out = (show(ui, &compass), orbit);
},
);
out
}
fn click(ctx: &egui::Context, at: Pos2) -> Option<ViewAction> {
let mut drag = Drag::default();
run(ctx, vec![Event::PointerMoved(at)], Some(at), &mut drag);
let pressed = run(ctx, crate::test_support::pointer(at, true), Some(at), &mut drag).0;
pressed.or(run(ctx, crate::test_support::pointer(at, false), Some(at), &mut drag).0)
}
#[test]
fn a_click_on_a_tip_or_home_returns_its_view_from_plain_axes() {
let ctx = crate::fonts::test_context();
let tips = tips(&FRONT, pos2(200.0, 200.0));
let x = tips.iter().find(|t| t.0 == "x").map(|t| t.2).unwrap();
assert_eq!(x, pos2(200.0 + (38.0 * 0.84) as f32, 200.0), "X points right");
assert_eq!(click(&ctx, x), Some(ViewAction::Snap { axis: "x", direction: [1.0, 0.0, 0.0] }));
let z = tips.iter().find(|t| t.0 == "z").map(|t| t.2).unwrap();
assert_eq!(click(&ctx, z), Some(ViewAction::Snap { axis: "z", direction: [0.0, 0.0, 1.0] }));
assert_eq!(click(&ctx, pos2(163.0, 163.0)), Some(ViewAction::Home));
assert_eq!(click(&ctx, pos2(20.0, 20.0)), None, "outside the compass");
}
#[test]
fn a_drag_on_the_compass_returns_orbit_steps_and_ends_on_release() {
let ctx = crate::fonts::test_context();
let mut drag = Drag::default();
let start = pos2(180.0, 215.0);
run(&ctx, vec![Event::PointerMoved(start)], Some(start), &mut drag);
let (_, pressed) = run(&ctx, crate::test_support::pointer(start, true), Some(start), &mut drag);
assert!(pressed.grabbed && pressed.step.is_none());
let mut steps = Vec::new();
for x in [190.0, 200.0, 230.0] {
let at = pos2(x, 205.0);
let (snap, orbit) = run(&ctx, vec![Event::PointerMoved(at)], Some(at), &mut drag);
assert_eq!(snap, None, "dragging over a tip does not snap");
steps.extend(orbit.step);
}
let travel = steps.iter().fold((0.0, 0.0, false), |(x, y, held), s| (x + s.dx, y + s.dy, held || s.snap));
assert!(!steps.is_empty());
assert_eq!(travel, (50.0, -10.0, false), "the steps add up to the pointer's travel once the drag is decided");
let end = pos2(230.0, 205.0);
let (_, released) = run(&ctx, crate::test_support::pointer(end, false), Some(end), &mut drag);
assert!(released.released && drag.at.is_none());
}
}
+127 -52
View File
@@ -7240,58 +7240,99 @@ pub(crate) fn axis_rect(app: &SolveApp, rect: Rect) -> Rect {
})
}
/// The camera's view axes, as the view compass takes them.
fn view_axes(cam: &Camera) -> crate::view_compass::ViewAxes {
let (right, up, back) = cam.basis();
crate::view_compass::ViewAxes { right: right.into(), up: up.into(), back: back.into() }
}
const AXIS_LENGTH: f64 = 38.0;
/// The artwork sits inside the pointer reach so the compass keeps breathing room in its circle.
const AXIS_ART_SCALE: f64 = 0.84;
use crate::icons::navigation::AXIS_RADIUS;
/// Six signed world axes, sorted back to front so overlapping tips have one owner (for checks).
#[cfg(test)]
fn axis_tips(cam: &Camera, c: Pos2) -> Vec<crate::view_compass::Tip> {
crate::view_compass::tips(&view_axes(cam), c)
/// Six signed world axes, sorted back to front so overlapping tips have one owner.
fn axis_tips(cam: &Camera, c: Pos2) -> Vec<(&'static str, Vec3, Pos2, f64)> {
let (r, u, b) = cam.basis();
let mut tips: Vec<_> = [("x", Vec3::X), ("-x", -Vec3::X), ("y", Vec3::Y), ("-y", -Vec3::Y), ("z", Vec3::Z), ("-z", -Vec3::Z)]
.into_iter()
.map(|(name, d)| {
(name, d, c + vec2((d.dot(r) * AXIS_LENGTH * AXIS_ART_SCALE) as f32, -(d.dot(u) * AXIS_LENGTH * AXIS_ART_SCALE) as f32), d.dot(b))
})
.collect();
tips.sort_by(|a, b| a.3.total_cmp(&b.3));
tips
}
/// The six signed world axes as `cam` poses them, for the navigator figure in Preferences: each
/// one's screen direction (right and down) and depth along the camera's back.
pub(crate) fn navigator_axes(cam: &Camera) -> [crate::ui_kit::NavigatorAxis; 6] {
let axes = view_axes(cam);
crate::view_compass::AXES.map(|(name, direction)| {
let ([x, y], depth) = axes.pose(direction);
crate::ui_kit::NavigatorAxis { name, direction, screen: vec2(x as f32, y as f32), depth }
})
let (right, up, back) = cam.basis();
let axis = |name: &'static str, d: Vec3| crate::ui_kit::NavigatorAxis {
name,
direction: d.into(),
screen: vec2(d.dot(right) as f32, -d.dot(up) as f32),
depth: d.dot(back),
};
[axis("x", Vec3::X), axis("-x", -Vec3::X), axis("y", Vec3::Y), axis("-y", -Vec3::Y), axis("z", Vec3::Z), axis("-z", -Vec3::Z)]
}
#[cfg(test)]
fn axis_hit(tips: &[crate::view_compass::Tip], p: Pos2) -> Option<usize> {
crate::view_compass::hit(tips, p)
fn axis_hit(tips: &[(&str, Vec3, Pos2, f64)], p: Pos2) -> Option<usize> {
tips.iter().enumerate().rev().find(|(_, (_, _, at, _))| at.distance(p) <= AXIS_RADIUS).map(|(i, _)| i)
}
/// Dragging anywhere on the view navigator orbits about the view centre. A stationary press
/// Dragging anywhere on the axis gizmo orbits about the view centre. A stationary press
/// on a tip chooses its view. Floating windows own their input over the gizmo.
fn axis_view_orbit(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) -> bool {
let pos = ui.input(|i| i.pointer.interact_pos());
let may_start = ui.is_enabled() && ui.rect_contains_pointer(rect) && !pos.is_some_and(|p| crate::shell::covered(app, p));
let mut drag = crate::view_compass::Drag { at: app.viewport.axis_drag, warp: app.viewport.axis_warp };
let orbit = crate::view_compass::orbit(ui, &mut drag, may_start, |p| {
let shown = crate::view_gizmo::shown(app);
match shown.style {
crate::prefs::NavigatorStyle::Axes => axis_rect(app, rect).contains(p),
crate::prefs::NavigatorStyle::Custom if shown.shape.is_some() => body_gizmo::drag_origin(app, rect, p),
crate::prefs::NavigatorStyle::Cube | crate::prefs::NavigatorStyle::Custom => {
navigation_cube::drag_origin(app, axis_center(app, rect), p, axis_rect(app, rect).width() / navigation_cube::DESIGN_SIZE)
}
}
let (pressed, down, decided, origin, pos, shift) = ui.input(|i| {
let p = &i.pointer;
(p.primary_pressed(), p.primary_down(), p.is_decidedly_dragging(), p.press_origin(), p.interact_pos(), i.modifiers.shift)
});
(app.viewport.axis_drag, app.viewport.axis_warp) = (drag.at, drag.warp);
if orbit.grabbed || orbit.released {
if pressed && ui.is_enabled() && ui.rect_contains_pointer(rect) && !pos.is_some_and(|p| crate::shell::covered(app, p)) {
let shown = crate::view_gizmo::shown(app);
app.viewport.axis_drag = origin.filter(|p| match shown.style {
crate::prefs::NavigatorStyle::Axes => axis_rect(app, rect).contains(*p),
crate::prefs::NavigatorStyle::Custom if shown.shape.is_some() => body_gizmo::drag_origin(app, rect, *p),
crate::prefs::NavigatorStyle::Cube | crate::prefs::NavigatorStyle::Custom => {
navigation_cube::drag_origin(app, axis_center(app, rect), *p, axis_rect(app, rect).width() / navigation_cube::DESIGN_SIZE)
}
});
app.viewport.axis_warp = None;
if app.viewport.axis_drag.is_some() {
app.viewport.orbit_free = None;
}
}
if !down && app.viewport.axis_drag.take().is_some() {
app.viewport.axis_warp = None;
app.viewport.orbit_free = None;
}
if let Some(crate::view_compass::OrbitStep { dx, dy, snap }) = orbit.step {
let Some(last) = app.viewport.axis_drag.filter(|_| decided) else { return false };
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
if let Some(p) = pos {
app.cancel_view_animation();
orbit_drag(app, rect, dx, dy, None, snap, false);
// While a warp is pending the pointer may still sit at the edge it hit (the OS has not
// echoed the warp, or cannot), have landed at the centre (the echo), or have moved past
// it (the echo plus a fast flick): measure each step from whichever reference the
// pointer is nearer to, so the view never jumps by the warp distance and a platform
// that ignores warps keeps orbiting from the real pointer instead of freezing.
let from = match app.viewport.axis_warp {
Some(w) if p.distance(w) <= p.distance(last) => {
app.viewport.axis_warp = None;
w
}
_ => last,
};
orbit_drag(app, rect, f64::from(p.x - from.x), f64::from(p.y - from.y), None, shift, false);
app.viewport.axis_drag = Some(p);
// The OS stops feeding movement once the cursor is pinned at the screen edge, so an
// orbit toward that edge would stall: warp the cursor back to the window centre and
// measure the next steps from there, keeping the orbit tracking while the button is held (#9943).
let screen = ui.ctx().viewport_rect();
let edge = 6.0;
if app.viewport.axis_warp.is_none()
&& (p.x <= screen.min.x + edge || p.x >= screen.max.x - edge || p.y <= screen.min.y + edge || p.y >= screen.max.y - edge)
{
let home = screen.center();
app.viewport.axis_warp = Some(home);
ui.ctx().send_viewport_cmd(egui::ViewportCommand::CursorPosition(home));
}
ui.ctx().request_repaint();
}
orbit.orbiting
true
}
/// The whole navigator's box takes the right-click for the navigator's menu, before the view
@@ -7339,30 +7380,64 @@ fn signed_axis_view(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
return;
}
crate::scenario::publish_rect("axis_view:outer", axis_rect(app, rect));
let t = Tokens::get();
let c = axis_center(app, rect);
let orbiting = axis_view_orbit(app, ui, rect);
let compass = crate::view_compass::Compass {
clip: rect,
centre: axis_center(app, rect),
axes: view_axes(&app.cam),
hover: ui.input(|i| i.pointer.hover_pos()).filter(|p| ui.is_enabled() && ui.rect_contains_pointer(rect) && !crate::shell::covered(app, *p)),
orbiting,
snap_hint: !app.preferences.free_orbit,
home_label: solidcraft_ui_text::tr_label("Home view").to_owned(),
};
// The compass returns a tip or Home; its drag came from `axis_view_orbit` above.
match crate::view_compass::show(ui, &compass) {
Some(crate::view_compass::ViewAction::Snap { direction, .. }) => {
let d = Vec3::from(direction);
let frame = app.cam.to_frame(d);
let dir = if app.cam.back().dot(d) > 0.9999 { -d } else { d };
let tips = axis_tips(&app.cam, c);
let hover = ui.input(|i| i.pointer.hover_pos()).filter(|p| ui.is_enabled() && ui.rect_contains_pointer(rect) && !crate::shell::covered(app, *p));
let hovered = if orbiting { None } else { hover.and_then(|p| axis_hit(&tips, p)) };
let painter = ui.painter_at(rect);
// Anywhere in the compass circle drags the view; the circle turns fully opaque under the pointer.
let in_circle = hover.is_some_and(|p| p.distance(c) <= 49.5);
let fill = if in_circle || orbiting { 1.0 } else { 0.35 };
painter.circle(c, 49.5, t.overlay.gamma_multiply(fill), Stroke::new(1.0, t.border.gamma_multiply(0.75)));
for (i, (name, d, at, depth)) in tips.iter().enumerate() {
if i == 3 {
// The origin dot sits behind the three front axes and over the three back ones.
painter.circle(c, 3.6, t.text.gamma_multiply(0.9), Stroke::new(1.4, t.panel));
}
crate::icons::navigation::axis_tip(&painter, c, name, d, *at, *depth, hovered == Some(i), t);
crate::scenario::publish_handle(&format!("axis_view:{name}"), *at);
}
crate::scenario::publish_count("axis_view_highlights", f64::from(u8::from(hovered.is_some())));
if let Some(i) = hovered
&& let Some((name, d, _, _)) = tips.get(i)
{
crate::scenario::publish_handle(&format!("axis_view_hover:{name}"), hover.unwrap_or(c));
ui.interact(Rect::from_center_size(hover.unwrap_or(c), vec2(2.0, 2.0)), ui.scope_id().with("axis_view_tip"), Sense::hover())
.backing_on_hover_text(format!(
"View from {}. Click again to reverse. Drag to orbit{}.",
name.to_uppercase(),
if app.preferences.free_orbit { "" } else { "; hold Shift to snap" }
));
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
if ui.input(|i| i.pointer.primary_clicked()) {
let frame = app.cam.to_frame(*d);
let dir = if app.cam.back().dot(*d) > 0.9999 { -*d } else { *d };
let mut to = app.cam.looking_from(dir);
if frame.z.abs() > 0.5 {
to.yaw = nearest_quarter_turn(app.cam.yaw);
}
app.animate_turn(to);
}
Some(crate::view_compass::ViewAction::Home) => app.animate_view("home"),
None => {}
}
let home = Rect::from_center_size(pos2(c.x - 37.0, c.y - 37.0), vec2(18.0, 18.0));
crate::scenario::publish_target("axis_view:home", home);
let hr = ui.interact(home, ui.scope_id().with("axis_view_home"), Sense::click());
// The home button sits in its own small round chip.
painter.circle(home.center(), 12.0, t.overlay.gamma_multiply(if hr.hovered() { 0.9 } else { 0.5 }), Stroke::new(1.0, t.border));
icons::paint_icon(&painter, home, "home", f32::from(t.icon.a()) / 255.0);
// Cursors: a grabbing hand while orbiting, an open hand over the circle, a pointer on the home
// chip (the axis ends keep their pointing hand).
if hr.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
} else if orbiting {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
} else if in_circle && hovered.is_none() {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
}
if hr.backing_on_hover_text(solidcraft_ui_text::tr_label("Home view")).clicked() {
app.animate_view("home");
}
}
@@ -685,7 +685,7 @@ fn compass_negative_ends_are_hollow_and_positive_ends_are_chunkier() {
let original = c + vec2(38.0, 0.0);
let painted = c + (original - c) * 0.84;
let output = draw(&ctx, |ui| {
crate::icons::navigation::axis_tip(ui.painter(), c, name, direction.into(), painted, 0.5, false, t);
crate::icons::navigation::axis_tip(ui.painter(), c, name, &direction, painted, 0.5, false, t);
});
let circles: Vec<_> = output.shapes.iter().filter_map(|s| if let Shape::Circle(c) = &s.shape { Some(c) } else { None }).collect();
let end = circles.iter().find(|circle| circle.center == painted).unwrap();
@@ -929,7 +929,7 @@ fn a_hovered_compass_end_turns_white_and_its_letter_takes_the_axis_colour() {
let original = c + vec2(38.0, 0.0);
let painted = c + (original - c) * 0.84;
let output = draw(&ctx, |ui| {
crate::icons::navigation::axis_tip(ui.painter(), c, name, direction.into(), painted, 0.5, true, t);
crate::icons::navigation::axis_tip(ui.painter(), c, name, &direction, painted, 0.5, true, t);
});
let circles: Vec<_> = output.shapes.iter().filter_map(|s| if let Shape::Circle(c) = &s.shape { Some(c) } else { None }).collect();
assert!(circles.iter().any(|circle| circle.center == painted && circle.fill == t.on_accent), "{name}: the hovered end is filled white");
-9
View File
@@ -44,15 +44,6 @@ when you send a message or press **Connect** (or at launch, when you turned that
## The panel
With **Preferences → Layout → Use shared panel layout** enabled, side panels keep
the conversation above the composer. A wide floating panel places the conversation
beside the provider controls and composer when both columns have enough width.
Smaller widths keep the stacked layout. The conversation, unsent draft and scroll
state stay with the same Assistant when the layout changes. Hovering over its shared
header reports the current agent state or pending approvals; requests that need
attention use the shared attention indication. Classic and the Assistant's own
native window keep their existing layout.
In the floating layout, Assistant fills the viewport height in the shared rounded panel, with
space around its edges. The compass and navigation bar move into the remaining space beside it. It overlays the model without changing the viewport projection;
clicks and scrolling over the panel stay in the chat. The docked layout retains its side panel.
-39
View File
@@ -1,39 +0,0 @@
Cut a flat bevel along edges of a solid: the "break all edges" or "0.03 × 45°" callout on a print. You pick the edges and how the chamfer is sized.
#### Before you start
- You need a solid body with edges to chamfer.
- Pick edges one at a time, or click a face to chamfer every edge around it.
- Chamfer edges before you add other features that touch them. An edge that already meets a small chamfer or fillet has little room left, and a chamfer there can fail.
#### Options
| Choice in the dialog | Meaning | Example or reason to choose it |
| --- | --- | --- |
| Edges | The edges to chamfer. Click edges one by one, or click a face to take all of its edges. | The four top edges of a block, or the top face. |
| Chamfer Type | Equal Distance: the same leg on both faces. That is a 45° chamfer only where the faces meet at 90°. Two Distances: a different leg on each face. Distance and Angle: one leg and an angle. | Equal Distance for a standard edge break on a square corner. Distance and Angle whenever the print gives an angle, like 0.06 × 30°, and for any chamfer on a sloped face. |
| Distance (Equal Distance) | The leg length on both faces. | 0.03 in for a typical edge break. |
| Distance 1 and Distance 2 (Two Distances) | The leg on each face. | 0.06 in by 0.03 in. |
| Distance 1 and Angle (Distance and Angle) | The leg on the first face, and the angle off that face. | 0.06 in at 45°. |
| Flip | Swaps which face the first distance is measured on. Shown for Two Distances and Distance and Angle. | Turn it on if the long leg ends up on the wrong face. |
| Tangent chain | Follows the edge around smooth (tangent) bends, such as a fillet, so one pick chamfers the whole run. | Leave it on for edges that flow into a fillet. |
| Preview | Shows the chamfer on the part while you set it up. | Leave it on. |
#### Try it: break the top edges of a plate
1. Make a 3 in × 2 in × 0.75 in block (sketch a rectangle, Extrude 0.75 in).
2. Click Chamfer and click the four top edges. Edges shows 4 selected.
3. Set Chamfer Type to Equal Distance and Distance to 0.03 in.
4. Click OK. All four top edges have a 0.03 × 45° chamfer.
5. To change it, edit the chamfer and set Chamfer Type to Distance and Angle, Distance 1 to 0.06 in and Angle to 30. Use Flip to swap which face takes the 0.06 in leg.
#### Tips and common mistakes
- **Do** type the unit with the number when the print uses another unit. In a millimetre design, typing 0.03 in enters 0.762 mm.
- **Do** chamfer all the edges of one size in a single Chamfer. It's one feature to change when the print changes.
- **Do** pick the edges one by one for Two Distances or Distance and Angle. Picking a whole face works with Equal Distance, but the other two types can stop with "not supported yet: chamfers ending at a concave corner".
- **Don't** chamfer an edge right next to an existing small chamfer with a larger size. If the chamfer would run past the neighbouring face, the dialog says so; make the chamfer smaller or chamfer those edges together in one feature.
#### See also
{{#command solid.hole}} adds a countersink at the top of a hole. {{#command solid.fillet}} rounds an edge instead of beveling it.
+6 -11
View File
@@ -10,22 +10,19 @@ Put a hole in a solid body: a simple hole, a counterbore, a countersink or a tap
| Choice in the dialog | Meaning | Example or reason to choose it |
| --- | --- | --- |
| Personal Presets | Saves the dialog settings under a name you choose (Save new), so you can reuse them. Presets are saved in your profile, so they carry over to every design. Rename and Delete manage saved presets. | Save a "1/4-20 blind" preset once and pick it for every tapped hole of that size. |
| Personal Presets | Saves the dialog settings under a name you choose (Save new), so you can reuse them. Rename and Delete manage saved presets. | Save a "1/4-20 blind" preset once and pick it for every tapped hole of that size. |
| Placement | Single drills one hole on the face you pick. Multiple drills a hole at each sketch point you select. | Single for one hole located from edges. Multiple for a bolt pattern laid out in a sketch. |
| Face | The face the hole is drilled into. | Click the top face of the part. |
| Reference 1 and Distance 1 | An edge of the face, and how far the hole center is from it. | The left edge, 0.75 in. |
| Reference 2 and Distance 2 | A second edge, and how far the hole center is from it. | The front edge, 0.75 in. |
| Extents | How the depth is set. Distance drills to the Depth you type. | Use Distance for a blind hole. |
| Hole Type | Simple is a straight hole. Counterbore and Countersink add a bore or a cone at the top. | Counterbore for a socket head cap screw. Countersink for a flat head screw: 82° for inch screws, 90° for metric. |
| Hole Tap Type | Simple is a plain drilled hole with a Diameter you type. Tapped drills the tap drill size and shows the thread settings. | Choose Tapped for any tapped hole. |
| Hole Tap Type | Simple is a plain drilled hole with a Diameter you type. Tapped drills the tap drill size and carries the thread as a callout; the thread itself is not cut into the hole wall. | Choose Tapped for any tapped hole. To show real thread geometry, use {{#command solid.thread}} on the hole wall. |
| Profile (Tapped) | The thread standard. | UNC for coarse inch threads, UNF for fine inch threads. |
| Size (Tapped) | The thread size, from a list. The Designation box shows the callout. | 1/4-20 UNC. |
| Tap drill diameter (Tapped) | The drill for the tapped hole. It fills in the standard tap drill for the size, the one on a tap drill chart (the app's messages call it "the tap maker's drill"), and you can type a different one. The app accepts drills from the smaller of the thread's minor diameter and the standard tap drill, up to 5% over the standard tap drill, and says the allowed range if you go outside it. For a custom metric size with no entry in the app's tap drill table, the app fills in major diameter minus pitch, the usual rule of thumb; check a tap drill chart. | 1/4-20 UNC fills in 5.1054 mm (0.201 in, a #7 drill). Typing 0.203 in (13/64) is accepted; 0.220 in is refused because too little thread would engage. |
| Size (Tapped) | The thread size, from a list. The Designation box shows the callout, and Tap drill diameter shows the drill the app uses. | 1/4-20 UNC uses a 0.201 in (#7) tap drill. The app shows it as 5.1054 mm. |
| Full thread depth (Tapped) | On: the thread runs the full depth of the hole. Off: a Thread depth box appears. | Turn it off for a blind tapped hole. |
| Thread depth (Tapped) | How deep the full thread goes, separate from the drill Depth. | Drill 0.625 in deep with 0.50 in of thread, so a standard tap doesn't bottom out. |
| Hand (Tapped) | Right or Left. | Right unless the print calls for LH. |
| Modeled (Tapped) | On: the thread groove is cut into the hole wall. Off: the thread is carried as a callout only. | Leave it off unless you need to see the thread or 3D print it. A modeled thread makes the part slower to rebuild. |
| End (Modeled on) | Square stops the thread sharp at the bottom. Fade runs it out over a length, the way a tap's chamfered end leaves it. | Fade for a realistic blind tapped hole. |
| Thread direction (Tapped) | Right-hand or left-hand thread. | Right-hand unless the print calls for LH. |
| Drill Point | The bottom of the hole. Angle leaves a drill point. | Use Angle for a hole made with a twist drill. |
| Depth | How deep the hole goes. | Measured to the end of the full diameter, not the tip of the drill point. |
| Tip Angle | The angle of the drill point. Default is 118°. | 118° for a standard jobber drill. 135° for most carbide and split-point drills. |
@@ -39,13 +36,11 @@ Put a hole in a solid body: a simple hole, a counterbore, a countersink or a tap
4. Click Reference 1, pick the left edge of the top face and set Distance 1 to 0.75 in. Click Reference 2, pick the front edge and set Distance 2 to 0.75 in.
5. Set Hole Tap Type to Tapped, Profile to UNC and Size to 1/4-20 UNC. Check that Tap drill diameter shows 5.1054 mm (0.201 in).
6. Turn off Full thread depth. Set Depth to 0.625 in and Thread depth to 0.50 in. Click OK.
#### Tips and common mistakes
- **Do** locate holes from edges the way the print dimensions them. When the part changes size, the holes keep their distances from those edges.
- **Do** use Tapped and pick the size from the list instead of typing a drill diameter. The hole then carries the thread callout, and the tap drill comes from a standard tap drill chart.
- **Do** check the Tap drill diameter before clicking OK. It is shown in millimetres: 5.1054 mm is 0.201 in. If your shop runs a different drill, such as 0.203 in for 1/4-20 in steel, type it in.
- **Don't** turn on Modeled just to show the hole is tapped. The callout already says it.
- **Do** use Tapped and pick the size from the list instead of typing a drill diameter. The hole then carries the thread callout, and the tap drill comes from the tap maker's table.
- **Do** check the Tap drill diameter before clicking OK. It is shown in millimetres: 5.1054 mm is 0.201 in.
- **Do** turn off Full thread depth on blind tapped holes and set a Thread depth shorter than the drill Depth, so a standard tap doesn't bottom out.
- **Do** type the unit with the number when the print is in another unit. In a millimetre design, typing 0.75 in enters 19.05 mm.
- **Don't** expect every hole to work on sheet metal. Sheet metal holes must go straight into a flat panel; holes into the edge of the sheet, countersinks and drill points are refused there.
-50
View File
@@ -1,50 +0,0 @@
Put a thread on a round face: an external thread on a shaft or stud, or an internal thread in a bore. You pick the thread form and size, how far the thread runs, and how its ends are finished.
#### Before you start
- You need a body with a cylindrical face: the outside of a shaft for an external thread, or the wall of a bore for an internal one.
- Size the cylinder first. For an external thread, make the shaft the thread's major diameter (12 mm for M12). For an internal thread, make the bore the tap drill size.
- Click the face near the end you want threaded. On a shaft with two free ends, that tells a limited-length thread where to start.
- For an ordinary tapped hole, including UNC and UNF, use {{#command solid.hole}} with Hole Tap Type set to Tapped instead. It drills the tap drill and keeps the thread callout on the hole.
#### Options
| Choice in the dialog | Meaning | Example or reason to choose it |
| --- | --- | --- |
| Cylindrical face | The round face the thread goes on. | Click the outside of the shaft near the end to thread. |
| Form | The thread standard: ISO metric, Trapezoidal (ISO 2904), Acme (ASME B1.5), Buttress (DIN 513 or ASME B1.9), and the pipe threads Pipe G (ISO 228-1), Pipe Rp (ISO 7-1), Pipe taper R, Rc (ISO 7-1) and Pipe taper NPT (ASME B1.20.1). UNC and UNF are not in this list. | ISO metric for fasteners. Acme or Trapezoidal for lead screws. Buttress for a screw that takes load one way, like a vise screw. Pipe taper NPT for US tapered pipe threads. |
| Modeled | On: the thread groove is cut into the part. Off: the thread is carried as a callout only. | Leave it off unless you need to see the thread or 3D print it. A modeled thread makes the part slower to rebuild. |
| Automatic size | On by default: picks the standard size that fits the face you selected. | Leave it on when the shaft is a standard diameter. |
| Size and Pitch | The thread size from the standard's table, and for metric threads the pitch. | M12 with the standard 1.75 mm pitch, or 1.25 mm for M12 fine. |
| Designation | The thread callout. You can type it directly. | M12, M12x1.25. |
| Hand | Right, Left, or Both. Pipe threads offer Right or Left only. | Right unless the print calls for LH. |
| Starts | Number of thread starts, 1 to 8. | 1 for fasteners. 2 or more for a fast-moving lead screw: each turn advances the pitch times the number of starts. |
| Steep Flank (Buttress only) | Which end the steep, load-carrying flank faces. | Point it toward the load. |
| Extent | Full runs the whole face. Length runs a set distance. Up to stops at a face or plane you pick. | Length for a threaded end on a longer shaft. Up to for a thread that stops at a shoulder. |
| Start end (optional) | Which end of the face the thread is measured from. Shown once Extent is Length or Up to. | Pick it when the thread starts at the wrong end. |
| Start (Ends) | None leaves the thread's free end plain. Chamfer adds a lead-in chamfer and shows Chamfer Angle and Chamfer Depth; left blank, they use the ISO 4753 standard chamfer. | Chamfer the start of any thread that has to take a nut or go into a tapped hole by hand. |
| End (Ends) | Square stops the thread sharp. Fade runs it out over a length (ISO 3508 runout, 2.5 pitches standard). | Fade for a thread cut with a die or single-point tool that stops short of a shoulder. |
#### Try it: a threaded shaft end
1. Sketch a 12 mm circle on the XY plane and extrude it 50 mm.
2. Click Thread, then click the round face of the shaft near its top end.
3. Check that Form is ISO metric and Automatic size picked M12 with a 1.75 mm pitch.
4. Set Extent to Length and the length to 20 mm.
5. Turn on Modeled to see the thread cut into the shaft.
6. Under Ends, set Start to Chamfer. Leave Chamfer Angle and Chamfer Depth blank for the standard ISO 4753 chamfer.
7. Click OK. The top 20 mm of the shaft carries an M12 thread with a lead-in chamfer.
#### Tips and common mistakes
- **Do** size the cylinder before threading it: major diameter for an external thread, tap drill size for an internal one.
- **Do** click the face near the end you want threaded, or pick Start end. Otherwise a Length thread can start at the wrong end.
- **Do** chamfer the start of an external thread. The shop will put one there anyway, and a nut starts on it in the model the same way it does on the part.
- **Do** leave room for runout before a shoulder. A thread can't run full depth right up to a shoulder without a relief groove.
- **Don't** turn on Modeled just to show the part is threaded. The callout already says it.
- **Don't** look for UNC or UNF here. Inch fastener threads are only on {{#command solid.hole}} tapped holes for now; the inch forms in this list are Acme, ASME buttress and the pipe threads.
- **Don't** mix up NPT and the ISO taper (R, Rc, often called BSPT). Both are tapered pipe threads, but the thread angle and pitch differ, and they don't seal together.
#### See also
{{#command solid.hole}} makes tapped holes with the right tap drill, including UNC and UNF. {{#command solid.chamfer}} breaks an edge by hand. {{#command solid.revolve}} makes a turned part to thread.
+1 -1
View File
@@ -85,7 +85,7 @@ origin; the design itself does not move. The face is tinted and outlined in the
copy made with Move/Copy or Mirror keeps it. A print face that is gone or no longer flat is
reported, and the body is sent as it lies. Curved faces and mesh bodies can't be print faces.
Known limitation: after a shell of a body that is already shelled, no face of that body can be
marked, as no sketch can be attached to one either; this needs kernel work in solidcore.
marked, as no sketch can be attached to one either; this needs kernel work, which is paused.
The list also has a plain **EasyPrint (Printables, in the browser)** row, and it is the one entry
that works in the browser build too. EasyPrint is Printables' slicer, so choosing it uploads the
+1 -9
View File
@@ -26,14 +26,6 @@ Browser/History uses the existing content preference and suppresses the separate
History body. Render Inspector, Shots and appearances keep their existing hosts
and contribute their actual footprints to navigation and camera fitting.
Assistant uses the shared flow and actual content width. Tall content keeps the
conversation above its pinned composer. A viable Wide presentation puts the
conversation beside the existing provider controls and composer; smaller widths
retain the stacked presentation. Both use the same content identities, draft,
scroll state and backend. Shared header status on hover and its attention indication
come from the existing Assistant state and pending approvals. Classic and native
Assistant composition is unchanged.
Visible bodies report their natural scroll content and fixed control bands.
A sole production panel hugs that content until column capacity requires scrolling;
Assistant declares Fill to use the full available side height. A newly visible unmeasured
@@ -58,7 +50,7 @@ Each section contains ordered tabs. Inactive tabs still constrain composite size
limits. Registrations declare allowed zones, PinOuter, Content/Fill height policy,
text-heavy content, surface preference, header actions and independent
`closable`/`hot_closable` flags. No generic host branches on product panel names.
Only the History product registration allows Bottom; synthetic kinds can allow
Only future History product registration allows Bottom; synthetic kinds can allow
all zones. Quick close is an affordance, not the close capability itself.
Layout is application/workspace preference data. It never enters CAD files,
+1 -1
View File
@@ -413,7 +413,7 @@ completed drawings publish atomically. The browser still exports synchronously.
- Print faces (#8429) are held like sketches on faces, so the same limit applies: after a shell
of a body that is already shelled, no face of that body has an exact name, and none can be
marked (`body.print_face` says so). Fixing it needs the shell to report its face history,
which is kernel work, done in `solidcore/` (CONTRIBUTING.md, "Kernel work goes in solidcore/").
which is kernel work, paused for now (CONTRIBUTING.md, "Kernel work is paused").
- G2 fillets: exact quintic blends of straight edges between planar faces, with one planar
end face at each end, including sloped ends (#2708). The UI and `solid.fillet` expose
`curvature` (alias `g2`); the size is an equivalent circular setback. Curved faces/edges,
+91
View File
@@ -0,0 +1,91 @@
'use strict';
// While a new geometry kernel is evaluated, new issues and discussions about kernel work, and new
// pull requests that change the kernel directories, get the `kernel-paused` label, so a maintainer
// finds them and answers in person (CONTRIBUTING.md, "Kernel work is paused"). Reports of crashes
// and data loss are left alone, and so is anyone who can push to the repository.
const LABEL = 'kernel-paused';
const LABEL_COLOR = 'b60205';
const LABEL_DESCRIPTION = 'Kernel work is paused while a new geometry kernel is evaluated';
// Kernel operations named in the title of an issue or discussion. "Hole" and "shell" are left out
// on purpose: they name UI work too. A pull request is judged by its files, never by its title.
const TOPIC =
/\b(booleans?|fillets?|chamfers?|offsets?|sweeps?|lofts?|threads?|step|stp|iges|igs|tessellat\w*|kernel|truck|b-?rep)\b/i;
// Crashes and lost work come first whatever the kernel is.
const URGENT = /\b(crash\w*|panic\w*|data loss|lost (my )?work|corrupt\w*)\b/i;
// What a pull request changes that makes it kernel work.
const KERNEL_PATHS = ['crates/kernel/', 'crates/geom/', 'solidcore/'];
function isTopic(title) {
return TOPIC.test(title || '');
}
function isUrgent(title, body) {
return URGENT.test(`${title || ''}\n${body || ''}`);
}
function touchesKernel(files) {
return files.some((f) => KERNEL_PATHS.some((p) => f.startsWith(p)));
}
// Whether the author can push to the repository (maintainers and their agents are not paused).
async function canPush({ github, context }, username) {
try {
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ ...context.repo, username });
return ['admin', 'maintain', 'write'].includes(data.permission);
} catch {
return false;
}
}
async function ensureLabel({ github, context }) {
try {
await github.rest.issues.getLabel({ ...context.repo, name: LABEL });
} catch (e) {
if (e.status !== 404) throw e;
await github.rest.issues.createLabel({ ...context.repo, name: LABEL, color: LABEL_COLOR, description: LABEL_DESCRIPTION });
}
}
// A new issue or pull request: returns what was done, for the log.
async function handleOpened({ github, context }) {
const item = context.payload.pull_request || context.payload.issue;
if (!item) return 'nothing to do';
const pull = Boolean(context.payload.pull_request);
if (isUrgent(item.title, item.body)) return `#${item.number}: a crash or data-loss report, left alone`;
if (await canPush({ github, context }, item.user.login)) return `#${item.number}: by a maintainer, left alone`;
let kernel;
if (pull) {
const files = await github.paginate(github.rest.pulls.listFiles, { ...context.repo, pull_number: item.number, per_page: 100 });
kernel = touchesKernel(files.map((f) => f.filename));
} else {
kernel = isTopic(item.title);
}
if (!kernel) return `#${item.number}: not kernel work`;
await ensureLabel({ github, context });
await github.rest.issues.addLabels({ ...context.repo, issue_number: item.number, labels: [LABEL] });
return `#${item.number}: labelled ${LABEL}`;
}
// A new discussion.
async function handleDiscussion({ github, context }) {
const d = context.payload.discussion;
if (!d) return 'nothing to do';
if (!isTopic(d.title) || isUrgent(d.title, d.body)) return `discussion #${d.number}: left alone`;
if (await canPush({ github, context }, d.user.login)) return `discussion #${d.number}: by a maintainer, left alone`;
await ensureLabel({ github, context });
const { repository } = await github.graphql(
'query($owner: String!, $repo: String!, $name: String!) { repository(owner: $owner, name: $repo) { label(name: $name) { id } } }',
{ ...context.repo, name: LABEL },
);
if (repository && repository.label) {
await github.graphql('mutation($id: ID!, $labels: [ID!]!) { addLabelsToLabelable(input: {labelableId: $id, labelIds: $labels}) { clientMutationId } }', {
id: d.node_id,
labels: [repository.label.id],
});
}
return `discussion #${d.number}: labelled ${LABEL}`;
}
module.exports = { LABEL, isTopic, isUrgent, touchesKernel, handleOpened, handleDiscussion };
+109
View File
@@ -0,0 +1,109 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const paused = require('./kernel-paused.js');
const repo = { owner: 'tangency-labs', repo: 'solidcraft' };
// A fake github-script client that records writes. `world` describes the repo.
function fake(world = {}) {
const calls = [];
const rec = (name) => async (args) => {
calls.push([name, args]);
return { data: {} };
};
const github = {
paginate: async (fn, args) => (await fn(args)).data,
graphql: async (query, args) => {
calls.push([query.trim().split(/[\s({]/)[0] === 'query' ? 'query' : query.match(/\{\s*(\w+)/)[1], args]);
return { repository: { label: { id: 'L1' } } };
},
rest: {
repos: {
getCollaboratorPermissionLevel: async ({ username }) => ({ data: { permission: (world.perms || {})[username] || 'read' } }),
},
pulls: { listFiles: async () => ({ data: (world.files || []).map((filename) => ({ filename })) }) },
issues: {
getLabel: async () => {
if (world.labelMissing) throw Object.assign(new Error('nf'), { status: 404 });
return { data: {} };
},
createLabel: rec('createLabel'),
addLabels: rec('addLabels'),
createComment: rec('comment'),
},
},
};
return { github, calls };
}
const names = (calls) => calls.map((c) => c[0]);
test('kernel topics are named in titles; hole and shell are not', () => {
for (const t of ['Fillet fails on a cone', 'Booleans: cut leaves a sliver', 'STEP import drops a face', 'Tessellation hangs']) {
assert.ok(paused.isTopic(t), t);
}
for (const t of ['Hole dialog: diameter field', 'Shell command icon', 'Sketch: dimension text', 'Stepper widget']) {
assert.ok(!paused.isTopic(t), t);
}
});
test('a kernel issue is labelled, nothing more', async () => {
const { github, calls } = fake({ labelMissing: true });
const context = { repo, payload: { issue: { number: 5, title: 'Chamfer on a spline edge fails', body: '', user: { login: 'someone' } } } };
await paused.handleOpened({ github, context });
assert.deepEqual(names(calls), ['createLabel', 'addLabels']);
});
test('crashes, data loss and maintainers are left alone', async () => {
for (const body of ['The app crashed when I filleted this', 'I lost my work after a boolean']) {
const { github, calls } = fake();
await paused.handleOpened({ github, context: { repo, payload: { issue: { number: 6, title: 'Fillet', body, user: { login: 'someone' } } } } });
assert.deepEqual(calls, [], body);
}
const { github, calls } = fake({ perms: { owner: 'admin' } });
await paused.handleOpened({ github, context: { repo, payload: { issue: { number: 7, title: 'Fillet', body: '', user: { login: 'owner' } } } } });
assert.deepEqual(calls, []);
});
test('a pull request that changes the kernel is labelled, whatever its title', async () => {
const { github, calls } = fake({ files: ['docs/a.md', 'crates/kernel/src/blend.rs'] });
const pr = { number: 8, title: 'Speed up a hot path', body: '', draft: false, node_id: 'PR_1', user: { login: 'someone' } };
await paused.handleOpened({ github, context: { repo, payload: { pull_request: pr } } });
assert.deepEqual(names(calls), ['addLabels']);
});
test('a pull request outside the kernel is left alone', async () => {
const { github, calls } = fake({ files: ['crates/ui-egui/src/dialogs.rs'] });
const pr = { number: 9, title: 'Dialog spacing', body: '', draft: false, node_id: 'PR_2', user: { login: 'someone' } };
await paused.handleOpened({ github, context: { repo, payload: { pull_request: pr } } });
assert.deepEqual(calls, []);
});
test('a pull request with a kernel topic in its title but no kernel files is left alone', async () => {
const { github, calls } = fake({ files: ['crates/ui-egui/src/dialogs.rs'] });
const pr = { number: 10, title: 'Fillet dialog: a clearer radius field', body: '', draft: false, node_id: 'PR_3', user: { login: 'someone' } };
await paused.handleOpened({ github, context: { repo, payload: { pull_request: pr } } });
assert.deepEqual(calls, []);
});
test('a pull request that changes crates/geom is labelled', async () => {
const { github, calls } = fake({ files: ['crates/geom/src/lib.rs'] });
const pr = { number: 11, title: 'Tidy a helper', body: '', draft: false, node_id: 'PR_4', user: { login: 'someone' } };
await paused.handleOpened({ github, context: { repo, payload: { pull_request: pr } } });
assert.deepEqual(names(calls), ['addLabels']);
});
test('a pull request that changes the vendored kernel is labelled', async () => {
const { github, calls } = fake({ files: ['solidcore/crates/solidcore/src/blend.rs'] });
const pr = { number: 12, title: 'Tidy a helper', body: '', draft: false, node_id: 'PR_5', user: { login: 'someone' } };
await paused.handleOpened({ github, context: { repo, payload: { pull_request: pr } } });
assert.deepEqual(names(calls), ['addLabels']);
});
test('a kernel discussion is labelled', async () => {
const { github, calls } = fake();
const d = { number: 3, title: 'Ideas for better booleans', body: '', node_id: 'D_1', user: { login: 'someone' } };
await paused.handleDiscussion({ github, context: { repo, payload: { discussion: d } } });
assert.deepEqual(names(calls), ['query', 'addLabelsToLabelable']);
});
+1 -9
View File
@@ -54,17 +54,12 @@ function parseDefaults(body) {
if (pref && pref[1].trim() && !/^<.*>$/.test(pref[2])) {
return { status: 'yes', detail: pref[1].trim(), preference: pref[2] };
}
// solidcore's rule is the opposite of the app's: a kernel result change becomes the default and the
// old path is deleted, no flag (solidcore/AGENTS.md §4b). Accepted only for PRs that stay in solidcore/.
const kernel = yes[1].match(/^(.*?)[ ,;]*\bkernel result change\b[ ,;]*no (?:preference|flag)\.?$/i);
if (kernel && kernel[1].trim()) return { status: 'kernel', detail: kernel[1].trim() };
}
return { status: 'malformed', detail: value };
}
const DEFAULTS_HELP =
'Add one line to the description: `Changes a default: no`, or ' +
'(solidcore/ only) `Changes a default: yes — <what changes>; kernel result change, no preference`, or ' +
'`Changes a default: yes — <what>, old behaviour kept behind preference <name>`.';
function isBot(user) {
@@ -156,10 +151,7 @@ async function checkDefaults({ github, context, core }) {
})).map((f) => f.filename);
if (isDocsOnly(files)) return { skipped: 'docs only' };
const result = parseDefaults(pull.body);
if (result.status === 'kernel' && !files.every((f) => f.startsWith('solidcore/') || DOC_FILE.test(f))) {
core.setFailed('"kernel result change, no preference" is only for pull requests that change solidcore/ alone; ' +
`a change to the app keeps its old behaviour behind a preference. ${DEFAULTS_HELP}`);
} else if (result.status === 'missing') {
if (result.status === 'missing') {
core.setFailed(`The description has no "Changes a default:" line. ${DEFAULTS_HELP}`);
} else if (result.status === 'malformed') {
core.setFailed(`Could not read "Changes a default: ${result.detail}". ${DEFAULTS_HELP}`);
-7
View File
@@ -47,13 +47,6 @@ test('parseDefaults yes forms', () => {
assert.equal(pc.parseDefaults('Changes a default: yes — x, old behaviour kept behind preference <name>').status, 'malformed');
});
test('parseDefaults kernel result change (solidcore/ only)', () => {
const r = pc.parseDefaults('Changes a default: yes — the fillet corner patch is G2 now; kernel result change, no preference');
assert.equal(r.status, 'kernel');
assert.equal(r.detail, 'the fillet corner patch is G2 now');
assert.equal(pc.parseDefaults('Changes a default: yes - kernel result change, no preference').status, 'malformed');
});
test('isBot and isDocsOnly', () => {
assert.ok(pc.isBot({ login: 'dependabot[bot]', type: 'Bot' }));
assert.ok(pc.isBot({ login: 'solidcraft-generated[bot]' }));
+5 -9
View File
@@ -72,7 +72,7 @@ not modelling a part through MCP.
operation protocol in `plan/execution-plan.md` section 6; do not stop to ask unless it lists the decision
as the owner's. Contributors without `plan/` can ignore this.
- **Update the roadmap** when a milestone task lands: `docs/roadmap-detail.md` tracks status, milestones and estimates; `ROADMAP.md` is the short user-facing roadmap.
- **Start here.** What to work on: `ROADMAP.md`, "Where help is wanted most" in CONTRIBUTING.md and the open issues labelled `help wanted`, `good first issue`, `needs-triage`; check what is already open first. Architecture: `docs/architecture.md` and "How the code is laid out" in CONTRIBUTING.md; kernel work goes in `solidcore/` ("Kernel work goes in solidcore/" in CONTRIBUTING.md). Licence rules: `deny.toml` and `cargo xtask licences --check`; only `crates/smlib` may use `solidcore`. When the code, docs and rules settle a question, act; ask on the issue or PR only when the answer changes what users see or what the change covers.
- **Start here.** What to work on: `ROADMAP.md`, "Where help is wanted most" in CONTRIBUTING.md and the open issues labelled `help wanted`, `good first issue`, `needs-triage`; check what is already open first. Architecture: `docs/architecture.md` and "How the code is laid out" in CONTRIBUTING.md; kernel work is paused ("Kernel work is paused" in CONTRIBUTING.md). Licence rules: `deny.toml` and `cargo xtask licences --check`; only `crates/smlib` may use `solidcore`. When the code, docs and rules settle a question, act; ask on the issue or PR only when the answer changes what users see or what the change covers.
- **Do not open a second PR** for an issue a lower-numbered open PR already closes, and keep the `Changes a default:` line filled in: `pr-checks.yml` fails both.
- **The gate reads the working tree.** Do not switch branches while `cargo xtask ci` runs: it compiles the other branch's code and fails on a change not in the PR. Run it in the background and wait for its log.
@@ -95,11 +95,6 @@ not modelling a part through MCP.
- **Do not parallelize what depends on itself.** A fix and the test that proves it, a
refactor and the callers it breaks, two edits to the same file: those stay sequential.
One subagent per independent unit of work, and no two units may write the same file.
- **Keep the cost down.** Keep one long-running agent session instead of restarting it.
Don't `--resume` a session after its prompt cache has gone cold: a cold resume re-reads the
whole history at full price. Act on CodeRabbit comments only when they're about crashes,
wrong results, data loss or security, and ignore style nitpicks. Don't push just to
re-trigger a review.
## 1. Check the environment
@@ -158,14 +153,15 @@ fails, inspect the divergence and preserve existing work; do not reset it away.
## 3. Choose an unclaimed task together
Ask about their interests and show a few issues that fit the available resources.
Kernel and geometry tasks are fine: they go in `solidcore/` (CONTRIBUTING.md, "Kernel work goes in solidcore/").
Read the current kernel-pause notice in CONTRIBUTING.md and skip kernel work
while paused, including tasks outside `crates/kernel/` that change kernel behaviour.
Fetch the whole open list, including drafts, before narrowing it:
```sh
gh issue list -R tangency-labs/solidcraft --state open --limit 500
gh pr list -R tangency-labs/solidcraft --state open --limit 500 --json number,title,files
gh issue list -R tangency-labs/solidcraft --state open --label 'good first issue' --search '-label:"in progress"' --limit 100
gh issue list -R tangency-labs/solidcraft --state open --label 'help wanted' --search '-label:"in progress"' --limit 100
gh issue list -R tangency-labs/solidcraft --state open --label 'good first issue' --search '-label:kernel-paused -label:"in progress"' --limit 100
gh issue list -R tangency-labs/solidcraft --state open --label 'help wanted' --search '-label:kernel-paused -label:"in progress"' --limit 100
```
Let the person choose. Set `N` to that issue's number, then inspect its discussion,
+1 -1
View File
@@ -27,4 +27,4 @@ The desktop app writes every panic and every unclean exit to a local crash folde
- `solidcraft-cli crashes`, `solidcraft-cli crashes show <id>`, `solidcraft-cli crashes replay <id>`
- the `diagnostics.crash_reports` command, or the MCP `crash_reports` tool
Crashes and data loss are taken first, including in kernel code, which lives in `solidcore/` (CONTRIBUTING.md, "Kernel work goes in solidcore/").
Crashes and data loss are taken as issues even while kernel work is paused (CONTRIBUTING.md, "Kernel work is paused").
+1 -1
View File
@@ -21,7 +21,7 @@ Drawings follow the same rules with their own numbers, from 1: a drawing-format
- Imported files are untrusted: sizes, entity counts and nesting are capped, and every kernel call is guarded (`skills/solidcraft-crash-safety/SKILL.md`). What cannot be read becomes a warning on the feature.
- STEP is read by our own reader in `crates/kernel/src/step_in`, written from the public standards, with no third-party STEP code. The reader and what it supports: `docs/step-import.md`.
- Imported assemblies are rigid by default; Unlock Contents releases them.
- Kernel work (booleans, STEP/IGES import and export, tessellation) goes in `solidcore/`: CONTRIBUTING.md, "Kernel work goes in solidcore/".
- Kernel work (booleans, STEP/IGES import and export, tessellation) is paused except crashes and data loss: CONTRIBUTING.md, "Kernel work is paused".
## Export
- STEP, 3MF, STL, OBJ and URDF go through `file.export` and `file.save_mesh`; the MCP `export` tool calls them. URDF: `docs/urdf.md`. 3MF face tags: `docs/formats/3mf-face-tags.md`.
+1 -1
View File
@@ -23,5 +23,5 @@ The authority is `solidcore/docs/layers.md`; this is its short form. Every job l
## Working in solidcore/
- Follow `solidcore/AGENTS.md` and the reference doc for the area (`solidcore/docs/`). A change there needs a code owner's review (`.github/CODEOWNERS`) and runs SolidCore checks (its tests, goldens, kernel rig, clippy, fmt, panic sites) in `.github/workflows/ci.yml`.
- New kernel cases go in `solidcore/crates/solidcore/tests/kernel_cases/`, never in SolidCraft (`cargo xtask lists`).
- Kernel work goes here: CONTRIBUTING.md, "Kernel work goes in solidcore/". Kernel-only PRs target the `kernel` branch.
- The kernel pause in CONTRIBUTING.md ("Kernel work is paused") still applies to `solidcore/`.
- Issues and claims for solidcore are in this repository, like any other change (`skills/solidcraft-contributing/SKILL.md`): the vendored tree is mirrored to tangency-labs/solidcore by `.github/workflows/solidcore-mirror.yml`, so pull requests are not opened there.
+1 -1
View File
@@ -15,7 +15,7 @@ description: "Use when a SolidCraft change is slow, blocks the window, runs long
- Profile first. Time with `solidcraft-cli` from `main` and from your branch, alternating runs, after the load is low. On a shared machine check `cut -d' ' -f1 /proc/loadavg` and wait while it is over 24; build with `nice`.
- Report medians with the range, the machine, and the cases. The pattern to follow is `docs/perf.md`: what was slow, why, the result, and proof that nothing changed (same bodies, volumes, areas and triangle counts).
- A speed-up never skips work on a mesh's box: decide with the exact bounds (`Body::exact_bounds`, grown by the record's tolerance as `apart` in `crates/kernel/src/ops.rs` does). A mesh is for display, export, previews and mesh bodies.
- A speed-up of kernel code goes in `solidcore/` with its root cause, per `solidcore/AGENTS.md`; do not grow `crates/kernel`. Report a slow case with its file under the `kernel-perf` label (CONTRIBUTING.md, "Where help is wanted most").
- Do not rewrite kernel code for speed: the kernel is being replaced, and kernel work is paused. Report a slow case with its file under the `kernel-perf` label (CONTRIBUTING.md, "Where help is wanted most").
- Pull requests run only the tests for what they touch; geometry-heavy tests run in the merge queue. Use `cargo xtask test` with the `ci` profile locally (`skills/solidcraft-tests/SKILL.md`).
## The browser build
+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":{}}
+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);
}
}

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