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
270 changed files with 24906 additions and 10825 deletions
+5 -17
View File
@@ -1,27 +1,15 @@
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
reviews:
profile: chill
poem: false
sequence_diagrams: false
related_issues: false
related_prs: false
suggested_labels: false
suggested_reviewers: false
in_progress_fortune: false
auto_review:
# Review when a PR is opened or marked ready, or on `@coderabbitai review`; not on every push.
drafts: false
auto_incremental_review: false
# Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
ignore_usernames:
- "dependabot[bot]"
path_instructions:
- path: "**"
instructions: |
Project rule: don't change anyone's existing UX defaults unless the change is purely additive and belongs in the application.
A changed default is when something people already rely on in the UI changes for everyone who didn't opt in: an existing control, interaction or setting value. Examples the owner declined or reverted: right click becoming select while left click orbits (make it opt-in instead); a PR that removed all camera animation when only orbit lag was to be fixed. Flag any change to something a user already relies on: a default setting or preference value, what a command or tool does when used the usual way, the UI's default layout or behaviour, a keyboard or mouse shortcut.
A changed kernel or geometry result (different faces or volumes, a case that now builds or is now refused), including anything under solidcore/, is not a changed default; don't flag it as one, and expect `Changes a default: no` for it.
Not defaults: bug fixes and more correct results, kernel or geometry result changes, new options that are off by default, and CI or tooling config. Don't flag purely additive changes: a new command, a new setting whose default keeps today's behaviour, or a new option that appears only when someone chooses it. Don't flag bug fixes that only turn a crash or an error into the result the feature always promised.
Project rule: don't change anyone's existing defaults unless the change is purely additive and belongs in the application.
Flag any change to something a user already relies on: a default setting or preference value, what a command or tool does when used the usual way, the UI's default layout or behaviour, a keyboard or mouse shortcut, or the result an existing saved design rebuilds to.
Don't flag purely additive changes: a new command, a new setting whose default keeps today's behaviour, or a new option that appears only when someone chooses it. Don't flag bug fixes that only turn a crash or an error into the result the feature always promised.
When you flag one, start the comment with "Changes a default:", say what a user sees before and after, whether a setting keeps the old behaviour, and whether the PR description's `Changes a default:` line says so.
# Where code goes, SolidCraft or SolidCore: solidcore/docs/layers.md.
@@ -189,5 +177,5 @@ reviews:
- name: "Existing defaults unchanged"
mode: "warning"
instructions: |
Pass if the pull request changes no UX default a user already relies on (default setting or preference values, what a command or tool does when used the usual way, the UI's default layout or behaviour, shortcuts), or if its changes are purely additive. UX defaults only: a changed kernel or geometry result (different faces or volumes, a case that now builds or is now refused), including anything under solidcore/, is not a changed default and passes.
Fail otherwise, naming each changed default and what a user sees before and after. Also fail if the description says `Changes a default: no` while the code changes a UX default.
Pass if the pull request changes no default a user already relies on (default setting or preference values, what a command or tool does when used the usual way, the UI's default layout or behaviour, shortcuts, or the result an existing saved design rebuilds to), or if its changes are purely additive.
Fail otherwise, naming each changed default and what a user sees before and after. Also fail if the description says `Changes a default: no` while the code changes one.
+1 -1
View File
@@ -6,7 +6,7 @@ Start with what this is, in plain words someone new to the project would underst
Changes a default: no
<!-- Required line, replace "no" if this changes what a user gets out of the box (a default value, key binding, setting, tool behaviour; a UX default only, a kernel result change is 'no'):
<!-- Required line, replace "no" if this changes what a user gets out of the box (a default value, key binding, setting, tool behaviour):
Changes a default: yes — <what>, old behaviour kept behind preference <name>
Docs-only and bot PRs are exempt. -->
+2 -6
View File
@@ -436,10 +436,6 @@ jobs:
run: |
cargo xtask mesh-decisions
cargo test -p xtask mesh_decisions --locked
- name: Apps compute no geometry
run: |
cargo xtask app-geometry
cargo test -p xtask app_geometry --locked
- name: Agent router and skills cite real files
run: |
cargo xtask skills
@@ -452,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);
+2 -2
View File
@@ -12,7 +12,7 @@ Work: ROADMAP.md, "Where help is wanted most" in CONTRIBUTING.md, issues labelle
- Never crash: no unwrap/expect/panic/unsafe in non-test code, errors are `Result`, input is hostile: clippy `-D warnings`, `hostile_params_never_panic`.
- Model decisions (what an operation does, which bodies it touches, which face or edge a point or name means, depths, nearness) use exact geometry; a mesh is for display, export, previews and mesh bodies (`xtask/mesh-decisions.txt` only shrinks): `cargo xtask mesh-decisions`.
- Everything is a command and programmatic calls never open dialogs: `skills/solidcraft-commands/SKILL.md`.
- Layering; only `crates/kernel` uses truck; solidcore only via `solidcore-ops`/`-tess`/`-record` (`xtask/solidcore-seam.txt`): `cargo xtask layers`/`app-geometry`; `solidcore/docs/layers.md`.
- Layering; only `crates/kernel` uses truck; solidcore only via `solidcore-ops`/`-tess`/`-record` (`xtask/solidcore-seam.txt`): `cargo xtask layers`; see `solidcore/docs/layers.md`.
- One outer UI container, one camera path (`navigation::handle`), colours from theme tokens: `cargo xtask ui-containers`.
- Application code is Rust only and builds for wasm: `cargo xtask wasm`, `cargo xtask web-offline-test`.
- The product name is SolidCraft; the old name lives only in `docs/compatibility.md`.
@@ -20,7 +20,7 @@ Work: ROADMAP.md, "Where help is wanted most" in CONTRIBUTING.md, issues labelle
- A design-format change is ONE new file in `crates/doc/src/format/pending/`: `scripts/test-format-pending.sh`.
- A command has input and output schemas (will become required; `schema/unconverted.txt` only shrinks): `skills/solidcraft-commands/SKILL.md`.
- New UI strings go in `assets/locales/pending/<slug>.json`, ATTRIBUTION rows in `attribution/pending/<slug>.md`: `cargo xtask lists`.
- A changed default (existing UI, not kernel results) keeps the old behaviour behind a preference; fill the PR's `Changes a default:` line.
- A changed default keeps the old behaviour behind a preference and fills the PR's `Changes a default:` line: `pr-checks.yml`.
- A regression test goes in the area's existing test file, never a per-bug file (#3226).
- Look at open PRs first, claim with a draft PR (lowest number wins), one topic per PR: `skills/solidcraft-contributing/SKILL.md`.
- A PR's title and first sentence make sense to someone who never saw your chat or the issue: `skills/solidcraft-contributing/SKILL.md`.
+9 -26
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,13 +429,9 @@ 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). A default here is a UX default only: a control, setting, preference or tool behaviour a user sees by default; a changed kernel or geometry result is `Changes a default: no`.
`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).
## Labels and triage
Generated
+12 -178
View File
@@ -125,21 +125,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher 0.4.4",
"cipher",
"cpufeatures 0.2.17",
]
[[package]]
name = "aes"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fc36d7440d015b45468ba08a21e80e056ebbd014c611c6298bb7503e04bd1"
dependencies = [
"cipher 0.5.2",
"cpubits",
"cpufeatures 0.3.1",
]
[[package]]
name = "ahash"
version = "0.8.12"
@@ -154,21 +143,6 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "alloc-no-stdlib"
version = "2.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
[[package]]
name = "alloc-stdlib"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195"
dependencies = [
"alloc-no-stdlib",
]
[[package]]
name = "allocator-api2"
version = "0.2.21"
@@ -511,15 +485,6 @@ dependencies = [
"generic-array",
]
[[package]]
name = "block-padding"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b"
dependencies = [
"hybrid-array",
]
[[package]]
name = "block2"
version = "0.5.1"
@@ -551,16 +516,6 @@ dependencies = [
"piper",
]
[[package]]
name = "brotli-decompressor"
version = "5.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
]
[[package]]
name = "bumpalo"
version = "3.20.3"
@@ -748,16 +703,7 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
dependencies = [
"cipher 0.4.4",
]
[[package]]
name = "cbc"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896"
dependencies = [
"cipher 0.5.2",
"cipher",
]
[[package]]
@@ -822,17 +768,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common 0.1.7",
"inout 0.1.4",
]
[[package]]
name = "cipher"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
dependencies = [
"crypto-common 0.2.2",
"inout 0.2.6",
"inout",
]
[[package]]
@@ -951,12 +887,6 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
[[package]]
name = "cpubits"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae"
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -1066,9 +996,9 @@ version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6"
dependencies = [
"aes 0.8.4",
"block-padding 0.3.3",
"cbc 0.1.2",
"aes",
"block-padding",
"cbc",
"dbus",
"fastrand",
"hkdf",
@@ -1165,15 +1095,6 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "ecb"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26f2a8b3e564eba0877223dc343703ad0385794e882e6d13f3a4dd5c6b1f41ac"
dependencies = [
"cipher 0.5.2",
]
[[package]]
name = "ecolor"
version = "0.37.0"
@@ -1704,7 +1625,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
dependencies = [
"color_quant",
"weezl 0.1.12",
"weezl",
]
[[package]]
@@ -2147,20 +2068,10 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"block-padding 0.3.3",
"block-padding",
"generic-array",
]
[[package]]
name = "inout"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a580c13f0ffc335ef42dd3f6b1a41b726051c5e1656d9a3aa6fddb3c5dfbbf9"
dependencies = [
"block-padding 0.4.2",
"hybrid-array",
]
[[package]]
name = "itertools"
version = "0.15.0"
@@ -2405,43 +2316,6 @@ version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "lopdf"
version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfffda0fe1ab0157e1a13c14bebd3f28671f2fccb7922f0722ec53926e6922d3"
dependencies = [
"aes 0.9.4",
"bitflags 2.13.2",
"brotli-decompressor",
"cbc 0.2.1",
"ecb",
"encoding_rs",
"flate2",
"getrandom 0.4.3",
"indexmap",
"itoa",
"log",
"md-5",
"nom",
"rand 0.10.3",
"rangemap",
"sha2 0.11.1",
"stringprep",
"thiserror 2.0.21",
"weezl 0.2.1",
]
[[package]]
name = "md-5"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98"
dependencies = [
"cfg-if",
"digest 0.11.3",
]
[[package]]
name = "memchr"
version = "2.8.3"
@@ -3437,7 +3311,6 @@ version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
dependencies = [
"chacha20",
"getrandom 0.4.3",
"rand_core 0.10.1",
]
@@ -3482,12 +3355,6 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08"
[[package]]
name = "rangemap"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a611d15b50743feb4c76b7d03edcb0e64f399c26961e4efe6975bc398be6aa3d"
[[package]]
name = "raw-window-handle"
version = "0.6.2"
@@ -3863,8 +3730,8 @@ version = "4.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4"
dependencies = [
"aes 0.8.4",
"cbc 0.1.2",
"aes",
"cbc",
"futures-util",
"generic-array",
"hkdf",
@@ -4466,7 +4333,7 @@ dependencies = [
"solidcraft-color",
"solidcraft-geom",
"web-time",
"weezl 0.1.12",
"weezl",
]
[[package]]
@@ -4535,7 +4402,6 @@ dependencies = [
"egui-wgpu",
"image",
"log",
"lopdf",
"minisign-verify",
"muda",
"nix 0.31.3",
@@ -4640,17 +4506,6 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
[[package]]
name = "stringprep"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1"
dependencies = [
"unicode-bidi",
"unicode-normalization",
"unicode-properties",
]
[[package]]
name = "subtle"
version = "2.6.1"
@@ -4774,7 +4629,7 @@ dependencies = [
"flate2",
"half",
"quick-error",
"weezl 0.1.12",
"weezl",
"zune-jpeg",
]
@@ -4813,12 +4668,6 @@ dependencies = [
"zerovec",
]
[[package]]
name = "tinyvec"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
[[package]]
name = "tokio"
version = "1.53.2"
@@ -4996,15 +4845,6 @@ version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "unicode-normalization"
version = "0.1.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d"
dependencies = [
"tinyvec",
]
[[package]]
name = "unicode-properties"
version = "0.1.5"
@@ -5386,12 +5226,6 @@ version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]]
name = "weezl"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4ca08e5ef825b65b056d9efbd95c8750683f0a6d0466d02e96dc2e4e360f3d2"
[[package]]
name = "wgpu"
version = "30.0.1"
+111 -570
View File
@@ -1,6 +1,6 @@
Third-party software in SolidCraft
SolidCraft (the desktop app, solidcraft-cli and the web app) is built from the 559 crates below.
SolidCraft (the desktop app, solidcraft-cli and the web app) is built from the 540 crates below.
Generated by `cargo xtask licences` from Cargo.lock and the crates' own licence files.
SolidCraft's own licence is in LICENSE-MIT and LICENSE-APACHE, and NOTICE.
@@ -14,10 +14,7 @@ accesskit_windows 0.35.2 (MIT OR Apache-2.0)
accesskit_winit 0.34.2 (Apache-2.0)
adler2 2.0.1 (0BSD OR MIT OR Apache-2.0)
aes 0.8.4 (MIT OR Apache-2.0)
aes 0.9.4 (MIT OR Apache-2.0)
ahash 0.8.12 (MIT OR Apache-2.0)
alloc-no-stdlib 2.0.4 (BSD-3-Clause)
alloc-stdlib 0.2.4 (BSD-3-Clause)
allocator-api2 0.2.21 (MIT OR Apache-2.0)
android-activity 0.6.1 (MIT OR Apache-2.0)
android-properties 0.2.2 (MIT)
@@ -50,11 +47,9 @@ bitflags 2.13.2 (MIT OR Apache-2.0)
block-buffer 0.10.4 (MIT OR Apache-2.0)
block-buffer 0.12.1 (MIT OR Apache-2.0)
block-padding 0.3.3 (MIT OR Apache-2.0)
block-padding 0.4.2 (MIT OR Apache-2.0)
block2 0.5.1 (MIT)
block2 0.6.2 (MIT)
blocking 1.7.0 (Apache-2.0 OR MIT)
brotli-decompressor 5.0.3 (BSD-3-Clause/MIT)
bumpalo 3.20.3 (MIT OR Apache-2.0)
bytemuck 1.25.2 (Zlib OR Apache-2.0 OR MIT)
bytemuck_derive 1.12.1 (Zlib OR Apache-2.0 OR MIT)
@@ -71,13 +66,10 @@ cadmpeg-registry 0.6.7 (Apache-2.0)
calloop 0.13.0 (MIT)
calloop-wayland-source 0.3.0 (MIT)
cbc 0.1.2 (MIT OR Apache-2.0)
cbc 0.2.1 (MIT OR Apache-2.0)
cfg-if 1.0.5 (MIT OR Apache-2.0)
cgl 0.3.2 (MIT / Apache-2.0)
chacha20 0.10.2 (MIT OR Apache-2.0)
chardetng 0.1.17 (Apache-2.0 OR MIT)
cipher 0.4.4 (MIT OR Apache-2.0)
cipher 0.5.2 (MIT OR Apache-2.0)
clipboard-win 5.4.1 (BSL-1.0)
codespan-reporting 0.13.1 (Apache-2.0)
color 0.3.3 (Apache-2.0 OR MIT)
@@ -91,7 +83,6 @@ core-foundation-sys 0.8.7 (MIT OR Apache-2.0)
core-graphics 0.23.2 (MIT OR Apache-2.0)
core-graphics-types 0.1.3 (MIT OR Apache-2.0)
core_detect 1.0.0 (MIT/Apache-2.0)
cpubits 0.1.1 (MIT OR Apache-2.0)
cpufeatures 0.2.17 (MIT OR Apache-2.0)
cpufeatures 0.3.1 (MIT OR Apache-2.0)
crc32fast 1.5.2 (MIT OR Apache-2.0)
@@ -115,7 +106,6 @@ document-features 0.2.12 (MIT OR Apache-2.0)
downcast-rs 1.2.1 (MIT/Apache-2.0)
dpi 0.1.2 (Apache-2.0 AND MIT)
dyn-clone 1.0.20 (MIT OR Apache-2.0)
ecb 0.2.1 (MIT OR Apache-2.0)
ecolor 0.37.0 (MIT OR Apache-2.0)
eframe 0.37.0 (MIT OR Apache-2.0)
egui 0.37.0 (MIT OR Apache-2.0)
@@ -202,7 +192,6 @@ image-webp 0.2.4 (MIT OR Apache-2.0)
indexmap 2.14.2 (Apache-2.0 OR MIT)
inflections 1.1.1 (MIT)
inout 0.1.4 (MIT OR Apache-2.0)
inout 0.2.6 (MIT OR Apache-2.0)
itertools 0.15.0 (MIT OR Apache-2.0)
itoa 1.0.18 (MIT OR Apache-2.0)
jni 0.22.4 (MIT OR Apache-2.0)
@@ -228,8 +217,6 @@ litemap 0.8.3 (Unicode-3.0)
litrs 1.0.0 (MIT OR Apache-2.0)
lock_api 0.4.14 (MIT OR Apache-2.0)
log 0.4.34 (MIT OR Apache-2.0)
lopdf 0.45.0 (MIT)
md-5 0.11.0 (MIT OR Apache-2.0)
memchr 2.8.3 (Unlicense OR MIT)
memmap2 0.9.11 (MIT OR Apache-2.0)
memoffset 0.9.1 (MIT)
@@ -324,13 +311,11 @@ quick-xml 0.41.0 (MIT)
quote 1.0.47 (MIT OR Apache-2.0)
r-efi 5.3.0 (MIT OR Apache-2.0 OR LGPL-2.1-or-later)
r-efi 6.0.0 (MIT OR Apache-2.0 OR LGPL-2.1-or-later)
rand 0.10.3 (MIT OR Apache-2.0)
rand 0.8.8 (MIT OR Apache-2.0)
rand_chacha 0.3.1 (MIT OR Apache-2.0)
rand_core 0.10.1 (MIT OR Apache-2.0)
rand_core 0.6.4 (MIT OR Apache-2.0)
range-alloc 0.1.5 (MIT OR Apache-2.0)
rangemap 1.8.0 (MIT/Apache-2.0)
raw-window-handle 0.6.2 (MIT OR Apache-2.0 OR Zlib)
raw-window-metal 1.1.0 (MIT OR Apache-2.0)
rayon 1.12.0 (MIT OR Apache-2.0)
@@ -402,7 +387,6 @@ spirv 0.4.0+sdk-1.4.341.0 (Apache-2.0)
stable_deref_trait 1.2.1 (MIT OR Apache-2.0)
static_assertions 1.1.0 (MIT OR Apache-2.0)
strict-num 0.1.1 (MIT)
stringprep 0.1.5 (MIT/Apache-2.0)
subtle 2.6.1 (BSD-3-Clause)
syn 2.0.119 (MIT OR Apache-2.0)
syn 3.0.6 (MIT OR Apache-2.0)
@@ -418,7 +402,6 @@ tiff 0.11.3 (MIT)
tiny-skia 0.12.0 (BSD-3-Clause)
tiny-skia-path 0.12.0 (BSD-3-Clause)
tinystr 0.8.4 (Unicode-3.0)
tinyvec 1.13.3 (Zlib OR Apache-2.0 OR MIT)
tokio 1.53.2 (MIT)
toml 1.1.8+spec-1.1.0 (MIT OR Apache-2.0)
toml_datetime 1.1.2+spec-1.1.0 (MIT OR Apache-2.0)
@@ -439,7 +422,6 @@ uds_windows 1.2.1 (MIT)
unicode-bidi 0.3.18 (MIT OR Apache-2.0)
unicode-general-category 1.1.0 (Apache-2.0)
unicode-ident 1.0.26 ((MIT OR Apache-2.0) AND Unicode-3.0)
unicode-normalization 0.1.26 ((MIT OR Apache-2.0) AND Unicode-3.0)
unicode-properties 0.1.5 ((MIT OR Apache-2.0) AND Unicode-3.0)
unicode-segmentation 1.13.3 (MIT OR Apache-2.0)
unicode-width 0.2.2 (MIT OR Apache-2.0)
@@ -473,7 +455,6 @@ web-time 1.1.0 (MIT OR Apache-2.0)
webbrowser 1.2.4 (MIT OR Apache-2.0)
webpki-root-certs 1.0.10 (CDLA-Permissive-2.0)
weezl 0.1.12 (MIT OR Apache-2.0)
weezl 0.2.1 (MIT OR Apache-2.0)
wgpu 30.0.1 (MIT OR Apache-2.0)
wgpu-core 30.0.1 (MIT OR Apache-2.0)
wgpu-core-deps-apple 30.0.1 (MIT OR Apache-2.0)
@@ -863,11 +844,10 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
aes 0.8.4, aes 0.9.4, block-buffer 0.10.4, block-buffer 0.12.1, block-padding 0.3.3, block-padding
0.4.2, cbc 0.1.2, cbc 0.2.1, chacha20 0.10.2, cipher 0.4.4, cipher 0.5.2, const-oid 0.10.2, cpubits
0.1.1, cpufeatures 0.2.17, cpufeatures 0.3.1, crypto-common 0.1.7, crypto-common 0.2.2, digest
0.10.7, digest 0.11.3, ecb 0.2.1, hkdf 0.12.4, hmac 0.12.1, hybrid-array 0.4.15, inout 0.1.4, inout
0.2.6, md-5 0.11.0, sha1 0.10.7, sha1 0.11.0, sha2 0.10.9, sha2 0.11.1
aes 0.8.4, block-buffer 0.10.4, block-buffer 0.12.1, block-padding 0.3.3, cbc 0.1.2, cipher 0.4.4,
const-oid 0.10.2, cpufeatures 0.2.17, cpufeatures 0.3.1, crypto-common 0.1.7, crypto-common 0.2.2,
digest 0.10.7, digest 0.11.3, hkdf 0.12.4, hmac 0.12.1, hybrid-array 0.4.15, inout 0.1.4, sha1
0.10.7, sha1 0.11.0, sha2 0.10.9, sha2 0.11.1
================================================================================
Apache License
@@ -1072,37 +1052,6 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
================================================================================
aes 0.9.4
================================================================================
Copyright (c) 2018-2024 The RustCrypto Project Developers
Copyright (c) 2018 Artyom Pavlov
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
ahash 0.8.12
================================================================================
@@ -1156,11 +1105,11 @@ plain 0.2.3, png 0.18.1, polling 3.11.0, pollster 0.4.0, pollster 1.0.1, rayon 1
1.2.0, secret-service 4.0.0, security-framework 2.11.1, security-framework 3.7.0,
security-framework-sys 2.17.0, send_wrapper 0.6.0, signal-hook-registry 1.4.8, simd_cesu8 1.2.0,
smallvec 1.16.2, smol_str 0.2.2, stable_deref_trait 1.2.1, tempfile 3.27.0, ttf-parser 0.25.1,
unicode-bidi 0.3.18, unicode-normalization 0.1.26, unicode-properties 0.1.5, unicode-segmentation
1.13.3, unicode-width 0.2.2, url 2.5.8, uuid 1.27.0, wasi 0.11.1+wasi-snapshot-preview1, wasip2
1.0.4+wasi-0.2.12, wasm-bindgen 0.2.129, wasm-bindgen-futures 0.4.79, wasm-bindgen-macro 0.2.129,
wasm-bindgen-macro-support 0.2.129, wasm-bindgen-shared 0.2.129, web-sys 0.3.106, weezl 0.1.12,
weezl 0.2.1, wit-bindgen 0.57.1, wl-clipboard-rs 0.9.5
unicode-bidi 0.3.18, unicode-properties 0.1.5, unicode-segmentation 1.13.3, unicode-width 0.2.2, url
2.5.8, uuid 1.27.0, wasi 0.11.1+wasi-snapshot-preview1, wasip2 1.0.4+wasi-0.2.12, wasm-bindgen
0.2.129, wasm-bindgen-futures 0.4.79, wasm-bindgen-macro 0.2.129, wasm-bindgen-macro-support
0.2.129, wasm-bindgen-shared 0.2.129, web-sys 0.3.106, weezl 0.1.12, wit-bindgen 0.57.1,
wl-clipboard-rs 0.9.5
================================================================================
Apache License
@@ -1365,23 +1314,6 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
================================================================================
alloc-no-stdlib 2.0.4, brotli-decompressor 5.0.3
================================================================================
Copyright (c) 2016 Dropbox, Inc.
All rights reserved.
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
================================================================================
allocator-api2 0.2.21, async-trait 0.1.92, dyn-clone 1.0.20, enumn 0.1.14, glifo 0.3.0, itoa 1.0.18,
libc 0.2.190, litrs 1.0.0, num_enum 0.7.6, num_enum_derive 0.7.6, proc-macro2 1.0.107, quote 1.0.47,
@@ -2001,8 +1933,8 @@ as-raw-xcb-connection 1.0.1, atspi 0.29.0, atspi-common 0.13.0, atspi-proxies 0.
0.1.17, codespan-reporting 0.13.1, encoding_rs 0.8.42, kurbo 0.13.1, multiversion_no_op 1.0.0,
nohash-hasher 0.2.0, peniko 0.6.1, polycool 0.4.0, raw-window-metal 1.1.0, rustls-platform-verifier
0.7.1, self_cell 1.3.0, siphasher 1.0.4, spade 2.15.1, static_assertions 1.1.0, sys-locale 0.3.2,
tinyvec 1.13.3, ureq 3.4.2, ureq-proto 0.6.4, utf8_iter 1.0.4, x11rb 0.13.2, x11rb-protocol 0.13.2,
zeroize 1.9.1, zeroize_derive 1.5.0, zune-core 0.5.3, zune-jpeg 0.5.15
ureq 3.4.2, ureq-proto 0.6.4, utf8_iter 1.0.4, x11rb 0.13.2, x11rb-protocol 0.13.2, zeroize 1.9.1,
zeroize_derive 1.5.0, zune-core 0.5.3, zune-jpeg 0.5.15
================================================================================
Apache License
@@ -2958,7 +2890,7 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
block-buffer 0.12.1, block-padding 0.4.2
block-buffer 0.12.1
================================================================================
Copyright (c) 2018-2025 The RustCrypto Project Developers
@@ -3087,20 +3019,6 @@ limitations under the License.
bytemuck 1.25.2, bytemuck_derive 1.12.1
================================================================================
MIT License
Copyright (c) 2019 Daniel "Lokathor" Gee.
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice (including the next paragraph) shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
================================================================================
bytemuck 1.25.2, bytemuck_derive 1.12.1, tinyvec 1.13.3
================================================================================
Copyright (c) 2019 Daniel "Lokathor" Gee.
This software is provided 'as-is', without any express or implied warranty. In no event will the authors be held liable for any damages arising from the use of this software.
@@ -3113,6 +3031,20 @@ Permission is granted to anyone to use this software for any purpose, including
3. This notice may not be removed or altered from any source distribution.
================================================================================
bytemuck 1.25.2, bytemuck_derive 1.12.1
================================================================================
MIT License
Copyright (c) 2019 Daniel "Lokathor" Gee.
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice (including the next paragraph) shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
================================================================================
byteorder 1.5.0, byteorder-lite 0.1.0, memchr 2.8.3, termcolor 1.4.1
================================================================================
@@ -3255,7 +3187,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
================================================================================
cbc 0.1.2, cbc 0.2.1
cbc 0.1.2
================================================================================
Copyright (c) 2018-2022 RustCrypto Developers
@@ -3358,36 +3290,6 @@ http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
option. All files in the project carrying such notice may not be
copied, modified, or distributed except according to those terms.
================================================================================
chacha20 0.10.2, zeroize_derive 1.5.0
================================================================================
Copyright (c) 2019-2026 The RustCrypto Project Developers
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
chardetng 0.1.17
================================================================================
@@ -3462,36 +3364,6 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
cipher 0.5.2
================================================================================
Copyright (c) 2016-2025 RustCrypto Developers
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
color_quant 1.1.0
================================================================================
@@ -3604,36 +3476,6 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
cpubits 0.1.1
================================================================================
Copyright (c) 2023-2026 The RustCrypto Project Developers
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
cpufeatures 0.2.17
================================================================================
@@ -3692,8 +3534,8 @@ SOFTWARE.
================================================================================
crc32fast 1.5.2, dpi 0.1.2, foreign-types 0.5.0, foreign-types-macros 0.2.4, foreign-types-shared
0.3.1, hex 0.4.3, jni-sys 0.3.1, jni-sys 0.4.1, quick-error 2.0.1, stringprep 0.1.5, toml_edit
0.25.15+spec-1.1.0, unicode-general-category 1.1.0, winit 0.30.13
0.3.1, hex 0.4.3, jni-sys 0.3.1, jni-sys 0.4.1, quick-error 2.0.1, toml_edit 0.25.15+spec-1.1.0,
unicode-general-category 1.1.0, winit 0.30.13
================================================================================
Apache License
@@ -5393,36 +5235,6 @@ and labelled as such in comments in the individual source files. All
have been licensed under extremely permissive terms.
------------------------------------------------------------------------------
================================================================================
ecb 0.2.1
================================================================================
Copyright (c) 2026 RustCrypto Developers
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
egui-wgpu 0.37.0
================================================================================
@@ -7957,37 +7769,6 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
inout 0.2.6
================================================================================
Copyright (c) 2022-2025 The RustCrypto Project Developers
Copyright (c) 2022 Artyom Pavlov
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
jni-sys 0.3.1, jni-sys 0.4.1
================================================================================
@@ -8926,65 +8707,6 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
lopdf 0.45.0
================================================================================
MIT License
Copyright (c) 2016 Junfeng Liu
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================================================
md-5 0.11.0, sha1 0.11.0, sha2 0.11.1
================================================================================
Copyright (c) 2016-2026 The RustCrypto Project Developers
Copyright (c) 2016 Artyom Pavlov
Copyright (c) 2009-2013 Mozilla Foundation
Copyright (c) 2006-2009 Graydon Hoare
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
memmap2 0.9.11
================================================================================
@@ -10357,7 +10079,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
================================================================================
rand 0.10.3, rand 0.8.8
rand 0.8.8
================================================================================
Apache License
@@ -10538,7 +10260,7 @@ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
END OF TERMS AND CONDITIONS
================================================================================
rand 0.10.3, rand 0.8.8, rand_chacha 0.3.1, rand_core 0.6.4
rand 0.8.8, rand_chacha 0.3.1, rand_core 0.6.4
================================================================================
Copyright 2018 Developers of the Rand project
@@ -10569,7 +10291,7 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
rand 0.10.3, rand 0.8.8, rand_chacha 0.3.1, rand_core 0.6.4
rand 0.8.8, rand_chacha 0.3.1, rand_core 0.6.4
================================================================================
Copyrights in the Rand project are retained by their contributors. No
@@ -10847,213 +10569,6 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================================================
rangemap 1.8.0
================================================================================
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
Copyright 2019-2022 Jeff Parsons, and [contributors](https://github.com/jeffparsons/rangemap/contributors)
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
================================================================================
rangemap 1.8.0
================================================================================
Copyright 2019 Jeffrey Parsons
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
================================================================================
raw-window-handle 0.6.2
================================================================================
@@ -12761,6 +12276,39 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
sha1 0.11.0, sha2 0.11.1
================================================================================
Copyright (c) 2016-2026 The RustCrypto Project Developers
Copyright (c) 2016 Artyom Pavlov
Copyright (c) 2009-2013 Mozilla Foundation
Copyright (c) 2006-2009 Graydon Hoare
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
signal-hook-registry 1.4.8
================================================================================
@@ -13266,30 +12814,6 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
================================================================================
stringprep 0.1.5
================================================================================
Copyright (c) 2017 The rust-stringprep Developers
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================================================
subtle 2.6.1
================================================================================
@@ -13453,16 +12977,6 @@ THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
================================================================================
tinyvec 1.13.3
================================================================================
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
================================================================================
tokio 1.53.2
================================================================================
@@ -14367,8 +13881,7 @@ carrying such notice may not be copied, modified, or distributed except
according to those terms.
================================================================================
unicode-bidi 0.3.18, unicode-normalization 0.1.26, unicode-properties 0.1.5, unicode-segmentation
1.13.3, unicode-width 0.2.2
unicode-bidi 0.3.18, unicode-properties 0.1.5, unicode-segmentation 1.13.3, unicode-width 0.2.2
================================================================================
Copyright (c) 2015 The Rust Project Developers
@@ -14398,7 +13911,7 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
unicode-ident 1.0.26, unicode-normalization 0.1.26
unicode-ident 1.0.26
================================================================================
UNICODE LICENSE V3
@@ -14441,19 +13954,6 @@ not be used in advertising or otherwise to promote the sale, use or other
dealings in these Data Files or Software without prior written
authorization of the copyright holder.
================================================================================
unicode-normalization 0.1.26, unicode-properties 0.1.5, unicode-segmentation 1.13.3, unicode-width
0.2.2
================================================================================
Licensed under the Apache License, Version 2.0
<LICENSE-APACHE or
http://www.apache.org/licenses/LICENSE-2.0> or the MIT
license <LICENSE-MIT or http://opensource.org/licenses/MIT>,
at your option. All files in the project carrying such
notice may not be copied, modified, or distributed except
according to those terms.
================================================================================
unicode-properties 0.1.5
================================================================================
@@ -14498,6 +13998,18 @@ not be used in advertising or otherwise to promote the sale, use or other
dealings in these Data Files or Software without prior written
authorization of the copyright holder.
================================================================================
unicode-properties 0.1.5, unicode-segmentation 1.13.3, unicode-width 0.2.2
================================================================================
Licensed under the Apache License, Version 2.0
<LICENSE-APACHE or
http://www.apache.org/licenses/LICENSE-2.0> or the MIT
license <LICENSE-MIT or http://opensource.org/licenses/MIT>,
at your option. All files in the project carrying such
notice may not be copied, modified, or distributed except
according to those terms.
================================================================================
untrusted 0.9.0
================================================================================
@@ -15214,7 +14726,7 @@ of Data, including for example machine learning models and models'
insights.
================================================================================
weezl 0.1.12, weezl 0.2.1
weezl 0.1.12
================================================================================
The MIT License (MIT)
@@ -16274,6 +15786,36 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
zeroize_derive 1.5.0
================================================================================
Copyright (c) 2019-2026 The RustCrypto Project Developers
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
zip 9.1.3
================================================================================
@@ -16593,7 +16135,6 @@ accesskit_macos 0.27.2: MIT OR Apache-2.0 (https://github.com/AccessKit/accesski
accesskit_unix 0.24.1: MIT OR Apache-2.0 (https://github.com/AccessKit/accesskit)
accesskit_windows 0.35.2: MIT OR Apache-2.0 (https://github.com/AccessKit/accesskit)
accesskit_winit 0.34.2: Apache-2.0 (https://github.com/AccessKit/accesskit)
alloc-stdlib 0.2.4: BSD-3-Clause (https://github.com/dropbox/rust-alloc-no-stdlib)
block2 0.5.1: MIT (https://github.com/madsmtm/objc2)
block2 0.6.2: MIT (https://github.com/madsmtm/objc2)
cadmpeg-codec-freecad 0.6.7: Apache-2.0 (https://github.com/cadmpeg/cadmpeg)
+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
-1
View File
@@ -1509,7 +1509,6 @@
"cmd.timeline_stop": "إيقاف السجل",
"cmd.timeline_suppress": "تعطيل الميزة",
"cmd.ui.assistant": "إظهار المساعد أو إخفاؤه",
"cmd.ui.palette": "البحث في الأوامر",
"cmd.view.navigator": "متصفح العرض المخصص",
"cmd.view.render_browser": "مستعرض التصيير",
"cmd.view.render_dock": "نقل لوحة التصيير",
-1
View File
@@ -1319,7 +1319,6 @@
"cmd.timeline_stop": "Verlauf anhalten",
"cmd.timeline_suppress": "Merkmal unterdrücken",
"cmd.ui.assistant": "Assistenten ein-/ausblenden",
"cmd.ui.palette": "Befehle suchen",
"cmd.view.navigator": "Eigener Ansichtsnavigator",
"cmd.view.render_browser": "Render-Browser",
"cmd.view.render_dock": "Render-Panel verschieben",
-1
View File
@@ -1509,7 +1509,6 @@
"cmd.timeline_stop": "Stop History",
"cmd.timeline_suppress": "Suppress Feature",
"cmd.ui.assistant": "Toggle Assistant",
"cmd.ui.palette": "Search Commands",
"cmd.view.navigator": "Custom View Navigator",
"cmd.view.render_browser": "Render Browser",
"cmd.view.render_dock": "Move Render Dock",
-1
View File
@@ -1509,7 +1509,6 @@
"cmd.timeline_stop": "עצירת ציר הזמן",
"cmd.timeline_suppress": "השבתת פיצ'ר",
"cmd.ui.assistant": "הצגה או הסתרה של העוזר",
"cmd.ui.palette": "חיפוש פקודות",
"cmd.view.navigator": "ניווט תצוגה מותאם אישית",
"cmd.view.render_browser": "דפדפן רינדור",
"cmd.view.render_dock": "הזז את עגינת הרינדור",
-1
View File
@@ -1509,7 +1509,6 @@
"cmd.timeline_stop": "履歴を停止",
"cmd.timeline_suppress": "フィーチャーを抑制",
"cmd.ui.assistant": "アシスタントの表示切り替え",
"cmd.ui.palette": "コマンドを検索",
"cmd.view.navigator": "カスタム ビューナビゲーター",
"cmd.view.render_browser": "Renderブラウザー",
"cmd.view.render_dock": "レンダードックを移動",
-1
View File
@@ -1252,7 +1252,6 @@
"cmd.timeline_stop": "기록 재생 중지",
"cmd.timeline_suppress": "특성 비활성화",
"cmd.ui.assistant": "도우미 표시 전환",
"cmd.ui.palette": "명령 검색",
"cmd.view.navigator": "사용자 지정 뷰 탐색기",
"cmd.view.render_browser": "렌더 브라우저",
"cmd.view.render_dock": "렌더 도크 이동",
-1
View File
@@ -1509,7 +1509,6 @@
"cmd.timeline_stop": "Dừng lịch sử",
"cmd.timeline_suppress": "Tạm nén tính năng",
"cmd.ui.assistant": "Bật/tắt trợ lý",
"cmd.ui.palette": "Tìm kiếm lệnh",
"cmd.view.navigator": "Khối điều hướng góc nhìn tùy chỉnh",
"cmd.view.render_browser": "Trình duyệt kết xuất",
"cmd.view.render_dock": "Di chuyển neo kết xuất",
-1
View File
@@ -1509,7 +1509,6 @@
"cmd.timeline_stop": "停止历史播放",
"cmd.timeline_suppress": "抑制特征",
"cmd.ui.assistant": "切换助手显示",
"cmd.ui.palette": "搜索命令",
"cmd.view.navigator": "自定义视图导航器",
"cmd.view.render_browser": "渲染浏览器",
"cmd.view.render_dock": "移动渲染停靠面板",
-64
View File
@@ -1,64 +0,0 @@
//! CSV text fields, as every SolidCraft export writes them and the parameter import reads them.
//!
//! A spreadsheet reads a field that starts with `=`, `+`, `-` or `@` as a formula, so a part
//! numbered `=1+1` would show `2`. A field like that is written with a `'` first, which
//! spreadsheets show as text (OWASP's CSV Injection page). [`unguard`] takes the `'` off again
//! when SolidCraft reads its own file back.
use std::borrow::Cow;
/// The first characters that make a spreadsheet read a field as a formula, from OWASP's list.
/// The full-width forms act as formulas in some locales, such as Japanese, so they are here too.
const FORMULA_STARTS: [char; 11] = ['=', '+', '-', '@', '\t', '\r', '\n', '=', '+', '-', '@'];
/// What makes a field need quotes: a separator (`;` as well as `,`, since a spreadsheet set to
/// `;` splits there and the new cell could start a formula), a quote, a tab or a line break.
const NEEDS_QUOTES: [char; 6] = [',', ';', '"', '\t', '\r', '\n'];
/// A text field, quoted only when it needs quotes.
pub fn field(text: &str) -> String {
let text = guard(text);
if text.contains(NEEDS_QUOTES) { quote(&text) } else { text.into_owned() }
}
/// A text field, always in quotes.
pub fn quoted(text: &str) -> String {
quote(&guard(text))
}
/// A field as SolidCraft wrote it, with the `'` that [`field`] or [`quoted`] put first taken off.
/// A field without one is returned as it is.
pub fn unguard(text: &str) -> &str {
match text.strip_prefix('\'') {
Some(rest) if starts_a_formula(rest) => rest,
_ => text,
}
}
/// `text` with a `'` first when a spreadsheet would read it as a formula. A field that already
/// starts with `'` and then a formula gets one more, so [`unguard`] gives back exactly what was
/// written. A whole plain number (`-5`, `+1e-9`) is read as that number, not as a formula, so it
/// is left as it is.
fn guard(text: &str) -> Cow<'_, str> {
if starts_a_formula(text) && !plain_number(text) { Cow::Owned(format!("'{text}")) } else { Cow::Borrowed(text) }
}
/// Whether `text`, after any `'` and spaces it starts with, starts as a formula does. Spaces count
/// because a spreadsheet may take them off as it reads (LibreOffice's "Trim spaces").
fn starts_a_formula(text: &str) -> bool {
text.trim_start_matches(['\'', ' ']).starts_with(FORMULA_STARTS)
}
/// A finite number and nothing else, spaces included. Rust's `f64` parsing also takes `inf`,
/// `infinity` and `NaN` in any case, and none of them is finite.
fn plain_number(text: &str) -> bool {
text.parse::<f64>().is_ok_and(f64::is_finite)
}
fn quote(text: &str) -> String {
format!("\"{}\"", text.replace('"', "\"\""))
}
#[cfg(test)]
#[path = "csv_tests.rs"]
mod tests;
-89
View File
@@ -1,89 +0,0 @@
use super::{FORMULA_STARTS, NEEDS_QUOTES, field, quoted, unguard};
#[test]
fn a_field_a_spreadsheet_would_run_gets_an_apostrophe() {
for (text, written) in [
("=1+1", "'=1+1"),
("+2 for the fit", "'+2 for the fit"),
("-5 mm shim", "'-5 mm shim"),
("@SUM(1,1)", "\"'@SUM(1,1)\""),
("=1+1", "'=1+1"),
("+2", "'+2"),
("-5", "'-5"),
("@x", "'@x"),
("\tx", "\"'\tx\""),
("\rx", "\"'\rx\""),
("\nx", "\"'\nx\""),
// Already an apostrophe, then a formula: one more, so the import gives back the original.
("'=x", "''=x"),
("''-x", "'''-x"),
// Spaces before a formula count: a spreadsheet may take them off.
(" =x", "' =x"),
(" -5", "' -5"),
("' @x", "'' @x"),
] {
assert_eq!(field(text), written, "{text:?}");
assert_eq!(unguard(written.trim_matches('"')), text, "{written:?}");
}
}
#[test]
fn a_whole_plain_number_stays_a_number() {
for number in ["-5", "+5", "-0", "0", "1e-9", "-1.5E+3", ".5", "-.5", "5.", "+1e5", "-12.000000000"] {
assert_eq!(field(number), number, "{number:?}");
}
// Not plain numbers: Rust's parse takes the words, spaces aren't a number's, and one too large isn't finite.
for text in ["-inf", "+inf", "-infinity", "-NaN", "+nan", "-", "+", "--5", "-5 mm", "-5 ", "-1e400", "-0x10", "-5,5"] {
assert!(field(text).trim_start_matches('"').starts_with('\''), "{text:?}: {}", field(text));
}
}
#[test]
fn other_fields_are_left_as_they_are() {
for text in ["", "Plate", "PL-100", "a=b", "x+1", "'quoted' word", "'", "''", "5 mm", " 5", "é=", "μ-5"] {
assert_eq!(field(text), text, "{text:?}");
assert_eq!(unguard(text), text, "{text:?}");
}
}
#[test]
fn a_field_with_a_separator_a_quote_or_a_break_is_quoted() {
assert_eq!(field("Plate, 3 mm"), "\"Plate, 3 mm\"");
assert_eq!(field("one; two"), "\"one; two\"");
assert_eq!(field("Pin \"short\""), "\"Pin \"\"short\"\"\"");
assert_eq!(field("a\tb"), "\"a\tb\"");
assert_eq!(field("line one\r\nline two"), "\"line one\r\nline two\"");
assert_eq!(quoted("Plate"), "\"Plate\"");
assert_eq!(quoted("=A1"), "\"'=A1\"");
assert_eq!(quoted("-5"), "\"-5\"");
}
/// Random fields from an alphabet heavy in what matters: every formula start, apostrophes, quotes,
/// separators, spaces, digits and the letters of `inf` and `NaN`. Each one reads back exactly, never starts
/// a formula once written unless it is a whole number, and is quoted whenever it holds a separator.
#[test]
fn fuzz_every_field_reads_back_and_never_starts_a_formula() {
let mut alphabet: Vec<char> = "'''\"\";,.eE0123456789 infaNxé".chars().collect();
alphabet.extend(FORMULA_STARTS);
let mut seed: u64 = 0x9e37_79b9_7f4a_7c15;
let mut rnd = move || {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
seed
};
for _ in 0..50_000 {
let n = (rnd() % 12) as usize;
let text: String = (0..n).filter_map(|_| alphabet.get((rnd() % alphabet.len() as u64) as usize)).collect();
let written = field(&text);
let inner = match written.strip_prefix('"').and_then(|w| w.strip_suffix('"')) {
Some(inner) => inner.replace("\"\"", "\""),
None => written.clone(),
};
assert_eq!(unguard(&inner), text, "{text:?} written as {written:?}");
let bare = inner.trim_start_matches(' ');
assert!(!bare.starts_with(FORMULA_STARTS) || text.parse::<f64>().is_ok_and(f64::is_finite), "{text:?} written as {written:?}");
assert!(!text.contains(NEEDS_QUOTES) || written.starts_with('"'), "{text:?} written as {written:?}");
assert_eq!(quoted(&text), format!("\"{}\"", inner.replace('"', "\"\"")), "{text:?}");
}
}
+34 -225
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;
@@ -4866,13 +4754,9 @@ fn hole_position(vals: &BTreeMap<String, Value>, position: &crate::FacePick, at:
// Pick an actual cylindrical wall, not a depth derived from its radius: a large pipe bore can
// be shallow or pass through a thin plate. Sampling its finite face also skips a wider entry
// pocket and keeps metadata attached to the narrow cylindrical bore.
//
// A hole drilled across a pocket or a slot leaves its wall in pieces along the axis (#13505):
// every piece the hole reaches (`reach`, its depth from `entry`) is returned, nearest the entry
// first, each as (its start and end from the entry along `inward`, a point on it).
fn parallel_tapped_wall(st: &ModelState, entry: Vec3, inward: Vec3, radius: f64, reach: f64) -> Vec<(f64, f64, Vec3)> {
fn parallel_tapped_wall(st: &ModelState, entry: Vec3, inward: Vec3, radius: f64) -> Option<Vec3> {
let tol = (radius * 1e-6).max(1e-6);
let mut walls: Vec<(f64, f64, Vec3)> = Vec::new();
let mut best: Option<(f64, Vec3)> = None;
for body in &st.bodies {
// A wall found lies on the line through `entry` along `inward`, give or take its radius
// (and the slant its axis may have off `inward` over the body's distance), and its
@@ -4899,15 +4783,12 @@ fn parallel_tapped_wall(st: &ModelState, entry: Vec3, inward: Vec3, radius: f64,
let b = (c.axis_point + c.axis * c.end - entry).dot(inward);
let lo = a.min(b).max(0.0);
let hi = a.max(b);
// One piece of wall made of several faces (a cylinder split at its seams) is one.
let mid = (lo + hi) * 0.5;
if hi > lo + 1e-9 && lo < reach - 1e-6 && !walls.iter().any(|(l, h, _)| (*l..=*h).contains(&mid)) {
walls.push((lo, hi, entry + inward * mid + inward.any_perp() * radius));
if hi > lo + 1e-9 && best.as_ref().is_none_or(|(old, _)| lo < *old) {
best = Some((lo, entry + inward * ((lo + hi) * 0.5) + inward.any_perp() * radius));
}
}
}
walls.sort_by(|a, b| a.0.total_cmp(&b.0));
walls
best.map(|(_, wall)| wall)
}
/// The cutting tools of a hole feature at every spot, each with its exact geometry when it is a
@@ -7078,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).
@@ -7659,7 +7516,7 @@ fn eval_feature(
let extra: Vec<String> = if *operation == Operation::Join { to_body.into_iter().collect() } else { Vec::new() };
apply_traced_with(doc, st, f, tools, *operation, &crate::plain(targets), &extra)
}
FeatureKind::Hole { thread, thread_depth, diameter, hand, modeled, ease_out, depth, to, .. } => {
FeatureKind::Hole { thread, thread_depth, diameter, hand, modeled, ease_out, .. } => {
let thread_depth = thread_depth.as_ref().map(|d| val(vals, d, Kind::Length)).transpose()?;
if thread_depth.is_some() && thread.is_none() {
return Err(DocError::Invalid("thread depth requires a tapped hole".into()));
@@ -7674,19 +7531,6 @@ fn eval_feature(
return Err(DocError::Invalid("thread depth must be finite and positive".into()));
}
let (spots, dir) = hole_spots(vals, st, f)?;
// How deep each hole goes (through all: as far as the wall goes), read before the cut:
// the pieces of a wall the thread runs along lie within it.
let reaches: Vec<f64> = if thread.is_some() {
spots
.iter()
.map(|at| match hole_depth_to(st, at, dir, &to.map(Vec3::from), depth)? {
Some(e) => val(vals, &e, Kind::Length),
None => Ok(f64::INFINITY),
})
.collect::<Result<_>>()?
} else {
Vec::new()
};
let tools = hole_feature_tools(vals, st, f)?;
let handled = sheet_hole_cut(st, f, &tools, warning)?;
let tools: Vec<Traced> = tools.into_iter().map(|(tool, _)| tool).collect();
@@ -7722,77 +7566,42 @@ fn eval_feature(
)
});
let ease = thread::runout(vals, ease_out)?;
for (p, reach) in spots.into_iter().zip(reaches) {
let walls = parallel_tapped_wall(st, p, dir, r, reach);
if pipe && walls.is_empty() {
return Err(DocError::Invalid("no cylindrical pipe-thread wall remains".into()));
}
// A pipe thread is one taper from the entry: its first wall only.
let walls: Vec<(f64, f64, Vec3)> = match walls.first() {
None => vec![(0.0, f64::INFINITY, p + dir.any_perp() * r + dir * (r * 0.5))],
Some(first) if pipe => vec![*first],
Some(_) => walls,
for p in spots {
let wall = if pipe {
parallel_tapped_wall(st, p, dir, r).ok_or_else(|| DocError::Invalid("no cylindrical pipe-thread wall remains".into()))?
} else {
parallel_tapped_wall(st, p, dir, r).unwrap_or(p + dir.any_perp() * r + dir * (r * 0.5))
};
// The thread depth runs from where the first wall starts, through every piece
// of the wall it reaches (#13505).
let first = walls.first().map_or(0.0, |w| w.0);
let stop = thread_depth.map(|d| first + d);
let ask = thread::ThreadAsk { designation: Some(d), tapped: true, hand: *hand, ..Default::default() };
if *modeled {
// Cut from the entry, as deep as `thread_depth` or along the whole wall:
// at a blind hole's bottom the groove stops clear of it, or fades out
// before it with a runout. Every piece is found before any is cut.
let mut on = Vec::new();
for (lo, hi, wall) in &walls {
let extent = match stop {
None => thread::Extent::Full,
Some(s) if s >= *hi => thread::Extent::Full,
Some(s) if s > *lo => thread::Extent::Length(s - lo),
Some(_) => break,
};
// A piece past the first starts at its end nearest the entry.
let from = if on.is_empty() { p } else { p + dir * *lo };
let ask = thread::ThreadAsk { modeled: true, extent, from: Some(from), ease_out: ease, ..ask };
on.push(thread_on(st, f.id, *wall, &ask)?);
}
for o in on {
thread::cut(st, o, (crate::thread_forms::ThreadTip::None, None, None))?;
}
// before it with a runout.
let extent = thread_depth.map_or(thread::Extent::Full, thread::Extent::Length);
let ask = thread::ThreadAsk { modeled: true, extent, from: Some(p), ease_out: ease, ..ask };
let on = thread_on(st, f.id, wall, &ask)?;
thread::cut(st, on, (crate::thread_forms::ThreadTip::None, None, None))?;
continue;
}
let mut threads = Vec::new();
for (lo, _, wall) in &walls {
if stop.is_some_and(|s| s <= *lo) {
break;
}
threads.push(thread_on(st, f.id, *wall, &ask)?.info);
}
let mut t = thread_on(st, f.id, wall, &ask)?.info;
if let Some(depth) = thread_depth {
let useful =
|t: &ThreadInfo| (t.axis_point + t.axis * t.start - p).dot(dir).max((t.axis_point + t.axis * t.end - p).dot(dir));
let available = threads.last().map_or(0.0, |t| useful(t) - first);
if depth > available + 1e-9 {
let available = t.end - t.start;
if depth > available {
return Err(DocError::Invalid(format!(
"thread depth {} mm exceeds the cylindrical wall's useful length of {} mm; reduce the thread depth or use full thread depth",
show_mm(depth),
show_mm(available)
)));
}
// Stop `depth` past the first wall's start in the drilling direction,
// independently of the wall probe's position and the cylinder surface's
// axis orientation.
if let (Some(t), Some(s)) = (threads.last_mut(), stop) {
let past = useful(t) - s;
if past > 0.0 {
if dir.dot(t.axis) > 0.0 {
t.end -= past;
} else {
t.start += past;
}
}
// Start at the entry in the drilling direction, independently of the
// wall probe's position and the cylinder surface's axis orientation.
if dir.dot(t.axis) > 0.0 {
t.end = t.start + depth;
} else {
t.start = t.end - depth;
}
}
st.threads.extend(threads);
st.threads.push(t);
}
}
Ok(())
-1
View File
@@ -22,7 +22,6 @@ mod component_removal;
pub use component_removal::RemovedTree;
pub mod config;
pub mod construct;
pub mod csv;
pub mod display_quality;
pub mod display_units;
mod document;
+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),
+2 -11
View File
@@ -50,11 +50,8 @@ pub struct EdgeHit {
}
/// The edge of a body a stored edge name finds: the edge with that name; else the one edge
/// between the faces it names exactly (see [`exactly`]). A numbered name (`~k`) was one of
/// several edges between its faces, told apart only by where they lay, so the one edge left
/// between them is it only where the reference's point still lies on it. `Err` explains when
/// there is none: no such edge, the named edge split into several, one of several edges whose
/// faces now bound one edge elsewhere, or a name saved before format 14.
/// between the faces it names exactly (see [`exactly`]). `Err` explains when there is none: no
/// such edge, the named edge split into several, or a name saved before format 14.
pub fn locate_edge(b: &ModelBody, name: &str, near: Vec3) -> Result<EdgeHit, String> {
if legacy_name(name).is_some() {
return Err(format!("edge `{name}` was named by a build before format 14"));
@@ -84,13 +81,7 @@ pub fn locate_edge(b: &ModelBody, name: &str, near: Vec3) -> Result<EdgeHit, Str
keep_best(&mut best, level, i);
}
let (_, cands) = best.ok_or_else(|| format!("edge `{name}` no longer exists"))?;
// When the faces of several numbered edges bound one edge now, every one of their names
// finds the faces of that edge: taking it for each would put them all on one edge (#13332).
let numbered = wanted.len() != name.len();
match cands.as_slice() {
[i] if numbered && !edge_near(b, &m, *i, near) => {
Err(format!("edge `{name}` was one of several between its faces, and the one left is elsewhere"))
}
[i] => exact(*i),
_ => Err(format!("edge `{name}` is no longer one edge")),
}
+4 -6
View File
@@ -483,10 +483,9 @@ impl Document {
}
/// User parameters as CSV (`name,unit,expression,value,comment`). The `value` column is the
/// number in the `unit` column, rounded for reading (#10747); import reads `expression`. A text
/// field a spreadsheet would run as a formula is written as text ([`crate::csv`]).
/// number in the `unit` column, rounded for reading (#10747); import reads `expression`.
pub fn params_csv(&self) -> String {
let q = crate::csv::field;
let q = |s: &str| if s.contains([',', '"', '\r', '\n']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() };
let mut out = String::from("name,unit,expression,value,comment,favorite\n");
for r in self.param_rows().into_iter().filter(|r| r.source == "user") {
out += &format!(
@@ -1138,14 +1137,13 @@ fn split_csv(text: &str, separator: char) -> impl Iterator<Item = &str> {
fn csv_fields(record: &str) -> Result<Vec<String>> {
split_csv(record, ',')
.map(|raw| {
// `unguard` takes off the `'` an export put before a value a spreadsheet would run.
let field = raw.trim();
if !field.contains('"') {
return Ok(crate::csv::unguard(field).to_string());
return Ok(field.to_string());
}
let inner = field.strip_prefix('"').and_then(|s| s.strip_suffix('"'));
match inner {
Some(s) if s.split("\"\"").all(|part| !part.contains('"')) => Ok(crate::csv::unguard(&s.replace("\"\"", "\"")).to_string()),
Some(s) if s.split("\"\"").all(|part| !part.contains('"')) => Ok(s.replace("\"\"", "\"")),
_ => Err(DocError::Invalid("invalid quoting in parameter CSV".into())),
}
})
+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;
}
+6 -3
View File
@@ -421,10 +421,13 @@ fn rows(s: &Session, ix: &Index, counts: &BTreeMap<u64, u64>, group: Group, kits
out
}
/// The bill of materials as CSV: fixed columns, every field quoted, and a field a spreadsheet
/// would run as a formula written as text.
/// A CSV field, always quoted.
fn quote(s: &str) -> String {
format!("\"{}\"", s.replace('"', "\"\""))
}
/// The bill of materials as CSV: fixed columns, every field quoted.
fn csv(rows: &[Value]) -> String {
let quote = solidcraft_doc::csv::quoted;
let mut out = ["item", "part_number", "description", "revision", "count", "definition"].map(quote).join(",");
out.push('\n');
let lines = rows.iter().flat_map(|r| std::iter::once(r).chain(r["children"].as_array().into_iter().flatten()));
-14
View File
@@ -201,20 +201,6 @@ fn csv_quotes_every_field_and_json_writes_the_rows() {
let _ = std::fs::remove_dir_all(&dir);
}
/// A field that a spreadsheet would run as a formula gets a `'` first, so it opens as text
/// (#12728). A count stays a number.
#[test]
fn a_csv_field_a_spreadsheet_would_run_opens_as_text() {
let dir = scratch("formula");
let mut s = assembly();
run(&mut s, "component.metadata.set", json!({"component": "Plate", "part_number": "=1+1", "description": "-5 mm shim", "revision": "@A"}));
let csv = dir.join("bom.csv");
run(&mut s, "assembly.bom", json!({"path": csv}));
let text = std::fs::read_to_string(&csv).unwrap();
assert!(text.contains("\n\"1\",\"'=1+1\",\"'-5 mm shim\",\"'@A\",\"3\",\"Plate\"\n"), "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn standard_parts_carry_a_part_number_that_follows_a_resize() {
let mut s = Session::default();
-16
View File
@@ -30,14 +30,6 @@ pub static COMMANDS: &[CommandSpec] = &[
.contract(&ASSISTANT_CONTRACT)
.output(crate::schema::output_schema_for::<crate::schema::edit::AssistantResult>)
.params("Show or hide Assistant in the app. Headless calls are refused without changing the design"),
CommandSpec::new("ui.palette", "Search Commands", crate::checked!("ui.palette", palette))
.icon("zoom")
.key("S")
.noundo()
.doc_only()
.contract(&PALETTE_CONTRACT)
.output(crate::schema::output_schema_for::<crate::schema::edit::PaletteResult>)
.params("Open command search in the app. Headless calls are refused without changing the design"),
CommandSpec::new("edit.undo", "Undo", crate::checked!("edit.undo", undo))
.icon("undo")
.key("Ctrl+Z")
@@ -265,14 +257,6 @@ fn assistant(_s: &mut Session, _p: &Value) -> Result<Value> {
Err(EngineError::Other("ui.assistant shows or hides Assistant in the app; no assistant panel exists in a headless session".into()))
}
const PALETTE_CONTRACT: crate::schema::TypedContract<crate::schema::edit::PaletteParams> = crate::schema::TypedContract::new();
/// Command search (#12190) is the app's: it opens in `SolveApp::start`, and its key can be moved
/// in Preferences › Shortcuts like any other command's.
fn palette(_s: &mut Session, _p: &Value) -> Result<Value> {
Err(EngineError::Other("ui.palette opens command search in the app; there is no search box in a headless session".into()))
}
const REPEAT_CONTRACT: crate::schema::TypedContract<crate::schema::edit::RepeatParams> = crate::schema::TypedContract::new();
/// Repeat Last Command (#7077) starts a tool, which only the app has: it starts the last one in
-6
View File
@@ -1687,12 +1687,6 @@ fn assistant_toggle_is_refused_headlessly_without_changing_the_document() {
assert_eq!((s.doc.to_json(), s.undo.len()), before);
let spec = crate::find_command("ui.assistant").unwrap();
assert_eq!((spec.shortcut, spec.tab, spec.undoable), (Some("Ctrl+Shift+A"), "", false));
// Command search likewise (#12190): S is its default key, not a reserved one.
let error = s.execute("ui.palette", &json!({})).unwrap_err().to_string();
assert!(error.contains("headless session"), "{error}");
assert_eq!((s.doc.to_json(), s.undo.len()), before);
let spec = crate::find_command("ui.palette").unwrap();
assert_eq!((spec.shortcut, spec.tab, spec.undoable), (Some("S"), "", false));
}
#[test]
+5 -15
View File
@@ -15,16 +15,14 @@ use crate::params::{UNKNOWN_COMMAND, bad, bool_, expr, expr_problem, req_expr, s
use crate::schema::features::SweepParams;
use crate::schema::features::{
AppliedRadius, BoundingSolidParams, BoxParams, ChamferParams, CombineParams, CombineResult, ConeParams, CylinderParams, ExtrudeParams,
FeatureNotice, FeatureResult, FilletParams, FilletResult, NoticeAction, OffsetPlaneParams, PipeParams, PlaneAtAngleParams, RevolveParams,
ScaleParams, SphereParams, TorusParams,
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};
const BOX_CONTRACT: TypedContract<BoxParams> = TypedContract::new();
const EXTRUDE_CONTRACT: TypedContract<ExtrudeParams> = TypedContract::new();
const REVOLVE_CONTRACT: TypedContract<RevolveParams> = TypedContract::new();
const CYLINDER_CONTRACT: TypedContract<CylinderParams> = TypedContract::new();
const SPHERE_CONTRACT: TypedContract<SphereParams> = TypedContract::new();
const TORUS_CONTRACT: TypedContract<TorusParams> = TypedContract::new();
@@ -38,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))
@@ -49,11 +45,9 @@ pub static COMMANDS: &[CommandSpec] = &[
.contract(&EXTRUDE_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params("distance: expr (or through_all: true); taper?: angle expr (positive: outward, so the far end is larger along the extrusion direction; negative: inward, so the far end is smaller); corner?: preserve_curvature|tangent|round (how a taper closes a sharp corner its offset opens: each wall continues as itself, a line's plane or an arc's cone, until it meets its neighbour; each continues straight along its end tangent; or an arc about the corner. Without it, each profile preserves curvature where every curve is a line or an arc and takes tangent where another curve has a corner, and the feature's warning says so; a corner rule given is kept, and refused where the kernel cannot build it); sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [{curves: [ids]}] (the profile whose outer loop is made of exactly these curves, or pieces of them; found again on every recompute, so it follows parameter changes; islands inside are its holes; an error when no profile or several match) | [{point:[x,y]}] (index and point picks are stored by their curves when that finds the same profile; a pick on a drawn profile that reference geometry, such as the projected outline of the face the sketch is on, cuts into regions takes the whole drawn profile); exact_regions?: bool (each index or point pick is exactly the region it names, as the dialogs send); default: every drawn profile, except that a loop inside another is its hole and a loop inside that hole is material again; face?: [x,y,z] (extrude a planar body face instead of a sketch profile); faces?: [[x,y,z],…] (coplanar faces of one body); face_body?: name (whose face, where faces of several bodies meet at the point); direction?: positive|negative|symmetric (distance is total thickness, split evenly around the start plane); convention?: total_distance|legacy_per_side (how a symmetric distance is read; default total_distance, split evenly around the start plane; legacy_per_side: the distance on each side, as older symmetric extrudes keep); distance2?: expr (Two Sides: a second distance on the side direction leaves free, the first side may be through_all or to; with direction: negative the first side goes against the normal and distance2 along it; a negative distance only turns its own side over); start_offset?: expr (along the sketch normal, also with direction: negative); to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, otherwise join existing solids; new in an empty component); touching?: separate|one_body (what a join makes of pieces that share no face: separate, the default, makes each piece that touches another only along an edge or at a point a body of its own and keeps pieces that touch nothing in one body; one_body keeps every piece in one body); targets?: [body] (Join from a face, or from a sketch on a body's face, defaults to that face's body; explicit targets or participants override); name?; body_name?; thin?: true (Thin: a wall along sketch curves instead of the profiles) with curves?: [curve ids] (open chains and closed loops, joined where they meet end to end; default: every drawn curve of the sketch), thickness: expr, side?: center|inside|outside|two_sides (a closed loop's inside is the area it encloses, an open chain's the side it bends toward, a straight line's the left of its first curve as drawn; default center), thickness2?: expr (two_sides: the thickness outside); corners are mitred, crossing walls are one"),
CommandSpec::new("solid.revolve", "Revolve", crate::checked!("solid.revolve", revolve))
CommandSpec::new("solid.revolve", "Revolve", revolve)
.at("SOLID", "CREATE")
.icon("revolve")
.contract(&REVOLVE_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params("axis: sketch line id | x | y (sketch axes) | X|Y|Z (world) | {type: world, axis: X|Y|Z} (as returned by timeline.inspect) | construction axis name (followed when edited) | {origin, dir} (origin: numbers or length expressions, followed when parameters change) | {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 (default 360 deg); angle2?: expr (Two Sides: the other side's angle); direction?: symmetric (angle split either side, independent of axis direction); convention?: sketch_normal|legacy_axis_start (default sketch_normal; legacy retains older angular starts); to?: [x,y,z] (To Object: turn until that face, vertex or point); sketch?, profiles? | face: [x,y,z] (a planar body face as the profile) and face_body?: name (whose face, where faces of several bodies meet), operation?, touching?, targets?, name?, body_name?"),
CommandSpec::new("solid.sweep", "Sweep", crate::checked!("solid.sweep", sweep))
.at("SOLID", "CREATE")
@@ -179,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")
+28 -78
View File
@@ -13,7 +13,6 @@ use super::features::{
add_feature, axis_dir_param, check_expr, feature_sketch, length_operands, plane_param, point_param, profiles, typed_length_unit,
};
use crate::params::{bad, bool_, expr, req_expr, str_, string_list, vec3};
use crate::schema::align::AlignParams;
use crate::schema::features::FeatureResult;
use crate::schema::features_more::{BoundaryFillParams, RemoveParams, RibParams};
use crate::schema::{TypedContract, output_schema_for};
@@ -21,7 +20,6 @@ use crate::{Result, Session};
/// Rib and Web take the same keys.
const RIB_CONTRACT: TypedContract<RibParams> = TypedContract::new();
const ALIGN_CONTRACT: TypedContract<AlignParams> = TypedContract::new();
const REMOVE_CONTRACT: TypedContract<RemoveParams> = TypedContract::new();
const BOUNDARY_FILL_CONTRACT: TypedContract<BoundaryFillParams> = TypedContract::new();
@@ -60,11 +58,9 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SOLID", "MODIFY")
.icon("replace_face")
.params("faces: [[x,y,z] points on planar faces, or face names from model.faces]; target: plane (XY|XZ|YZ|construction plane|{origin, normal}) | target_face: [x,y,z] on a parallel planar face; body?"),
CommandSpec::new("solid.align", "Align", crate::checked!("solid.align", align))
CommandSpec::new("solid.align", "Align", align)
.at("SOLID", "MODIFY")
.icon("align")
.contract(&ALIGN_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params("bodies: [names]; from: [x,y,z] | \"origin\" | {snap: vertex|edge_mid|face_center|circle_center, at: [x,y,z]} | from_face: [x,y,z] | from_plane: XY|XZ|YZ|construction or offset plane name or id|{origin, normal}; to: (the same) | to_face: [x,y,z] | to_plane: (a plane as from_plane; the point or face goes onto it along its normal, and the plane may move later: the bodies follow); from_frame / to_frame: a frame found again whenever the design changes, so the bodies follow it, in place of that side's point, face or plane: a joint origin as joint.create takes one ({face|circle|edge|point: [x,y,z]}, a face's name, a named joint origin) or {sketch, point|curve} (a sketch point, or a circle's, arc's or ellipse's centre, z along the sketch's normal), on the bodies' own component; faces, planes and frames also turn to meet; flip?: bool; axes?: [x, y, z] (point-to-point only: which world axes the offset moves along; default all three, at least one true); angle?: expr (a turn about the target's direction after the move)"),
CommandSpec::new("solid.remove", "Remove", crate::checked!("solid.remove", remove))
.at("SOLID", "MODIFY")
@@ -425,58 +421,21 @@ fn replace_face(s: &mut Session, p: &Value) -> Result<Value> {
add_feature(s, p, FeatureKind::ReplaceFace { faces: faces.into_iter().map(FacePick).collect(), target, body: body.map(BodyName) })
}
/// What `solid.align` says when `axes` is not three booleans, or when none is true.
pub(crate) const AXES_SHAPE: &str = "`axes` must be [x, y, z] booleans";
pub(crate) const AXES_NONE: &str = "`axes` must enable at least one of X, Y and Z";
/// `axes` as `solid.align` reads it: three booleans, one per world axis, at least one of them
/// true; the message is the handler's.
pub(crate) fn axes_mask(v: &Value) -> std::result::Result<[bool; 3], &'static str> {
let a = v.as_array().filter(|a| a.len() == 3).ok_or(AXES_SHAPE)?;
let mut mask = [false; 3];
for (slot, v) in mask.iter_mut().zip(a) {
*slot = v.as_bool().ok_or(AXES_SHAPE)?;
}
if !mask.iter().any(|on| *on) {
return Err(AXES_NONE);
}
Ok(mask)
}
/// `axes`: three booleans, one per world axis, for a point-to-point Align.
/// `None` when the call does not restrict the move.
fn axes_param(p: &Value, cmd: &str) -> Result<Option<[bool; 3]>> {
let Some(v) = p.get("axes").filter(|v| !v.is_null()) else { return Ok(None) };
let mask = axes_mask(v).map_err(|why| bad(cmd, why))?;
let a = v.as_array().filter(|a| a.len() == 3).ok_or_else(|| bad(cmd, "`axes` must be [x, y, z] booleans"))?;
let mut mask = [false; 3];
for (slot, v) in mask.iter_mut().zip(a) {
*slot = v.as_bool().ok_or_else(|| bad(cmd, "`axes` must be [x, y, z] booleans"))?;
}
if !mask.iter().any(|on| *on) {
return Err(bad(cmd, "`axes` must enable at least one of X, Y and Z"));
}
Ok((mask != [true; 3]).then_some(mask))
}
/// An Align side given as a point (`from` or `to`), as the handler reads it.
pub(crate) enum SidePoint {
/// The text `origin` (any case): the world origin.
Origin,
/// `{snap?, at}`: the nearest `snap` (vertex unless the object says another) to `at`.
Snap { at: Vec3, kind: String },
/// `[x, y, z]`.
Point(Vec3),
}
/// The side `pk` (`from` or `to`) as a point, with the handler's message when it is none of the
/// forms; `fk` is the face key beside it (`from_face`).
pub(crate) fn side_point(v: Option<&Value>, pk: &str, fk: &str) -> std::result::Result<SidePoint, String> {
// The origin point.
if v.and_then(Value::as_str).is_some_and(|o| o.eq_ignore_ascii_case("origin")) {
return Ok(SidePoint::Origin);
}
// A snap point: {snap: vertex|edge_mid|face_center|circle_center, at: [x,y,z]}.
if let Some(o) = v.filter(|v| v.is_object()) {
let at = o.get("at").and_then(vec3).ok_or_else(|| format!("`{pk}.at` must be [x, y, z]"))?;
let kind = o.get("snap").and_then(Value::as_str).unwrap_or("vertex").to_string();
return Ok(SidePoint::Snap { at, kind });
}
v.and_then(vec3).map(SidePoint::Point).ok_or_else(|| format!("give `{pk}`, `{fk}` or `{pk}_plane` ([x, y, z], \"origin\", or a plane)"))
}
fn align(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.align";
let bodies = string_list(cmd, p, "bodies")?;
@@ -533,13 +492,20 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
let n = super::face::face_normal_world(s, fp).ok_or_else(|| bad(cmd, format!("`{fk}` must be on a planar face")))?;
return Ok((fp, Some(n)));
}
match side_point(p.get(pk), pk, fk).map_err(|why| bad(cmd, why))? {
SidePoint::Origin => Ok((Vec3::ZERO, None)),
SidePoint::Snap { at, kind } => {
snap_point(&*s.world_state()?, &kind, at).map(|q| (q, None)).ok_or_else(|| bad(cmd, format!("no {kind} to snap to near `{pk}`")))
}
SidePoint::Point(v) => Ok((v, None)),
// The origin point.
if p.get(pk).and_then(Value::as_str).is_some_and(|o| o.eq_ignore_ascii_case("origin")) {
return Ok((Vec3::ZERO, None));
}
// A snap point: {snap: vertex|edge_mid|face_center|circle_center, at: [x,y,z]}.
if let Some(o) = p.get(pk).filter(|v| v.is_object()) {
let at = o.get("at").and_then(vec3).ok_or_else(|| bad(cmd, format!("`{pk}.at` must be [x, y, z]")))?;
let kind = o.get("snap").and_then(Value::as_str).unwrap_or("vertex");
return snap_point(&*s.world_state()?, kind, at).map(|q| (q, None)).ok_or_else(|| bad(cmd, format!("no {kind} to snap to near `{pk}`")));
}
p.get(pk)
.and_then(vec3)
.map(|v| (v, None))
.ok_or_else(|| bad(cmd, format!("give `{pk}`, `{fk}` or `{pk}_plane` ([x, y, z], \"origin\", or a plane)")))
};
let pick = |s: &Session, pk: &str, fk: &str, held: bool| -> Result<(Vec3, Option<Vec3>)> {
let (at, n) = world_pick(s, pk, fk, held)?;
@@ -602,24 +568,7 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
}
/// The longest sketch point or curve id an Align frame takes.
pub(crate) const MAX_SKETCH_ID: usize = 256;
/// The point or curve of an Align `{sketch, point|curve}` frame: an id of 1 to
/// [`MAX_SKETCH_ID`] characters. `None` when the frame gives both, neither or an id that is not
/// one.
pub(crate) enum SketchFrameId {
Point(String),
Curve(String),
}
pub(crate) fn sketch_frame_id(v: &Value) -> Option<SketchFrameId> {
let id = |k: &str| str_(v, k).filter(|n| !n.is_empty() && n.chars().count() <= MAX_SKETCH_ID).map(str::to_string);
match (id("point"), id("curve")) {
(Some(point), None) => Some(SketchFrameId::Point(point)),
(None, Some(curve)) => Some(SketchFrameId::Curve(curve)),
_ => None,
}
}
const MAX_SKETCH_ID: usize = 256;
/// An Align side's frame (#6257), `<key>_frame`: a joint origin as `joint.create` takes one, or a
/// sketch point or the centre of a sketch circle, arc or ellipse (`{sketch, point|curve}`), on the
@@ -636,10 +585,11 @@ fn align_frame(s: &Session, p: &Value, key: &str, bodies: &[String]) -> Result<O
let (frame, on) = match v.get("sketch") {
Some(sk) => {
let sketch = super::features::sketch_id(s, Some(sk), cmd, &fk)?;
let frame = match sketch_frame_id(v) {
Some(SketchFrameId::Point(point)) => AlignFrame::SketchPoint { sketch: SketchId(sketch), point },
Some(SketchFrameId::Curve(curve)) => AlignFrame::SketchCurve { sketch: SketchId(sketch), curve },
None => return Err(bad(cmd, format!("`{fk}`: a sketch frame takes `point` or `curve` (an id of 1 to {MAX_SKETCH_ID} characters)"))),
let id = |k: &str| str_(v, k).filter(|n| !n.is_empty() && n.chars().count() <= MAX_SKETCH_ID).map(str::to_string);
let frame = match (id("point"), id("curve")) {
(Some(point), None) => AlignFrame::SketchPoint { sketch: SketchId(sketch), point },
(None, Some(curve)) => AlignFrame::SketchCurve { sketch: SketchId(sketch), curve },
_ => return Err(bad(cmd, format!("`{fk}`: a sketch frame takes `point` or `curve` (an id of 1 to {MAX_SKETCH_ID} characters)"))),
};
(frame, s.doc.feature(sketch).map_or(0, |f| f.component))
}
+19 -48
View File
@@ -23,16 +23,10 @@ use solidcraft_io::Format;
use super::CommandSpec;
use super::print_face;
use crate::params::{bad, bool_, num, str_, string_list};
use crate::schema::file::ExportResult;
use crate::schema::insert_mesh::{InsertMeshParams, MeshInserted};
use crate::schema::save::{SaveAsParams, SaveParams, SaveResult};
#[cfg(not(target_arch = "wasm32"))]
use crate::schema::save_mesh::{SaveMeshResult, SlicerDelivery};
use crate::schema::{TypedContract, output_schema_for};
use crate::{EngineError, Result, Session};
const INSERT_MESH_CONTRACT: TypedContract<InsertMeshParams> = TypedContract::new();
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("file.new", "New Design", new_doc).icon("new").key("Ctrl+N").noundo().params("name?"),
CommandSpec::new("doc.open", "Open", open)
@@ -44,11 +38,9 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SOLID", "INSERT")
.icon("import")
.params("path: STEP, IGES, FreeCAD .FCStd or Rhino .3dm file (its bodies join the design as an Import base feature in a new component named after the file, placed in the active component; an assembly's products become nested components, a repeated part one component with an occurrence per placement, all held as one rigid group until component.unlock → component, component_name, occurrence, subcomponents?, rigid_joints?, group?: the folder); folder?: bool (default false; true: an import that brings more than one item into the history (its Import feature and the rigid joints holding its parts) goes into a new history folder named `Group #N`); name? (the feature's and the component's); up?: z|y (default z: the file's axes are kept; y: the file has Y up, so the import's component is turned a quarter turn about X, Y becoming Z → stand_up: its occurrence); names?: auto|solid|product|instance (default auto: keep meaningful solid names, replace placeholders with the nearest named product, else instance; solid: the solid's own name; product: the part's name; instance: the occurrence's name, else the product's); matrix?: [12 or 16 numbers] (where the new component's occurrence sits in the active component, after `up`: a rigid row-major 3x4 rotation-and-translation or 4x4 matrix, flat or as rows, as occurrence.move takes it; a scale, shear or mirror is refused before the file is read); tolerance? (see occurrence.move)"),
CommandSpec::new("file.insert_mesh", "Insert Mesh", crate::checked!("file.insert_mesh", insert_mesh))
CommandSpec::new("file.insert_mesh", "Insert Mesh", insert_mesh)
.at("SOLID", "INSERT")
.icon("import")
.contract(&INSERT_MESH_CONTRACT)
.output(output_schema_for::<MeshInserted>)
.params("path: .3mf, .stl, .obj, .ply (with faces), .glb or .gltf file (its meshes join the design as mesh bodies in a new component named after the file → component, component_name, occurrence; a glTF file's node tree becomes nested components held as one rigid group until component.unlock, a mesh placed again another occurrence, its materials the bodies' appearances, and its metres millimetres); name?; up?: z|y for a glTF file (default y: glTF has Y up, so the import's component is turned a quarter turn about X, Y becoming Z → stand_up: its occurrence; z: the file's axes are kept); units?: mm|cm|m|in|ft for an STL or OBJ file, or a PLY file with faces (default mm): the unit its coordinates are in, scaled to millimetres as the file is read (a 3MF or glTF file states its own). The design keeps the millimetre mesh and not the unit, so insert the file again to try another; origin?: bool (default false: the mesh keeps its file's coordinates; true: once read, scaled and stood up, the import's occurrence is moved so the box around its bodies is centred on X and Y of the active component with its lowest point on Z = 0 → moved_to_origin: [x, y, z], the shift made)"),
CommandSpec::new("file.save", "Save", crate::checked!("file.save", save))
.icon("save")
@@ -71,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
@@ -110,12 +100,10 @@ pub static COMMANDS: &[CommandSpec] = &[
.contract(&EXPORT_URDF_CONTRACT)
.output(crate::schema::output_schema_for::<crate::schema::file::UrdfExportResult>)
.params("path; mesh_prefix?; effort? (default 1000); velocity? (default 10) → {path, format, files, links, joints, warnings}: the whole assembly as a robot, with STL meshes written next to it; docs/urdf.md"),
CommandSpec::new("file.save_mesh", "Save As Mesh", crate::checked!("file.save_mesh", save_mesh))
CommandSpec::new("file.save_mesh", "Save As Mesh", save_mesh)
.at("INSERT", "FILES")
.icon("mesh_export")
.noundo()
.contract(&SAVE_MESH_CONTRACT)
.output(crate::schema::output_schema_for::<crate::schema::save_mesh::SaveMeshResult>)
.params("path; format?: stl|3mf|3mf-tagged (default from the extension, else stl; 3mf-tagged: docs/formats/3mf-face-tags.md); bodies?: [names] (default: all); ascii?: bool (STL); slicer?: id or name from file.slicers, the program path it lists, or a program the user added under Custom… (send the saved file there, through the same steps as file.send_to_slicer: a slicer that has to be started first is waited for; may be deprecated later in favour of file.send_to_slicer or a Send to App workflow, kept for scripts); single_instance?: bool (default true; see file.send_to_slicer); 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)"),
CommandSpec::new("file.send_to_slicer", "Send to Slicer", send_to_slicer)
.enabled(starts_programs)
@@ -140,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();
@@ -148,7 +135,6 @@ const EXPORT_STL_CONTRACT: crate::schema::TypedContract<crate::schema::file::Exp
const EXPORT_3MF_CONTRACT: crate::schema::TypedContract<crate::schema::file::Export3mfParams> = crate::schema::TypedContract::new();
const EXPORT_OBJ_CONTRACT: crate::schema::TypedContract<crate::schema::file::ExportMeshParams> = crate::schema::TypedContract::new();
const EXPORT_URDF_CONTRACT: crate::schema::TypedContract<crate::schema::file::ExportUrdfParams> = crate::schema::TypedContract::new();
const SAVE_MESH_CONTRACT: crate::schema::TypedContract<crate::schema::save_mesh::SaveMeshParams> = crate::schema::TypedContract::new();
fn path_arg<'a>(p: &'a Value, cmd: &str) -> Result<&'a str> {
str_(p, "path").filter(|x| !x.trim().is_empty() && x.len() < 4096).ok_or_else(|| bad(cmd, "`path` is required"))
@@ -321,24 +307,18 @@ fn add_mesh(s: &mut Session, path: &str, name: Option<&str>, y_up: Option<bool>,
// After the turn, so it is the stood-up mesh that sits on Z = 0.
let moved = if origin { import::move_to_origin(s, &placed)? } else { None };
let fname = s.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default();
let reply = MeshInserted {
path: path.to_string(),
feature: id,
name: fname,
bodies: bodies.clone(),
created_bodies: bodies,
warnings: mf.warnings,
component: placed.component,
component_name: placed.name.clone(),
occurrence: placed.occurrence,
subcomponents: (placed.components > 0).then_some(placed.components),
rigid_joints: (placed.components > 0).then_some(placed.joints.len()),
moved_to_origin: moved.map(|shift| [shift.x, shift.y, shift.z]),
units: unit.map(|(name, _)| name.to_string()),
stand_up: turned,
param_warnings: Vec::new(),
};
serde_json::to_value(reply).map_err(|e| bad("file.insert_mesh", e.to_string()))
let mut out = json!({"path": path, "feature": id, "name": fname, "bodies": bodies, "created_bodies": bodies, "warnings": mf.warnings});
placed.report(&mut out);
if let Some(shift) = moved {
out["moved_to_origin"] = json!([shift.x, shift.y, shift.z]);
}
if let Some((name, _)) = unit {
out["units"] = json!(name);
}
if let Some(turned) = turned {
out["stand_up"] = json!(turned);
}
Ok(out)
}
/// Turn an import's component a quarter turn about X (the file's Y up becomes Z up), contents
@@ -997,9 +977,7 @@ fn export_to(
solidcraft_io::export_with_parts(&s.doc.painted(&*s.world_state()?), bodies, parts, format, &name, &s.doc.metadata, quality)?
};
solidcraft_io::vfs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let reply =
ExportResult { path: path.to_string(), bytes: bytes.len(), format: format!("{format:?}"), files: Vec::new(), param_warnings: Vec::new() };
serde_json::to_value(reply).map_err(|e| EngineError::Other(e.to_string()))
Ok(json!({"path": path, "bytes": bytes.len(), "format": format!("{format:?}")}))
}
/// [`export_to`] for a mesh file a slicer is sent (STL or 3MF): each body laid on its print face.
@@ -1017,16 +995,9 @@ fn export_laid_to(
let laid = print_face::lay_on_print_faces(s, &mut state, bodies, parts);
let bytes = solidcraft_io::export_with_parts(&state, bodies, parts, format, &name, &s.doc.metadata, quality)?;
solidcraft_io::vfs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let reply = SaveMeshResult {
path: path.to_string(),
bytes: bytes.len(),
format: format!("{format:?}"),
print_faces: laid.bodies,
warnings: laid.warnings,
delivery: SlicerDelivery::default(),
param_warnings: Vec::new(),
};
serde_json::to_value(reply).map_err(|e| EngineError::Other(e.to_string()))
let mut out = json!({"path": path, "bytes": bytes.len(), "format": format!("{format:?}")});
laid.report(&mut out);
Ok(out)
}
fn export(s: &mut Session, p: &Value) -> Result<Value> {
+5 -12
View File
@@ -13,7 +13,6 @@ use solidcraft_io::Format;
use super::easyprint::{Exported, export_bodies};
use super::export_laid_to;
use crate::params::bad;
use crate::schema::save_mesh::SlicerDelivery;
use crate::slicers::{self, Slicer, TargetKind};
use crate::{EngineError, Result, Session};
@@ -121,24 +120,18 @@ fn deliver_via(
out: &mut Value,
) {
let Some(obj) = out.as_object_mut() else { return };
// What happened, as the typed value the reply's schema describes; only its one key is set.
let mut say = |delivery: SlicerDelivery| {
if let Ok(Value::Object(keys)) = serde_json::to_value(delivery) {
obj.extend(keys);
}
};
// A target that drops a file given at a cold start is started alone, and sent the file once it is ready.
if wait != Wait::No && target.waits_for_ready().is_some() && target.ready_via(busctl, slicers::PROBE_LIMIT) == Some(false) {
if let Err(e) = target.start(single_instance) {
say(SlicerDelivery { slicer_error: Some(e), ..Default::default() });
obj.insert("slicer_error".into(), json!(e));
return;
}
if wait == Wait::Return {
say(SlicerDelivery { starting: Some(target.name.clone()), ..Default::default() });
obj.insert("starting".into(), json!(target.name));
return;
}
if !slicers::wait_for_ready_via(target, busctl, limit, poll) {
say(SlicerDelivery { slicer_timeout: Some(target.name.clone()), ..Default::default() });
obj.insert("slicer_timeout".into(), json!(target.name));
return;
}
// After a start the file has to reach the instance that is now running, so the flag goes
@@ -147,8 +140,8 @@ fn deliver_via(
}
// The file is saved either way; a program that won't start is reported, not fatal.
match target.open(path, single_instance) {
Ok(()) => say(SlicerDelivery { slicer: Some(target.name.clone()), ..Default::default() }),
Err(e) => say(SlicerDelivery { slicer_error: Some(e), ..Default::default() }),
Ok(()) => obj.insert("slicer".into(), json!(target.name)),
Err(e) => obj.insert("slicer_error".into(), json!(e)),
};
}
-1
View File
@@ -46,7 +46,6 @@ pub(crate) use features::profiles as feature_profiles;
pub(crate) use features::{EdgeRefForm, PLANE_FORMS, axis_dir_form, axis_line_object, edge_ref_form, explicit_plane, plane_name_problem, sketch_key};
pub use features::{auto_operation, extrude_reach, shown_scope};
pub(crate) use features::{rail_fit_form, sweep_orientation};
pub(crate) use features_more::{MAX_SKETCH_ID, SidePoint, SketchFrameId, axes_mask, side_point, sketch_frame_id};
pub use file::open_steps::OpenSteps;
pub(crate) use inspect::color_param;
pub use join_pieces::{Addition, addition};
+1 -1
View File
@@ -913,7 +913,7 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
c += &format!(
"{},{},{},{},{},{},{},{},{},{},{},{},{},{}\n",
Value::from(r.step),
solidcraft_doc::csv::field(&at.occurrence),
at.occurrence.replace(',', " "),
o[0],
o[1],
o[2],
-19
View File
@@ -687,25 +687,6 @@ fn motion_study_plays_and_exports() {
assert_eq!(run(&mut s, "motion.list", json!({}))["studies"].as_array().map(Vec::len), Some(0));
}
/// A placement's name in a motion CSV is one field, whatever it holds (#12728). A comma is kept in
/// quotes, where it used to become a space; a quote or a line break no longer splits the row; and a
/// name that a spreadsheet would run as a formula opens as text.
#[test]
fn a_motion_csv_keeps_each_placement_name_in_its_field() {
let mut s = rail();
run(&mut s, "motion.study", json!({"name": "Push", "steps": 100, "keys": [{"joint": "Rail", "points": [[0, 10], [100, -5]]}]}));
run(&mut s, "component.rename", json!({"component": "B", "name": "=B, \"x\"\ny"}));
let dir = solidcraft_io::vfs::temp_dir().join(format!("solidcraft-motion-names-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let csv = dir.join("names.csv");
run(&mut s, "motion.export", json!({"study": "Push", "samples": 2, "path": csv.to_string_lossy()}));
let text = std::fs::read_to_string(&csv).unwrap();
let _ = std::fs::remove_dir_all(&dir);
assert!(text.contains(",A:1,") && text.contains(",\"'=B, \"\"x\"\"\ny:1\","), "{text}");
// A header, a row for each placement at each of the two samples, and the line break in each of B's rows.
assert_eq!(text.matches('\n').count(), 1 + 2 * 2 + 2, "{text}");
}
/// Regression (#9741): `motion.study` takes degrees for angle keys, so `motion.list` must give
/// the keys back in those same units — feeding a listed study straight back to `motion.study`
/// must not turn a 90° key into 1.57°.
-21
View File
@@ -400,27 +400,6 @@ fn csv_and_json_round_trip() {
let _ = std::fs::remove_dir_all(&dir);
}
/// A value a spreadsheet would run as a formula is exported as text, with a `'` first, and
/// imports back as it was (#12728). A plain number keeps its sign, and `;` makes a field quoted.
#[test]
fn csv_keeps_formulas_as_text_and_imports_them_back() {
let mut s = Session::default();
let rows = [("d3", "4", "plain"), ("a", "=d3", "+2 for the fit"), ("b", "=5", "'=kept"), ("c", "-5 mm", "@x"), ("e", "6", "one; two")];
for (name, expression, comment) in rows {
run(&mut s, "parameters.add", json!({"name": name, "expression": expression, "comment": comment}));
}
let csv = run(&mut s, "parameters.export", json!({"format": "csv"}))["text"].as_str().unwrap_or_default().to_string();
for written in [",'=d3,", ",'+2 for the fit,", ",'=5,", ",''=kept,", ",'-5 mm,-5,'@x,", ",\"one; two\","] {
assert!(csv.contains(written), "{written}: {csv}");
}
let mut t = Session::default();
run(&mut t, "parameters.import", json!({"text": csv}));
for (name, _, comment) in rows {
let (before, after) = (s.doc.param(name), t.doc.param(name));
assert_eq!(after.map(|p| (p.expr.as_str(), p.comment.as_str())), before.map(|p| (p.expr.as_str(), comment)), "{name}: {csv}");
}
}
#[test]
fn csv_round_trips_comments_with_line_breaks() {
for comment in ["line one\nline two", "\r\nfirst, \"quoted\"\r\n\r\nlast\r\n", "one\rtwo"] {
-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");
}
}
}
+2 -3
View File
@@ -1205,9 +1205,8 @@ fn export_bend_table(s: &mut Session, p: &Value) -> Result<Value> {
let text = if format == BendTableFormat::Json {
serde_json::to_string_pretty(&table).map_err(|e| EngineError::Other(e.to_string()))?
} else {
// Always quote text fields: body/rule names may include commas, quotes or newlines, or start
// as a spreadsheet formula does.
let quote = solidcraft_doc::csv::quoted;
// Always quote text fields: body/rule names may include commas, quotes or newlines.
let quote = |s: &str| format!("\"{}\"", s.replace('"', "\"\""));
let mut csv =
"body,rule,thickness_mm,k_factor,bend,angle_deg,direction,radius_mm,allowance_mm,length_mm,start_x_mm,start_y_mm,end_x_mm,end_y_mm,included_angle_deg\r\n"
.to_string();
-11
View File
@@ -2069,17 +2069,6 @@ fn a_flange_bent_inside_keeps_the_base_sketchs_arcs_in_the_flat_pattern_and_the_
}
}
/// A rule or body name that starts as a spreadsheet formula does is written as text (#12728).
#[test]
fn bend_table_writes_a_formula_like_name_as_text() {
let mut s = Session::default();
run(&mut s, "sheet.manage_rules", json!({"name": "=A1+1", "thickness": 2, "bend_radius": 3, "k_factor": 0.4}));
base(&mut s, 100.0, 60.0);
run(&mut s, "sheet.flange", json!({"edges": [[50, 0, 2]], "height": 20}));
let text = run(&mut s, "sheet.export_bend_table", json!({}))["text"].as_str().unwrap_or_default().to_string();
assert!(text.lines().nth(1).is_some_and(|row| row.contains(",\"'=A1+1\",2.000000000,")), "{text}");
}
#[test]
fn bend_table_exports_shop_dimensions_and_tracks_parameter_changes() {
let mut s = Session::default();
+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 -4
View File
@@ -696,10 +696,7 @@ fn map_local(s: &Session, world: &solidcraft_doc::ModelState, id: &str, params:
Axis => {
if let Some(axis) = v.as_object() {
let mut axis = axis.clone();
// An `{origin, dir}` axis without its origin passes through the world's,
// which the component may have moved.
let origin = axis.get("origin").cloned().or_else(|| axis.contains_key("dir").then(|| pt(Vec3::ZERO)));
if let Some(origin) = origin.and_then(|o| m.make(&o)) {
if let Some(origin) = axis.get("origin").and_then(|o| m.make(o)) {
axis.insert("origin".into(), origin);
}
if let Some(dir) = axis.get("dir").and_then(vec3)
-32
View File
@@ -456,38 +456,6 @@ fn a_surface_revolve_about_a_picked_edge_of_a_moved_component() {
assert!(near(edge.0, line.0) && near(edge.1, line.1), "{edge:?} against {line:?}");
}
/// A revolve axis given only by its direction passes through the world origin in a moved
/// component, as one with `origin: [0, 0, 0]` does: solid and surface revolves, made and edited.
#[test]
fn an_axis_without_an_origin_passes_through_the_world_origin() {
let made = |command: &str, axis: Value| {
let mut s = moved_part();
run(&mut s, "sketch.create", json!({"plane": "XZ", "name": "Ring"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [5, 0], "p1": [10, 5]}));
run(&mut s, "sketch.finish", json!({}));
let mut p = json!({"sketch": "Ring", "axis": axis, "name": "Turn"});
if command == "solid.revolve" {
p["operation"] = json!("new");
}
run(&mut s, command, p.clone());
let body = s.model.state().bodies.last().map(|b| b.name.clone()).unwrap();
let first = world_bbox(&s, &body);
// Edit Feature with the same axis given again.
run(&mut s, "timeline.redefine", json!({"feature": "Turn", "command": command, "params": p}));
let body = s.model.state().bodies.last().map(|b| b.name.clone()).unwrap();
(first, world_bbox(&s, &body))
};
for command in ["solid.revolve", "surface.revolve"] {
let (given, given_edit) = made(command, json!({"origin": [0, 0, 0], "dir": [0, 0, 1]}));
let (omitted, omitted_edit) = made(command, json!({"dir": [0, 0, 1]}));
assert!(near(given.0, omitted.0) && near(given.1, omitted.1), "{command}: {given:?} against {omitted:?}");
assert!(
near(given_edit.0, omitted_edit.0) && near(given_edit.1, omitted_edit.1),
"{command}, edited: {given_edit:?} against {omitted_edit:?}"
);
}
}
/// A new placement given to `timeline.redefine` is still a world point.
#[test]
fn a_new_primitive_placement_is_still_mapped_from_the_world() {
-41
View File
@@ -125,47 +125,6 @@ fn an_upstream_cut_that_splits_a_face_keeps_the_edge_that_bounded_it() {
assert!(solid_at(&s, [0.1, 15.0, 9.9]), "the left one is not");
}
/// Three pockets of a pattern open onto the block's front wall, and a channel at their depth
/// joins their floors into one face: the three edges between the wall and that floor share one
/// name, numbered by where they lie, and a chamfer keeps all three. With the channel shallower,
/// each pocket has its own floor again, and only the first pocket's edge lies between the wall
/// and the floor the names name. The chamfer asks for its edges again instead of chamfering
/// that one edge for all three (#13332); with the floors joined again, it finds all three.
#[test]
fn edges_numbered_between_two_faces_are_not_all_taken_for_the_one_left_there() {
let mut s = Session::default();
block(&mut s);
run(&mut s, "parameters.add", json!({"name": "channel_floor", "expression": "5 mm"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "PocketSketch"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [3, -5], "p1": [9, 6]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "PocketSketch", "distance": 12, "start_offset": 5, "operation": "cut", "name": "Pocket"}));
run(&mut s, "solid.pattern.rectangular", json!({"features": ["Pocket"], "dir1": [1, 0, 0], "count1": 3, "spacing1": 12, "name": "Row"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "ChannelSketch"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [3, 3], "p1": [33, 9]}));
run(&mut s, "sketch.finish", json!({}));
let channel = json!({"sketch": "ChannelSketch", "distance": 12, "start_offset": "channel_floor", "operation": "cut", "name": "Channel"});
run(&mut s, "solid.extrude", channel);
run(&mut s, "solid.chamfer", json!({"edges": [[6, 0, 5], [18, 0, 5], [30, 0, 5]], "distance": 1, "name": "Chamfer"}));
let (block, pocket) = (id(&s, "Block"), id(&s, "Pocket"));
let chamfer = s.doc.find_feature("Chamfer").cloned().unwrap();
let mut stored = chamfer.edge_names.clone();
stored.sort();
assert_eq!(stored, (1..=3).map(|k| format!("F{block}:side:l1|F{pocket}:top~{k}")).collect::<Vec<_>>());
// Inside the block just below each pocket's floor, at the front: cut away by a chamfer there.
let chamfered = |s: &Session| [6.0, 18.0, 30.0].map(|x| !solid_at(s, [x, 0.2, 4.8]));
assert_eq!(chamfered(&s), [true; 3]);
change(&mut s, "channel_floor", "6 mm");
let asked = reselect(&s, "Chamfer");
assert!(asked.as_ref().is_some_and(|name| chamfer.edge_names.contains(name)), "{asked:?}, {:?}", error(&s, "Chamfer"));
assert_eq!(chamfered(&s), [false; 3], "no pocket's edge is chamfered for another's");
change(&mut s, "channel_floor", "5 mm");
assert_eq!(error(&s, "Chamfer"), None);
assert_eq!(chamfered(&s), [true; 3]);
}
#[test]
fn a_blend_follows_its_named_edge_to_a_moved_lid_instead_of_the_body_below() {
for (command, size, removed) in [("solid.fillet", "radius", (4.0 - std::f64::consts::PI) * 5.0), ("solid.chamfer", "distance", 2.0 * 5.0)] {
-344
View File
@@ -1,344 +0,0 @@
//! Contract for `solid.align` in `cmd/features_more.rs` (#13314). Align adds a feature and returns
//! `add_feature`'s reply, a [`super::features::FeatureResult`].
//!
//! 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. Whether a body, a plane, a sketch or a joint origin
//! exists, whether a pick lies on a planar face, and which combinations of keys go together, stay
//! the handler's to check.
//!
//! Align reads some keys only in some cases, and the contract reads them in the same cases:
//!
//! - a side (`from` or `to`) that has a plane or a frame (`from_plane`, `from_frame`) is worked
//! out from that, and its point and face keys are not read (a call that gives both is refused by
//! the handler as a whole);
//! - otherwise a face that is a point (`from_face`) wins, and `from` is not read at all;
//! - otherwise `from` is read: `"origin"`, `{snap?, at}` or `[x, y, z]`.
//!
//! So the type is written by hand, in the handler's order, and a call that works today gets no
//! warning for a key the handler does not read. Where the handler reads a key of the wrong type as
//! if it were left out (`flip`, `angle`, a `from_face` that is not a point, a `snap` that is not
//! text), the contract refuses it: that is what warn and strict are for. `bodies` is optional in
//! the type and required in the schema, and so is one of `from`, `from_face`, `from_plane` and
//! `from_frame` (and the same for `to`), so that the handler's own message is what a caller sees.
use std::borrow::Cow;
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
use serde::de::Error as _;
use serde::{Deserialize, Deserializer};
use serde_json::{Map, Value, json};
use super::check::whole;
use super::config::NameList;
use super::joints::JointOriginArg;
use super::motion::require;
use super::types::{AngleExpr, FeatureCommon, PlaneParam, Point3, SketchRef, StringList, feature_common};
use crate::cmd::{MAX_SKETCH_ID, SidePoint, SketchFrameId, axes_mask, sketch_frame_id, sketch_key};
use crate::params::{self, MAX_STRING_LIST};
/// The handler's message for `bodies` left out, `null` or empty.
const BODIES_MESSAGE: &str = "`bodies` must list the bodies to move";
/// The kinds of snap point `from` and `to` take.
const SNAPS: [&str; 4] = ["vertex", "edge_mid", "face_center", "circle_center"];
/// The `bodies` schema: one name, or a list of 1 to 10 000.
fn bodies_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "The bodies to move, by name: one, or a list of 1 to 10 000.",
"anyOf": [
{"type": "string"},
{"type": "array", "items": {"type": "string"}, "minItems": 1, "maxItems": MAX_STRING_LIST}
]
})
}
/// A point of an Align side: `"origin"`, a snap `{snap?, at}` or `[x, y, z]`. Only for the schema:
/// whether it is read at all depends on the side's other keys, so [`AlignParams`] reads it.
struct SidePointSchema;
impl JsonSchema for SidePointSchema {
fn schema_name() -> Cow<'static, str> {
"AlignPoint".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
let c = json!({"type": "number", "exclusiveMinimum": -1e9, "exclusiveMaximum": 1e9});
json_schema!({
"description": "A point: [x, y, z] in mm, the text \"origin\" (any case), or {\"snap\"?, \"at\"}: the nearest vertex, edge midpoint, planar face centre or circular edge centre of the model to the point `at`. {\"x\", \"y\", \"z\"} is not a point here.",
"anyOf": [
{"type": "array", "items": c, "minItems": 3, "maxItems": 3},
{"type": "string", "pattern": "^[Oo][Rr][Ii][Gg][Ii][Nn]$"},
{
"type": "object",
"properties": {
"snap": {"description": "What to snap to. Default: vertex.", "type": "string", "enum": SNAPS},
"at": {"description": "The point to snap near: [x, y, z] or {\"x\", \"y\", \"z\"}, in mm.", "anyOf": [
{"type": "array", "items": c, "minItems": 3, "maxItems": 3},
{"type": "object", "properties": {"x": c, "y": c, "z": c}, "required": ["x", "y", "z"]}
]}
},
"required": ["at"]
}
]
})
}
}
/// A frame of an Align side: a joint origin as `joint.create` takes one, or `{sketch, point|curve}`.
/// Only for the schema; [`AlignParams`] reads it with [`frame_problem`].
struct FrameSchema;
impl JsonSchema for FrameSchema {
fn schema_name() -> Cow<'static, str> {
"AlignFrame".into()
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
let sketch = generator.subschema_for::<SketchRef>();
let origin = generator.subschema_for::<JointOriginArg>();
let id = json!({"type": "string", "minLength": 1, "maxLength": MAX_SKETCH_ID});
json_schema!({
"description": "A frame that is found again whenever the design changes, so the bodies follow it: a sketch point, or the centre of a sketch circle, arc or ellipse, as {\"sketch\", \"point\" | \"curve\"} (z along the sketch's normal); or a joint origin as joint.create takes one: {face | circle | edge | point: [x, y, z]}, a face's name, or the name of a joint origin. On the bodies' own component, which must hold all of them and be placed once.",
"anyOf": [
{
"type": "object",
"properties": {
"sketch": sketch,
"point": {"description": "A sketch point's id: 1 to 256 characters. Give this or `curve`.", "allOf": [id]},
"curve": {"description": "A sketch circle's, arc's or ellipse's id: 1 to 256 characters. Give this or `point`.", "allOf": [id]}
},
"required": ["sketch"],
"anyOf": [
{"required": ["point"], "not": {"required": ["curve"]}},
{"required": ["curve"], "not": {"required": ["point"]}}
]
},
{"allOf": [origin], "not": {"type": "object", "required": ["sketch"]}}
]
})
}
}
/// `axes`: three booleans, at least one true. Only for the schema.
struct AxesSchema;
impl JsonSchema for AxesSchema {
fn schema_name() -> Cow<'static, str> {
"AlignAxes".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "Which world axes the move follows: [x, y, z] booleans, at least one true.",
"type": "array",
"items": {"type": "boolean"},
"minItems": 3,
"maxItems": 3,
"contains": {"const": true}
})
}
}
/// `bodies` is required, and so is one key for each side.
fn align_schema(schema: &mut Schema) {
require(schema, "bodies");
// Each side needs one of its four keys; the handler says which, in its own words.
let side = |keys: [&str; 4]| json!({"anyOf": keys.iter().map(|k| json!({"required": [k]})).collect::<Vec<_>>()});
let sides = [side(["from", "from_face", "from_plane", "from_frame"]), side(["to", "to_face", "to_plane", "to_frame"])];
match schema.get_mut("allOf") {
Some(Value::Array(all)) => all.extend(sides),
_ => {
schema.insert("allOf".into(), Value::Array(sides.into()));
}
}
}
/// Move bodies so that a point, face, plane or frame on them meets another one, as a feature in
/// the timeline (one undo step). Each side is one of: a point (`from`, `to`), a planar face
/// picked by a point on it (`from_face`, `to_face`), a plane (`from_plane`, `to_plane`) or a frame
/// (`from_frame`, `to_frame`); give one key for each side. A plane or a frame is worked out again
/// whenever the design changes, so the bodies follow it. Faces, planes and frames also turn the
/// bodies so that they meet (the first side's face against the second's, or with `flip` side by
/// side); a point onto a plane moves along the plane's normal. `axes` keeps the move to some
/// world axes and only goes with two points. Align makes no body, so `body_name` and `body_names`
/// do nothing here (as for Remove), and `participants` changes nothing in the result.
#[derive(Debug, JsonSchema)]
#[schemars(deny_unknown_fields, transform = align_schema)]
pub struct AlignParams {
// Optional in the type and required in the schema (`align_schema`): left out, `null` or
// empty, the handler refuses the call with its own message.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(schema_with = "bodies_schema")]
pub bodies: Option<NameList>,
/// Where the move starts, on the bodies. Not read when `from_face` is a point; not with
/// `from_plane` or `from_frame` (the handler refuses both).
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "SidePointSchema")]
pub from: Option<Value>,
/// A point on a planar face of the bodies to start from: the bodies turn so that the face
/// meets the target's. When it is a point, `from` is not read. Not with `from_plane` or
/// `from_frame`.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "Point3")]
pub from_face: Option<Value>,
/// A plane to start from, kept as a plane so the bodies follow it when it moves; the bodies
/// turn so that it meets the target. Not with `from`, `from_face` or `from_frame`.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "PlaneParam")]
pub from_plane: Option<Value>,
/// A frame to start from, in place of the point, face or plane; the bodies turn so that it
/// meets the target. Not with `from`, `from_face` or `from_plane`.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "FrameSchema")]
pub from_frame: Option<Value>,
/// Where the bodies go. Not read when `to_face` is a point; not with `to_plane` or
/// `to_frame`.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "SidePointSchema")]
pub to: Option<Value>,
/// A point on a planar face to go to. When it is a point, `to` is not read. Not with
/// `to_plane` or `to_frame`.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "Point3")]
pub to_face: Option<Value>,
/// A plane to go to, which may move later (the bodies follow); a point or a face goes onto
/// it along its normal. Not with `to`, `to_face` or `to_frame`.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "PlaneParam")]
pub to_plane: Option<Value>,
/// A frame to go to, in place of the point, face or plane. It must not be on a body that
/// moves. Not with `to`, `to_face` or `to_plane`.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "FrameSchema")]
pub to_frame: Option<Value>,
/// With faces, planes or frames: true turns the bodies so that the two normals point the same
/// way, instead of facing each other. Default: false.
#[serde(default)]
pub flip: Option<bool>,
/// Which world axes the offset moves along, with a point on each side: [x, y, z] booleans,
/// at least one true. Default: all three. Refused with a face, plane or frame.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "AxesSchema")]
pub axes: Option<[bool; 3]>,
/// A turn about the target's direction (a face's normal, a plane's normal or a frame's z)
/// after the move, in degrees or an expression. Needs a face, a plane or a frame as the
/// target. Default: none.
#[serde(default)]
pub angle: Option<AngleExpr>,
#[serde(flatten)]
pub common: FeatureCommon,
}
/// `bodies` as the handler reads it: names, at least one; `null` and a missing key are none.
fn bodies(m: &Map<String, Value>) -> Result<NameList, String> {
let names = match m.get("bodies") {
None | Some(Value::Null) => NameList::default(),
// Each of its messages starts with "must": the key goes in front, as `string_list` has it.
Some(v) => NameList::deserialize(v).map_err(|e| whole(format!("`bodies` {e}")))?,
};
if names.0.is_empty() { Err(whole(BODIES_MESSAGE)) } else { Ok(names) }
}
/// `key`, a plane, when it is given (`null` included: the handler reads it as a plane and
/// refuses it).
fn plane(m: &Map<String, Value>, key: &str) -> Result<Option<Value>, String> {
// The reader's own message is a whole sentence, marked as such.
m.get(key).map(|v| PlaneParam::deserialize(v).map(|p| p.0).map_err(|e| e.to_string())).transpose()
}
/// What is wrong with the frame `v` of `key` (`from_frame`), as the handler says it, marked
/// whole, or `None`.
fn frame_problem(v: &Value, key: &str) -> Option<String> {
if let Some(sketch) = v.get("sketch") {
if sketch_key(Some(sketch)).is_none() {
return Some(whole(format!("`{key}` must be a sketch id or name")));
}
return match sketch_frame_id(v) {
Some(SketchFrameId::Point(_) | SketchFrameId::Curve(_)) => None,
None => Some(whole(format!("`{key}`: a sketch frame takes `point` or `curve` (an id of 1 to {MAX_SKETCH_ID} characters)"))),
};
}
// A joint origin, which the handler reads as `joint.create` reads one.
JointOriginArg::deserialize(v).err().map(|e| e.to_string())
}
/// `<key>_frame`, when it is given and not `null`.
fn frame(m: &Map<String, Value>, key: &str) -> Result<Option<Value>, String> {
let frame_key = format!("{key}_frame");
match m.get(&frame_key) {
None | Some(Value::Null) => Ok(None),
// The handler refuses a frame given beside the side's other keys before it reads the frame.
Some(v) if [key.to_string(), format!("{key}_face"), format!("{key}_plane")].iter().any(|k| m.contains_key(k)) => Ok(Some(v.clone())),
Some(v) => frame_problem(v, &frame_key).map_or_else(|| Ok(Some(v.clone())), Err),
}
}
/// The point and face of the side `pk` (`from`), read as the handler reads them: not at all when
/// the side has a plane or a frame (`held`); the face if it is a point, and then `from` is not
/// read; else `from`.
fn side(m: &Map<String, Value>, pk: &str, held: bool) -> Result<(Option<Value>, Option<Value>), String> {
if held {
return Ok((None, None));
}
let fk = format!("{pk}_face");
match m.get(&fk) {
None | Some(Value::Null) => {}
Some(v) if params::vec3(v).is_some() => return Ok((None, Some(v.clone()))),
Some(_) => return Err(whole(format!("`{fk}` must be a point [x, y, z] of numbers"))),
}
let v = m.get(pk);
match crate::cmd::side_point(v, pk, &fk).map_err(whole)? {
SidePoint::Snap { .. } => {
// The handler reads a `snap` that is not text as a vertex; the word is its to look up.
if v.and_then(|o| o.get("snap")).is_some_and(|s| !s.is_null() && !s.is_string()) {
return Err(whole(format!("`{pk}.snap` must be {}", SNAPS.join(", "))));
}
}
SidePoint::Origin | SidePoint::Point(_) => {}
}
Ok((v.cloned(), None))
}
impl<'de> Deserialize<'de> for AlignParams {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let m = Map::<String, Value>::deserialize(d)?;
let fail = D::Error::custom;
// In the handler's order: the bodies, the planes, the frames, the two sides' points, the
// angle and the axes; then `add_feature`'s keys.
let bodies = bodies(&m).map_err(fail)?;
let (from_plane, to_plane) = (plane(&m, "from_plane").map_err(fail)?, plane(&m, "to_plane").map_err(fail)?);
let (from_frame, to_frame) = (frame(&m, "from").map_err(fail)?, frame(&m, "to").map_err(fail)?);
let (from, from_face) = side(&m, "from", from_plane.is_some() || from_frame.is_some()).map_err(fail)?;
let (to, to_face) = side(&m, "to", to_plane.is_some() || to_frame.is_some()).map_err(fail)?;
let angle = match m.get("angle") {
None | Some(Value::Null) => None,
Some(v) => Some(AngleExpr::deserialize(v).map_err(|e| fail(whole(format!("`angle` {e}"))))?),
};
let axes = match m.get("axes") {
None | Some(Value::Null) => None,
Some(v) => Some(axes_mask(v).map_err(|why| fail(whole(why)))?),
};
let flip = match m.get("flip") {
None | Some(Value::Null) => None,
Some(Value::Bool(b)) => Some(*b),
Some(_) => return Err(fail(whole("`flip` must be true or false"))),
};
// `add_feature` reads `body_names` and `participants` as `string_list` does, whose messages
// are `NameList`'s, not `FeatureCommon`'s; they are read here, and the rest go to it.
let mut m = m;
let mut lists = Vec::new();
for key in ["participants", "body_names"] {
if let Some(v) = m.remove(key).filter(|v| !v.is_null()) {
lists.push(StringList(NameList::deserialize(v).map_err(|e| fail(whole(format!("`{key}` {e}"))))?.0));
} else {
lists.push(StringList::default());
}
}
let mut common = feature_common::<_, FeatureCommon>(Value::Object(m)).map_err(|e| fail(e.to_string()))?;
let (body_names, participants) = (lists.pop().filter(|l| !l.0.is_empty()), lists.pop().filter(|l| !l.0.is_empty()));
(common.body_names, common.participants) = (body_names, participants);
Ok(AlignParams { bodies: Some(bodies), from, from_face, from_plane, from_frame, to, to_face, to_plane, to_frame, flip, axes, angle, common })
}
}
#[cfg(test)]
#[path = "align_tests.rs"]
mod tests;
-329
View File
@@ -1,329 +0,0 @@
use serde_json::{Value, json};
use super::AlignParams;
use crate::Session;
use crate::schema::features::FeatureResult;
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
use crate::schema::{ParamCheck, input_schema_for, issues, output_schema_for};
/// Box `A` (10 x 10 x 10 at the origin) and `B` (10 x 20 x 10 at (50, 0, 20)), and sketch `S` on
/// XY with a circle `ring` at (0, 5) and a point `p` at (20, 0).
fn scene(level: ParamCheck) -> Session {
let mut s = Session { param_check: level, ..Session::default() };
for (id, p) in [
("solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "A"})),
("solid.box", json!({"length": 10, "width": 20, "height": 10, "corner": [50, 0, 20], "body_name": "B", "operation": "new"})),
("sketch.create", json!({"plane": "XY", "name": "S"})),
("sketch.circle.center", json!({"center": [0, 5], "radius": 3, "id": "ring"})),
("sketch.point", json!({"point": [20, 0], "id": "p"})),
("sketch.finish", json!({})),
] {
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
}
s
}
/// `base` for body `B` with `extra`'s keys added.
fn call(extra: Value) -> Value {
let mut p = json!({"bodies": ["B"]});
if let (Some(p), Some(extra)) = (p.as_object_mut(), extra.as_object()) {
p.extend(extra.clone());
}
p
}
fn messages(p: &Value) -> Vec<String> {
issues("solid.align", p).into_iter().map(|i| i.message).collect()
}
#[test]
fn schemas_and_the_key_check_agree() {
assert_keys(
input_schema_for::<AlignParams>(),
&[
"angle",
"axes",
"bodies",
"body_name",
"body_names",
"flip",
"from",
"from_face",
"from_frame",
"from_plane",
"name",
"participants",
"to",
"to_face",
"to_frame",
"to_plane",
],
);
let schema = input_schema_for::<AlignParams>();
assert_eq!(schema.as_value()["required"], json!(["bodies"]));
}
/// Calls that fail today get the same message in strict, on a design with the bodies, a sketch
/// and its geometry (the handler's first check is that the bodies exist).
#[test]
fn strict_keeps_todays_errors() {
let long = "q".repeat(300);
let cases = [
json!({}),
json!({"bodies": null, "from": [0, 0, 0], "to": [1, 1, 1]}),
json!({"bodies": [], "from": [0, 0, 0], "to": [1, 1, 1]}),
json!({"bodies": 5, "from": [0, 0, 0], "to": [1, 1, 1]}),
json!({"bodies": ["B", 5], "from": [0, 0, 0], "to": [1, 1, 1]}),
call(json!({})),
call(json!({"from": [0, 0, 0]})),
call(json!({"to": [0, 0, 0]})),
call(json!({"from": [0, 0], "to": [0, 0, 0]})),
call(json!({"from": null, "to": [0, 0, 0]})),
call(json!({"from": "nowhere", "to": [0, 0, 0]})),
call(json!({"from": {"x": 1, "y": 2, "z": 3}, "to": [0, 0, 0]})),
call(json!({"from": {"snap": "vertex"}, "to": [0, 0, 0]})),
call(json!({"from": {"at": "here"}, "to": [0, 0, 0]})),
call(json!({"from": [0, 0, 0], "to": {"at": [1, 2]}})),
call(json!({"from": [0, 0, 0], "to": "centre"})),
call(json!({"from": "origin", "to_face": null})),
call(json!({"from_plane": null, "to": [0, 0, 0]})),
call(json!({"from_plane": true, "to": [0, 0, 0]})),
call(json!({"from_plane": -1, "to": [0, 0, 0]})),
call(json!({"from_plane": {"origin": [0, 0]}, "to": [0, 0, 0]})),
call(json!({"from_plane": {}, "to": [0, 0, 0]})),
call(json!({"from": [0, 0, 0], "to_plane": [1]})),
call(json!({"from_frame": 5, "to": [0, 0, 0]})),
// A frame beside the side's other keys is refused as "not both" before it is read.
call(json!({"from_frame": 5, "from": [0, 0, 0], "to": [1, 1, 1]})),
call(json!({"from_plane": "XY", "from_frame": {"sketch": "S"}, "to": [0, 0, 0]})),
call(json!({"from_frame": {}, "to": [0, 0, 0]})),
call(json!({"from_frame": {"frame": {}}, "to": [0, 0, 0]})),
call(json!({"from_frame": {"sketch": true, "point": "p"}, "to": [0, 0, 0]})),
call(json!({"from_frame": {"sketch": null, "point": "p"}, "to": [0, 0, 0]})),
call(json!({"from_frame": {"sketch": "S"}, "to": [0, 0, 0]})),
call(json!({"from_frame": {"sketch": "S", "point": "p", "curve": "ring"}, "to": [0, 0, 0]})),
call(json!({"from_frame": {"sketch": "S", "point": ""}, "to": [0, 0, 0]})),
call(json!({"from_frame": {"sketch": "S", "point": long}, "to": [0, 0, 0]})),
call(json!({"from": [0, 0, 0], "to_frame": {"sketch": "S", "point": 5}})),
call(json!({"from": [0, 0, 0], "to_frame": {"face": [1, 2]}})),
call(json!({"from": [0, 0, 0], "to": [1, 1, 1], "axes": "x"})),
call(json!({"from": [0, 0, 0], "to": [1, 1, 1], "axes": [true, false]})),
call(json!({"from": [0, 0, 0], "to": [1, 1, 1], "axes": [true, 1, false]})),
call(json!({"from": [0, 0, 0], "to": [1, 1, 1], "axes": [false, false, false]})),
call(json!({"from": [0, 0, 0], "to": [1, 1, 1], "participants": 5})),
call(json!({"from": [0, 0, 0], "to": [1, 1, 1], "participants": ["Nope"]})),
call(json!({"from": [0, 0, 0], "to": [1, 1, 1], "participants": ["B", false]})),
call(json!({"from": [0, 0, 0], "to": [1, 1, 1], "participants": 5, "body_names": 6})),
call(json!({"from": [0], "to": [1]})),
call(json!({"from_plane": 7.5, "to_plane": true})),
call(json!({"from": [0, 0, 0], "to": [1, 1, 1], "body_names": [7]})),
];
for p in &cases {
let error = |level: ParamCheck| scene(level).execute("solid.align", p).err().map(|e| e.to_string());
let off = error(ParamCheck::Off);
assert!(off.is_some(), "{p} should fail today");
assert_eq!(error(ParamCheck::Strict), off, "{p}");
}
// The ones that fail before any body is looked up, with the shared helper's session.
assert_strict_keeps_todays_errors(&[
("solid.align", json!({})),
("solid.align", json!({"bodies": []})),
("solid.align", json!({"bodies": 5})),
("solid.align", json!({"bodies": [1]})),
]);
}
/// A side that has a plane or a frame is read from that, and one that has a face that is a point
/// is read from the face: the keys the handler does not read in that case are not checked, so a
/// call that works today gets no warning.
#[test]
fn keys_the_handler_does_not_read_are_not_checked() {
let calls = [
// `from` is not read when `from_face` is a point.
call(json!({"from_face": [55, 10, 20], "from": "garbage", "to_plane": "XY"})),
call(json!({"from_face": [55, 10, 20], "from": null, "to_plane": "XY"})),
call(json!({"from_face": [55, 10, 20], "from": {"snap": 5}, "to_plane": "XY"})),
call(json!({"from_face": [55, 10, 20], "to_face": [5, 5, 0], "to": 17})),
// A null face is a missing one.
call(json!({"from_face": null, "from": [50, 0, 20], "to": [0, 0, 0]})),
// A null frame is a missing one; so is a face that is null beside a frame.
call(json!({"from_frame": null, "from": [50, 0, 20], "to": "origin"})),
call(json!({"from": [50, 0, 20], "to_frame": null, "to": [1, 2, 3]})),
// A snap with no `snap` snaps to a vertex; the object's other keys are not read.
call(json!({"from": {"at": [50, 0, 20], "note": 1}, "to": {"snap": null, "at": [0, 0, 0]}})),
// `axes` and `angle` may be `null`.
call(json!({"from": [50, 0, 20], "to": [0, 0, 0], "axes": null, "angle": null, "flip": null})),
];
for p in &calls {
assert!(scene(ParamCheck::Off).execute("solid.align", p).is_ok(), "{p} works today");
assert!(messages(p).is_empty(), "{p}: {:?}", messages(p));
let reply = scene(ParamCheck::Warn).execute("solid.align", p).unwrap_or_else(|e| panic!("{p}: {e}"));
assert!(reply.get("param_warnings").is_none(), "{p}: {reply}");
assert!(scene(ParamCheck::Strict).execute("solid.align", p).is_ok(), "{p}");
}
// With a plane or a frame the point and face are not read: the handler refuses the call, in
// its own words, and the contract leaves that to it.
for p in [
call(json!({"from_plane": "XY", "from": "garbage", "to": [0, 0, 0]})),
call(json!({"from_plane": "XY", "from_face": "garbage", "to": [0, 0, 0]})),
call(json!({"from_frame": {"face": [55, 10, 20]}, "from": 5, "to": [0, 0, 0]})),
] {
assert!(messages(&p).is_empty(), "{p}: {:?}", messages(&p));
let error = |level: ParamCheck| scene(level).execute("solid.align", &p).err().map(|e| e.to_string());
assert!(error(ParamCheck::Off).is_some_and(|e| e.contains("not both")), "{p}");
assert_eq!(error(ParamCheck::Strict), error(ParamCheck::Off), "{p}");
}
}
/// Keys of the wrong type that the handler reads as left out are refused by the contract: in Warn
/// the call still works, with a warning naming the key.
#[test]
fn the_contract_refuses_what_the_handler_ignores() {
let cases = [
(call(json!({"from": [50, 0, 20], "to": [0, 0, 0], "flip": "yes"})), "flip"),
(call(json!({"from": [50, 0, 20], "to": [0, 0, 0], "angle": true})), "angle"),
(call(json!({"from": [50, 0, 20], "to": [0, 0, 0], "angle": []})), "angle"),
(call(json!({"from_face": "junk", "from": [50, 0, 20], "to": [0, 0, 0]})), "from_face"),
(call(json!({"from": {"snap": 5, "at": [50, 0, 20]}, "to": [0, 0, 0]})), "from.snap"),
];
for (p, key) in cases {
assert!(scene(ParamCheck::Off).execute("solid.align", &p).is_ok(), "{p} works today");
let warned = scene(ParamCheck::Warn).execute("solid.align", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
assert!(warned["param_warnings"].to_string().contains(key), "{p}: {warned}");
let error = scene(ParamCheck::Strict).execute("solid.align", &p).err().map(|e| e.to_string()).unwrap_or_default();
assert!(error.contains(&format!("`{key}`")), "{p}: {error}");
}
}
/// The messages the check gives, which are the handler's.
#[test]
fn the_check_names_what_is_wrong_with_a_key() {
let go = |extra: Value| messages(&call(extra));
assert_eq!(messages(&json!({})), ["`bodies` must list the bodies to move"]);
assert_eq!(messages(&json!({"bodies": [5]})), ["`bodies` must list names; the entry 1 is 5"]);
assert_eq!(go(json!({})), ["give `from`, `from_face` or `from_plane` ([x, y, z], \"origin\", or a plane)"]);
assert_eq!(go(json!({"from": "origin"})), ["give `to`, `to_face` or `to_plane` ([x, y, z], \"origin\", or a plane)"]);
assert_eq!(go(json!({"from": "ORIGIN", "to": {"snap": "vertex"}})), ["`to.at` must be [x, y, z]"]);
assert_eq!(go(json!({"from": "origin", "to": "origin", "axes": [false, false, false]})), ["`axes` must enable at least one of X, Y and Z"]);
assert_eq!(go(json!({"from": "origin", "to": "origin", "axes": [true]})), ["`axes` must be [x, y, z] booleans"]);
assert_eq!(
go(json!({"from": "origin", "to": "origin", "angle": "x".repeat(5000)})),
["`angle` must be an expression of at most 4096 characters"]
);
assert_eq!(go(json!({"from": "origin", "to": "origin", "participants": 5})), ["`participants` must be a list of names; it is 5"]);
assert_eq!(go(json!({"from": "origin", "to": "origin", "bodies2": 1})).len(), 1);
assert_eq!(go(json!({"from": "origin", "to": "origin", "flipp": true})), ["unknown key `flipp`; did you mean `flip`?"]);
assert_eq!(go(json!({"from": "origin", "to": "origin", "flip": 1})), ["`flip` must be true or false"]);
}
/// The schema takes what the handler takes where the keys alone decide it. Each call is checked
/// against the schema and against the handler, with the check off.
#[test]
fn the_schema_takes_what_the_handler_takes() {
let schema = input_schema_for::<AlignParams>();
let schema = schema.as_value();
let ring = json!({"sketch": "S", "curve": "ring"});
let calls = [
call(json!({"from": [50, 0, 20], "to": [0, 0, 0]})),
call(json!({"from": "Origin", "to": {"snap": "vertex", "at": [0, 0, 0]}})),
call(json!({"from": {"at": {"x": 50, "y": 0, "z": 20}}, "to": [0, 0, 0], "axes": [false, false, true]})),
call(json!({"from_face": [55, 10, 20], "to_plane": "XY"})),
call(json!({"from_face": [55, 10, 20], "to_face": [5, 5, 0], "flip": true, "angle": "30 deg"})),
call(json!({"from_plane": {"origin": [0, 0, 5], "normal": [0, 0, 1]}, "to": [0, 0, 7]})),
call(json!({"from_face": {"x": 55, "y": 10, "z": 20}, "to_plane": "XY"})),
call(json!({"from_frame": {"face": [55, 10, 20]}, "to": [0, 0, 0]})),
call(json!({"from_frame": {"frame": {"origin": [55, 10, 20], "z": [0, 0, -1]}}, "to": [0, 0, 0]})),
call(json!({"from_frame": {"face": [55, 10, 20]}, "to_frame": ring, "angle": 90})),
call(json!({"from_frame": {"face": [55, 10, 20]}, "to_frame": ring, "angle": 90})),
call(json!({"from_frame": {"sketch": "S", "point": "p"}, "to": [20, 0, 5]})),
json!({"bodies": "B", "from": [50, 0, 20], "to": [0, 0, 0], "name": "Move", "participants": ["B"]}),
json!({}),
json!({"bodies": ["B"]}),
json!({"bodies": [], "from": [50, 0, 20], "to": [0, 0, 0]}),
call(json!({"from": [50, 0, 20]})),
call(json!({"to": [50, 0, 20]})),
call(json!({"from": [50, 0], "to": [0, 0, 0]})),
call(json!({"from": {"x": 50, "y": 0, "z": 20}, "to": [0, 0, 0]})),
call(json!({"from": {"snap": "vertex"}, "to": [0, 0, 0]})),
call(json!({"from_plane": 7.5, "to": [0, 0, 0]})),
call(json!({"from_frame": {"sketch": "S"}, "to": [0, 0, 0]})),
call(json!({"from_frame": {"sketch": "S", "point": "p", "curve": "ring"}, "to": [0, 0, 0]})),
call(json!({"from_frame": {"sketch": "S", "point": ""}, "to": [0, 0, 0]})),
call(json!({"from_frame": {"sketch": "S", "point": "q".repeat(257)}, "to": [0, 0, 0]})),
call(json!({"from_frame": 5, "to": [0, 0, 0]})),
call(json!({"from": [50, 0, 20], "to": [0, 0, 0], "axes": [true]})),
call(json!({"from": [50, 0, 20], "to": [0, 0, 0], "flip": "yes"})),
];
for p in &calls {
// The schema takes a call when the handler does and the check has nothing to say (a
// `flip` that is not true or false runs today, and is a warning).
let takes = scene(ParamCheck::Off).execute("solid.align", p).is_ok();
assert_eq!(conforms(schema, schema, p), takes && messages(p).is_empty(), "{p}: {:?}", messages(p));
}
}
/// Each form of the command, in Warn: a reply that matches the output schema and carries no
/// warning, since Warn runs a call the contract refuses as well. The same calls pass strict.
#[test]
fn replies_match_the_output_schema() {
let ring = json!({"sketch": "S", "curve": "ring"});
let calls = [
call(json!({"from": [50, 0, 20], "to": [0, 0, 0]})),
call(json!({"from": [50, 0, 20], "to": [0, 0, 0], "axes": [true, false, true]})),
call(json!({"from": "ORIGIN", "to": {"snap": "vertex", "at": [10, 20, 30]}})),
call(json!({"from": {"snap": "edge_mid", "at": [50, 0, 25]}, "to": {"at": [0, 0, 0]}})),
call(json!({"from_face": [55, 10, 20], "to_face": [5, 5, 10]})),
call(json!({"from_face": [55, 10, 20], "to_face": [5, 5, 10], "flip": true, "angle": "30 deg"})),
call(json!({"from_face": {"x": 55, "y": 10, "z": 20}, "to_plane": "XY"})),
call(json!({"from_plane": {"origin": [0, 0, 5], "normal": [0, 0, 1]}, "to": [0, 0, 7]})),
call(json!({"from_plane": "XY", "to_plane": {"origin": [0, 0, 5], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}})),
call(json!({"from_frame": {"face": [55, 10, 20]}, "to_frame": ring, "angle": 90})),
call(json!({"from_frame": {"sketch": "S", "point": "p"}, "to": [20, 0, 5], "flip": true})),
call(json!({"from_frame": {"point": [55, 10, 20]}, "to": [0, 0, 0]})),
call(json!({"from_frame": {"frame": {"origin": [55, 10, 20], "z": [0, 0, -1]}}, "to": [0, 0, 0]})),
call(json!({"from": [50, 0, 20], "to_frame": {"sketch": "S", "point": "p"}})),
json!({"bodies": "B", "from": [50, 0, 20], "to": [0, 0, 0], "name": "Move B", "participants": ["B", "A"]}),
json!({"bodies": ["A", "B"], "from": [0, 0, 0], "to": [0, 0, 5], "body_name": "unused", "participants": "*"}),
];
let schema = output_schema_for::<FeatureResult>();
for p in &calls {
let reply = scene(ParamCheck::Warn).execute("solid.align", p).unwrap_or_else(|e| panic!("{p}: {e}"));
assert!(conforms(schema.as_value(), schema.as_value(), &reply), "{p}: {reply}");
assert!(reply.get("param_warnings").is_none(), "{p}: {reply}");
assert!(messages(p).is_empty(), "{p}: {:?}", messages(p));
let strict = scene(ParamCheck::Strict).execute("solid.align", p).unwrap_or_else(|e| panic!("{p}: {e}"));
// The replies are the same but for the time it took.
let same = |mut r: Value| {
r.as_object_mut().map(|o| o.remove("ms"));
r
};
let off = scene(ParamCheck::Off).execute("solid.align", p).unwrap_or_else(|e| panic!("{p}: {e}"));
assert_eq!(same(strict), same(reply.clone()), "{p}");
assert_eq!(same(off), same(reply), "{p}");
}
// A misspelt key still runs in Warn, and says so, with a reply that matches.
let p = call(json!({"from": [50, 0, 20], "to": [0, 0, 0], "axis": [true, true, true]}));
let warned = scene(ParamCheck::Warn).execute("solid.align", &p).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(warned["param_warnings"], json!(["unknown key `axis`; did you mean `axes`?"]), "{warned}");
assert!(conforms(schema.as_value(), schema.as_value(), &warned), "{warned}");
}
/// The patterns and schemas the tests above can't read (`conforms` skips patterns and `contains`).
#[test]
fn the_schema_text_is_pinned() {
let schema = input_schema_for::<AlignParams>();
let all = schema.as_value().to_string();
assert!(all.contains(r#""pattern":"^[Oo][Rr][Ii][Gg][Ii][Nn]$""#), "{all}");
assert!(all.contains(r#""contains":{"const":true}"#), "{all}");
assert!(all.contains(r#""maxLength":256"#), "{all}");
assert!(schema.as_value()["properties"]["from"]["description"].is_string());
for key in ["from", "to"] {
let any = format!(
r#"{{"anyOf":[{{"required":["{key}"]}},{{"required":["{key}_face"]}},{{"required":["{key}_plane"]}},{{"required":["{key}_frame"]}}]}}"#
);
assert!(all.contains(&any), "{key}: {all}");
}
// `origin` in any case is what the handler reads, and nothing around it.
let origin = |t: &str| scene(ParamCheck::Off).execute("solid.align", &call(json!({"from": t, "to": [0, 0, 0]}))).is_ok();
assert!(origin("origin") && origin("OrIgIn") && !origin(" origin") && !origin("origin\n"));
}
+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,
+1 -11
View File
@@ -19,15 +19,6 @@ pub struct AssistantParams {}
#[derive(Debug, Serialize, JsonSchema)]
pub struct AssistantResult {}
/// Command search is UI-owned and takes no parameters.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct PaletteParams {}
/// Headless sessions refuse to open command search and produce no result.
#[derive(Debug, Serialize, JsonSchema)]
pub struct PaletteResult {}
/// `edit.repeat` takes no parameters: it starts the last tool with its defaults.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
@@ -1528,7 +1519,7 @@ mod tests {
use super::{
AssistantParams, CheckpointParams, CheckpointResult, FeatureKeys, FeatureRef, FeatureSelection, FolderId, FolderIds, MarkerPosition, NewName,
PaletteParams, RedoParams, RedoResult, RepeatParams, RestoreCheckpointParams, SetFields, TimelineComputeAllParams, TimelineComputeAllResult,
RedoParams, RedoResult, RepeatParams, RestoreCheckpointParams, SetFields, TimelineComputeAllParams, TimelineComputeAllResult,
TimelineDeleteParams, TimelineDeleteResult, TimelineDependentsParams, TimelineDependentsResult, TimelineEditParams, TimelineEditResult,
TimelineInspectParams, TimelineInspectResult, TimelineMarkerResult, TimelineRenameParams, TimelineRenameResult, TimelineRollToParams,
TimelineRollbackParams, TimelineSuppressParams, TimelineSuppressResult, UndoParams, UndoResult,
@@ -1546,7 +1537,6 @@ mod tests {
fn schemas_and_the_key_check_agree() {
assert_keys(input_schema_for::<RepeatParams>(), &[]);
assert_keys(input_schema_for::<AssistantParams>(), &[]);
assert_keys(input_schema_for::<PaletteParams>(), &[]);
assert_keys(input_schema_for::<UndoParams>(), &[]);
assert_keys(input_schema_for::<RedoParams>(), &[]);
assert_keys(input_schema_for::<CheckpointParams>(), &[]);
-73
View File
@@ -18,7 +18,6 @@ use super::types::{
OperationParam, PlaneCommon, PlaneOrFace, Point3, PointExpr3, ProfilesParam, ScaleFactors, SketchRef, StringList, ThinSideParam, ToPoint,
TouchingParam, feature_common, present,
};
use super::types::{RevolveAxis, RevolveConventionParam, RevolveDirection, RevolveToPoint};
use crate::params::MAX_STRING_LIST;
/// Add a box primitive.
@@ -182,78 +181,6 @@ pub struct ExtrudeParams {
pub thickness2: Option<LengthExpr>,
}
/// Revolve sketch profiles (or a planar body face) about an axis into a solid.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct RevolveParams {
/// The axis to turn about.
pub axis: RevolveAxis,
/// How far to turn: more than 0 and at most 360 degrees. With `angle2`, the angle on the
/// first side, the two adding up to the turn; with `direction: symmetric`, the whole turn,
/// split either side. Default: 360 deg. Not used when `to` is given, though it must still
/// evaluate.
#[serde(default)]
pub angle: Option<AngleExpr>,
/// Two Sides: the angle on the other side, turned back from the profile. Not used when `to`
/// is given; used instead of `direction: symmetric` when both are given.
#[serde(default)]
pub angle2: Option<AngleExpr>,
/// `symmetric` splits the angle evenly either side of the profile, whichever way the axis
/// points. The handler ignores every other value.
#[serde(default)]
pub direction: Option<RevolveDirection>,
/// When true, the same as `direction: symmetric`.
#[serde(default)]
pub symmetric: Option<bool>,
/// Where the angle starts. Default: sketch_normal.
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "RevolveConventionParam")]
pub convention: Option<RevolveConventionParam>,
/// Turn until this face, vertex or point (To Object). Takes the place of `angle`, `angle2`
/// and `direction`.
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "RevolveToPoint")]
pub to: Option<RevolveToPoint>,
/// The sketch to revolve, by id or name. Default: the active sketch, else the last one.
/// Ignored when `face` is given.
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "IdOrName")]
pub sketch: Option<IdOrName>,
/// Which of the sketch's profiles to revolve. Default: all of them. Ignored when `face` is
/// given.
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "ProfilesParam")]
pub profiles: Option<ProfilesParam>,
/// Each index or point pick is exactly the region it names. Without it, a pick on a drawn
/// profile that reference geometry (a face's projected outline) cuts into regions takes the
/// whole drawn profile. The dialogs send `true`: they highlight the regions they pick.
#[serde(default)]
pub exact_regions: Option<bool>,
/// A point on a planar body face to revolve instead of a sketch profile.
#[serde(default)]
pub face: Option<Point3>,
/// The body whose face to use, where faces of several bodies meet at `face`.
#[serde(default)]
pub face_body: Option<String>,
/// With `face`, among placed instances: the body whose placement `face` is read in (read by
/// the engine's frame mapping, before the command runs). Default: `face_body`, else the
/// body nearest the point.
#[serde(default)]
pub body: Option<String>,
/// How the revolve combines with existing bodies. Default: new.
#[serde(default)]
pub operation: Option<OperationParam>,
/// The bodies a join, cut or intersect works on. Default for a join from a body's face, or
/// from a sketch on one: that body. Explicit `targets` or `participants` replace it.
#[serde(default)]
pub targets: Option<StringList>,
/// What a join makes of pieces that share no face. Default: separate.
#[serde(default)]
pub touching: Option<TouchingParam>,
#[serde(flatten, deserialize_with = "feature_common")]
pub common: FeatureCommon,
}
/// Something a feature's result tells the user without stopping them, with an action to take.
#[derive(Debug, Serialize, JsonSchema)]
pub struct FeatureNotice {
+1 -167
View File
@@ -4,7 +4,7 @@ use serde_json::{Value, json};
use super::{
BoundingSolidParams, BoxParams, ChamferParams, CombineParams, CombineResult, ConeParams, CylinderParams, ExtrudeParams, FeatureResult,
FilletParams, FilletResult, OffsetPlaneParams, PipeParams, PlaneAtAngleParams, RevolveParams, ScaleParams, SphereParams, TorusParams,
FilletParams, FilletResult, OffsetPlaneParams, PipeParams, PlaneAtAngleParams, ScaleParams, SphereParams, TorusParams,
};
use super::{SurfaceSweepParams, SweepParams};
use crate::Session;
@@ -52,31 +52,6 @@ fn schemas_and_the_key_check_agree() {
"touching",
],
);
assert_keys(
input_schema_for::<RevolveParams>(),
&[
"angle",
"angle2",
"axis",
"body",
"body_name",
"body_names",
"convention",
"direction",
"exact_regions",
"face",
"face_body",
"name",
"operation",
"participants",
"profiles",
"sketch",
"symmetric",
"targets",
"to",
"touching",
],
);
assert_keys(
input_schema_for::<CylinderParams>(),
&["axis", "base", "body_name", "body_names", "diameter", "height", "name", "operation", "participants", "radius", "references", "targets"],
@@ -190,41 +165,6 @@ fn strict_keeps_todays_errors() {
("solid.extrude", json!({"distance": 5, "convention": "per_side"})),
("solid.extrude", json!({"distance": " "})),
("solid.extrude", json!({"distance": 5, "operation": "x".repeat(1000)})),
("solid.revolve", json!({"axis": "y", "operation": "bogus"})),
("solid.revolve", json!({"axis": "y", "operation": "auto"})),
("solid.revolve", json!({"axis": "y", "angle": "no_such_parameter"})),
("solid.revolve", json!({"axis": "y", "angle2": "no_such_parameter"})),
("solid.revolve", json!({"axis": "y", "to": [1, 2]})),
("solid.revolve", json!({"axis": "y", "to": null})),
("solid.revolve", json!({"axis": "y", "convention": "per_side"})),
("solid.revolve", json!({"axis": "y", "convention": "SKETCH_NORMAL"})),
("solid.revolve", json!({"axis": "y", "convention": null})),
("solid.revolve", json!({"axis": "y", "sketch": true})),
("solid.revolve", json!({"axis": "y", "sketch": null})),
("solid.revolve", json!({"axis": "y", "sketch": "Missing"})),
("solid.revolve", json!({"axis": "y", "profiles": []})),
("solid.revolve", json!({"axis": "y", "profiles": [{"point": [1]}]})),
("solid.revolve", json!({"axis": "y", "profiles": [true]})),
("solid.revolve", json!({"axis": "y", "participants": ["Missing"]})),
("solid.revolve", json!({"axis": null})),
("solid.revolve", json!({"axis": 5})),
("solid.revolve", json!({"axis": [0, 1, 0]})),
("solid.revolve", json!({"axis": true})),
("solid.revolve", json!({"axis": {}})),
("solid.revolve", json!({"axis": {"origin": [0, 0, 0]}})),
("solid.revolve", json!({"axis": {"dir": [0, 1]}})),
("solid.revolve", json!({"axis": {"dir": "y"}})),
("solid.revolve", json!({"axis": {"origin": [0, 0], "dir": [0, 1, 0]}})),
("solid.revolve", json!({"axis": {"origin": "o", "dir": [0, 1, 0]}})),
("solid.revolve", json!({"axis": {"origin": null, "dir": [0, 1, 0]}})),
("solid.revolve", json!({"axis": {"origin": ["no_such_parameter", 0, 0], "dir": [0, 1, 0]}})),
("solid.revolve", json!({"axis": {"type": "world"}})),
("solid.revolve", json!({"axis": {"type": "world", "axis": "y"}})),
("solid.revolve", json!({"axis": {"type": "world", "axis": 5}})),
("solid.revolve", json!({"axis": {"edge": "x"}})),
("solid.revolve", json!({"axis": {"face": [1, 2]}})),
("solid.revolve", json!({"axis": {"type": "edge"}})),
("solid.revolve", json!({"axis": {"edge": [1, 2, 3]}})),
("solid.cylinder", json!({"radius": 5, "height": 5, "axis": "W"})),
("solid.cylinder", json!({"radius": 5, "height": 5, "axis": " w "})),
("solid.cylinder", json!({"radius": 5, "height": 5, "axis": [0, 0, 0]})),
@@ -926,109 +866,3 @@ fn converted_primitives_and_body_commands_reply_with_feature_result() {
let (changed, removed) = (r["changed_bodies"].as_array().cloned(), r["removed_bodies"].as_array().cloned());
assert!(changed.is_some_and(|c| c.contains(&json!("Bounding"))) && removed.is_some_and(|r| r.contains(&json!("Peg"))), "{r}");
}
/// A 2 x 10 profile standing off the Y axis in the XY plane, a box to join to or turn a face of,
/// and a construction axis to turn about.
fn revolve_rig(level: ParamCheck) -> Session {
let mut s = Session { param_check: level, ..Session::default() };
for (id, p) in [
("sketch.create", json!({"name": "Profile", "plane": "XY"})),
("sketch.rectangle.two_point", json!({"p0": [2, 0], "p1": [4, 10]})),
("sketch.finish", json!({})),
("solid.box", json!({"corner": [20, 0, 0], "length": 5, "width": 5, "height": 5, "body_name": "Block"})),
("construct.axis.two_points", json!({"a": {"point": [0, 0, 0]}, "b": {"point": [0, 1, 0]}, "name": "Spindle"})),
] {
s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"));
}
s
}
/// Every form a revolve call takes.
fn revolve_calls() -> Vec<Value> {
vec![
json!({"sketch": "Profile", "axis": "y", "angle": "90 deg", "operation": "new", "name": "A", "body_name": "A"}),
json!({"axis": "Y", "angle": 45, "direction": "Symmetric", "body_names": ["B"]}),
json!({"sketch": 1, "axis": {"origin": ["0 mm", 0, 0], "dir": {"x": 0, "y": 1, "z": 0}}, "symmetric": true, "convention": "legacy_axis_start"}),
json!({"axis": {"dir": [0, 1, 0]}, "angle": "30 deg", "angle2": "15 deg", "profiles": "all", "exact_regions": true}),
json!({"axis": {"type": "world", "axis": "Y"}, "angle": 20, "profiles": [0], "convention": "sketch_normal"}),
json!({"axis": "Spindle", "angle": 10, "direction": null, "symmetric": null, "touching": null}),
json!({"axis": {"type": "sketch_line", "dir": [0, 1, 0]}, "to": [0, 5, 3], "angle": 5, "angle2": null}),
json!({"axis": "y", "angle": "60 deg", "operation": "union", "targets": ["Block"], "participants": [], "touching": "one_body"}),
json!({"axis": "y", "angle": 15, "operation": "join", "participants": ["A"], "name": "Join"}),
json!({"axis": "y", "angle": 15, "operation": "cut", "targets": "A"}),
json!({"axis": {"origin": [0, 0, 5], "dir": [0, 1, 0]}, "face": [22.5, 2.5, 5], "face_body": "Block", "body": "Block", "angle": 90}),
]
}
/// `solid.revolve` replies with `FeatureResult` in each of its forms, its contract taking each: in
/// Warn a contract that refused one would still run it, so the replies are checked for warnings.
#[test]
fn revolve_replies_match_the_output_schema_and_forms_are_accepted() {
let schema = output_schema_for::<FeatureResult>();
let schema = schema.as_value();
let mut s = revolve_rig(ParamCheck::Warn);
for p in revolve_calls() {
let r = s.execute("solid.revolve", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
assert!(conforms(schema, schema, &r), "{r} doesn't match {schema}");
assert!(r.get("param_warnings").is_none(), "{p}: {r}");
}
// Warn reports what the contract refuses and runs the call anyway.
let typo = s.execute("solid.revolve", &json!({"axis": "y", "angel": 10})).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(typo["param_warnings"], json!(["unknown key `angel`"]));
assert!(conforms(schema, schema, &typo), "{typo}");
}
/// The check changes nothing about the reply: each call gives the same JSON with the check off
/// and in strict (`ms`, the time it took, apart), and its keys are the ones the command always
/// answered with.
#[test]
fn revolve_reply_keeps_its_old_shape_whatever_the_check() {
let (mut off, mut strict) = (revolve_rig(ParamCheck::Off), revolve_rig(ParamCheck::Strict));
for p in revolve_calls() {
let run = |s: &mut Session| {
let mut r = s.execute("solid.revolve", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
let keys: BTreeSet<String> = r.as_object().into_iter().flat_map(|o| o.keys().cloned()).collect();
assert!(r["ms"].is_null() || r["ms"].is_number(), "{r}");
r["ms"] = Value::Null;
(r, keys)
};
let (a, a_keys) = run(&mut off);
let (b, _) = run(&mut strict);
assert_eq!(a, b, "{p}");
let old = ["bodies", "changed_bodies", "created_bodies", "feature", "ms", "name", "removed_bodies", "warning"];
assert!(old.iter().all(|k| a_keys.contains(*k)), "{a}");
assert!(a_keys.iter().all(|k| old.contains(&k.as_str()) || matches!(k.as_str(), "component" | "notice")), "{a}");
}
}
/// What the handler ignores or reads as left out, strict refuses; off and warn run it as before.
#[test]
fn revolve_wrong_typed_keys_are_reported() {
for (key, value) in [
("direction", json!("negative")),
("direction", json!(true)),
("symmetric", json!("yes")),
("exact_regions", json!(1)),
("angle", json!(true)),
("angle", json!(" ")),
("angle2", json!([])),
("face", json!("x")),
("face", json!([1, 2])),
("face_body", json!(5)),
("body", json!(5)),
("name", json!(5)),
("body_name", json!(5)),
("operation", json!(5)),
("colour", json!("red")),
] {
let p = json!({"axis": "y", "angle": 30, key: value});
let mut off = revolve_rig(ParamCheck::Off);
let r = off.execute("solid.revolve", &p);
assert!(r.is_ok(), "{p}: {r:?}");
let mut warn = revolve_rig(ParamCheck::Warn);
let r = warn.execute("solid.revolve", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
assert!(r["param_warnings"].as_array().is_some_and(|w| w.len() == 1), "{p}: {r}");
let mut strict = revolve_rig(ParamCheck::Strict);
assert!(strict.execute("solid.revolve", &p).is_err(), "{p}");
}
}
+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` and
/// `file.save_mesh` also StlAscii or ThreeMfTagged, and for `file.export` also 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);
}
-362
View File
@@ -1,362 +0,0 @@
//! Contract and reply type for `file.insert_mesh` in `cmd/file.rs` (#13309).
//!
//! 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. Whether the file can be read stays the
//! handler's to check. A key the handler refuses when it is left out (`path`) is optional in the
//! type and required in the schema, so the handler's message, not the check's "is required", is
//! what a caller sees.
//!
//! Some keys are read only in some cases, and the contract reads them in the same cases:
//!
//! - nothing but `path` is read when the file is not a mesh file (the handler refuses it first);
//! - `up` is read only for a glTF file (any other file is refused with the handler's own message,
//! whatever `up` says, so that message is left to the handler);
//! - `names` and `folder` are read only when `up` is given for a glTF file: the handler checks
//! them there, as it does for a STEP file, and they change nothing for a mesh;
//! - `units` is read whatever the file (a glTF or 3MF file is refused it afterwards, which the
//! handler says).
//!
//! A value of the wrong type that the handler reads as if the key were left out (a `name` that is
//! not text) is refused by the contract, as schemas.md has it: that difference is what warn and
//! strict report. 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), except that a bad
//! `path` is always the first.
use std::borrow::Cow;
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
use serde::de::Error as _;
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::Value;
use super::check::{located, whole};
use super::types::{not_blank_pattern, whitespace_class};
/// What the handler says of a `path` that is missing, not text, blank or too long.
const PATH_REQUIRED: &str = "`path` is required";
const UP_MESSAGE: &str = "`up` must be \"z\" (the file's axes are kept) or \"y\" (the file has Y up: its Y becomes Z)";
const NAMES_MESSAGE: &str = "`names` must be \"auto\", \"solid\", \"product\" or \"instance\"";
const FOLDER_MESSAGE: &str =
"`folder` must be true (an import that brings more than one item goes into a `Group #N` history folder) or false (no folder)";
const ORIGIN_MESSAGE: &str =
"`origin` must be true (the mesh is moved so its box is centred on X and Y with its base on Z = 0) or false (the file's coordinates are kept)";
/// A key that is absent or `null` (the handler reads both as left out), else the value read by
/// `read`, with the handler's `message` when `read` refuses it.
fn read_key<'de, D: Deserializer<'de>, T>(d: D, message: &str, read: impl FnOnce(&Value) -> Option<T>) -> Result<Option<T>, D::Error> {
let value = Value::deserialize(d)?;
if value.is_null() {
return Ok(None);
}
read(&value).map(Some).ok_or_else(|| D::Error::custom(whole(message)))
}
fn path_key<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
match Value::deserialize(d)? {
Value::String(s) if !s.trim().is_empty() && s.len() < 4096 => Ok(Some(s)),
_ => Err(D::Error::custom(whole(PATH_REQUIRED))),
}
}
fn up_key<'de, D: Deserializer<'de>>(d: D) -> Result<Option<UpAxis>, D::Error> {
read_key(d, UP_MESSAGE, |v| match v.as_str().map(|u| u.trim().to_ascii_lowercase()).as_deref() {
Some("z") => Some(UpAxis::Z),
Some("y") => Some(UpAxis::Y),
_ => None,
})
}
fn names_key<'de, D: Deserializer<'de>>(d: D) -> Result<Option<StepNamesParam>, D::Error> {
read_key(d, NAMES_MESSAGE, |v| {
let word = v.as_str().and_then(solidcraft_io::StepNames::parse)?;
Some(match word {
solidcraft_io::StepNames::Auto => StepNamesParam::Auto,
solidcraft_io::StepNames::Solid => StepNamesParam::Solid,
solidcraft_io::StepNames::Product => StepNamesParam::Product,
solidcraft_io::StepNames::Instance => StepNamesParam::Instance,
})
})
}
fn units_key<'de, D: Deserializer<'de>>(d: D) -> Result<Option<MeshUnits>, D::Error> {
let value = Value::deserialize(d)?;
match &value {
Value::Null => Ok(None),
Value::String(u) => match solidcraft_doc::point_cloud::UNITS.iter().find(|(name, _)| name.eq_ignore_ascii_case(u)) {
Some((name, _)) => Ok(Some(MeshUnits((*name).to_string()))),
None => Err(D::Error::custom(whole(format!("`units` must be one of {} (not `{u}`)", unit_names())))),
},
_ => Err(D::Error::custom(whole(format!("`units` must be one of {}", unit_names())))),
}
}
fn flag_key<'de, D: Deserializer<'de>>(d: D, message: &str) -> Result<Option<bool>, D::Error> {
read_key(d, message, Value::as_bool)
}
fn folder_key<'de, D: Deserializer<'de>>(d: D) -> Result<Option<bool>, D::Error> {
flag_key(d, FOLDER_MESSAGE)
}
fn origin_key<'de, D: Deserializer<'de>>(d: D) -> Result<Option<bool>, D::Error> {
flag_key(d, ORIGIN_MESSAGE)
}
/// The unit names, as the handler lists them in its messages.
fn unit_names() -> String {
solidcraft_doc::point_cloud::UNITS.iter().map(|(n, _)| *n).collect::<Vec<_>>().join(", ")
}
/// The keys `file.insert_mesh` reads. See [`InsertMeshParams`], which leaves out the keys the
/// handler does not read for the call.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields, transform = path_required)]
pub struct InsertMeshKeys {
// Optional in the type and required in the schema (`path_required`): left out, the handler
// refuses it with its own message.
/// The file to insert: `.3mf`, `.stl`, `.obj`, `.ply` (with faces), `.glb` or `.gltf` (any
/// case), not blank and fewer than 4096 UTF-8 bytes. Any other kind of file is refused. Its
/// meshes join the design as mesh bodies in a new component, placed in the active component.
#[serde(default, deserialize_with = "path_key", skip_serializing_if = "Option::is_none")]
#[schemars(with = "String")]
pub path: Option<String>,
/// The new component's name, and the feature's. Default (also when blank): the file's name
/// without its extension.
#[serde(default)]
pub name: Option<String>,
/// For a glTF file only (any other file is refused it): `y` (the default: glTF has Y up)
/// turns the import's component a quarter turn about X, so Y becomes Z, and the reply gives
/// its occurrence as `stand_up`; `z` keeps the file's axes. Any case.
#[serde(default, deserialize_with = "up_key", skip_serializing_if = "Option::is_none")]
pub up: Option<UpAxis>,
/// Read only when `up` is given for a glTF file, where it is checked and changes nothing:
/// `auto`, `solid`, `product` or `instance`, in any case. It is how a STEP file's bodies are
/// named (`file.insert_step`).
#[serde(default, deserialize_with = "names_key", skip_serializing_if = "Option::is_none")]
pub names: Option<StepNamesParam>,
/// Read only when `up` is given for a glTF file, where it is checked and changes nothing:
/// true or false. It is whether a STEP import goes into a history folder (`file.insert_step`).
#[serde(default, deserialize_with = "folder_key", skip_serializing_if = "Option::is_none")]
#[schemars(with = "Option<bool>")]
pub folder: Option<bool>,
/// For an STL, OBJ or PLY file: the unit its coordinates are in, scaled to millimetres as the
/// file is read: `mm`, `cm`, `m`, `in` or `ft`, in any case. Default: mm. A 3MF or glTF file
/// states its own, and is refused `units`. The design keeps the millimetre mesh and not the
/// unit, so insert the file again to try another.
#[serde(default, deserialize_with = "units_key", skip_serializing_if = "Option::is_none")]
pub units: Option<MeshUnits>,
/// Whether, once the mesh is read, scaled and stood up, the import's occurrence is moved so
/// the box around its bodies is centred on X and Y of the active component with its lowest
/// point on Z = 0 (the reply gives the shift as `moved_to_origin`). Default: false, so the
/// mesh keeps the coordinates its file gives it.
#[serde(default, deserialize_with = "origin_key", skip_serializing_if = "Option::is_none")]
#[schemars(with = "Option<bool>")]
pub origin: Option<bool>,
}
/// Leaves out the keys the handler does not read for this call, in the order it looks at them.
fn leave_unread(value: &mut Value) {
let Some(keys) = value.as_object_mut() else { return };
let Some(path) = keys.get("path").and_then(Value::as_str).filter(|p| !p.trim().is_empty() && p.len() < 4096).map(str::to_string) else {
return;
};
if !solidcraft_io::is_mesh_path(&path) && !solidcraft_io::is_ply_path(&path) {
// Refused for its kind before anything else is read.
for key in ["name", "up", "names", "folder", "units", "origin"] {
keys.remove(key);
}
return;
}
let up_given = keys.get("up").is_some_and(|u| !u.is_null());
if !(up_given && solidcraft_io::is_gltf_path(&path)) {
keys.remove("names");
keys.remove("folder");
}
if !solidcraft_io::is_gltf_path(&path) {
keys.remove("up");
}
}
/// Insert a 3MF, STL, OBJ, PLY or glTF mesh file into the design as mesh bodies, in a new
/// component named after the file and placed in the active component. A glTF file's node tree
/// becomes nested components held as one rigid group until `component.unlock`, a mesh placed
/// again becomes another occurrence, its materials become the bodies' appearances, and its
/// metres become millimetres.
#[derive(Debug)]
pub struct InsertMeshParams(pub InsertMeshKeys);
impl<'de> Deserialize<'de> for InsertMeshParams {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let mut value = Value::deserialize(d)?;
// A bad `path` is the handler's first refusal, whatever else is wrong.
if let Some(path) = value.as_object().map(|o| o.get("path")) {
let ok = path.and_then(Value::as_str).is_some_and(|p| !p.trim().is_empty() && p.len() < 4096);
if !ok {
let only = serde_json::json!({"path": path.cloned().unwrap_or(Value::Null)});
return serde_path_to_error::deserialize::<_, InsertMeshKeys>(&only).map(InsertMeshParams).map_err(|e| D::Error::custom(located(&e)));
}
}
leave_unread(&mut value);
serde_path_to_error::deserialize(&value).map(InsertMeshParams).map_err(|e| D::Error::custom(located(&e)))
}
}
impl JsonSchema for InsertMeshParams {
fn schema_name() -> Cow<'static, str> {
"InsertMeshParams".into()
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
let mut schema = InsertMeshKeys::json_schema(generator);
schema.insert("description".into(), INSERT_MESH.into());
schema
}
}
const INSERT_MESH: &str = "Insert a 3MF, STL, OBJ, PLY or glTF mesh file into the design as mesh bodies, in a new component named after the file and placed in the active component. A glTF file's node tree becomes nested components held as one rigid group until `component.unlock`, a mesh placed again becomes another occurrence, its materials become the bodies' appearances, and its metres become millimetres.";
/// `path` is required, as the handler has it (not blank, fewer than 4096 bytes; `maxLength`
/// counts characters, so 4095 is as close as the schema gets). `names` and `folder` have their
/// shapes only where the handler reads them: when `up` is given (not null) for a glTF file.
fn path_required(schema: &mut Schema) {
super::motion::require(schema, "path");
let Some(props) = schema.get_mut("properties").and_then(Value::as_object_mut) else { return };
props.insert(
"path".into(),
serde_json::json!({
"description": props.get("path").and_then(|p| p.get("description")).cloned().unwrap_or_default(),
"type": "string",
"maxLength": 4095,
"pattern": not_blank_pattern(),
}),
);
let mut read_only_with_up = |key: &str| {
let shape = props.get_mut(key).map(Value::take).unwrap_or_default();
props.insert(key.into(), serde_json::json!({"description": shape.get("description").cloned().unwrap_or_default()}));
shape
};
let names = read_only_with_up("names");
let folder = read_only_with_up("folder");
schema.insert("anyOf".into(), serde_json::json!([{"properties": {"up": {"type": "null"}}}, {"properties": {"names": names, "folder": folder}}]));
}
/// Which way is up in a glTF file.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum UpAxis {
/// The file's axes are kept.
Z,
/// The file has Y up: its Y becomes Z.
Y,
}
/// Case-insensitive words (`word`), each letter in both cases, as a pattern; the pattern allows
/// the whitespace Rust's `trim` removes around them when `padded`.
fn words_pattern(words: &[&str], padded: bool) -> String {
let word = |w: &str| w.chars().map(|c| format!("[{}{}]", c.to_ascii_lowercase(), c.to_ascii_uppercase())).collect::<String>();
let alternatives = words.iter().map(|w| word(w)).collect::<Vec<_>>().join("|");
if padded {
let ws = whitespace_class();
format!("^[{ws}]*({alternatives})[{ws}]*$")
} else {
format!("^({alternatives})$")
}
}
impl JsonSchema for UpAxis {
fn schema_name() -> Cow<'static, str> {
"UpAxis".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "`z` (the file's axes are kept) or `y` (the file has Y up: its Y becomes Z), in any case.",
"type": "string",
"pattern": words_pattern(&["z", "y"], true)
})
}
}
/// How a STEP file's bodies are named; read, and checked, for a glTF file with `up` too.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum StepNamesParam {
Auto,
Solid,
Product,
Instance,
}
impl JsonSchema for StepNamesParam {
fn schema_name() -> Cow<'static, str> {
"StepNamesParam".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "`auto`, `solid`, `product` or `instance`, in any case.",
"type": "string",
"pattern": words_pattern(&["auto", "solid", "product", "instance"], true)
})
}
}
/// A length unit of an STL, OBJ or PLY file: one of `solidcraft_doc::point_cloud::UNITS`, in the
/// case the table spells it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MeshUnits(pub String);
impl JsonSchema for MeshUnits {
fn schema_name() -> Cow<'static, str> {
"MeshUnits".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
let names: Vec<&str> = solidcraft_doc::point_cloud::UNITS.iter().map(|(n, _)| *n).collect();
json_schema!({
"description": "`mm`, `cm`, `m`, `in` or `ft`, in any case.",
"type": "string",
"pattern": words_pattern(&names, false)
})
}
}
/// What `file.insert_mesh` returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct MeshInserted {
/// The file, as given.
pub path: String,
/// The mesh import feature's id.
pub feature: u64,
/// The feature's name.
pub name: String,
/// The bodies the import made, by name.
pub bodies: Vec<String>,
/// The same names as `bodies`.
pub created_bodies: Vec<String>,
/// What the file reader noticed and put right.
pub warnings: Vec<String>,
/// The new component's id.
pub component: u64,
/// The new component's name.
pub component_name: String,
/// The component's occurrence in the active component; null when it has none.
pub occurrence: Option<u64>,
/// How many components were made below it (a glTF file's nodes). Only when there are some.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subcomponents: Option<usize>,
/// How many rigid joints hold its contents together. Only when `subcomponents` is given.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rigid_joints: Option<usize>,
/// With `origin`: the shift, [x, y, z] in mm, that moved the import to the origin; absent
/// when it was not moved.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub moved_to_origin: Option<[f64; 3]>,
/// The unit the file's coordinates were read in, when `units` was given.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub units: Option<String>,
/// For a glTF file stood up from Y up to Z up: the occurrence that was turned.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stand_up: Option<u64>,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
#[cfg(test)]
#[path = "insert_mesh_tests.rs"]
mod tests;
@@ -1,290 +0,0 @@
use std::path::{Path, PathBuf};
use serde_json::{Value, json};
use super::{InsertMeshParams, MeshInserted, StepNamesParam, UpAxis};
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};
#[test]
fn schemas_and_the_key_check_agree() {
assert_keys(input_schema_for::<InsertMeshParams>(), &["folder", "name", "names", "origin", "path", "units", "up"]);
}
#[test]
fn strict_keeps_todays_errors() {
let long = "x".repeat(4096);
assert_strict_keeps_todays_errors(&[
("file.insert_mesh", json!({})),
("file.insert_mesh", json!({"path": ""})),
("file.insert_mesh", json!({"path": " "})),
("file.insert_mesh", json!({"path": 5})),
("file.insert_mesh", json!({"path": null})),
("file.insert_mesh", json!({"path": long})),
("file.insert_mesh", json!({"up": "q", "units": "yd"})),
// The kind of file is refused before anything else is read.
("file.insert_mesh", json!({"path": "x.step"})),
("file.insert_mesh", json!({"path": "x.step", "up": "q", "units": 5, "origin": 1})),
("file.insert_mesh", json!({"path": "x.step", "name": 5})),
// `up` is for a glTF file only, whatever it says.
("file.insert_mesh", json!({"path": "x.stl", "up": "y"})),
("file.insert_mesh", json!({"path": "x.stl", "up": "q"})),
("file.insert_mesh", json!({"path": "x.obj", "up": 5})),
("file.insert_mesh", json!({"path": "x.glb", "up": "q"})),
("file.insert_mesh", json!({"path": "x.glb", "up": 5})),
("file.insert_mesh", json!({"path": "x.glb", "up": "y", "names": "bogus"})),
("file.insert_mesh", json!({"path": "x.gltf", "up": "z", "names": 5})),
("file.insert_mesh", json!({"path": "x.gltf", "up": "z", "folder": "yes"})),
("file.insert_mesh", json!({"path": "x.stl", "units": "yd"})),
("file.insert_mesh", json!({"path": "x.stl", "units": 5})),
("file.insert_mesh", json!({"path": "x.stl", "units": true})),
("file.insert_mesh", json!({"path": "x.3mf", "units": "mm"})),
("file.insert_mesh", json!({"path": "x.glb", "units": "mm"})),
("file.insert_mesh", json!({"path": "x.stl", "origin": "yes"})),
("file.insert_mesh", json!({"path": "x.stl", "origin": 1})),
// The kind of file is read in any case, and a PLY file is a mesh too.
("file.insert_mesh", json!({"path": "X.GLB", "up": "q"})),
("file.insert_mesh", json!({"path": "X.STL", "up": "y"})),
("file.insert_mesh", json!({"path": "X.3MF", "units": "mm"})),
("file.insert_mesh", json!({"path": "x.ply", "units": "yd"})),
("file.insert_mesh", json!({"path": "x.ply", "up": "y"})),
// A file that isn't there.
("file.insert_mesh", json!({"path": "does-not-exist.stl"})),
("file.insert_mesh", json!({"path": "does-not-exist.glb", "up": "Z", "names": " Auto ", "folder": false})),
]);
}
fn parse(p: Value) -> Result<InsertMeshParams, String> {
serde_json::from_value(p).map_err(|e| e.to_string())
}
/// The keys read as the handler reads them: the same words in any case, `null` as left out, and
/// the keys it doesn't read in a call left unread, so a bad one there is no problem.
#[test]
fn the_types_read_each_key_as_the_handler_does() {
let keys = |p: Value| parse(p).unwrap_or_else(|e| panic!("{e}")).0;
let k = keys(json!({"path": "a.glb", "name": "Mine", "up": " Y ", "names": " Solid ", "folder": true, "units": "IN", "origin": false}));
assert_eq!(k.path.as_deref(), Some("a.glb"));
assert_eq!(k.name.as_deref(), Some("Mine"));
assert_eq!(k.up, Some(UpAxis::Y));
assert_eq!(k.names, Some(StepNamesParam::Solid));
assert_eq!(k.folder, Some(true));
assert_eq!(k.units.map(|u| u.0), Some("in".to_string()));
assert_eq!(k.origin, Some(false));
assert_eq!(keys(json!({"path": "a.glb", "up": "Z"})).up, Some(UpAxis::Z));
let none = keys(json!({"path": "a.stl", "name": null, "up": null, "names": null, "folder": null, "units": null, "origin": null}));
assert!(none.name.is_none() && none.up.is_none() && none.units.is_none() && none.origin.is_none());
// Not read: `names` and `folder` without an `up` for a glTF file, `up` for any other file,
// everything but `path` for a file that isn't a mesh.
for p in [
json!({"path": "a.stl", "names": 5, "folder": "x"}),
json!({"path": "a.glb", "names": 5, "folder": "x"}),
json!({"path": "a.glb", "up": null, "names": 5, "folder": "x"}),
json!({"path": "a.stl", "up": "sideways", "names": 5}),
json!({"path": "a.step", "up": 1, "names": 5, "folder": 5, "units": 5, "origin": 5}),
] {
parse(p.clone()).unwrap_or_else(|e| panic!("{p}: {e}"));
}
parse(json!({"path": "a.GLB", "up": "y", "names": "auto"})).unwrap_or_else(|e| panic!("{e}"));
assert!(parse(json!({"path": "a.GLB", "up": "y", "names": "x"})).is_err());
parse(json!({"path": "a.PLY", "up": 5, "names": 5})).unwrap_or_else(|e| panic!("{e}"));
let k = keys(json!({"path": "a.glb", "names": 5}));
assert!(k.names.is_none() && k.folder.is_none());
// Read, and refused with the handler's message.
let refused = |p: Value| crate::schema::issues("file.insert_mesh", &p).into_iter().next().map(|i| i.message).unwrap_or_default();
for (p, message) in [
(json!({"path": "a.glb", "up": "x"}), "`up` must be \"z\" (the file's axes are kept) or \"y\" (the file has Y up: its Y becomes Z)"),
(json!({"path": "a.glb", "up": "y", "names": "x"}), "`names` must be \"auto\", \"solid\", \"product\" or \"instance\""),
(json!({"path": "a.stl", "units": "yd"}), "`units` must be one of mm, cm, m, in, ft (not `yd`)"),
(json!({"path": "a.stl", "units": 1}), "`units` must be one of mm, cm, m, in, ft"),
(json!({"path": "a.3mf", "units": []}), "`units` must be one of mm, cm, m, in, ft"),
(json!({"path": "a.stl", "origin": "yes"}), "`origin` must be true"),
(json!({"path": "a.glb", "up": "z", "folder": 0}), "`folder` must be true"),
(json!({}), "`path` is required"),
(json!({"path": " "}), "`path` is required"),
(json!({"up": "x", "path": 5}), "`path` is required"),
] {
let e = refused(p.clone());
assert!(e.starts_with(message), "{p}: {e}");
}
}
/// The check names what is wrong, in the handler's words, and a key the handler would ignore is
/// still a known key.
#[test]
fn the_check_names_what_is_wrong() {
let issues = |p: Value| crate::schema::issues("file.insert_mesh", &p).into_iter().map(|i| i.message).collect::<Vec<_>>();
assert_eq!(issues(json!({"path": "a.stl", "unit": "in"})), ["unknown key `unit`; did you mean `units`?"]);
assert_eq!(issues(json!({"path": "a.stl", "units": "yd"})), ["`units` must be one of mm, cm, m, in, ft (not `yd`)"]);
assert_eq!(issues(json!({"path": "a.stl", "name": 5})), ["`name` must be text"]);
assert_eq!(issues(json!({})), ["`path` is required"]);
// `name` is read as text or not at all; a number is ignored by the handler but is a mistake.
assert_eq!(issues(json!({"path": "a.stl", "name": null})), Vec::<String>::new());
assert!(issues(json!({"path": "a.stl", "up": "y", "names": 5})).is_empty(), "an `up` for another file is the handler's to refuse");
assert!(issues(json!({"path": "a.stl", "names": 5, "folder": 5})).is_empty());
}
/// The published schema: `path` is required and shaped as the handler reads it; `names` and
/// `folder` have their shapes only where they are read.
#[test]
fn the_schema_takes_what_the_handler_reads() {
let schema = input_schema_for::<InsertMeshParams>();
let schema = schema.as_value();
for (p, takes) in [
(json!({"path": "a.stl"}), true),
(json!({"path": "a.glb", "up": "Y", "names": "Auto", "folder": true, "name": "N"}), true),
(json!({"path": "a.stl", "units": "in", "origin": true}), true),
(json!({"path": "a.stl", "units": null, "origin": null, "up": null}), true),
// Left unread, so not shaped.
(json!({"path": "a.stl", "names": 5, "folder": "x"}), true),
(json!({"path": "a.glb", "up": null, "names": 5, "folder": "x"}), true),
(json!({}), false),
(json!({"path": 5}), false),
(json!({"path": "a.stl", "origin": "yes"}), false),
(json!({"path": "a.stl", "units": 5}), false),
(json!({"path": "a.stl", "folder": "x", "up": "y"}), false),
(json!({"path": "a.glb", "up": "y", "names": 5}), false),
(json!({"path": "a.stl", "name": 5}), false),
] {
assert_eq!(conforms(schema, schema, &p), takes, "{p}");
}
}
/// `conforms` doesn't read patterns: pin the text of the ones the schema carries.
#[test]
fn the_patterns_are_pinned() {
let schema = input_schema_for::<InsertMeshParams>();
let schema = schema.as_value();
assert_eq!(schema["properties"]["path"]["pattern"], json!(crate::schema::types::not_blank_pattern()));
assert_eq!(schema["properties"]["path"]["maxLength"], json!(4095));
let ws = crate::schema::types::whitespace_class();
assert_eq!(schema["$defs"]["UpAxis"]["pattern"], json!(format!("^[{ws}]*([zZ]|[yY])[{ws}]*$")));
assert_eq!(schema["$defs"]["MeshUnits"]["pattern"], json!("^([mM][mM]|[cC][mM]|[mM]|[iI][nN]|[fF][tT])$"));
assert_eq!(
schema["$defs"]["StepNamesParam"]["pattern"],
json!(format!("^[{ws}]*([aA][uU][tT][oO]|[sS][oO][lL][iI][dD]|[pP][rR][oO][dD][uU][cC][tT]|[iI][nN][sS][tT][aA][nN][cC][eE])[{ws}]*$"))
);
}
fn folder(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("solidcraft-insert-mesh-schema-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap_or_else(|e| panic!("{e}"));
dir
}
/// A cube of side 10 mm, saved as STL, and the same as 3MF.
fn cube_files(dir: &Path) -> (String, String) {
let mut part = Session::default();
let (stl, threemf) = (dir.join("cube.stl").to_string_lossy().to_string(), dir.join("cube.3mf").to_string_lossy().to_string());
for (id, p) in [
("solid.box", json!({"length": 10, "width": 10, "height": 10})),
("file.save_mesh", json!({"path": stl})),
("file.export_3mf", json!({"path": threemf})),
] {
part.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"));
}
(stl, threemf)
}
/// A glTF file (a glb): a cube of side 0.01 m placed by a `Base` node with an `Arm` node below
/// it, so the import has components of its own.
fn glb(dir: &Path) -> String {
let s = 0.01f32;
let p: Vec<[f32; 3]> = (0..8).map(|i| [(i & 1) as f32 * s, ((i >> 1) & 1) as f32 * s, ((i >> 2) & 1) as f32 * s]).collect();
let t: [u16; 36] = [0, 2, 1, 1, 2, 3, 4, 5, 6, 5, 7, 6, 0, 1, 4, 1, 5, 4, 2, 6, 3, 3, 6, 7, 0, 4, 2, 2, 4, 6, 1, 3, 5, 3, 7, 5];
let mut bin: Vec<u8> = p.iter().flatten().flat_map(|v| v.to_le_bytes()).collect();
bin.extend(t.iter().flat_map(|v| v.to_le_bytes()));
let json = json!({
"asset": {"version": "2.0"},
"scene": 0,
"scenes": [{"nodes": [0]}],
"nodes": [
{"name": "Base", "mesh": 0, "children": [1]},
{"name": "Arm", "mesh": 1, "translation": [0.0, 0.05, 0.0]},
],
"meshes": [
{"name": "Block", "primitives": [{"attributes": {"POSITION": 0}, "indices": 1}]},
{"name": "Peg", "primitives": [{"attributes": {"POSITION": 0}, "indices": 1}]},
],
"accessors": [
{"bufferView": 0, "componentType": 5126, "count": 8, "type": "VEC3", "min": [0.0, 0.0, 0.0], "max": [s, s, s]},
{"bufferView": 1, "componentType": 5123, "count": 36, "type": "SCALAR"},
],
"bufferViews": [{"buffer": 0, "byteOffset": 0, "byteLength": 96}, {"buffer": 0, "byteOffset": 96, "byteLength": 72}],
"buffers": [{"byteLength": bin.len()}],
});
let path = dir.join("robot.glb");
std::fs::write(&path, solidcraft_io::glb_container(json.to_string().as_bytes(), &bin)).unwrap_or_else(|e| panic!("{e}"));
path.to_string_lossy().to_string()
}
fn matches(reply: &Value) {
let schema = output_schema_for::<MeshInserted>();
let schema = schema.as_value();
assert!(conforms(schema, schema, reply), "{reply} doesn't match {schema}");
}
/// Every form the command takes, run in Warn: each reply matches its output schema and carries
/// no warning (Warn runs a call the contract refuses anyway, so this shows the contract takes
/// each call). The keys the handler leaves unread are given bad values on purpose.
#[test]
fn replies_match_the_output_schema() {
let dir = folder("replies");
let (stl, threemf) = cube_files(&dir);
let robot = glb(&dir);
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
let mut run = |p: Value| {
let r = s.execute("file.insert_mesh", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
assert!(r.get("param_warnings").is_none(), "{p}: {r}");
matches(&r);
r
};
let plain = run(json!({"path": stl}));
assert_eq!(plain["component_name"], "cube", "{plain}");
assert!(plain.get("units").is_none() && plain.get("moved_to_origin").is_none() && plain.get("stand_up").is_none(), "{plain}");
assert!(plain.get("subcomponents").is_none() && plain["bodies"] == plain["created_bodies"], "{plain}");
assert!(plain["occurrence"].is_u64(), "{plain}");
let named = run(json!({"path": stl, "name": "Block", "units": "IN", "origin": true}));
assert_eq!(named["units"], "in", "{named}");
assert_eq!(named["component_name"], "Block", "{named}");
assert!(named["moved_to_origin"].as_array().is_some_and(|a| a.len() == 3), "{named}");
run(json!({"path": stl, "units": "cm", "origin": false, "up": null, "name": null}));
// Keys left unread, with values that would be refused if they were read.
run(json!({"path": stl, "names": 5, "folder": "x"}));
run(json!({"path": threemf, "names": [], "folder": {}}));
run(json!({"path": robot, "names": 5, "folder": "x"}));
let stood = run(json!({"path": robot}));
assert!(stood["stand_up"].is_u64() && stood["subcomponents"].is_u64() && stood["rigid_joints"].is_u64(), "{stood}");
let kept = run(json!({"path": robot, "up": "Z", "names": " Instance ", "folder": true, "origin": true}));
assert!(kept.get("stand_up").is_none() && kept["subcomponents"].is_u64(), "{kept}");
let turned = run(json!({"path": robot, "up": " y "}));
assert!(turned["stand_up"].is_u64(), "{turned}");
// A misspelt key still runs in Warn, says so, and the reply still matches.
let warned = s.execute("file.insert_mesh", &json!({"path": stl, "unit": "in"})).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(warned["param_warnings"], json!(["unknown key `unit`; did you mean `units`?"]), "{warned}");
matches(&warned);
let _ = std::fs::remove_dir_all(&dir);
}
/// In Strict a call that works today is not refused, and one that fails fails with the
/// handler's message (see `strict_keeps_todays_errors`).
#[test]
fn strict_runs_the_calls_that_work() {
let dir = folder("strict");
let (stl, _) = cube_files(&dir);
let robot = glb(&dir);
let mut s = Session { param_check: ParamCheck::Strict, ..Session::default() };
for p in [json!({"path": stl, "units": "mm"}), json!({"path": robot, "up": "y", "names": "auto", "folder": false})] {
s.execute("file.insert_mesh", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
}
let e = s.execute("file.insert_mesh", &json!({"path": stl, "unit": "in"})).err().map(|e| e.to_string()).unwrap_or_default();
assert!(e.contains("unknown key `unit`"), "{e}");
let _ = std::fs::remove_dir_all(&dir);
}
-5
View File
@@ -54,7 +54,6 @@ mod coverage_tests;
mod tests;
// The contracts, by command file.
pub mod align;
pub mod appearance;
pub mod bom;
pub mod browser;
@@ -72,7 +71,6 @@ pub mod features;
pub mod features_more;
pub mod file;
pub mod frame;
pub mod insert_mesh;
pub mod inspect;
pub mod interference;
pub mod joint_sweep;
@@ -107,7 +105,6 @@ pub mod render_stage;
pub mod render_surface_resources;
pub mod render_ui;
pub mod save;
pub mod save_mesh;
pub mod section;
pub mod select;
pub mod sheet;
@@ -118,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;
@@ -127,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;
-364
View File
@@ -1,364 +0,0 @@
//! Contracts and result types for `file.save_mesh` in `cmd/file.rs`.
//!
//! 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. Whether a body exists, a slicer is installed or the file
//! can be written stays 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).
//!
//! `path` is the one key the handler refuses when it is left out: it is optional in the type, so the
//! handler keeps its message, and required in the schema.
//!
//! Two keys are read only in some cases, and the contract reads them in the same cases: `ascii`
//! only when `format` is `stl` (in any case), and `single_instance` only when `slicer` is text. A
//! value of the wrong type that the handler reads as if the key were left out (`ascii: "yes"` next
//! to `format: "stl"`, a number for `slicer`) is refused by the contract, as schemas.md has it: that
//! difference is what warn and strict report.
use std::borrow::Cow;
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
use serde::de::Error as _;
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::{Value, json};
use super::check::{located, whole};
use super::types::{not_blank_pattern, whitespace_class};
/// A key that, once given (`null` included), must parse as `T`, as the handler reads it.
fn some<'de, D: Deserializer<'de>, T: Deserialize<'de>>(d: D) -> Result<Option<T>, D::Error> {
T::deserialize(d).map(Some)
}
/// The keys `file.save_mesh` reads. See [`SaveMeshParams`], which leaves `ascii` and
/// `single_instance` unread in the cases the handler does.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields, transform = conditional_keys)]
pub struct SaveMeshKeys {
/// The file to write: not blank, fewer than 4096 UTF-8 bytes.
#[serde(default, deserialize_with = "some", skip_serializing_if = "Option::is_none")]
#[schemars(with = "MeshPath")]
pub path: Option<MeshPath>,
/// The file format, in any case: `stl`, `3mf`, or `3mf-tagged` (3MF with a face tag on each
/// triangle, docs/formats/3mf-face-tags.md). Default: from the extension of `path` (`.3mf`
/// gives 3mf), else stl.
#[serde(default)]
pub format: Option<MeshFormat>,
/// The bodies to save, by name. Default: every body.
#[serde(default)]
pub bodies: Option<MeshBodies>,
/// STL only (read only when `format` is `stl`): true writes ASCII STL instead of binary.
/// Default: false.
#[serde(default)]
pub ascii: Option<bool>,
/// A slicer to send the saved file to, after it is written where `path` says, through the
/// same steps as `file.send_to_slicer`: an id or a name from `file.slicers`, the program path
/// it lists, or a program the user added under Custom…. A slicer that has to be started first
/// is waited for. Kept for scripts; may be deprecated later in favour of `file.send_to_slicer`
/// or a Send to App workflow. Default: none.
#[serde(default)]
pub slicer: Option<String>,
/// Read only when `slicer` is given: true opens the file in the window the slicer already has
/// open, for slicers that load files that way (see `file.send_to_slicer`). Default: true.
#[serde(default)]
pub single_instance: Option<bool>,
/// How closely the mesh follows curved faces (STL, OBJ and 3MF), in any case: `standard`, `fine`
/// or `finest`, a chord tolerance of 2e-4, 5e-5 or 1e-5 of each body's size. Default: standard.
#[serde(default)]
pub quality: Option<MeshQualityParam>,
/// 3MF only: `body` is one object per body; `component` is 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 (other bodies stay objects of their own). `component` is refused for
/// other formats. Default: body.
#[serde(default)]
pub objects: Option<MeshObjects>,
}
/// Leaves out the keys the handler never reads for this call: `ascii` unless `format` is `stl`,
/// and `single_instance` unless `slicer` is text.
fn leave_unread(value: &mut Value) {
let Some(keys) = value.as_object_mut() else { return };
let stl = keys.get("format").and_then(Value::as_str).is_some_and(|f| f.eq_ignore_ascii_case("stl"));
if !stl {
keys.remove("ascii");
}
if !keys.get("slicer").is_some_and(Value::is_string) {
keys.remove("single_instance");
}
}
fn read_keys<'de, D: Deserializer<'de>>(d: D) -> Result<SaveMeshKeys, D::Error> {
let mut value = Value::deserialize(d)?;
leave_unread(&mut value);
serde_path_to_error::deserialize(&value).map_err(|e| D::Error::custom(located(&e)))
}
/// Save bodies as a mesh file: binary or ASCII STL, 3MF, or 3MF with a face tag on each triangle.
/// The format is `format`, else the extension of `path`, else STL. With `slicer`, the saved file
/// is then sent to that program. Nothing is added to undo.
#[derive(Debug)]
pub struct SaveMeshParams(pub SaveMeshKeys);
impl<'de> Deserialize<'de> for SaveMeshParams {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
read_keys(d).map(SaveMeshParams)
}
}
impl JsonSchema for SaveMeshParams {
fn schema_name() -> Cow<'static, str> {
"SaveMeshParams".into()
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
let mut schema = SaveMeshKeys::json_schema(generator);
schema.insert("description".into(), SAVE_MESH.into());
schema
}
}
const SAVE_MESH: &str = "Save bodies as a mesh file: binary or ASCII STL, 3MF, or 3MF with a face tag on each triangle. The format is `format`, else the extension of `path`, else STL. With `slicer`, the saved file is then sent to that program. Nothing is added to undo.";
/// The pattern for `format`: the three words, in any case.
const FORMAT_PATTERN: &str = "^([Ss][Tt][Ll]|3[Mm][Ff]|3[Mm][Ff]-[Tt][Aa][Gg][Gg][Ee][Dd])$";
/// The pattern for `quality`: the three words, in any case, with whitespace around them allowed
/// (the handler trims).
fn quality_pattern() -> String {
let ws = whitespace_class();
format!("^[{ws}]*([Ss][Tt][Aa][Nn][Dd][Aa][Rr][Dd]|[Ff][Ii][Nn][Ee]|[Ff][Ii][Nn][Ee][Ss][Tt])[{ws}]*$")
}
/// `path` is required (the handler says so when it is left out); `ascii` and `single_instance`
/// have their shapes only where the handler reads them, as [`leave_unread`] has it.
fn conditional_keys(schema: &mut Schema) {
super::motion::require(schema, "path");
let Some(props) = schema.get_mut("properties").and_then(Value::as_object_mut) else { return };
let mut read_only_when = |key: &str| {
let shape = props.get_mut(key).map(Value::take).unwrap_or_default();
props.insert(key.into(), json!({"description": shape.get("description").cloned().unwrap_or_default()}));
shape
};
let ascii = read_only_when("ascii");
let single_instance = read_only_when("single_instance");
// `format` is `stl` in any case: the eight spellings, which `enum` says and a pattern would only approximate.
let spellings: Vec<String> =
(0..8).map(|n| "stl".chars().enumerate().map(|(i, c)| if n >> i & 1 == 1 { c.to_ascii_uppercase() } else { c }).collect()).collect();
let stl = json!({"required": ["format"], "properties": {"format": {"enum": spellings}}});
let slicer = json!({"required": ["slicer"], "properties": {"slicer": {"type": "string"}}});
schema.insert(
"allOf".into(),
json!([
{"anyOf": [{"not": stl}, {"properties": {"ascii": ascii}}]},
{"anyOf": [{"not": slicer}, {"properties": {"single_instance": single_instance}}]},
]),
);
}
/// The file to write, as `path_arg` reads `path`: not blank and under 4096 bytes, or the
/// handler's "`path` is required" (anything but text reads as no `path`).
#[derive(Clone, Debug, PartialEq)]
pub struct MeshPath(pub String);
impl<'de> Deserialize<'de> for MeshPath {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
match Value::deserialize(d)? {
Value::String(s) if !s.trim().is_empty() && s.len() < 4096 => Ok(MeshPath(s)),
_ => Err(D::Error::custom(whole("`path` is required"))),
}
}
}
impl JsonSchema for MeshPath {
fn schema_name() -> Cow<'static, str> {
"MeshPath".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
// `maxLength` counts characters, so 4095 is as close as the schema gets to "under 4096 bytes".
json_schema!({
"description": "The file to write: not blank, fewer than 4096 UTF-8 bytes.",
"type": "string",
"maxLength": 4095,
"pattern": not_blank_pattern()
})
}
}
/// The bodies to save, read by the handler's own `string_list`: a name or a list of names, with its
/// messages for anything else.
#[derive(Clone, Debug, PartialEq)]
pub struct MeshBodies(pub Vec<String>);
impl<'de> Deserialize<'de> for MeshBodies {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let keys = json!({"bodies": Value::deserialize(d)?});
match crate::params::string_list("file.save_mesh", &keys, "bodies") {
Ok(names) => Ok(MeshBodies(names)),
Err(crate::EngineError::BadParams { msg, .. }) => Err(D::Error::custom(whole(msg))),
Err(e) => Err(D::Error::custom(whole(e))),
}
}
}
impl JsonSchema for MeshBodies {
fn schema_name() -> Cow<'static, str> {
"MeshBodies".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "A body name, or a list of body names (at most 10000).",
"anyOf": [{"type": "string"}, {"type": "array", "items": {"type": "string"}, "maxItems": crate::params::MAX_STRING_LIST}]
})
}
}
/// A mesh format, as `file.save_mesh` reads `format` (the words in any case).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MeshFormat {
/// STL, binary unless `ascii` is true.
Stl,
/// 3MF.
ThreeMf,
/// 3MF with a face tag on each triangle.
ThreeMfTagged,
}
impl<'de> Deserialize<'de> for MeshFormat {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
match String::deserialize(d)?.to_ascii_lowercase().as_str() {
"stl" => Ok(MeshFormat::Stl),
"3mf" => Ok(MeshFormat::ThreeMf),
"3mf-tagged" => Ok(MeshFormat::ThreeMfTagged),
other => Err(D::Error::custom(whole(format!("unknown mesh format `{other}` (stl, 3mf, 3mf-tagged)")))),
}
}
}
impl JsonSchema for MeshFormat {
fn schema_name() -> Cow<'static, str> {
"MeshFormat".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "stl, 3mf or 3mf-tagged, in any case.",
"type": "string",
"pattern": FORMAT_PATTERN
})
}
}
/// How closely the mesh follows curved faces, as `quality` reads it (the words in any case,
/// whitespace around them ignored).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MeshQualityParam {
/// A chord tolerance of 2e-4 of each body's size.
Standard,
/// 5e-5 of each body's size.
Fine,
/// 1e-5 of each body's size.
Finest,
}
impl<'de> Deserialize<'de> for MeshQualityParam {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let v = Value::deserialize(d)?;
let named = v.as_str().filter(|s| solidcraft_io::MeshQuality::from_name(s).is_some()).map(|s| s.trim().to_ascii_lowercase());
match named.as_deref() {
Some("standard") => Ok(MeshQualityParam::Standard),
Some("fine") => Ok(MeshQualityParam::Fine),
Some("finest") => Ok(MeshQualityParam::Finest),
_ => Err(D::Error::custom(whole(format!("`quality` must be standard, fine or finest, not {v}")))),
}
}
}
impl JsonSchema for MeshQualityParam {
fn schema_name() -> Cow<'static, str> {
"MeshQualityParam".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "standard (a chord tolerance of 2e-4 of each body's size), fine (5e-5) or finest (1e-5), in any case.",
"type": "string",
"pattern": quality_pattern()
})
}
}
/// What each 3MF object holds, as `objects` reads it (lower case only).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MeshObjects {
/// One object per body.
Body,
/// One object for each placed component with two or more bodies.
Component,
}
impl<'de> Deserialize<'de> for MeshObjects {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let v = Value::deserialize(d)?;
match v.as_str() {
Some("body") => Ok(MeshObjects::Body),
Some("component") => Ok(MeshObjects::Component),
_ => Err(D::Error::custom(whole(format!("`objects` must be body or component, not {v}")))),
}
}
}
impl JsonSchema for MeshObjects {
fn schema_name() -> Cow<'static, str> {
"MeshObjects".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "body (one object per body) or component (one object for each placed component with two or more bodies, with those bodies as its parts). Lower case only.",
"type": "string",
"enum": ["body", "component"]
})
}
}
/// What happened when the saved file was handed to `slicer`. Only the one that applies is there.
#[derive(Debug, Default, Serialize, JsonSchema)]
pub struct SlicerDelivery {
/// The slicer's name: the file was opened there.
#[serde(skip_serializing_if = "Option::is_none")]
pub slicer: Option<String>,
/// Why the program would not open the file. The file is saved either way.
#[serde(skip_serializing_if = "Option::is_none")]
pub slicer_error: Option<String>,
/// The slicer's name: the slicer had to be started first and was not ready (never for
/// `file.save_mesh`, which waits for it).
#[serde(skip_serializing_if = "Option::is_none")]
pub starting: Option<String>,
/// The slicer's name: it was started but was not ready within 10 seconds, so the file was
/// not sent. The file is saved.
#[serde(skip_serializing_if = "Option::is_none")]
pub slicer_timeout: Option<String>,
}
/// What `file.save_mesh` returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct SaveMeshResult {
/// The file written, as given.
pub path: String,
/// Its size in bytes.
pub bytes: usize,
/// The format written: StlBinary, StlAscii, ThreeMf or ThreeMfTagged.
pub format: String,
/// With `slicer` only: the bodies that were laid on their print face for the slicer.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub print_faces: Vec<String>,
/// With `slicer` only: a warning for each body that has a print face but was saved as it lies.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<String>,
/// With `slicer` only: what happened when the file was handed over.
#[serde(flatten)]
pub delivery: SlicerDelivery,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
#[cfg(test)]
#[path = "save_mesh_tests.rs"]
mod tests;
-303
View File
@@ -1,303 +0,0 @@
use std::collections::BTreeSet;
use serde_json::{Value, json};
use super::{SaveMeshParams, SaveMeshResult};
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};
fn temp_dir(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("solidcraft-13308-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn with_box(level: ParamCheck) -> Session {
let mut s = Session { param_check: level, ..Session::default() };
s.execute("solid.box", &json!({"length": 20, "width": 10, "height": 5, "body_name": "Block"})).unwrap();
s.execute("solid.cylinder", &json!({"radius": 3, "height": 8, "body_name": "Pin", "operation": "new"})).unwrap();
s
}
#[test]
fn schemas_and_the_key_check_agree() {
assert_keys(input_schema_for::<SaveMeshParams>(), &["ascii", "bodies", "format", "objects", "path", "quality", "single_instance", "slicer"]);
// `path` is the key the handler refuses when it is left out: required in the schema, optional in the type.
let schema = input_schema_for::<SaveMeshParams>();
assert_eq!(schema.as_value()["required"], json!(["path"]));
assert!(serde_json::from_value::<SaveMeshParams>(json!({})).is_ok());
// The patterns are pinned (`conforms` doesn't read them).
let defs = &schema.as_value()["$defs"];
assert_eq!(defs["MeshFormat"]["pattern"], "^([Ss][Tt][Ll]|3[Mm][Ff]|3[Mm][Ff]-[Tt][Aa][Gg][Gg][Ee][Dd])$");
assert_eq!(defs["MeshPath"]["pattern"], json!(crate::schema::types::not_blank_pattern()));
assert_eq!(defs["MeshPath"]["maxLength"], 4095);
let quality = defs["MeshQualityParam"]["pattern"].as_str().unwrap();
assert!(quality.starts_with("^[\\u0009-\\u000d\\u0020") && quality.contains("[Ff][Ii][Nn][Ee][Ss][Tt]"), "{quality}");
assert_eq!(schema.as_value()["allOf"].as_array().map(Vec::len), Some(2));
}
/// Strict gives the handler's message for the calls it refuses for a key. Nothing is written: each
/// of these is refused before the file is.
#[test]
fn strict_keeps_todays_errors() {
let p = |extra: Value| {
let mut p = json!({"path": "/nonexistent-13308/out.stl"});
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
p
};
let mut cases: Vec<(&str, Value)> = vec![
("file.save_mesh", json!({})),
("file.save_mesh", json!({"path": ""})),
("file.save_mesh", json!({"path": " "})),
("file.save_mesh", json!({"path": 5})),
("file.save_mesh", json!({"path": null})),
("file.save_mesh", json!({"path": ["a.stl"]})),
("file.save_mesh", json!({"path": "x".repeat(4096)})),
];
for extra in [
json!({"format": "obj"}),
json!({"format": "step"}),
json!({"format": ""}),
json!({"format": "3MF-Tagged "}),
json!({"quality": "ultra"}),
json!({"quality": 5}),
json!({"quality": true}),
json!({"quality": ""}),
json!({"objects": "Body"}),
json!({"objects": "parts"}),
json!({"objects": 5}),
json!({"objects": false}),
json!({"bodies": 5}),
json!({"bodies": [1, "a"]}),
json!({"bodies": {"a": 1}}),
json!({"bodies": true}),
json!({"format": "stl", "bodies": ["a", null]}),
] {
cases.push(("file.save_mesh", p(extra)));
}
assert_strict_keeps_todays_errors(&cases);
}
/// The refusals only the handler can make (they need the design or two keys together) are the same
/// in strict, and nothing is written.
#[test]
fn strict_keeps_the_handlers_own_refusals() {
let dir = temp_dir("own");
let at = |n: &str| dir.join(n).to_string_lossy().into_owned();
for p in [
json!({"path": at("a.stl"), "objects": "component"}),
json!({"path": at("b.stl"), "bodies": ["Nope"]}),
json!({"path": at("c.3mf"), "slicer": "no such slicer anywhere"}),
json!({"path": at("d.stl"), "slicer": ""}),
] {
let error = |level: ParamCheck| with_box(level).execute("file.save_mesh", &p).err().map(|e| e.to_string());
let off = error(ParamCheck::Off);
assert!(off.is_some(), "{p} should fail today");
assert_eq!(error(ParamCheck::Strict), off, "{p}");
}
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0);
let _ = std::fs::remove_dir_all(&dir);
}
/// The types read each key as the handler does: keys it never reads in a call are not read by the
/// contract either, and the ones it reads fail with its words.
#[test]
fn the_types_read_like_the_handler() {
let reads = |p: Value| serde_json::from_value::<SaveMeshParams>(p).is_ok();
let issues = |p: Value| crate::schema::issues("file.save_mesh", &p).into_iter().map(|i| i.message).collect::<Vec<_>>();
// `ascii` is read only for `format: stl`, in any case; `single_instance` only with a text `slicer`.
assert!(reads(json!({"path": "a.stl", "ascii": "yes"})) && reads(json!({"path": "a.stl", "ascii": 1, "format": "3mf"})));
assert!(reads(json!({"path": "a.stl", "ascii": {}, "format": null})) && reads(json!({"path": "a.stl", "ascii": [], "format": null})));
assert!(reads(json!({"path": "a.stl", "single_instance": "no"})) && reads(json!({"path": "a.stl", "single_instance": 0, "slicer": null})));
assert!(reads(json!({"path": "a.stl", "format": "STL", "ascii": true})) && reads(json!({"path": "a.stl", "format": "Stl", "ascii": null})));
assert!(reads(json!({"path": "a.stl", "slicer": "prusa", "single_instance": false})));
assert!(!reads(json!({"path": "a.stl", "format": "stl", "ascii": "yes"})) && !reads(json!({"path": "a.stl", "format": "STL", "ascii": 1})));
assert!(!reads(json!({"path": "a.stl", "slicer": "prusa", "single_instance": "no"})));
// `null` reads as left out where the handler does.
assert!(reads(json!({"path": "a.stl", "format": null, "bodies": null, "slicer": null, "quality": null, "objects": null})));
// Stricter than the handler, which reads a wrong type as left out.
assert!(!reads(json!({"path": "a.stl", "format": 5})) && !reads(json!({"path": "a.stl", "slicer": 5})));
assert!(!reads(json!({"path": "a.stl", "slicer": true})));
// The words the handler takes: any case for the format and the quality (trimmed), lower case for `objects`.
for format in ["stl", "3MF", "3mf-tagged", "3Mf-TaGGed"] {
assert!(reads(json!({"path": "a", "format": format})), "{format}");
}
for quality in ["standard", "FINE", " Finest\n"] {
assert!(reads(json!({"path": "a", "quality": quality})), "{quality:?}");
}
assert!(reads(json!({"path": "a", "objects": "body"})) && reads(json!({"path": "a", "objects": "component"})));
assert!(reads(json!({"path": "a", "bodies": "A"})) && reads(json!({"path": "a", "bodies": ["A", "B"]})));
assert!(reads(json!({"path": "a", "bodies": []})));
// The handler's own messages.
assert_eq!(issues(json!({"path": ""})), ["`path` is required"]);
assert_eq!(issues(json!({"path": 5})), ["`path` is required"]);
assert_eq!(issues(json!({"path": null})), ["`path` is required"]);
assert_eq!(issues(json!({"path": "a", "format": "OBJ"})), ["unknown mesh format `obj` (stl, 3mf, 3mf-tagged)"]);
assert_eq!(issues(json!({"path": "a", "quality": "ultra"})), ["`quality` must be standard, fine or finest, not \"ultra\""]);
assert_eq!(issues(json!({"path": "a", "quality": 5})), ["`quality` must be standard, fine or finest, not 5"]);
assert_eq!(issues(json!({"path": "a", "objects": "Body"})), ["`objects` must be body or component, not \"Body\""]);
assert_eq!(issues(json!({"path": "a", "objects": null})), Vec::<String>::new());
assert_eq!(issues(json!({"path": "a", "bodies": 5})), ["`bodies` must be a list of names; it is 5"]);
assert_eq!(issues(json!({"path": "a", "pth": 1})), ["unknown key `pth`; did you mean `path`?"]);
// A key the handler doesn't read in this call is not checked, so no warning for it.
assert!(issues(json!({"path": "a", "format": "3mf", "ascii": "yes", "single_instance": 3})).is_empty());
assert_eq!(issues(json!({"path": "a", "format": "stl", "ascii": "yes"})), ["`ascii` must be true or false"]);
}
/// What the schema takes: the same calls as the type, including the keys read only in some cases.
#[test]
fn the_schema_takes_what_the_handler_reads() {
let schema = input_schema_for::<SaveMeshParams>();
let schema = schema.as_value();
for (p, takes) in [
(json!({"path": "a.stl"}), true),
(json!({"path": "a.3mf", "format": "3MF", "bodies": ["A"], "quality": "fine", "objects": "component"}), true),
(json!({"path": "a", "format": "stl", "ascii": true}), true),
(json!({"path": "a", "slicer": "prusa", "single_instance": false}), true),
(json!({"path": "a", "bodies": "A"}), true),
(json!({}), false),
(json!({"path": 5}), false),
(json!({"path": "a", "format": 5}), false),
(json!({"path": "a", "objects": "x"}), false),
(json!({"path": "a", "bodies": 5}), false),
(json!({"path": "a", "wibble": 1}), false),
// Read only in some cases: anything elsewhere, the shape where it is read.
(json!({"path": "a", "ascii": "yes"}), true),
(json!({"path": "a", "format": "3mf", "ascii": "yes"}), true),
(json!({"path": "a", "format": "stl", "ascii": "yes"}), false),
(json!({"path": "a", "single_instance": "no"}), true),
(json!({"path": "a", "slicer": 5, "single_instance": "no"}), false),
(json!({"path": "a", "slicer": "x", "single_instance": "no"}), false),
] {
// `conforms` doesn't read `pattern`: the cases a pattern alone refuses are in the type tests.
assert_eq!(conforms(schema, schema, &p), takes, "{p}");
}
}
fn matches(r: &Value) {
let schema = output_schema_for::<SaveMeshResult>();
let schema = schema.as_value();
assert!(conforms(schema, schema, r), "{r} doesn't match {schema}");
}
fn keys(r: &Value) -> BTreeSet<String> {
r.as_object().map(|o| o.keys().cloned().collect()).unwrap_or_default()
}
/// Every form of the command replies as its output schema says, in warn and without warnings, with
/// the keys it always had; a misspelt key gets its warning and the reply still matches.
#[test]
fn replies_match_the_output_schema() {
let dir = temp_dir("replies");
let at = |n: &str| dir.join(n).to_string_lossy().into_owned();
let mut s = with_box(ParamCheck::Warn);
let run = |s: &mut Session, p: Value| {
let r = s.execute("file.save_mesh", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
assert!(r.get("param_warnings").is_none(), "{p}: {r}");
matches(&r);
assert_eq!(keys(&r), ["bytes", "format", "path"].map(String::from).into(), "{p}: {r}");
assert_eq!(r["bytes"].as_u64(), std::fs::metadata(r["path"].as_str().unwrap()).ok().map(|m| m.len()), "{p}: {r}");
r
};
for (p, format) in [
(json!({"path": at("a.stl")}), "StlBinary"),
(json!({"path": at("b.3mf")}), "ThreeMf"),
(json!({"path": at("c.dat")}), "StlBinary"),
(json!({"path": at("d.dat"), "format": "STL"}), "StlBinary"),
(json!({"path": at("e.stl"), "format": "stl", "ascii": true}), "StlAscii"),
(json!({"path": at("f.stl"), "format": "Stl", "ascii": null}), "StlBinary"),
(json!({"path": at("g.stl"), "format": "3mf"}), "ThreeMf"),
(json!({"path": at("h.3mf"), "format": "3MF-tagged"}), "ThreeMfTagged"),
(json!({"path": at("i.stl"), "bodies": "Block", "quality": "Fine"}), "StlBinary"),
(json!({"path": at("j.3mf"), "bodies": ["Block", "Pin"], "quality": " finest ", "objects": "component"}), "ThreeMf"),
(json!({"path": at("k.3mf"), "objects": "body", "bodies": []}), "ThreeMf"),
(json!({"path": at("l.stl"), "format": null, "bodies": null, "quality": null, "objects": null, "slicer": null}), "StlBinary"),
// Keys the handler doesn't read in these calls take anything, and get no warning.
(json!({"path": at("m.3mf"), "ascii": "yes"}), "ThreeMf"),
(json!({"path": at("n.stl"), "single_instance": [1], "ascii": {}}), "StlBinary"),
(json!({"path": at("o.stl"), "format": "3mf", "ascii": 7, "single_instance": "no"}), "ThreeMf"),
] {
let r = run(&mut s, p.clone());
assert_eq!(r["format"], format, "{p}");
}
assert!(std::fs::read_to_string(at("e.stl")).unwrap().starts_with("solid"));
assert!(std::fs::read(at("a.stl")).unwrap().len() > 84);
// A misspelt key runs in warn and says so; the reply still matches. Strict refuses it.
let p = json!({"path": at("p.stl"), "qualty": "fine"});
let r = s.execute("file.save_mesh", &p).unwrap();
assert!(r["param_warnings"].as_array().is_some_and(|w| w.iter().any(|m| m.as_str().is_some_and(|m| m.contains("`qualty`")))), "{r}");
matches(&r);
assert_eq!(r["format"], "StlBinary");
s.param_check = ParamCheck::Strict;
let error = s.execute("file.save_mesh", &json!({"path": at("q.stl"), "qualty": "fine"})).err().map(|e| e.to_string()).unwrap_or_default();
assert!(error.contains("`qualty`") && error.contains("did you mean `quality`"), "{error}");
assert!(!std::path::Path::new(&at("q.stl")).exists());
let _ = std::fs::remove_dir_all(&dir);
}
/// With a `slicer`: the reply says where the file went; a body with a print face is laid on it and
/// listed. The program is a script that records what it is given.
#[cfg(unix)]
#[test]
fn replies_with_a_slicer_match_the_output_schema() {
use std::os::unix::fs::PermissionsExt;
let _turn = crate::tests::mesh_convert::CHOSEN_PROGRAMS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let dir = temp_dir("slicer");
let (script, seen) = (dir.join("fake-slicer"), dir.join("seen"));
std::fs::write(&script, format!("#!/bin/sh\necho \"$@\" >> {}\n", seen.display())).unwrap();
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
let spec = script.to_string_lossy().into_owned();
crate::slicers::set_chosen_programs(std::slice::from_ref(&spec));
let mut s = with_box(ParamCheck::Warn);
s.execute("body.print_face", &json!({"body": "Block", "point": [10, 5, 5]})).unwrap();
let out = dir.join("part.stl").to_string_lossy().into_owned();
let r = s.execute("file.save_mesh", &json!({"path": out, "slicer": spec, "single_instance": false}));
let again =
s.execute("file.save_mesh", &json!({"path": dir.join("x.3mf"), "slicer": spec, "format": "3mf", "objects": "component", "bodies": "Block"}));
crate::slicers::set_chosen_programs(&[]);
let (r, again) = (r.unwrap(), again.unwrap());
for r in [&r, &again] {
assert!(r.get("param_warnings").is_none(), "{r}");
matches(r);
assert!(r["slicer"].is_string() && r.get("slicer_error").is_none(), "{r}");
}
assert_eq!(keys(&r), ["bytes", "format", "path", "print_faces", "slicer"].map(String::from).into(), "{r}");
assert_eq!(r["print_faces"], json!(["Block"]));
assert_eq!(r["path"], json!(out));
assert_eq!(keys(&again), ["bytes", "format", "path", "print_faces", "slicer"].map(String::from).into(), "{again}");
let _ = std::fs::remove_dir_all(&dir);
}
/// The typed reply serializes to the keys the handler's JSON had, and nothing for what is unset.
#[test]
fn the_reply_type_serializes_as_before() {
use super::{SaveMeshResult, SlicerDelivery};
let reply = |delivery| SaveMeshResult {
path: "a.stl".into(),
bytes: 5,
format: "StlBinary".into(),
print_faces: Vec::new(),
warnings: Vec::new(),
delivery,
param_warnings: Vec::new(),
};
assert_eq!(serde_json::to_value(reply(SlicerDelivery::default())).unwrap(), json!({"path": "a.stl", "bytes": 5, "format": "StlBinary"}));
// Print faces and warnings are there when there are any, as `Laid::report` put them.
let laid = SaveMeshResult { print_faces: vec!["A".into()], warnings: vec!["w".into()], ..reply(SlicerDelivery::default()) };
let r = serde_json::to_value(laid).unwrap();
assert_eq!(r, json!({"path": "a.stl", "bytes": 5, "format": "StlBinary", "print_faces": ["A"], "warnings": ["w"]}));
matches(&r);
for (delivery, key) in [
(SlicerDelivery { slicer: Some("P".into()), ..Default::default() }, "slicer"),
(SlicerDelivery { slicer_error: Some("e".into()), ..Default::default() }, "slicer_error"),
(SlicerDelivery { starting: Some("P".into()), ..Default::default() }, "starting"),
(SlicerDelivery { slicer_timeout: Some("P".into()), ..Default::default() }, "slicer_timeout"),
] {
let r = serde_json::to_value(reply(delivery)).unwrap();
assert_eq!(keys(&r), ["bytes", "format", "path", key].map(String::from).into(), "{r}");
matches(&r);
}
}
@@ -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}");
}
}
}
-75
View File
@@ -7,7 +7,6 @@ use serde_json::{Value, json};
use super::check::schema_keys;
use super::features::{BoxParams, ExtrudeParams};
use super::types::{RevolveAxis, RevolveConventionParam, RevolveDirection, RevolveToPoint};
use super::*;
use crate::Session;
@@ -1137,80 +1136,6 @@ fn axis_lines_match_the_axis_reader() {
}
}
/// A revolve's `axis` is accepted as `features::axis_param` reads it before it looks anything up,
/// and its schema says the same, apart from what a schema can't say (below).
#[test]
fn revolve_axes_match_the_axis_reader() {
let schema = input_schema_for::<RevolveAxis>();
let schema = schema.as_value();
for (v, accepted) in [
(json!("y"), true),
(json!("Z"), true),
(json!("z"), true),
(json!("l1"), true),
(json!("Spindle"), true),
(json!(""), true),
(json!({"dir": [0, 0, 1]}), true),
(json!({"dir": [0, 0, 0]}), true),
(json!({"origin": [1, 2, 3], "dir": {"x": 0, "y": 1, "z": 0}}), true),
(json!({"origin": ["1 mm", 0, {"x": 1}], "dir": [0, 0, 1]}), false),
(json!({"origin": ["1 mm", "w / 2", 0], "dir": [0, 0, 1]}), true),
(json!({"origin": {"x": 1, "y": 2, "z": "3 mm"}, "dir": [0, 0, 1]}), true),
(json!({"origin": "o", "dir": [0, 0, 1]}), false),
(json!({"origin": null, "dir": [0, 0, 1]}), false),
(json!({"origin": [1, 2], "dir": [0, 0, 1]}), false),
(json!({"origin": [0, 0, 0]}), false),
(json!({"dir": [0, 1]}), false),
(json!({"dir": "y"}), false),
(json!({"dir": [0, 0, 1], "type": "sketch_line", "extra": 1}), true),
(json!({"type": "world", "axis": "X"}), true),
(json!({"type": "world", "axis": "X", "origin": 5, "dir": "q"}), true),
(json!({"type": "world", "axis": "x"}), false),
(json!({"type": "world", "axis": 5}), false),
(json!({"type": "world"}), false),
(json!({"type": "world", "dir": [0, 0, 1]}), false),
(json!({"edge": [1, 2, 3]}), true),
(json!({"face": [1, 2, 3], "dir": [0, 0, 1]}), true),
(json!({"face": "x", "dir": [0, 0, 1]}), false),
(json!({"type": "face", "at": [1, 2, 3]}), true),
(json!({"type": "vertex", "at": [1, 2, 3]}), false),
(json!({"face": "x", "vertex": [1, 2, 3]}), false),
(json!({}), false),
(json!(5), false),
(json!(true), false),
(json!(null), false),
(json!([0, 0, 1]), false),
] {
assert_eq!(parse::<RevolveAxis>(v.clone()).is_some(), accepted, "{v}");
assert_eq!(conforms(schema, schema, &v), accepted, "schema {v}");
}
// Where the schema can't follow the reader: a saved edge is read whole by serde.
let v = json!({"type": "edge", "at": "x"});
assert!(parse::<RevolveAxis>(v.clone()).is_none() && conforms(schema, schema, &v), "{v}");
}
/// Revolve's `direction` and `convention` take what the handler reads, and its `to` is a point.
#[test]
fn revolve_keywords_match_the_handler() {
for (v, accepted) in
[(json!("symmetric"), true), (json!("SYMMETRIC"), true), (json!(" symmetric"), false), (json!("negative"), false), (json!(true), false)]
{
assert_eq!(parse::<RevolveDirection>(v.clone()).is_some(), accepted, "{v}");
let schema = input_schema_for::<RevolveDirection>();
assert_eq!(conforms(schema.as_value(), schema.as_value(), &v), accepted && v == json!("symmetric"), "schema {v}");
}
for (v, accepted) in [
(json!("sketch_normal"), true),
(json!("legacy_axis_start"), true),
(json!("Sketch_Normal"), false),
(json!("legacy_per_side"), false),
(json!(null), false),
] {
assert_eq!(parse::<RevolveConventionParam>(v.clone()).is_some(), accepted, "{v}");
}
assert!(parse::<RevolveToPoint>(json!([1, 2, 3])).is_some() && parse::<RevolveToPoint>(json!([1, 2])).is_none());
}
/// A construction plane's name is refused when `features::check_plane_name` refuses it. (The
/// schema can't say which names: see `PlaneName`.)
#[test]
+6 -180
View File
@@ -210,12 +210,6 @@ target_point!(
"must be a point [x, y, z] on the face or point to start from"
);
target_point!(
/// Revolve's `to`: a [`Point3`], with the handler's message.
RevolveToPoint,
"must be a point [x, y, z] to turn to"
);
/// Extrude's `faces`: 1 to 1024 points on coplanar faces of one body, read as the handler reads
/// them, with its messages.
#[derive(Clone, Debug, PartialEq)]
@@ -980,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`
@@ -1366,17 +1324,6 @@ impl JsonSchema for PlaneOrFace {
}
}
/// What `construct::model_axis_param` finds wrong with an edge or face axis before it looks
/// anything up in the design: a reference that isn't a picked or saved face or edge.
fn model_axis_problem(v: &Value) -> Option<String> {
use crate::cmd::GeoForm;
use solidcraft_doc::construct::GeoRef;
geo_problem(v).or_else(|| match crate::cmd::geo_form(v) {
Ok(GeoForm::Face(_) | GeoForm::Edge(_) | GeoForm::Saved(GeoRef::Face { .. } | GeoRef::Edge { .. })) => None,
_ => Some(solidcraft_doc::construct::MODEL_AXIS_REFS.to_string()),
})
}
/// The line `construct.plane.at_angle` turns about, as `features::axis_line` reads it: an edge or
/// face of the model (as `construct::model_axis_param` reads it), else X, Y, Z, a construction
/// axis's name or `{origin?, dir}`. Kept as given, for the handler. Whether a picked edge or face
@@ -1386,9 +1333,14 @@ pub struct AxisLine(pub Value);
impl<'de> Deserialize<'de> for AxisLine {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
use crate::cmd::GeoForm;
use solidcraft_doc::construct::GeoRef;
let v = Value::deserialize(d)?;
let problem = if crate::cmd::is_model_axis(&v) {
model_axis_problem(&v)
geo_problem(&v).or_else(|| match crate::cmd::geo_form(&v) {
Ok(GeoForm::Face(_) | GeoForm::Edge(_) | GeoForm::Saved(GeoRef::Face { .. } | GeoRef::Edge { .. })) => None,
_ => Some(solidcraft_doc::construct::MODEL_AXIS_REFS.to_string()),
})
} else {
match &v {
Value::String(_) => None,
@@ -1428,132 +1380,6 @@ impl JsonSchema for AxisLine {
}
}
/// What `features::axis_param` says to an `axis` that is none of its forms.
const REVOLVE_AXIS_MESSAGE: &str =
"`axis` must be a sketch line id, x, y (sketch axes), X, Y, Z, a construction axis, {origin, dir} or {edge|face: [x,y,z]}";
/// What `features::axis_param` finds wrong with an object axis that isn't an edge or face of the
/// model, before it looks anything up in the design.
fn revolve_axis_object_problem(o: &Value) -> Option<String> {
if o.get("type").and_then(Value::as_str) == Some("world") {
let ok = o.get("axis").and_then(Value::as_str).is_some_and(|a| matches!(a, "X" | "Y" | "Z"));
return (!ok).then(|| whole("world axis requires `axis` to be X, Y or Z"));
}
if let Some(origin) = o.get("origin")
&& serde_json::from_value::<PointExpr3>(origin.clone()).is_err()
{
return Some(whole("`origin` must be a point [x, y, z] of numbers or length expressions"));
}
match o.get("dir") {
None => Some(whole("axis needs `dir`")),
Some(dir) => params::vec3(dir).is_none().then(|| whole("axis `dir` must be a direction [x, y, z] of numbers")),
}
}
/// Revolve's `axis`, as `features::axis_param` reads it before it looks anything up: a sketch
/// line's id or name, `x` or `y`, X, Y or Z, a construction axis's name (any text), `{type: "world",
/// axis}`, `{origin?, dir}`, or an edge or face of the model (as `construct::model_axis_param`
/// reads it). Kept as given, for the handler. Whether a line or axis exists, and whether a picked
/// edge or face gives an axis, is checked by the handler, which has the design.
#[derive(Clone, Debug, PartialEq)]
pub struct RevolveAxis(pub Value);
impl<'de> Deserialize<'de> for RevolveAxis {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let v = Value::deserialize(d)?;
let problem = if crate::cmd::is_model_axis(&v) {
model_axis_problem(&v)
} else {
match &v {
Value::String(_) => None,
Value::Object(_) => revolve_axis_object_problem(&v),
_ => Some(whole(REVOLVE_AXIS_MESSAGE)),
}
};
match problem {
Some(why) => Err(D::Error::custom(why)),
None => Ok(RevolveAxis(v)),
}
}
}
impl JsonSchema for RevolveAxis {
fn schema_name() -> Cow<'static, str> {
"RevolveAxis".into()
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
let point = generator.subschema_for::<Point3>();
let origin = generator.subschema_for::<PointExpr3>();
let model = json!({"anyOf": [{"required": ["edge"]}, {"required": ["face"]}, {"required": ["type"], "properties": {"type": {"enum": ["edge", "face"]}}}]});
json_schema!({
"description": "The axis to turn about: a sketch line's id or name (a line of the profile's sketch wins over a construction axis of the same name); x or y, the sketch's own axes; X, Y or Z, the world axes (any other case is a name); a construction axis's name, which the revolve follows when it is edited; {\"type\": \"world\", \"axis\": X, Y or Z}, as timeline.inspect gives one back; {\"origin\"?: [x, y, z] (numbers or length expressions; default: the world origin), \"dir\": [x, y, z]}, which the revolve follows when parameters change; or an edge or face of a body ({\"edge\": [x, y, z]} or {\"face\": [x, y, z]}, a point on a straight or circular edge, or on a cylinder, cone or torus face, or as saved: {\"type\": \"edge\" or \"face\", \"at\", \"name\"?}), about whose axis it turns, followed as the model changes.",
"anyOf": [
{"type": "string"},
{"type": "object", "properties": {"type": {"enum": ["world"]}, "axis": {"enum": ["X", "Y", "Z"]}}, "required": ["type", "axis"]},
{
"type": "object",
"properties": {"origin": origin, "dir": point},
"required": ["dir"],
"not": {"anyOf": [model, {"required": ["type"], "properties": {"type": {"enum": ["world"]}}}]}
},
picked_at("face", &point),
picked_at("edge", &point),
{"type": "object", "properties": {"type": {"enum": ["edge", "face"]}}, "required": ["type"]}
]
})
}
}
/// Revolve's `direction`: only `symmetric` (any case, not trimmed) does anything; the handler
/// ignores every other value.
#[derive(Clone, Debug, PartialEq)]
pub struct RevolveDirection;
impl<'de> Deserialize<'de> for RevolveDirection {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
match Value::deserialize(d)? {
Value::String(s) if s.eq_ignore_ascii_case("symmetric") => Ok(RevolveDirection),
_ => Err(D::Error::custom("must be symmetric")),
}
}
}
impl JsonSchema for RevolveDirection {
fn schema_name() -> Cow<'static, str> {
"RevolveDirection".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
enum_schema(
&["symmetric"],
"symmetric: the angle is split either side of the sketch plane, whichever way the axis points. The same as `symmetric: true`. Matching ignores case.",
)
}
}
/// Revolve's angular start, as the handler reads `convention` (serde, case-sensitive).
#[derive(Clone, Debug, PartialEq)]
pub struct RevolveConventionParam(pub solidcraft_doc::RevolveConvention);
impl<'de> Deserialize<'de> for RevolveConventionParam {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
serde_json::from_value(Value::deserialize(d)?)
.map(RevolveConventionParam)
.map_err(|_| D::Error::custom("must be sketch_normal or legacy_axis_start"))
}
}
impl JsonSchema for RevolveConventionParam {
fn schema_name() -> Cow<'static, str> {
"RevolveConvention".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
enum_schema(
&["sketch_normal", "legacy_axis_start"],
"sketch_normal (the default): the angle is measured from the sketch plane, turning about the axis as the sketch's normal sweeps. legacy_axis_start keeps the angular start older designs had.",
)
}
}
/// A construction plane's name, which `features::check_plane_name` refuses when it names an
/// origin plane. Strict gives the same message but names the command that was called, where the
/// handler (`add_feature`, which isn't given it) says `construct.plane`.
+8
View File
@@ -7,8 +7,11 @@ doc.recover
doc.recovery_discard
doc.recovery_list
face.delete
file.export
file.insert_mesh
file.insert_step
file.new
file.save_mesh
file.slicers
motion.export_animation
motion.trace
@@ -21,6 +24,8 @@ render.preflight
render.scene
render.start
sheet.export_dxf
sketch.draw.dimensioned
solid.align
solid.coil
solid.draft
solid.emboss
@@ -34,7 +39,10 @@ solid.pattern.path
solid.pattern.rectangular
solid.press_pull
solid.replace_face
solid.revolve
solid.shell
solid.split_body
solid.split_face
solid.thread
surface.blend
surface.extend
+1 -1
View File
@@ -235,7 +235,7 @@ fn send_to_slicer_needs_a_slicer_and_bodies() {
/// The chosen-programs list is process-wide: tests that set it take turns.
#[cfg(unix)]
pub(crate) static CHOSEN_PROGRAMS: std::sync::Mutex<()> = std::sync::Mutex::new(());
static CHOSEN_PROGRAMS: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// `file.save_mesh` with a `slicer` writes where the caller said and hands that file over, by the
/// same steps as `file.send_to_slicer`.
-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}°");
}
}
-57
View File
@@ -1528,60 +1528,3 @@ fn a_modelled_thread_leaves_the_next_hole_a_cylinder_to_thread() {
assert!(t3.is_ok(), "gap {gap}: the second hole takes a modelled thread: {t3:?}");
}
}
/// #13505: a tapped hole drilled across two slots has its wall in three pieces (z 30..40, 15..25
/// and 5..10). The thread runs along every piece the hole reaches, cosmetic or modelled, and a
/// thread depth runs on through the pieces from the first one's start.
#[test]
fn a_tapped_hole_across_slots_is_threaded_along_every_piece_of_its_wall() {
let slotted = || {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"corner": [0, 0, 0], "length": 30, "width": 30, "height": 40, "body_name": "Part"}));
for z in [25, 10] {
run(&mut s, "solid.box", json!({"corner": [-1, -1, z], "length": 32, "width": 21, "height": 5, "operation": "cut", "targets": ["Part"]}));
}
s
};
let hole = |s: &mut Session, extra: Value| {
let mut ask = json!({"position": [15, 10, 40], "direction": [0, 0, -1], "thread": "M4", "depth": 35});
ask.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
run(s, "solid.hole", ask);
};
let spans = |s: &Session| {
let mut v: Vec<(f64, f64)> = s
.model
.state()
.threads
.iter()
.map(|t| {
let (a, b) = ((t.axis_point + t.axis * t.start).z, (t.axis_point + t.axis * t.end).z);
(a.min(b), a.max(b))
})
.collect();
v.sort_by(|a, b| b.0.total_cmp(&a.0));
v
};
let near = |got: Vec<(f64, f64)>, want: &[(f64, f64)]| {
assert_eq!(got.len(), want.len(), "{got:?} vs {want:?}");
for (g, w) in got.iter().zip(want) {
assert!((g.0 - w.0).abs() < 1e-6 && (g.1 - w.1).abs() < 1e-6, "{got:?} vs {want:?}");
}
};
let mut s = slotted();
hole(&mut s, json!({}));
near(spans(&s), &[(30.0, 40.0), (15.0, 25.0), (5.0, 10.0)]);
// 22 mm from the entry: on through the first slot, stopping in the second piece.
let mut s = slotted();
hole(&mut s, json!({"thread_depth": 22}));
near(spans(&s), &[(30.0, 40.0), (18.0, 25.0)]);
// Modelled, every piece of the wall is grooved: each loses material the cosmetic one keeps.
let mut s = slotted();
hole(&mut s, json!({"modeled": true}));
assert!(s.model.state().body("Part").unwrap().body.validity().is_empty());
let mut plain = slotted();
hole(&mut plain, json!({}));
for (z0, z1) in [(30.0, 40.0), (15.0, 25.0), (5.0, 10.0)] {
let (cut, drilled) = (volume_between(&s, "Part", (z0, z1), None), volume_between(&plain, "Part", (z0, z1), None));
assert!(cut < drilled - 1.0, "z {z0}..{z1}: {cut} vs {drilled}");
}
}
+5 -6
View File
@@ -432,12 +432,11 @@ mod tests {
assert!(classify(&body, &mesh, 0).0.iter().all(|f| !*f));
}
/// A G2 corner patch's sides are G2 to their rounds except near the faces they end on: each
/// side is one join, drawn whole (the 20 mm box's r3 corner). Since tangency-labs/solidcore#2988 the
/// corner SMLib leaves open is the blend corner's Setback, whose sections are not split, so
/// each side is one edge (the N x N patch before it held each side as two).
/// A G2 corner patch's sides are G2 to their rounds except near the faces they end on, and
/// SMLib holds each side as two edges: the side is one join, drawn whole, not up to the
/// vertex in its middle where the G2 half begins (the 20 mm box's r3 corner).
#[test]
fn a_g2_corner_s_side_to_its_round_is_drawn_whole() {
fn a_seam_split_in_two_edges_is_drawn_whole() {
let body = crate::box_solid(Vec3::ZERO, Vec3::new(20., 20., 20.)).unwrap();
let picks = [Vec3::new(20., 20., 10.), Vec3::new(20., 10., 20.), Vec3::new(10., 20., 20.)];
let g2 = crate::fillet_g2_corners(&body, &picks, 3., &[]).unwrap();
@@ -453,7 +452,7 @@ mod tests {
for round in rounds {
let side: Vec<usize> =
(0..mesh.edges.len()).filter(|e| mesh.edge_faces[*e].contains(&patch) && mesh.edge_faces[*e].contains(&round)).collect();
assert_eq!(side.len(), 1, "the Setback's section is one edge (tangency-labs/solidcore#2988): {side:?}");
assert_eq!(side.len(), 2, "SMLib splits the side in two: {side:?}");
assert!(side.iter().all(|e| mesh.shaded_edge_visible(*e)), "the side to round {round} is drawn whole: {side:?}");
}
}
-20
View File
@@ -2487,26 +2487,6 @@ fn compact_execute_keeps_changes_ids_and_default_replies() {
}
}
/// #9180: a compact sketch reply counts the points of a spline's poles instead of listing them,
/// and keeps the point ids of curves with only their ends.
#[test]
fn compact_sketch_replies_count_spline_poles() {
let mut s = server();
tool(&mut s, "execute", json!({"command": "sketch.create", "params": {"plane": "XY"}}));
let points = json!([[0, 0], [10, 5], [20, 0], [30, 5], [40, 0], [50, 5]]);
let full = tool(&mut s, "execute", json!({"command": "sketch.spline.fit_point", "params": {"points": points}}));
let listed = full["created"]["points"].as_array().unwrap().len();
let curves = full["created"]["curves"].as_array().unwrap().len();
assert!(listed > 2 * curves, "{full}");
let compact = tool(&mut s, "execute", json!({"command": "sketch.spline.fit_point", "params": {"points": points}, "verbose": false}));
assert_eq!(compact["created"]["point_count"], json!(listed), "{compact}");
assert!(compact["created"].get("points").is_none(), "{compact}");
assert_eq!(compact["created"]["curves"].as_array().map(Vec::len), Some(curves));
let rectangle =
tool(&mut s, "execute", json!({"command": "sketch.rectangle.two_point", "params": {"p0": [0, 20], "p1": [10, 30]}, "verbose": false}));
assert_eq!(rectangle["created"]["points"].as_array().map(Vec::len), Some(4), "{rectangle}");
}
/// A connected app sees exactly one engine call; no inventory inspection is needed.
#[test]
fn compact_execute_uses_one_backend_call_and_keeps_removed_bodies() {
+2 -12
View File
@@ -122,7 +122,7 @@ pub fn tool_definitions_with(opts: Options) -> Vec<Value> {
json!({
"command": string("Command id (see list_commands)"),
"params": {"type": "object", "description": "The command's parameters"},
"verbose": {"type": "boolean", "description": "false: omit the full feature body list and sketch ids repeated outside `created`; keep created_bodies, changed_bodies and removed_bodies. When a sketch command creates more points than its new curves have ends (spline poles), `created.points` becomes `created.point_count` (default true)"},
"verbose": {"type": "boolean", "description": "false: omit the full feature body list and sketch ids repeated outside `created`; keep created_bodies, changed_bodies and removed_bodies (default true)"},
"expect": expect_schema(),
}),
&["command"],
@@ -160,7 +160,7 @@ pub fn tool_definitions_with(opts: Options) -> Vec<Value> {
"coalesce_parameters": {"type": "boolean", "description": "Set each run of consecutive plain parameter edits (parameters.change or set_parameter with a name and a value, or changes) together, rebuilding the design once for the run instead of once per edit; a run that is refused goes step by step, naming the refused step. Other steps still see the design up to date. Each step keeps its own result; the run is one undo step (default false)"},
"timings": {"type": "boolean", "description": "Add `timings`: [{index, command, ms}] for every step run (the failing one included) and `total_ms` (default false)"},
"profile": {"type": "boolean", "description": "Like timings, and each step's `profile`: {boolean_ms, tessellate_ms, other_ms}, its time in kernel booleans, in tessellation and the rest (default false)"},
"verbose": {"type": "boolean", "description": "false: a compact reply. Feature steps leave out the full body list (created_bodies, changed_bodies and removed_bodies stay), sketch steps the ids they repeat outside `created`, and `created.points` becomes `created.point_count` when there are more points than the new curves have ends (default true)"},
"verbose": {"type": "boolean", "description": "false: a compact reply. Feature steps leave out the full body list (created_bodies, changed_bodies and removed_bodies stay), sketch steps the ids they repeat outside `created` (default true)"},
"expect": expect_schema(),
}),
&[],
@@ -1829,16 +1829,6 @@ fn compact(mut v: Value) -> Value {
}
}
}
// More new points than the new curves have ends (an offset's spline poles, #9180): agents
// refer to those curves by id, so the points only fill the context. Keep how many there were.
if let Some(created) = o.get_mut("created").and_then(Value::as_object_mut) {
let count = |k: &str| created.get(k).and_then(Value::as_array).map_or(0, Vec::len);
let (points, curves) = (count("points"), count("curves"));
if curves > 0 && points > 2 * curves {
created.remove("points");
created.insert("point_count".into(), json!(points));
}
}
v
}
+6 -7
View File
@@ -606,10 +606,9 @@ fn pocket_chamfer_on_a_boolean_body() {
}
/// The corner of r5 rounds at (60, 40, 20) on a 60 x 40 x 20 box, set back 8 and left open: the
/// box's six faces and the three rounds (9 faces, 27 edges, 19 vertices), a sheet, and one hole
/// of nine edges, chained end to start, each beside a face of the sheet. The marked corner is the
/// blend corner's Setback (tangency-labs/solidcore#2988), whose sections and face curves are one
/// edge each; the N x N patch it replaced split each in two (12 edges, 30 and 22).
/// box's six faces and the three rounds (9 faces, 30 edges, 22 vertices), a sheet, and one hole
/// of twelve edges (each section and face curve in two), chained end to start, each beside a face
/// of the sheet.
#[test]
fn a_setback_corner_left_open_returns_its_hole() {
let edges = [[30.0, 40.0, 20.0], [60.0, 20.0, 20.0], [60.0, 40.0, 10.0]];
@@ -617,17 +616,17 @@ fn a_setback_corner_left_open_returns_its_hole() {
let b = Shape::Box([0.0; 3], [60.0, 40.0, 20.0]).core();
blend_holes(&b, Blend::Round, &edges, &[5.0; 3], 0.1, &[[60.0, 40.0, 20.0]], &[8.0], Corners::Open).unwrap()
});
assert_eq!(counts(&open).unwrap(), [9, 27, 19]);
assert_eq!(counts(&open).unwrap(), [9, 30, 22]);
assert!(!super::is_manifold_solid(&open).unwrap());
let [hole] = holes.as_slice() else { panic!("one corner: {holes:?}") };
assert_eq!(hole.edges.len(), 9);
assert_eq!(hole.edges.len(), 12);
let flat = to_arrays(&open).unwrap();
let (rec, _) = native(&flat).unwrap();
solidcraft_geom::brep::check::validate(&rec).unwrap();
let ends = &flat.ints[&crate::flat::EDGE_VERTICES];
let end = |h: &crate::HoleEdge, last: bool| ends[2 * h.edge + usize::from(h.forward == last)];
for (k, h) in hole.edges.iter().enumerate() {
let next = &hole.edges[(k + 1) % 9];
let next = &hole.edges[(k + 1) % 12];
assert_eq!(end(h, true), end(next, false), "edge {k} ends where the next starts");
assert!(h.face < 9);
}
+6 -8
View File
@@ -389,26 +389,24 @@ fn draft_tilts_a_side_about_its_line_on_the_neutral_plane() {
}
/// The corner of r5 rounds at (60, 40, 20) on a 60 x 40 x 20 box, set back 8 and left open: the
/// box's six faces and the three rounds (9 faces, 27 edges, 19 vertices), a sheet SMLib's check
/// passes, and one hole of nine edges, chained end to start, each bounding a different face than
/// the patch's neighbours across it. The marked corner is the blend corner's Setback
/// (tangency-labs/solidcore#2988), whose sections and face curves are one edge each; the N x N
/// patch it replaced split each in two (12 edges, 30 and 22).
/// box's six faces and the three rounds (9 faces, 30 edges, 22 vertices), a sheet SMLib's check
/// passes, and one hole of twelve edges (each section and face curve in two), chained end to
/// start, each bounding a different face than the patch's neighbours across it.
#[test]
fn a_setback_corner_left_open_returns_its_hole() {
let b = Brep::make_box([0.0; 3], [60.0, 40.0, 20.0]).unwrap();
let edges = [[30.0, 40.0, 20.0], [60.0, 20.0, 20.0], [60.0, 40.0, 10.0]];
let (open, holes) = b.blend_setback_open(Blend::Round, &edges, &[5.0; 3], &[[60.0, 40.0, 20.0]], &[8.0], 0.1).unwrap();
shot(&open, "setback_open");
assert_eq!(open.counts().unwrap(), [9, 27, 19]);
assert_eq!(open.counts().unwrap(), [9, 30, 22]);
assert!(!open.is_manifold_solid().unwrap());
let [hole] = holes.as_slice() else { panic!("one corner: {holes:?}") };
assert_eq!(hole.edges.len(), 9);
assert_eq!(hole.edges.len(), 12);
let arrays = open.arrays().unwrap();
let ends = &arrays.ints[&crate::flat::EDGE_VERTICES];
let end = |h: &HoleEdge, last: bool| ends[2 * h.edge + usize::from(h.forward == last)];
for (k, h) in hole.edges.iter().enumerate() {
let next = &hole.edges[(k + 1) % 9];
let next = &hole.edges[(k + 1) % 12];
assert_eq!(end(h, true), end(next, false), "edge {k} ends where the next starts");
assert!(h.face < 9);
}
-1
View File
@@ -70,7 +70,6 @@ proc-macro2 = { workspace = true }
# egui-winit's clipboard.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
arboard = { version = "3.6", default-features = false, features = ["image-data"] }
lopdf = { version = "0.45", default-features = false }
[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
# The offscreen viewport render test opens a device without a window.
+26 -211
View File
@@ -30,11 +30,6 @@ const MAX_PICTURE_BYTES: u64 = 40 * 1024 * 1024;
const MAX_TEXT_BYTES: u64 = 256 * 1024;
/// Largest PDF sent as a document; a bigger one goes by reference.
const MAX_PDF_BYTES: u64 = 12 * 1024 * 1024;
/// Pages whose text is taken from a PDF, and the most characters of it kept.
const MAX_PDF_PAGES: usize = 300;
const MAX_PDF_CHARS: usize = 200_000;
/// A PDF page's content may inflate to at most this (a compressed bomb is refused by the reader).
const MAX_PDF_PAGE_BYTES: usize = 32 * 1024 * 1024;
/// A picture's longer side is scaled down to this before it is sent.
pub const MAX_SIDE: u32 = 2048;
/// Largest picture sent, as it came or re-encoded: its base64 (a third larger) stays under 5 MB,
@@ -93,11 +88,10 @@ pub enum Content {
size: [u32; 2],
},
Text(Arc<String>),
/// A PDF, sent whole as a document to an agent that takes them; to the others, its text.
/// A PDF, sent whole as a document to an agent that takes them.
Pdf {
bytes: Arc<Vec<u8>>,
path: PathBuf,
text: Option<Arc<PdfText>>,
},
/// A file the agent is pointed to, not given: what it is, its size and where it is.
Reference {
@@ -109,60 +103,6 @@ pub enum Content {
Imported,
}
/// What was taken out of a PDF: its page count and its text, without layout, pictures or fonts.
#[derive(Clone, Debug, PartialEq)]
pub struct PdfText {
pub pages: usize,
pub text: String,
/// The text stops short: of the pages or of the characters kept.
pub cut: bool,
}
/// The text of a PDF, from its pages in order (up to [`MAX_PDF_PAGES`] pages and
/// [`MAX_PDF_CHARS`] characters). `None` when it can't be read at all (damaged, or a reader
/// panic, which is caught); an encrypted or scanned one gives its page count and no text.
#[cfg(not(target_arch = "wasm32"))]
pub fn pdf_text(bytes: &[u8]) -> Option<PdfText> {
std::panic::catch_unwind(|| {
let doc = lopdf::Document::load_mem(bytes).ok()?;
let pages: Vec<u32> = doc.get_pages().keys().copied().collect();
let mut text = String::new();
let mut chars = 0usize;
let mut cut = pages.len() > MAX_PDF_PAGES;
if !doc.is_encrypted() {
for page in pages.iter().take(MAX_PDF_PAGES) {
// A page that can't be read is skipped; the others still give their text.
let chunks = doc.extract_text_chunks_with_limit(&[*page], MAX_PDF_PAGE_BYTES);
let page_text: String = chunks.into_iter().filter_map(Result::ok).collect();
let page_text = page_text.trim();
if page_text.is_empty() {
continue;
}
if !text.is_empty() {
text.push_str("\n\n");
}
let room = MAX_PDF_CHARS.saturating_sub(chars);
let n = page_text.chars().count();
if n > room {
text.extend(page_text.chars().take(room));
cut = true;
break;
}
text.push_str(page_text);
chars += n;
}
}
Some(PdfText { pages: pages.len(), text, cut })
})
.ok()
.flatten()
}
#[cfg(target_arch = "wasm32")]
pub fn pdf_text(_bytes: &[u8]) -> Option<PdfText> {
None
}
#[derive(Clone, Debug, PartialEq)]
pub struct Attachment {
/// Its own on the message, never reused (set when it is added there; 0 until then): the
@@ -282,9 +222,7 @@ pub fn read(path: &Path) -> Result<Attachment, String> {
return Ok(Attachment { id: 0, name, content: Content::Reference { path, size: meta.len(), mime: "application/pdf" }, thumb: None });
}
let bytes = std::fs::read(&path).map_err(|e| failed(&name, e))?;
// Read here, off the window thread: for an agent that takes no documents.
let text = pdf_text(&bytes).map(Arc::new);
Ok(Attachment { id: 0, name, content: Content::Pdf { bytes: Arc::new(bytes), path, text }, thumb: None })
Ok(Attachment { id: 0, name, content: Content::Pdf { bytes: Arc::new(bytes), path }, thumb: None })
}
Kind::Other if matches!(path.extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).as_deref(), Some("heic" | "heif")) => {
Err(refused("ui.agent_chat_file_heic", &name, &[]))
@@ -396,24 +334,6 @@ fn thumbnail(img: &image::DynamicImage) -> Option<Arc<egui::ColorImage>> {
Some(Arc::new(egui::ColorImage::from_rgba_unmultiplied(size, t.as_raw())))
}
/// Add a file's text to the message under `head`, within the `left` characters shared by the
/// files: whole when it fits; cut, saying how much is missing, when it doesn't; named only when
/// nothing is left.
fn fit(out: &mut String, left: &mut usize, head: &str, text: &str) {
let t = text.trim_end();
let len = t.chars().count();
if *left == 0 {
*out += &format!("\n\n{head}: left out, the message is at its size limit.");
} else if len <= *left {
*out += &format!("\n\n{head}:\n```\n{t}\n```");
*left -= len;
} else {
let kept: String = t.chars().take(*left).collect();
*out += &format!("\n\n{head} (cut: {} more characters left out):\n```\n{kept}\n```", len - *left);
*left = 0;
}
}
/// What the message says about its files, after the person's words: the text files' text, and the
/// pictures and imported parts by name (the agent sees the pictures themselves as images).
/// `pictures_sent` and `documents_sent`: whether this agent takes pictures and PDFs. The text files share `budget` characters
@@ -433,8 +353,21 @@ pub fn note(files: &[Attachment], pictures_sent: bool, documents_sent: bool, bud
let mut left = budget;
for f in files {
match &f.content {
Content::Text(t) => fit(&mut out, &mut left, &format!("Attached file `{}`", f.name), t),
Content::Pdf { path, bytes, .. } if documents_sent => {
Content::Text(t) => {
let t = t.trim_end();
let len = t.chars().count();
if left == 0 {
out += &format!("\n\nAttached file `{}`: left out, the message is at its size limit.", f.name);
} else if len <= left {
out += &format!("\n\nAttached file `{}`:\n```\n{t}\n```", f.name);
left -= len;
} else {
let kept: String = t.chars().take(left).collect();
out += &format!("\n\nAttached file `{}` (cut: {} more characters left out):\n```\n{kept}\n```", f.name, len - left);
left = 0;
}
}
Content::Pdf { path, bytes } if documents_sent => {
out += &format!(
"\n\nAttached PDF `{}` ({}, at {}): sent with this message as a document.",
f.name,
@@ -442,29 +375,14 @@ pub fn note(files: &[Attachment], pictures_sent: bool, documents_sent: bool, bud
path.display()
);
}
// An agent that takes no documents gets the text, or is pointed to the file.
Content::Pdf { path, bytes, text } => {
let size = size_text(bytes.len() as u64);
match text.as_deref().filter(|t| !t.text.trim().is_empty()) {
Some(t) => {
let cut = if t.cut { "; cut, the rest is in the file" } else { "" };
let head = format!(
"Attached PDF `{}` ({} pages, {size}, at {}): its text only, layout, pictures and fonts are dropped{cut}",
f.name,
t.pages,
path.display()
);
fit(&mut out, &mut left, &head, &t.text);
}
None => {
let why = if text.is_some() { "no text could be taken from it (scanned or protected)" } else { "its text could not be read" };
out += &format!(
"\n\nAttached file `{}` (PDF, {size}, at {}): {why}; read it with your own tools, it is not in this message.",
f.name,
path.display()
);
}
}
// An agent that takes no documents is pointed to the file, as for any other file.
Content::Pdf { path, bytes } => {
out += &format!(
"\n\nAttached file `{}` (PDF, {}, at {}): read it with your own tools; it is not in this message.",
f.name,
size_text(bytes.len() as u64),
path.display()
);
}
Content::Reference { path, size, mime } => {
out += &format!(
@@ -487,7 +405,7 @@ pub fn parts(files: &[Attachment], pictures: bool, documents: bool) -> Vec<Promp
.iter()
.filter_map(|f| match &f.content {
Content::Picture { mime, bytes, .. } if pictures => Some(PromptPart::Image { mime: (*mime).to_string(), base64: backend::base64(bytes) }),
Content::Pdf { bytes, path, .. } if documents => Some(PromptPart::Document {
Content::Pdf { bytes, path } if documents => Some(PromptPart::Document {
name: f.name.clone(),
uri: file_uri(path),
mime: "application/pdf".to_string(),
@@ -762,107 +680,4 @@ mod tests {
assert_eq!(size_text(1536), "2 KB");
assert_eq!(size_text(5 * 1024 * 1024 + 512 * 1024), "5.5 MB");
}
/// A PDF with one line of text on each page.
fn sample_pdf(pages: &[&str]) -> Vec<u8> {
use lopdf::content::{Content, Operation};
use lopdf::{Document, Object, Stream, dictionary};
let mut doc = Document::with_version("1.5");
let pages_id = doc.new_object_id();
let font = doc.add_object(dictionary! {"Type" => "Font", "Subtype" => "Type1", "BaseFont" => "Helvetica"});
let resources = doc.add_object(dictionary! {"Font" => dictionary! {"F1" => font}});
let mut kids = Vec::new();
for line in pages {
let mut ops = vec![
Operation::new("BT", vec![]),
Operation::new("Tf", vec!["F1".into(), 12.into()]),
Operation::new("Td", vec![72.into(), 700.into()]),
];
if !line.is_empty() {
ops.push(Operation::new("Tj", vec![Object::string_literal(*line)]));
}
ops.push(Operation::new("ET", vec![]));
let content = doc.add_object(Stream::new(dictionary! {}, Content { operations: ops }.encode().unwrap()));
kids.push(Object::Reference(doc.add_object(dictionary! {"Type" => "Page", "Parent" => pages_id, "Contents" => content})));
}
let tree = dictionary! {
"Type" => "Pages", "Kids" => kids, "Count" => pages.len() as i64, "Resources" => resources,
"MediaBox" => vec![0.into(), 0.into(), 595.into(), 842.into()],
};
doc.objects.insert(pages_id, Object::Dictionary(tree));
let catalog = doc.add_object(dictionary! {"Type" => "Catalog", "Pages" => pages_id});
doc.trailer.set("Root", catalog);
let mut out = Vec::new();
doc.save_to(&mut out).unwrap();
out
}
/// An agent that takes no documents gets the PDF's text, with its page count and a note that
/// the layout was dropped; one that takes documents gets the PDF and not the text.
#[test]
fn a_pdf_gives_its_text_to_agents_without_documents() {
let d = dir("pdftext");
let path = d.join("brief.pdf");
std::fs::write(&path, sample_pdf(&["Wall 2 mm", "Hole 5 mm"])).unwrap();
let a = read(&path).unwrap();
let Content::Pdf { text: Some(t), .. } = &a.content else { panic!("a PDF with its text: {:?}", a.content) };
assert_eq!(t.pages, 2);
assert!(!t.cut && t.text.contains("Wall 2 mm") && t.text.contains("Hole 5 mm"), "{:?}", t.text);
assert!(t.text.find("Wall").unwrap() < t.text.find("Hole").unwrap(), "pages in order");
let files = vec![a];
let n = note(&files, false, false, usize::MAX);
assert!(n.contains("Attached PDF `brief.pdf` (2 pages"), "{n}");
assert!(n.contains("layout, pictures and fonts are dropped") && n.contains("Wall 2 mm"), "{n}");
let short = note(&files, false, false, 5);
assert!(short.contains("more characters left out"), "the text shares the message's budget: {short}");
let with = note(&files, false, true, usize::MAX);
assert!(with.contains("as a document") && !with.contains("Wall 2 mm"), "{with}");
let _ = std::fs::remove_dir_all(&d);
}
/// A PDF that is all pictures (a scan), or can't be read, says so and points to the file; the
/// text kept is capped.
#[test]
fn pdfs_without_text_or_too_long_are_said_so() {
let blank = pdf_text(&sample_pdf(&[""])).unwrap();
assert_eq!((blank.pages, blank.text.as_str()), (1, ""));
let a = Attachment {
id: 0,
name: "scan.pdf".into(),
content: Content::Pdf { bytes: Arc::new(vec![1; 10]), path: "/a/scan.pdf".into(), text: Some(Arc::new(blank)) },
thumb: None,
};
assert!(note(&[a], false, false, usize::MAX).contains("no text could be taken from it"));
let broken = Attachment {
id: 0,
name: "x.pdf".into(),
content: Content::Pdf { bytes: Arc::new(vec![1; 10]), path: "/a/x.pdf".into(), text: None },
thumb: None,
};
assert!(note(&[broken], false, false, usize::MAX).contains("its text could not be read"));
let long = "a".repeat(MAX_PDF_CHARS + 5000);
let t = pdf_text(&sample_pdf(&[&long])).unwrap();
assert!(t.cut && t.text.chars().count() == MAX_PDF_CHARS, "{} characters", t.text.chars().count());
}
/// Hostile PDFs never panic: truncated and damaged copies of a real one are read or refused.
#[test]
fn damaged_pdfs_never_panic() {
let good = sample_pdf(&["Wall 2 mm", "Hole 5 mm"]);
assert!(pdf_text(&good).is_some());
assert!(pdf_text(b"").is_none() && pdf_text(b"%PDF-1.7\n").is_none() && pdf_text(b"not a pdf").is_none());
for cut in (0..good.len()).step_by(37) {
let _ = pdf_text(&good[..cut]);
}
let mut seed = 0x9e37_79b9_u32;
for _ in 0..200 {
let mut bad = good.clone();
for _ in 0..4 {
seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
let at = usize::try_from(seed >> 8).unwrap() % bad.len();
bad[at] = (seed & 0xff) as u8;
}
let _ = pdf_text(&bad);
}
}
}
+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),
+1 -14
View File
@@ -330,20 +330,7 @@ pub fn items(app: &SolveApp, target: &Target) -> Vec<Item> {
act(if app.session.visibility.hidden_bodies.contains(name) { "ui.show" } else { "ui.hide" }, "Show/Hide", "eye")
.with(json!({"bodies":[name]})),
],
Target::Body { name } => {
let mut v = body_items(app, std::slice::from_ref(name), true);
// A selected browser body hands every selected body to the mesh dialogs (#12334).
// A row outside the selection still opens them with only the body right-clicked.
let selected = sel_bodies(app);
if selected.len() > 1 && selected.contains(name) {
for item in &mut v {
if item.id == "ui.saveMesh" || item.id == "ui.sendToSlicer" {
item.params = Some(json!({ "bodies": selected }));
}
}
}
v
}
Target::Body { name } => body_items(app, std::slice::from_ref(name), true),
Target::Sketch { id } => sketch_items(app, *id),
Target::Feature { id } => feature_items(app, *id),
Target::Plane { id } => plane_items(app, *id),
+274 -218
View File
@@ -4,11 +4,13 @@
use std::collections::HashMap;
use egui::vec2;
use solidcraft_engine::Session;
use solidcraft_engine::render::{Camera, StandardView};
use crate::SolveApp;
use crate::start_page;
use crate::ui_kit::{self, FileView, RecoveryMessage};
use solidcraft_theme::Tokens;
/// Recent designs kept.
pub const MAX_RECENT: usize = 12;
@@ -620,13 +622,7 @@ enum FileAction {
Shortcuts,
Crashes,
About,
/// Recover (true) or discard the recoverable design at this index.
Recovery(usize, bool),
/// The recent design at this index in `HomeState::recent`.
OpenRecent(usize),
ForgetRecent(usize),
ShowInFolder(usize),
Sample(usize),
Recovery(solidcraft_engine::recovery::Entry, bool),
}
/// File view replaces the workspace while keeping the open design and its history intact.
@@ -676,6 +672,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
return;
}
}
let t = Tokens::get();
let samples = solidcraft_engine::sample::samples();
for i in 0..samples.len() {
let key = format!("sample:{i}");
@@ -698,174 +695,246 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
want(&mut app.home, path);
}
}
let page = start_page::Page {
id: "home",
name: app.tr("ui.start_page").into(),
interactive,
back: start_page::Back {
template: app.tr("home.back").into(),
document: app.session.doc.name.as_str().into(),
shortcut: "Esc".into(),
chosen: FileAction::Back,
},
rail: rail(app),
title: match app.home.section {
Section::Start => app.tr_label("Start").into(),
Section::Recovery => app.tr_label("Recover").into(),
Section::Export => app.tr("home.export").into(),
},
hint: app
.tr(match app.home.section {
Section::Start => "home.start_hint",
Section::Recovery => "home.recovery_hint",
Section::Export => "home.export_hint",
})
.into(),
error: app.status.as_ref().filter(|(_, _, error)| *error).map(|(message, _, _)| message.as_str().into()),
body: match app.home.section {
Section::Start => start_page::Body::Start(Box::new(start_body(app))),
Section::Recovery => start_page::Body::Reviews(recovery_body(app)),
Section::Export => start_page::Body::Tiles(export_tiles(app)),
},
};
if let Some(action) = start_page::show(ui, page) {
let mut action = None;
FileView::page(ui, |ui| {
ui.add_enabled_ui(interactive, |ui| {
let full = ui.available_rect_before_wrap();
crate::scenario::publish_rect("home:page", full);
let narrow = full.width() < t.file_nav_width + FileView::tile_min_width() + t.file_content_inset_x * 2.0;
if narrow {
FileView::navigation(ui, |ui| {
ui_kit::dropdown_menu(ui, "home_navigation", app.tr("ui.start_page"), |ui| {
ui.set_min_width(t.file_nav_width);
FileView::navigation(ui, |ui| {
navigation(app, ui, &mut action);
if action.is_some() {
ui.close();
}
});
});
});
content(app, ui, &mut action, true);
} else {
let rail = egui::Rect::from_min_size(full.min, vec2(t.file_nav_width, full.height()));
FileView::rail(ui, rail, |ui| {
egui::ScrollArea::vertical().id_salt("home_navigation").auto_shrink([false, false]).show(ui, |ui| {
navigation(app, ui, &mut action);
});
});
let body = egui::Rect::from_min_max(egui::pos2(rail.right(), full.top()), full.max);
ui.scope_builder(egui::UiBuilder::new().max_rect(body), |ui| content(app, ui, &mut action, false));
}
});
});
if let Some(action) = action {
dispatch(app, ui.ctx(), action);
}
}
/// SolidCraft's rail: Start, New, Open, Recover; the design's own entries; the app's.
fn rail(app: &SolveApp) -> Vec<start_page::RailItem<'_, FileAction>> {
fn navigation(app: &SolveApp, ui: &mut egui::Ui, action: &mut Option<FileAction>) {
let busy = app.file_picker_busy();
let design = !crate::documents::active_is_blank(app);
let response = FileView::back_to(ui, "home:back", app.tr("home.back"), &app.session.doc.name, "Esc");
publish("home:back", &response);
if response.clicked() {
*action = Some(FileAction::Back);
}
let primary = if cfg!(target_os = "macos") { "⌘" } else { "Ctrl+" };
let shift = if cfg!(target_os = "macos") { "⇧" } else { "Shift+" };
let recover: start_page::Label<'_> = if app.recoverable.is_empty() {
app.tr_label("Recover").into()
let recover = if app.recoverable.is_empty() {
app.tr_label("Recover").to_string()
} else {
format!("{} ({})", app.tr_label("Recover"), app.recoverable.len()).into()
format!("{} ({})", app.tr_label("Recover"), app.recoverable.len())
};
let nav: [(&str, start_page::Label<'_>, String, Option<Section>, bool, FileAction); 12] = [
("home:start", app.tr_label("Start").into(), String::new(), Some(Section::Start), true, FileAction::Section(Section::Start)),
("home:new", app.tr_label("New").into(), format!("{primary}N"), None, true, FileAction::New("preferred")),
("home:open", app.tr("ui.open").into(), format!("{primary}O"), None, true, FileAction::Open),
("home:recover", recover, String::new(), Some(Section::Recovery), true, FileAction::Section(Section::Recovery)),
("home:save", app.tr("ui.save").into(), format!("{primary}S"), None, design, FileAction::Save),
("home:save_as", app.tr("ui.save_as").into(), format!("{primary}{shift}S"), None, design, FileAction::SaveAs),
("home:export", app.tr("home.export").into(), String::new(), Some(Section::Export), design, FileAction::Section(Section::Export)),
("home:properties", app.tr("home.properties").into(), String::new(), None, design, FileAction::Properties),
("home:settings", app.tr("home.settings").into(), format!("{primary},"), None, true, FileAction::Settings),
("home:shortcuts", app.tr("home.shortcuts").into(), String::new(), None, true, FileAction::Shortcuts),
("home:crashes", app.tr("home.crashes").into(), String::new(), None, true, FileAction::Crashes),
("home:about", app.tr_label("About SolidCraft").into(), String::new(), None, true, FileAction::About),
let nav = [
("home:start", app.tr_label("Start"), String::new(), Some(Section::Start), true, FileAction::Section(Section::Start)),
("home:new", app.tr_label("New"), format!("{primary}N"), None, true, FileAction::New("preferred")),
("home:open", app.tr("ui.open"), format!("{primary}O"), None, true, FileAction::Open),
("home:recover", recover.as_str(), String::new(), Some(Section::Recovery), true, FileAction::Section(Section::Recovery)),
("home:save", app.tr("ui.save"), format!("{primary}S"), None, design, FileAction::Save),
("home:save_as", app.tr("ui.save_as"), format!("{primary}{shift}S"), None, design, FileAction::SaveAs),
("home:export", app.tr("home.export"), String::new(), Some(Section::Export), design, FileAction::Section(Section::Export)),
("home:properties", app.tr("home.properties"), String::new(), None, design, FileAction::Properties),
("home:settings", app.tr("home.settings"), format!("{primary},"), None, true, FileAction::Settings),
("home:shortcuts", app.tr("home.shortcuts"), String::new(), None, true, FileAction::Shortcuts),
("home:crashes", app.tr("home.crashes"), String::new(), None, true, FileAction::Crashes),
("home:about", app.tr_label("About SolidCraft"), String::new(), None, true, FileAction::About),
];
let mut rail = Vec::with_capacity(nav.len() + 2);
for (index, (id, label, shortcut, section, enabled, chosen)) in nav.into_iter().enumerate() {
for (index, (id, label, key, section, enabled, picked)) in nav.into_iter().enumerate() {
if index == 4 || index == 8 {
rail.push(start_page::RailItem::Separator);
FileView::separator(ui);
}
let response = FileView::nav_row(ui, id, label, &key, section == Some(app.home.section), enabled && !busy);
publish(id, &response);
if response.clicked() {
*action = Some(picked);
}
rail.push(start_page::RailItem::Entry(start_page::RailEntry {
id: id.into(),
label,
shortcut: shortcut.into(),
selected: section == Some(app.home.section),
enabled: enabled && !busy,
chosen,
}));
}
rail
}
fn start_body(app: &SolveApp) -> start_page::Start<'_, FileAction> {
let banner = app.recoverable.first().map(|entry| {
fn publish(id: &str, response: &egui::Response) {
crate::scenario::publish_handle(id, response.rect.center());
crate::scenario::publish_rect(id, response.rect);
}
fn content(app: &mut SolveApp, ui: &mut egui::Ui, action: &mut Option<FileAction>, narrow: bool) {
let t = Tokens::get();
FileView::content(ui, narrow, |ui| {
egui::ScrollArea::vertical().id_salt("home_content").auto_shrink([false, false]).show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 0.0;
let (title, hint) = match app.home.section {
Section::Start => (app.tr_label("Start"), app.tr("home.start_hint")),
Section::Recovery => (app.tr_label("Recover"), app.tr("home.recovery_hint")),
Section::Export => (app.tr("home.export"), app.tr("home.export_hint")),
};
FileView::title(ui, title);
ui.add_space(t.space_6);
FileView::subtitle(ui, hint);
ui.add_space(t.file_section_gap);
if let Some((message, _, true)) = &app.status {
ui_kit::text(ui, message, ui_kit::Text::Error("home_status"));
}
match app.home.section {
Section::Start => start_content(app, ui, action),
Section::Recovery => recovery_content(app, ui, action),
Section::Export => export_content(app, ui, action),
}
});
});
}
fn start_content(app: &mut SolveApp, ui: &mut egui::Ui, action: &mut Option<FileAction>) {
let t = Tokens::get();
if let Some(entry) = app.recoverable.first().cloned() {
let title = solidcraft_ui_text::format("home.recovery_count", &[("count", &app.recoverable.len().to_string())]);
let filename = entry
.path
.as_deref()
.and_then(|path| std::path::Path::new(path).file_name())
.map(|name| name.to_string_lossy())
.unwrap_or_else(|| entry.name.as_str().into());
let single = app.recoverable.len() == 1;
start_page::Banner {
title: solidcraft_ui_text::format("home.recovery_count", &[("count", &app.recoverable.len().to_string())]).into(),
before_filename: " · ".into(),
filename,
after_filename: format!(" · {}", crate::recovery_ui::ago(entry.saved_at)).into(),
recover: (app.tr_label("Recover").into(), if single { FileAction::Recovery(0, true) } else { FileAction::Section(Section::Recovery) }),
discard: single.then(|| (app.tr_label("Discard").into(), FileAction::Recovery(0, false))),
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| entry.name.clone());
let metadata = format!(" · {}", crate::recovery_ui::ago(entry.saved_at));
let (recover, discard) = FileView::recovery_banner(
ui,
"home:recovery",
RecoveryMessage { title: &title, before_filename: " · ", filename: &filename, after_filename: &metadata },
app.tr_label("Recover"),
(app.recoverable.len() == 1).then(|| app.tr_label("Discard")),
);
publish("home:recovery:recover", &recover);
if recover.clicked() {
*action =
Some(if app.recoverable.len() == 1 { FileAction::Recovery(entry.clone(), true) } else { FileAction::Section(Section::Recovery) });
}
});
if let Some(discard) = discard {
publish("home:recovery:discard", &discard);
if discard.clicked() {
*action = Some(FileAction::Recovery(entry, false));
}
}
ui.add_space(t.file_section_gap);
}
let shortcut = if cfg!(target_os = "macos") { "⌘O" } else { "Ctrl+O" };
let open_hint = solidcraft_ui_text::format("home.open_hint", &[("shortcut", shortcut)]);
let tiles = [
("home:new_mm", "new", app.tr("home.new_mm"), start_page::Label::from(app.tr("home.new_mm_hint")), true, FileAction::New("mm")),
("home:new_inches", "new", app.tr("home.new_inches"), app.tr("home.new_inches_hint").into(), false, FileAction::New("in")),
("home:open_tile", "open", app.tr("home.open"), open_hint.into(), false, FileAction::Open),
("home:import", "import", app.tr("home.import"), app.tr("home.import_hint").into(), false, FileAction::Import),
]
.into_iter()
.map(|(id, icon, title, hint, primary, chosen)| start_page::Tile {
id: id.into(),
icon: start_page::Icon::Kit(icon),
title: title.into(),
hint,
primary,
chosen,
})
.collect();
let recent = app.home.recent.iter().enumerate().map(|(index, path)| recent_entry(app, index, path)).collect();
let samples = solidcraft_engine::sample::samples()
.iter()
.enumerate()
.map(|(index, sample)| start_page::Sample {
title: app.tr_label(sample.name).into(),
description: (if app.home.wanted == Some(index) { app.tr("home.opening") } else { app.tr_label(sample.about) }).into(),
icon: start_page::Icon::Kit("box"),
thumbnail: texture(app.home.thumbs.get(&format!("sample:{index}")).and_then(Option::as_ref)),
chosen: FileAction::Sample(index),
})
.collect();
start_page::Start {
banner,
actions_title: app.tr_label("New").into(),
actions: start_page::Tiles { grid: "home_new", tiles },
recent_title: app.tr("home.recent").into(),
recent_empty: app.tr("home.recent_empty").into(),
recent,
samples_title: app.tr("home.samples").into(),
samples,
}
}
fn recent_entry<'a>(app: &'a SolveApp, index: usize, path: &'a str) -> start_page::Recent<'a, FileAction> {
// Keep the extension so plate.step and plate.stl are not both "plate" (#9081).
let title = std::path::Path::new(path).file_name().map_or_else(|| path.into(), |name| name.to_string_lossy());
let missing = !available(app, path);
let when = if app.session.path.as_deref() == Some(path) { app.tr("home.open_now").to_string() } else { recent_time(app, path) };
let shown_path: start_page::Label<'a> = if missing {
format!("{} · {}", app.tr("home.missing"), friendly_path(path)).into()
} else if path.starts_with("/disk/") {
app.tr_label("On your computer").into()
} else if path.starts_with("/opfs/") {
app.tr("home.in_this_browser").into()
} else {
friendly_path(path).into()
};
let mut menu =
vec![start_page::MenuEntry { label: app.tr_label("Remove from list").into(), icon: "delete", chosen: FileAction::ForgetRecent(index) }];
if !cfg!(target_arch = "wasm32") {
menu.push(start_page::MenuEntry { label: app.tr_label("Show in folder").into(), icon: "folder", chosen: FileAction::ShowInFolder(index) });
}
start_page::Recent {
title,
path: shown_path,
when: when.into(),
kind: recent_kind(app, path).into(),
thumbnail: texture(app.home.thumbs.get(path).and_then(Option::as_ref)),
// A design whose picture could not be made shows a dimmed placeholder, distinct from the
// one shown while a picture is still loading (#4655).
thumbnail_failed: app.home.thumb_failed(path),
chosen: FileAction::OpenRecent(index),
menu,
}
("home:new_mm", "new", app.tr("home.new_mm"), app.tr("home.new_mm_hint"), true),
("home:new_inches", "new", app.tr("home.new_inches"), app.tr("home.new_inches_hint"), false),
("home:open_tile", "open", app.tr("home.open"), open_hint.as_str(), false),
("home:import", "import", app.tr("home.import"), app.tr("home.import_hint"), false),
];
FileView::section(ui, app.tr_label("New"), t.space_12, |ui| {
FileView::grid(ui, "home_new", FileView::tile_min_width(), tiles.len(), |ui, index, width| {
let Some((id, icon, title, hint, primary)) = tiles.get(index) else { return };
let response = FileView::action_tile(ui, id, width, icon, title, hint, *primary);
publish(id, &response);
if response.clicked() {
*action = Some(match index {
0 => FileAction::New("mm"),
1 => FileAction::New("in"),
2 => FileAction::Open,
_ => FileAction::Import,
});
}
});
});
ui.add_space(t.file_section_gap);
FileView::section(ui, app.tr("home.recent"), t.space_10, |ui| {
if app.home.recent.is_empty() {
FileView::detail(ui, app.tr("home.recent_empty"));
}
if !app.home.recent.is_empty() {
FileView::recent_list(ui, |ui| {
for (index, path) in app.home.recent.clone().iter().enumerate() {
// Keep the extension so plate.step and plate.stl are not both "plate" (#9081).
let name = std::path::Path::new(path).file_name().map(|name| name.to_string_lossy()).unwrap_or_else(|| path.as_str().into());
let missing = !available(app, path);
let when = if app.session.path.as_ref() == Some(path) { app.tr("home.open_now").to_string() } else { recent_time(app, path) };
let shown_path = if missing {
format!("{} · {}", app.tr("home.missing"), friendly_path(path))
} else if path.starts_with("/disk/") {
app.tr_label("On your computer").to_string()
} else if path.starts_with("/opfs/") {
app.tr("home.in_this_browser").to_string()
} else {
friendly_path(path)
};
let kind = recent_kind(app, path);
let response = FileView::recent_row(
ui,
&format!("home:recent:{index}"),
ui_kit::RecentRow {
title: &name,
path: &shown_path,
when: &when,
workspace: &kind,
thumbnail: texture(app.home.thumbs.get(path).and_then(Option::as_ref)),
// A design whose picture could not be made shows a dimmed placeholder,
// distinct from the one shown while a picture is still loading (#4655).
thumbnail_failed: app.home.thumb_failed(path),
},
);
publish(&format!("home:recent:{index}"), &response);
ui_kit::Menu::context(&response, |ui| {
if crate::context_menu::button(ui, app.tr_label("Remove from list"), "delete").clicked() {
app.home.forget(path);
ui.close();
}
if !cfg!(target_arch = "wasm32") && crate::context_menu::button(ui, app.tr_label("Show in folder"), "folder").clicked() {
if let Some(show) = &app.services.show_in_folder {
show(path);
}
ui.close();
}
});
if response.clicked() {
open_recent(app, path);
}
}
});
}
});
ui.add_space(t.file_section_gap);
FileView::section(ui, app.tr("home.samples"), t.space_10, |ui| {
let samples: Vec<_> = solidcraft_engine::sample::samples()
.iter()
.enumerate()
.map(|(index, sample)| {
let hint = if app.home.wanted == Some(index) { app.tr("home.opening") } else { app.tr_label(sample.about) };
ui_kit::SampleCard {
title: app.tr_label(sample.name),
description: hint,
icon: "box",
thumbnail: texture(app.home.thumbs.get(&format!("sample:{index}")).and_then(Option::as_ref)),
}
})
.collect();
for (index, response) in FileView::sample_grid(ui, "home:sample", &samples).into_iter().enumerate() {
publish(&format!("home:sample:{index}"), &response);
if response.clicked() {
open_sample(app, index);
}
}
});
}
fn texture(texture: Option<&egui::TextureHandle>) -> Option<(egui::TextureId, egui::Vec2)> {
@@ -938,55 +1007,62 @@ fn recent_time_at(app: &SolveApp, time: u64, now: u64) -> String {
solidcraft_ui_text::format(key, &[("count", &count.to_string())])
}
fn recovery_body(app: &SolveApp) -> start_page::Reviews<'_, FileAction> {
let items = app
.recoverable
.iter()
.take(20)
.enumerate()
.map(|(index, entry)| start_page::Review {
title: entry.name.as_str().into(),
detail: solidcraft_ui_text::format(
"recovery.detail",
&[
("file", entry.path.as_deref().unwrap_or(app.tr("recovery.never_saved"))),
("features", &entry.features.to_string()),
("when", &crate::recovery_ui::ago(entry.saved_at)),
],
)
.into(),
recover: (app.tr_label("Recover").into(), FileAction::Recovery(index, true)),
discard: (app.tr_label("Discard").into(), FileAction::Recovery(index, false)),
})
.collect();
start_page::Reviews { empty: app.tr("home.recovery_empty").into(), items }
fn recovery_content(app: &SolveApp, ui: &mut egui::Ui, action: &mut Option<FileAction>) {
if app.recoverable.is_empty() {
ui_kit::text(ui, app.tr("home.recovery_empty"), ui_kit::Text::Inline);
}
for (index, entry) in app.recoverable.iter().take(20).enumerate() {
let detail = solidcraft_ui_text::format(
"recovery.detail",
&[
("file", entry.path.as_deref().unwrap_or(app.tr("recovery.never_saved"))),
("features", &entry.features.to_string()),
("when", &crate::recovery_ui::ago(entry.saved_at)),
],
);
let (recover, discard) =
FileView::recovery_review(ui, &format!("home:recovery:{index}"), &entry.name, &detail, app.tr_label("Recover"), app.tr_label("Discard"));
for (id, response, recover) in [("recover", Some(recover), true), ("discard", discard, false)] {
let Some(response) = response else { continue };
publish(&format!("home:recovery:{index}:{id}"), &response);
if response.clicked() {
*action = Some(FileAction::Recovery(entry.clone(), recover));
}
}
ui.add_space(Tokens::get().space_12);
}
}
fn export_tiles(app: &SolveApp) -> start_page::Tiles<'_, FileAction> {
fn export_content(app: &SolveApp, ui: &mut egui::Ui, action: &mut Option<FileAction>) {
let formats = [
("step", "STEP", Some("home.export_step_hint"), FileAction::Export("step")),
("mesh", "Mesh", Some("home.export_mesh_hint"), FileAction::Mesh),
("view_dxf", "DXF", None, FileAction::ViewDxf),
("obj", "OBJ", None, FileAction::Export("obj")),
("urdf", "URDF", None, FileAction::Export("urdf")),
("json", "JSON", Some("home.export_json_hint"), FileAction::Export("json")),
("step", "STEP", Some("home.export_step_hint")),
("mesh", "Mesh", Some("home.export_mesh_hint")),
("view_dxf", "DXF", None),
("obj", "OBJ", None),
("urdf", "URDF", None),
("json", "JSON", Some("home.export_json_hint")),
];
let tiles = formats
.into_iter()
.map(|(id, format, hint, chosen)| start_page::Tile {
id: format!("home:export:{id}").into(),
icon: start_page::Icon::Kit("export"),
title: if id == "view_dxf" {
app.tr_label("Export Current View as DXF…").into()
} else {
format!("{} {}…", app.tr("home.export"), app.tr_label(format)).into()
},
hint: hint.map_or(format, |key| app.tr(key)).into(),
primary: false,
chosen,
})
.collect();
start_page::Tiles { grid: "home_exports", tiles }
FileView::grid(ui, "home_exports", FileView::tile_min_width(), formats.len(), |ui, index, width| {
let Some((id, format, hint)) = formats.get(index) else { return };
let label = if *id == "view_dxf" {
app.tr_label("Export Current View as DXF…").to_string()
} else {
format!("{} {}…", app.tr("home.export"), app.tr_label(format))
};
let subtitle = hint.map(|key| app.tr(key)).unwrap_or(format);
let response = FileView::action_tile(ui, &format!("home:export:{id}"), width, "export", &label, subtitle, false);
publish(&format!("home:export:{id}"), &response);
if response.clicked() {
*action = Some(match *id {
"step" => FileAction::Export("step"),
"mesh" => FileAction::Mesh,
"view_dxf" => FileAction::ViewDxf,
"obj" => FileAction::Export("obj"),
"urdf" => FileAction::Export("urdf"),
_ => FileAction::Export("json"),
});
}
});
}
fn dispatch(app: &mut SolveApp, ctx: &egui::Context, action: FileAction) {
@@ -1050,27 +1126,7 @@ fn dispatch(app: &mut SolveApp, ctx: &egui::Context, action: FileAction) {
}
FileAction::Crashes => crate::help::run(app, ctx, "crashes"),
FileAction::About => crate::help::run(app, ctx, "about"),
FileAction::Recovery(index, recover) => {
if let Some(entry) = app.recoverable.get(index).cloned() {
crate::recovery_ui::apply(app, entry, recover);
}
}
FileAction::OpenRecent(index) => {
if let Some(path) = app.home.recent.get(index).cloned() {
open_recent(app, &path);
}
}
FileAction::ForgetRecent(index) => {
if let Some(path) = app.home.recent.get(index).cloned() {
app.home.forget(&path);
}
}
FileAction::ShowInFolder(index) => {
if let (Some(show), Some(path)) = (&app.services.show_in_folder, app.home.recent.get(index)) {
show(path);
}
}
FileAction::Sample(index) => open_sample(app, index),
FileAction::Recovery(entry, recover) => crate::recovery_ui::apply(app, entry, recover),
}
}
+1 -37
View File
@@ -17,6 +17,7 @@ pub(crate) const DRAG_ZOOM: &str = "view.tool.zoom";
/// Keys the application handles before any command shortcut.
pub const RESERVED: &[&str] = &[
"Escape",
"S",
"V",
"F2",
"F6",
@@ -1883,43 +1884,6 @@ mod tests {
h.frames(1);
assert!(h.app.ui.palette_open && h.app.dialog.is_none(), "typing in a field never repeats");
}
/// Command search is an ordinary command (#12190): S opens it out of the box, Preferences ›
/// Shortcuts can move it, and S is then free for another command.
#[test]
fn command_search_can_move_off_s_and_s_can_start_another_command() {
let mut h = crate::scenario::Harness::new();
h.app.home.open = false;
h.frames(2);
assert_eq!(effective(&h.app, "ui.palette").as_deref(), Some("S"));
h.step(&json!({"key": "S"})).unwrap();
assert!(h.app.ui.palette_open, "S opens command search by default");
h.call("ui.palette", json!({"open": false}));
h.frames(2);
bind(&mut h.app, "ui.palette", "Shift+J", true).unwrap();
h.step(&json!({"key": "S"})).unwrap();
assert!(!h.app.ui.palette_open, "S no longer opens command search");
h.step(&json!({"key": "J", "shift": true})).unwrap();
assert!(h.app.ui.palette_open, "its new key does");
h.call("ui.palette", json!({"open": false}));
h.frames(2);
// The closed field shows the key command search has now, and no key once it has none.
let width = || crate::scenario::rect_of("ribbon:search").map(|r| r.width());
let shown = display_for(&h.app, "ui.palette");
assert_eq!(shown.as_deref(), Some("Shift+J"));
assert_eq!(width(), Some(crate::ui_kit::CommandSearch::closed_width(&h.ctx, shown.as_deref())));
assert!(width() > Some(crate::ui_kit::CommandSearch::closed_width(&h.ctx, Some("S"))), "not the old S");
bind(&mut h.app, "ui.palette", "", false).unwrap();
h.frames(2);
assert_eq!(width(), Some(crate::ui_kit::CommandSearch::closed_width(&h.ctx, None)));
bind(&mut h.app, "ui.palette", "Shift+J", true).unwrap();
h.frames(2);
bind(&mut h.app, "solid.box", "S", true).unwrap();
h.step(&json!({"key": "S"})).unwrap();
h.frames(1);
assert_eq!(h.app.dialog.as_ref().and_then(|d| d.command()), Some("solid.box"), "S starts the command given it");
assert!(!h.app.ui.palette_open);
}
}
#[cfg(test)]
-3
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;
@@ -293,7 +292,6 @@ pub mod space_mouse_calibration;
pub mod space_mouse_model;
pub mod space_mouse_offer;
pub mod stand_up;
pub mod start_page;
mod status_bar;
#[cfg(test)]
mod sweep_dialog_tests;
@@ -320,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;
-26
View File
@@ -1821,32 +1821,6 @@ fn appearance_browser_selection_reaches_the_editor_without_other_bodies() {
}
}
/// #12334: Save As Mesh and Send to Slicer on a selected browser row open their dialog with every
/// selected body; a row outside the selection, or a lone selected one, opens it with that row only.
#[test]
fn mesh_export_from_a_selected_browser_body_takes_the_selected_bodies() {
let cases = [
("One", vec!["One", "Two"], vec!["One", "Two"]),
("Two", vec!["One", "Two"], vec!["One", "Two"]),
("Other", vec!["One", "Two"], vec!["Other"]),
("One", vec!["One"], vec!["One"]),
];
for label in ["Send to Slicer", "Save As Mesh"] {
for (clicked, selected, expected) in &cases {
let mut app = appearance_selection_app();
// Send to Slicer needs a way to start a program.
app.services.pick_program = Some(Box::new(|| Box::pin(async { None })));
let items: Vec<_> = selected.iter().map(|name| json!({"type":"body","name":name})).collect();
app.run("select.set", json!({ "items": items })).unwrap();
let item = find(&context_menu::items(&app, &Target::Body { name: (*clicked).into() }), label);
context_menu::run_item(&mut app, &item, pos2(0.0, 0.0));
let dialog = app.dialog.as_ref().unwrap_or_else(|| panic!("{label} opens its dialog"));
let bodies: Vec<_> = expected.iter().map(|name| solidcraft_engine::Sel::Body { name: (*name).into() }).collect();
assert_eq!(dialog.inputs[0].items, bodies, "{label}: clicked {clicked}, selected {selected:?}");
}
}
}
#[test]
fn appearance_parent_click_opens_editor_after_hovering_quick_choices() {
use crate::scenario::{Harness, rect_of};
-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
-56
View File
@@ -7326,62 +7326,6 @@ mod tests {
}
}
/// #11445: the theme row opens on the theme in use each time Preferences or its page shows it,
/// and the arrows still move it freely while it stays in view.
#[test]
fn theme_row_opens_on_the_theme_in_use_each_time_it_comes_into_view() {
let mut h = crate::scenario::Harness::new();
h.app.home.open = false;
h.call("ui.resize", json!({"width":1600,"height":1100}));
h.call("ui.prefs", json!({"theme":"dark","language":"en","open":"Appearance"}));
h.frames(4);
let count = theme_choices(&h.app).len();
let page_back = |h: &mut crate::scenario::Harness| {
for _ in 0..=count {
if crate::scenario::rect_of("prefs:theme_previous").is_none() {
return;
}
h.step(&json!({"click":{"handle":"prefs:theme_previous"}})).unwrap();
h.frames(3);
}
panic!("the previous arrow never reached the start: the row is held in place");
};
page_back(&mut h);
// A theme the row does not show at its start.
let far =
theme_choices(&h.app).into_iter().map(|(id, _)| id).find(|id| crate::scenario::rect_of(&format!("prefs:theme:{id}")).is_none()).unwrap();
let shows = |id: &str| {
let viewport = crate::scenario::rect_of("prefs:theme_viewport").unwrap();
crate::scenario::rect_of(&format!("prefs:theme:{id}")).is_some_and(|rect| viewport.expand(1.0).contains_rect(rect))
};
h.call("ui.prefs", json!({"theme":far}));
h.frames(4);
assert!(shows(&far), "a theme chosen elsewhere comes into view: {far}");
page_back(&mut h);
assert!(!shows(&far), "the arrows move the row away from the theme in use");
h.call("ui.prefs", json!({"button":"ok"}));
h.frames(3);
h.call("ui.prefs", json!({"open":"Appearance"}));
h.frames(4);
assert!(shows(&far), "reopening Preferences shows the theme in use: {far}");
page_back(&mut h);
h.frames(4);
assert!(!shows(&far), "the row stays where the arrows leave it");
h.call("ui.prefs", json!({"open":"General"}));
h.frames(3);
h.call("ui.prefs", json!({"open":"Appearance"}));
h.frames(4);
assert!(shows(&far), "coming back to the page shows the theme in use: {far}");
page_back(&mut h);
h.app.prefs_window.filter = "Onyx".to_owned();
h.frames(5);
h.app.prefs_window.filter.clear();
h.frames(5);
assert!(shows(&far), "clearing a search shows the theme in use: {far}");
assert_eq!(h.app.preferences.theme, far, "showing a theme never chooses one");
h.call("ui.prefs", json!({"button":"cancel"}));
}
#[test]
fn appearance_controls_edit_real_fields_and_cancel_restores_them() {
let mut h = crate::scenario::Harness::new();
+1 -11
View File
@@ -304,17 +304,7 @@ fn row_contents(app: &mut SolveApp, ui: &mut egui::Ui) {
let selected_index = visible.iter().position(|e| e.id == reveal);
let identity = format!("{reveal}:{:?}:{:?}:{}:{}", state.tone, state.high, state.imported, state.grid);
let reveal_key = ui.make_persistent_id("theme_picker_reveal");
// The theme in use also comes into view each time the picker does (#11445): Preferences
// opens, or its page or a cleared search shows it again. Only then: the arrows, the wheel and
// a drag move the row freely while it stays in view.
let shown_key = ui.make_persistent_id("theme_picker_shown");
let pass = ui.ctx().cumulative_pass_nr();
let returning = ui.data_mut(|d| {
let returning = d.get_temp::<u64>(shown_key).is_none_or(|last| last.saturating_add(1) < pass);
d.insert_temp(shown_key, pass);
returning
});
let changed = returning || ui.data(|d| d.get_temp::<String>(reveal_key)).as_ref() != Some(&identity);
let changed = ui.data(|d| d.get_temp::<String>(reveal_key)).as_ref() != Some(&identity);
let mut chosen = None;
let mut card = |ui: &mut egui::Ui, index: usize, width: f32| {
let Some(e) = visible.get(index) else { return };
+1 -3
View File
@@ -455,7 +455,6 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
ui_kit::scope(ui, |ui| {
ui.spacing_mut().item_spacing.y = 0.0;
let invalid = typed(&query).is_some_and(|(_, result)| result.is_err());
let key = crate::keymap::display_for(app, "ui.palette");
let field = ui_kit::CommandSearch::field(
ui,
&mut query,
@@ -467,7 +466,6 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
progress,
radius: ui_kit::CommandSearch::field_radius(progress, ui_kit::Container::new(ctx, ui_kit::Surface::Chrome).floating()),
},
key.as_deref(),
MAX_QUERY,
);
if !open && progress == 0.0 {
@@ -1098,7 +1096,7 @@ mod tests {
let toolbar = crate::scenario::rect_of("toolbar").unwrap();
assert_eq!(crate::toolbar::TAB_ROW_HEIGHT, 30.0);
assert_eq!(crate::toolbar::RIBBON_HEIGHT, 117.0);
assert_eq!(closed.size(), vec2(ui_kit::CommandSearch::closed_width(&h.ctx, Some("S")), 22.0));
assert_eq!(closed.size(), vec2(ui_kit::CommandSearch::closed_width(&h.ctx), 22.0));
assert_eq!(closed.right(), toolbar.right() - 4.0);
assert_eq!(closed.top(), toolbar.top() + 4.0);
assert_eq!(closed.center().y, toolbar.top() + 15.0);
-560
View File
@@ -1,560 +0,0 @@
//! The start page as a component: a rail of entries beside a titled body (action tiles, the
//! recent files and the samples, or a section of the host's tiles or reviews). The host gives
//! every label, icon, thumbnail and what each entry stands for (`A`); [`show`] draws the page and
//! returns the entry chosen this frame, which the host runs. Nothing here knows the app.
//!
//! Tiles and samples take a host [`Icon`] (a kit icon name, a texture or the host's painter);
//! menu entries take a kit icon name. Thumbnails are textures the host made.
use std::borrow::Cow;
use egui::vec2;
pub use crate::ui_kit::Icon;
use crate::ui_kit::{self, FileView, RecoveryMessage};
use solidcraft_theme::Tokens;
/// Text the host gives: borrowed when it has it, owned when it formatted it this frame.
pub type Label<'a> = Cow<'a, str>;
/// The whole page. `id` names it (`{id}:page`, `{id}:back`, `{id}:recent:N`, `{id}:sample:N`…).
pub struct Page<'a, A> {
pub id: &'a str,
/// The narrow layout's menu title, standing for the rail.
pub name: Label<'a>,
/// Whether the page takes input (not while a file picker or a load is up).
pub interactive: bool,
pub back: Back<'a, A>,
pub rail: Vec<RailItem<'a, A>>,
pub title: Label<'a>,
pub hint: Label<'a>,
/// An error shown under the hint.
pub error: Option<Label<'a>>,
pub body: Body<'a, A>,
}
/// The rail's first row, back to the open document.
pub struct Back<'a, A> {
/// The label, with `{name}` for the document's name.
pub template: Label<'a>,
pub document: Label<'a>,
pub shortcut: Label<'a>,
pub chosen: A,
}
pub enum RailItem<'a, A> {
Entry(RailEntry<'a, A>),
Separator,
}
pub struct RailEntry<'a, A> {
pub id: Label<'a>,
pub label: Label<'a>,
/// The key hint drawn at the row's end (empty for none).
pub shortcut: Label<'a>,
pub selected: bool,
pub enabled: bool,
pub chosen: A,
}
pub enum Body<'a, A> {
/// Boxed: the start section is far larger than the other bodies.
Start(Box<Start<'a, A>>),
Tiles(Tiles<'a, A>),
Reviews(Reviews<'a, A>),
}
/// The start section: an optional recovery banner, the action tiles, the recent files and the
/// samples.
pub struct Start<'a, A> {
pub banner: Option<Banner<'a, A>>,
pub actions_title: Label<'a>,
pub actions: Tiles<'a, A>,
pub recent_title: Label<'a>,
/// Shown when there are no recent files.
pub recent_empty: Label<'a>,
pub recent: Vec<Recent<'a, A>>,
pub samples_title: Label<'a>,
pub samples: Vec<Sample<'a, A>>,
}
/// A grid of action tiles; `grid` keeps its widgets' state apart from other grids.
pub struct Tiles<'a, A> {
pub grid: &'a str,
pub tiles: Vec<Tile<'a, A>>,
}
pub struct Tile<'a, A> {
pub id: Label<'a>,
pub icon: Icon<'a>,
pub title: Label<'a>,
pub hint: Label<'a>,
/// Drawn as the page's main action.
pub primary: bool,
pub chosen: A,
}
pub struct Banner<'a, A> {
pub title: Label<'a>,
pub before_filename: Label<'a>,
pub filename: Label<'a>,
pub after_filename: Label<'a>,
pub recover: (Label<'a>, A),
pub discard: Option<(Label<'a>, A)>,
}
pub struct Recent<'a, A> {
pub title: Label<'a>,
pub path: Label<'a>,
pub when: Label<'a>,
pub kind: Label<'a>,
pub thumbnail: Option<(egui::TextureId, egui::Vec2)>,
/// No picture could be made: a dimmed placeholder rather than a loading one.
pub thumbnail_failed: bool,
pub chosen: A,
/// The row's context menu.
pub menu: Vec<MenuEntry<'a, A>>,
}
pub struct MenuEntry<'a, A> {
pub label: Label<'a>,
/// A kit icon name: the menu row is a `ui_kit::MenuItem`, which takes a host `Icon` with #13379.
pub icon: &'a str,
pub chosen: A,
}
pub struct Sample<'a, A> {
pub title: Label<'a>,
pub description: Label<'a>,
pub icon: Icon<'a>,
pub thumbnail: Option<(egui::TextureId, egui::Vec2)>,
pub chosen: A,
}
/// A list of entries to review (the recoverable designs), each with a keep and a drop action.
pub struct Reviews<'a, A> {
/// Shown when the list is empty.
pub empty: Label<'a>,
pub items: Vec<Review<'a, A>>,
}
pub struct Review<'a, A> {
pub title: Label<'a>,
pub detail: Label<'a>,
pub recover: (Label<'a>, A),
pub discard: (Label<'a>, A),
}
/// Draw the page; the entry chosen this frame, if any.
pub fn show<A>(ui: &mut egui::Ui, page: Page<'_, A>) -> Option<A> {
let t = Tokens::get();
let Page { id, name, interactive, back, rail, title, hint, error, body } = page;
let mut chosen = None;
let mut rail = Some((back, rail));
let mut body = Some(body);
FileView::page(ui, |ui| {
ui.add_enabled_ui(interactive, |ui| {
let full = ui.available_rect_before_wrap();
solidcraft_ui_registry::publish_rect(&format!("{id}:page"), full);
let narrow = full.width() < t.file_nav_width + FileView::tile_min_width() + t.file_content_inset_x * 2.0;
let navigation_id = format!("{id}_navigation");
if narrow {
FileView::navigation(ui, |ui| {
ui_kit::dropdown_menu(ui, navigation_id.as_str(), &name, |ui| {
ui.set_min_width(t.file_nav_width);
FileView::navigation(ui, |ui| {
if let Some((back, rail)) = rail.take() {
navigation(ui, id, back, rail, &mut chosen);
}
if chosen.is_some() {
ui.close();
}
});
});
});
if let Some(body) = body.take() {
content(ui, id, (title.as_ref(), hint.as_ref(), error.as_deref()), body, true, &mut chosen);
}
} else {
let rail_rect = egui::Rect::from_min_size(full.min, vec2(t.file_nav_width, full.height()));
FileView::rail(ui, rail_rect, |ui| {
egui::ScrollArea::vertical().id_salt(navigation_id.as_str()).auto_shrink([false, false]).show(ui, |ui| {
if let Some((back, rail)) = rail.take() {
navigation(ui, id, back, rail, &mut chosen);
}
});
});
let body_rect = egui::Rect::from_min_max(egui::pos2(rail_rect.right(), full.top()), full.max);
ui.scope_builder(egui::UiBuilder::new().max_rect(body_rect), |ui| {
if let Some(body) = body.take() {
content(ui, id, (title.as_ref(), hint.as_ref(), error.as_deref()), body, false, &mut chosen);
}
});
}
});
});
chosen
}
fn navigation<A>(ui: &mut egui::Ui, id: &str, back: Back<'_, A>, rail: Vec<RailItem<'_, A>>, chosen: &mut Option<A>) {
let back_id = format!("{id}:back");
let response = FileView::back_to(ui, &back_id, &back.template, &back.document, &back.shortcut);
publish(&back_id, &response);
if response.clicked() {
*chosen = Some(back.chosen);
}
for item in rail {
match item {
RailItem::Separator => FileView::separator(ui),
RailItem::Entry(entry) => {
let response = FileView::nav_row(ui, &entry.id, &entry.label, &entry.shortcut, entry.selected, entry.enabled);
publish(&entry.id, &response);
if response.clicked() {
*chosen = Some(entry.chosen);
}
}
}
}
}
fn publish(id: &str, response: &egui::Response) {
solidcraft_ui_registry::publish_handle(id, response.rect.center());
solidcraft_ui_registry::publish_rect(id, response.rect);
}
fn content<A>(ui: &mut egui::Ui, id: &str, heading: (&str, &str, Option<&str>), body: Body<'_, A>, narrow: bool, chosen: &mut Option<A>) {
let t = Tokens::get();
let (title, hint, error) = heading;
let mut body = Some(body);
FileView::content(ui, narrow, |ui| {
egui::ScrollArea::vertical().id_salt(format!("{id}_content")).auto_shrink([false, false]).show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 0.0;
FileView::title(ui, title);
ui.add_space(t.space_6);
FileView::subtitle(ui, hint);
ui.add_space(t.file_section_gap);
if let Some(message) = error {
ui_kit::text(ui, message, ui_kit::Text::Error(&format!("{id}_status")));
}
match body.take() {
Some(Body::Start(start)) => start_content(ui, id, *start, chosen),
Some(Body::Tiles(tiles)) => tile_grid(ui, tiles, chosen),
Some(Body::Reviews(reviews)) => review_list(ui, id, reviews, chosen),
None => {}
}
});
});
}
fn tile_grid<A>(ui: &mut egui::Ui, tiles: Tiles<'_, A>, chosen: &mut Option<A>) {
let Tiles { grid, tiles } = tiles;
let mut tiles: Vec<Option<Tile<'_, A>>> = tiles.into_iter().map(Some).collect();
FileView::grid(ui, grid, FileView::tile_min_width(), tiles.len(), |ui, index, width| {
let Some(tile) = tiles.get_mut(index).and_then(Option::take) else { return };
let response = FileView::action_tile(ui, &tile.id, width, tile.icon, &tile.title, &tile.hint, tile.primary);
publish(&tile.id, &response);
if response.clicked() {
*chosen = Some(tile.chosen);
}
});
}
fn start_content<A>(ui: &mut egui::Ui, id: &str, start: Start<'_, A>, chosen: &mut Option<A>) {
let t = Tokens::get();
let Start { banner, actions_title, actions, recent_title, recent_empty, recent, samples_title, samples } = start;
if let Some(banner) = banner {
let banner_id = format!("{id}:recovery");
let message = RecoveryMessage {
title: &banner.title,
before_filename: &banner.before_filename,
filename: &banner.filename,
after_filename: &banner.after_filename,
};
let (recover_label, recover_chosen) = banner.recover;
let (discard_label, discard_chosen) = banner.discard.map_or((None, None), |(label, chosen)| (Some(label), Some(chosen)));
let (recover, discard) = FileView::recovery_banner(ui, &banner_id, message, &recover_label, discard_label.as_deref());
publish(&format!("{banner_id}:recover"), &recover);
if recover.clicked() {
*chosen = Some(recover_chosen);
}
if let (Some(discard), Some(discard_chosen)) = (discard, discard_chosen) {
publish(&format!("{banner_id}:discard"), &discard);
if discard.clicked() {
*chosen = Some(discard_chosen);
}
}
ui.add_space(t.file_section_gap);
}
FileView::section(ui, &actions_title, t.space_12, |ui| tile_grid(ui, actions, chosen));
ui.add_space(t.file_section_gap);
FileView::section(ui, &recent_title, t.space_10, |ui| {
if recent.is_empty() {
FileView::detail(ui, &recent_empty);
return;
}
FileView::recent_list(ui, |ui| {
for (index, row) in recent.into_iter().enumerate() {
let row_id = format!("{id}:recent:{index}");
let response = FileView::recent_row(
ui,
&row_id,
ui_kit::RecentRow {
title: &row.title,
path: &row.path,
when: &row.when,
workspace: &row.kind,
thumbnail: row.thumbnail,
thumbnail_failed: row.thumbnail_failed,
},
);
publish(&row_id, &response);
let mut menu = row.menu.into_iter().map(Some).collect::<Vec<_>>();
ui_kit::Menu::context(&response, |ui| {
for entry in &mut menu {
let picked =
entry.as_ref().is_some_and(|entry| ui_kit::MenuItem::new(&entry.label, &entry.label, entry.icon).show(ui).clicked());
if picked && let Some(entry) = entry.take() {
*chosen = Some(entry.chosen);
ui.close();
}
}
});
if response.clicked() {
*chosen = Some(row.chosen);
}
}
});
});
ui.add_space(t.file_section_gap);
FileView::section(ui, &samples_title, t.space_10, |ui| {
let (shown, picks): (Vec<_>, Vec<_>) =
samples.into_iter().map(|sample| ((sample.title, sample.description, sample.icon, sample.thumbnail), sample.chosen)).unzip();
let cards: Vec<_> = shown
.iter()
.map(|(title, description, icon, thumbnail)| ui_kit::SampleCard { title, description, icon: *icon, thumbnail: *thumbnail })
.collect();
let sample_id = format!("{id}:sample");
let responses = FileView::sample_grid(ui, &sample_id, &cards);
for ((index, response), pick) in responses.into_iter().enumerate().zip(picks) {
publish(&format!("{sample_id}:{index}"), &response);
if response.clicked() {
*chosen = Some(pick);
}
}
});
}
fn review_list<A>(ui: &mut egui::Ui, id: &str, reviews: Reviews<'_, A>, chosen: &mut Option<A>) {
if reviews.items.is_empty() {
ui_kit::text(ui, &reviews.empty, ui_kit::Text::Inline);
}
for (index, review) in reviews.items.into_iter().enumerate() {
let review_id = format!("{id}:recovery:{index}");
let (recover_label, recover_chosen) = review.recover;
let (discard_label, discard_chosen) = review.discard;
let (recover, discard) = FileView::recovery_review(ui, &review_id, &review.title, &review.detail, &recover_label, &discard_label);
publish(&format!("{review_id}:recover"), &recover);
if recover.clicked() {
*chosen = Some(recover_chosen);
}
if let Some(discard) = discard {
publish(&format!("{review_id}:discard"), &discard);
if discard.clicked() {
*chosen = Some(discard_chosen);
}
}
ui.add_space(Tokens::get().space_12);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Clone, Debug, PartialEq)]
enum Picked {
Back,
Rail(&'static str),
Tile(&'static str),
Recent(usize),
Forget(usize),
ShowInFolder(usize),
Sample(usize),
}
/// The texture the second tile's icon is drawn from.
const HOST_ICON: egui::TextureId = egui::TextureId::User(7);
/// A start page of plain data: no app, no design, no engine.
fn page(recent: usize) -> Page<'static, Picked> {
let tile = |id: &'static str, icon, primary| Tile {
id: id.into(),
icon,
title: id.into(),
hint: "a hint".into(),
primary,
chosen: Picked::Tile(id),
};
let entry = |id: &'static str, selected, enabled| RailEntry {
id: id.into(),
label: id.into(),
shortcut: "".into(),
selected,
enabled,
chosen: Picked::Rail(id),
};
Page {
id: "start",
name: "Start page".into(),
interactive: true,
back: Back { template: "Back to {name}".into(), document: "Plate".into(), shortcut: "Esc".into(), chosen: Picked::Back },
rail: vec![
RailItem::Entry(entry("start:home", true, true)),
RailItem::Entry(entry("start:save", false, false)),
RailItem::Separator,
RailItem::Entry(entry("start:about", false, true)),
],
title: "Start".into(),
hint: "Pick something".into(),
error: None,
body: Body::Start(Box::new(Start {
banner: None,
actions_title: "New".into(),
actions: Tiles {
grid: "start_new",
tiles: vec![tile("start:first", Icon::Kit("new"), true), tile("start:second", Icon::Texture(HOST_ICON), false)],
},
recent_title: "Recent".into(),
recent_empty: "Nothing yet".into(),
recent: (0..recent)
.map(|i| Recent {
title: format!("part {i}").into(),
path: "~/part".into(),
when: "Just now".into(),
kind: "Surface".into(),
thumbnail: None,
thumbnail_failed: false,
chosen: Picked::Recent(i),
menu: vec![
MenuEntry { label: "Remove from list".into(), icon: "delete", chosen: Picked::Forget(i) },
MenuEntry { label: "Show in folder".into(), icon: "folder", chosen: Picked::ShowInFolder(i) },
],
})
.collect(),
samples_title: "Samples".into(),
samples: (0..3)
.map(|i| Sample {
title: format!("sample {i}").into(),
description: "a sample".into(),
icon: Icon::Kit("box"),
thumbnail: None,
chosen: Picked::Sample(i),
})
.collect(),
})),
}
}
/// The page drawn frame by frame, with what it chose so far.
struct Drive {
ctx: egui::Context,
screen: egui::Rect,
recent: usize,
chosen: Vec<Picked>,
}
impl Drive {
fn new(size: egui::Vec2, recent: usize) -> Drive {
let mut drive =
Drive { ctx: crate::fonts::test_context(), screen: egui::Rect::from_min_size(egui::Pos2::ZERO, size), recent, chosen: Vec::new() };
drive.frame(Vec::new());
drive
}
fn frame(&mut self, events: Vec<egui::Event>) -> egui::FullOutput {
solidcraft_ui_registry::clear_handles();
let input = egui::RawInput { screen_rect: Some(self.screen), focused: true, events, ..Default::default() };
let recent = self.recent;
let chosen = &mut self.chosen;
crate::test_support::frame(&self.ctx, input, |ui| chosen.extend(show(ui, page(recent))))
}
fn at(name: &str) -> egui::Pos2 {
solidcraft_ui_registry::handle_at(name).unwrap_or_else(|| panic!("{name} was not drawn"))
}
/// Press and release at what `name` published last frame.
fn click_with(&mut self, name: &str, button: egui::PointerButton) {
let pos = Self::at(name);
for pressed in [true, false] {
self.frame(vec![egui::Event::PointerMoved(pos), egui::Event::PointerButton { pos, button, pressed, modifiers: Default::default() }]);
}
}
fn click(&mut self, name: &str) {
self.click_with(name, egui::PointerButton::Primary);
}
}
fn click(size: egui::Vec2, name: &str, recent: usize) -> Vec<Picked> {
let mut drive = Drive::new(size, recent);
drive.click(name);
drive.chosen
}
#[test]
fn start_page_from_plain_data_returns_the_tile_clicked() {
let wide = egui::vec2(1400.0, 900.0);
assert_eq!(click(wide, "start:second", 0), [Picked::Tile("start:second")]);
assert_eq!(click(wide, "start:first", 0), [Picked::Tile("start:first")]);
}
#[test]
fn start_page_returns_the_rail_entry_recent_and_sample_clicked() {
let wide = egui::vec2(1400.0, 900.0);
assert_eq!(click(wide, "start:back", 2), [Picked::Back]);
assert_eq!(click(wide, "start:about", 2), [Picked::Rail("start:about")]);
assert_eq!(click(wide, "start:recent:1", 2), [Picked::Recent(1)]);
assert_eq!(click(wide, "start:sample:2", 2), [Picked::Sample(2)]);
}
#[test]
fn start_page_disabled_rail_entry_returns_nothing() {
assert_eq!(click(egui::vec2(1400.0, 900.0), "start:save", 0), []);
}
#[test]
fn start_page_recent_context_menu_returns_the_entry_picked() {
for (entry, picked) in [("menu:Remove from list", Picked::Forget(1)), ("menu:Show in folder", Picked::ShowInFolder(1))] {
let mut drive = Drive::new(egui::vec2(1400.0, 900.0), 2);
drive.click_with("start:recent:1", egui::PointerButton::Secondary);
drive.frame(Vec::new());
drive.click(entry);
assert_eq!(drive.chosen, [picked], "{entry}");
}
}
#[test]
fn start_page_tile_draws_the_hosts_texture_icon() {
let mut drive = Drive::new(egui::vec2(1400.0, 900.0), 0);
let output = drive.frame(Vec::new());
let tile = solidcraft_ui_registry::rect_of("start:second").unwrap();
let mut drawn = false;
for clipped in &output.shapes {
crate::test_support::each_shape(&clipped.shape, &mut |shape| {
if let egui::Shape::Mesh(mesh) = shape {
drawn |= mesh.texture_id == HOST_ICON && tile.contains_rect(mesh.calc_bounds());
}
});
}
assert!(drawn, "the second tile draws its icon from the host's texture");
}
#[test]
fn start_page_lays_out_inside_a_narrow_window_and_keeps_its_tiles() {
assert_eq!(click(egui::vec2(420.0, 800.0), "start:first", 0), [Picked::Tile("start:first")]);
let page = solidcraft_ui_registry::rect_of("start:page").unwrap();
assert!(page.contains_rect(solidcraft_ui_registry::rect_of("start:second").unwrap()));
assert!(solidcraft_ui_registry::handle_at("start:back").is_none(), "the rail folds into the page's menu");
}
}
+9 -7
View File
@@ -275,9 +275,9 @@ fn report_rect(bar: Rect, captions: f32, tabs_start: f32, label_width: f32, vers
/// The closed search field in the application bar, ending `right`: whole when the document tabs
/// keep the room Report a bug's label leaves them (`tabs_room`: theirs without the field), else
/// just its magnifier.
fn header_search_rect(app: &SolveApp, ctx: &egui::Context, bar: Rect, right: f32, tabs_room: f32) -> Rect {
fn header_search_rect(ctx: &egui::Context, bar: Rect, right: f32, tabs_room: f32) -> Rect {
let height = 26.0;
let full = crate::ui_kit::CommandSearch::closed_width(ctx, crate::keymap::display_for(app, "ui.palette").as_deref());
let full = crate::ui_kit::CommandSearch::closed_width(ctx);
let whole = bar.width() >= 640.0 && tabs_room - full - HEADER_SEARCH_GAP >= 216.0;
let width = if whole { full } else { crate::ui_kit::CommandSearch::compact_width(height) };
Rect::from_min_size(pos2(right - width, bar.center().y - height / 2.0), vec2(width, height))
@@ -536,7 +536,7 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) {
// moves the assistant button and the end of the document tabs left by its width.
let gear_left = report.left() - 68.0;
let search = (search_in_header(app) && design_shown(app))
.then(|| header_search_rect(app, ui.ctx(), r, gear_left - HEADER_SEARCH_GAP, report.left() - CLUSTER_BUTTONS - tabs_start));
.then(|| header_search_rect(ui.ctx(), r, gear_left - HEADER_SEARCH_GAP, report.left() - CLUSTER_BUTTONS - tabs_start));
let search_shift = search.map_or(0.0, |s| s.width() + HEADER_SEARCH_GAP);
if let Some(search) = search {
// shortcut_box::show draws the one search editor wherever this rectangle is.
@@ -1385,8 +1385,7 @@ fn ribbon_tabs(app: &mut SolveApp, ui: &mut egui::Ui, row: Rect) -> Option<bool>
// The application bar may hold the search field instead ([`HeaderLayout`]): the tabs then
// run to the row's end.
let in_ribbon = !search_in_header(app);
let search_width =
if in_ribbon { crate::ui_kit::CommandSearch::closed_width(ui.ctx(), crate::keymap::display_for(app, "ui.palette").as_deref()) } else { 0.0 };
let search_width = if in_ribbon { crate::ui_kit::CommandSearch::closed_width(ui.ctx()) } else { 0.0 };
// The first tab and the search field sit the same distance from the card's top, left and right
// edges, so their outer corners are concentric with the card's corners.
let floating = Container::for_layout(app.ui.layout, Surface::Panel).floating();
@@ -3743,6 +3742,9 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) {
// A single Esc with nothing left to cancel keeps the sketch open (#1804); a quick
// second press finishes it (above).
}
Key::S if !mods.shift && !mods.alt => {
app.start("ui.palette");
}
// With a menu open, V runs that menu's Show/Hide on what the menu is for.
Key::V => {
if !crate::context_menu::press_shortcut(app, "V") {
@@ -4472,7 +4474,7 @@ mod header_report_tests {
let search = rect("ribbon:search");
assert!(search.top() >= APP_BAR_HEIGHT, "search is in the ribbon: {search:?}");
assert_eq!(search.height(), TAB_ROW_HEIGHT - 2.0 * RIBBON_TAB_INSET);
assert_eq!(search.width(), crate::ui_kit::CommandSearch::closed_width(&h.ctx, Some("S")));
assert_eq!(search.width(), crate::ui_kit::CommandSearch::closed_width(&h.ctx));
assert!(crate::scenario::rect_of("titlebar:search").is_none());
let report = rect("titlebar:report");
// A pre-release's longer version takes more room than RIGHT_GROUP and moves the button left.
@@ -4505,7 +4507,7 @@ mod header_report_tests {
let (search, prefs) = (rect("titlebar:search"), rect("titlebar:preferences"));
assert_eq!(search.right(), prefs.left() - HEADER_SEARCH_GAP, "search sits immediately left of Settings");
assert!(search.bottom() <= APP_BAR_HEIGHT && (search.center().y - prefs.center().y).abs() < 0.5);
assert_eq!(search.width(), crate::ui_kit::CommandSearch::closed_width(&h.ctx, Some("S")), "the whole field where there is room");
assert_eq!(search.width(), crate::ui_kit::CommandSearch::closed_width(&h.ctx), "the whole field where there is room");
assert_eq!(rect("ribbon:search"), search, "the one search field is drawn in the bar");
// Report a bug: an icon the size of Settings and Help, still named for assistive tools.
let report = rect("titlebar:report");
+10 -44
View File
@@ -25,43 +25,11 @@ pub(crate) struct RecentRow<'a> {
pub thumbnail_failed: bool,
}
/// A picture the host gives a kit surface: a name in the kit's icon set, a texture the host made
/// (its own app icon, say), or the host's own painter, called with the icon's rect and opacity.
#[derive(Clone, Copy)]
pub enum Icon<'a> {
Kit(&'a str),
Texture(TextureId),
Paint(&'a dyn Fn(&egui::Painter, Rect, f32)),
}
impl<'a> From<&'a str> for Icon<'a> {
fn from(name: &'a str) -> Self {
Icon::Kit(name)
}
}
impl Icon<'_> {
pub(crate) fn paint(&self, painter: &egui::Painter, rect: Rect, opacity: f32) {
match self {
Icon::Kit(name) => crate::icons::paint_accented(painter, rect, name, None, opacity),
Icon::Texture(texture) => {
painter.image(
*texture,
rect,
Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)),
Tokens::get().file_image_tint.gamma_multiply(opacity),
);
}
Icon::Paint(paint) => paint(painter, rect, opacity),
}
}
}
#[derive(Clone, Copy)]
pub(crate) struct SampleCard<'a> {
pub title: &'a str,
pub description: &'a str,
pub icon: Icon<'a>,
pub icon: &'a str,
pub thumbnail: Option<(TextureId, Vec2)>,
}
@@ -277,22 +245,14 @@ impl FileView {
ui.add_space(t.space_10);
}
pub(crate) fn action_tile<'a>(
ui: &mut Ui,
id: &str,
width: f32,
icon: impl Into<Icon<'a>>,
label: &str,
subtitle: &str,
primary: bool,
) -> Response {
pub(crate) fn action_tile(ui: &mut Ui, id: &str, width: f32, icon: &str, label: &str, subtitle: &str, primary: bool) -> Response {
let t = Tokens::get();
let (rect, response) = allocate(ui, id, width, t.file_tile_height, label);
card(ui, rect, &response, primary);
let inner = rect.shrink(t.space_16);
let icon_size = t.file_tile_icon.min(inner.height()).min(inner.width());
let icon_rect = Rect::from_center_size(pos2(inner.left() + icon_size / 2.0, inner.center().y), Vec2::splat(icon_size));
icon.into().paint(ui.painter(), icon_rect, if ui.is_enabled() { 1.0 } else { 0.45 });
crate::icons::paint_accented(ui.painter(), icon_rect, icon, None, if ui.is_enabled() { 1.0 } else { 0.45 });
let text_rect = Rect::from_min_max(pos2((icon_rect.right() + t.space_14).min(inner.right()), inner.top()), inner.max);
let ink = if primary { t.file_primary_ink } else { t.form_ink };
let mut title_job = egui::text::LayoutJob::simple(label.to_owned(), crate::fonts::semibold(t.font_heading), ink, text_rect.width());
@@ -602,7 +562,13 @@ fn sample_card_with_height(ui: &mut Ui, id: &str, width: f32, sample: &SampleCar
let image = Rect::from_min_size(inner.min, vec2(inner.width(), t.file_sample_image_height));
thumbnail(ui, image, sample.thumbnail);
if sample.thumbnail.is_none() {
sample.icon.paint(ui.painter(), Rect::from_center_size(image.center(), Vec2::splat(t.file_tile_icon.min(image.width()))), 1.0);
crate::icons::paint_accented(
ui.painter(),
Rect::from_center_size(image.center(), Vec2::splat(t.file_tile_icon.min(image.width()))),
sample.icon,
None,
1.0,
);
}
let painter = ui.painter().with_clip_rect(ui.clip_rect().intersect(inner));
let title_top = image.bottom() + t.space_8;
+1 -2
View File
@@ -1120,7 +1120,7 @@ const ELEMENTS: &[Element] = &[
FileView::sample_grid(
ui,
"gallery:file:sample",
&[SampleCard { title: "Sample Plate", description: "Parametric plate with holes and a bored boss", icon: "new".into(), thumbnail: None }],
&[SampleCard { title: "Sample Plate", description: "Parametric plate with holes and a bored boss", icon: "new", thumbnail: None }],
);
}),
element!("file_recovery", |ui, _| {
@@ -1145,7 +1145,6 @@ const ELEMENTS: &[Element] = &[
radius: CommandSearch::field_radius(1.0, true),
progress: 1.0,
},
Some("S"),
4096,
);
ui.add_space(Tokens::get().space_6);
+2 -2
View File
@@ -137,8 +137,8 @@ fn sample_grid_stretches_each_row_without_clipping_long_descriptions() {
|ui| {
ui.set_width(680.0);
cards = FileView::sample_grid(ui, "samples", &[
SampleCard { title: "Plate", description: "Short description", icon: "new".into(), thumbnail: None },
SampleCard { title: "Enclosure", description: "Long descriptive sample text that wraps across several lines while preserving every word through the final marker.", icon: "new".into(), thumbnail: None },
SampleCard { title: "Plate", description: "Short description", icon: "new", thumbnail: None },
SampleCard { title: "Enclosure", description: "Long descriptive sample text that wraps across several lines while preserving every word through the final marker.", icon: "new", thumbnail: None },
]);
},
);
-1
View File
@@ -83,7 +83,6 @@ pub(crate) use disclosure::*;
pub(crate) use drag::*;
pub(crate) use fields::*;
pub use fields::{Expressions, cell_editor, install_expressions, wide_cell_editor};
pub use file_view::Icon;
pub(crate) use file_view::{FileView, RecentRow, RecoveryMessage, SampleCard};
pub(crate) use framing::*;
pub(crate) use history::*;

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