Compare commits

...

17 Commits

Author SHA1 Message Date
jyviko 01c86d9cb1 solidcore: affine maps from an application's numbers, and ops::affine (#15239)
The kernel builds the maps (math.rs, not upstream), in Transform's
row-vector convention (rows 0-2 the images of the axes, row 3 the
translation):
- Transform::rotation(point, axis, angle): Rodrigues' formula about the
  line through point along axis (made unit), transposed for rows;
- Transform::uniform_scaling(centre, factor), keeping its centre;
- Transform::reflection(origin, normal): I - 2 n n^T and 2 (origin.n) n,
  every entry exact for an axis normal, so twice is the identity exactly.
Each refuses numbers not finite, an axis or normal of no length, and a
zero factor (InvalidInput, "math::Transform").

solidcore-ops::affine forwards them as the matrices Body::transform and
curve::transform take: translation, rotation, scaling (a positive factor;
a mirror is reflection), reflection and then (first, then second, through
Transform::concat; a result not finite is refused), each Argument for what
it refuses. The ban list keeps the new constructors and concat for ops:
the kernel computes, ops calls (ban_list.py, clippy.toml).

Tests: kernel, a quarter turn about z takes x to y and a turn keeps its
line and lengths, a scale keeps its centre, an axis mirror maps exactly
and twice is the identity, a slanted mirror keeps its plane, refusals.
Ops, the mirror in y = 0 is the y-mirror matrix, axis mirrors twice are
the identity exactly, x to y under a quarter turn, a slanted turn keeps
its line, a scale keeps its centre, two moves stack to their sum exactly,
every result regular at REGULAR_RATIO, refusals and an overflowing stack.

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

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
2026-11-11 21:43:36 -05:00
jyviko e64c6af4d4 solidcore: a line skew to the revolve axis sweeps its hyperboloid, not a cone (#15209)
* solidcore: a line skew to the axis revolves onto its hyperboloid, not a cone

sm_CreateAnalyticRotationalSweep (the whole turn's one-curve special
case) made a line neither parallel nor square to the axis the cone
through its end circles: their radii and axial positions, without
asking whether the line and the axis lie in one plane. A skew line
sweeps a hyperboloid of one sheet, whose radius is the root of a
quadratic in the axial coordinate; the cone meets it only at the ends.
The line [600,0,0]-[700,0,200] about [500,-50,0] along [40,80,300]
came out 3.85 mm inside it, 44% along (WR-776).

New primitive_creation::p1::coplanar_with_axis(axis_base, axis, start,
end, tol): parallel lines, or a common perpendicular no longer than tol.
The cone branch returns SM_ERR before any edit for a line it fails at the
B-rep's tolerance, so the whole turn's per-curve rule gives the line its
SurfOfRevolution (with revolve_analytic_profile_faces, analytic_revolution
first, which already refuses a curve outside one half-plane through the
axis). The result changes only for skew lines; no flag.

Test: a_line_skew_to_the_axis_revolves_onto_its_hyperboloid, fix off
and on: at 25 x 25 parameters of every face, the point's distance from
the axis equals the profile point's at the same axial coordinate within
1e-6, both measured from the profile's and axis's numbers. Upstream
record WR-776, with the related perpendicular-line ring left open.

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

* solidcore: drop the unused coplanar_with_axis re-export

It is used only inside primitives.rs.

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

* solidcore: a skew line's refusal in the one-curve revolve is a described failure

The cone branch of analytic_rotational_sweep refused a line skew to the
axis with a bare SM_ERR, which the primitive routines no longer build
(error::primitive::tests::the_primitive_routines_build_no_bare_statuses).
It is now SolidError::primitive_failed with what is wrong, as the branch's
neighbours (a line on the axis, a planar face not built) describe theirs.
The caller ignores it the same way and gives the line its surface of
revolution; the status it stands for is unchanged.

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

---------

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 21:43:16 -05:00
jyviko 295a5e1410 solidcraft-control: the control client, shared; the MCP bridge on top of it (#15125)
client::Connection is the app-independent half of the MCP bridge's
Remote: a connection to an app's loopback control port with the app's
token (auth::App::client_token) on every request, one write and one
bounded read (MAX_REPLY) per request, a fresh connection after a lost
one (a request sent once more, or never twice for Request::once), and
the wait for a request the app says is still running, through
control.pending, with the waits as parameters. CallError tells an app
that is not running (with how to start it, from the App's name and the
port) from one lost during the call, a request still running, the app's
refusal and a bad reply.

Remote keeps only what is the MCP bridge's: the agent's folders on every
request, the engine's names and effects for requests, the param-check
handshake and engine.batch sent once. Its messages are unchanged, except
the "did not answer" one, which lost a run of spaces in the middle.

Tests against a server::Hub on an ephemeral port: a round trip with the
token, a wrong or missing token refused, not running, lost mid-call and
reached again once back, a connection closed without a reply, and a
request still running waited for through control.pending.

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

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-11-11 21:42:52 -05:00
jyviko 81afe5b90d solidcore: n-sided boundary patches and soft CV moves on sheets (#15106)
* solidcore: loft_rails' skin-and-certificate loop as one crate function

The loop that places stations at span middles, skins the wires, bounds
each rail against the skin on every span (certificate::span_bound),
refines unproved spans at their middles and refuses past the wire cap
moves, unchanged, into loft_rails::skin_and_certify, which loft_rails
calls with its own station placement. Plain, Wire and StationAt become
crate-visible for it. No behaviour change; a two-rail sweep will place
its stations through the same loop.

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

* solidcore: a certified n-sided fill of a loop of curves

nurbs::n_sided runs the kernel's fill (N four-sided patches round a centre) and
certifies it: each half of each boundary curve against the patch side it is
(certified_distance plus split and extraction rounding, in the loop's own
frame), and every inner join between two patches (G0). Tangency across the
inner curves is the fill's aim within its angle tolerance and is stated as
not certified. Rational curves refused; equal weights taken as polynomial
(gordon's helpers now shared within the crate). Tests: a regular pentagon of
lines fills flat within 1e-9; a rational side is refused.

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

* solidcore: boundary patches of five or more curves, the certified n-sided fill

surface::n_sided(curves) -> (Brep, bound): the loop's curves turned end to end
as boundary takes them, the kernel's certified n-sided fill (nurbs::n_sided),
its N faces stitched into one sheet; refused for a rational curve
(Unsupported) or a boundary or inner join past the zone tolerance
(PastTolerance). surface::boundary now takes five or more curves through it.
Tests: a regular pentagon of lines fills flat with five faces joined along five
inner edges; five cubics on a saddle bound their patch within the bound; five
arcs on a sphere are refused.

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

* Move a sheet's CV rows, columns or points, with a soft-select falloff

solidcore_ops::surface_cvs::move_net moves picked rows (u indices),
columns (v indices) or control points by a delta. With a Falloff (a
radius in index steps and a Linear or Smooth shape) the points around
the picks move too, each by its weight: 1 at a pick, falling with its
index distance (along u for rows, along v for columns, the square ring
for points), 0 past the radius. net_weights gives the table, for a
preview; an index distance and a weight are no geometric quantity.

The move itself is the kernel's: srf_basic::Surface::displace_cpts
moves each listed point by its fraction of the delta (a rational
point's weighted coordinates by w t delta, its weight kept), all or
nothing.

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

* solidcore: the soft-move falloff weight is the kernel's

solidcore-ops denies float arithmetic outside tests, and falloff_weight
computed s = d / (radius + 1) and the linear and smoothstep weights itself.
The weight is now nurbs::srf_basic::falloff_weight with FalloffShape, beside
displace_cpts, which takes it; ops maps its Shape and calls it. Same formula,
same values.

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

* solidcore: the proven loft drain is in skin_and_certify now

The full-range drain of the pending stations moved from loft_rails into
skin_and_certify with the loop; its panic-sites row follows it, same proof.

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

---------

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 21:42:31 -05:00
MarkusRe 937722290a solidcore-ops: every kernel offset refusal has a named reason, no text catch-all (#15035) (#15160)
* claim: #15035

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

* solidcore-ops: test that every kernel offset refusal arrives named, not as text (#15035)

The kernel's offset routines fail with a phrase of their own (`OffsetFailure::Failed`,
`Missing`, `InvalidValue`, `Inconsistent`). Ops has no variant for them, so they reach the
app as `OffsetRefusal::Other { message }`, the kernel's text. The table of
`every_other_offset_refusal_says_why_and_where` now expects a named variant for each,
keeping the kernel's phrase. On main it does not build: `OffsetRefusal` has no such
variants.

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

* solidcore-ops: name every kernel offset refusal, delete the text catch-all (#15035)

`OffsetRefusal::Other { message }` carried any kernel offset reason ops did not list as
the kernel's text, and a match arm `other => unnamed(&other)` sent it there. The reasons
that took it were the offset routines' own failures: `Failed`, `Missing`, `InvalidValue`
and `Inconsistent`, each a fixed phrase.

- `OffsetRefusal` gets a variant for each of the four, keeping the kernel's phrase, and
  says it as the kernel does. `Other` and `unnamed` are gone.
- `offset_refused` names every `OffsetFailure`, with no wildcard arm. `Folds`, `WallsTouch`
  and `EdgeVanishes` go to `Error::WallFolds`, `Error::WallsTouch` and
  `Error::Unsupported` there, as before; `status` sends any offset reason to it and maps
  only reasonless errors by kind, so the mapping lives in one place.
- The kernel's `OffsetFailure` is no longer `#[non_exhaustive]`: across crates that
  attribute forces a wildcard arm, so a new kernel reason would still compile.
- The test that read `unnamed` is replaced by a check in the table test that the four
  phrases are said in the kernel's words, as they were as text.
- docs/offset-and-shell.md said `Collapses` reached the caller as a bare status; it has
  had its own refusal since #14783.

No result changes: every refusal keeps its error, and the four keep their text.

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

* solidcore-ops: an offset refusal names the failure's face, and a draft's too (#15035)

- `status` takes the face from the failure (`Failure::entity`), not only from the reason:
  the two agree unless the kernel set the face itself (`Failure::at`), as a draft does for
  a face without a surface, whose reason names none.
- The surface a draft gives a face: a kernel failure there goes through `status` as the
  local operation's does, so an offset reason reaches the caller named with its face
  instead of as the kernel's status.
- docs/offset-and-shell.md: the sheet offset and the symmetric thicken do not reach the
  mapping yet, since `offset_brep` wraps its executive's failure in one of its own.

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

* solidcore: test that an offset failure keeps its class, and a phrase arrives as one (#15035)

Review on #15160: `OffsetFailure` stays `#[non_exhaustive]`, so ops has to match it with a
wildcard. What keeps a new reason from reaching ops as text alone is then the kernel's own
classification. `every_offset_failure_keeps_its_class` pins every reason's class and whether it
is a phrase (a routine's failure without a cause of its own yet) or a defect of the kernel
(`Inconsistent`); its exhaustive `row` does not compile without a row for a new reason.

In ops, the phrase causes arrive as one transitional `OffsetRefusal::Phrase` that carries the
class, the defect flag and the kernel's words, instead of four named refusals, and only a
phrase arrives as one. The wildcard helper `unnamed` keeps its test, now expecting the class.

Neither builds at the previous head: `OffsetFailure` has no `is_phrase`/`is_defect` and
`OffsetRefusal` no `Phrase`.

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

* solidcore-ops: a kernel phrase arrives as one transitional refusal with its class (#15035)

Review on #15160 (bherbruck), lining offset up with the blend failure record (#15090):

- `OffsetFailure` keeps `#[non_exhaustive]`: solidcore is a general kernel, and a new reason
  must not break downstream crates. Completeness comes from inside the crate instead: the
  exhaustive `is_phrase()` and `is_defect()` next to `kind()`, pinned by
  `every_offset_failure_keeps_its_class`.
- The four phrase causes are not frozen as named public refusals. They arrive, with any reason
  ops does not name yet, as `OffsetRefusal::Phrase { class, defect, phrase }` (transitional),
  through the wildcard arm (`unnamed`). `Inconsistent` is a defect, not a refusal.
- The face is read through `SolidError::entity()`, not the `Failure.entity` field, which #15090
  makes crate-private.
- `OffsetRefusal` is `#[non_exhaustive]`, so ops can name more reasons without breaking callers.

The doc paragraph says so, and now also that `DistanceUnresolved` and the merge check's
`Unsound` reach ops with their reasons in the sheet offset and the symmetric thicken; only the
executive's own failures are wrapped by `collapse_offset_status`.

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

* solidcore-ops: a reason ops does not name keeps the error its class has (#15035)

The wildcard arm of `offset_refused` sent every unnamed reason to `OffsetRefusal::Phrase`.
On main, `status` looked at the error's kind first, so a new reason of the walls-touch, fold or
vanishing-edge kind (such as the `SectionOrderLost` that #14683 and #11776 add, of kind
`OffsetWallsTouch`) became `Error::WallsTouch`, `WallFolds` or `Unsupported`. `by_kind` now holds
that mapping, and both `status` and the wildcard arm use it, so such a reason keeps its error
until ops names it. The unnamed-reason test checks the mapping.

The kernel test's doc says what forces a new reason to be classed (the exhaustive `kind`,
`is_phrase` and `is_defect`), `is_defect` says it is per cause until offset has classes of its
own, `draft` lists `Error::OffsetRefused`, and §4.2 of the doc follows.

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

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
2026-11-12 02:04:34 +00:00
Sam fdc13eaef5 Split Face: a loops option splits a face along a sketch's closed shapes (#15225)
With loops: true, the sketch's curves are joined end to end before the
split, so a closed shape of several curves (a triangle, a slot) splits
the face along itself instead of each side being extended across the
face. Off by default; open curves are extended as before.
2026-11-12 02:03:15 +00:00
bherbruck fa3d016da8 solidcore: offset reach: a meeting's distance takes the trace's |u| (#14830) (#15201)
The trace's direction u is unit only to rounding (d / sqrt(|d|^2)), and a
point x0 + tau u is |tau| |u| from the foot, not |tau|. planned's bounds
(Roots, Touch, Cover) now take foot^2 + tau^2 (u . u), u . u enclosed with
its rate, so an upper bound cannot sit an ulp below the distance. The
kept straight edge's meeting with a plane (now line_meets_plane) encloses
|x0 + tau u - p| directly, since its x0 is the foot only for |u| = 1, and
its curve-deviation slack takes the sine |u.nu| / (|u| |nu|).


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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 01:48:18 +00:00
bherbruck 185667c004 geom: the global solver adds a solution without copying the ones it holds (#15200)
GlobalSolve::add_sorted_solution compared against the stored solutions by copying each one,
and its pass that combines overlapping solutions copied both of every pair and wrote both back
(about a million pairs on a two-cylinder cut). SolutionArray::at borrows what get_at copies and
pair_mut hands out two distinct solutions (i < j, bounds-checked), so both work in place.
combine_min_max changes the pair only on its split path, after its only fallible call, so the
result on success and the untouched pair on error are as with the copies. Order and logic are
unchanged. brep-bench boolean/tube-flush-ends (callgrind): instructions 3,966,547,083 ->
3,440,780,489; memcpy calls from add_sorted_solution 7,106,860 -> 996,945; STL byte-identical.
Part of #14830.


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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 01:48:10 +00:00
bherbruck b935118729 Release builds run on Blacksmith runners (#15227)
Linux, Flatpak, Arch, snap, FreeBSD, macOS, web and the release job move to Blacksmith, as CI
did. The version and Windows jobs stay on GitHub's runners: SignPath Foundation only signs a
request when every job leading up to it ran on a GitHub-hosted runner.

The icons job is gone: make_icons has only copied the committed icon files since #833, so its
15-minute CLI build held up the Windows jobs for nothing. package.ps1 already falls back to the
same committed solidcraft.ico.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 01:47:04 +00:00
MarkusRe 5cd9b8ffc6 solidcore: six fillet solver fixes become the only path (#14246 batch B27) (#15045)
* claim: #14246 batch B27 (six fillet solver flags)

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

* solidcore: B27 tests run on the default kernel (test first)

The tests of six fillet solver flags (WR-301 fillet_rail_rim_on_face,
WR-422 fillet_torus_center_on_axis, WR-568 fillet_torus_span_from_start_section,
WR-592 fillet_torus_rail_on_axis, WR-603 fillet_trace_from_tangent_point_toward_next;
WR-595 fillet_tangent_point_one_sided_step is shown by the goldens) now
assert the fixed result on Kernel::default() and drop their off halves.
On main's code they fail; the next commit makes the fixed paths the
only ones.

Part of #14246 (batch B27).

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

* solidcore: six fillet solver fixes become the default (retire their flags, B27)

The fixed paths of six UpstreamFixes flags of the fillet solver are now
the only ones, and the fields, the off paths and every preset line go:

- fillet_rail_rim_on_face (WR-301): the rollover joins a rail's
  neighbouring intervals on its face before it runs (p5.rs).
- fillet_torus_center_on_axis (WR-422), fillet_torus_rail_on_axis
  (WR-592), fillet_torus_span_from_start_section (WR-568): the torus
  round's section on the arc's axis, its rail point on the axis and its
  span from the start section (p2.rs).
- fillet_trace_from_tangent_point_toward_next (WR-603) and
  fillet_tangent_point_one_sided_step (WR-595): a trace from a tangent
  point starts toward the next point and takes its tangent from the
  forward step (p4.rs).

Fixtures 1080 and 4055 no longer opt in; each WR record gets an
addendum.

Part of #14246 (batch B27).

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

* goldens: bless the 25 rows the six fillet solver fixes move (B27)

blend.fillet_executive_p3 (14 rows) and blend.fillet_solver_p4 (11 rows)
move; no other unit does. Two rounds along a boolean's arc are now built
(WR-568) and match the same box turned 90 degrees, which already built;
five rows move by roundoff with a rail point on the axis (WR-422,
WR-592); seven stub rounds traced from a tangent point move by at most
1.4e-7 of their volume (WR-595, WR-603); eleven generated tangent points
take the forward step (WR-595). Every row, with the flag that moves it:
plan/findings/blend.fillet_solver--b27-six-flags-retired-2026-11-11.md.

Part of #14246 (batch B27).

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

* solidcore: B27 tests and notes after review

no_room_refusals_are_named took the holed cube's round of 5 on the
default kernel as its refusal under another code; with WR-301 the default
that round builds, so the test now takes a chamfer of 25 on the cube of
20 (SM_FILERR_BAD_INT). The WR-603 peg test pins its face count and the
added volume again. Module and doc comments name what is opt-in and what
is the default now; c3_trace_start drops a length check the pattern
already makes; the findings file's numbers are recomputed from the rows.

Part of #14246 (batch B27).

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

* solidcore: WR-512 arc round test allows the default torus span (B27)

the_flag_builds_the_arc_round compares the default kernel with WR-512 to
the pinned C++ with the fork's WR-512 line at 1e-9. With WR-568 the
default, the arc round's torus span comes from its start section, which
the pinned C++ does not do: 9531.3100487 against 9531.3100143, 3.6e-9
relative (with upstream's span they agree to 1e-12; none of the other
five B27 fixes moves it). The test now allows 1e-8, as its neighbour
with every fix does.

Part of #14246 (batch B27).

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

* plan: B27 findings name every moved golden row with its values

Part of #14246 (batch B27).

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

* solidcore: no_room_refusals_are_named keeps the chamfer of 5, now built (B27 review)

The holed cube's chamfer of 5 was the test's refusal under another code;
with WR-301 the default it builds, to the closed form 4000 - 12.5 * 20.
The test now asserts that, and keeps the chamfer of 25 on the cube as the
refusal that names no NoRoom reason.

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

* solidcore: WR-512 arc round test pins the C++ volume with WR-568's span (B27 review)

Instead of widening the_flag_builds_the_arc_round to 1e-8, compare at the
old 1e-9 with a new reference: the pinned C++ with the fork's WR-512 line
and WR-568's span line (smlib 985faae) both linked ahead, 9531.3100486849.
The default kernel gives 9531.3100486743, 1.1e-12 relative.

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

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
2026-11-12 01:43:06 +00:00
Daniel 02afa23329 Preferences: the Placement handles row is "Placed features", and its tip is true for the primitives (#14766)
* Preferences: the Placement handles row is "Placed features", and its tip is true for the primitives

Box, Cylinder, Cone, Sphere, Torus and Coil have the placement marker
too, but the Preferences row was labelled "Rest, Boss, Snap Fit", and 22
translations of the tooltip still named those three features only. The
tooltip also promised a ring to turn the feature, which the primitives
do not have.

The label reads "Placed features" and the tooltip "A marker to drag the
feature across the model and, where the feature can turn, a ring to turn
it." in English and in every catalog that has the message. The setting
and its default are unchanged.

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

* Preferences search finds the Placed features row by each feature with the marker

With the row labelled "Placed features", searching Preferences for Rest,
Boss or Snap Fit no longer found Placement handles, as the old label did.
The row now gives the search the names of every feature that has the
marker (Rest, Boss, Snap Fit, Box, Cylinder, Cone, Sphere, Torus, Coil),
from their existing name keys, so it is found by any of them in the
current language.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 01:30:46 +00:00
MarkusRe db742b4a8f Native body files: re-record the box-and-round wire record fixture, whose topology is only renumbered (#15057)
* claim: box-and-round native wire record fixture

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

* Native body files: re-bless the box-and-round wire record fixture

The rounded box's record numbers its vertices, edges and faces in a new
order since the solidcore update to 8ddce9016. Renumbered by geometry,
the old fixture matches the new record in every value within the test's
own tolerance (1e-10 relative): surfaces, domains, loops and pcurves,
curves, intervals, tolerances, uses, shells and history. The plain box's
record is unchanged byte for byte. Re-blessed with NATIVE_BREP_BLESS=1;
the test leaves tests/KNOWN_FAILING_TESTS.

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

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
2026-11-12 01:30:11 +00:00
N3MI-DG 0371e7d2d1 STL and 3MF export: Optionally export file on print face (backend) (#15204) (#15205)
* claim: #15204

* STL and 3MF export: a script can write each body turned onto its print face, without a slicer (#15204)

file.export, file.export_stl, file.export_3mf and file.save_mesh take
lay_on_print_faces: true. Each body with a print face is written laid on
it, as Send to Slicer lays it: the face down at Z = 0, the centre of mass
over the origin. The design does not move and nothing is added to undo.
The reply names the laid bodies in print_faces, with a warning for each
print face that can't be used. The default is false, so existing exports
are unchanged; formats other than STL and 3MF refuse true.

The slicer path's laying code is now one function every mesh export
shares, built for the web too. file.export_stl gets its own input
contract, since it shared one with file.export_obj.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 01:16:59 +00:00
infield 270573eb0b Opening older designs: inserted STEP parts keep their place and shape (#13382) (#14211)
* claim: #13382

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

* Opening older designs: inserted STEP parts keep their place and shape (#13382)

An Import feature keeps its body names and skipped bodies by the body
numbers of the STEP reader that inserted the file. The 0.40 reader numbers
some files' bodies otherwise (it reads bodies 0.30.1 left out), so on
opening each body took the next one's name, and the name put it in another
part's component: parts moved and took other parts' shapes.

When a design is evaluated on opening, match each body through the stored
component tree (same product, same position among its bodies) and move the
stored names and skip entries to today's numbering. A body only today's
reader has gets the reader's name and is skipped when a fresh insert would
skip it. Trees that disagree about their products are left as they are.
Designs that open from their saved model cache are not read again.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 01:07:12 +00:00
ancient_astronaut_theorist 1155cdd918 Booleans: a join that fills a hollow completely gives a solid instead of an export error (#14414) (#14733)
* claim: #14414

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

* solidcore: a join that fills a void exactly gives a solid body (#14414)

A join whose tool exactly fills a body's internal void deleted the
void's faces correctly, but the joined region kept the void's flag:
merge data is recorded only when a face of the other body is copied in,
and when the tool's faces all lie on the void's faces nothing is
copied. The result was a box with an empty inside, which the export
refused ("a face between two material regions or two empty ones").

A face that a regularized operation deletes has the same class on both
sides: material for a union, empty for an intersection or a difference.
A region joined across a deleted face, where neither region has merge
data, now takes that class (deleted_face_sides, joined_without_data in
merge/p4.rs). Regression tests in solidcore-ops/tests/boolean.rs cover
cube and sphere voids filled exactly and by larger tools, a partial
fill, and the difference and intersection of a cube on the void.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 01:06:55 +00:00
ancient_astronaut_theorist 21c7836995 Hole tests: a hole across the part that touches none of its sides keeps the rest silently (#15119)
* claim: hole test off a tangency

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

* Hole test: the hole that crosses the plate no longer sits on a tangency

a_hole_wider_than_the_part_says_it_removes_all_of_it drilled its 40 mm
hole at (10, 10) in a 40 x 30 plate, so the hole's edge touched the side
y = 30 along the line x = 10. The corner it leaves at (0, 30) meets the
rest only along that line, and the kernel rightly parts it into 2 bodies
(4031.96 mm3 in all; exact 4031.95), where the test wanted 1.

The test is about what the hole says, so the hole moves to (10, 15): it
still crosses the plate's whole width but touches no side, and one body
remains (3623.44 mm3; exact 3623.43). The test leaves the known-failing
list.

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

* Hole tests: keep the touching case listed; a hole clear of the sides is its own test

The hole wider than the part goes back to touching the plate's side along a
line, with its known-failing line, as the record of that bug. The same hole
moved clear of the sides is a new test beside it: it keeps the rest in one
piece and silently, and says so once a parameter widens it past the part.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 00:58:52 +00:00
bherbruck b4e1178bdd solidcore: offset local reach bounds each edge once (#14830) (#15182)
local_extension_reach meets every edge from both its faces, and each
asks edge_interval_maximum for the same quantity on the same curve,
interval and tolerance, so the same bits. The offset executive keeps the
bounds one local operation has found (EdgeBounds, by edge and quantity:
a moved face's shift or a plane-quadric meeting), and the second face
takes the first's where everything the bound is a function of has the
same bits: the curve, interval, tolerance and the surfaces' exact
classes. They are cleared when the local surfaces are set and when the
relations are cleared, never kept across operations.


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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 00:48:19 +00:00
132 changed files with 5354 additions and 1980 deletions
+6
View File
@@ -9,3 +9,9 @@ self-hosted-runner:
- blacksmith-16vcpu-ubuntu-2404
- blacksmith-32vcpu-ubuntu-2404
- blacksmith-8vcpu-windows-2025
# Release builds (workflows/release.yml).
- blacksmith-16vcpu-ubuntu-2204
- blacksmith-16vcpu-ubuntu-2204-arm
- blacksmith-4vcpu-ubuntu-2404-arm
- blacksmith-6vcpu-macos-15
- blacksmith-12vcpu-macos-15
+27 -54
View File
@@ -8,6 +8,9 @@
# The macOS app is signed and notarized when the APPLE_* secrets exist. The Windows binaries and
# MSI are Authenticode-signed through SignPath Foundation when the SIGNPATH_API_TOKEN secret exists
# (see the windows job). Nothing else is signed yet.
# Runners: Blacksmith (blacksmith-<N>vcpu-<os>), as CI uses, except the `version` and `windows`
# jobs, which stay GitHub-hosted: SignPath Foundation only signs a request when every job leading
# up to it ran on a GitHub-hosted runner.
#
# Linux x86_64, aarch64 AppImage (+ .zsync), .deb, .rpm, .flatpak, .snap, .tar.gz packaging/linux
# Arch Linux x86_64, aarch64 .pkg.tar.zst, repackaged from the .tar.gz the arch job
@@ -114,40 +117,18 @@ jobs:
{ echo "version=$VERSION"; echo "date=$(date -u +%Y-%m-%d)"; echo "prerelease=$pre"; } >>"$GITHUB_OUTPUT"
echo "### SolidCraft v$VERSION (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
# The app icon in every size, drawn by solidcraft-cli (no image files are committed), for the
# Windows jobs: the ARM64 build is cross-compiled and can't run there to draw its own.
icons:
needs: version
if: needs.version.outputs.build == 'true'
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@1.98.0
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-icons
- run: cargo build --release --locked -p solidcraft-cli
- run: |
. packaging/env.sh
make_icons target/release/solidcraft-cli icons
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: icons
path: icons
if-no-files-found: error
linux:
name: Linux ${{ matrix.arch }}
needs: version
if: needs.version.outputs.build == 'true'
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35.
# Ubuntu 22.04, the oldest image, so the binaries only need glibc >= 2.35.
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- { arch: x86_64, runner: ubuntu-22.04, deb: amd64 }
- { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 }
- { arch: x86_64, runner: blacksmith-16vcpu-ubuntu-2204, deb: amd64 }
- { arch: aarch64, runner: blacksmith-16vcpu-ubuntu-2204-arm, deb: arm64 }
env:
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
@@ -203,8 +184,8 @@ jobs:
fail-fast: false
matrix:
include:
- { arch: x86_64, runner: ubuntu-24.04 }
- { arch: aarch64, runner: ubuntu-24.04-arm }
- { arch: x86_64, runner: blacksmith-4vcpu-ubuntu-2404 }
- { arch: aarch64, runner: blacksmith-4vcpu-ubuntu-2404-arm }
env:
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
@@ -233,7 +214,7 @@ jobs:
needs: [version, linux]
if: needs.version.outputs.build == 'true'
# makepkg only repackages the tarball's prebuilt binaries, so both architectures build here.
runs-on: ubuntu-24.04
runs-on: blacksmith-2vcpu-ubuntu-2404
# A pre-release or nightly without an Arch package is still worth having; a stable release isn't.
continue-on-error: ${{ contains(needs.version.outputs.version, '-') }}
strategy:
@@ -303,8 +284,8 @@ jobs:
fail-fast: false
matrix:
include:
- { arch: x86_64, runner: ubuntu-24.04 }
- { arch: aarch64, runner: ubuntu-24.04-arm }
- { arch: x86_64, runner: blacksmith-4vcpu-ubuntu-2404 }
- { arch: aarch64, runner: blacksmith-4vcpu-ubuntu-2404-arm }
env:
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
@@ -349,8 +330,8 @@ jobs:
name: FreeBSD x86_64
needs: version
if: needs.version.outputs.build == 'true'
# GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM.
runs-on: ubuntu-latest
# There are no FreeBSD runners: build in a FreeBSD 14.3 VM (Blacksmith's x64 runners have KVM).
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 180
env:
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
@@ -363,8 +344,8 @@ jobs:
with:
release: "14.3"
usesh: true
cpu: 4
mem: 12288
cpu: 14
mem: 40960
# Passed into the VM by name; never templated into the script below.
envs: "SOLIDCRAFT_VERSION SOLIDCRAFT_BUILD_DATE"
prepare: |
@@ -374,9 +355,9 @@ jobs:
set -e
. "$HOME/.cargo/env"
git config --global --add safe.directory "$PWD"
# Build outside the checkout so only dist/ is copied back out of the VM. 4 jobs: more
# runs the VM out of memory.
export CARGO_TARGET_DIR=/var/tmp/solidcraft-target CARGO_BUILD_JOBS=4
# Build outside the checkout so only dist/ is copied back out of the VM. About 3 GB of
# VM memory per job: 4 jobs in 12 GB was the most that fit before.
export CARGO_TARGET_DIR=/var/tmp/solidcraft-target CARGO_BUILD_JOBS=12
bash packaging/freebsd/package.sh
tar -tzf dist/release/solidcraft-*-freebsd-x86_64.tar.gz \
| grep -E '/bin/solidcraft$|/bin/solidcraft-cli$|/share/applications/io\.tangencylabs\.solidcraft\.desktop$|/share/doc/solidcraft/THIRD-PARTY-LICENSES\.txt$'
@@ -388,11 +369,12 @@ jobs:
# The two halves of the universal binary build in parallel on their own runners (each alone
# took about half of the old single job's 45 minutes); `macos` below joins and packages them.
# Blacksmith's macOS runners are Apple silicon: the x86_64 half is cross-compiled.
macos-build:
name: macOS build ${{ matrix.arch }}
needs: version
if: needs.version.outputs.build == 'true'
runs-on: macos-15
runs-on: blacksmith-12vcpu-macos-15
strategy:
fail-fast: false
matrix:
@@ -426,7 +408,7 @@ jobs:
name: macOS universal
needs: [version, macos-build]
if: needs.version.outputs.build == 'true'
runs-on: macos-15
runs-on: blacksmith-6vcpu-macos-15
env:
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
@@ -466,10 +448,11 @@ jobs:
windows:
name: Windows ${{ matrix.arch }}
needs: [version, icons]
needs: version
if: needs.version.outputs.build == 'true'
# Must stay a GitHub-hosted runner, like every job before it: SignPath's open-source policy
# only signs builds whose jobs all ran on GitHub-hosted runners.
# Must stay a GitHub-hosted runner, like the `version` job before it: SignPath's open-source
# policy only signs requests whose jobs leading up to them all ran on GitHub-hosted runners.
# The installer icon is the committed assets/icons/app/windows/exports/solidcraft.ico.
runs-on: windows-latest
# actions: read lets the SignPath action read this job's details and fetch the uploaded
# artifact with GITHUB_TOKEN (the workflow default above grants contents: read only).
@@ -498,10 +481,6 @@ jobs:
SIGNPATH_PROJECT_SLUG: solidcraft
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: icons
path: icons
- uses: dtolnay/rust-toolchain@1.98.0
with:
targets: ${{ matrix.target }}
@@ -516,8 +495,6 @@ jobs:
- name: Build and package (msi, portable zip)
if: env.SIGN_WINDOWS != 'true'
shell: pwsh
env:
SOLIDCRAFT_ICONS: ${{ github.workspace }}/icons
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
# ---- signed path ---------------------------------------------------------------------------
@@ -531,8 +508,6 @@ jobs:
- name: Build and stage the binaries for signing
if: env.SIGN_WINDOWS == 'true'
shell: pwsh
env:
SOLIDCRAFT_ICONS: ${{ github.workspace }}/icons
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }} -Phase stage
- name: Upload the unsigned binaries
id: unsigned-exes
@@ -576,8 +551,6 @@ jobs:
- name: Package the signed binaries (msi, portable zip)
if: env.SIGN_WINDOWS == 'true'
shell: pwsh
env:
SOLIDCRAFT_ICONS: ${{ github.workspace }}/icons
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }} -Phase package
- name: Upload the unsigned MSI
id: unsigned-msi
@@ -627,7 +600,7 @@ jobs:
name: Web (wasm)
needs: version
if: needs.version.outputs.build == 'true'
runs-on: ubuntu-latest
runs-on: blacksmith-8vcpu-ubuntu-2404
env:
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
TRUNK_VERSION: 0.21.14
@@ -656,7 +629,7 @@ jobs:
needs: [version, linux, flatpak, arch, snap, freebsd, macos, windows, web]
# Not for the release gate (packages_only): it builds and checks the packages, nothing more.
if: needs.version.outputs.build == 'true' && !inputs.packages_only
runs-on: ubuntu-latest
runs-on: blacksmith-2vcpu-ubuntu-2404
permissions:
contents: write
env:
+2 -2
View File
@@ -2653,7 +2653,7 @@
"prefs.design_tools.imported_files": "الملفات المستوردة",
"prefs.design_tools.note": "ملاحظة",
"prefs.design_tools.off": "إيقاف",
"prefs.design_tools.placement_group": "مسند، نتوء، تعشيق مرن",
"prefs.design_tools.placement_group": "الميزات الموضوعة",
"prefs.design_tools.rows": "صفوف",
"prefs.devices": "الفأرة ثلاثية الأبعاد",
"prefs.dialog_buttons": "أزرار مربعات الحوار",
@@ -6070,7 +6070,7 @@
"ui.pitch": "الخطوة",
"ui.placement": "الموضع",
"ui.placement_handles": "مقابض الموضع",
"ui.placement_handles_tip": "علامة لسحب الميزة عبر النموذج وحلقة لتدويرها.",
"ui.placement_handles_tip": "علامة لسحب الميزة عبر النموذج، وحلقة لتدويرها حيث يمكن تدوير الميزة.",
"ui.plane": "المستوى",
"ui.plane_at_angle": "مستوى بزاوية",
"ui.plugins": "الإضافات",
+1 -1
View File
@@ -3384,7 +3384,7 @@
"ui.pitch": "Pitch",
"ui.placement": "Placement",
"ui.placement_handles": "Placement handles",
"ui.placement_handles_tip": "Handles on the preview of a Rest, Boss or Snap Fit: drag the marker to move it across the model, turn it by its ring",
"ui.placement_handles_tip": "Маркер за влачене на елемента по модела и, където елементът може да се завърта, пръстен за завъртането му.",
"ui.plane": "Plane",
"ui.plane_at_angle": "Plane at Angle",
"ui.plugins": "Plugins",
+1 -1
View File
@@ -4907,7 +4907,7 @@
"ui.pitch": "Korak",
"ui.placement": "Postavljanje",
"ui.placement_handles": "Ručice za postavljanje",
"ui.placement_handles_tip": "Ručice na pregledu oslonca, ispusta ili škljocajućeg spoja: povucite oznaku da je pomjerite po modelu, okrenite je njenim prstenom",
"ui.placement_handles_tip": "Oznaka za povlačenje operacije po modelu i, gdje se operacija može okretati, prsten za njeno okretanje.",
"ui.plane": "Ravan",
"ui.plane_at_angle": "Ravan pod uglom",
"ui.plugins": "Proširenja",
+2 -2
View File
@@ -2653,7 +2653,7 @@
"prefs.design_tools.imported_files": "Fitxers importats",
"prefs.design_tools.note": "Nota",
"prefs.design_tools.off": "Desactivat",
"prefs.design_tools.placement_group": "Repòs, Resalt, Encaix",
"prefs.design_tools.placement_group": "Operacions col·locades",
"prefs.design_tools.rows": "Files",
"prefs.devices": "Ratolí 3D",
"prefs.dialog_buttons": "Botons de diàleg",
@@ -6070,7 +6070,7 @@
"ui.pitch": "Inclinació",
"ui.placement": "Col·locació",
"ui.placement_handles": "Tiradors de col·locació",
"ui.placement_handles_tip": "Un marcador per arrossegar l'operació pel model i un anell per girar-la.",
"ui.placement_handles_tip": "Un marcador per arrossegar l'operació pel model i, on l'operació es pot girar, un anell per girar-la.",
"ui.plane": "Pla",
"ui.plane_at_angle": "Pla amb angle",
"ui.plugins": "Complements",
+2 -2
View File
@@ -2562,7 +2562,7 @@
"prefs.design_tools.imported_files": "Importované soubory",
"prefs.design_tools.note": "Poznámka",
"prefs.design_tools.off": "Vypnuto",
"prefs.design_tools.placement_group": "Opěra, Nálitek, Západka",
"prefs.design_tools.placement_group": "Umisťované prvky",
"prefs.design_tools.rows": "Řádky",
"prefs.devices": "3D myš",
"prefs.dialog_buttons": "Tlačítka dialogu",
@@ -5824,7 +5824,7 @@
"ui.pitch": "Stoupání",
"ui.placement": "Umístění",
"ui.placement_handles": "Úchyty umístění",
"ui.placement_handles_tip": "Úchyty na náhledu opěry, nálitku nebo západky: tažením značky ji přesunete po modelu, prstencem ji otočíte",
"ui.placement_handles_tip": "Značka pro přetažení prvku po modelu a tam, kde se prvek může otáčet, kroužek pro jeho otočení.",
"ui.plane": "Rovina",
"ui.plane_at_angle": "Rovina pod úhlem",
"ui.plugins": "Moduly plug-in",
+1 -1
View File
@@ -4916,7 +4916,7 @@
"ui.pitch": "Stigning",
"ui.placement": "Placering",
"ui.placement_handles": "Placeringshåndtag",
"ui.placement_handles_tip": "Håndtag på forhåndsvisningen af en Understøtning, Skruebøsning eller Snapforbindelse: Træk i markøren for at flytte den hen over modellen, og drej den ved dens ring",
"ui.placement_handles_tip": "En markør til at trække featuren hen over modellen og, hvor featuren kan drejes, en ring til at dreje den.",
"ui.plane": "Plan",
"ui.plane_at_angle": "Plan i vinkel",
"ui.plugins": "Plugins",
+2 -2
View File
@@ -2424,7 +2424,7 @@
"prefs.design_tools.imported_files": "Importierte Dateien",
"prefs.design_tools.note": "Hinweis",
"prefs.design_tools.off": "Aus",
"prefs.design_tools.placement_group": "Auflage, Zapfen, Schnappverbindung",
"prefs.design_tools.placement_group": "Platzierte Funktionen",
"prefs.design_tools.rows": "Zeilen",
"prefs.devices": "3D-Maus",
"prefs.dialog_buttons": "Dialogschaltflächen",
@@ -5758,7 +5758,7 @@
"ui.pitch": "Steigung",
"ui.placement": "Platzierung",
"ui.placement_handles": "Platzierungsgriffe",
"ui.placement_handles_tip": "Eine Markierung zum Verschieben des Features über das Modell und ein Ring zum Drehen.",
"ui.placement_handles_tip": "Eine Markierung zum Verschieben des Features über das Modell und, wo sich das Feature drehen lässt, ein Ring zum Drehen.",
"ui.plane": "Ebene",
"ui.plane_at_angle": "Ebene im Winkel",
"ui.plugins": "Plugins",
+2 -2
View File
@@ -2653,7 +2653,7 @@
"prefs.design_tools.imported_files": "Imported files",
"prefs.design_tools.note": "Note",
"prefs.design_tools.off": "Off",
"prefs.design_tools.placement_group": "Rest, Boss, Snap Fit",
"prefs.design_tools.placement_group": "Placed features",
"prefs.design_tools.rows": "Rows",
"prefs.devices": "3D mouse",
"prefs.dialog_buttons": "Dialog buttons",
@@ -6070,7 +6070,7 @@
"ui.pitch": "Pitch",
"ui.placement": "Placement",
"ui.placement_handles": "Placement handles",
"ui.placement_handles_tip": "A marker to drag the feature across the model and a ring to turn it.",
"ui.placement_handles_tip": "A marker to drag the feature across the model and, where the feature can turn, a ring to turn it.",
"ui.plane": "Plane",
"ui.plane_at_angle": "Plane at Angle",
"ui.plugins": "Plugins",
+2 -2
View File
@@ -2653,7 +2653,7 @@
"prefs.design_tools.imported_files": "Archivos importados",
"prefs.design_tools.note": "Nota",
"prefs.design_tools.off": "Desactivado",
"prefs.design_tools.placement_group": "Apoyo, Saliente, Encaje",
"prefs.design_tools.placement_group": "Operaciones colocadas",
"prefs.design_tools.rows": "Filas",
"prefs.devices": "Ratón 3D",
"prefs.dialog_buttons": "Botones de diálogo",
@@ -6070,7 +6070,7 @@
"ui.pitch": "Inclinación",
"ui.placement": "Colocación",
"ui.placement_handles": "Tiradores de colocación",
"ui.placement_handles_tip": "Tiradores en la vista previa de un Refuerzo, Saliente o Encaje a presión: arrastra el marcador para moverlo por el modelo, gíralo por su anillo.",
"ui.placement_handles_tip": "Un marcador para arrastrar la operación por el modelo y, donde la operación puede girar, un anillo para girarla.",
"ui.plane": "Plano",
"ui.plane_at_angle": "Plano en ángulo",
"ui.plugins": "Complementos",
+2 -2
View File
@@ -2581,7 +2581,7 @@
"prefs.design_tools.imported_files": "Tuodut tiedostot",
"prefs.design_tools.note": "Huomautus",
"prefs.design_tools.off": "Pois",
"prefs.design_tools.placement_group": "Tuki, Tappi, Napsautusliitos",
"prefs.design_tools.placement_group": "Sijoitettavat piirteet",
"prefs.design_tools.rows": "Rivit",
"prefs.devices": "3D-hiiri",
"prefs.dialog_buttons": "Valintaikkunan painikkeet",
@@ -5850,7 +5850,7 @@
"ui.pitch": "Nousu",
"ui.placement": "Sijoitus",
"ui.placement_handles": "Sijoituskahvat",
"ui.placement_handles_tip": "Kahvat tuen, tapin tai napsautusliitoksen esikatselussa: siirrä merkkiä mallin pinnalla vetämällä ja käännä sitä renkaasta",
"ui.placement_handles_tip": "Merkki piirteen vetämiseen mallin yli ja, jos piirrettä voi kääntää, rengas sen kääntämiseen.",
"ui.plane": "Taso",
"ui.plane_at_angle": "Taso kulmassa",
"ui.plugins": "Laajennukset",
+2 -2
View File
@@ -2343,7 +2343,7 @@
"prefs.design_tools.imported_files": "Fichiers importés",
"prefs.design_tools.note": "Remarque",
"prefs.design_tools.off": "Désactivé",
"prefs.design_tools.placement_group": "Appui, Bossage, Clip",
"prefs.design_tools.placement_group": "Fonctions placées",
"prefs.design_tools.rows": "Lignes",
"prefs.devices": "Souris 3D",
"prefs.dialog_buttons": "Boutons OK",
@@ -5287,7 +5287,7 @@
"ui.pitch": "Pas",
"ui.placement": "Placement",
"ui.placement_handles": "Poignées de placement",
"ui.placement_handles_tip": "Poignées sur l'aperçu d'un appui, d'un bossage ou d'un clip : faire glisser le repère pour le déplacer sur le modèle, le tourner par son anneau",
"ui.placement_handles_tip": "Un repère pour faire glisser la fonction sur le modèle et, là où la fonction peut pivoter, un anneau pour la faire pivoter.",
"ui.plane": "Plan",
"ui.plane_at_angle": "Plan incliné",
"ui.plugins": "Modules externes",
+1 -1
View File
@@ -4915,7 +4915,7 @@
"ui.pitch": "Paso",
"ui.placement": "Colocación",
"ui.placement_handles": "Asas de colocación",
"ui.placement_handles_tip": "Asas na vista previa dun Apoio, un Saínte ou un Encaixe a presión: arrastra o marcador para movelo polo modelo, xírao polo seu anel",
"ui.placement_handles_tip": "Un marcador para arrastrar a operación polo modelo e, onde a operación pode xirar, un anel para xirala.",
"ui.plane": "Plano",
"ui.plane_at_angle": "Plano en ángulo",
"ui.plugins": "Complementos",
+2 -2
View File
@@ -2653,7 +2653,7 @@
"prefs.design_tools.imported_files": "קבצים מיובאים",
"prefs.design_tools.note": "הערה",
"prefs.design_tools.off": "כבוי",
"prefs.design_tools.placement_group": "משען, בליטה, חיבור נצמד",
"prefs.design_tools.placement_group": "פיצ'רים ממוקמים",
"prefs.design_tools.rows": "שורות",
"prefs.devices": "עכבר תלת־ממדי",
"prefs.dialog_buttons": "לחצני תיבות דו־שיח",
@@ -6070,7 +6070,7 @@
"ui.pitch": "פסיעה",
"ui.placement": "מיקום",
"ui.placement_handles": "ידיות מיקום",
"ui.placement_handles_tip": "סמן לגרירת התכונה על המודל וטבעת לסיבובה.",
"ui.placement_handles_tip": "סמן לגרירת התכונה על המודל, וטבעת לסיבובה במקום שבו אפשר לסובב את התכונה.",
"ui.plane": "מישור",
"ui.plane_at_angle": "מישור בזווית",
"ui.plugins": "תוספים",
+1 -1
View File
@@ -4907,7 +4907,7 @@
"ui.pitch": "Korak",
"ui.placement": "Postavljanje",
"ui.placement_handles": "Ručice za postavljanje",
"ui.placement_handles_tip": "Ručice na pregledu oslonca, ispusta ili škljocajućeg spoja: povucite oznaku kako biste je pomaknuli po modelu, okrenite je njezinim prstenom",
"ui.placement_handles_tip": "Oznaka za povlačenje značajke po modelu i, gdje se značajka može okretati, prsten za njezino okretanje.",
"ui.plane": "Ravnina",
"ui.plane_at_angle": "Ravnina pod kutom",
"ui.plugins": "Proširenja",
+1 -1
View File
@@ -4915,7 +4915,7 @@
"ui.pitch": "Emelkedés",
"ui.placement": "Elhelyezés",
"ui.placement_handles": "Elhelyezési fogantyúk",
"ui.placement_handles_tip": "Fogantyúk egy Támasz, Dudor vagy Pattintós illesztés előnézetén: húzza a jelölőt a modell fölött, a gyűrűjénél fogva forgassa el",
"ui.placement_handles_tip": "Egy jelölő az elem modellen való húzásához és, ahol az elem elforgatható, egy gyűrű az elforgatásához.",
"ui.plane": "Sík",
"ui.plane_at_angle": "Szögben álló sík",
"ui.plugins": "Bővítmények",
+1 -1
View File
@@ -4915,7 +4915,7 @@
"ui.pitch": "Pitch",
"ui.placement": "Penempatan",
"ui.placement_handles": "Handle penempatan",
"ui.placement_handles_tip": "Handle pada pratinjau Rest, Boss, atau Snap Fit: seret penanda untuk memindahkannya di model, putar lewat cincinnya",
"ui.placement_handles_tip": "Penanda untuk menyeret fitur di atas model dan, jika fitur dapat diputar, cincin untuk memutarnya.",
"ui.plane": "Bidang",
"ui.plane_at_angle": "Bidang Bersudut",
"ui.plugins": "Plugin",
+2 -2
View File
@@ -2588,7 +2588,7 @@
"prefs.design_tools.imported_files": "File importati",
"prefs.design_tools.note": "Avvisa",
"prefs.design_tools.off": "Disattivato",
"prefs.design_tools.placement_group": "Appoggio, Risalto, Aggancio a scatto",
"prefs.design_tools.placement_group": "Lavorazioni posizionate",
"prefs.design_tools.rows": "Righe",
"prefs.devices": "Mouse 3D",
"prefs.dialog_buttons": "Pulsanti OK",
@@ -5873,7 +5873,7 @@
"ui.pitch": "Passo",
"ui.placement": "Posizionamento",
"ui.placement_handles": "Maniglie di posizionamento",
"ui.placement_handles_tip": "Maniglie sull'anteprima di un Appoggio, Risalto o Aggancio a scatto: trascina il marcatore per spostarlo sul modello, ruotalo dal suo anello",
"ui.placement_handles_tip": "Un indicatore per trascinare la lavorazione sul modello e, dove la lavorazione può ruotare, un anello per ruotarla.",
"ui.plane": "Piano",
"ui.plane_at_angle": "Piano inclinato",
"ui.plugins": "Plugin",
+2 -2
View File
@@ -2653,7 +2653,7 @@
"prefs.design_tools.imported_files": "インポートしたファイル",
"prefs.design_tools.note": "通知",
"prefs.design_tools.off": "オフ",
"prefs.design_tools.placement_group": "レスト、ボス、スナップフィット",
"prefs.design_tools.placement_group": "配置するフィーチャー",
"prefs.design_tools.rows": "行数",
"prefs.devices": "3D マウス",
"prefs.dialog_buttons": "ダイアログのボタン",
@@ -6070,7 +6070,7 @@
"ui.pitch": "ピッチ",
"ui.placement": "配置",
"ui.placement_handles": "配置ハンドル",
"ui.placement_handles_tip": "モデル上でフィーチャーをドラッグするマーカーと、回転させるリング。",
"ui.placement_handles_tip": "モデル上でフィーチャーをドラッグするマーカーと、フィーチャーを回転できる場合は回転させるリング。",
"ui.plane": "平面",
"ui.plane_at_angle": "傾斜平面",
"ui.plugins": "プラグイン",
+1 -1
View File
@@ -5073,7 +5073,7 @@
"ui.pitch": "ბიჯი",
"ui.placement": "განთავსება",
"ui.placement_handles": "განთავსების სახელურები",
"ui.placement_handles_tip": "სახელურები საყრდენის, ბოსის ან ჩამკეტის გადახედვაზე: მარკერი გადაათრიეთ მოდელზე მის გადასაადგილებლად, ხოლო რგოლით შეაბრუნეთ",
"ui.placement_handles_tip": "მარკერი ოპერაციის მოდელზე გადასატანად და, სადაც ოპერაციის მობრუნება შეიძლება, რგოლი მის მოსაბრუნებლად.",
"ui.plane": "სიბრტყე",
"ui.plane_at_angle": "სიბრტყე კუთხით",
"ui.plugins": "პლაგინები",
+2 -2
View File
@@ -1996,7 +1996,7 @@
"prefs.design_tools.imported_files": "가져온 파일",
"prefs.design_tools.note": "알림",
"prefs.design_tools.off": "끄기",
"prefs.design_tools.placement_group": "레스트, 보스, 스냅 핏",
"prefs.design_tools.placement_group": "배치 피처",
"prefs.design_tools.rows": "행",
"prefs.devices": "3D 마우스",
"prefs.dialog_buttons": "대화 상자 버튼",
@@ -5195,7 +5195,7 @@
"ui.pipe": "파이프",
"ui.pitch": "피치",
"ui.placement": "배치",
"ui.placement_handles_tip": "모델 위로 피처를 드래그하는 마커와 회전하는 링입니다.",
"ui.placement_handles_tip": "모델 위로 피처를 드래그하는 마커와, 피처를 회전할 수 있는 경우 회전하는 링입니다.",
"ui.plane": "평면",
"ui.plane_at_angle": "각도 평면",
"ui.plugins": "플러그인",
+2 -2
View File
@@ -2582,7 +2582,7 @@
"prefs.design_tools.imported_files": "Importētie faili",
"prefs.design_tools.note": "Piezīme",
"prefs.design_tools.off": "Izslēgts",
"prefs.design_tools.placement_group": "Atbalsts, izcilnis, aizspiedsavienojums",
"prefs.design_tools.placement_group": "Izvietojamās operācijas",
"prefs.design_tools.rows": "Rindas",
"prefs.devices": "3D pele",
"prefs.dialog_buttons": "Dialoglodziņu pogas",
@@ -5903,7 +5903,7 @@
"ui.pitch": "Solis",
"ui.placement": "Izvietojums",
"ui.placement_handles": "Izvietošanas turi",
"ui.placement_handles_tip": "Marķieris operācijas vilkšanai pa modeli un gredzens tās pagriešanai.",
"ui.placement_handles_tip": "Marķieris operācijas vilkšanai pa modeli un, kur operāciju var pagriezt, gredzens tās pagriešanai.",
"ui.plane": "Plakne",
"ui.plane_at_angle": "Plakne leņķī",
"ui.plugins": "Spraudņi",
+1 -1
View File
@@ -4915,7 +4915,7 @@
"ui.pitch": "Pic",
"ui.placement": "Penempatan",
"ui.placement_handles": "Pemegang penempatan",
"ui.placement_handles_tip": "Pemegang pada pratonton Penyokong, Bonggol atau Sambungan Sentap: seret penanda untuk menggerakkannya merentasi model, putarkannya melalui cincinnya",
"ui.placement_handles_tip": "Penanda untuk menyeret ciri merentasi model dan, jika ciri boleh dipusingkan, gelang untuk memusingkannya.",
"ui.plane": "Satah",
"ui.plane_at_angle": "Satah Bersudut",
"ui.plugins": "Pemalam",
+1 -1
View File
@@ -4915,7 +4915,7 @@
"ui.pitch": "Stigning",
"ui.placement": "Plassering",
"ui.placement_handles": "Plasseringshåndtak",
"ui.placement_handles_tip": "Håndtak på forhåndsvisningen av et anlegg, skruefeste eller snap-fit: dra markøren for å flytte den over modellen, drei den med ringen",
"ui.placement_handles_tip": "En markør for å dra funksjonen over modellen og, der funksjonen kan dreies, en ring for å dreie den.",
"ui.plane": "Plan",
"ui.plane_at_angle": "Plan i vinkel",
"ui.plugins": "Programtillegg",
+1 -1
View File
@@ -4929,7 +4929,7 @@
"ui.pitch": "Steek",
"ui.placement": "Plaatsing",
"ui.placement_handles": "Plaatsingsgrepen",
"ui.placement_handles_tip": "Grepen op het voorbeeld van een Rustvlak, Nok of Snapverbinding: sleep de markering om hem over het model te verplaatsen, draai hem aan de ring",
"ui.placement_handles_tip": "Een markering om het kenmerk over het model te slepen en, waar het kenmerk kan draaien, een ring om het te draaien.",
"ui.plane": "Vlak",
"ui.plane_at_angle": "Vlak onder hoek",
"ui.plugins": "Plug-ins",
+2 -2
View File
@@ -2653,7 +2653,7 @@
"prefs.design_tools.imported_files": "Zaimportowane pliki",
"prefs.design_tools.note": "Uwaga",
"prefs.design_tools.off": "Wyłączone",
"prefs.design_tools.placement_group": "Podparcie, Występ, Zatrzask",
"prefs.design_tools.placement_group": "Umieszczane operacje",
"prefs.design_tools.rows": "Wiersze",
"prefs.devices": "Mysz 3D",
"prefs.dialog_buttons": "Przyciski okien dialogowych",
@@ -6070,7 +6070,7 @@
"ui.pitch": "Skok",
"ui.placement": "Umiejscowienie",
"ui.placement_handles": "Uchwyty umieszczania",
"ui.placement_handles_tip": "Uchwyty na podglądzie podparcia, występu lub zatrzasku: przeciągnij znacznik, aby przesunąć element po modelu, obróć go pierścieniem",
"ui.placement_handles_tip": "Znacznik do przeciągania operacji po modelu i, tam gdzie operację można obracać, pierścień do jej obracania.",
"ui.plane": "Płaszczyzna",
"ui.plane_at_angle": "Płaszczyzna pod kątem",
"ui.plugins": "Wtyczki",
+2 -2
View File
@@ -2588,7 +2588,7 @@
"prefs.design_tools.imported_files": "Arquivos importados",
"prefs.design_tools.note": "Observação",
"prefs.design_tools.off": "Desativado",
"prefs.design_tools.placement_group": "Apoio, Ressalto, Encaixe por pressão",
"prefs.design_tools.placement_group": "Recursos posicionados",
"prefs.design_tools.rows": "Linhas",
"prefs.devices": "Mouse 3D",
"prefs.dialog_buttons": "Botões das caixas de diálogo",
@@ -5927,7 +5927,7 @@
"ui.pitch": "Passo",
"ui.placement": "Posicionamento",
"ui.placement_handles": "Alças de posicionamento",
"ui.placement_handles_tip": "Um marcador para arrastar o recurso pelo modelo e um anel para girá-lo.",
"ui.placement_handles_tip": "Um marcador para arrastar o recurso pelo modelo e, onde o recurso pode girar, um anel para girá-lo.",
"ui.plane": "Plano",
"ui.plane_at_angle": "Plano em ângulo",
"ui.plugins": "Plug-ins",
+1 -1
View File
@@ -4915,7 +4915,7 @@
"ui.pitch": "Pas",
"ui.placement": "Plasare",
"ui.placement_handles": "Mânere de plasare",
"ui.placement_handles_tip": "Mânere pe previzualizarea unui Reazem, Bosaj sau a unei Prinderi cu clichet: trageți marcajul pentru a-l muta pe model, rotiți-l cu inelul său",
"ui.placement_handles_tip": "Un marcaj pentru a trage operația pe model și, acolo unde operația se poate roti, un inel pentru a o roti.",
"ui.plane": "Plan",
"ui.plane_at_angle": "Plan în unghi",
"ui.plugins": "Pluginuri",
+1 -1
View File
@@ -4870,7 +4870,7 @@
"ui.pitch": "Шаг",
"ui.placement": "Размещение",
"ui.placement_handles": "Манипуляторы размещения",
"ui.placement_handles_tip": "Манипуляторы на предпросмотре «Опоры», «Бобышки» или «Защёлки»: перетащите маркер, чтобы переместить его по модели, поверните за кольцо",
"ui.placement_handles_tip": "Маркер для перетаскивания операции по модели и, где операцию можно поворачивать, кольцо для её поворота.",
"ui.plane": "Плоскость",
"ui.plane_at_angle": "Плоскость под углом",
"ui.plugins": "Плагины",
+1 -1
View File
@@ -4907,7 +4907,7 @@
"ui.pitch": "Корак",
"ui.placement": "Постављање",
"ui.placement_handles": "Ручице за постављање",
"ui.placement_handles_tip": "Ручице на прегледу ослонца, испуста или шкљоцајућег споја: превуците ознаку да је померите по моделу, окрените је њеним прстеном",
"ui.placement_handles_tip": "Ознака за превлачење операције по моделу и, где се операција може окретати, прстен за њено окретање.",
"ui.plane": "Раван",
"ui.plane_at_angle": "Раван под углом",
"ui.plugins": "Проширења",
+1 -1
View File
@@ -4916,7 +4916,7 @@
"ui.pitch": "Stigning",
"ui.placement": "Placering",
"ui.placement_handles": "Placeringshandtag",
"ui.placement_handles_tip": "Handtag på förhandsvisningen av ett stöd, nav eller snäppfäste: dra markören för att flytta det över modellen, vrid det med dess ring",
"ui.placement_handles_tip": "En markör för att dra funktionen över modellen och, där funktionen kan vridas, en ring för att vrida den.",
"ui.plane": "Plan",
"ui.plane_at_angle": "Plan i vinkel",
"ui.plugins": "Insticksmoduler",
+1 -1
View File
@@ -4916,7 +4916,7 @@
"ui.pitch": "Adım",
"ui.placement": "Yerleşim",
"ui.placement_handles": "Yerleşim tutamaçları",
"ui.placement_handles_tip": "Bir Dayama, Kabarıklık veya Kilit Geçme önizlemesindeki tutamaçlar: işaretçiyi model üzerinde hareket ettirmek için sürükleyin, halkasıyla döndürün",
"ui.placement_handles_tip": "Özelliği model üzerinde sürüklemek için bir işaretçi ve, özellik döndürülebiliyorsa, döndürmek için bir halka.",
"ui.plane": "Düzlem",
"ui.plane_at_angle": "Açılı düzlem",
"ui.plugins": "Eklentiler",
+2 -2
View File
@@ -2623,7 +2623,7 @@
"prefs.design_tools.imported_files": "Імпортовані файли",
"prefs.design_tools.note": "Примітка",
"prefs.design_tools.off": "Вимк.",
"prefs.design_tools.placement_group": "Упор, Бобишка, Защіпкове з'єднання",
"prefs.design_tools.placement_group": "Розміщувані операції",
"prefs.design_tools.rows": "Рядки",
"prefs.devices": "3D-миша",
"prefs.dialog_buttons": "Кнопки діалогу",
@@ -5989,7 +5989,7 @@
"ui.pitch": "Крок",
"ui.placement": "Розміщення",
"ui.placement_handles": "Ручки розміщення",
"ui.placement_handles_tip": "Маркер, щоб перетягнути елемент по моделі, і кільце, щоб його повернути.",
"ui.placement_handles_tip": "Маркер, щоб перетягнути елемент по моделі, і, де елемент можна повернути, кільце, щоб його повернути.",
"ui.plane": "Площина",
"ui.plane_at_angle": "Площина під кутом",
"ui.plugins": "Плагіни",
+2 -2
View File
@@ -2653,7 +2653,7 @@
"prefs.design_tools.imported_files": "Tệp đã nhập",
"prefs.design_tools.note": "Thông báo",
"prefs.design_tools.off": "Tắt",
"prefs.design_tools.placement_group": "Gối đỡ, Trụ, Khớp gài",
"prefs.design_tools.placement_group": "Các tính năng đặt vị trí",
"prefs.design_tools.rows": "Hàng",
"prefs.devices": "Chuột 3D",
"prefs.dialog_buttons": "Nút hộp thoại",
@@ -6070,7 +6070,7 @@
"ui.pitch": "Bước xoắn",
"ui.placement": "Vị trí đặt",
"ui.placement_handles": "Tay nắm định vị",
"ui.placement_handles_tip": "Dấu để kéo đặc trưng trên mô hình và vòng để xoay.",
"ui.placement_handles_tip": "Dấu để kéo đặc trưng trên mô hình và, ở nơi đặc trưng có thể xoay, vòng để xoay.",
"ui.plane": "Mặt phẳng",
"ui.plane_at_angle": "Mặt phẳng theo góc",
"ui.plugins": "Plugin",
+2 -2
View File
@@ -2588,7 +2588,7 @@
"prefs.design_tools.imported_files": "導入的檔案",
"prefs.design_tools.note": "提示",
"prefs.design_tools.off": "關閉",
"prefs.design_tools.placement_group": "支座、凸台、卡扣",
"prefs.design_tools.placement_group": "放置的特徵",
"prefs.design_tools.rows": "行數",
"prefs.devices": "3D 游標",
"prefs.dialog_buttons": "對話框按鈕",
@@ -5929,7 +5929,7 @@
"ui.pitch": "螺距",
"ui.placement": "放置",
"ui.placement_handles": "放置手柄",
"ui.placement_handles_tip": "用於在模型上拖動特徵的標記和用於旋轉的圓環。",
"ui.placement_handles_tip": "用於在模型上拖動特徵的標記,以及在特徵可旋轉時用於旋轉的圓環。",
"ui.plane": "平面",
"ui.plane_at_angle": "角度平面",
"ui.plugins": "插件",
+2 -2
View File
@@ -2653,7 +2653,7 @@
"prefs.design_tools.imported_files": "导入的文件",
"prefs.design_tools.note": "提示",
"prefs.design_tools.off": "关闭",
"prefs.design_tools.placement_group": "支座、凸台、卡扣",
"prefs.design_tools.placement_group": "放置的特征",
"prefs.design_tools.rows": "行数",
"prefs.devices": "3D 鼠标",
"prefs.dialog_buttons": "对话框按钮",
@@ -6070,7 +6070,7 @@
"ui.pitch": "螺距",
"ui.placement": "放置",
"ui.placement_handles": "放置手柄",
"ui.placement_handles_tip": "用于在模型上拖动特征的标记和用于旋转的圆环。",
"ui.placement_handles_tip": "用于在模型上拖动特征的标记,以及在特征可旋转时用于旋转的圆环。",
"ui.plane": "平面",
"ui.plane_at_angle": "角度平面",
"ui.plugins": "插件",
+1
View File
@@ -20,4 +20,5 @@ from an `App` value the app defines. No app types and no engine.
| `auth::tokens_match`, `auth::remove_token_file`, `auth::TokenFile`, `auth::pid_path`, `auth::MIN_TOKEN_LEN` | Constant-time compare and token file housekeeping |
| `Effect` | What a request still running does when it finishes: reads only, writes a file, or changes; `when_done(document)` says it |
| `server::Hub`, `server::Protocol`, `server::Summary`, `server::Waits`, `server::new_token` | The loopback JSON-lines server behind the token (desktop only); the app says what its requests mean |
| `client::Connection`, `client::Request`, `client::CallError`, `client::Waits` | The client (desktop only): the app's token on every request, one bounded round trip, a fresh connection after a lost one, the wait for a request still running (`control.pending`); "not running" told from "lost during the call" |
| `ControlRequest`, `ControlResponse`, `DocumentWatch`, `DOCUMENT_WATCH` | A request as the window gets it; the document id and revision subscribed connections wait on |
+616
View File
@@ -0,0 +1,616 @@
//! The control channel's client (docs/control-protocol.md): a connection to an app's loopback
//! control port, with the app's token ([`App::client_token`]) on every request, one bounded
//! round trip per request, a fresh connection after one is lost, and the wait for a request the
//! app says is still running (#1618), through `control.pending`. Its errors tell an app that is
//! not running from one that went away during a call ([`CallError`]). What a request means, and
//! anything an app adds to its parameters, is the caller's.
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpStream, ToSocketAddrs};
use std::time::{Duration, Instant};
use serde_json::{Value, json};
use crate::Effect;
use crate::auth::App;
/// Longest reply line accepted from the app.
pub const MAX_REPLY: u64 = 64 << 20;
/// How long a reply may take: kernel operations (booleans, fillets) on big parts can take a while.
pub const READ_TIMEOUT: Duration = Duration::from_secs(300);
/// How long the app may take to answer a request sent [`Request::once`]: a little longer than its
/// control server waits for a long request (3600 s), so its "still running" reply arrives first.
pub const ONCE_TIMEOUT: Duration = Duration::from_secs(3720);
/// How long connecting to the app may take.
const CONNECT_TIMEOUT: Duration = Duration::from_millis(800);
/// How often a long wait is reported ([`Connection::with_progress`]).
const PROGRESS_EVERY: Duration = Duration::from_secs(30);
/// How an error starts when a request is still running in the app after the wait: the request
/// was not cancelled and its reply waits on the connection; [`Connection::pending`] gets it.
pub const STILL_RUNNING: &str = "still running:";
/// How long to wait for a request the app says is still running.
#[derive(Clone, Copy, Debug)]
pub struct Waits {
/// How long a call goes on waiting, asking `control.pending`, after the app said its request
/// is still running (the app says so after 60 s), before it says so itself.
pub pending: Duration,
/// How long a later call, or [`Connection::pending`], waits for a request still running from
/// an earlier call.
pub settle: Duration,
/// How often `control.pending` is asked while waiting.
pub poll: Duration,
}
impl Default for Waits {
fn default() -> Self {
Waits { pending: Duration::from_secs(240), settle: Duration::from_secs(60), poll: Duration::from_millis(500) }
}
}
/// Why a call has no result. Each holds the message for the caller (an agent reads it).
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CallError {
/// Nothing listens at the address: the app is not running, or it quit before the call was
/// sent. The message says how to start it.
NotRunning(String),
/// The app went away or did not answer during the call, after running it or not.
Lost(String),
/// The request is still running in the app after the wait (it starts with [`STILL_RUNNING`]),
/// or an earlier one is and this one was not sent.
StillRunning(String),
/// The app answered with an error (a refused token says which token file it came from).
Refused(String),
/// The reply was not a control reply.
BadReply(String),
}
impl CallError {
/// The message.
pub fn message(&self) -> &str {
match self {
CallError::NotRunning(m) | CallError::Lost(m) | CallError::StillRunning(m) | CallError::Refused(m) | CallError::BadReply(m) => m,
}
}
}
impl std::fmt::Display for CallError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.message())
}
}
impl std::error::Error for CallError {}
impl From<CallError> for String {
fn from(e: CallError) -> String {
e.message().to_string()
}
}
/// A request: its method and parameters, how messages name it and what it does if it is still
/// running when its call returns.
#[derive(Clone, Debug)]
pub struct Request {
pub method: String,
pub params: Value,
/// The request in messages ("`engine.execute` (solid.fillet)").
pub what: String,
/// What it still does when it finishes after its wait.
pub effect: Effect,
/// Never sent twice: on a fresh connection, waiting up to [`ONCE_TIMEOUT`], and once it is
/// being written any error is final, as the app may have run it. Otherwise a request that
/// finds its connection lost is sent once more on a fresh one (the app may have restarted).
pub once: bool,
}
impl Request {
/// `method` with `params`, named "`method`" in messages, taken to change the document, sent
/// again once on a fresh connection when its connection was lost.
pub fn new(method: &str, params: Value) -> Self {
Request { method: method.to_string(), params, what: format!("`{method}`"), effect: Effect::Changes, once: false }
}
}
/// A control reply as a call's result: its `result`, or its `error`.
pub fn outcome(v: &Value) -> Result<Value, String> {
if v.get("ok").and_then(Value::as_bool) == Some(true) {
Ok(v.get("result").cloned().unwrap_or(Value::Null))
} else {
Err(v.get("error").and_then(Value::as_str).unwrap_or("error").to_string())
}
}
/// A request the app said is still running. Its reply will come on this connection, through
/// `control.pending`, so the connection is kept for it.
struct Outstanding {
what: String,
/// What it does when it finishes; `None`: not known (a request this client did not send).
effect: Option<Effect>,
since: Instant,
}
impl Outstanding {
fn secs(&self) -> u64 {
self.since.elapsed().as_secs()
}
}
/// What became of a request still running when its call returned, once a later call waited it
/// out: kept for [`Connection::pending`].
enum Settled {
Finished { what: String, reply: Value },
Lost { what: String },
}
/// The end of a wait for an outstanding request.
enum Waited {
/// It finished: its own reply.
Done(Value),
/// The app holds nothing (any more) for this connection.
Nothing,
/// Still running when the wait ran out.
Running,
}
/// A connection to the app listening at an address. It connects when a call needs it, so a
/// client started before the app reaches it once it runs.
pub struct Connection {
app: App,
/// What the app calls its document in messages ("design").
document: &'static str,
addr: String,
port: u16,
conn: Option<(BufReader<TcpStream>, TcpStream)>,
/// Counts the connections made, so a caller can tell a fresh one from the one it asked.
serial: u64,
next_id: u64,
/// The token given, when one was. Otherwise each request looks the token up again, since
/// every launch of the app makes a new one.
explicit_token: Option<String>,
/// A request still running in the app; its reply waits on `conn`.
outstanding: Option<Outstanding>,
/// An outstanding request's end, once a later call waited for it (for `pending`).
settled: Option<Settled>,
waits: Waits,
/// Where a long wait is reported.
progress: fn(&str),
}
impl Connection {
/// A connection to `app` at `addr` (`host:port`), with `token` when given, else the app's
/// token found the usual way ([`App::client_token`]); not connected yet.
pub fn new(app: App, addr: &str, token: Option<&str>) -> Self {
let port = addr.rsplit(':').next().and_then(|p| p.parse::<u16>().ok()).unwrap_or(0);
Self {
app,
document: "design",
addr: addr.to_string(),
port,
conn: None,
serial: 0,
next_id: 1,
explicit_token: token.map(str::to_string),
outstanding: None,
settled: None,
waits: Waits::default(),
progress: |_| {},
}
}
/// What the app calls its document in messages ("design", the default).
#[must_use]
pub fn with_document(mut self, document: &'static str) -> Self {
self.document = document;
self
}
/// Report a long wait for a request still running to `progress`, every 30 s.
#[must_use]
pub fn with_progress(mut self, progress: fn(&str)) -> Self {
self.progress = progress;
self
}
/// How long to wait for a request still running.
pub fn set_waits(&mut self, waits: Waits) {
self.waits = waits;
}
/// The address it connects to.
pub fn addr(&self) -> &str {
&self.addr
}
/// The port of the address (0 when it names none).
pub fn port(&self) -> u16 {
self.port
}
/// The token given, if one was.
pub fn explicit_token(&self) -> Option<&str> {
self.explicit_token.as_deref()
}
/// Whether a connection is open now.
pub fn is_connected(&self) -> bool {
self.conn.is_some()
}
/// The number of connections made so far: the same number while the same connection stays
/// open.
pub fn serial(&self) -> u64 {
self.serial
}
/// Whether a request of this connection is still running in the app.
pub fn has_outstanding(&self) -> bool {
self.outstanding.is_some()
}
/// Close the connection. A request still running loses its reply with it.
pub fn disconnect(&mut self) {
self.conn = None;
if let Some(o) = self.outstanding.take() {
self.settled = Some(Settled::Lost { what: o.what });
}
}
/// Connect afresh. Failing to reach the app at all is `NotConnected`, so [`Connection::explain`]
/// can tell it from a connection lost during a call.
///
/// # Errors
/// `NotConnected` when nothing listens at the address (or it does not resolve).
pub fn connect(&mut self) -> std::io::Result<()> {
self.disconnect();
let not_connected = |e: std::io::Error| std::io::Error::new(std::io::ErrorKind::NotConnected, e);
let mut last = std::io::Error::new(std::io::ErrorKind::NotFound, format!("cannot resolve {}", self.addr));
for sa in self.addr.to_socket_addrs().map_err(not_connected)? {
match TcpStream::connect_timeout(&sa, CONNECT_TIMEOUT) {
Ok(s) => {
s.set_nodelay(true).ok();
s.set_read_timeout(Some(READ_TIMEOUT)).ok();
let read = s.try_clone()?;
self.conn = Some((BufReader::new(read), s));
self.serial += 1;
return Ok(());
}
Err(e) => last = e,
}
}
Err(not_connected(last))
}
/// See whether the app is there now; the error says why not. The connection doesn't stay
/// open: the app closes one that shows no token within a few seconds, and the first call may
/// be long away. That call connects again.
///
/// # Errors
/// As [`Connection::connect`].
pub fn try_connect(&mut self) -> std::io::Result<()> {
self.connect()?;
self.disconnect();
Ok(())
}
/// The error for a failed exchange: whether the app is not running (and how to start it) or
/// went away during the call.
pub fn explain(&self, e: &std::io::Error) -> CallError {
let (addr, port, name, document) = (&self.addr, self.port, self.app.name, self.document);
let start = format!("{} --control {port}", name.to_lowercase());
if e.kind() == std::io::ErrorKind::NotConnected {
CallError::NotRunning(format!(
"no {name} app is listening at {addr} ({e}): it is not running, or it quit. Start it with `{start}` (or ask the user to); the \
next call connects to it"
))
} else {
CallError::Lost(format!(
"the {name} app at {addr} did not answer ({e}): it may have quit or crashed during this call, after running it or not. Inspect \
the {document} before sending a change again; if the app quit, start it with `{start}`"
))
}
}
/// The request line for `method`: a fresh id and the app's token (looked up again for every
/// request: an app restarted mid-session has a new one).
pub fn request_line(&mut self, method: &str, params: Value) -> String {
let id = self.next_id;
self.next_id += 1;
let mut msg = json!({"id": id, "method": method, "params": params});
if let Some(t) = self.app.client_token(self.port, self.explicit_token.as_deref()) {
msg["token"] = json!(t.token);
}
msg.to_string()
}
/// One request line out and one reply line back, at most [`MAX_REPLY`] long, connecting
/// first when not connected. Any failure closes the connection (a timed-out read leaves a
/// late reply on the stream).
fn roundtrip(&mut self, line: &str) -> std::io::Result<String> {
if self.conn.is_none() {
self.connect()?;
}
let Some((reader, writer)) = self.conn.as_mut() else { return Err(std::io::Error::other("not connected")) };
// One write: a request that loses its newline still reaches the app as a request.
let sent = writer.write_all(format!("{line}\n").as_bytes()).and_then(|()| writer.flush());
if let Err(e) = sent {
self.disconnect();
return Err(e);
}
let mut reply = String::new();
let n = match reader.by_ref().take(MAX_REPLY).read_line(&mut reply) {
Ok(n) => n,
Err(e) => {
self.disconnect();
return Err(e);
}
};
if n == 0 || !reply.ends_with('\n') {
self.disconnect();
return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "connection closed"));
}
Ok(reply)
}
fn set_read_timeout(&self, t: Duration) {
if let Some((_, writer)) = &self.conn {
writer.set_read_timeout(Some(t)).ok();
}
}
/// A reply line as JSON.
fn parse(reply: &str) -> Result<Value, CallError> {
serde_json::from_str(reply).map_err(|e| CallError::BadReply(format!("bad reply from the app: {e}")))
}
/// A control reply as a call's result; a refused token says which token file it came from
/// (and whether it is stale).
///
/// # Errors
/// [`CallError::Refused`] with the app's error.
pub fn outcome(&self, v: &Value) -> Result<Value, CallError> {
outcome(v).map_err(|error| {
CallError::Refused(
self.app.client_token(self.port, self.explicit_token.as_deref()).map_or_else(|| error.clone(), |t| t.explain_error(&error)),
)
})
}
/// `method` with `params`, at once and once: not behind a request still running on this
/// connection, no second try. The app's whole reply (`ok`, `result` or `error`, and anything
/// it adds), for a request the app answers at once on any connection
/// (`control.capabilities`, `control.running`).
///
/// # Errors
/// As [`Connection::explain`]; [`CallError::BadReply`].
pub fn ask(&mut self, method: &str, params: Value) -> Result<Value, CallError> {
let line = self.request_line(method, params);
let reply = self.roundtrip(&line).map_err(|e| self.explain(&e))?;
Self::parse(&reply)
}
/// `method` with `params`, waiting at most `timeout` for the reply and never behind a request
/// still running: for a request the app answers within a frame, asked again later when the
/// app is busy. One try.
///
/// # Errors
/// [`CallError::StillRunning`] when a request of this connection is still running; as
/// [`Connection::ask`] and [`Connection::outcome`].
pub fn quick(&mut self, method: &str, params: Value, timeout: Duration) -> Result<Value, CallError> {
if self.outstanding.is_some() {
return Err(CallError::StillRunning("a request is still running in the app".into()));
}
if self.conn.is_none() {
self.connect().map_err(|e| self.explain(&e))?;
}
let line = self.request_line(method, params);
self.set_read_timeout(timeout);
let reply = self.roundtrip(&line);
self.set_read_timeout(READ_TIMEOUT);
let reply = reply.map_err(|e| self.explain(&e))?;
let v = Self::parse(&reply)?;
self.outcome(&v)
}
/// Ask the app, on this connection only, about the request it still runs for it.
fn ask_pending(&mut self) -> Result<Value, CallError> {
if self.conn.is_none() {
self.disconnect();
return Err(CallError::Lost("the connection to the app was lost".into()));
}
let v = self.ask("control.pending", json!({}))?;
outcome(&v).map_err(CallError::Refused).inspect_err(|_| self.disconnect())
}
/// `control.cancel` for the request still running on this connection. Sent at once, without
/// waiting for that request first, and never on a fresh connection, which has nothing to
/// cancel. Once the app no longer holds it (removed, stopped or finished), later calls don't
/// wait for it.
///
/// # Errors
/// As [`Connection::ask`]; [`CallError::Refused`].
pub fn cancel(&mut self, params: Value) -> Result<Value, CallError> {
if self.conn.is_none() {
return Ok(json!({"cancelled": null, "note": "nothing to cancel: no request of this client is running in the app"}));
}
let v = self.ask("control.cancel", params)?;
let r = outcome(&v).map_err(CallError::Refused);
if r.is_ok() {
self.outstanding = None;
}
r
}
/// Wait up to `limit` for the outstanding request, asking `control.pending` now and then and
/// reporting how long it takes. An error: the connection, and so the reply, was lost.
fn wait_outstanding(&mut self, limit: Duration) -> Result<Waited, CallError> {
let start = Instant::now();
let mut told = start;
loop {
let Some(o) = &self.outstanding else { return Ok(Waited::Nothing) };
let what = o.what.clone();
let r = self.ask_pending()?;
if r.get("pending").is_none_or(Value::is_null) {
self.outstanding = None;
return Ok(Waited::Nothing);
}
if r.get("done").and_then(Value::as_bool) == Some(true) {
self.outstanding = None;
return Ok(Waited::Done(r.get("reply").cloned().unwrap_or(Value::Null)));
}
let waited = start.elapsed();
if waited >= limit {
return Ok(Waited::Running);
}
if told.elapsed() >= PROGRESS_EVERY {
let secs = self.outstanding.as_ref().map_or(0, Outstanding::secs);
(self.progress)(&format!("{what} is still running in the app ({secs} s); waiting for it"));
told = Instant::now();
}
std::thread::sleep(self.waits.poll.min(limit.saturating_sub(waited)));
}
}
/// The error for the outstanding request still running after its wait.
fn still_running(&self, earlier: bool) -> CallError {
let Some(o) = &self.outstanding else { return CallError::StillRunning(format!("{STILL_RUNNING} a request is still running in the app")) };
let document = self.document;
let effect =
o.effect.map_or_else(|| format!("it was not cancelled and may still change the {document} when it finishes"), |e| e.when_done(document));
let settle = self.waits.settle.as_secs();
CallError::StillRunning(if earlier {
format!(
"{STILL_RUNNING} an earlier request, {}, is still running in the app ({} s; {effect}), so this call was not sent. Call the \
`pending` tool to wait for its result, then send this call again",
o.what,
o.secs()
)
} else {
format!(
"{STILL_RUNNING} {} is still running in the app after {} s ({effect}); don't send it again. Call the `pending` tool to \
wait for its result (up to {settle} s per call); other calls wait for it first",
o.what,
o.secs()
)
})
}
/// Before a new request: wait for one still running from an earlier call, keeping its end for
/// `pending`. Still running after the wait: the new request is not sent.
fn settle_earlier(&mut self) -> Result<(), CallError> {
let Some(what) = self.outstanding.as_ref().map(|o| o.what.clone()) else { return Ok(()) };
match self.wait_outstanding(self.waits.settle) {
Ok(Waited::Done(reply)) => self.settled = Some(Settled::Finished { what, reply }),
Ok(Waited::Nothing) => {}
Ok(Waited::Running) => return Err(self.still_running(true)),
// The connection was lost, and the earlier reply with it (`pending` says so): the new
// request goes on a fresh connection.
Err(_) => {}
}
Ok(())
}
/// `control.pending` for the client: the end of the request still running from an earlier
/// call, waiting for it up to the settle wait: `{pending: what | null, done?, reply?, lost?,
/// running_s?}`. Never sent on a fresh connection, which has nothing pending.
pub fn pending(&mut self) -> Value {
if let Some(s) = self.settled.take() {
return match s {
Settled::Finished { what, reply } => json!({"pending": what, "done": true, "reply": reply}),
Settled::Lost { what } => json!({"pending": what, "lost": true}),
};
}
let Some(what) = self.outstanding.as_ref().map(|o| o.what.clone()) else { return json!({"pending": null}) };
match self.wait_outstanding(self.waits.settle) {
Ok(Waited::Done(reply)) => json!({"pending": what, "done": true, "reply": reply}),
Ok(Waited::Nothing) => json!({"pending": null}),
Ok(Waited::Running) => json!({"pending": what, "done": false, "running_s": self.outstanding.as_ref().map_or(0, Outstanding::secs)}),
Err(_) => {
self.settled = None;
json!({"pending": what, "lost": true})
}
}
}
/// Send `request` and wait for its reply: the app's whole reply (`ok`, `result` or `error`,
/// and anything it adds). First a request still running from an earlier call is waited for
/// (except for `control.running`, which the app answers at once); a request the app says is
/// still running is waited for through `control.pending`; a request refused unrun as busy
/// behind one this client did not know of is sent once more after that one, when
/// `retry_busy`.
///
/// # Errors
/// As [`Connection::explain`]: a request whose connection is lost is sent once more on a fresh
/// one (unless [`Request::once`]), and when nothing listens any more the app went away, maybe
/// while running it ([`CallError::Lost`]); [`CallError::StillRunning`]; [`CallError::BadReply`].
pub fn exchange(&mut self, request: Request, retry_busy: bool) -> Result<Value, CallError> {
let Request { method, params, what, effect, once } = request;
if method != "control.running" {
self.settle_earlier()?;
}
let again = retry_busy.then(|| params.clone());
let sent = Instant::now();
let line = self.request_line(&method, params);
let reply = if once {
// A fresh connection (an idle one may have been closed by an app restart; connecting
// again sends nothing).
self.connect().or_else(|_| self.connect()).and_then(|()| {
self.set_read_timeout(ONCE_TIMEOUT);
let r = self.roundtrip(&line);
self.set_read_timeout(READ_TIMEOUT);
r
})
} else {
self.roundtrip(&line).or_else(|lost| {
self.roundtrip(&line).map_err(|e| if e.kind() == std::io::ErrorKind::NotConnected && lost.kind() != e.kind() { lost } else { e })
})
}
.map_err(|e| self.explain(&e))?;
let v = Self::parse(&reply)?;
// Timed out in the app, still running: wait for its result on this connection.
if v.get("running").and_then(Value::as_bool) == Some(true) {
self.outstanding = Some(Outstanding { what: what.clone(), effect: Some(effect), since: sent });
(self.progress)(&format!(
"{what} is still running in the app after {} s; waiting up to {} s more for it",
sent.elapsed().as_secs(),
self.waits.pending.as_secs()
));
let document = self.document;
return match self.wait_outstanding(self.waits.pending)? {
Waited::Done(reply) => Ok(reply),
Waited::Nothing => Err(CallError::Lost(format!(
"{what} timed out in the app, which kept no result for it: inspect the {document} before sending it again"
))),
Waited::Running => Err(self.still_running(false)),
};
}
// Refused unrun behind a request this client did not know of: wait for that one, then
// send this one once more.
if v.get("busy").and_then(Value::as_bool) == Some(true)
&& let Some(params) = again
{
self.outstanding = Some(Outstanding { what: "an earlier request".into(), effect: None, since: Instant::now() });
self.settle_earlier()?;
return self.exchange(Request { method, params, what, effect, once }, false);
}
Ok(v)
}
/// [`Connection::exchange`] (retrying a busy refusal once) and the reply's result.
///
/// # Errors
/// As [`Connection::exchange`] and [`Connection::outcome`].
pub fn call(&mut self, request: Request) -> Result<Value, CallError> {
let v = self.exchange(request, true)?;
self.outcome(&v)
}
}
#[cfg(test)]
#[path = "client_tests.rs"]
mod tests;
+190
View File
@@ -0,0 +1,190 @@
//! The client against the shared server ([`crate::server::Hub`]) on an ephemeral loopback port.
use super::*;
use crate::ControlRequest;
use crate::server::{Hub, Protocol, Stop, Summary, Waits as ServerWaits};
use std::ffi::OsString;
use std::net::TcpListener;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::mpsc::channel;
const TOKEN: &str = "0123456789abcdef0123456789abcdef";
fn no_dir() -> Option<PathBuf> {
None
}
fn no_env(_: &str) -> Option<OsString> {
None
}
fn utc(secs: u64) -> String {
secs.to_string()
}
/// An app with no token files and no environment: a client sends only the token it is given.
const APP: App = App { name: "Probe", data_dir: no_dir, env: no_env, utc, default_port: 0 };
/// An app whose requests all may change its document.
struct Probe;
impl Protocol for Probe {
fn summary(&self, method: &str, _: &Value) -> Summary {
Summary { what: format!("`{method}`"), effect: Effect::Changes, command: None, steps: None, long: false }
}
fn document(&self) -> &'static str {
"design"
}
fn change_event(&self) -> &'static str {
"design.changed"
}
fn revision_method(&self) -> &'static str {
"document.revision"
}
fn docs(&self) -> &'static str {
"docs/control-protocol.md"
}
fn capabilities(&self) -> Value {
json!({})
}
}
/// The app on `port` (0: an ephemeral one) behind [`TOKEN`]: its window answers each request after
/// `delay` with the request's method and parameters, and the server says a request is still
/// running after `wait`.
fn serve(port: u16, delay: Duration, wait: Duration) -> Stop {
// A port just closed may take a moment to be free again on a loaded machine.
let started = Instant::now();
let mut bound = TcpListener::bind(("127.0.0.1", port));
while bound.is_err() && started.elapsed() < Duration::from_secs(2) {
std::thread::sleep(Duration::from_millis(10));
bound = TcpListener::bind(("127.0.0.1", port));
}
let listener = bound.unwrap();
let (tx, rx) = channel::<ControlRequest>();
std::thread::spawn(move || {
for req in rx {
std::thread::sleep(delay);
let _ = req.reply.send(json!({"ok": true, "result": {"method": req.method, "params": req.params}}));
}
});
let waits = ServerWaits { short: wait, long: wait, token: Duration::from_secs(5) };
let hub = Arc::new(Hub::new(Probe, || {}, 1 << 20).with_waits(waits));
hub.listen_stoppable(listener, TOKEN.into(), tx, false).unwrap()
}
fn addr(port: u16) -> String {
format!("127.0.0.1:{port}")
}
/// A port nothing listens on.
fn free_port() -> u16 {
TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port()
}
#[test]
fn a_call_is_one_round_trip_with_the_token() {
let app = serve(0, Duration::ZERO, Duration::from_secs(10));
let mut c = Connection::new(APP, &addr(app.port()), Some(TOKEN));
assert!(!c.is_connected());
let r = c.call(Request::new("document.inspect", json!({"a": 1}))).unwrap();
assert_eq!(r, json!({"method": "document.inspect", "params": {"a": 1}}));
assert!(c.is_connected());
let serial = c.serial();
// the next call goes on the same connection
c.call(Request::new("document.inspect", json!({}))).unwrap();
assert_eq!(c.serial(), serial);
// `try_connect` sees the app there and leaves no connection open
let mut fresh = Connection::new(APP, &addr(app.port()), Some(TOKEN));
fresh.try_connect().unwrap();
assert!(!fresh.is_connected());
}
#[test]
fn a_wrong_token_or_none_is_refused() {
let app = serve(0, Duration::ZERO, Duration::from_secs(10));
for token in [Some("fedcba9876543210fedcba9876543210"), None] {
let mut c = Connection::new(APP, &addr(app.port()), token);
match c.call(Request::new("document.inspect", json!({}))) {
Err(CallError::Refused(e)) => assert!(e.starts_with("unauthorized"), "{token:?}: {e}"),
other => panic!("{token:?}: {other:?}"),
}
}
}
#[test]
fn an_app_not_running_says_how_to_start_it() {
let port = free_port();
let mut c = Connection::new(APP, &addr(port), Some(TOKEN));
let e = c.call(Request::new("document.inspect", json!({}))).unwrap_err();
let CallError::NotRunning(m) = &e else { panic!("{e:?}") };
assert!(m.starts_with(&format!("no Probe app is listening at 127.0.0.1:{port}")), "{m}");
assert!(m.contains(&format!("`probe --control {port}`")), "{m}");
assert_eq!(c.try_connect().unwrap_err().kind(), std::io::ErrorKind::NotConnected);
}
/// An app that goes away during a call is lost, not "not running"; the next call says it is not
/// running, and once it runs again the next call reaches it.
#[test]
fn an_app_gone_during_a_call_is_lost_and_reached_again_once_back() {
let app = serve(0, Duration::ZERO, Duration::from_secs(10));
let port = app.port();
let mut c = Connection::new(APP, &addr(port), Some(TOKEN));
c.call(Request::new("document.inspect", json!({}))).unwrap();
app.stop();
let e = c.call(Request::new("document.inspect", json!({}))).unwrap_err();
let CallError::Lost(m) = &e else { panic!("{e:?}") };
assert!(m.contains("may have quit") && m.contains("Inspect the design") && m.contains(&format!("`probe --control {port}`")), "{m}");
assert!(matches!(c.call(Request::new("document.inspect", json!({}))), Err(CallError::NotRunning(_))));
let _back = serve(port, Duration::ZERO, Duration::from_secs(10));
assert_eq!(c.call(Request::new("ui.view", json!({}))).unwrap()["method"], json!("ui.view"));
}
/// A server that reads a request and closes the connection without a reply: the call is lost
/// (after one more try on a fresh connection), and the reply is never taken for "not running".
#[test]
fn a_connection_closed_without_a_reply_is_lost() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
std::thread::spawn(move || {
for stream in listener.incoming().flatten() {
let mut line = String::new();
let _ = BufReader::new(stream).read_line(&mut line);
}
});
let mut c = Connection::new(APP, &addr(port), Some(TOKEN));
let e = c.call(Request::new("document.inspect", json!({}))).unwrap_err();
assert!(matches!(e, CallError::Lost(_)), "{e:?}");
assert!(!c.is_connected());
}
/// A request the app says is still running is waited for through `control.pending`, on the same
/// connection, and its own reply is the call's result.
#[test]
fn a_request_still_running_is_waited_for_through_pending() {
let app = serve(0, Duration::from_millis(400), Duration::from_millis(50));
let mut c = Connection::new(APP, &addr(app.port()), Some(TOKEN));
c.set_waits(Waits { pending: Duration::from_secs(10), settle: Duration::from_secs(10), poll: Duration::from_millis(5) });
let r = c.call(Request::new("solid.slow", json!({"n": 2}))).unwrap();
assert_eq!(r, json!({"method": "solid.slow", "params": {"n": 2}}));
assert!(!c.has_outstanding());
assert_eq!(c.pending(), json!({"pending": null}));
}
/// Past the wait, the call says the request is still running, and `pending` later has its end.
#[test]
fn a_request_still_running_past_the_wait_is_reported_and_its_end_kept() {
let app = serve(0, Duration::from_millis(400), Duration::from_millis(20));
let mut c = Connection::new(APP, &addr(app.port()), Some(TOKEN));
c.set_waits(Waits { pending: Duration::from_millis(30), settle: Duration::from_secs(10), poll: Duration::from_millis(5) });
let mut slow = Request::new("solid.slow", json!({}));
slow.what = "`solid.slow` (now)".into();
let e = c.call(slow).unwrap_err();
let CallError::StillRunning(m) = &e else { panic!("{e:?}") };
assert!(m.starts_with(STILL_RUNNING) && m.contains("`solid.slow` (now)") && m.contains("will still apply"), "{m}");
assert!(c.has_outstanding());
let end = c.pending();
assert_eq!(end["done"], json!(true), "{end}");
assert_eq!(end["reply"]["result"]["method"], json!("solid.slow"), "{end}");
}
+7 -4
View File
@@ -1,14 +1,17 @@
//! The control channel any app may share (docs/control-protocol.md): the per-launch token and its
//! files ([`auth`]), the loopback server that admits a connection by the token and hands each
//! request to the window ([`server`]), the request the window answers ([`ControlRequest`]), the
//! document watch subscribed connections wait on ([`DocumentWatch`]), and what a request still running
//! does when it finishes ([`Effect`]). It needs no UI toolkit; the window's own requests over egui
//! (`ui.screenshot`, `ui.resize`) are `solidcraft-control-egui`.
//! request to the window ([`server`]), the client that talks to it ([`client`]), the request the
//! window answers ([`ControlRequest`]), the document watch subscribed connections wait on
//! ([`DocumentWatch`]), and what a request still running does when it finishes ([`Effect`]). It
//! needs no UI toolkit; the window's own requests over egui (`ui.screenshot`, `ui.resize`) are
//! `solidcraft-control-egui`.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
pub mod auth;
mod cancel;
#[cfg(not(target_arch = "wasm32"))]
pub mod client;
mod effect;
mod request;
#[cfg(not(target_arch = "wasm32"))]
+4
View File
@@ -1197,6 +1197,10 @@ pub enum FeatureKind {
tool: Option<FaceAt>,
#[serde(default, skip_serializing_if = "Option::is_none")]
sketch: Option<SketchId>,
/// With `sketch`: its curves joined end to end first, so a closed loop cuts along itself
/// only. Off (each curve on its own, an open one extended past its ends) in older designs.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
loops: bool,
},
/// Scale bodies about a point (uniform, or per axis).
Scale {
+13 -7
View File
@@ -9715,7 +9715,7 @@ fn eval_feature(
}
apply_traced(doc, st, f, made, *operation, &[])
}
FeatureKind::SplitFace { faces, body, plane, tool, sketch } => {
FeatureKind::SplitFace { faces, body, plane, tool, sketch, loops } => {
let i = body_at(st, body, &crate::plain(faces))?;
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
let mut picked: Vec<usize> = Vec::new();
@@ -9735,12 +9735,18 @@ fn eval_feature(
}
(None, Some(sid)) => {
let ss = st.sketch(sid.0).ok_or_else(|| DocError::Unknown(format!("sketch {sid}")))?;
// Each curve's exact segments (lines and arcs split by their own plane and cylinder).
let curves: Vec<Vec<solidcore_record::Seg2>> = (0..ss.sketch.curves.len())
.filter(|ci| ss.sketch.curves.get(*ci).is_some_and(|c| !c.construction))
.map(|ci| ss.sketch.segs(ci))
.filter(|segs| !segs.is_empty())
.collect();
// Each curve's exact segments (lines and arcs split by their own plane and cylinder),
// or with `loops` the curves joined end to end: a closed chain is not extended.
let curves: Vec<Vec<solidcore_record::Seg2>> = if *loops {
let ids: Vec<String> = ss.sketch.curves.iter().filter(|c| !c.construction).map(|c| c.id.clone()).collect();
surface::curve_chains(ss, &ids)?
} else {
(0..ss.sketch.curves.len())
.filter(|ci| ss.sketch.curves.get(*ci).is_some_and(|c| !c.construction))
.map(|ci| ss.sketch.segs(ci))
.filter(|segs| !segs.is_empty())
.collect()
};
if curves.is_empty() {
return Err(DocError::Invalid(format!("sketch `{}` has no curves to split with", ss.name)));
}
+1 -1
View File
@@ -824,7 +824,7 @@ const BLEND_STATIONS: usize = 41;
/// The sketch curves `ids` (free-form ones exactly where they can be) grouped into chains of segments that meet end to end, each oriented
/// to run one way. Curves are joined greedily at their free ends: where three meet, one of them
/// starts a chain of its own.
fn curve_chains(ss: &SolvedSketch, ids: &[String]) -> Result<Vec<Vec<Seg2>>> {
pub(super) fn curve_chains(ss: &SolvedSketch, ids: &[String]) -> Result<Vec<Vec<Seg2>>> {
if ids.len() > MAX_CHAIN_CURVES {
return Err(DocError::Invalid(format!("at most {MAX_CHAIN_CURVES} curves")));
}
@@ -0,0 +1,5 @@
//! A Split Face by sketch curves may save `loops: true`: the sketch's curves are joined end to end
//! before the split, so a closed loop of lines and arcs cuts the face along the loop only. An older
//! design has none and splits by each curve on its own, an open one extended past its ends, as
//! before. The last release warns about the newer format number and opens the design, splitting
//! that feature curve by curve (its sides extended across the face).
+4 -1
View File
@@ -191,7 +191,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.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)",
"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; each curve on its own, an open one extended past its ends) and loops?: bool (default false: true joins the curves end to end first, so a closed loop cuts along itself only)",
),
CommandSpec::new("solid.split_body", "Split Body", crate::checked!("solid.split_body", split_body))
.at("SOLID", "MODIFY")
@@ -2946,6 +2946,7 @@ fn split_face(s: &mut Session, p: &Value) -> Result<Value> {
plane: xy(),
tool: Some(solidcraft_doc::FaceAt { body: BodyName(tb), point: Position(point) }),
sketch: None,
loops: false,
}
} else if p.get("sketch").is_some_and(|v| !v.is_null()) {
let sketch = sketch_id(s, p.get("sketch"), cmd, "sketch")?;
@@ -2955,6 +2956,7 @@ fn split_face(s: &mut Session, p: &Value) -> Result<Value> {
plane: xy(),
tool: None,
sketch: Some(SketchId(sketch)),
loops: bool_(p, "loops").unwrap_or(false),
}
} else {
let plane = plane_param(s, p.get("plane"), cmd)?;
@@ -2964,6 +2966,7 @@ fn split_face(s: &mut Session, p: &Value) -> Result<Value> {
plane,
tool: None,
sketch: None,
loops: false,
}
};
add_face_feature(&picked, s, p, kind)
@@ -2603,6 +2603,64 @@ fn an_imported_assembly_with_a_repeated_part_moves_as_one() {
let _ = std::fs::remove_dir_all(&dir);
}
/// #13382: a design saved by a version whose STEP reader numbered the file's bodies otherwise
/// opens with each body where it was and with its own shape. Here the older reader left the
/// file's first body out, so every stored name and tree index after it is one lower. The names
/// went by index: each body took the next one's name, and that name put it in the next part's
/// component.
#[test]
fn an_import_saved_by_an_older_step_reader_opens_with_its_bodies_in_place() {
use solidcraft_doc::FeatureKind;
use solidcraft_doc::kernel::StepNode;
let dir = std::env::temp_dir().join(format!("solidcraft-13382-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("parts.step").to_string_lossy().to_string();
let mut src = Session::default();
// Three parts of different sizes, each in its own component and placed apart.
for (k, (name, size)) in [("Small", [10, 10, 10]), ("Long", [40, 6, 6]), ("Tall", [8, 8, 30])].into_iter().enumerate() {
let made = run(&mut src, "component.create", json!({"name": name}));
run(&mut src, "solid.box", json!({"length": size[0], "width": size[1], "height": size[2], "body_name": name}));
run(&mut src, "component.activate", json!({"component": 0}));
// The first part is grounded where it is made.
if k > 0 {
run(&mut src, "occurrence.move", json!({"occurrence": made["occurrence"], "translate": [100 * k, 0, 0], "space": "world"}));
}
}
run(&mut src, "file.export", json!({"path": path}));
let mut s = Session::default();
run(&mut s, "file.insert_step", json!({"path": path}));
let names: Vec<String> = s.world_state().unwrap().bodies.iter().map(|b| b.name.clone()).collect();
assert_eq!(names.len(), 3, "{names:?}");
let before: Vec<_> = names.iter().map(|n| placed_bounds(&s, n)).collect();
// The design as the older reader saved it: no body 0, the others numbered from 0.
let mut doc = (*s.doc).clone();
let f = doc.features.iter_mut().find(|f| matches!(f.kind, FeatureKind::Import { .. })).unwrap();
let first = f.body_names.remove(0);
fn renumber(nodes: &mut [StepNode]) {
for n in nodes {
n.bodies = n.bodies.iter().filter(|b| **b != 0).map(|b| b - 1).collect();
renumber(&mut n.children);
}
}
if let FeatureKind::Import { components, skip, .. } = &mut f.kind {
renumber(components);
assert!(skip.is_empty(), "no part is placed twice: {skip:?}");
}
let opened = Session::opened(doc, None);
let now: Vec<String> = opened.world_state().unwrap().bodies.iter().map(|b| b.name.clone()).collect();
assert_eq!(now.len(), 3, "the body the older reader left out is built too: {now:?}");
for (n, b) in names.iter().zip(&before) {
if *n == first {
continue;
}
let got = placed_bounds(&opened, n);
assert!(shifted(*b, got, [0.0; 3]), "{n} opened at {got:?}, was at {b:?}");
}
let _ = std::fs::remove_dir_all(&dir);
}
/// A 20 × 30 × 8 plate made in its own component and stood up by its placement (turned
/// `angle` degrees about X), the way an imported part set upright is: in the world it lies
/// flat at `x0`..`x0 + 20`, y 0..30, z 0..8. The design is active again afterwards.
+85 -21
View File
@@ -31,7 +31,6 @@ use crate::schema::insert_mesh::{InsertMeshParams, MeshInserted};
use crate::schema::insert_step::{InsertStepParams, StepInserted};
use crate::schema::new_design::{NewDesignParams, NewDesignResult};
use crate::schema::save::{SaveAsParams, SaveParams, SaveResult};
#[cfg(not(target_arch = "wasm32"))]
use crate::schema::save_mesh::{SaveMeshResult, SlicerDelivery};
use crate::schema::slicers::{SlicerInfo, SlicersParams, SlicersResult};
use crate::schema::{TypedContract, output_schema_for};
@@ -102,7 +101,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.noundo()
.contract(&EXPORT_CONTRACT)
.output(crate::schema::output_schema_for::<crate::schema::file::FileExportResult>)
.params("path; format?: stl|stla|obj|step|3mf|3mf-tagged|glb|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; glb: binary glTF in metres with Y up, each body placed and named, its colours, metallic and roughness as materials; a transparent body or face, or a body a render setup gives an image texture or optics, is refused; 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, 3MF and GLB: 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)"),
.params("path; format?: stl|stla|obj|step|3mf|3mf-tagged|glb|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; glb: binary glTF in metres with Y up, each body placed and named, its colours, metallic and roughness as materials; a transparent body or face, or a body a render setup gives an image texture or optics, is refused; 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, 3MF and GLB: 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); lay_on_print_faces?: bool (STL and 3MF only, default false: each body with a print face (body.print_face) is written laid on it, as a slicer export lays it → print_faces: the bodies laid, warnings: each body that has a print face but was written as it lies)"),
// 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
// 3MF). Each takes `file.export`'s parameters for its format, without `format`.
@@ -117,13 +116,13 @@ pub static COMMANDS: &[CommandSpec] = &[
.noundo()
.contract(&EXPORT_STL_CONTRACT)
.output(crate::schema::output_schema_for::<crate::schema::file::ExportResult>)
.params("path; bodies?: [names] (default: every body); quality?: standard|fine|finest (default standard) → {path, bytes, format}: binary STL, whatever the path's extension"),
.params("path; bodies?: [names] (default: every body); quality?: standard|fine|finest (default standard); lay_on_print_faces?: bool (default false: as file.export) → {path, bytes, format, print_faces?, warnings?}: binary STL, whatever the path's extension"),
CommandSpec::new("file.export_3mf", "Export 3MF", crate::checked!("file.export_3mf", export_3mf))
.icon("mesh_export")
.noundo()
.contract(&EXPORT_3MF_CONTRACT)
.output(crate::schema::output_schema_for::<crate::schema::file::ExportResult>)
.params("path; bodies?: [names] (default: every body); quality?: standard|fine|finest (default standard); objects?: body|component (as file.export) → {path, bytes, format}: 3MF, whatever the path's extension"),
.params("path; bodies?: [names] (default: every body); quality?: standard|fine|finest (default standard); objects?: body|component (as file.export); lay_on_print_faces?: bool (default false: as file.export) → {path, bytes, format, print_faces?, warnings?}: 3MF, whatever the path's extension"),
CommandSpec::new("file.export_obj", "Export OBJ", crate::checked!("file.export_obj", export_obj_cmd))
.icon("export")
.noundo()
@@ -142,7 +141,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.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)"),
.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); lay_on_print_faces?: bool (default false: each body with a print face (body.print_face) is saved laid on it; a file sent to `slicer` is laid on them whatever this says)"),
CommandSpec::new("file.send_to_slicer", "Send to Slicer", send_to_slicer)
.enabled(starts_programs)
.at("INSERT", "FILES")
@@ -179,7 +178,7 @@ const EXPORT_CONTRACT: crate::schema::TypedContract<crate::schema::file::ExportP
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();
const EXPORT_STL_CONTRACT: crate::schema::TypedContract<crate::schema::file::ExportMeshParams> = crate::schema::TypedContract::new();
const EXPORT_STL_CONTRACT: crate::schema::TypedContract<crate::schema::file::ExportStlParams> = crate::schema::TypedContract::new();
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 SLICERS_CONTRACT: TypedContract<SlicersParams> = TypedContract::new();
@@ -1223,8 +1222,7 @@ fn export_to(
)?
};
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() };
let reply = ExportResult { path: path.to_string(), bytes: bytes.len(), format: format!("{format:?}"), ..ExportResult::default() };
serde_json::to_value(reply).map_err(|e| EngineError::Other(e.to_string()))
}
@@ -1233,8 +1231,60 @@ fn export_thumbnail(s: &Session, format: Format, bodies: &[String]) -> Option<Ve
matches!(format, Format::ThreeMf | Format::ThreeMfTagged).then(|| thumbnail::exported(s, bodies)).flatten()
}
/// [`export_to`] for a mesh file a slicer is sent (STL or 3MF): each body laid on its print face.
#[cfg(not(target_arch = "wasm32"))]
/// `lay_on_print_faces` as the mesh exports read it: true writes each body with a print face laid
/// on it, as a slicer export does. Only STL and 3MF take it; `false` is accepted for any format.
fn lay_on_print_faces(p: &Value, cmd: &str, format: Format) -> Result<bool> {
match p.get("lay_on_print_faces") {
None | Some(Value::Null) | Some(Value::Bool(false)) => Ok(false),
Some(Value::Bool(true)) if matches!(format, Format::StlBinary | Format::StlAscii | Format::ThreeMf | Format::ThreeMfTagged) => Ok(true),
Some(Value::Bool(true)) => Err(bad(cmd, "`lay_on_print_faces` is for STL and 3MF: other formats are not printed")),
Some(_) => Err(bad(cmd, "`lay_on_print_faces` must be true or false")),
}
}
/// Write the bodies as a mesh file (STL or 3MF), each laid on its print face: the file's size, and
/// which bodies were laid and which were written as they lie.
fn write_laid(
s: &Session,
path: &str,
format: Format,
bodies: &[String],
parts: &[(String, Vec<String>)],
quality: solidcraft_io::MeshQuality,
) -> Result<(usize, print_face::Laid)> {
let name = std::path::Path::new(path).file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| s.doc.name.clone());
let mut state = s.doc.painted(&*s.world_state()?);
let laid = print_face::lay_on_print_faces(s, &mut state, bodies, parts);
let thumbnail = export_thumbnail(s, format, bodies);
let bytes = solidcraft_io::export_with_parts(&state, bodies, parts, format, &name, &s.doc.metadata, quality, thumbnail.as_deref())?;
solidcraft_io::vfs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
Ok((bytes.len(), laid))
}
/// [`export_to`] for `file.export`, `file.export_stl` and `file.export_3mf` with
/// `lay_on_print_faces`: each body laid on its print face.
fn export_laid(
s: &Session,
path: &str,
format: Format,
bodies: &[String],
parts: &[(String, Vec<String>)],
quality: solidcraft_io::MeshQuality,
) -> Result<Value> {
let (bytes, laid) = write_laid(s, path, format, bodies, parts, quality)?;
let reply = ExportResult {
path: path.to_string(),
bytes,
format: format!("{format:?}"),
print_faces: laid.bodies,
warnings: laid.warnings,
..ExportResult::default()
};
serde_json::to_value(reply).map_err(|e| EngineError::Other(e.to_string()))
}
/// [`export_to`] for a mesh file a slicer is sent, or `file.save_mesh` with `lay_on_print_faces`
/// (STL or 3MF): each body laid on its print face.
fn export_laid_to(
s: &Session,
path: &str,
@@ -1243,15 +1293,10 @@ fn export_laid_to(
parts: &[(String, Vec<String>)],
quality: solidcraft_io::MeshQuality,
) -> Result<Value> {
let name = std::path::Path::new(path).file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| s.doc.name.clone());
let mut state = s.doc.painted(&*s.world_state()?);
let laid = print_face::lay_on_print_faces(s, &mut state, bodies, parts);
let thumbnail = export_thumbnail(s, format, bodies);
let bytes = solidcraft_io::export_with_parts(&state, bodies, parts, format, &name, &s.doc.metadata, quality, thumbnail.as_deref())?;
solidcraft_io::vfs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let (bytes, laid) = write_laid(s, path, format, bodies, parts, quality)?;
let reply = SaveMeshResult {
path: path.to_string(),
bytes: bytes.len(),
bytes,
format: format!("{format:?}"),
print_faces: laid.bodies,
warnings: laid.warnings,
@@ -1273,7 +1318,11 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
}
let quality = mesh_quality(p, cmd)?;
let parts = mesh_parts(s, p, cmd, format)?;
export_to(s, cmd, path, format, &string_list(cmd, p, "bodies")?, &parts, quality)
let bodies = string_list(cmd, p, "bodies")?;
if lay_on_print_faces(p, cmd, format)? {
return export_laid(s, path, format, &bodies, &parts, quality);
}
export_to(s, cmd, path, format, &bodies, &parts, quality)
}
fn export_step(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1285,14 +1334,22 @@ fn export_step(s: &mut Session, p: &Value) -> Result<Value> {
fn export_stl(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "file.export_stl";
let path = path_arg(p, cmd)?;
export_to(s, cmd, path, Format::StlBinary, &string_list(cmd, p, "bodies")?, &[], mesh_quality(p, cmd)?)
let (bodies, quality) = (string_list(cmd, p, "bodies")?, mesh_quality(p, cmd)?);
if lay_on_print_faces(p, cmd, Format::StlBinary)? {
return export_laid(s, path, Format::StlBinary, &bodies, &[], quality);
}
export_to(s, cmd, path, Format::StlBinary, &bodies, &[], quality)
}
fn export_3mf(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "file.export_3mf";
let path = path_arg(p, cmd)?;
let parts = mesh_parts(s, p, cmd, Format::ThreeMf)?;
export_to(s, cmd, path, Format::ThreeMf, &string_list(cmd, p, "bodies")?, &parts, mesh_quality(p, cmd)?)
let (bodies, quality) = (string_list(cmd, p, "bodies")?, mesh_quality(p, cmd)?);
if lay_on_print_faces(p, cmd, Format::ThreeMf)? {
return export_laid(s, path, Format::ThreeMf, &bodies, &parts, quality);
}
export_to(s, cmd, path, Format::ThreeMf, &bodies, &parts, quality)
}
fn export_obj_cmd(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1433,6 +1490,9 @@ fn save_mesh(s: &mut Session, p: &Value) -> Result<Value> {
// a possibility, not a plan.
let quality = mesh_quality(p, cmd)?;
let parts = mesh_parts(s, p, cmd, format)?;
// Read before `slicer`, so a wrong value is refused there too; a file sent to a slicer is laid
// on its print faces whatever this says.
let lay = lay_on_print_faces(p, cmd, format)?;
if let Some(spec) = str_(p, "slicer") {
#[cfg(target_arch = "wasm32")]
{
@@ -1456,7 +1516,11 @@ fn save_mesh(s: &mut Session, p: &Value) -> Result<Value> {
},
);
}
export_to(s, cmd, path, format, &string_list(cmd, p, "bodies")?, &parts, quality)
let bodies = string_list(cmd, p, "bodies")?;
if lay {
return export_laid_to(s, path, format, &bodies, &parts, quality);
}
export_to(s, cmd, path, format, &bodies, &parts, quality)
}
/// The browser build can't start a program.
+101
View File
@@ -186,6 +186,107 @@ fn node_key(imp: &StepImport, node: &StepNode, depth: usize) -> u64 {
h.finish()
}
/// For each body `now` gives, the index `stored` gave the same body, or `None` for a body
/// `stored` doesn't have. A body is the same one when it has the same place in both trees: the
/// same product at the same position among its siblings, and the same position among that
/// product's bodies. `None` when the trees disagree about their products (a name or a number of
/// children), or a body index is out of range or used twice: then nothing can be matched.
fn matched_bodies(stored: &[StepNode], now: &[StepNode], count: usize) -> Option<Vec<Option<usize>>> {
let mut matched = vec![None; count];
let mut taken = std::collections::HashSet::new();
let mut pending: Vec<(&[StepNode], &[StepNode])> = vec![(stored, now)];
while let Some((was, is)) = pending.pop() {
if was.len() != is.len() {
return None;
}
for (a, b) in was.iter().zip(is) {
if a.name != b.name {
return None;
}
for (old, new) in a.bodies.iter().zip(&b.bodies) {
let slot = matched.get_mut(*new)?;
if slot.is_some() || !taken.insert(*old) {
return None;
}
*slot = Some(*old);
}
pending.push((&a.children, &b.children));
}
}
Some(matched)
}
/// An Import feature written against `imp`'s numbering of the file's bodies (#13382). The
/// feature keeps its body names, skipped bodies and linked face names by body index, as the
/// STEP reader that inserted the file numbered them. A later reader can number the same file's
/// bodies differently, for example when it reads a body an earlier one left out. Each name
/// then went to the wrong body, which its name put in another component: bodies opened moved
/// and with another body's shape. The stored tree says which body each index was, so each
/// stored value moves to the index `imp` gives that body. A body only `imp` has gets the
/// reader's name, and is skipped when a new insert would skip it. Returns whether anything
/// changed; the feature is left as it is when the trees can't be matched.
pub(crate) fn realign_import(f: &mut solidcraft_doc::Feature, imp: &StepImport) -> bool {
let FeatureKind::Import { components, skip, .. } = &f.kind else { return false };
if components.is_empty() || *components == imp.tree {
return false;
}
let count = imp.bodies.len();
let Some(matched) = matched_bodies(components, &imp.tree, count) else { return false };
let stored_count = {
let mut all = Vec::new();
for n in components {
collect_bodies(n, &mut all, 0);
}
all.len()
};
if stored_count == count && matched.iter().enumerate().all(|(k, m)| *m == Some(k)) {
// The same numbering: only placements or names that don't move bodies differ.
return false;
}
let old_skip: std::collections::HashSet<u64> = skip.iter().copied().collect();
let new_skip: std::collections::HashSet<u64> = Plan::of(imp).skip.into_iter().collect();
let skip: Vec<u64> = (0..count as u64)
.filter(|k| match matched.get(*k as usize).copied().flatten() {
Some(old) => old_skip.contains(&(old as u64)),
None => new_skip.contains(k),
})
.collect();
f.body_names = by_new_index(&matched, &f.body_names);
if !f.link_faces.is_empty() {
f.link_faces = by_new_index(&matched, &f.link_faces);
}
if let FeatureKind::Import { components, skip: stored, .. } = &mut f.kind {
components.clone_from(&imp.tree);
*stored = skip;
}
true
}
/// `stored`, kept by the old body indices, as a list by the new ones (empty for a new body).
fn by_new_index<T: Clone + Default>(matched: &[Option<usize>], stored: &[T]) -> Vec<T> {
matched.iter().map(|m| m.and_then(|old| stored.get(old)).cloned().unwrap_or_default()).collect()
}
/// [`realign_import`] for every Import feature of `doc` that is not suppressed, read with the
/// reader that will build it. The reads are returned so the caller can hold them while it
/// evaluates `doc`: the evaluation then finds each file read already instead of reading it again.
pub(crate) fn realign_imports(doc: &mut solidcraft_doc::Document) -> Vec<std::sync::Arc<StepImport>> {
let mut reads = Vec::new();
for f in doc.features.iter_mut().filter(|f| !f.suppressed) {
let FeatureKind::Import { step, components, .. } = &f.kind else { continue };
if step.is_empty() || components.is_empty() {
continue;
}
// A file that doesn't read fails the feature when it is evaluated, which reports it.
let Ok(imp) = solidcraft_doc::kernel::step_import_shared(step) else { continue };
if realign_import(f, &imp) {
log::info!("{}: the stored body names follow this version's numbering of the file's bodies", f.name);
}
reads.push(imp);
}
reads
}
/// The import's component, as the command reports it. The reply types in `schema/insert_mesh.rs`
/// and `schema/insert_step.rs` are what the commands answer with these fields.
pub(crate) struct Placed {
+1
View File
@@ -51,6 +51,7 @@ pub(crate) use features::{EdgeRefForm, PLANE_FORMS, axis_dir_form, axis_line_obj
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(crate) use file::import::realign_imports;
pub(crate) use file::mesh_crease_angle;
pub use file::open_steps::OpenSteps;
pub(crate) use file::{NAMES_MESSAGE, UP_MESSAGE, names_form, up_form, usable_path};
+133 -2
View File
@@ -1,4 +1,5 @@
//! Laying bodies on their print face for a slicer (#8429). The bodies being sent are copied (the
//! Laying bodies on their print face for a slicer (#8429), or for an STL or 3MF export with
//! `lay_on_print_faces`. The bodies being sent are copied (the
//! state the export writes is already a copy), and each copy whose body has a print face is
//! turned so the face points down, its centre of mass moved onto the world origin in X and Y,
//! and the face moved onto Z = 0: one rigid transform, applied once. The design and its timeline
@@ -20,7 +21,7 @@ pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("body.print_face", "Pri
.key("Ctrl+B")
.contract(&PRINT_FACE_CONTRACT)
.output(crate::schema::output_schema_for::<PrintFaceResult>)
.params("body: its name; point?: [x, y, z] on a flat face of it, in mm (omit to clear) → {body, face?}: marks the face a slicer export (file.send_to_slicer, file.save_mesh with slicer, file.open_in_easyprint) lays the body on: turned so the face points down at Z = 0, the centre of mass over the origin. One per body, replacing the last; flat faces of solid bodies only. A print face that is gone or no longer flat is reported in the export's warnings, and the body exported as it lies")];
.params("body: its name; point?: [x, y, z] on a flat face of it, in mm (omit to clear) → {body, face?}: marks the face a slicer export (file.send_to_slicer, file.save_mesh with slicer, file.open_in_easyprint), or an STL or 3MF export with lay_on_print_faces (file.export, file.export_stl, file.export_3mf, file.save_mesh), lays the body on: turned so the face points down at Z = 0, the centre of mass over the origin. One per body, replacing the last; flat faces of solid bodies only. A print face that is gone or no longer flat is reported in the export's warnings, and the body exported as it lies")];
const PRINT_FACE_CONTRACT: crate::schema::TypedContract<crate::schema::print_face::PrintFaceParams> = crate::schema::TypedContract::new();
@@ -353,6 +354,136 @@ mod tests {
assert_eq!(kept.doc.print_faces, s.doc.print_faces);
}
/// `name` in a fresh folder of the system's temporary folder, for `case`.
fn temp_path(case: &str, name: &str) -> String {
let dir = std::env::temp_dir().join(format!("solidcraft-lay-on-print-faces-{case}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("temporary folder");
dir.join(name).to_string_lossy().into_owned()
}
/// The box around every triangle of the STL file at `path`, in mm.
fn stl_box(path: &str) -> ([f64; 3], [f64; 3]) {
let triangles = solidcraft_io::read_stl(&std::fs::read(path).expect("the STL file")).expect("an STL file");
assert!(!triangles.is_empty(), "{path} has no triangles");
let (mut min, mut max) = ([f64::INFINITY; 3], [f64::NEG_INFINITY; 3]);
for p in triangles.iter().flatten() {
for k in 0..3 {
min[k] = min[k].min(f64::from(p[k]));
max[k] = max[k].max(f64::from(p[k]));
}
}
(min, max)
}
/// Near enough for a coordinate an STL file holds as an f32.
fn near_f32(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-4
}
#[test]
fn an_stl_export_lays_the_body_on_its_print_face_only_when_asked() {
// The 10 x 5 end at X = 20 is the print face, so laid the 20 mm length stands up at Z = 0,
// centred on the origin in X and Y.
let mut s = block();
run(&mut s, "body.print_face", json!({"body": "Block", "point": [20, 5, 2.5]}));
let undo = s.undo.len();
let laid_box = |(min, max): ([f64; 3], [f64; 3])| {
near_f32(min[2], 0.)
&& near_f32(max[2], 20.)
&& near_f32(min[0] + max[0], 0.)
&& near_f32(min[1] + max[1], 0.)
&& near_f32((max[0] - min[0]) * (max[1] - min[1]), 50.)
};
for (id, name, extra) in [
("file.export_stl", "a.stl", json!({})),
("file.export", "b.stl", json!({})),
("file.export", "c.stl", json!({"format": "stla"})),
("file.save_mesh", "d.stl", json!({})),
("file.save_mesh", "e.stl", json!({"format": "stl", "ascii": true})),
] {
let path = temp_path("stl", name);
let mut p = json!({"path": path, "lay_on_print_faces": true});
p.as_object_mut().expect("object").extend(extra.as_object().expect("object").clone());
let reply = run(&mut s, id, p);
assert_eq!(reply["print_faces"], json!(["Block"]), "{id}: {reply}");
assert!(reply.get("warnings").is_none(), "{id}: {reply}");
let bbox = stl_box(&path);
assert!(laid_box(bbox), "{id} {name}: {bbox:?}");
// Without the key, or with it false, the body is written as it lies in the design.
for lay in [json!(null), json!(false)] {
let path = temp_path("stl-as-it-lies", name);
let mut p = json!({"path": path, "lay_on_print_faces": lay});
p.as_object_mut().expect("object").extend(extra.as_object().expect("object").clone());
let reply = run(&mut s, id, p);
assert!(reply.get("print_faces").is_none(), "{id}: {reply}");
let (min, max) = stl_box(&path);
assert!(near_f32(min[0], 0.) && near_f32(max[0], 20.) && near_f32(min[2], 0.) && near_f32(max[2], 5.), "{id}: {min:?} {max:?}");
}
}
// The design itself has not moved, and nothing was added to undo.
assert_eq!(s.undo.len(), undo);
let m = solidcraft_kernel::measure_mass(&s.model.state().bodies[0].body).expect("measure");
assert!(near(m.bbox.min.x, 0.) && near(m.bbox.max.x, 20.) && near(m.bbox.max.z, 5.), "{:?}", m.bbox);
}
#[test]
fn a_3mf_export_reports_the_bodies_it_laid_on_their_print_face() {
let mut s = block();
run(&mut s, "body.print_face", json!({"body": "Block", "point": [20, 5, 2.5]}));
for (id, name) in [("file.export_3mf", "a.3mf"), ("file.export", "b.3mf"), ("file.save_mesh", "c.3mf")] {
let path = temp_path("3mf", name);
let reply = run(&mut s, id, json!({"path": path, "lay_on_print_faces": true}));
assert_eq!(reply["print_faces"], json!(["Block"]), "{id}: {reply}");
assert!(std::fs::metadata(&path).is_ok_and(|m| m.len() > 0), "{id}: {path}");
}
}
#[test]
fn an_export_with_lay_on_print_faces_writes_a_body_without_one_as_it_lies() {
// A second body with no print face lies where it is; a print face cut away is reported.
let mut s = block();
run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 4, "corner": [50, 0, 0], "body_name": "Cube", "operation": "new"}));
let path = temp_path("none", "cube.stl");
let reply = run(&mut s, "file.export_stl", json!({"path": path, "bodies": ["Cube"], "lay_on_print_faces": true}));
assert!(reply.get("print_faces").is_none() && reply.get("warnings").is_none(), "{reply}");
let (min, max) = stl_box(&path);
assert!(near_f32(min[0], 50.) && near_f32(max[0], 54.) && near_f32(max[2], 4.), "{min:?} {max:?}");
run(&mut s, "body.print_face", json!({"body": "Block", "point": [10, 5, 5]}));
run(&mut s, "solid.box", json!({"length": 30, "width": 20, "height": 5, "corner": [-5, -5, 3], "operation": "cut"}));
let path = temp_path("cut", "block.stl");
let reply = run(&mut s, "file.export_stl", json!({"path": path, "bodies": ["Block"], "lay_on_print_faces": true}));
assert!(reply.get("print_faces").is_none(), "{reply}");
assert!(reply["warnings"].as_array().is_some_and(|w| w.len() == 1 && w[0].as_str().is_some_and(|w| w.contains("Block"))), "{reply}");
let (min, max) = stl_box(&path);
assert!(near_f32(min[0], 0.) && near_f32(max[0], 20.) && near_f32(max[2], 3.), "{min:?} {max:?}");
}
#[test]
fn lay_on_print_faces_is_refused_for_a_format_that_is_not_printed_or_a_value_that_is_not_true_or_false() {
let mut s = block();
run(&mut s, "body.print_face", json!({"body": "Block", "point": [20, 5, 2.5]}));
for (id, name, lay) in [
("file.export", "a.step", json!(true)),
("file.export", "a.obj", json!(true)),
("file.export", "a.glb", json!(true)),
("file.export", "a.json", json!(true)),
("file.export", "b.stl", json!("yes")),
("file.export_stl", "c.stl", json!(1)),
("file.export_3mf", "d.3mf", json!([true])),
("file.save_mesh", "e.stl", json!({})),
] {
let path = temp_path("refused", name);
let e = s.execute(id, &json!({"path": path, "lay_on_print_faces": lay})).expect_err(id).to_string();
assert!(e.contains("lay_on_print_faces"), "{id} {name}: {e}");
assert!(std::fs::metadata(&path).is_err(), "{id} {name}: a refused export writes nothing");
}
// False is accepted for any format: the file is written as the design has it.
let path = temp_path("false", "a.step");
run(&mut s, "file.export", json!({"path": path, "lay_on_print_faces": false}));
assert!(std::fs::metadata(&path).is_ok());
}
#[test]
fn renaming_the_body_keeps_its_print_face() {
let mut s = block();
+20
View File
@@ -494,6 +494,26 @@ fn a_hole_wider_than_the_part_says_it_removes_all_of_it() {
assert_eq!(said(&s, "H1"), "");
}
/// A hole across the part's width that touches none of its sides keeps the rest, in one piece and
/// silently, and says so once a parameter widens it past the whole part. The same hole touching a
/// side along a line is the case above.
#[test]
fn a_hole_across_the_part_clear_of_its_sides_keeps_the_rest_silently() {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "hd", "expression": "40 mm"}));
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "name": "Plate"}));
run(&mut s, "solid.hole", json!({"position": [10, 15, 10], "diameter": "hd", "name": "H1"}));
assert_eq!(said(&s, "H1"), "");
assert_eq!(s.model.state().bodies.len(), 1);
// The plate less the part of the 40 mm circle inside it, 10 mm deep.
assert!((volume(&mut s) - 3623.4382).abs() < 1e-3, "{}", volume(&mut s));
change(&mut s, "hd", "100 mm");
assert!(s.model.state().bodies.is_empty());
assert!(said(&s, "H1").contains("the hole removes all of Body1"), "{}", said(&s, "H1"));
change(&mut s, "hd", "5 mm");
assert_eq!(said(&s, "H1"), "");
}
/// The volume of one body.
fn body_volume(s: &mut Session, name: &str) -> f64 {
let m = run(s, "inspect.measure", json!({}));
+14 -7
View File
@@ -801,14 +801,21 @@ impl Session {
solidcraft_io::vfs::install_checks();
// The session keeps what is hidden (an undo step of its own); the design file carries it.
let visibility = std::mem::take(&mut doc.visibility);
let doc = Arc::new(doc);
let mut doc = Arc::new(doc);
// A model saved under another check mode holds that mode's results: evaluated again.
let model = Self::saved_model(&doc, saved).filter(|model| model.validation() == validation).unwrap_or_else(|| {
let mut model = Model::new();
model.set_validation(validation);
model.evaluate_shared(&doc);
model
});
let model = match Self::saved_model(&doc, saved).filter(|model| model.validation() == validation) {
Some(model) => model,
None => {
// Stored body names follow the numbering of the STEP reader that inserted each
// file; this version's reader may number the bodies otherwise (#13382). The
// reads are held so the evaluation doesn't read the files again.
let _reads = cmd::realign_imports(Arc::make_mut(&mut doc));
let mut model = Model::new();
model.set_validation(validation);
model.evaluate_shared(&doc);
model
}
};
Self::with_model(doc, visibility, model)
}
+57 -6
View File
@@ -52,9 +52,8 @@ pub struct ExportStepParams {
pub bodies: Option<StringList>,
}
/// Export bodies as a mesh file: binary STL for `file.export_stl`, OBJ for `file.export_obj`
/// (the colours of the bodies go in a material library, `.mtl`, written next to it). The file
/// has that format whatever the path's extension.
/// Export bodies as an OBJ mesh file (`file.export_obj`), the colours of the bodies in a material
/// library, `.mtl`, written next to it. The file is OBJ whatever the path's extension.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct ExportMeshParams {
@@ -68,6 +67,26 @@ pub struct ExportMeshParams {
pub quality: Option<MeshQualityParam>,
}
/// Export bodies as a binary STL file (`file.export_stl`), whatever the path's extension.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct ExportStlParams {
/// The file to write.
pub path: String,
/// The bodies to export, by name. Default: every body.
#[serde(default)]
pub bodies: Option<StringList>,
/// How closely the mesh follows curved faces. Default: standard.
#[serde(default)]
pub quality: Option<MeshQualityParam>,
/// True writes each body with a print face (`body.print_face`) laid on it, as a slicer export
/// lays it: turned so the face points down at Z = 0, its centre of mass over the origin. Each
/// body is laid on its own, so several land on the origin together. Default: false, every body
/// as it lies in the design.
#[serde(default)]
pub lay_on_print_faces: Option<bool>,
}
/// Export bodies as a 3MF file, whatever the path's extension.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
@@ -83,6 +102,11 @@ pub struct Export3mfParams {
/// What each object in the file holds. Default: body.
#[serde(default)]
pub objects: Option<ObjectsParam>,
/// As `file.export_stl`'s: true writes each body with a print face laid on it. A body that is
/// one of the parts of an object (`objects: component`) stays as it lies, so the object keeps
/// its shape. Default: false.
#[serde(default)]
pub lay_on_print_faces: Option<bool>,
}
/// Export the whole assembly as a robot description (URDF), with an STL mesh for each link
@@ -140,6 +164,10 @@ pub struct ExportParams {
/// URDF only: each joint's velocity limit, a positive number. Default: 10.
#[serde(default)]
pub velocity: Option<f64>,
/// STL and 3MF only: true writes each body with a print face laid on it, as
/// `file.export_stl` has it; refused for the other formats. Default: false. Not read for URDF.
#[serde(default)]
pub lay_on_print_faces: Option<bool>,
}
fn export_required(schema: &mut Schema) {
@@ -179,8 +207,17 @@ fn names<E: serde::de::Error>(m: &Map<String, Value>) -> Result<Option<StringLis
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 };
let mut out = ExportParams {
path: None,
format: None,
bodies: None,
quality: None,
objects: None,
mesh_prefix: None,
effort: None,
velocity: None,
lay_on_print_faces: 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") {
@@ -230,12 +267,19 @@ impl<'de> Deserialize<'de> for ExportParams {
_ => return Err(refuse(format!("`objects` must be body or component, not {v}"))),
},
};
out.lay_on_print_faces = match m.get("lay_on_print_faces") {
None | Some(Value::Null) => None,
Some(Value::Bool(false)) => Some(false),
Some(Value::Bool(true)) if matches!(format, Format::StlBinary | Format::StlAscii | Format::ThreeMf | Format::ThreeMfTagged) => Some(true),
Some(Value::Bool(true)) => return Err(refuse("`lay_on_print_faces` is for STL and 3MF: other formats are not printed")),
Some(_) => return Err(refuse("`lay_on_print_faces` must be true or false")),
};
Ok(out)
}
}
/// What `file.export_step`, `file.export_stl`, `file.export_3mf` and `file.export_obj` return.
#[derive(Debug, Serialize, JsonSchema)]
#[derive(Debug, Default, Serialize, JsonSchema)]
pub struct ExportResult {
/// The file written.
pub path: String,
@@ -247,6 +291,13 @@ pub struct ExportResult {
/// OBJ only: every file written, the material library first and the mesh file last.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub files: Vec<String>,
/// With `lay_on_print_faces` only: the bodies that were written laid on their print face.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub print_faces: Vec<String>,
/// With `lay_on_print_faces` only: a warning for each body that has a print face but was
/// written as it lies.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<String>,
/// 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>,
+46 -5
View File
@@ -3,9 +3,9 @@ use std::time::Duration;
use serde_json::{Value, json};
use super::{
Export3mfParams, ExportMeshParams, ExportParams, ExportStepParams, ExportUrdfParams, FileExportResult, NewerFileReport, OpenElsewhere,
OpenParams, OpenResult, OpenedDesign, RecoverParams, RecoverResult, RecoveryDiscardParams, RecoveryDiscardResult, RecoveryListParams,
RecoveryListResult,
Export3mfParams, ExportMeshParams, ExportParams, ExportStepParams, ExportStlParams, ExportUrdfParams, FileExportResult, NewerFileReport,
OpenElsewhere, OpenParams, OpenResult, OpenedDesign, RecoverParams, RecoverResult, RecoveryDiscardParams, RecoveryDiscardResult,
RecoveryListParams, RecoveryListResult,
};
use crate::Session;
use crate::recovery::Autosaver;
@@ -16,9 +16,13 @@ use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
fn schemas_and_the_key_check_agree() {
assert_keys(input_schema_for::<ExportStepParams>(), &["bodies", "path"]);
assert_keys(input_schema_for::<ExportMeshParams>(), &["bodies", "path", "quality"]);
assert_keys(input_schema_for::<Export3mfParams>(), &["bodies", "objects", "path", "quality"]);
assert_keys(input_schema_for::<ExportStlParams>(), &["bodies", "lay_on_print_faces", "path", "quality"]);
assert_keys(input_schema_for::<Export3mfParams>(), &["bodies", "lay_on_print_faces", "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"]);
assert_keys(
input_schema_for::<ExportParams>(),
&["bodies", "effort", "format", "lay_on_print_faces", "mesh_prefix", "objects", "path", "quality", "velocity"],
);
assert_keys(input_schema_for::<OpenParams>(), &["folder", "max_points", "names", "origin", "path", "read_only", "units", "up"]);
assert_keys(input_schema_for::<RecoveryListParams>(), &["dir"]);
assert_keys(input_schema_for::<RecoverParams>(), &["dir", "discard", "id"]);
@@ -244,6 +248,17 @@ fn run(s: &mut Session, id: &str, p: Value) -> Value {
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
}
/// Mark a print face on the first body of `s`, a box: its side at the lowest X.
fn mark_a_print_face(s: &mut Session) {
let (name, bbox) = {
let world = s.world_state().unwrap_or_else(|e| panic!("{e}"));
let body = world.bodies.first().expect("a body");
(body.name.clone(), solidcraft_kernel::measure_mass(&body.body).expect("measure").bbox)
};
let point = [bbox.min.x, (bbox.min.y + bbox.max.y) / 2.0, (bbox.min.z + bbox.max.z) / 2.0];
run(s, "body.print_face", json!({"body": name, "point": point}));
}
/// 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();
@@ -284,6 +299,12 @@ fn strict_keeps_todays_errors() {
("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})),
("file.export", json!({"path": at("a.step"), "lay_on_print_faces": true})),
("file.export", json!({"path": at("a.obj"), "lay_on_print_faces": true})),
("file.export", json!({"path": at("a.stl"), "lay_on_print_faces": "yes"})),
("file.export", json!({"path": at("a.3mf"), "lay_on_print_faces": 1})),
("file.export_stl", json!({"path": at("a.stl"), "lay_on_print_faces": "yes"})),
("file.export_3mf", json!({"path": at("a.3mf"), "lay_on_print_faces": [true]})),
("doc.open", json!({"path": ""})),
("doc.open", json!({"path": " "})),
("doc.open", json!({"path": 5})),
@@ -321,6 +342,19 @@ fn the_export_type_reads_each_key_only_for_its_format() {
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());
// `lay_on_print_faces`: true for STL and 3MF only, false for any format, not read for URDF.
for path in ["a.stl", "a.3mf"] {
assert_eq!(ok(json!({"path": path, "lay_on_print_faces": true})).lay_on_print_faces, Some(true), "{path}");
}
assert_eq!(ok(json!({"path": "a", "format": "stla", "lay_on_print_faces": true})).lay_on_print_faces, Some(true));
assert_eq!(ok(json!({"path": "a.step", "lay_on_print_faces": false})).lay_on_print_faces, Some(false));
assert!(ok(json!({"path": "a.urdf", "lay_on_print_faces": "yes"})).lay_on_print_faces.is_none());
for path in ["a.step", "a.obj", "a.glb", "a.json"] {
let e = read(json!({"path": path, "lay_on_print_faces": true})).expect_err(path);
assert!(e.contains("STL and 3MF"), "{path}: {e}");
}
let e = read(json!({"path": "a.stl", "lay_on_print_faces": "yes"})).expect_err("a string");
assert!(e.contains("must be true or false"), "{e}");
}
/// (`conforms` does not read `oneOf`, so `quality` and `objects` values are checked by the type,
@@ -334,7 +368,9 @@ fn the_export_schema_takes_what_the_handler_reads() {
(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!({"path": "a.stl", "lay_on_print_faces": true}), true),
(json!({}), false),
(json!({"path": "a.stl", "lay_on_print_faces": "yes"}), false),
(json!({"path": "a.stl", "bodies": [1]}), false),
(json!({"path": "a.stl", "colour": true}), false),
] {
@@ -359,10 +395,15 @@ fn export_replies_match_the_output_schema() {
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}),
// With a print face marked: `print_faces` in the first, and in the second a warning (the
// body is a part of a component's object, so it stays as it lies).
json!({"path": at("i.stl"), "lay_on_print_faces": true}),
json!({"path": at("j.3mf"), "objects": "component", "lay_on_print_faces": true}),
];
for level in [ParamCheck::Warn, ParamCheck::Strict] {
let mut s = two_cubes();
s.param_check = level;
mark_a_print_face(&mut s);
for p in &calls {
let reply = run(&mut s, "file.export", p.clone());
assert!(reply.get("param_warnings").is_none(), "{p}: {reply}");
+5 -3
View File
@@ -6,9 +6,11 @@ use serde::{Deserialize, Serialize};
use super::types::Point3;
/// Mark the flat face of a body at a point as the body's print face, or clear it. A slicer
/// export (`file.send_to_slicer`, `file.save_mesh` with `slicer`, `file.open_in_easyprint`) lays
/// the body on its print face: turned so the face points down and lies at Z = 0, the centre of
/// mass over the origin. A body has one print face at most; marking another replaces it.
/// export (`file.send_to_slicer`, `file.save_mesh` with `slicer`, `file.open_in_easyprint`), and an
/// STL or 3MF export with `lay_on_print_faces` (`file.export`, `file.export_stl`, `file.export_3mf`,
/// `file.save_mesh`), lays the body on its print face: turned so the face points down and lies at
/// Z = 0, the centre of mass over the origin. A body has one print face at most; marking another
/// replaces it.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct PrintFaceParams {
+10 -2
View File
@@ -72,6 +72,13 @@ pub struct SaveMeshKeys {
/// other formats. Default: body.
#[serde(default)]
pub objects: Option<MeshObjects>,
/// True saves each body with a print face (`body.print_face`) laid on it, as a slicer export
/// lays it: turned so the face points down at Z = 0, its centre of mass over the origin. Each
/// body is laid on its own, so several land on the origin together; a part of a 3MF object
/// (`objects: component`) stays as it lies. A file sent to `slicer` is laid on them whatever
/// this says. Default: false.
#[serde(default)]
pub lay_on_print_faces: Option<bool>,
}
/// Leaves out the keys the handler never reads for this call: `ascii` unless `format` is `stl`,
@@ -345,10 +352,11 @@ pub struct SaveMeshResult {
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.
/// With `slicer` or `lay_on_print_faces` only: the bodies that were saved laid on their print face.
#[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.
/// With `slicer` or `lay_on_print_faces` 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.
+17 -1
View File
@@ -23,7 +23,10 @@ fn with_box(level: ParamCheck) -> Session {
#[test]
fn schemas_and_the_key_check_agree() {
assert_keys(input_schema_for::<SaveMeshParams>(), &["ascii", "bodies", "format", "objects", "path", "quality", "single_instance", "slicer"]);
assert_keys(
input_schema_for::<SaveMeshParams>(),
&["ascii", "bodies", "format", "lay_on_print_faces", "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"]));
@@ -74,6 +77,9 @@ fn strict_keeps_todays_errors() {
json!({"bodies": {"a": 1}}),
json!({"bodies": true}),
json!({"format": "stl", "bodies": ["a", null]}),
json!({"lay_on_print_faces": "yes"}),
json!({"lay_on_print_faces": 1}),
json!({"format": "3mf", "lay_on_print_faces": {}}),
] {
cases.push(("file.save_mesh", p(extra)));
}
@@ -224,6 +230,16 @@ fn replies_match_the_output_schema() {
}
assert!(std::fs::read_to_string(at("e.stl")).unwrap().starts_with("solid"));
assert!(std::fs::read(at("a.stl")).unwrap().len() > 84);
// Laid on their print faces: the bodies laid are named, and the reply still matches.
s.execute("body.print_face", &json!({"body": "Block", "point": [10, 5, 5]})).unwrap();
for p in [json!({"path": at("r.stl"), "lay_on_print_faces": true}), json!({"path": at("s.3mf"), "lay_on_print_faces": true})] {
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", "print_faces"].map(String::from).into(), "{p}: {r}");
assert_eq!(r["print_faces"], json!(["Block"]), "{p}");
}
s.execute("body.print_face", &json!({"body": "Block"})).unwrap();
// 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"});
+5
View File
@@ -64,6 +64,11 @@ pub struct SplitFaceParams {
/// `tool` is given.
#[serde(default)]
pub sketch: Option<SketchRef>,
/// With `sketch`: true joins its curves end to end first, so a closed loop of lines and arcs
/// splits the faces along the loop only. Default: false, each curve on its own, an open one
/// extended past its ends.
#[serde(default)]
pub loops: Option<bool>,
/// The plane to split along. Read when neither `tool` nor `sketch` is given; then it is
/// required.
#[serde(default)]
+4 -1
View File
@@ -16,7 +16,7 @@ fn schemas_and_the_key_check_agree() {
);
assert_keys(
input_schema_for::<SplitFaceParams>(),
&["body", "body_name", "body_names", "faces", "name", "participants", "plane", "sketch", "tool"],
&["body", "body_name", "body_names", "faces", "loops", "name", "participants", "plane", "sketch", "tool"],
);
}
@@ -155,6 +155,8 @@ fn the_check_takes_what_the_schema_allows() {
("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]], "sketch": "Cuts", "loops": true}), true),
("solid.split_face", &face, json!({"faces": [[1, 2, 3]], "sketch": "Cuts", "loops": null}), 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),
@@ -162,6 +164,7 @@ fn the_check_takes_what_the_schema_allows() {
("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]], "sketch": "Cuts", "loops": "yes"}), 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),
] {
+27
View File
@@ -55,6 +55,33 @@ fn split_a_face_by_sketch_curves() {
assert!((v - 24000.0).abs() < 1e-6 * 24000.0, "{v}");
}
/// A triangle of three lines on the top: by default each side is extended across the face; with
/// `loops` the sides are joined first and the closed loop cuts out the triangle only. Open curves
/// are extended either way.
#[test]
fn split_a_face_by_a_closed_loop_of_lines() {
let split = |loops: bool| {
let mut s = boxed();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Loop"}));
run(&mut s, "sketch.line", json!({"points": [[10, 5], [30, 5], [20, 25]], "closed": true}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.split_face", json!({"faces": [[20, 12, 20]], "body": "B", "sketch": "Loop", "loops": loops}));
faces_volume(&mut s, "B")
};
let (f, v) = split(true);
assert_eq!(f, 7, "the triangle and the rest of the top");
assert!((v - 24000.0).abs() < 1e-6 * 24000.0, "{v}");
assert!(split(false).0 > 7, "each side extended across the top");
let mut s = boxed();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Cuts"}));
run(&mut s, "sketch.line", json!({"points": [[10, 10], [10, 20]]}));
run(&mut s, "sketch.line", json!({"points": [[30, 10], [30, 20]]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.split_face", json!({"faces": [[20, 15, 20]], "body": "B", "sketch": "Cuts", "loops": true}));
assert_eq!(faces_volume(&mut s, "B").0, 8, "two open lines still cut across the top");
}
#[test]
fn a_tool_that_misses_is_refused() {
let mut s = boxed();
File diff suppressed because it is too large Load Diff
+54 -423
View File
@@ -1,10 +1,9 @@
//! Where MCP tool calls end up: a control-channel method call (`docs/control-protocol.md`).
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpStream, ToSocketAddrs};
use std::time::{Duration, Instant};
use std::time::Duration;
use serde_json::{Value, json};
use solidcraft_control::client::{CallError, Connection, Request, outcome};
use solidcraft_engine::Session;
use solidcraft_engine::control_pending;
use solidcraft_engine::panels::ActionSink;
@@ -57,116 +56,35 @@ pub trait Backend {
}
}
/// Longest reply line accepted from the app.
const MAX_REPLY: u64 = 64 << 20;
/// How long a reply may take: kernel operations (booleans, fillets) on big parts can take a while.
const READ_TIMEOUT: Duration = Duration::from_secs(300);
/// How long the app may take to answer `mcp.options`: the window answers it within a frame, so a
/// longer wait means the app is busy and the server asks again on a later call.
const OPTIONS_TIMEOUT: Duration = Duration::from_secs(2);
/// How long the app may take to run an `engine.batch`: a little longer than its control server
/// waits (3600 s), so its "still running" reply arrives first.
const BATCH_TIMEOUT: Duration = Duration::from_secs(3720);
/// How long a call goes on waiting, asking `control.pending`, after the app said its request is
/// still running (the app says so after 60 s; #1618), before it says so itself.
const PENDING_WAIT: Duration = Duration::from_secs(240);
/// How long a later call, or the `pending` tool, waits for a request still running from an
/// earlier call.
const SETTLE_WAIT: Duration = Duration::from_secs(60);
/// How often `control.pending` is asked while waiting.
const POLL_EVERY: Duration = Duration::from_millis(500);
/// How often a long wait is reported on stderr.
const PROGRESS_EVERY: Duration = Duration::from_secs(30);
/// How a tool error starts when a request is still running in the app after the wait: the
/// request was not cancelled and its reply waits on the connection; the `pending` tool gets it.
/// `batch` uses it to tell "still running" from "failed".
pub(crate) const STILL_RUNNING: &str = "still running:";
/// A request the app said is still running (#1618). Its reply will come on this connection,
/// through `control.pending`, so the connection is kept for it.
struct Outstanding {
what: String,
/// What it does when it finishes; `None`: not known (a request this bridge did not send).
effect: Option<solidcraft_control::Effect>,
since: Instant,
}
impl Outstanding {
fn secs(&self) -> u64 {
self.since.elapsed().as_secs()
}
}
/// What became of a request still running when its call returned, once a later call waited it
/// out: kept for the `pending` tool.
enum Settled {
Finished { what: String, reply: Value },
Lost { what: String },
}
/// The end of a wait for an outstanding request.
enum Waited {
/// It finished: its own reply.
Done(Value),
/// The app holds nothing (any more) for this connection.
Nothing,
/// Still running when the wait ran out.
Running,
}
pub(crate) use solidcraft_control::client::STILL_RUNNING;
/// How long to wait for a request still running (tests shorten them).
#[derive(Clone, Copy)]
pub(crate) struct Waits {
pub pending: Duration,
pub settle: Duration,
pub poll: Duration,
}
const WAITS: Waits = Waits { pending: PENDING_WAIT, settle: SETTLE_WAIT, poll: POLL_EVERY };
#[cfg(test)]
pub(crate) use solidcraft_control::client::Waits;
/// The level a control reply says its request was checked at (#9184), if it says one.
fn echoed(v: &Value) -> Option<ParamCheck> {
v.get("param_check").and_then(Value::as_str).and_then(ParamCheck::parse)
}
/// A control reply as a call's result.
fn outcome(v: &Value) -> Result<Value, String> {
if v.get("ok").and_then(Value::as_bool) == Some(true) {
Ok(v.get("result").cloned().unwrap_or(Value::Null))
} else {
Err(v.get("error").and_then(Value::as_str).unwrap_or("error").to_string())
}
}
/// A running SolidCraft app reached through its loopback control port
/// (`solidcraft --control PORT`).
/// (`solidcraft --control PORT`): the shared control client ([`Connection`]), with the agent's
/// folders on every request and the calls checked against their commands' contracts.
pub struct Remote {
addr: String,
port: u16,
conn: Option<(BufReader<TcpStream>, TcpStream)>,
next_id: u64,
/// `--token`, when given. Otherwise each request looks the token up again, since every
/// launch of the app makes a new one (docs/control-protocol.md).
explicit_token: Option<String>,
/// A request still running in the app; its reply waits on `conn`.
outstanding: Option<Outstanding>,
/// An outstanding request's end, once a later call waited for it (for `pending`).
settled: Option<Settled>,
waits: Waits,
conn: Connection,
/// How calls are checked (`--param-check`): by the app, when it checks a request's own
/// level, else here before they are sent.
param_check: ParamCheck,
/// Whether the connected app checks a request's `param_check` itself (#9184), asked once per
/// connection; `None` until asked.
app_checks: Option<bool>,
/// connection (the connection's serial number beside it); `None` until asked.
app_checks: Option<(u64, bool)>,
/// The level the app said it checked the last reply at (`param_check` beside `ok`).
echoed: Option<ParamCheck>,
}
@@ -175,6 +93,11 @@ pub struct Remote {
/// `batch` uses it to tell "never sent" from "sent, reply lost".
pub(crate) const NOT_RUNNING: &str = "no SolidCraft app is listening at";
/// A long wait for a request still running, on stderr.
fn note(message: &str) {
solidcraft_engine::note!("solidcraft-mcp: {message}");
}
impl Remote {
/// Connect to the app at `addr`, with its control token found the usual way
/// (`SOLIDCRAFT_CONTROL_TOKEN`, else the app's token file for that port).
@@ -185,24 +108,15 @@ impl Remote {
/// [`Remote::connect`] with the token given (`--token`); `None` finds it the usual way.
pub fn connect_with_token(addr: &str, token: Option<&str>) -> std::io::Result<Self> {
let mut r = Self::new(addr, token);
r.reconnect()?;
r.conn.connect()?;
Ok(r)
}
/// A bridge to the app at `addr` that connects when a call needs it: an MCP server started
/// before the app keeps its tools, and they reach the app once it runs.
pub fn new(addr: &str, token: Option<&str>) -> Self {
let port = addr.rsplit(':').next().and_then(|p| p.parse::<u16>().ok()).unwrap_or(0);
let explicit_token = token.map(str::to_string);
Self {
addr: addr.to_string(),
port,
conn: None,
next_id: 1,
explicit_token,
outstanding: None,
settled: None,
waits: WAITS,
conn: Connection::new(solidcraft_engine::control_auth::APP, addr, token).with_progress(note),
param_check: ParamCheck::Off,
app_checks: None,
echoed: None,
@@ -219,312 +133,45 @@ impl Remote {
/// Shorter waits for requests still running (tests).
#[cfg(test)]
pub(crate) fn set_waits(&mut self, waits: Waits) {
self.waits = waits;
}
/// Close the connection. A request still running loses its reply with it.
fn drop_conn(&mut self) {
self.conn = None;
// The next connection may reach another app (restarted, another version).
self.app_checks = None;
if let Some(o) = self.outstanding.take() {
self.settled = Some(Settled::Lost { what: o.what });
}
self.conn.set_waits(waits);
}
/// See whether the app is there now (`new` does not); the error says why not. The connection
/// doesn't stay open: the app closes one that shows no token within a few seconds, and the
/// first call may be long away. That call connects again.
pub fn try_connect(&mut self) -> std::io::Result<()> {
self.reconnect()?;
self.drop_conn();
Ok(())
self.conn.try_connect()
}
/// Failing to reach the app at all is `NotConnected`, so [`Remote::explain`] can tell it from
/// a connection lost during a call.
fn reconnect(&mut self) -> std::io::Result<()> {
self.drop_conn();
let not_connected = |e: std::io::Error| std::io::Error::new(std::io::ErrorKind::NotConnected, e);
let mut last = std::io::Error::new(std::io::ErrorKind::NotFound, format!("cannot resolve {}", self.addr));
for sa in self.addr.to_socket_addrs().map_err(not_connected)? {
match TcpStream::connect_timeout(&sa, Duration::from_millis(800)) {
Ok(s) => {
s.set_nodelay(true).ok();
s.set_read_timeout(Some(READ_TIMEOUT)).ok();
let read = s.try_clone()?;
self.conn = Some((BufReader::new(read), s));
return Ok(());
}
Err(e) => last = e,
}
}
Err(not_connected(last))
}
/// The tool error for a failed call: whether the app is not running (and how to start it) or
/// went away during the call.
fn explain(&self, e: &std::io::Error) -> String {
let (addr, port) = (&self.addr, self.port);
if e.kind() == std::io::ErrorKind::NotConnected {
format!(
"{NOT_RUNNING} {addr} ({e}): it is not running, or it quit. Start it with `solidcraft --control {port}` \
(or ask the user to); the next call connects to it"
)
} else {
format!(
"the SolidCraft app at {addr} did not answer ({e}): it may have quit or crashed during this call, after running it or not. Inspect the design before sending a change again; if the app quit, start it with `solidcraft --control {port}`"
)
}
}
fn roundtrip(&mut self, line: &str) -> std::io::Result<String> {
if self.conn.is_none() {
self.reconnect()?;
}
let Some((reader, writer)) = self.conn.as_mut() else { return Err(std::io::Error::other("not connected")) };
// One write: a request that loses its newline still reaches the app as a request.
let sent = writer.write_all(format!("{line}\n").as_bytes()).and_then(|()| writer.flush());
if let Err(e) = sent {
self.drop_conn();
return Err(e);
}
let mut reply = String::new();
let n = match reader.by_ref().take(MAX_REPLY).read_line(&mut reply) {
Ok(n) => n,
Err(e) => {
// A timed-out read leaves a late reply on the stream: start afresh next time.
self.drop_conn();
return Err(e);
}
};
if n == 0 || !reply.ends_with('\n') {
self.drop_conn();
return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "connection closed"));
}
Ok(reply)
}
fn set_read_timeout(&self, t: Duration) {
if let Some((_, writer)) = &self.conn {
writer.set_read_timeout(Some(t)).ok();
}
}
/// `engine.batch` is never sent twice (#743): it goes on a fresh connection (an idle one may
/// have been closed by an app restart; connecting again sends nothing), and once it is being
/// written any error is final, as the app may have run it.
fn send_batch(&mut self, line: &str) -> std::io::Result<String> {
self.reconnect().or_else(|_| self.reconnect())?;
self.set_read_timeout(BATCH_TIMEOUT);
let r = self.roundtrip(line);
self.set_read_timeout(READ_TIMEOUT);
r
}
}
impl Remote {
/// The request line for `method`: a fresh id, the agent's folders and the app's token.
fn request_line(&mut self, method: &str, params: Value) -> String {
let id = self.next_id;
self.next_id += 1;
/// `params` with the agent's folders: the app confines the files a request reaches to them
/// (control.rs), and to the folders it opened or saved a design in, which count as its open
/// design's.
fn scoped(params: Value) -> Value {
let mut params = params;
// The app confines the files this request reaches to the agent's folders (control.rs).
if let (Some(scope), Some(o)) = (solidcraft_engine::io::vfs::current_scope(), params.as_object_mut()) {
o.insert("allow_paths".into(), json!(scope.roots().iter().map(|r| r.to_string_lossy()).collect::<Vec<_>>()));
// And the folders it opened or saved a design in, which count as its open design's.
if !scope.design_roots().is_empty() {
o.insert("design_folders".into(), json!(scope.design_roots().iter().map(|r| r.to_string_lossy()).collect::<Vec<_>>()));
}
}
let mut msg = json!({"id": id, "method": method, "params": params});
// The token goes with every request (an app restarted mid-session has a new one).
let token = solidcraft_engine::control_auth::APP.client_token(self.port, self.explicit_token.as_deref());
if let Some(t) = &token {
msg["token"] = json!(t.token);
}
msg.to_string()
}
/// Ask the app, on this connection only, about the request it still runs for it.
fn ask_pending(&mut self) -> Result<Value, String> {
if self.conn.is_none() {
self.drop_conn();
return Err("the connection to the app was lost".into());
}
let line = self.request_line("control.pending", json!({}));
let reply = self.roundtrip(&line).map_err(|e| self.explain(&e))?;
let v: Value = serde_json::from_str(&reply).map_err(|e| format!("bad reply from the app: {e}"))?;
outcome(&v).inspect_err(|_| self.drop_conn())
}
/// `control.cancel` for the request still running on this connection (a timed-out call's,
/// docs/control-protocol.md). Sent at once, without waiting for that request first, and never
/// on a fresh connection, which has nothing to cancel. Once the app no longer holds it
/// (removed, stopped or finished), later calls don't wait for it.
fn cancel_outstanding(&mut self, params: Value) -> Result<Value, String> {
if self.conn.is_none() {
return Ok(json!({"cancelled": null, "note": "nothing to cancel: no request of this bridge is running in the app"}));
}
let line = self.request_line("control.cancel", params);
let reply = self.roundtrip(&line).map_err(|e| self.explain(&e))?;
let v: Value = serde_json::from_str(&reply).map_err(|e| format!("bad reply from the app: {e}"))?;
let r = outcome(&v);
if r.is_ok() {
self.outstanding = None;
}
r
}
/// Wait up to `limit` for the outstanding request, asking `control.pending` now and then and
/// saying on stderr how long it takes. An error: the connection, and so the reply, was lost.
fn wait_outstanding(&mut self, limit: Duration) -> Result<Waited, String> {
let start = Instant::now();
let mut told = start;
loop {
let Some(o) = &self.outstanding else { return Ok(Waited::Nothing) };
let what = o.what.clone();
let r = self.ask_pending()?;
if r.get("pending").is_none_or(Value::is_null) {
self.outstanding = None;
return Ok(Waited::Nothing);
}
if r.get("done").and_then(Value::as_bool) == Some(true) {
self.outstanding = None;
return Ok(Waited::Done(r.get("reply").cloned().unwrap_or(Value::Null)));
}
let waited = start.elapsed();
if waited >= limit {
return Ok(Waited::Running);
}
if told.elapsed() >= PROGRESS_EVERY {
let secs = self.outstanding.as_ref().map_or(0, Outstanding::secs);
solidcraft_engine::note!("solidcraft-mcp: {what} is still running in the app ({secs} s); waiting for it");
told = Instant::now();
}
std::thread::sleep(self.waits.poll.min(limit.saturating_sub(waited)));
}
}
/// The tool error for the outstanding request still running after its wait.
fn still_running(&self, earlier: bool) -> String {
let Some(o) = &self.outstanding else { return format!("{STILL_RUNNING} a request is still running in the app") };
let effect =
o.effect.map_or_else(|| "it was not cancelled and may still change the design when it finishes".into(), |e| e.when_done("design"));
let settle = self.waits.settle.as_secs();
if earlier {
format!(
"{STILL_RUNNING} an earlier request, {}, is still running in the app ({} s; {effect}), so this call was not sent. Call the \
`pending` tool to wait for its result, then send this call again",
o.what,
o.secs()
)
} else {
format!(
"{STILL_RUNNING} {} is still running in the app after {} s ({effect}); don't send it again. Call the `pending` tool to \
wait for its result (up to {settle} s per call); other calls wait for it first",
o.what,
o.secs()
)
}
}
/// Before a new request: wait for one still running from an earlier call, keeping its end for
/// `pending`. Still running after the wait: the new request is not sent.
fn settle_earlier(&mut self) -> Result<(), String> {
let Some(what) = self.outstanding.as_ref().map(|o| o.what.clone()) else { return Ok(()) };
match self.wait_outstanding(self.waits.settle) {
Ok(Waited::Done(reply)) => self.settled = Some(Settled::Finished { what, reply }),
Ok(Waited::Nothing) => {}
Ok(Waited::Running) => return Err(self.still_running(true)),
// The connection was lost, and the earlier reply with it (`pending` says so): the new
// request goes on a fresh connection.
Err(_) => {}
}
Ok(())
}
/// `control.pending` for the bridge: the end of the request still running from an earlier
/// call, waiting for it up to the settle wait. Never sent on a fresh connection, which has
/// nothing pending.
fn pending_status(&mut self) -> Result<Value, String> {
if let Some(s) = self.settled.take() {
return Ok(match s {
Settled::Finished { what, reply } => json!({"pending": what, "done": true, "reply": reply}),
Settled::Lost { what } => json!({"pending": what, "lost": true}),
});
}
let Some(what) = self.outstanding.as_ref().map(|o| o.what.clone()) else { return Ok(json!({"pending": null})) };
match self.wait_outstanding(self.waits.settle) {
Ok(Waited::Done(reply)) => Ok(json!({"pending": what, "done": true, "reply": reply})),
Ok(Waited::Nothing) => Ok(json!({"pending": null})),
Ok(Waited::Running) => Ok(json!({"pending": what, "done": false, "running_s": self.outstanding.as_ref().map_or(0, Outstanding::secs)})),
Err(_) => {
self.settled = None;
Ok(json!({"pending": what, "lost": true}))
}
}
params
}
/// `method` sent to the app as the shared client sends a request: named and with its effect
/// for messages, `engine.batch` never sent twice (#743), and the level the app checked the
/// reply at kept.
fn call_once(&mut self, method: &str, params: Value, retry_busy: bool) -> Result<Value, String> {
// The app answers `control.running` at once, even behind a request still running: it is
// not held back for that one as other calls are.
if method != "control.running" {
self.settle_earlier()?;
}
self.echoed = None;
let what = control_pending::describe(method, &params);
let effect = control_pending::effect(method, &params);
let again = retry_busy.then(|| params.clone());
let sent = Instant::now();
let line = self.request_line(method, params);
let reply = if method == "engine.batch" {
self.send_batch(&line)
} else {
// One retry on a fresh connection (the app may have restarted). When nothing listens
// any more, the app went away, maybe while running this request: say that, not
// just "not running".
self.roundtrip(&line).or_else(|lost| {
self.roundtrip(&line).map_err(|e| if e.kind() == std::io::ErrorKind::NotConnected && lost.kind() != e.kind() { lost } else { e })
})
}
.map_err(|e| self.explain(&e))?;
let v: Value = serde_json::from_str(&reply).map_err(|e| format!("bad reply from the app: {e}"))?;
self.echoed = echoed(&v);
// Timed out in the app, still running (#1618): wait for its result on this connection.
if v.get("running").and_then(Value::as_bool) == Some(true) {
self.outstanding = Some(Outstanding { what: what.clone(), effect: Some(effect), since: sent });
solidcraft_engine::note!(
"solidcraft-mcp: {what} is still running in the app after {} s; waiting up to {} s more for it",
sent.elapsed().as_secs(),
self.waits.pending.as_secs()
);
return match self.wait_outstanding(self.waits.pending)? {
Waited::Done(reply) => {
self.echoed = echoed(&reply);
outcome(&reply)
}
Waited::Nothing => {
Err(format!("{what} timed out in the app, which kept no result for it: inspect the design before sending it again"))
}
Waited::Running => Err(self.still_running(false)),
};
}
// Refused unrun behind a request this bridge did not know of: wait for that one, then
// send this one once more.
if v.get("busy").and_then(Value::as_bool) == Some(true)
&& let Some(params) = again
{
self.outstanding = Some(Outstanding { what: "an earlier request".into(), effect: None, since: Instant::now() });
self.settle_earlier()?;
return self.call_once(method, params, false);
}
// A refused token: say which token file it came from (and whether it is stale).
outcome(&v).map_err(|error| {
solidcraft_engine::control_auth::APP
.client_token(self.port, self.explicit_token.as_deref())
.map_or_else(|| error.clone(), |t| t.explain_error(&error))
})
let request = Request {
method: method.to_string(),
what: control_pending::describe(method, &params),
effect: control_pending::effect(method, &params),
once: method == "engine.batch",
params: Self::scoped(params),
};
let reply = self.conn.exchange(request, retry_busy)?;
self.echoed = echoed(&reply);
Ok(self.conn.outcome(&reply)?)
}
}
@@ -549,21 +196,21 @@ impl Remote {
/// connection with `control.capabilities`, which lists the levels it takes; an older app
/// answers "unknown method". Any other answer (no app, a busy one) is asked again next time.
fn app_checks(&mut self) -> bool {
if let Some(known) = self.app_checks {
if let Some((serial, known)) = self.app_checks
&& self.conn.is_connected()
&& serial == self.conn.serial()
{
return known;
}
// Sent ahead of settling a request still running on this connection: the app answers
// `control.capabilities` at once, as `control.running`, without taking that one's reply.
let line = self.request_line("control.capabilities", json!({}));
let reply = self.roundtrip(&line).ok().and_then(|r| serde_json::from_str::<Value>(&r).ok());
let reply = self.conn.ask("control.capabilities", Self::scoped(json!({}))).ok();
let known = match reply.as_ref().map(outcome) {
Some(Ok(c)) => Some(c["param_check"].as_array().is_some_and(|levels| levels.iter().any(|l| l == self.param_check.name()))),
Some(Err(e)) if e.starts_with("unknown method") => Some(false),
_ => None,
};
if self.conn.is_some() {
self.app_checks = known;
}
self.app_checks = if self.conn.is_connected() { known.map(|k| (self.conn.serial(), k)) } else { None };
known.unwrap_or(false)
}
@@ -573,7 +220,7 @@ impl Remote {
fn confirmed(&mut self, reply: Result<Value, String>) -> Result<Value, String> {
let unconfirmed = reply.is_ok() && self.echoed != Some(self.param_check);
if unconfirmed {
self.app_checks = Some(false);
self.app_checks = Some((self.conn.serial(), false));
}
match reply {
Ok(_) if unconfirmed && self.param_check == ParamCheck::Strict => {
@@ -658,12 +305,12 @@ impl Remote {
impl Backend for Remote {
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
match method {
"control.pending" => self.pending_status(),
"control.pending" => Ok(self.conn.pending()),
"engine.execute" if self.param_check != ParamCheck::Off => self.execute_checked(params),
"engine.batch" if self.param_check != ParamCheck::Off => self.batch_checked(params),
"engine.script" if self.param_check != ParamCheck::Off => self.call_once(method, self.at_level(params), true),
// Not behind the request it cancels, as other calls are.
"control.cancel" => self.cancel_outstanding(params),
"control.cancel" => Ok(self.conn.cancel(Self::scoped(params))?),
_ => self.call_once(method, params, true),
}
}
@@ -675,46 +322,30 @@ impl Backend for Remote {
}
/// The same app with this bridge's token, not yet connected: it connects when asked.
fn side_channel(&self) -> Option<Remote> {
Some(Remote::new(&self.addr, self.explicit_token.as_deref()).with_param_check(self.param_check))
Some(Remote::new(self.conn.addr(), self.conn.explicit_token()).with_param_check(self.param_check))
}
fn app_options(&mut self) -> Result<Option<Value>, String> {
// This runs on the serve loop before a tool call, so it never waits: not behind a request
// still running (ask again on a later call), and one try with no retry, because a retry
// reconnects with READ_TIMEOUT and could block for minutes.
if self.outstanding.is_some() {
return Err("a request is still running in the app".into());
}
if self.conn.is_none() {
self.reconnect().map_err(|e| self.explain(&e))?;
}
let line = self.request_line("mcp.options", json!({}));
self.set_read_timeout(OPTIONS_TIMEOUT);
let reply = self.roundtrip(&line);
self.set_read_timeout(READ_TIMEOUT);
let reply = reply.map_err(|e| self.explain(&e))?;
let v: Value = serde_json::from_str(&reply).map_err(|e| format!("bad reply from the app: {e}"))?;
let r = outcome(&v).map_err(|error| {
solidcraft_engine::control_auth::APP
.client_token(self.port, self.explicit_token.as_deref())
.map_or_else(|| error.clone(), |t| t.explain_error(&error))
});
match r {
match self.conn.quick("mcp.options", Self::scoped(json!({})), OPTIONS_TIMEOUT) {
Ok(v) => Ok(Some(v)),
// An app from before the setting (its agent chat refuses methods it doesn't know):
// the server's own options decide.
Err(e) if e.starts_with("unknown method") || e.contains("is not available in the agent chat") => Ok(None),
Err(e) => Err(e),
Err(CallError::Refused(e)) if e.starts_with("unknown method") || e.contains("is not available in the agent chat") => Ok(None),
Err(e) => Err(e.into()),
}
}
fn describe(&self) -> String {
if self.conn.is_some() {
format!("connected to the SolidCraft app at {} (the user watches the viewport)", self.addr)
if self.conn.is_connected() {
format!("connected to the SolidCraft app at {} (the user watches the viewport)", self.conn.addr())
} else {
// Still "connected to the SolidCraft app": the skills tell bridged from headless by it.
format!(
"connected to the SolidCraft app at {} once it runs (the user watches the viewport). It was not running \
when this server started; until it is (`solidcraft --control {}`), tool calls say so",
self.addr, self.port
self.conn.addr(),
self.conn.port()
)
}
}
-6
View File
@@ -71,14 +71,12 @@ impl Body {
pub(crate) fn fixes() -> UpstreamFixes {
let mut f = UpstreamFixes::default();
f.fillet_retry_radius_as_first = true;
f.fillet_rail_rim_on_face = true;
f.fillet_refuse_rails_leaving_faces = true;
f.fillet_refuse_face_consuming_blends = true;
f.fillet_refuse_non_manifold_result = true;
f.fillet_vertex_error_diagnostics = true;
f.fillet_corner_ball_patch = true;
f.fillet_chamfer_cone_on_arc = true;
f.fillet_torus_center_on_axis = true;
f.offset_excess_faces_after_merge = true;
f.offset_clip_right_side_out = true;
f.revolution_offset_axis_both_sides = true;
@@ -172,7 +170,6 @@ pub(crate) fn fixes() -> UpstreamFixes {
f.local_operation_step_walls = true;
f.edge_check_orders_tangent_faces = true;
f.thicken_planar_side_walls = true;
f.fillet_torus_span_from_start_section = true;
f.fillet_knife_edge_rounds = true;
f.revolution_round_offset_collapses = true;
f.fillet_chamfer_cone_stored_as_cone = true;
@@ -187,12 +184,9 @@ pub(crate) fn fixes() -> UpstreamFixes {
f.cylinder_plane_tangent_within_tolerance = true;
f.boolean_wire_edge_keeps_faceuse_shell = true;
f.fillet_nx1_tangent_corner_edge = true;
f.fillet_torus_rail_on_axis = true;
f.loops_go_on_round_the_vertex = true;
f.revolve_splits_curves_on_axis = true;
f.cover_wraps_across_closed_seam = true;
f.fillet_trace_from_tangent_point_toward_next = true;
f.fillet_tangent_point_one_sided_step = true;
f.fillet_rails_meet_at_one_end_point = true;
f.fillet_trace_reaches_rails = true;
f.delete_vertex_joins_seam_iso_curve = true;
+22
View File
@@ -13080,6 +13080,28 @@ mod settings_search_tests {
assert!(!drawn(&h, "Status bar"), "a miss shows nothing");
}
/// The Placed features row is found by the name of every feature that has the marker, not
/// only by its own label.
#[test]
fn preferences_search_finds_placement_handles_by_each_placed_feature() {
let mut h = crate::scenario::Harness::new();
h.app.home.open = false;
crate::prefs::open_at(&mut h.app, crate::prefs::PAGE_TOOLS);
h.frames(2);
let drawn = |h: &crate::scenario::Harness, label: &str| {
let body = crate::scenario::rect_of("dialog:page_body");
h.widget(label).is_some_and(|[x, y]| body.is_none_or(|body| body.contains(egui::pos2(x as f32, y as f32))))
};
for query in ["Placed features", "Rest", "Boss", "Snap Fit", "Box", "Cylinder", "Cone", "Sphere", "Torus", "Coil"] {
h.app.prefs_window.shell.filter = query.into();
h.frames(4);
assert!(drawn(&h, "Placement handles"), "searching '{query}' should show Placement handles");
}
h.app.prefs_window.shell.filter = "zzzz-no-setting".into();
h.frames(4);
assert!(!drawn(&h, "Placement handles"), "a miss shows nothing");
}
#[test]
fn design_and_tools_short_labels_keep_legacy_search_and_reveal_the_real_control() {
for language in ["en", "ja"] {
+13 -5
View File
@@ -7,7 +7,7 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
ui_kit::settings_heading(ui, app.tr("prefs.structure.tools"), app.tr("prefs.structure.tools_note"));
ui_kit::settings_section(ui, "create", app.tr("ui.create"), |ui| {
create_card(ui, |ui| {
create_row(ui, app.tr("ui.hole"), &["hole"], true, |ui| {
create_row(ui, app.tr("ui.hole"), None, &["hole"], true, |ui| {
for (setting, help, key, legacy) in [
(
setting::hole_snaps as fn(&mut SolveApp, &mut egui::Ui) -> egui::Response,
@@ -29,7 +29,7 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
});
}
});
create_row(ui, app.tr_label("Extrude"), &["extrude"], false, |ui| {
create_row(ui, app.tr_label("Extrude"), None, &["extrude"], false, |ui| {
for (setting, help, key) in [
(
setting::extrude_auto_to_object as fn(&mut SolveApp, &mut egui::Ui) -> egui::Response,
@@ -56,7 +56,9 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
crate::scenario::publish_target("prefs:extrude_view_return", response.rect);
});
});
create_row(ui, app.tr("prefs.design_tools.placement_group"), &["rest", "boss", "snap_fit"], false, |ui| {
// Found by the name of every feature that has the marker, as well as by the label.
let placed = PLACED_FEATURES.iter().map(|key| app.tr(key)).collect::<Vec<_>>().join(" ");
create_row(ui, app.tr("prefs.design_tools.placement_group"), Some(&placed), &["rest", "boss", "snap_fit"], false, |ui| {
ui.horizontal_wrapped(|ui| {
let response = setting::placement_handles(app, ui).backing_on_hover_text(app.tr("ui.placement_handles_tip"));
crate::scenario::publish_target("prefs:placement_handles", response.rect);
@@ -215,8 +217,14 @@ fn create_card(ui: &mut egui::Ui, rows: impl FnOnce(&mut egui::Ui)) {
/// One tool in the Create card: its icons in tiles and its name on the left, its settings on the
/// right, a rule above every row but the first. Search uses the same block gate as `tool_group`.
fn create_row(ui: &mut egui::Ui, label: &str, icons: &[&str], first: bool, add: impl FnOnce(&mut egui::Ui)) {
let gate = crate::ui_kit::settings_search::block_gate(ui, label, None);
/// The features with a placement marker, by their name keys: Preferences search finds the
/// Placed features row by any of them.
const PLACED_FEATURES: [&str; 9] =
["cmd.plastic_rest", "cmd.plastic_boss", "cmd.plastic_snap_fit", "ui.box", "ui.cylinder", "ui.cone", "ui.sphere", "ui.torus", "ui.coil"];
/// A tool's row; `hint` is more text the search finds the row by (it is not shown).
fn create_row(ui: &mut egui::Ui, label: &str, hint: Option<&str>, icons: &[&str], first: bool, add: impl FnOnce(&mut egui::Ui)) {
let gate = crate::ui_kit::settings_search::block_gate(ui, label, hint);
let before = crate::ui_kit::settings_search::hits(ui);
if gate.as_ref().is_some_and(|gate| !gate.show) {
crate::ui_kit::settings_search::run_hidden(ui, label, add);
+3
View File
@@ -93,6 +93,9 @@ copy of the body so the print face points down at Z = 0, with its centre of mass
origin; the design itself does not move. The face is tinted and outlined in the viewport, and a
copy made with Move/Copy or Mirror keeps it. A print face that is gone or no longer flat is
reported, and the body is sent as it lies. Curved faces and mesh bodies can't be print faces.
Scripts and agents can write the same laid-down copies to an STL or 3MF file without a slicer:
`file.export_stl`, `file.export_3mf`, `file.export` and `file.save_mesh` take
`lay_on_print_faces: true`, and the reply names the bodies that were laid on their print face.
Known limitation: after a shell of a body that is already shelled, no face of that body can be
marked, as no sketch can be attached to one either; this needs kernel work in solidcore.
+1 -1
View File
@@ -18,7 +18,7 @@ Each group is a 120x64 figure at the left of its checkboxes, with a group name u
| Extrude: Choose the operation automatically | Checkbox | `Prefs.auto_operation` | `true` | "Join, New Body or Cut, from what the material touches." | `tool-extrude` | |
| Extrude: Turn the view to isometric when Extrude opens | Checkbox | `Prefs.extrude_view_isometric` | `false` | none | `tool-extrude` | Retained newer control, directly after automatic operation in the same group. |
| Extrude: Go back to the earlier view when Extrude closes | Checkbox | `Prefs.extrude_view_return` | `false` | none | `tool-extrude` | Directly after isometric; disabled unless isometric is on. |
| Rest, Boss, Snap Fit: Placement handles | Checkbox | `Prefs.placement_handles` | `false` | "A marker to drag the feature across the model and a ring to turn it." | `tool-placement` | |
| Placed features: Placement handles | Checkbox | `Prefs.placement_handles` | `false` | "A marker to drag the feature across the model and, where the feature can turn, a ring to turn it." | `tool-placement` | |
## Assembly Move
+3 -2
View File
@@ -347,8 +347,9 @@ that every file came back signed. Without the secret (forks) the jobs build unsi
- **Test-sign without publishing:** `gh workflow run release.yml -R tangency-labs/solidcraft --ref
<branch> -f draft=false`, then check the Windows jobs' summaries and the `release-assets`
artifact.
- SignPath only signs runs whose jobs all ran on GitHub-hosted runners, so keep the `version`,
`icons` and `windows` jobs off self-hosted runners.
- SignPath only signs requests whose jobs leading up to them all ran on GitHub-hosted runners, so
the `version` and `windows` jobs stay on GitHub's runners while every other release job runs on
Blacksmith.
## Homebrew tap
@@ -17,9 +17,9 @@
{"case":"adjacent-two-bodies-small","deferred":null,"error":null,"ints":{"api_status_result_vertex_edge_face_counts":[0,1,4,4,1],"body0_topology":{"blocks":[[128403,5122478]],"n":91},"body0_vertex_edge_face_counts":[6,7,2],"body1_topology":{"blocks":[[128403,5122478]],"n":91},"body1_vertex_edge_face_counts":[6,7,2],"result_topology":{"blocks":[[71196,1456742]],"n":49}},"topology":null,"values":{"body0_geometry":{"blocks":[[437.16011999999995,437.16011999999995,41736.076799999995]],"n":186},"body1_geometry":{"blocks":[[654.3001199999998,654.3001199999998,60852.5788]],"n":186},"fillet_point_step":[0.00001],"result_geometry":{"blocks":[[370.2274884099221,373.42748840992215,19893.411576968836]],"n":106}}}
{"case":"attributed-box-all-edges-r1","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,24,48,26]},"topology":null,"values":{"attributes":{"blocks":[[120977.06194288167,120977.06194288167,7270559.503909666]],"n":174},"fillet_point_step":[0.00001]}}
{"case":"attributed-box-cylinder-arc-round-seam-mid-r2","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,15,23,10]},"topology":null,"values":{"attributes":{"blocks":[[501488.75762421096,501488.75762421096,44364677.586285785]],"n":164},"fillet_point_step":[0.00001]}}
{"case":"attributed-box-cylinder-arc-round-seam-opposite-r2","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[1001,10,15,8]},"topology":null,"values":{"attributes":{"blocks":[[480413.1912678502,480413.1912678502,34610352.53320036]],"n":140},"fillet_point_step":[0.00001]}}
{"case":"attributed-box-cylinder-arc-round-seam-opposite-r2","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,12,18,9]},"topology":null,"values":{"attributes":{"blocks":[[459392.6159228618,459392.6159228618,34982982.892283924]],"n":144},"fillet_point_step":[0.00001]}}
{"case":"attributed-box-cylinder-arc-round-seam-quarter-r2","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,12,18,9]},"topology":null,"values":{"attributes":{"blocks":[[459392.61597430886,459392.61597430886,34980837.82187664]],"n":144},"fillet_point_step":[0.00001]}}
{"case":"attributed-box-cylinder-arc-round-seam-three-quarter-r2","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[1001,10,15,8]},"topology":null,"values":{"attributes":{"blocks":[[480413.19126785017,480413.19126785017,34607083.53077263]],"n":140},"fillet_point_step":[0.00001]}}
{"case":"attributed-box-cylinder-arc-round-seam-three-quarter-r2","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,12,18,9]},"topology":null,"values":{"attributes":{"blocks":[[459392.61603265547,459392.61603265547,34979788.966652505]],"n":144},"fillet_point_step":[0.00001]}}
{"case":"attributed-box-edge0-behavior0","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,10,15,7]},"topology":null,"values":{"attributes":{"blocks":[[352173.9822852112,352173.9822852112,21632188.503961973]],"n":114},"fillet_point_step":[0.00001]}}
{"case":"attributed-box-edge0-behavior1","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,10,15,7]},"topology":null,"values":{"attributes":{"blocks":[[352190.9822852112,352190.9822852112,21633222.503961973]],"n":114},"fillet_point_step":[0.00001]}}
{"case":"attributed-box-edge0-behavior2","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,10,15,7]},"topology":null,"values":{"attributes":{"blocks":[[352197.9822852112,352197.9822852112,21633766.503961973]],"n":114},"fillet_point_step":[0.00001]}}
@@ -56,7 +56,7 @@
{"case":"attributed-step-edge9-r1","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,14,21,9]},"topology":null,"values":{"attributes":{"blocks":[[518859.2787837579,518859.2787837579,44576274.79263492]],"n":162},"fillet_point_step":[0.00001]}}
{"case":"attributed-variable-box-edge0-1-to-2","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,10,15,7]},"topology":null,"values":{"attributes":{"blocks":[[352177.32784037106,352177.32784037106,21632479.256082594]],"n":114},"fillet_point_step":[0.00001]}}
{"case":"attributed-variable-box-edge6-2-to-1","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,10,15,7]},"topology":null,"values":{"attributes":{"blocks":[[352165.32784038194,352165.32784038194,21631500.77399957]],"n":114},"fillet_point_step":[0.00001]}}
{"case":"big-rad-filleted_box-r1.0-top-edges","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,20,36,18],"mass_status":[0],"result_topology":{"blocks":[[281291,35318792],[285980,37333166],[211429,16888283]],"n":668}},"topology":null,"values":{"fillet_point_step":[0.00001],"mass_properties":[564.238945222809,983.5015547299164,983.5015547299164,5.038410057395567,5.000000000000008,4.999999925784339,65212.8703730667,65652.94303844232,65652.94978056221,24587.53852411164,24776.42022262525,24776.420624076974],"result_geometry":{"blocks":[[1043.909543499877,1043.909543499877,132577.34831244644],[786.6137295003684,786.6137295003684,92148.64887494127],[548.8554048175436,548.8554048175436,83452.93188722905],[707.0839096995004,708.6839096995003,71698.26219582555],[75.48697566657789,75.48697566657789,960.1950347119924]],"n":1052},"selected_edge_before":{"blocks":[[112569.0,112569.0,7262465.274761933]],"n":136}}}
{"case":"big-rad-filleted_box-r1.0-top-edges","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,20,36,18],"mass_status":[0],"result_topology":{"blocks":[[281283,35317521],[285988,37334239],[211429,16888283]],"n":668}},"topology":null,"values":{"fillet_point_step":[0.00001],"mass_properties":[564.2389452228085,983.5015547299157,983.5015547299157,5.038410057395571,5.000000000000004,4.999999925784341,65212.870373066624,65652.9430384423,65652.94978056214,24587.53852411162,24776.42022262525,24776.420624076964],"result_geometry":{"blocks":[[1043.909543499877,1043.909543499877,132801.34831244644],[786.6137295003689,786.6137295003689,92148.64887494135],[548.8554048175439,548.8554048175439,83452.93188722912],[707.0839096995005,708.6839096995004,71698.26219582556],[75.48697566657812,75.48697566657812,960.1950347119977]],"n":1052},"selected_edge_before":{"blocks":[[112569.0,112569.0,7262465.274761933]],"n":136}}}
{"case":"big-rad-filleted_box-r2.0-top-edges","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[1001,16,24,10],"mass_status":[0],"result_topology":{"blocks":[[281192,35301210],[207150,18894363]],"n":440}},"topology":null,"values":{"fillet_point_step":[0.00001],"mass_properties":[581.1150528699196,991.4160344161737,991.4160344161737,5.000000000000002,4.999999999999986,4.999999975615006,65845.15814429456,65969.77702349689,65969.78339230167,24785.400739525885,24785.40073952599,24785.400860155278],"result_geometry":{"blocks":[[1040.9539390913508,1040.9539390913508,137529.47261491715],[755.3864709086489,755.3864709086489,107054.86402295402],[580.7960876026176,593.5960876026177,48777.44680852571]],"n":676},"selected_edge_before":{"blocks":[[112569.0,112569.0,7262465.274761933]],"n":136}}}
{"case":"big-rad-filleted_box-r3.0-top-edges","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[1001,16,24,10],"mass_status":[0],"result_topology":{"blocks":[[281192,35301210],[207150,18894363]],"n":440}},"topology":null,"values":{"fillet_point_step":[0.00001],"mass_properties":[581.1150528699196,991.4160344161737,991.4160344161737,5.000000000000002,4.999999999999986,4.999999975615006,65845.15814429456,65969.77702349689,65969.78339230167,24785.400739525885,24785.40073952599,24785.400860155278],"result_geometry":{"blocks":[[1040.9539390913508,1040.9539390913508,137529.47261491715],[755.3864709086489,755.3864709086489,107054.86402295402],[580.7960876026176,593.5960876026177,48777.44680852571]],"n":676},"selected_edge_before":{"blocks":[[112569.0,112569.0,7262465.274761933]],"n":136}}}
{"case":"big-radius-box_diff_cyl_big_rad-r28-edge12","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,5,7,3],"mass_status":[1007],"result_topology":{"blocks":[[143392,7604201]],"n":110}},"topology":null,"values":{"fillet_point_step":[0.00001],"mass_properties":[],"result_geometry":{"blocks":[[3031.0901543149744,4114.4964121177445,280772.74380950886]],"n":200},"selected_edge_before":{"blocks":[[14306.057758366,14306.057758366,72955.84024483882]],"n":17}}}
@@ -365,13 +365,13 @@
{"case":"issue919-cylinder-rim-edge2-blend-curve-g1-r9.99","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[1007,2,3,3],"mass_status":[0],"result_topology":{"blocks":[[87159,2825806]],"n":60}},"topology":null,"values":{"fillet_point_step":[0.00001],"mass_properties":[1884.9556065113175,6283.18500796372,6283.18500796372,-1.4726672827265266e-8,1.4726670363343151e-8,9.999999999999998,994837.7456252411,994837.7456252411,314159.4891268237,0.011249309873292779,0.009398701749560834,-0.07608134049405635],"result_geometry":{"blocks":[[527.7040579753087,702.5531233622659,26658.127877841434]],"n":116},"selected_edge_before":{"blocks":[[14156.70132599177,14188.020009198175,71124.74995626012]],"n":17}}}
{"case":"issue919-cylinder-rim-edge2-blend-curve-g2-r10.0","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[1007,2,3,3],"mass_status":[0],"result_topology":{"blocks":[[87159,2825806]],"n":60}},"topology":null,"values":{"fillet_point_step":[0.00001],"mass_properties":[1884.9556065113175,6283.18500796372,6283.18500796372,-1.4726672827265266e-8,1.4726670363343151e-8,9.999999999999998,994837.7456252411,994837.7456252411,314159.4891268237,0.011249309873292779,0.009398701749560834,-0.07608134049405635],"result_geometry":{"blocks":[[527.7040579753087,702.5531233622659,26658.127877841434]],"n":116},"selected_edge_before":{"blocks":[[14156.70132599177,14188.020009198175,71124.74995626012]],"n":17}}}
{"case":"issue919-cylinder-rim-edge2-blend-curve-g2-r9.99","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[1007,2,3,3],"mass_status":[0],"result_topology":{"blocks":[[87159,2825806]],"n":60}},"topology":null,"values":{"fillet_point_step":[0.00001],"mass_properties":[1884.9556065113175,6283.18500796372,6283.18500796372,-1.4726672827265266e-8,1.4726670363343151e-8,9.999999999999998,994837.7456252411,994837.7456252411,314159.4891268237,0.011249309873292779,0.009398701749560834,-0.07608134049405635],"result_geometry":{"blocks":[[527.7040579753087,702.5531233622659,26658.127877841434]],"n":116},"selected_edge_before":{"blocks":[[14156.70132599177,14188.020009198175,71124.74995626012]],"n":17}}}
{"case":"issue920-cylinder-rim-edge1-constant-r10.0","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,3,4,3],"mass_status":[0],"result_topology":{"blocks":[[101236,3961388]],"n":77}},"topology":null,"values":{"fillet_point_step":[0.00001],"mass_properties":[1570.7963760973983,5235.988291000227,5235.988291000227,1.8833101490165886e-7,2.059948257914543e-7,11.500000235372113,947714.0853077865,947713.8663011974,240855.69263077818,0.01617878124484213,0.014791470053185648,-0.03804027228670748],"result_geometry":{"blocks":[[590.9590566994827,737.60927109562,34353.41686048178]],"n":138},"selected_edge_before":{"blocks":[[14095.70132599177,14127.020009198175,70343.74995626012]],"n":17}}}
{"case":"issue920-cylinder-rim-edge1-constant-r10.0","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,3,4,3],"mass_status":[0],"result_topology":{"blocks":[[101236,3961388]],"n":77}},"topology":null,"values":{"fillet_point_step":[0.00001],"mass_properties":[1570.796376097398,5235.988291000227,5235.988291000227,1.883310151187847e-7,2.0599482613885566e-7,11.500000235372115,947714.0853077865,947713.8663011974,240855.69263077818,0.016178781256665505,0.01479147006091632,-0.038040272280341014],"result_geometry":{"blocks":[[590.9590566994827,737.60927109562,34353.41686048178]],"n":138},"selected_edge_before":{"blocks":[[14095.70132599177,14127.020009198175,70343.74995626012]],"n":17}}}
{"case":"issue920-cylinder-rim-edge1-constant-r9.99","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,3,5,4],"mass_status":[0],"result_topology":{"blocks":[[130313,6525256]],"n":96}},"topology":null,"values":{"fillet_point_step":[0.00001],"mass_properties":[1571.1549739407485,5237.781041311214,5237.781041311214,1.882665543057572e-7,2.0592431928935601e-7,11.497487105531828,947809.066019011,947808.8470124219,240963.9868810795,0.016178781243650686,0.014791470064092968,-0.03804027240533144],"result_geometry":{"blocks":[[596.9287052557129,789.048870953054,40056.385280241884]],"n":174},"selected_edge_before":{"blocks":[[14095.70132599177,14127.020009198175,70343.74995626012]],"n":17}}}
{"case":"issue920-cylinder-rim-edge1-variable-constant-law-r10.0","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,3,4,3],"mass_status":[0],"result_topology":{"blocks":[[101236,3961388]],"n":77}},"topology":null,"values":{"fillet_point_step":[0.00001],"mass_properties":[1570.7963760973983,5235.988291000227,5235.988291000227,1.8833101490165886e-7,2.059948257914543e-7,11.500000235372113,947714.0853077865,947713.8663011974,240855.69263077818,0.01617878124484213,0.014791470053185648,-0.03804027228670748],"result_geometry":{"blocks":[[590.9590566994827,737.60927109562,34353.41686048178]],"n":138},"selected_edge_before":{"blocks":[[14095.70132599177,14127.020009198175,70343.74995626012]],"n":17}}}
{"case":"issue920-cylinder-rim-edge1-variable-constant-law-r10.0","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,3,4,3],"mass_status":[0],"result_topology":{"blocks":[[101236,3961388]],"n":77}},"topology":null,"values":{"fillet_point_step":[0.00001],"mass_properties":[1570.796376097398,5235.988291000227,5235.988291000227,1.883310151187847e-7,2.0599482613885566e-7,11.500000235372115,947714.0853077865,947713.8663011974,240855.69263077818,0.016178781256665505,0.01479147006091632,-0.038040272280341014],"result_geometry":{"blocks":[[590.9590566994827,737.60927109562,34353.41686048178]],"n":138},"selected_edge_before":{"blocks":[[14095.70132599177,14127.020009198175,70343.74995626012]],"n":17}}}
{"case":"issue920-cylinder-rim-edge1-variable-constant-law-r9.99","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,3,5,4],"mass_status":[0],"result_topology":{"blocks":[[130313,6525256]],"n":96}},"topology":null,"values":{"fillet_point_step":[0.00001],"mass_properties":[1571.1549739407485,5237.781041311214,5237.781041311214,1.882665543057572e-7,2.0592431928935601e-7,11.497487105531828,947809.066019011,947808.8470124219,240963.9868810795,0.016178781243650686,0.014791470064092968,-0.03804027240533144],"result_geometry":{"blocks":[[596.9287052557129,789.048870953054,40056.385280241884]],"n":174},"selected_edge_before":{"blocks":[[14095.70132599177,14127.020009198175,70343.74995626012]],"n":17}}}
{"case":"issue920-cylinder-rim-edge2-constant-r10.0","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,3,4,3],"mass_status":[0],"result_topology":{"blocks":[[101242,3857334]],"n":77}},"topology":null,"values":{"fillet_point_step":[0.00001],"mass_properties":[1570.7963806678383,5235.989024424656,5235.989024424656,-2.059947972606453e-7,-1.8833099001756472e-7,8.500002269459227,633554.8212238712,633555.0402304602,240855.4741473579,-0.004930509755303675,-0.005392948670318698,-0.038040272243961226],"result_geometry":{"blocks":[[488.1492145711593,651.8980941809427,32888.688473480026]],"n":138},"selected_edge_before":{"blocks":[[14156.70132599177,14188.020009198175,71124.74995626012]],"n":17}}}
{"case":"issue920-cylinder-rim-edge2-constant-r10.0","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,3,4,3],"mass_status":[0],"result_topology":{"blocks":[[101242,3857334]],"n":77}},"topology":null,"values":{"fillet_point_step":[0.00001],"mass_properties":[1570.7963806678379,5235.989024424654,5235.989024424654,-2.0599479760804667e-7,-1.8833099051491674e-7,8.500002269459227,633554.821223871,633555.0402304601,240855.47414735783,-0.004930509781645134,-0.005392948677594708,-0.03804027233582019],"result_geometry":{"blocks":[[488.14921457115923,651.8980941809427,32888.688473480026]],"n":138},"selected_edge_before":{"blocks":[[14156.70132599177,14188.020009198175,71124.74995626012]],"n":17}}}
{"case":"issue920-cylinder-rim-edge2-constant-r9.99","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,3,5,4],"mass_status":[0],"result_topology":{"blocks":[[130319,6421375]],"n":96}},"topology":null,"values":{"fillet_point_step":[0.00001],"mass_properties":[1571.1549777992927,5237.781801224955,5237.781801224955,-2.0592429249187742e-7,-1.882665288112231e-7,8.502515462564519,634068.7746551691,634068.9936617584,240963.76368978285,-0.004930509781670225,-0.0053929487658412765,-0.038040272398055484],"result_geometry":{"blocks":[[624.2477803851453,823.5034052152915,53997.852758887406]],"n":174},"selected_edge_before":{"blocks":[[14156.70132599177,14188.020009198175,71124.74995626012]],"n":17}}}
{"case":"issue920-cylinder-rim-edge2-variable-constant-law-r10.0","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,3,4,3],"mass_status":[0],"result_topology":{"blocks":[[101242,3857334]],"n":77}},"topology":null,"values":{"fillet_point_step":[0.00001],"mass_properties":[1570.7963806678383,5235.989024424656,5235.989024424656,-2.059947972606453e-7,-1.8833099001756472e-7,8.500002269459227,633554.8212238712,633555.0402304602,240855.4741473579,-0.004930509755303675,-0.005392948670318698,-0.038040272243961226],"result_geometry":{"blocks":[[488.1492145711593,651.8980941809427,32888.688473480026]],"n":138},"selected_edge_before":{"blocks":[[14156.70132599177,14188.020009198175,71124.74995626012]],"n":17}}}
{"case":"issue920-cylinder-rim-edge2-variable-constant-law-r10.0","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,3,4,3],"mass_status":[0],"result_topology":{"blocks":[[101242,3857334]],"n":77}},"topology":null,"values":{"fillet_point_step":[0.00001],"mass_properties":[1570.7963806678379,5235.989024424654,5235.989024424654,-2.0599479760804667e-7,-1.8833099051491674e-7,8.500002269459227,633554.821223871,633555.0402304601,240855.47414735783,-0.004930509781645134,-0.005392948677594708,-0.03804027233582019],"result_geometry":{"blocks":[[488.14921457115923,651.8980941809427,32888.688473480026]],"n":138},"selected_edge_before":{"blocks":[[14156.70132599177,14188.020009198175,71124.74995626012]],"n":17}}}
{"case":"issue920-cylinder-rim-edge2-variable-constant-law-r9.99","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,3,5,4],"mass_status":[0],"result_topology":{"blocks":[[130319,6421375]],"n":96}},"topology":null,"values":{"fillet_point_step":[0.00001],"mass_properties":[1571.1549777992927,5237.781801224955,5237.781801224955,-2.0592429249187742e-7,-1.882665288112231e-7,8.502515462564519,634068.7746551691,634068.9936617584,240963.76368978285,-0.004930509781670225,-0.0053929487658412765,-0.038040272398055484],"result_geometry":{"blocks":[[624.2477803851453,823.5034052152915,53997.852758887406]],"n":174},"selected_edge_before":{"blocks":[[14156.70132599177,14188.020009198175,71124.74995626012]],"n":17}}}
{"case":"issue_2020_mitred_put_back_d9_adjusted_topology","deferred":null,"error":null,"ints":{"all_corner_vertex_state":{"blocks":[[52,908]],"n":32},"before_direct_topology":{"blocks":[[78,1076]],"n":41},"corner_adjustment_flags":[0,0,0,1,0,0,2,0,0,3,0,0],"corner_calculation_flags":[0,0,0,0,1,0,0,0,2,0,0,0,3,0,0,0],"offsets_after_state":{"blocks":[[486632,29434852]],"n":108},"offsets_before_state":{"blocks":[[486632,29434852]],"n":108},"prefix_state":{"blocks":[[129520,6117777]],"n":245},"prefix_statuses":[0,0,0,0,0]},"topology":null,"values":{"actual_solver_corner_parameters":{"blocks":[[26.792846803190923,26.792846803190923,453.0330947014537]],"n":28},"all_corner_vertices":{"blocks":[[144.00000000000006,144.00000000000006,952.000000000001]],"n":20},"fillet_point_step":[0.00001],"offsets_after_distances":[-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0],"offsets_before_distances":[-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0],"selected_edge_before":{"blocks":[[56502.00000000001,56502.00000000001,1722373.9091997326]],"n":68}}}
{"case":"issue_2020_mitred_put_back_d9_corner_edges","deferred":null,"error":null,"ints":{"all_corner_vertex_state":{"blocks":[[32,612]],"n":32},"before_direct_topology":{"blocks":[[78,1076]],"n":41},"corner_adjustment_flags":[0,0,0,1,0,0,2,0,0,3,0,0],"corner_calculation_flags":[0,0,0,0,1,0,0,0,2,0,0,0,3,0,0,0],"corner_edge_geometry_state":{"blocks":[[56154,1403810]],"n":48},"generated_geometry_state":{"blocks":[[228206,15463960]],"n":132},"offsets_after_state":{"blocks":[[486632,29434852]],"n":108},"offsets_before_state":{"blocks":[[486632,29434852]],"n":108},"prefix_state":{"blocks":[[129520,6117777]],"n":245},"prefix_statuses":[0,0,0,0,0,0,0]},"topology":null,"values":{"actual_solver_corner_parameters":{"blocks":[[26.792846803190923,26.792846803190923,453.0330947014537]],"n":28},"all_corner_vertices":{"blocks":[[276.00000000000034,291.9999999999999,3768.00000000001]],"n":40},"corner_edge_geometry_samples":{"blocks":[[701.094308273315,730.1495971314017,20855.868192437465]],"n":76},"fillet_point_step":[0.00001],"generated_geometry_samples":{"blocks":[[-0.7999999999997469,1.4e+21,-7.000000000000052e+20],[61.60000000000035,2e+20,-1e+20]],"n":272},"offsets_after_distances":[-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0],"offsets_before_distances":[-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0],"selected_edge_before":{"blocks":[[56502.00000000001,56502.00000000001,1722373.9091997326]],"n":68}}}
@@ -706,9 +706,9 @@
{"case":"stubs4-degenerate-bottle-e10-r0.5","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[1001,26,40,16],"mass_status":[0],"result_topology":{"blocks":[[281457,35341216],[282271,36580789],[241977,24565779]],"n":730}},"topology":null,"values":{"fillet_point_step":[0.00001],"mass_properties":[33.11819079597675,11.066834056610833,11.066834056610833,-1.7609358703810353e-6,-3.0259935914096315e-6,2.0788625909315117,64.79258549927602,66.6733025969641,5.277123502477886,-0.00010373323274112306,-0.00006130966599405097,0.000020823413685507833],"result_geometry":{"blocks":[[912.7406198383134,996.1574227558623,139865.57295882053],[1710.5690534181747,1900.7696584750158,195545.28934387196],[536.122128285832,587.7649824357395,53296.94403726827],[1116.695548284241,1296.7760762038654,136279.5389856879],[81.3031298885769,99.10219410214427,4292.377944642321]],"n":1112},"selected_edge_before":{"blocks":[[14124.027919677248,14129.830196394449,70404.16195467954]],"n":17}}}
{"case":"stubs4-degenerate-bottle-e13-r0.5","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[1001,26,40,16],"mass_status":[0],"result_topology":{"blocks":[[281457,35341216],[282271,36580789],[241977,24565779]],"n":730}},"topology":null,"values":{"fillet_point_step":[0.00001],"mass_properties":[33.11819079597675,11.066834056610833,11.066834056610833,-1.7609358703810353e-6,-3.0259935914096315e-6,2.0788625909315117,64.79258549927602,66.6733025969641,5.277123502477886,-0.00010373323274112306,-0.00006130966599405097,0.000020823413685507833],"result_geometry":{"blocks":[[912.7406198383134,996.1574227558623,139865.57295882053],[1710.5690534181747,1900.7696584750158,195545.28934387196],[536.122128285832,587.7649824357395,53296.94403726827],[1116.695548284241,1296.7760762038654,136279.5389856879],[81.3031298885769,99.10219410214427,4292.377944642321]],"n":1112},"selected_edge_before":{"blocks":[[14127.137890774764,14132.830196425228,70408.49505041902]],"n":17}}}
{"case":"stubs4-degenerate-bottle-e2-r0.5","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[1001,26,40,16],"mass_status":[0],"result_topology":{"blocks":[[281457,35341216],[282271,36580789],[241977,24565779]],"n":730}},"topology":null,"values":{"fillet_point_step":[0.00001],"mass_properties":[33.11819079597675,11.066834056610833,11.066834056610833,-1.7609358703810353e-6,-3.0259935914096315e-6,2.0788625909315117,64.79258549927602,66.6733025969641,5.277123502477886,-0.00010373323274112306,-0.00006130966599405097,0.000020823413685507833],"result_geometry":{"blocks":[[912.7406198383134,996.1574227558623,139865.57295882053],[1710.5690534181747,1900.7696584750158,195545.28934387196],[536.122128285832,587.7649824357395,53296.94403726827],[1116.695548284241,1296.7760762038654,136279.5389856879],[81.3031298885769,99.10219410214427,4292.377944642321]],"n":1112},"selected_edge_before":{"blocks":[[14121.830195910168,14121.830195910168,70458.47793958586]],"n":17}}}
{"case":"stubs4-degenerate-brepForFillet2-e5-r0.5","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,11,17,8],"mass_status":[0],"result_topology":{"blocks":[[284319,36121624],[75249,2183309]],"n":315}},"topology":null,"values":{"fillet_point_step":[0.001],"mass_properties":[1440.2514087704533,3616.228750038445,3616.228750038445,3.457251172602004,6.96874443703026,11.462741006165695,855608.8549164346,741301.9063565227,324387.4354611304,303890.9642818426,98588.77684098834,79222.60746546565],"result_geometry":{"blocks":[[977.9593770006319,1132.1570714206728,112679.78113225622],[1036.828160581436,1132.6782038029176,128907.1646927588]],"n":494},"selected_edge_before":{"blocks":[[14104.237249363965,14104.237249363965,70989.17788508131]],"n":17}}}
{"case":"stubs4-degenerate-brepForFillet2-e7-r0.5","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,11,17,8],"mass_status":[0],"result_topology":{"blocks":[[284315,36028144],[75276,1943882]],"n":315}},"topology":null,"values":{"fillet_point_step":[0.001],"mass_properties":[1434.4468924323423,3614.9815411983363,3614.9815411983363,3.4584870631799705,6.9681018581584295,11.45680847593723,854576.7452027344,740358.9010593686,324305.3770227939,303605.7854831291,98562.20093185853,79209.84097411254],"result_geometry":{"blocks":[[914.9820844486802,1052.8699989034462,105507.89478294915],[1124.5314068643559,1268.0947705547399,136640.55870717773]],"n":494},"selected_edge_before":{"blocks":[[14135.395411755704,14135.395411755704,71369.33404886544]],"n":17}}}
{"case":"stubs4-degenerate-brepForFillet2-e9-r0.5","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,11,17,8],"mass_status":[0],"result_topology":{"blocks":[[284315,36122025],[75252,2183684]],"n":315}},"topology":null,"values":{"fillet_point_step":[0.001],"mass_properties":[1440.2673124917385,3616.232865303477,3616.232865303477,3.4572613193719812,6.968146164613963,11.462734246179187,855582.4593741557,741302.5220788062,324361.5529561333,303878.4671120746,98589.1222390095,79198.81936566049],"result_geometry":{"blocks":[[941.8468663093593,1095.7347807641254,106272.87711605163],[1191.5144945435786,1287.0778582339628,145508.12373045052]],"n":494},"selected_edge_before":{"blocks":[[14144.237249363965,14144.237249363965,71425.17788508131]],"n":17}}}
{"case":"stubs4-degenerate-brepForFillet2-e5-r0.5","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,11,17,8],"mass_status":[0],"result_topology":{"blocks":[[284319,36121624],[75249,2183309]],"n":315}},"topology":null,"values":{"fillet_point_step":[0.001],"mass_properties":[1440.2512042078263,3616.228017328615,3616.228017328615,3.4572526876318976,6.96874544266061,11.462739678939666,855608.6103133827,741301.6535002191,324387.4213297927,303890.9378205091,98588.82988258151,79222.6133688387],"result_geometry":{"blocks":[[977.961010051866,1132.158704471907,112679.82359158955],[1036.8292124050079,1132.679254497851,128907.22375615075]],"n":494},"selected_edge_before":{"blocks":[[14104.237249363965,14104.237249363965,70989.17788508131]],"n":17}}}
{"case":"stubs4-degenerate-brepForFillet2-e7-r0.5","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,11,17,8],"mass_status":[0],"result_topology":{"blocks":[[284315,36028144],[75276,1943882]],"n":315}},"topology":null,"values":{"fillet_point_step":[0.001],"mass_properties":[1434.446719545761,3614.981574290599,3614.981574290599,3.458487141241509,6.968101849333976,11.456808441115292,854576.7487812358,740358.908198703,324305.38297009957,303605.7870037983,98562.20396720385,79209.84335560785],"result_geometry":{"blocks":[[914.982144292844,1052.8700587476103,105507.89657827417],[1124.5314332536234,1268.0947969440074,136640.56046809343]],"n":494},"selected_edge_before":{"blocks":[[14135.395411755704,14135.395411755704,71369.33404886544]],"n":17}}}
{"case":"stubs4-degenerate-brepForFillet2-e9-r0.5","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,11,17,8],"mass_status":[0],"result_topology":{"blocks":[[284315,36122025],[75252,2183684]],"n":315}},"topology":null,"values":{"fillet_point_step":[0.001],"mass_properties":[1440.2673071459665,3616.232846225047,3616.232846225047,3.4572613565128156,6.9681461275206615,11.462734211720338,855582.4493567429,741302.5155547927,324361.5489719664,303878.46230592194,98589.12346911957,79198.82032237474],"result_geometry":{"blocks":[[941.846900626913,1095.734815081679,106272.87800830806],[1191.5145200824738,1287.0778837728578,145508.12565509713]],"n":494},"selected_edge_before":{"blocks":[[14144.237249363965,14144.237249363965,71425.17788508131]],"n":17}}}
{"case":"stubs4-degenerate-caps_coons-e5-r0.5","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[1001,5,6,2],"mass_status":[1007],"result_topology":{"blocks":[[114369,5170962]],"n":103}},"topology":null,"values":{"fillet_point_step":[0.00001],"mass_properties":[],"result_geometry":{"blocks":[[33.20006041930303,362.35023081673893,6668.477882377478]],"n":164},"selected_edge_before":{"blocks":[[14037.1094889089,14070.282001512544,70183.14505475634]],"n":17}}}
{"case":"stubs4-degenerate-nurb_cone-e1-r0.5","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[1001,16,24,10],"mass_status":[0],"result_topology":{"blocks":[[281552,35353171],[207130,17929604]],"n":440}},"topology":null,"values":{"fillet_point_step":[0.00001],"mass_properties":[314.8325246121948,213.7213448689033,213.7213448689033,-25.256791575512928,-20.641446371057185,-48.78070940849276,602531.7041465972,647001.3562174569,230178.1359116786,215876.09028929376,263548.4756783311,111869.03168332015],"result_geometry":{"blocks":[[-4492.250713961271,4649.51618583184,-535943.3213095476],[-4052.741456601031,4286.165134468015,-516235.4603248068],[-2714.7015550832602,2771.3320313018985,-230085.89935783745]],"n":676},"selected_edge_before":{"blocks":[[13749.737417808685,14322.262582191315,66614.77086913426]],"n":17}}}
{"case":"stubs4-degenerate-nurb_cone-e11-r0.5","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[1001,16,24,10],"mass_status":[0],"result_topology":{"blocks":[[281552,35353171],[207130,17929604]],"n":440}},"topology":null,"values":{"fillet_point_step":[0.001],"mass_properties":[314.8325246121948,213.7213448689033,213.7213448689033,-25.256791575512928,-20.641446371057185,-48.78070940849276,602531.7041465972,647001.3562174569,230178.1359116786,215876.09028929376,263548.4756783311,111869.03168332015],"result_geometry":{"blocks":[[-4492.250713961271,4649.51618583184,-535943.3213095476],[-4052.741456601031,4286.165134468015,-516235.4603248068],[-2714.7015550832602,2771.3320313018985,-230085.89935783745]],"n":676},"selected_edge_before":{"blocks":[[13738.84427117878,14353.15572882122,66458.77782534131]],"n":17}}}
@@ -716,10 +716,10 @@
{"case":"stubs4-degenerate-nurb_cone-e3-r0.5","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[1001,16,24,10],"mass_status":[0],"result_topology":{"blocks":[[281552,35353171],[207130,17929604]],"n":440}},"topology":null,"values":{"fillet_point_step":[0.001],"mass_properties":[314.8325246121948,213.7213448689033,213.7213448689033,-25.256791575512928,-20.641446371057185,-48.78070940849276,602531.7041465972,647001.3562174569,230178.1359116786,215876.09028929376,263548.4756783311,111869.03168332015],"result_geometry":{"blocks":[[-4492.250713961271,4649.51618583184,-535943.3213095476],[-4052.741456601031,4286.165134468015,-516235.4603248068],[-2714.7015550832602,2771.3320313018985,-230085.89935783745]],"n":676},"selected_edge_before":{"blocks":[[13744.700724671131,14331.299275328869,66625.11707259771]],"n":17}}}
{"case":"stubs4-degenerate-nurb_cone-e5-r0.5","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[1001,16,24,10],"mass_status":[0],"result_topology":{"blocks":[[281552,35353171],[207130,17929604]],"n":440}},"topology":null,"values":{"fillet_point_step":[0.00001],"mass_properties":[314.8325246121948,213.7213448689033,213.7213448689033,-25.256791575512928,-20.641446371057185,-48.78070940849276,602531.7041465972,647001.3562174569,230178.1359116786,215876.09028929376,263548.4756783311,111869.03168332015],"result_geometry":{"blocks":[[-4492.250713961271,4649.51618583184,-535943.3213095476],[-4052.741456601031,4286.165134468015,-516235.4603248068],[-2714.7015550832602,2771.3320313018985,-230085.89935783745]],"n":676},"selected_edge_before":{"blocks":[[13746.484192903254,14333.515807096746,66632.79245387115]],"n":17}}}
{"case":"stubs4-degenerate-nurb_cone-e8-r0.5","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[1001,16,24,10],"mass_status":[0],"result_topology":{"blocks":[[281552,35353171],[207130,17929604]],"n":440}},"topology":null,"values":{"fillet_point_step":[0.001],"mass_properties":[314.8325246121948,213.7213448689033,213.7213448689033,-25.256791575512928,-20.641446371057185,-48.78070940849276,602531.7041465972,647001.3562174569,230178.1359116786,215876.09028929376,263548.4756783311,111869.03168332015],"result_geometry":{"blocks":[[-4492.250713961271,4649.51618583184,-535943.3213095476],[-4052.741456601031,4286.165134468015,-516235.4603248068],[-2714.7015550832602,2771.3320313018985,-230085.89935783745]],"n":676},"selected_edge_before":{"blocks":[[13736.67714277342,14349.32285722658,66464.01751703939]],"n":17}}}
{"case":"stubs4-degenerate-test5-e2-r0.5","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,11,15,5],"mass_status":[1007],"result_topology":{"blocks":[[286149,29279394]],"n":224}},"topology":null,"values":{"fillet_point_step":[0.001],"mass_properties":[],"result_geometry":{"blocks":[[85.68068516522655,378.28962485244244,18376.163061145715],[88.63424007630162,173.94735361807966,4988.6552368631865]],"n":404},"selected_edge_before":{"blocks":[[14033.1235,14049.423499999999,70158.1555]],"n":17}}}
{"case":"stubs4-degenerate-test5-e3-r0.5","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,11,15,5],"mass_status":[1007],"result_topology":{"blocks":[[286141,29613358]],"n":224}},"topology":null,"values":{"fillet_point_step":[0.001],"mass_properties":[],"result_geometry":{"blocks":[[92.5919820900713,371.60164723693,21582.09004605816],[81.40325931621032,180.63533123359207,3655.954458180061]],"n":404},"selected_edge_before":{"blocks":[[14034.1235,14050.423499999999,70167.3055]],"n":17}}}
{"case":"stubs4-degenerate-test5-e5-r0.5","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,11,15,5],"mass_status":[1007],"result_topology":{"blocks":[[286142,29613638]],"n":224}},"topology":null,"values":{"fillet_point_step":[0.001],"mass_properties":[],"result_geometry":{"blocks":[[102.47681659976956,382.38943660577735,23475.24605930542],[126.08763981341828,197.33074608584062,7072.332436838247]],"n":404},"selected_edge_before":{"blocks":[[14052.4235,14052.4235,70331.6555]],"n":17}}}
{"case":"stubs4-degenerate-test5-e9-r0.5","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,11,15,5],"mass_status":[1007],"result_topology":{"blocks":[[286145,29532836]],"n":224}},"topology":null,"values":{"fillet_point_step":[0.001],"mass_properties":[],"result_geometry":{"blocks":[[136.43076299695784,400.9393465658624,31560.31867032784],[92.02989898141172,178.78083612575566,7363.807143274321]],"n":404},"selected_edge_before":{"blocks":[[14056.4235,14056.4235,70328.8055]],"n":17}}}
{"case":"stubs4-degenerate-test5-e2-r0.5","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,11,15,5],"mass_status":[1007],"result_topology":{"blocks":[[286149,29279394]],"n":224}},"topology":null,"values":{"fillet_point_step":[0.001],"mass_properties":[],"result_geometry":{"blocks":[[85.68068056302971,378.2896320462491,18376.1619712122],[88.63423681712015,173.94735705977516,4988.654837848823]],"n":404},"selected_edge_before":{"blocks":[[14033.1235,14049.423499999999,70158.1555]],"n":17}}}
{"case":"stubs4-degenerate-test5-e3-r0.5","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,11,15,5],"mass_status":[1007],"result_topology":{"blocks":[[286141,29613358]],"n":224}},"topology":null,"values":{"fillet_point_step":[0.001],"mass_properties":[],"result_geometry":{"blocks":[[92.59197973664538,371.60165077974125,21582.08948024115],[81.40325384112059,180.63533832628286,3655.954013313813]],"n":404},"selected_edge_before":{"blocks":[[14034.1235,14050.423499999999,70167.3055]],"n":17}}}
{"case":"stubs4-degenerate-test5-e5-r0.5","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,11,15,5],"mass_status":[1007],"result_topology":{"blocks":[[286142,29613638]],"n":224}},"topology":null,"values":{"fillet_point_step":[0.001],"mass_properties":[],"result_geometry":{"blocks":[[102.47681984275921,382.38943984881865,23475.246803095986],[126.08764664308549,197.3307525314586,7072.332913603795]],"n":404},"selected_edge_before":{"blocks":[[14052.4235,14052.4235,70331.6555]],"n":17}}}
{"case":"stubs4-degenerate-test5-e9-r0.5","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,11,15,5],"mass_status":[1007],"result_topology":{"blocks":[[286145,29532836]],"n":224}},"topology":null,"values":{"fillet_point_step":[0.001],"mass_properties":[],"result_geometry":{"blocks":[[136.43077066828778,400.93935387465575,31560.32044426053],[92.02990150619084,178.78083850562157,7363.80759763486]],"n":404},"selected_edge_before":{"blocks":[[14056.4235,14056.4235,70328.8055]],"n":17}}}
{"case":"stubs4-delete-edge-bottle-e16-r0.5","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[1001,26,40,16],"mass_status":[0],"result_topology":{"blocks":[[281457,35341216],[282271,36580789],[241977,24565779]],"n":730}},"topology":null,"values":{"fillet_point_step":[0.00001],"mass_properties":[33.11819079597675,11.066834056610833,11.066834056610833,-1.7609358703810353e-6,-3.0259935914096315e-6,2.0788625909315117,64.79258549927602,66.6733025969641,5.277123502477886,-0.00010373323274112306,-0.00006130966599405097,0.000020823413685507833],"result_geometry":{"blocks":[[912.7406198383134,996.1574227558623,139865.57295882053],[1710.5690534181747,1900.7696584750158,195545.28934387196],[536.122128285832,587.7649824357395,53296.94403726827],[1116.695548284241,1296.7760762038654,136279.5389856879],[81.3031298885769,99.10219410214427,4292.377944642321]],"n":1112},"selected_edge_before":{"blocks":[[14131.330290252932,14137.869747404748,70425.4044659795]],"n":17}}}
{"case":"stubs4-delete-edge-bottle-e19-r0.5","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[1001,26,40,16],"mass_status":[0],"result_topology":{"blocks":[[281457,35341216],[282271,36580789],[241977,24565779]],"n":730}},"topology":null,"values":{"fillet_point_step":[0.00001],"mass_properties":[33.11819079597675,11.066834056610833,11.066834056610833,-1.7609358703810353e-6,-3.0259935914096315e-6,2.0788625909315117,64.79258549927602,66.6733025969641,5.277123502477886,-0.00010373323274112306,-0.00006130966599405097,0.000020823413685507833],"result_geometry":{"blocks":[[912.7406198383134,996.1574227558623,139865.57295882053],[1710.5690534181747,1900.7696584750158,195545.28934387196],[536.122128285832,587.7649824357395,53296.94403726827],[1116.695548284241,1296.7760762038654,136279.5389856879],[81.3031298885769,99.10219410214427,4292.377944642321]],"n":1112},"selected_edge_before":{"blocks":[[14140.869745985467,14140.869745985467,70500.33847700804]],"n":17}}}
{"case":"stubs4-delete-edge-bottle-e27-r0.5","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[1001,26,40,16],"mass_status":[0],"result_topology":{"blocks":[[281457,35341216],[282271,36580789],[241977,24565779]],"n":730}},"topology":null,"values":{"fillet_point_step":[0.00001],"mass_properties":[33.11819079597675,11.066834056610833,11.066834056610833,-1.7609358703810353e-6,-3.0259935914096315e-6,2.0788625909315117,64.79258549927602,66.6733025969641,5.277123502477886,-0.00010373323274112306,-0.00006130966599405097,0.000020823413685507833],"result_geometry":{"blocks":[[912.7406198383134,996.1574227558623,139865.57295882053],[1710.5690534181747,1900.7696584750158,195545.28934387196],[536.122128285832,587.7649824357395,53296.94403726827],[1116.695548284241,1296.7760762038654,136279.5389856879],[81.3031298885769,99.10219410214427,4292.377944642321]],"n":1112},"selected_edge_before":{"blocks":[[14051.213183992433,14054.532782269893,70249.37439179729]],"n":17}}}
@@ -75,7 +75,7 @@
{"case":"gen0024","deferred":null,"error":null,"ints":{"status":[1001]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[1.3725441272977728,0.6862720636488864],"point":{"blocks":[[15.294333106129692,74.24695926186625,277.26166903632435]],"n":80}}}
{"case":"gen0025","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[0.07497759728747988,0.07497759728747988],"point":{"blocks":[[20.200632377815086,45.69462178879397,583.6012086259886]],"n":80}}}
{"case":"gen0026","deferred":null,"error":null,"ints":{"status":[1001]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[-351325.87341182795,351325.87341182795],"point":{"blocks":[[-116004.64807679922,1232437.5830538757,-6675641.576840369]],"n":80}}}
{"case":"gen0027","deferred":null,"error":null,"ints":{"status":[1001]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[0.27063687841080664,0.27063687841080664],"point":{"blocks":[[19.619452599694057,35.31708324180408,447.5100656516453]],"n":80}}}
{"case":"gen0027","deferred":null,"error":null,"ints":{"status":[1001]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[0.27063687841080664,0.27063687841080664],"point":{"blocks":[[19.619520279904034,35.31714247266496,447.5149876339198]],"n":80}}}
{"case":"gen0028","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[-0.9020857020487547,-0.9020857020487547],"point":{"blocks":[[19.032946418962048,40.723307502640814,475.973364731492]],"n":80}}}
{"case":"gen0029","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[0.8626803266400759,-0.8626803266400759],"point":{"blocks":[[45.73091678001725,67.7182218583911,1482.7043233607176]],"n":80}}}
{"case":"gen0030","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[0.14397316956260245,-0.14397316956260245],"point":{"blocks":[[25.5536390461404,33.613732647770306,732.1313875262564]],"n":80}}}
@@ -97,7 +97,7 @@
{"case":"gen0046","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[0.32184618316829305,0.32184618316829305],"point":{"blocks":[[19.763840541773273,39.03486785606795,551.1708791831503]],"n":80}}}
{"case":"gen0047","deferred":null,"error":null,"ints":{"status":[1001]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[0.33312092349014705,-0.33312092349014705],"point":{"blocks":[[7.040340626471737,26.13161034528739,144.9432934426138]],"n":80}}}
{"case":"gen0048","deferred":null,"error":null,"ints":{"extend":[0,0],"solver_status":[0],"status":[1006]},"topology":null,"values":{"fillet_point_step":[0.00001],"g1":[0.5,0.8626636515623405],"knots_in":[0.5,0.8626636515623405]}}
{"case":"gen0049","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[-1.5245077199873365,-1.5245077199873365],"point":{"blocks":[[-15.073318613688702,48.202897057202925,-770.0017196509854]],"n":80}}}
{"case":"gen0049","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[-1.5245077199873365,-1.5245077199873365],"point":{"blocks":[[-14.301965863707672,48.973090396590756,-788.1026734875113]],"n":80}}}
{"case":"gen0050","deferred":null,"error":null,"ints":{"status":[1001]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[-0.7310627321796721,0.36553136608983605],"point":{"blocks":[[30.468690740854942,56.973252644511575,855.5218606973722]],"n":80}}}
{"case":"gen0051","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[0.19181242535671425,0.19181242535671425],"point":{"blocks":[[167138.68739382955,167166.5633069897,1337537.0199421444]],"n":80}}}
{"case":"gen0052","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[0.00008705041260730905,-0.00008705041260730905],"point":{"blocks":[[373.3856616499212,1329.6637292290825,4075.836177353789]],"n":80}}}
@@ -185,7 +185,7 @@
{"case":"gen0134","deferred":null,"error":null,"ints":{"status":[1001]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[-0.15418256711648276,-0.07709128355824138],"point":{"blocks":[[-0.26881742962136523,29.37482073855683,-131.35008253638503]],"n":80}}}
{"case":"gen0135","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[0.9760961831605279,-0.9760961831605279],"point":{"blocks":[[21.025297812363757,101.96423972501205,407.6903925943848]],"n":80}}}
{"case":"gen0136","deferred":null,"error":null,"ints":{"extend":[1,0],"solver_status":[0],"status":[1001]},"topology":null,"values":{"fillet_point_step":[0.00001],"g1":[0.3021049424762976,0.31372290219633897,0.5],"knots_in":[0.3021049424762976,0.31372290219633897,0.5]}}
{"case":"gen0137","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[-1.0576790822895008,-1.0576790822895008],"point":{"blocks":[[9.335519627322816,46.872479150055376,169.28060844591442]],"n":80}}}
{"case":"gen0137","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[-1.0576790822895008,-1.0576790822895008],"point":{"blocks":[[13.566495429311722,46.98956097137515,253.36975225263933]],"n":80}}}
{"case":"gen0138","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[-0.6683590933820529,0.6683590933820529],"point":{"blocks":[[19.01514505736307,42.42894026927875,341.8749123784208]],"n":80}}}
{"case":"gen0139","deferred":null,"error":null,"ints":{"status":[1001]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[0.0,0.0],"point":{"blocks":[[8.569487284056807,25.3198495572189,4.416521455062805]],"n":80}}}
{"case":"gen0140","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[-0.4624016120819063,-0.4624016120819063],"point":{"blocks":[[17.173329837472025,41.53570566654,441.65602257301765]],"n":80}}}
@@ -211,7 +211,7 @@
{"case":"gen0160","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[0.5496454922726274,0.5496454922726274],"point":{"blocks":[[16.564768931704776,37.759077703189824,298.1480286446076]],"n":80}}}
{"case":"gen0161","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[287214.5259962959,287214.5259962959],"point":{"blocks":[[3816288.9089792436,10671278.914155519,38669663.12654833]],"n":80}}}
{"case":"gen0162","deferred":null,"error":null,"ints":{"status":[1001]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[0.5747792377896995,-0.28738961889484976],"point":{"blocks":[[58.91781798645779,82.23215696733745,1759.4838832143903]],"n":80}}}
{"case":"gen0163","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[0.22976384063762864,-0.22976384063762864],"point":{"blocks":[[10.720789707704943,55.541616265798496,62.68940729085022]],"n":80}}}
{"case":"gen0163","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[0.22976384063762864,-0.22976384063762864],"point":{"blocks":[[9.434962951306456,55.19956767728689,-52.78807811852818]],"n":80}}}
{"case":"gen0164","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[0.2642518931171112,-0.1321259465585556],"point":{"blocks":[[4.149969516948741,26.208704086903612,-16.358239053020796]],"n":80}}}
{"case":"gen0165","deferred":null,"error":null,"ints":{"status":[1001]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[4.737045353992686e-7,2.368522676996343e-7],"point":{"blocks":[[-24857875498.02827,24859818313.83341,-223721042039.0927]],"n":80}}}
{"case":"gen0166","deferred":null,"error":null,"ints":{"status":[1001]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[2.702330611485978e-7,-2.702330611485978e-7],"point":{"blocks":[[89463506600.02216,89463984496.77466,805170923729.6243]],"n":80}}}
@@ -219,7 +219,7 @@
{"case":"gen0168","deferred":null,"error":null,"ints":{"status":[1001]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[0.06236256656271657,0.06236256656271657],"point":{"blocks":[[9.197902206312381,36.806480941533124,24.63894952216701]],"n":80}}}
{"case":"gen0169","deferred":null,"error":null,"ints":{"status":[1001]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[76.04306912264374,-76.04306912264374],"point":{"blocks":[[4425.043245579697,7622.315960265144,150273.22079801888]],"n":80}}}
{"case":"gen0170","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[0.39756283642594914,-0.39756283642594914],"point":{"blocks":[[7.381401640225262,45.80037607571984,-51.355411134648094]],"n":80}}}
{"case":"gen0171","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[0.0,0.0],"point":{"blocks":[[13.182162664588459,39.48730185470428,338.8025711586965]],"n":80}}}
{"case":"gen0171","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[0.0,0.0],"point":{"blocks":[[10.99999113096873,39.22679472432958,179.70172715799077]],"n":80}}}
{"case":"gen0172","deferred":null,"error":null,"ints":{"status":[1001]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[1.6198336950826169,1.6198336950826169],"point":{"blocks":[[26.676211927653018,164.40394865910753,1700.676462754016]],"n":80}}}
{"case":"gen0173","deferred":null,"error":null,"ints":{"status":[1001]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[0.5376285763084768,0.5376285763084768],"point":{"blocks":[[25024.96152479502,25041.65633388397,301599.58464651764]],"n":80}}}
{"case":"gen0174","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[412.1007407754986,-206.0503703877493],"point":{"blocks":[[9594.848434170177,21991.54292662045,376251.5556356339]],"n":80}}}
@@ -269,7 +269,7 @@
{"case":"gen0218","deferred":null,"error":null,"ints":{"status":[1001]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[0.292234297866413,-0.292234297866413],"point":{"blocks":[[-7.816330082841141,77.69700078693428,-348.2666303314022]],"n":80}}}
{"case":"gen0219","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[-1.1523844362351285,1.1523844362351285],"point":{"blocks":[[20.44696232695649,35.15812098497622,542.6715434809263]],"n":80}}}
{"case":"gen0220","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[251641.27250837215,125820.63625418607],"point":{"blocks":[[9928383.970224954,29913211.533604838,70400437.53197363]],"n":80}}}
{"case":"gen0221","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[-0.0,-0.0],"point":{"blocks":[[1.6290541023966307,27.93836802816698,-182.4340526090423]],"n":80}}}
{"case":"gen0221","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[-0.0,-0.0],"point":{"blocks":[[3.775737832024795,28.024394813310053,-29.33682967692149]],"n":80}}}
{"case":"gen0222","deferred":null,"error":null,"ints":{"status":[1001]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[1.4198678936498121,-1.4198678936498121],"point":{"blocks":[[-42.9066648626919,142.76489644611658,-1120.2077599750735]],"n":80}}}
{"case":"gen0223","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[-1.1713949832846184,-1.1713949832846184],"point":{"blocks":[[-51.5928380467218,135.56818813521377,-2456.689711846445]],"n":80}}}
{"case":"gen0224","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[-55.74678722526025,-55.74678722526025],"point":{"blocks":[[-3033.32130164265,6222.781383579647,-114675.7577482022]],"n":80}}}
@@ -397,7 +397,7 @@
{"case":"gen0346","deferred":null,"error":null,"ints":{"status":[1001]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[0.27845437433400994,0.27845437433400994],"point":{"blocks":[[31.77212522902905,38.411249671779615,657.6375855996513]],"n":80}}}
{"case":"gen0347","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[0.0,0.0],"point":{"blocks":[[14.42643481624605,38.898545652461245,419.41384411033823]],"n":80}}}
{"case":"gen0348","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[0.024946036492406145,-0.024946036492406145],"point":{"blocks":[[12.269876550986305,28.630828865536774,93.46571111584737]],"n":80}}}
{"case":"gen0349","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[-0.8084930105557193,0.8084930105557193],"point":{"blocks":[[5.155159530096939,39.89145114600119,-205.5706761366323]],"n":80}}}
{"case":"gen0349","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[-0.8084930105557193,0.8084930105557193],"point":{"blocks":[[2.381289125567308,39.99897684511395,-279.3346691456838]],"n":80}}}
{"case":"gen0350","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[-0.2897156024848271,-0.2897156024848271],"point":{"blocks":[[13.993282952100863,36.242777939961584,208.59173608238274]],"n":80}}}
{"case":"gen0351","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[1.0141310825330543,-0.5070655412665271],"point":{"blocks":[[-25.16359507230496,55.04546714619831,-1290.7954915809735]],"n":80}}}
{"case":"gen0352","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[-24149.909384874732,24149.909384874732],"point":{"blocks":[[10403206.057448285,20812931.965070143,445181663.4602736]],"n":80}}}
@@ -425,7 +425,7 @@
{"case":"gen0374","deferred":null,"error":null,"ints":{"status":[1001]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[-146.52461555673372,-146.52461555673372],"point":{"blocks":[[-922.2370076909385,10139.270345109735,-48637.117917711366]],"n":80}}}
{"case":"gen0375","deferred":null,"error":null,"ints":{"extend":[0,0],"solver_status":[0],"status":[1001]},"topology":null,"values":{"fillet_point_step":[0.00001],"g1":[0.059485057875261475],"knots_in":[0.059485057875261475]}}
{"case":"gen0376","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[-0.0,-0.0],"point":{"blocks":[[4270251.596985117,7626411.224863285,132446896.57202563]],"n":80}}}
{"case":"gen0377","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[-498.51383362867233,498.51383362867233],"point":{"blocks":[[-6592.907111109868,13883.698749003837,-141796.8511512976]],"n":80}}}
{"case":"gen0377","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[-498.51383362867233,498.51383362867233],"point":{"blocks":[[-6591.732743439909,13882.56835003608,-141749.00948763467]],"n":80}}}
{"case":"gen0378","deferred":null,"error":null,"ints":{"status":[1001]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[78.67183682576177,78.67183682576177],"point":{"blocks":[[-1744.8622968961572,1764.273044129071,-122520.53088124155]],"n":80}}}
{"case":"gen0379","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[-0.0,0.0],"point":{"blocks":[[-34916167718.30228,34916167763.03917,-279329341603.0519]],"n":80}}}
{"case":"gen0380","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[0.7541805448141334,0.7541805448141334],"point":{"blocks":[[21.84350428542847,47.43949785555593,404.9848034985693]],"n":80}}}
@@ -580,7 +580,7 @@
{"case":"gen0529","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[1.1508522372415169,-1.1508522372415169],"point":{"blocks":[[1.8325186878033715,56.29865648181925,-340.3186580489356]],"n":80}}}
{"case":"gen0530","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[0.49054485491942723,-0.49054485491942723],"point":{"blocks":[[7.236692566166987,54.34778536135473,-141.78096024015886]],"n":80}}}
{"case":"gen0531","deferred":null,"error":null,"ints":{"extend":[1,0],"solver_status":[0],"status":[1006]},"topology":null,"values":{"fillet_point_step":[0.00001],"g1":[0.6278786548543553,0.31650252466469564,0.5],"knots_in":[0.6278786548543553,0.31650252466469564,0.5]}}
{"case":"gen0532","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[0.00018572213244843157,-0.00018572213244843157],"point":{"blocks":[[-1120.667466564267,3006.97625017855,-11024.48464648686]],"n":80}}}
{"case":"gen0532","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[0.00018572213244843157,-0.00018572213244843157],"point":{"blocks":[[-6100.370291092273,7799.90204176117,-55775.02201245272]],"n":80}}}
{"case":"gen0533","deferred":null,"error":null,"ints":{"status":[1001]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[0.0,-0.0],"point":{"blocks":[[5.606656790097681,107.03761455793614,-340.0649040651823]],"n":80}}}
{"case":"gen0534","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[1.5790767629736289,1.5790767629736289],"point":{"blocks":[[2.418010021071451,44.65034770788736,-292.965942919616]],"n":80}}}
{"case":"gen0535","deferred":null,"error":null,"ints":{"extend":[0,0],"solver_status":[0],"status":[1001]},"topology":null,"values":{"fillet_point_step":[0.00001],"g1":[0.9234478012231815],"knots_in":[0.9234478012231815]}}
@@ -628,7 +628,7 @@
{"case":"gen0577","deferred":null,"error":null,"ints":{"status":[1001]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[1.4696712182972984,1.4696712182972984],"point":{"blocks":[[59.1250603695906,76.60130498539112,2085.92718898999]],"n":80}}}
{"case":"gen0578","deferred":null,"error":null,"ints":{"status":[1001]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[0.590642470336763,0.590642470336763],"point":{"blocks":[[-21.46094174519216,71.6406986203895,-770.8928341376198]],"n":80}}}
{"case":"gen0579","deferred":null,"error":null,"ints":{"status":[1001]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[-0.042121849952598524,0.042121849952598524],"point":{"blocks":[[15.443716508467887,71.02447900078211,212.31784072107814]],"n":80}}}
{"case":"gen0580","deferred":null,"error":null,"ints":{"status":[1001]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[-0.0,-0.0],"point":{"blocks":[[5502761.207644772,10005453.890265154,89687681.6219422]],"n":80}}}
{"case":"gen0580","deferred":null,"error":null,"ints":{"status":[1001]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[-0.0,-0.0],"point":{"blocks":[[5502762.817410249,10005453.970621428,89687798.79781598]],"n":80}}}
{"case":"gen0581","deferred":null,"error":null,"ints":{"status":[1001]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[0.00044238756102644956,-0.00022119378051322478],"point":{"blocks":[[683.8457615253996,690.7548713571667,7601.961915957611]],"n":80}}}
{"case":"gen0582","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[-308021.79493079573,-308021.79493079573],"point":{"blocks":[[8356020.378702474,12825252.847665178,289398215.1605343]],"n":80}}}
{"case":"gen0583","deferred":null,"error":null,"ints":{"status":[1001]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[1.319349557027487e-7,6.596747785137436e-8],"point":{"blocks":[[-719890.7600532864,719916.447547392,-7130325.34823085]],"n":80}}}
@@ -642,7 +642,7 @@
{"case":"gen0591","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[0.8033147828536416,0.8033147828536416],"point":{"blocks":[[20.885335582722558,58.32648437443985,708.3418753107223]],"n":80}}}
{"case":"gen0592","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[-1.1624619995951506,-0.5812309997975753],"point":{"blocks":[[-11.35263106406933,74.76900844910355,-1083.98000945865]],"n":80}}}
{"case":"gen0593","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[397.32274097089766,-397.32274097089766],"point":{"blocks":[[-3419.3921225439144,8523.359196756446,-166828.8704449418]],"n":80}}}
{"case":"gen0594","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[2.0804487010843495e-7,-2.0804487010843495e-7],"point":{"blocks":[[630551.4862896164,630560.5280039047,6648048.921130304]],"n":80}}}
{"case":"gen0594","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[2.0804487010843495e-7,-2.0804487010843495e-7],"point":{"blocks":[[520571.5702657828,520580.4131364739,5108767.744788313]],"n":80}}}
{"case":"gen0595","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[-0.9428581744297906,0.9428581744297906],"point":{"blocks":[[22.87616290722158,38.14000613960552,596.9756363945345]],"n":80}}}
{"case":"gen0596","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[-329.5127908634342,329.5127908634342],"point":{"blocks":[[-82.45538469442229,10097.70561180192,-54109.53663840378]],"n":80}}}
{"case":"gen0597","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[1.5520477637791908e-7,1.5520477637791908e-7],"point":{"blocks":[[53.13636678092718,55.91301238753362,543.9950381533218]],"n":80}}}
-5
View File
@@ -1760,10 +1760,6 @@ fn enable_fix(k: &mut Kernel, name: &str) -> Result<(), String> {
"cover_refuses_folded_continuation" => &mut k.fixes.cover_refuses_folded_continuation,
"cover_wraps_across_closed_seam" => &mut k.fixes.cover_wraps_across_closed_seam,
"fillet_refuse_non_manifold_result" => &mut k.fixes.fillet_refuse_non_manifold_result,
"fillet_torus_rail_on_axis" => &mut k.fixes.fillet_torus_rail_on_axis,
"fillet_trace_from_tangent_point_toward_next" => {
&mut k.fixes.fillet_trace_from_tangent_point_toward_next
}
"analytic_copy_keeps_parameterization" => &mut k.fixes.analytic_copy_keeps_parameterization,
"precise_properties_body_length_scale" => &mut k.fixes.precise_properties_body_length_scale,
"boolean_wire_edge_keeps_faceuse_shell" => {
@@ -1776,7 +1772,6 @@ fn enable_fix(k: &mut Kernel, name: &str) -> Result<(), String> {
"stitch_solid_nests_cavities" => &mut k.fixes.stitch_solid_nests_cavities,
"ssi_low_angle_start_on_probation" => &mut k.fixes.ssi_low_angle_start_on_probation,
"curve_tight_box_interval_knots" => &mut k.fixes.curve_tight_box_interval_knots,
"fillet_tangent_point_one_sided_step" => &mut k.fixes.fillet_tangent_point_one_sided_step,
"fillet_rails_meet_at_one_end_point" => &mut k.fixes.fillet_rails_meet_at_one_end_point,
"fillet_nx1_tangent_side_edge" => &mut k.fixes.fillet_nx1_tangent_side_edge,
"fillet_nx1_tangent_corner_edge" => &mut k.fixes.fillet_nx1_tangent_corner_edge,
+4 -2
View File
@@ -15,7 +15,9 @@ FLOATS = ["sqrt", "cbrt", "hypot", "sin", "cos", "tan", "sin_cos", "asin", "acos
"ln", "ln_1p", "log", "log2", "log10", "mul_add", "recip", "to_radians", "to_degrees"]
# Per type: (file, type, path) and the methods of that type kept. A name kept on one type opens
# nothing on another. Kept: constructors from given values, field reads and writes, and the
# kernel's certified queries on a transform (`is_regular`, `row_lengths`, `of_placement`), which ops calls rather than computes.
# kernel's certified queries on a transform (`is_regular`, `row_lengths`, `of_placement`), and the
# kernel's maps built from an application's numbers and their composition (`rotation`,
# `uniform_scaling`, `reflection`, `concat`), which ops calls rather than computes.
TYPES = [
("geom/vector3d.rs", "Vec3", "solidcore::Vec3",
{"new", "from_array", "to_array", "set_uninitialized", "is_initialized", "is_undef",
@@ -25,7 +27,7 @@ TYPES = [
"component", "component_mut"}),
("math.rs", "Transform", "solidcore::Transform",
{"translation", "x_axis", "y_axis", "z_axis", "translation_part", "is_regular", "row_lengths",
"of_placement"}),
"of_placement", "rotation", "uniform_scaling", "reflection", "concat"}),
("geom/transform.rs", "Transform", "solidcore::geom::transform::Transform",
{"new", "matrix", "type_code", "type_string", "class_string", "is_kind_of", "read_from_db"}),
("geom/matrix.rs", "Matrix", "solidcore::geom::matrix::Matrix",
-1
View File
@@ -127,7 +127,6 @@ disallowed-methods = [
{ path = "solidcore::Vec2::max_dimension", reason = "ops computes no geometry (docs/layers.md §4)" },
{ path = "solidcore::Vec2::rotate", reason = "ops computes no geometry (docs/layers.md §4)" },
{ path = "solidcore::Vec2::unitize", reason = "ops computes no geometry (docs/layers.md §4)" },
{ path = "solidcore::Transform::concat", reason = "ops computes no geometry (docs/layers.md §4)" },
{ path = "solidcore::Transform::from_axes", reason = "ops computes no geometry (docs/layers.md §4)" },
{ path = "solidcore::Transform::transform_point", reason = "ops computes no geometry (docs/layers.md §4)" },
{ path = "solidcore::Transform::transform_vector", reason = "ops computes no geometry (docs/layers.md §4)" },
+184
View File
@@ -0,0 +1,184 @@
//! Affine maps from an application's numbers, as the matrices [`crate::Body::transform`] and
//! [`crate::curve::transform`] take: rows 0-2 the images of x, y and z, row 3 the translation
//! (`p' = p·A + t`), the fourth column `(0, 0, 0, 1)`. A move, a turn about a line, a scale
//! about a point, a mirror in a plane, and one map after another. The maps are the kernel's
//! ([`solidcore::Transform::translation`], [`solidcore::Transform::rotation`],
//! [`solidcore::Transform::uniform_scaling`], [`solidcore::Transform::reflection`],
//! [`solidcore::Transform::concat`]); ops checks the numbers and passes them on.
use solidcore::{Transform, Vec3};
use crate::{Error, Result};
/// An affine map in the row-vector convention of [`crate::Body::transform`].
pub type Matrix = [[f64; 4]; 4];
/// Whether every number of `v` is finite.
fn finite(v: &[f64]) -> bool {
v.iter().all(|x| x.is_finite())
}
/// `v` as the kernel's vector.
fn vec3([x, y, z]: [f64; 3]) -> Vec3 {
Vec3::new(x, y, z)
}
/// The kernel's map, or [`Error::Argument`] for the numbers it refused.
fn taken(map: solidcore::Result<Transform>) -> Result<Matrix> {
map.map(|t| t.m).map_err(|_| Error::Argument)
}
/// The move by `by`.
///
/// # Errors
/// [`Error::Argument`] for a number not finite.
pub fn translation(by: [f64; 3]) -> Result<Matrix> {
if !finite(&by) {
return Err(Error::Argument);
}
Ok(Transform::translation(vec3(by)).m)
}
/// The turn by `angle` radians, right-handed about `axis`, about the line through `point`
/// along `axis` (any length but none).
///
/// # Errors
/// [`Error::Argument`] for a point, axis or angle not finite, or an axis of no length.
pub fn rotation(point: [f64; 3], axis: [f64; 3], angle: f64) -> Result<Matrix> {
taken(Transform::rotation(vec3(point), vec3(axis), angle))
}
/// The scale by `factor` in every direction about `centre`, which it keeps.
///
/// # Errors
/// [`Error::Argument`] for a centre not finite, or a factor not finite and positive (a mirror
/// is [`reflection`]).
pub fn scaling(centre: [f64; 3], factor: f64) -> Result<Matrix> {
if factor.is_nan() || factor <= 0.0 {
return Err(Error::Argument);
}
taken(Transform::uniform_scaling(vec3(centre), factor))
}
/// The mirror in the plane through `origin` square to `normal` (any length but none).
///
/// # Errors
/// [`Error::Argument`] for an origin or normal not finite, or a normal of no length.
pub fn reflection(origin: [f64; 3], normal: [f64; 3]) -> Result<Matrix> {
taken(Transform::reflection(vec3(origin), vec3(normal)))
}
/// `first`, then `second` (`p·A1 + t1`, then that `·A2 + t2`): steps stacked into one map.
///
/// # Errors
/// [`Error::Argument`] for a map with a number not finite, given or made.
pub fn then(first: &Matrix, second: &Matrix) -> Result<Matrix> {
if !finite(first.as_flattened()) || !finite(second.as_flattened()) {
return Err(Error::Argument);
}
let both = Transform { m: *first }.concat(&Transform { m: *second }).m;
if !finite(both.as_flattened()) {
return Err(Error::Argument);
}
Ok(both)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::body::REGULAR_RATIO;
use solidcore::geom::certified::Verdict;
/// The mirror in the plane y = 0.
const MIRROR_Y: Matrix = [
[1.0, 0.0, 0.0, 0.0],
[0.0, -1.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
[0.0, 0.0, 0.0, 1.0],
];
const IDENTITY: Matrix = [
[1.0, 0.0, 0.0, 0.0],
[0.0, 1.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
[0.0, 0.0, 0.0, 1.0],
];
/// `p` under `m`, in the row-vector convention.
fn apply(m: &Matrix, p: [f64; 3]) -> [f64; 3] {
[0, 1, 2].map(|j| p[0] * m[0][j] + p[1] * m[1][j] + p[2] * m[2][j] + m[3][j])
}
fn near(a: [f64; 3], b: [f64; 3], tol: f64) -> bool {
a.iter().zip(b).all(|(x, y)| (x - y).abs() <= tol)
}
fn regular(m: &Matrix) -> bool {
Transform { m: *m }.is_regular(REGULAR_RATIO) == Verdict::Yes
}
#[test]
fn a_mirror_in_y_0_is_the_mirror_matrix_and_twice_is_the_identity() {
assert_eq!(reflection([0.0; 3], [0.0, 1.0, 0.0]).unwrap(), MIRROR_Y);
// any length of an axis normal, any origin on its plane's side: exact
for (origin, normal) in [
([3.0, 5.0, -2.0], [0.0, 4.0, 0.0]),
([1.0, 2.0, 7.5], [0.0, 0.0, -1.0]),
([-6.0, 0.0, 1.0], [2.0, 0.0, 0.0]),
] {
let m = reflection(origin, normal).unwrap();
assert!(regular(&m));
assert_eq!(then(&m, &m).unwrap(), IDENTITY);
}
}
#[test]
fn a_quarter_turn_about_z_takes_x_to_y_and_a_turn_keeps_its_line() {
let q = std::f64::consts::FRAC_PI_2;
let m = rotation([0.0; 3], [0.0, 0.0, 1.0], q).unwrap();
assert!(regular(&m));
assert!(near(apply(&m, [1.0, 0.0, 0.0]), [0.0, 1.0, 0.0], 1e-15));
assert!(near(apply(&m, [0.0, 1.0, 0.0]), [-1.0, 0.0, 0.0], 1e-15));
// about the line through (10, 0, 0) along a slanted axis: the line's points stay
let m = rotation([10.0, 0.0, 0.0], [1.0, 2.0, 2.0], 0.7).unwrap();
assert!(regular(&m));
assert!(near(apply(&m, [10.0, 0.0, 0.0]), [10.0, 0.0, 0.0], 1e-13));
assert!(near(apply(&m, [13.0, 6.0, 6.0]), [13.0, 6.0, 6.0], 1e-12));
}
#[test]
fn a_scale_keeps_its_centre_and_moves_stack() {
let c = [1.0, -2.0, 3.0];
let m = scaling(c, 2.5).unwrap();
assert!(regular(&m));
assert!(near(apply(&m, c), c, 1e-14));
assert!(near(apply(&m, [2.0, -2.0, 3.0]), [3.5, -2.0, 3.0], 1e-14));
let (a, b) = ([1.5, -2.0, 3.0], [0.25, 4.0, -1.0]);
let sum = [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
let stacked = then(&translation(a).unwrap(), &translation(b).unwrap()).unwrap();
assert_eq!(stacked, translation(sum).unwrap());
assert!(regular(&stacked));
}
#[test]
fn numbers_not_finite_or_of_no_size_are_refused() {
assert_eq!(translation([f64::NAN, 0.0, 0.0]), Err(Error::Argument));
assert_eq!(rotation([0.0; 3], [0.0; 3], 1.0), Err(Error::Argument));
assert_eq!(
rotation([0.0; 3], [0.0, 0.0, 1.0], f64::INFINITY),
Err(Error::Argument)
);
assert_eq!(scaling([0.0; 3], 0.0), Err(Error::Argument));
assert_eq!(scaling([0.0; 3], -1.0), Err(Error::Argument));
assert_eq!(scaling([0.0; 3], f64::NAN), Err(Error::Argument));
assert_eq!(scaling([f64::NAN, 0.0, 0.0], 2.0), Err(Error::Argument));
assert_eq!(reflection([0.0; 3], [0.0; 3]), Err(Error::Argument));
assert_eq!(
reflection([0.0, f64::NAN, 0.0], [0.0, 1.0, 0.0]),
Err(Error::Argument)
);
// a stack whose numbers overflow
let far = translation([1e308, 0.0, 0.0]).unwrap();
assert_eq!(then(&far, &far), Err(Error::Argument));
}
}
+15 -6
View File
@@ -278,8 +278,9 @@ pub enum Error {
}
/// Why the kernel refuses an offset or a draft ([`Error::OffsetRefused`]), as its
/// `OffsetFailure` names it.
/// `OffsetFailure` names it. More reasons get names of their own, so match it with a wildcard.
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub enum OffsetRefusal {
/// The face's offset by `distance` is not proved regular, and no wall size would help:
/// the face bends towards that side nowhere the kernel's sizing looked, so no radius of
@@ -336,10 +337,18 @@ pub enum OffsetRefusal {
/// What the built body fails.
what: solidcore::error::offset::Unsoundness,
},
/// A reason this version of ops does not name yet: the kernel's own statement of it.
Other {
/// The kernel's statement of its reason.
message: String,
/// A failure the kernel states only in words so far: a routine's own failure (its phrase
/// causes `Failed`, `Missing`, `InvalidValue` and `Inconsistent`), or a reason this version
/// of ops does not name yet. Transitional: each gets a variant of its own here once the
/// kernel gives it a cause of its own.
Phrase {
/// The kernel's class of the failure.
class: solidcore::error::ErrorKind,
/// True when the failure is the kernel's fault, not a fact of the input (a consistency
/// check of its own that did not hold): a bug to report, not a refusal.
defect: bool,
/// The kernel's statement of it.
phrase: String,
},
}
@@ -389,7 +398,7 @@ impl std::fmt::Display for OffsetRefusal {
)
}
OffsetRefusal::Unsound { what } => write!(f, "the body built is unsound ({what:?})"),
OffsetRefusal::Other { message } => write!(f, "{message}"),
OffsetRefusal::Phrase { phrase, .. } => f.write_str(phrase),
}
}
}
+2
View File
@@ -18,6 +18,7 @@
//! the kernel's entities;
//! - `finish`: the result contract, from what the input was (crate-internal: the families use it);
//! - [`provenance`]: the hook an application's construction history rides on.
//! - [`affine`]: the maps a body or a curve is moved by, from an application's numbers.
//!
//! The families: [`measure`] (mass properties, area, box, point class, gap samples, the picks'
//! distances and normals), [`contact`] (whether bodies meet, their boundary distance),
@@ -29,6 +30,7 @@
#![deny(clippy::disallowed_methods)]
#![cfg_attr(not(test), deny(clippy::float_arithmetic))]
pub mod affine;
pub mod blend;
pub mod body;
pub mod boolean;
+61 -46
View File
@@ -39,49 +39,59 @@ use crate::{Error, OffsetRefusal, Result};
/// The largest offset distance or wall the operations take.
const MAX_DISTANCE: f64 = 1e6;
/// An offset's kernel error as the operation's: walls that touch ([`Error::WallsTouch`]), a wall
/// whose face's offset folds ([`Error::WallFolds`], with the largest wall the kernel names), a
/// vertex moved past an edge's other end ([`Error::Unsupported`]), any other offset refusal
/// the kernel gives a reason for as [`Error::OffsetRefused`], its face through `index` (#14783),
/// and the rest as [`crate::error::status`].
/// What [`Error::Unsupported`] says for an offset that moves a vertex past an edge's other end.
const EDGE_VANISHES: &str = "a vertex moved past the other end of an edge";
/// An offset's kernel error as the operation's: a refusal the kernel gives a reason for by that
/// reason ([`offset_refused`]); one without by its kind ([`by_kind`]), else as
/// [`crate::error::status`].
fn status(e: SolidError, index: &dyn Fn(FaceId) -> Option<usize>) -> Error {
match e.kind() {
ErrorKind::OffsetWallsTouch => Error::WallsTouch,
// (the kernel's fold kind also covers an offset not certified regular, which is no
// fold: its own reason, #14783)
ErrorKind::OffsetFolds => match e.failure().map(|f| &f.reason) {
Some(Reason::Offset(OffsetFailure::Folds { largest, .. })) => Error::WallFolds {
largest: Some(*largest),
},
Some(Reason::Offset(o @ OffsetFailure::NotCertified { .. })) => {
offset_refused(*o, index)
}
_ => Error::WallFolds { largest: None },
},
ErrorKind::OffsetEdgeVanishes => {
Error::Unsupported("a vertex moved past the other end of an edge")
}
_ => {
let offset = match e.failure().map(|f| &f.reason) {
Some(Reason::Offset(o)) => Some(*o),
_ => None,
};
match offset {
Some(o) => offset_refused(o, index),
None => kernel_status(e),
}
}
if let Some(Reason::Offset(o)) = e.failure().map(|f| &f.reason) {
return offset_refused(*o, e.entity(), index);
}
by_kind(e.kind()).unwrap_or_else(|| kernel_status(e))
}
/// The operation's error for an offset refusal of `kind` that has one of its own: walls that
/// touch ([`Error::WallsTouch`]), a wall that folds ([`Error::WallFolds`], no largest wall
/// named), a vertex moved past an edge's other end ([`Error::Unsupported`]).
fn by_kind(kind: ErrorKind) -> Option<Error> {
match kind {
ErrorKind::OffsetWallsTouch => Some(Error::WallsTouch),
ErrorKind::OffsetFolds => Some(Error::WallFolds { largest: None }),
ErrorKind::OffsetEdgeVanishes => Some(Error::Unsupported(EDGE_VANISHES)),
_ => None,
}
}
/// The kernel's offset refusal `o` as [`Error::OffsetRefused`]: why, and the face it names
/// through `index` (`None` where it names none, or one `index` does not find).
fn offset_refused(o: OffsetFailure, index: &dyn Fn(FaceId) -> Option<usize>) -> Error {
let face = match o.entity() {
/// The kernel's offset refusal `o` as the operation's error: walls that touch
/// ([`Error::WallsTouch`]), a wall whose face's offset folds ([`Error::WallFolds`], with the
/// largest wall the kernel names), a vertex moved past an edge's other end
/// ([`Error::Unsupported`]), and every other reason as [`Error::OffsetRefused`] (#14783): why,
/// and the face the failure is about (`entity`, the reason's own unless the kernel set another)
/// through `index` (`None` where it names none, or one `index` does not find). A reason this
/// version does not name yet takes the error of its class where that has one ([`by_kind`]), as
/// it did before it had a reason; it and a phrase are otherwise said by their class and in the
/// kernel's words ([`unnamed`], #15035).
fn offset_refused(
o: OffsetFailure,
entity: Option<EntityRef>,
index: &dyn Fn(FaceId) -> Option<usize>,
) -> Error {
let face = match entity {
Some(EntityRef::Face(f)) => index(f),
_ => None,
};
let why = match o {
OffsetFailure::Folds { largest, .. } => {
return Error::WallFolds {
largest: Some(largest),
}
}
OffsetFailure::WallsTouch { .. } => return Error::WallsTouch,
OffsetFailure::EdgeVanishes { .. } => return Error::Unsupported(EDGE_VANISHES),
// (the kernel's fold kind also covers an offset not certified regular, which is no
// fold: its own reason, #14783)
OffsetFailure::NotCertified { distance, .. } => OffsetRefusal::NotCertified { distance },
OffsetFailure::Collapses {
distance, radius, ..
@@ -103,19 +113,23 @@ fn offset_refused(o: OffsetFailure, index: &dyn Fn(FaceId) -> Option<usize>) ->
OffsetFailure::ReachUncertified { .. } => OffsetRefusal::ReachUncertified,
OffsetFailure::LoopGapUncertified { .. } => OffsetRefusal::LoopGapUncertified,
OffsetFailure::Unsound { what } => OffsetRefusal::Unsound { what },
// a reason this version does not name (one the kernel adds later; `Folds`, `WallsTouch`
// and `EdgeVanishes` come with their own kinds, taken above): still said, never a bare
// status
other => unnamed(&other),
// the kernel's phrase causes (`OffsetFailure::is_phrase`), and a reason the kernel adds
// later: still classed and said, never a bare status
other => match by_kind(other.kind()) {
Some(e) => return e,
None => unnamed(&other),
},
};
Error::OffsetRefused { face, why }
}
/// A kernel offset refusal ops does not name yet, as [`OffsetRefusal::Other`]: the kernel's
/// own statement of it.
/// A kernel offset failure ops does not name, as [`OffsetRefusal::Phrase`]: the kernel's class
/// of it, whether it is a defect, and its own statement of it.
fn unnamed(o: &OffsetFailure) -> OffsetRefusal {
OffsetRefusal::Other {
message: o.to_string(),
OffsetRefusal::Phrase {
class: o.kind(),
defect: o.is_defect(),
phrase: o.to_string(),
}
}
@@ -435,8 +449,9 @@ fn refused(r: DraftRefusal) -> Error {
/// face the body does not have, a body that is not a valid solid facing out, or a pull in the
/// neutral plane for a plane face;
/// [`Error::Unsupported`] for a face the kernel cannot draft (what it is), or a face drafted past
/// where its neighbours meet; [`Error::NoEffect`] for a draft that moved no face; the kernel's
/// other errors; those of [`finish`].
/// where its neighbours meet; [`Error::NoEffect`] for a draft that moved no face;
/// [`Error::OffsetRefused`] for a face the kernel refuses with a reason; the kernel's other
/// errors; those of [`finish`].
pub fn draft(
body: &Body,
order: &Order,
@@ -471,7 +486,7 @@ pub fn draft(
match draft_surface(k, b, f, &the_draft) {
Ok(Ok(s)) => surfaces.push(s),
Ok(Err(r)) => return Ok(Err(refused(r))),
Err(e) => return Ok(Err(kernel_status(e))),
Err(e) => return Ok(Err(status(e, &in_order(order)))),
}
}
// the marks live for this call only: put on the picked faces, read and removed once the
+86 -6
View File
@@ -394,11 +394,13 @@ fn the_offset_refusals_are_typed() {
/// Each of the kernel's other offset refusals as [`Error::OffsetRefused`] (#14783): its reason,
/// and its face by the index the call's lookup gives, `None` for a face it does not find or a
/// refusal that names none. Never the bare status they were before.
/// refusal that names none. Never the bare status they were before, and never the kernel's text
/// in place of a reason (#15035): a routine's own failure keeps its kind and the kernel's phrase.
#[test]
fn every_other_offset_refusal_says_why_and_where() {
use crate::OffsetRefusal as R;
use solidcore::error::offset::{LoopCrossing, OffsetFailure as O, Unsoundness};
use solidcore::error::ErrorKind;
use solidcore::error::Failure;
use solidcore::FaceId;
let named = FaceId::from_index(3);
@@ -501,17 +503,78 @@ fn every_other_offset_refusal_says_why_and_where() {
what: Unsoundness::VolumeUndecided,
},
),
// a routine's own failure, so far only a phrase: its class, whether it is the kernel's
// fault, and the kernel's words, as it said them when it arrived as text
(
O::Failed("the face has no surface"),
None,
R::Phrase {
class: ErrorKind::Failed,
defect: false,
phrase: "the face has no surface".to_owned(),
},
),
(
O::Missing("the face's surface"),
None,
R::Phrase {
class: ErrorKind::Missing,
defect: false,
phrase: "the face's surface is missing".to_owned(),
},
),
(
O::InvalidValue("the face has no outer face use"),
None,
R::Phrase {
class: ErrorKind::InvalidInput,
defect: false,
phrase: "invalid input: the face has no outer face use".to_owned(),
},
),
(
O::Inconsistent("an offset edge has no curve"),
None,
R::Phrase {
class: ErrorKind::Failed,
defect: true,
phrase: "a consistency check failed: an offset edge has no curve".to_owned(),
},
),
];
for (o, face, why) in cases {
let got = refused(o);
assert_eq!(got, Error::OffsetRefused { face, why }, "{o:?}");
assert!(!got.to_string().contains("status"), "{got}");
// only a phrase arrives as one: every reason the kernel names is named here too
let Error::OffsetRefused { why, .. } = &got else {
panic!("not an offset refusal: {o:?}");
};
assert_eq!(matches!(why, R::Phrase { .. }), o.is_phrase(), "{o:?}");
}
// the face the kernel sets on a failure whose reason names none (a draft's face without a
// surface) is the one said
let what = "the face has no surface or no outer face use";
let at = Failure::new("test", O::InvalidValue(what))
.at(solidcore::EntityRef::Face(named))
.into_error();
assert_eq!(
super::status(at, &index),
Error::OffsetRefused {
face: Some(7),
why: R::Phrase {
class: ErrorKind::InvalidInput,
defect: false,
phrase: format!("invalid input: {what}"),
},
}
);
}
/// A reason ops does not name yet (one the kernel adds later) is said in the kernel's words,
/// never a bare status: [`super::unnamed`], the arm every unnamed reason takes, keeps the
/// kernel's own statement, and the error says it.
/// A reason ops does not name yet (one the kernel adds later) is said by its class and in the
/// kernel's words, never a bare status: [`super::unnamed`], the arm every unnamed reason takes,
/// keeps the kernel's class, whether it is a defect, and its own statement, and the error says
/// it.
#[test]
fn an_unnamed_offset_refusal_keeps_the_kernels_words() {
use solidcore::error::offset::OffsetFailure;
@@ -519,11 +582,28 @@ fn an_unnamed_offset_refusal_keeps_the_kernels_words() {
let why = super::unnamed(&o);
assert_eq!(
why,
crate::OffsetRefusal::Other {
message: o.to_string()
crate::OffsetRefusal::Phrase {
class: o.kind(),
defect: o.is_defect(),
phrase: o.to_string(),
}
);
assert!(!o.to_string().is_empty());
// one whose class has an error of its own (a new reason of the walls-touch, fold or
// vanishing-edge kind) keeps taking it, as it did before it had a reason
assert_eq!(
super::by_kind(solidcore::error::ErrorKind::OffsetWallsTouch),
Some(Error::WallsTouch)
);
assert_eq!(
super::by_kind(solidcore::error::ErrorKind::OffsetFolds),
Some(Error::WallFolds { largest: None })
);
assert!(matches!(
super::by_kind(solidcore::error::ErrorKind::OffsetEdgeVanishes),
Some(Error::Unsupported(_))
));
assert_eq!(super::by_kind(o.kind()), None);
let e = Error::OffsetRefused { face: Some(2), why };
assert_eq!(
e.to_string(),
+152 -7
View File
@@ -16,10 +16,12 @@ use solidcore::nurbs::b_spline_curve::{
BSplineCurve, BSplineCurveForm, CanonicalData, Ends, KnotType,
};
use solidcore::nurbs::b_spline_surface::{BSplineSurface, SurfParamType};
use solidcore::nurbs::n_sided::{n_sided as kernel_n_sided, NSidedRefusal, MAX_SIDES, MIN_SIDES};
use solidcore::primitive::api_curve_ops::make_curves_compatible;
use solidcore::query::api_queries::point_distance;
use solidcore::surface::surface::Surface;
use solidcore::topology::brep::p3::new_brep;
use solidcore::topology::brep::p4::stitch_faces;
use solidcore::topology::brep::sheet::face_on;
use solidcore::topology::Brep;
use solidcore::SolidError;
@@ -237,7 +239,8 @@ fn compatible(a: BSplineCurve, b: BSplineCurve) -> solidcore::Result<[BSplineCur
/// curve, point for point at the same fraction of its range (a rational curve, an arc, stays the
/// same curve but not the same parameter: the kernel gives the four common end weights). Three curves bound the same patch
/// with its fourth side collapsed to the point where the third curve meets the first (a
/// degenerate Coons patch: one side of its box is that corner). Each curve is taken over its
/// degenerate Coons patch: one side of its box is that corner). Five or more bound the
/// certified n-sided fill ([`n_sided`]), whose certificate this drops. Each curve is taken over its
/// natural range (a line from its start to its end, a spline over its knots' domain).
///
/// The ends must meet within the model's zone tolerance: the kernel's tolerance within which
@@ -248,15 +251,14 @@ fn compatible(a: BSplineCurve, b: BSplineCurve) -> solidcore::Result<[BSplineCur
///
/// # Errors
/// [`Error::Argument`] for fewer than three curves, or a curve refused (a circle, an ellipse, a
/// traced intersection curve, numbers the kernel refuses); [`Error::Unsupported`] for five or
/// more curves (no n-sided patch yet); [`Error::BoundaryGap`] for curves that do not meet; the
/// kernel's status.
/// traced intersection curve, numbers the kernel refuses); [`Error::BoundaryGap`] for curves
/// that do not meet; for five or more, those of [`n_sided`]; the kernel's status.
pub fn boundary(curves: &[Curve]) -> Result<Record> {
if curves.len() < 3 {
return Err(Error::Argument);
}
if curves.len() > 4 {
return Err(Error::Unsupported("a patch bounded by five or more curves"));
return n_sided(curves).map(|(sheet, _)| sheet);
}
record(run("surface::boundary", |k| {
let mut sides = refused!(splines(k, curves)?);
@@ -286,6 +288,66 @@ pub fn boundary(curves: &[Curve]) -> Result<Record> {
}))
}
/// The n-sided patch bounded by `curves` (three or more, meeting end to end round one loop in
/// the order given, each turned to run on from the one before, as [`boundary`] takes them): the
/// kernel's fill, N four-sided faces round a centre, each bounded by halves of two of the curves
/// and two inner curves from their middles to the centre, stitched into one sheet; and the
/// certified bound within which every curve is the sheet's boundary (`solidcore::nurbs::n_sided`).
/// The faces meet along the inner curves within the model's zone tolerance (G0, certified);
/// tangency across them is the fill's aim, within its angle tolerance, not certified.
///
/// # Errors
/// As [`boundary`] for the curves and their joins; [`Error::Unsupported`] for a rational curve
/// (an arc: the fill takes polynomial curves) or more than 64 curves; [`Error::PastTolerance`]
/// for a boundary or an inner join not proved within the zone tolerance; the kernel's status.
pub fn n_sided(curves: &[Curve]) -> Result<(Record, f64)> {
if curves.len() < MIN_SIDES {
return Err(Error::Argument);
}
if curves.len() > MAX_SIDES {
return Err(Error::Unsupported(
"an n-sided patch of more than 64 curves",
));
}
let mut bound = None;
let made = run("surface::n_sided", |k| {
let mut sides = refused!(splines(k, curves)?);
let tol = context_zone_tol_3d(k, None, SmallTopoType::NormalSize);
refused!(looped(&mut sides, tol)?);
match kernel_n_sided(&sides, tol)? {
Ok(fill) => {
bound = Some(fill.bound);
sheet_of(k, fill.patches)
}
Err(NSidedRefusal::Rational) => Ok(Err(Error::Unsupported(
"a rational curve bounding an n-sided patch",
))),
Err(NSidedRefusal::PastTolerance { bound }) => Ok(Err(Error::PastTolerance { bound })),
Err(NSidedRefusal::Joins { gap }) => Ok(Err(Error::PastTolerance { bound: gap })),
}
});
let sheet = record(made)?;
Ok((sheet, bound.ok_or(Error::Exception)?))
}
/// A sheet of one face on each of `patches`, over its whole natural box, stitched along the
/// sides they share.
fn sheet_of(k: &mut Kernel, patches: Vec<BSplineSurface>) -> solidcore::Result<Out<Brep>> {
let mut b = new_brep(k, USE_DEFAULT, USE_DEFAULT)?;
for s in patches {
let s = Surface::from(s);
let bx = s.natural_uv_domain_with_nurb(k)?;
face_on(k, &mut b, s, &bx)?;
}
b.refresh_cached_topology();
let tol = b.tolerance();
if let Err(e) = stitch_faces(k, &mut b, tol, None, false) {
return Ok(Err(status(e)));
}
b.refresh_cached_topology();
finish(k, b, Expect::FACES)
}
/// The ruled surface between `a` and `b`, one face: `S(s, t) = (1 - t) a(s) + t b(s)`, `s` the
/// same fraction of each curve's natural range (the two first made compatible, exactly, as in
/// [`boundary`]), `t` from 0 on `a` to 1 on `b`. So the surface's sides are the two curves
@@ -561,7 +623,7 @@ mod tests {
}
#[test]
fn too_few_or_too_many_curves_and_circles_are_refused() {
fn too_few_curves_and_circles_are_refused_and_five_fill() {
assert_eq!(
boundary(&[line(P[0], P[1]), line(P[1], P[0])]),
Err(Error::Argument)
@@ -576,7 +638,8 @@ mod tests {
let pentagon: Vec<Curve> = (0..5)
.map(|i| line(corners[i], corners[(i + 1) % 5]))
.collect();
assert!(matches!(boundary(&pentagon), Err(Error::Unsupported(_))));
// five curves: the n-sided fill, five faces
assert_eq!(boundary(&pentagon).unwrap().faces.len(), 5);
let circle = Curve::Circle {
frame: Frame {
origin: [0.0; 3],
@@ -590,4 +653,86 @@ mod tests {
Err(Error::Argument)
);
}
/// The corners of a regular pentagon of radius 10 about the z axis, at height `z(x, y)`.
fn pentagon(z: impl Fn(f64, f64) -> f64) -> Vec<[f64; 3]> {
(0..5)
.map(|i| {
let a = std::f64::consts::TAU * f64::from(i) / 5.0;
let (x, y) = (10.0 * a.cos(), 10.0 * a.sin());
[x, y, z(x, y)]
})
.collect()
}
#[test]
fn a_regular_pentagon_of_lines_fills_flat() {
let p = pentagon(|_, _| 0.0);
let sides: Vec<Curve> = (0..5).map(|i| line(p[i], p[(i + 1) % 5])).collect();
let (sheet, bound) = n_sided(&sides).unwrap();
assert!(bound <= 1e-9, "{bound}");
assert_eq!(sheet.faces.len(), 5);
// five faces round a centre, stitched along the five inner curves
assert_eq!(sheet.edges.iter().filter(|e| e.uses.len() == 2).count(), 5);
// planar data, planar faces: every point of every face in z = 0
for face in &sheet.faces {
for (s, t) in [(0.0, 0.0), (0.3, 0.6), (1.0, 0.5), (0.5, 1.0)] {
assert!(at(face, s, t)[2].abs() < 1e-9);
}
}
}
#[test]
fn five_splines_on_a_saddle_bound_their_patch() {
// each side a cubic whose control points lie on z = x y / 50 at thirds of the chord
let z = |x: f64, y: f64| x * y / 50.0;
let p = pentagon(z);
let sides: Vec<Curve> = (0..5)
.map(|i| {
let (a, b) = (p[i], p[(i + 1) % 5]);
let third = |t: f64| {
let (x, y) = (a[0] + t * (b[0] - a[0]), a[1] + t * (b[1] - a[1]));
[x, y, z(x, y)]
};
Curve::Spline {
knots: vec![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0],
points: vec![a, third(1.0 / 3.0), third(2.0 / 3.0), b],
}
})
.collect();
let (sheet, bound) = n_sided(&sides).unwrap();
assert!(bound <= 1e-6, "{bound}");
assert_eq!(sheet.faces.len(), 5);
// the corners are vertices of the sheet: its boundary runs through them
for corner in &p {
assert!(
sheet.vertices.iter().any(|v| near(v.point, *corner, 1e-6)),
"{corner:?}"
);
}
}
#[test]
fn five_arcs_on_a_sphere_are_refused() {
// five arcs of the circle z = 6 on the sphere of radius 10 (radius 8), as rational
// quadratics
let half = std::f64::consts::PI / 5.0;
let w = half.cos();
let arcs: Vec<Curve> = (0..5)
.map(|i| {
let a = 2.0 * half * f64::from(i);
let on = |t: f64, r: f64| [r * t.cos(), r * t.sin(), 6.0];
let (s, m, e) = (on(a, 8.0), on(a + half, 8.0 / w), on(a + 2.0 * half, 8.0));
Curve::RationalSpline {
knots: vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
homogeneous: vec![
[s[0], s[1], s[2], 1.0],
[m[0] * w, m[1] * w, m[2] * w, w],
[e[0], e[1], e[2], 1.0],
],
}
})
.collect();
assert!(matches!(n_sided(&arcs), Err(Error::Unsupported(_))));
}
}
+301 -1
View File
@@ -9,7 +9,9 @@ use solidcore::nurbs::crv_fit::p1::approx_knots;
use solidcore::nurbs::geom::Rectangle;
use solidcore::nurbs::knots::{clamped_uniform, KnotVector, Side};
use solidcore::nurbs::math::{CPoint, NOW};
use solidcore::nurbs::srf_basic::{Direction, Surface as NurbSurface};
use solidcore::nurbs::srf_basic::{
falloff_weight as kernel_falloff, Direction, FalloffShape, Surface as NurbSurface,
};
use solidcore::nurbs::srf_deviation::certified_distance;
use solidcore::Vec3;
use solidcore_record::brep::{
@@ -79,6 +81,144 @@ pub fn move_cvs(sheet: &Brep, moves: &[((usize, usize), [f64; 3])]) -> Result<Br
}))
}
/// What [`move_net`] moves: whole rows (u indices), whole columns (v indices) or single control
/// points `(i, j)`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Picks {
/// Every point of these u indices.
Rows(Vec<usize>),
/// Every point of these v indices.
Columns(Vec<usize>),
/// These points, `(u index, v index)`.
Cvs(Vec<(usize, usize)>),
}
/// The shape of a soft selection's falloff over the index distance `d` from the picks, with
/// `s = d / (radius + 1)`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Shape {
/// `1 - s`.
Linear,
/// `1 - (3 s² - 2 s³)`: flat at the picks and at the radius's edge.
Smooth,
}
/// A soft selection: the points within `radius` index steps of the picks move too, by the
/// weight [`Shape`] gives their distance.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Falloff {
/// The most index steps from a pick a point moves at: 0 moves the picks alone.
pub radius: u32,
/// How the weight falls from 1 at the picks.
pub shape: Shape,
}
/// The weight of a point `d` index steps from the nearest pick: 1 at a pick, falling by
/// `falloff`'s shape, 0 past its radius (and for every point off the picks without a falloff).
#[must_use]
pub fn falloff_weight(falloff: Option<Falloff>, d: usize) -> f64 {
let Some(f) = falloff else {
return if d == 0 { 1.0 } else { 0.0 };
};
let Ok(d) = u32::try_from(d) else {
return 0.0;
};
let shape = match f.shape {
Shape::Linear => FalloffShape::Linear,
Shape::Smooth => FalloffShape::Smooth,
};
kernel_falloff(shape, f.radius, d)
}
/// The weight table of a `rows` by `cols` net (row by row, as [`Net::points`]): each point's
/// [`falloff_weight`] at its index distance from the nearest pick. A row pick's distance is
/// along u (`|i - row|`), a column pick's along v (`|j - column|`), a point pick's the larger of
/// the two (the square ring of the net around it).
///
/// # Errors
/// [`Error::Argument`] for no picks, a pick past the net, or a net of no points or more than
/// [`REBUILD_MAX_SAMPLES`].
pub fn net_weights(
rows: usize,
cols: usize,
picks: &Picks,
falloff: Option<Falloff>,
) -> Result<Vec<f64>> {
let total = rows
.checked_mul(cols)
.filter(|&n| n > 0 && n <= REBUILD_MAX_SAMPLES)
.ok_or(Error::Argument)?;
let (empty, inside) = match picks {
Picks::Rows(p) => (p.is_empty(), p.iter().all(|&i| i < rows)),
Picks::Columns(p) => (p.is_empty(), p.iter().all(|&j| j < cols)),
Picks::Cvs(p) => (p.is_empty(), p.iter().all(|&(i, j)| i < rows && j < cols)),
};
if empty || !inside {
return Err(Error::Argument);
}
let nearest = |i: usize, j: usize| -> usize {
let d = match picks {
Picks::Rows(p) => p.iter().map(|&r| i.abs_diff(r)).min(),
Picks::Columns(p) => p.iter().map(|&c| j.abs_diff(c)).min(),
Picks::Cvs(p) => p
.iter()
.map(|&(a, b)| i.abs_diff(a).max(j.abs_diff(b)))
.min(),
};
d.unwrap_or(usize::MAX)
};
let mut table = Vec::new();
table
.try_reserve_exact(total)
.map_err(|_| Error::Argument)?;
for i in 0..rows {
for j in 0..cols {
table.push(falloff_weight(falloff, nearest(i, j)));
}
}
Ok(table)
}
/// `sheet` with the picked control points moved by `delta`, and with a `falloff` the points
/// around them by their weight of it ([`net_weights`]): the kernel moves each point of weight
/// `t` by `t delta` (`Surface::displace_cpts`). The knots and degrees are kept; the result is a
/// new one-face sheet on the moved surface, its whole box.
///
/// # Errors
/// As [`control_net`] and [`net_weights`]; [`Error::Argument`] for a `delta` not finite or
/// past the coordinate bound, or a moved point past it; the kernel's status otherwise.
pub fn move_net(
sheet: &Brep,
picks: &Picks,
delta: [f64; 3],
falloff: Option<Falloff>,
) -> Result<Brep> {
if !coord_ok(&delta) {
return Err(Error::Argument);
}
let ((u, v), points) = untrimmed(sheet)?;
let net = control_net(sheet)?;
let weights = net_weights(net.rows, net.cols, picks, falloff)?;
let moves: Vec<((usize, usize), f64)> = (0..net.rows)
.flat_map(|i| (0..net.cols).map(move |j| (i, j)))
.zip(weights)
.filter(|&(_, t)| t > 0.0)
.collect();
let mut surface = kernel_surface(u, v, points)?;
let moved = record(run("surface_cvs::move_net", |k| {
surface.displace_cpts(&moves, Vec3::new(delta[0], delta[1], delta[2]))?;
sheet_of(k, BSplineSurface::from_gw_nurb(&surface))
}))?;
if !control_net(&moved)?
.points
.iter()
.all(|p| coord_ok(p.as_slice()))
{
return Err(Error::Argument);
}
Ok(moved)
}
/// `sheet`'s surface refitted to degrees `(deg_u, deg_v)` with `(spans_u, spans_v)` uniform
/// spans on `[0, 1]²`, and the kernel's certified bound of how far the refit is from the
/// surface at every parameter ([`certified_distance`]), kept when within `tol`. The surface,
@@ -374,6 +514,166 @@ mod tests {
);
}
#[test]
fn a_row_move_moves_exactly_that_row() {
let s = long_sheet();
let net = control_net(&s).unwrap();
let delta = [0.0, 0.0, 1.0];
let last = net.rows - 1;
for falloff in [
None,
Some(Falloff {
radius: 0,
shape: Shape::Smooth,
}),
] {
let moved = move_net(&s, &Picks::Rows(vec![last]), delta, falloff).unwrap();
let after = control_net(&moved).unwrap();
assert_eq!((after.rows, after.cols), (net.rows, net.cols));
for (n, (p, q)) in net.points.iter().zip(&after.points).enumerate() {
if n / net.cols == last {
assert_eq!(*q, [p[0], p[1], p[2] + 1.0], "point {n}");
} else {
assert_eq!(p, q, "point {n}");
}
}
}
// a column the same way
let moved = move_net(&s, &Picks::Columns(vec![0]), delta, None).unwrap();
let after = control_net(&moved).unwrap();
for (n, (p, q)) in net.points.iter().zip(&after.points).enumerate() {
if n % net.cols == 0 {
assert_eq!(*q, [p[0], p[1], p[2] + 1.0], "point {n}");
} else {
assert_eq!(p, q, "point {n}");
}
}
}
#[test]
fn falloff_weights_are_one_at_the_pick_zero_past_the_radius_and_monotone() {
assert_eq!(falloff_weight(None, 0), 1.0);
assert_eq!(falloff_weight(None, 1), 0.0);
for shape in [Shape::Linear, Shape::Smooth] {
let f = Some(Falloff { radius: 3, shape });
let w: Vec<f64> = (0..8).map(|d| falloff_weight(f, d)).collect();
assert_eq!(w[0], 1.0, "{shape:?}");
assert!(
w[1..=3].iter().all(|&x| x > 0.0 && x < 1.0),
"{shape:?}: {w:?}"
);
assert!(w[4..].iter().all(|&x| x == 0.0), "{shape:?}: {w:?}");
assert!(
w.windows(2).all(|p| p[1] < p[0] || p[1] == 0.0),
"{shape:?}: {w:?}"
);
// the table: a point pick's distance is its square ring on the net
let table = net_weights(9, 7, &Picks::Cvs(vec![(4, 3)]), f).unwrap();
assert_eq!(table.len(), 63);
for i in 0..9_usize {
for j in 0..7_usize {
let d = i.abs_diff(4).max(j.abs_diff(3));
assert_eq!(table[i * 7 + j], w[d], "{shape:?} at ({i}, {j})");
}
}
}
// the smooth shape is flat at the pick: it falls less over the first step
let (linear, smooth) = (
falloff_weight(
Some(Falloff {
radius: 3,
shape: Shape::Linear,
}),
1,
),
falloff_weight(
Some(Falloff {
radius: 3,
shape: Shape::Smooth,
}),
1,
),
);
assert!(smooth > linear, "{smooth} {linear}");
// rows and columns by their one index
let rows = net_weights(
5,
3,
&Picks::Rows(vec![0]),
Some(Falloff {
radius: 1,
shape: Shape::Linear,
}),
)
.unwrap();
assert_eq!(
rows,
vec![1.0, 1.0, 1.0, 0.5, 0.5, 0.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]
);
// bad picks
assert_eq!(
net_weights(5, 3, &Picks::Rows(vec![]), None),
Err(Error::Argument)
);
assert_eq!(
net_weights(5, 3, &Picks::Rows(vec![5]), None),
Err(Error::Argument)
);
assert_eq!(
net_weights(5, 3, &Picks::Columns(vec![3]), None),
Err(Error::Argument)
);
assert_eq!(
net_weights(5, 3, &Picks::Cvs(vec![(0, 3)]), None),
Err(Error::Argument)
);
assert_eq!(
net_weights(0, 3, &Picks::Rows(vec![0]), None),
Err(Error::Argument)
);
}
#[test]
fn a_soft_move_moves_each_point_by_its_weight() {
let s = bicubic();
let net = control_net(&s).unwrap();
let f = Some(Falloff {
radius: 1,
shape: Shape::Linear,
});
let picks = Picks::Cvs(vec![(1, 1)]);
let moved = move_net(&s, &picks, [0.0, 0.0, 2.0], f).unwrap();
let after = control_net(&moved).unwrap();
let weights = net_weights(net.rows, net.cols, &picks, f).unwrap();
for (n, ((p, q), w)) in net
.points
.iter()
.zip(&after.points)
.zip(&weights)
.enumerate()
{
assert_eq!((q[0], q[1]), (p[0], p[1]), "point {n}");
assert!(
(q[2] - (p[2] + 2.0 * w)).abs() <= 1e-15 * (1.0 + p[2].abs()),
"point {n}: {p:?} to {q:?}, weight {w}"
);
}
// the pick by the whole delta, its ring by half, the rest not at all
assert_eq!(weights[5], 1.0);
assert_eq!(weights[0], 0.5);
assert_eq!(weights[3 * 4 + 3], 0.0);
assert_eq!(after.points[15], net.points[15]);
// bad moves
assert_eq!(
move_net(&s, &picks, [f64::NAN, 0.0, 0.0], f),
Err(Error::Argument)
);
assert_eq!(
move_net(&s, &Picks::Cvs(vec![(4, 0)]), [0.0; 3], f),
Err(Error::Argument)
);
}
/// The Coons sheet of four bent cubic Bézier sides: a bicubic Bézier patch.
fn bicubic() -> Brep {
let side = |points: [[f64; 3]; 4]| Curve::Spline {
+61 -1
View File
@@ -7,7 +7,7 @@ use std::io::Read as _;
use serde_json::Value;
use solidcore_ops::boolean::{boolean, imprint, union_all};
use solidcore_ops::sweep::{extrude, revolve, Curve, DraftCorner};
use solidcore_ops::{make_box, session, Body, BooleanOp, CheckLevel, Error};
use solidcore_ops::{make_box, make_sphere, session, Body, BooleanOp, CheckLevel, Error};
use solidcore_record::brep::Brep as Record;
fn all_fixes() {
@@ -65,6 +65,66 @@ fn boxes_touching_along_an_edge_union_to_a_closed_body() {
assert_eq!(u.solid_count().unwrap(), 2);
}
/// A 20 mm box with a closed void at its middle: a 10 mm cube void, or a sphere void of radius 5.
fn box_with_void(cube: bool) -> Body {
let tool = if cube {
make_box([5.0, 5.0, 5.0], [10.0, 10.0, 10.0]).unwrap()
} else {
make_sphere([10.0, 10.0, 10.0], 5.0).unwrap()
};
let b = make_box([0.0, 0.0, 0.0], [20.0, 20.0, 20.0]).unwrap();
boolean(b, tool, BooleanOp::Subtract).unwrap().unwrap()
}
#[test]
fn a_union_that_fills_a_void_is_the_whole_box() {
all_fixes();
// Every face of the void is deleted, and the region they joined is material. The tool's
// faces lie on the void's, or inside the box's material, so none of them is copied in.
let fillers = [
(true, make_box([5.0, 5.0, 5.0], [10.0, 10.0, 10.0]).unwrap()),
(true, make_box([4.0, 4.0, 4.0], [12.0, 12.0, 12.0]).unwrap()),
(true, make_box([5.0, 4.0, 4.0], [12.0, 12.0, 12.0]).unwrap()),
(false, make_sphere([10.0, 10.0, 10.0], 5.0).unwrap()),
(false, make_sphere([10.0, 10.0, 10.0], 6.0).unwrap()),
(
false,
make_box([5.0, 5.0, 5.0], [10.0, 10.0, 10.0]).unwrap(),
),
];
for (n, (cube, filler)) in fillers.into_iter().enumerate() {
let u = boolean(box_with_void(cube), filler, BooleanOp::Union)
.unwrap()
.unwrap();
assert!(near(volume(&u), 8000.0), "filler {n}: {}", volume(&u));
assert_eq!(u.counts().unwrap(), [6, 12, 8], "filler {n}");
assert!(u.is_manifold_solid().unwrap(), "filler {n}");
assert!(u.to_record().is_ok(), "filler {n}");
}
// A filler that leaves part of the void keeps that part: 20³ − 10 · 10 · 1.
let part = make_box([6.0, 5.0, 5.0], [10.0, 10.0, 10.0]).unwrap();
let u = boolean(box_with_void(true), part, BooleanOp::Union)
.unwrap()
.unwrap();
assert!(near(volume(&u), 7900.0), "{}", volume(&u));
}
#[test]
fn a_cube_on_a_void_is_kept_out_by_a_difference_and_shares_nothing_with_it() {
all_fixes();
let cube = || make_box([5.0, 5.0, 5.0], [10.0, 10.0, 10.0]).unwrap();
// The faces bound material on opposite sides: a difference keeps them, so the box is
// unchanged, and an intersection deletes them and leaves nothing.
let d = boolean(box_with_void(true), cube(), BooleanOp::Subtract)
.unwrap()
.unwrap();
assert!(near(volume(&d), 7000.0), "{}", volume(&d));
assert_eq!(d.counts().unwrap(), [12, 24, 16]);
assert!(boolean(box_with_void(true), cube(), BooleanOp::Intersect)
.unwrap()
.is_none());
}
#[test]
fn an_imprint_cuts_the_faces_the_tool_crosses() {
all_fixes();
+25 -32
View File
@@ -1180,11 +1180,11 @@ impl FilletSolver {
/// `Kernel::fixes.fillet_chamfer_cone_stored_as_cone` as well (WR-572) that revolution
/// is stored as the cone `CopyAndAddAnalytics` finds in its NURBS form, the rails made on
/// it.
/// With `Kernel::fixes.fillet_torus_center_on_axis` (WR-422), a section centred on the
/// arc's axis has its rail points turned into the half-planes through the edge's ends and
/// takes the edge's start for the nearest point.
/// With `Kernel::fixes.fillet_torus_rail_on_axis` (WR-592), a start rail point within
/// the solver's tolerance of the arc's axis is moved onto the axis.
/// Not upstream: a section centred on the arc's axis has its rail points turned into
/// the half-planes through the edge's ends and takes the edge's start for the nearest
/// point (WR-422); a start rail point within the solver's tolerance of the arc's axis
/// is moved onto the axis (WR-592); the span is the turn from the start section to the
/// end one, without `IsArc`'s start angle (WR-568).
///
/// `SM_ERR` when the generator is not circular, the edge curve is not an arc, the
/// radius is tiny or changes, or the nearest point on the edge is not unique; those
@@ -1214,8 +1214,8 @@ impl FilletSolver {
/// and `IsArc` gates. The frame is the class axis with its origin where the axis meets
/// the plane through the edge's start square to it, its x axis the world x (y when the
/// axis is within `acos(0.9)` of x) made square to the axis; there is no `IsArc` start
/// angle, so the span is the turn from the start section (as with
/// `fillet_torus_span_from_start_section`).
/// angle, so the span is the turn from the start section, as in
/// [`FilletSolver::torus_fillet_geom_parts`].
///
/// # Errors
///
@@ -1255,11 +1255,12 @@ impl FilletSolver {
// no work - not a circular cross-section on a circular arc (with
// `fixes.fillet_chamfer_cone_on_arc`, or a linear one: its revolution is a cone)
// Side effect: get circular arc RefFrame, Radius, StartAng, and EndAng.
// Side effect: get circular arc RefFrame, Radius, StartAng, and EndAng (only the
// frame is used: the span is the turn from the start section, WR-568).
let section = fsg_type(s)?;
let linear_section =
k.fixes.fillet_chamfer_cone_on_arc && section == FilletSurfaceGeneratorType::Linear;
let (ref_frame, start_ang) = if let Some(class) = class {
let ref_frame = if let Some(class) = class {
// WR-669: the circular or linear cross-section turned about the faces' common
// axis (the class's); the frame's origin: the centre of the edge's circle, where
// the axis meets the edge's plane
@@ -1275,17 +1276,17 @@ impl FilletSolver {
let x = x.unitize()?;
let mut frame = Axis2Placement::default();
frame.set_canonical(centre, x, z.cross(x))?;
(frame, 0.0)
frame
} else {
if section != FilletSurfaceGeneratorType::Circular && !linear_section {
return Err(sm_err());
}
let Some((ref_frame, _arc_radius, start_ang, _end_ang)) =
let Some((ref_frame, _arc_radius, _start_ang, _end_ang)) =
curve.is_arc(k, 11, ANALYTIC_TOL)?
else {
return Err(sm_err());
};
(ref_frame, start_ang)
ref_frame
};
// select new fillet surface storage spot
@@ -1323,14 +1324,13 @@ impl FilletSolver {
let mut p1 = eval_offset(k, s, 1, start_uv1)?; // sP1 = rail2 point
s.reload_surface_offsets()?;
// `fixes.fillet_torus_center_on_axis` (WR-422): when the section's centre lies on
// the arc's axis the rail points carry no angle, so turn them into the half-planes
// through the edge's ends.
// Not upstream (WR-422): when the section's centre lies on the arc's axis the rail
// points carry no angle, so turn them into the half-planes through the edge's ends.
let ivl = edge::interval(b, e)?;
let unit_axis = ref_frame.z_axis().unitize()?;
let axis_pnt = ref_frame.origin();
let center_tol = ops.solver().approx_tol_3d;
let center_on_axis = k.fixes.fillet_torus_center_on_axis && {
let center_on_axis = {
let off = arc_center - axis_pnt;
(off - unit_axis * off.dot(unit_axis)).length() < center_tol
};
@@ -1344,16 +1344,14 @@ impl FilletSolver {
None
};
// `fixes.fillet_torus_rail_on_axis` (WR-592): a rail point within the tolerance of
// the axis turns round it in a circle smaller than the tolerance, a point: it is
// the axis's point, so the surface is exactly singular there.
if k.fixes.fillet_torus_rail_on_axis {
for rail_pnt in [&mut p0, &mut p1] {
let off = *rail_pnt - axis_pnt;
let on_axis = axis_pnt + unit_axis * off.dot(unit_axis);
if on_axis.distance(*rail_pnt) < center_tol {
*rail_pnt = on_axis;
}
// Not upstream (WR-592): a rail point within the tolerance of the axis turns round
// it in a circle smaller than the tolerance, a point: it is the axis's point, so the
// surface is exactly singular there.
for rail_pnt in [&mut p0, &mut p1] {
let off = *rail_pnt - axis_pnt;
let on_axis = axis_pnt + unit_axis * off.dot(unit_axis);
if on_axis.distance(*rail_pnt) < center_tol {
*rail_pnt = on_axis;
}
}
@@ -1438,16 +1436,11 @@ impl FilletSolver {
span_ang += 2.0 * PI;
}
let end_ang = rad_to_deg(span_ang);
// Not upstream (WR-568, the fork's tangency-labs/smlib@985faae): the span is the
// turn from the start section to the end one; the start angle `IsArc` gives is
// measured from the arc's own frame (an `SmCircle`'s analytic domain, which need
// not start at 0), not from the start section, so it is no part of that turn
let mut angle_span = if k.fixes.fillet_torus_span_from_start_section {
end_ang
} else {
end_ang - start_ang
};
let mut angle_span = rad_to_deg(span_ang);
// Determine if we need to extend the span even when 'm_bExtendBefore' is FALSE
if !curve.is_closed(k, &ivl, 0.0)? && !s.extend_after {
+28 -37
View File
@@ -2101,9 +2101,8 @@ fn c3_make_intersector<'s>(
/// :2054-2090: the trace locals (tolerances, `sDir = sPVSt[1]`); a TANGENT last point
/// reverses `st.tsect_pnts`, sets `sDir = -sPVEn[1]` and `bReverseTracing`; the local
/// extension flags from the solver's. With
/// `Kernel::fixes.fillet_trace_from_tangent_point_toward_next` (WR-603) a TANGENT first
/// point starts the trace toward the next point.
/// extension flags from the solver's. Not upstream (WR-603): a TANGENT first point starts
/// the trace toward the next point.
fn c3_trace_start(
ops: &mut dyn FilletSolverOps,
cx: &mut Cx<'_>,
@@ -2129,22 +2128,20 @@ fn c3_trace_start(
}
}
// `fixes.fillet_trace_from_tangent_point_toward_next` (WR-603): a trace whose first
// through point is a tangent point starts toward the next through point (the chord
// between their points on the first offset surface): the offsets touch there and the
// edge's end tangent can be the line along which they touch to second order.
if cx.k.fixes.fillet_trace_from_tangent_point_toward_next && st.tsect_pnts.len() > 1 {
if let (Some(&first), Some(&next)) = (st.tsect_pnts.first(), st.tsect_pnts.get(1)) {
let first = st.store.get(cx.fillets, first)?;
if first.point_type == IntersectionPointType::TangentPoint {
let uv_from = first.uv_pos(SrfIndex::First);
let uv_to = st.store.get(cx.fillets, next)?.uv_pos(SrfIndex::First);
let from = offset_evaluate_point(cx.k, ops, 0, uv_from)?;
let to = offset_evaluate_point(cx.k, ops, 0, uv_to)?;
let chord = to - from;
if chord.length() > st.trace.approx_tol_3d {
st.trace.dir = chord;
}
// Not upstream (WR-603): a trace whose first through point is a tangent point starts
// toward the next through point (the chord between their points on the first offset
// surface): the offsets touch there and the edge's end tangent can be the line along
// which they touch to second order.
if let (Some(&first), Some(&next)) = (st.tsect_pnts.first(), st.tsect_pnts.get(1)) {
let first = st.store.get(cx.fillets, first)?;
if first.point_type == IntersectionPointType::TangentPoint {
let uv_from = first.uv_pos(SrfIndex::First);
let uv_to = st.store.get(cx.fillets, next)?.uv_pos(SrfIndex::First);
let from = offset_evaluate_point(cx.k, ops, 0, uv_from)?;
let to = offset_evaluate_point(cx.k, ops, 0, uv_to)?;
let chord = to - from;
if chord.length() > st.trace.approx_tol_3d {
st.trace.dir = chord;
}
}
}
@@ -2464,14 +2461,12 @@ mod tests_c3 {
}
/// WR-603: two points, the first a tangent point, at uv `(0.2, 0.3)` and
/// `(0.6, 0.7)` of the first surface; the trace start direction with and without
/// `fixes.fillet_trace_from_tangent_point_toward_next`, and the chord between the
/// points on the first offset surface.
fn traced_from_tangent_first(fix: bool) -> (Vec3, Vec3) {
/// `(0.6, 0.7)` of the first surface; the trace start direction, and the chord
/// between the points on the first offset surface.
fn traced_from_tangent_first() -> (Vec3, Vec3) {
use crate::intersect::surface_intersector::p1::SrfIndex;
let mut k = Kernel::default();
k.fixes.fillet_trace_from_tangent_point_toward_next = fix;
let k = Kernel::default();
let (a, b) = planes();
let mut fs = solver(&k, &a, &b);
let mut st = state(line(), [a, b]);
@@ -2493,14 +2488,12 @@ mod tests_c3 {
}
#[test]
fn trace_from_a_tangent_first_point_starts_toward_the_next_with_the_fix() {
let (dir, chord) = traced_from_tangent_first(true);
fn trace_from_a_tangent_first_point_starts_toward_the_next() {
let (dir, chord) = traced_from_tangent_first();
assert_eq!(dir, chord);
assert!(chord.length() > 0.1, "{chord:?}");
// upstream: the filleted edge's start tangent
let (dir, chord) = traced_from_tangent_first(false);
assert_eq!(dir, state(line(), planes().into()).pv_start[1]);
assert_ne!(dir, chord);
// not the filleted edge's start tangent, which upstream starts along
assert_ne!(dir, state(line(), planes().into()).pv_start[1]);
}
#[test]
@@ -3824,12 +3817,10 @@ fn c6_compute_point_values(
None
};
// :3664 central difference, or one-sided at a domain end. With
// `fixes.fillet_tangent_point_one_sided_step` (WR-595) a tangent point, where the
// intersection curve ends or branches, takes the forward step alone when it found
// one (`bFoundForw && bFoundBack && !bTangentPoint`).
let tangent_point = k.fixes.fillet_tangent_point_one_sided_step
&& point.point_type == IntersectionPointType::TangentPoint;
// :3664 central difference, or one-sided at a domain end. Not upstream (WR-595): a
// tangent point, where the intersection curve ends or branches, takes the forward
// step alone when it found one (`bFoundForw && bFoundBack && !bTangentPoint`).
let tangent_point = point.point_type == IntersectionPointType::TangentPoint;
let (step_pt_forw, step_pt_back) = match (forw_pts, back_pts) {
(Some((f0, f1)), Some((b0, b1))) if !tangent_point => {
let fw = (f0 + f1) / 2.0;
+1 -3
View File
@@ -341,9 +341,7 @@ impl FilletSolver {
fillets.with_rail_classification(rail, |pseudo, cc| {
context.classify(k, target, pseudo, face, cc)
})?;
if k.fixes.fillet_rail_rim_on_face {
join_intervals_on_face(fillets, rail)?;
}
join_intervals_on_face(fillets, rail)?;
finish_classification(
solver,
k,
+59
View File
@@ -36,6 +36,10 @@
//! `boolean_union_refuses_solid_leak` (own design, WR-619), a union whose step 6 would
//! delete a face between the infinite region and a solid region is refused with `SM_ERR`
//! (the face in the error) instead of returning a body whose solid merged into the outside.
//! Own design, not upstream: in step 6 a region that a deleted face joins to another, neither
//! with merge data (no face of the other body was copied into them, as when a union fills a
//! void exactly), takes the class a deleted face has on both sides ([`deleted_face_sides`]);
//! upstream leaves it the flag it had, so the filled void stayed void.
use super::p1::{self, MergeOptions};
use crate::error::boolean::BooleanFailure;
@@ -1216,6 +1220,58 @@ fn result_solid(operation: BooleanOperation, data: &MergeRegionData, was_solid:
}
}
/// What a regularized operation makes both sides of a face it deletes (§4.4 of
/// `docs/booleans.md`): solid for a union, void for an intersection or a difference. Each
/// deletes a face exactly where the result is the same on both sides: a union a face inside
/// the other body or a coincident pair bounding material on opposite sides, an intersection
/// a face outside it or such a pair, a difference a face inside it or a pair bounding
/// material on the same side. `None` for the operations whose deletions are not all one class.
fn deleted_face_sides(operation: BooleanOperation) -> Option<bool> {
match operation {
BooleanOperation::Union => Some(true),
BooleanOperation::Intersection | BooleanOperation::Difference => Some(false),
_ => None,
}
}
/// The regions of `primary` on either side of a face in `delete` that joins two of them
/// with no merge data on either, the infinite region aside. A region gets merge data when a
/// face of the other body is copied into it ([`remember_split`]); one whose boundary the
/// other body's faces all lie on gets none, as the void a union fills exactly, so the
/// deletion joins it to its neighbour with nothing to give the joined region its flag.
fn joined_without_data(
primary: &Brep,
intersector: &impl ManifoldIntersector,
attributes: &AttributeStore,
delete: &[FaceId],
) -> Result<Vec<RegionId>> {
let infinite = primary.infinite_region();
let recorded = |r: RegionId| {
merge_data(
attributes,
intersector.object_key(false, MergeEntity::Region(r)),
)
.is_some()
};
let mut joined = Vec::new();
for &f in delete {
let Some(fu) = face::faceuses(primary, f)?.0 else {
continue;
};
let (r1, r2) = region_pair(primary, fu)?;
if r1 == r2 || infinite == Some(r1) || infinite == Some(r2) || recorded(r1) || recorded(r2)
{
continue;
}
for r in [r1, r2] {
if !joined.contains(&r) {
joined.push(r);
}
}
}
Ok(joined)
}
/// Every region's enclosing shell first, the region's outer shell that
/// `SmBrep::AssertValid` test 12 takes it to be (the fork's fix, tangency-labs/smlib
/// `abastran/void-shell-check`), only with the opt-in
@@ -1485,6 +1541,7 @@ fn editing_stages(
return Err(Failure::new(OP, BooleanFailure::SolidLeaks { face: f }).into_error());
}
}
let joined = joined_without_data(primary, intersector, attributes, &faces.delete)?;
intersector.delete_faces(
kernels,
primary,
@@ -1499,6 +1556,8 @@ fn editing_stages(
) {
let solid = result_solid(operation, data, void(primary, r)? == 0);
primary.region_mut(r)?.set_is_void(i32::from(!solid));
} else if let (true, Some(solid)) = (joined.contains(&r), deleted_face_sides(operation)) {
primary.region_mut(r)?.set_is_void(i32::from(!solid));
}
}
if !infinite_current || void(primary, required(primary.infinite_region())?)? == 0 {
+204
View File
@@ -40,6 +40,11 @@ use super::{
use crate::topology::{EdgeId, FaceId};
/// Why an offset, shell, thicken or local operation is refused.
///
/// A caller outside the crate matches it with a wildcard: a reason it does not name yet still
/// has its class ([`OffsetFailure::kind`]) and says whether it is a phrase
/// ([`OffsetFailure::is_phrase`]) or a defect ([`OffsetFailure::is_defect`]), which every reason
/// must answer here.
#[derive(Debug, Clone, Copy, PartialEq)]
#[non_exhaustive]
pub enum OffsetFailure {
@@ -289,6 +294,62 @@ impl OffsetFailure {
)
}
/// True for a phrase cause ([`OffsetFailure::Failed`], [`OffsetFailure::Missing`],
/// [`OffsetFailure::InvalidValue`], [`OffsetFailure::Inconsistent`]): a routine's failure
/// not yet given a cause of its own, told apart only by its words. Its class is its status's
/// kind; each moves to a cause of its own, as the blend's phrase causes do
/// ([`crate::error::blend::BlendFailure`]).
#[must_use]
pub const fn is_phrase(&self) -> bool {
match self {
Self::Folds { .. }
| Self::NotCertified { .. }
| Self::Collapses { .. }
| Self::WallsTouch { .. }
| Self::LoopsCross { .. }
| Self::EdgeVanishes { .. }
| Self::DistanceUnresolved { .. }
| Self::ToleranceUnresolved { .. }
| Self::SeamOpened { .. }
| Self::ExactCurveUncut
| Self::FacesApart { .. }
| Self::ReachUncertified { .. }
| Self::LoopGapUncertified { .. }
| Self::Unsound { .. } => false,
Self::Failed(_) | Self::Missing(_) | Self::InvalidValue(_) | Self::Inconsistent(_) => {
true
}
}
}
/// True for a failure that is the kernel's fault, not a fact of the input: a consistency
/// check of a routine's own data that did not hold ([`OffsetFailure::Inconsistent`]).
/// Report it. Said per cause, as its class ([`ErrorKind::Failed`]) does not say it, until
/// offset failures move onto a class of their own.
#[must_use]
pub const fn is_defect(&self) -> bool {
match self {
Self::Inconsistent(_) => true,
Self::Folds { .. }
| Self::NotCertified { .. }
| Self::Collapses { .. }
| Self::WallsTouch { .. }
| Self::LoopsCross { .. }
| Self::EdgeVanishes { .. }
| Self::DistanceUnresolved { .. }
| Self::ToleranceUnresolved { .. }
| Self::SeamOpened { .. }
| Self::ExactCurveUncut
| Self::FacesApart { .. }
| Self::ReachUncertified { .. }
| Self::LoopGapUncertified { .. }
| Self::Unsound { .. }
| Self::Failed(_)
| Self::Missing(_)
| Self::InvalidValue(_) => false,
}
}
/// The status the C++ reports for the refusal (difftest's comparison detail).
pub(crate) const fn comparison_status(&self) -> i32 {
match self {
@@ -659,6 +720,149 @@ mod tests {
assert_eq!(SolidError::sm_err("offset::op").offset_failure(), None);
}
/// Every offset failure's class, and whether it is a phrase (a routine's failure not yet
/// given a cause of its own) or a defect of the kernel, pinned. A new reason must answer
/// all three (`kind`, `is_phrase` and `is_defect` match exhaustively) and adds its arm to
/// `row` and its row here, so a caller outside the crate, which has to match with a
/// wildcard, still gets its class and never only its text (#15035).
#[test]
fn every_offset_failure_keeps_its_class() {
use OffsetFailure as O;
let face = FaceId::from_index(1);
// (the failure, its class, a phrase, a defect)
let pinned = [
(
O::Folds {
face,
distance: 2.0,
largest: 1.0,
},
ErrorKind::OffsetFolds,
false,
false,
),
(
O::NotCertified {
face,
distance: 2.0,
},
ErrorKind::OffsetFolds,
false,
false,
),
(
O::Collapses {
face,
distance: -5.0,
radius: 5.0,
},
ErrorKind::OffsetCollapses,
false,
false,
),
(
O::WallsTouch {
face: None,
contact: WallContact::Touch,
},
ErrorKind::OffsetWallsTouch,
false,
false,
),
(
O::LoopsCross {
face: None,
crossing: LoopCrossing::SharesEdge,
},
ErrorKind::OffsetLoopsCross,
false,
false,
),
(
O::EdgeVanishes {
edge: None,
face: None,
},
ErrorKind::OffsetEdgeVanishes,
false,
false,
),
(
O::DistanceUnresolved {
distance: 1e-9,
resolution: 1e-6,
},
ErrorKind::InvalidInput,
false,
false,
),
(
O::ToleranceUnresolved { tolerance: 1e-13 },
ErrorKind::InvalidInput,
false,
false,
),
(O::SeamOpened { face }, ErrorKind::Unsupported, false, false),
(O::ExactCurveUncut, ErrorKind::Unsupported, false, false),
(O::FacesApart { face }, ErrorKind::Unsupported, false, false),
(
O::ReachUncertified { face },
ErrorKind::Unsupported,
false,
false,
),
(
O::LoopGapUncertified { face: None },
ErrorKind::Unsupported,
false,
false,
),
(
O::Unsound {
what: Unsoundness::NotManifoldSolid,
},
ErrorKind::Unsupported,
false,
false,
),
(O::Failed("x"), ErrorKind::Failed, true, false),
(O::Missing("x"), ErrorKind::Missing, true, false),
(O::InvalidValue("x"), ErrorKind::InvalidInput, true, false),
// a consistency check of the kernel's own data that did not hold: its fault
(O::Inconsistent("x"), ErrorKind::Failed, true, true),
];
let row = |o: &O| -> usize {
match o {
O::Folds { .. } => 0,
O::NotCertified { .. } => 1,
O::Collapses { .. } => 2,
O::WallsTouch { .. } => 3,
O::LoopsCross { .. } => 4,
O::EdgeVanishes { .. } => 5,
O::DistanceUnresolved { .. } => 6,
O::ToleranceUnresolved { .. } => 7,
O::SeamOpened { .. } => 8,
O::ExactCurveUncut => 9,
O::FacesApart { .. } => 10,
O::ReachUncertified { .. } => 11,
O::LoopGapUncertified { .. } => 12,
O::Unsound { .. } => 13,
O::Failed(_) => 14,
O::Missing(_) => 15,
O::InvalidValue(_) => 16,
O::Inconsistent(_) => 17,
}
};
for (i, (o, class, phrase, defect)) in pinned.iter().enumerate() {
assert_eq!(row(o), i, "{o:?} is listed once, in order");
assert_eq!(o.kind(), *class, "{o:?}");
assert_eq!(o.is_phrase(), *phrase, "{o:?}");
assert_eq!(o.is_defect(), *defect, "{o:?}");
// a phrase is what replaced a bare status, and only a phrase
assert_eq!(o.replaces_upstream_status(), *phrase, "{o:?}");
}
}
/// The offset routines describe their failures: their library code builds no bare
/// `Upstream` status, except the files listed (with the bare sites each may still
/// hold) whose conversion waits on other open work.
+14 -18
View File
@@ -2082,24 +2082,21 @@ pub trait GlobalSolve<'t, O: SolutionObject + 't> {
// for every current solution
let num_sols = self.solver().solutions.len();
for ii in 0..num_sols {
let test = self.solver().solutions.get_at(ii)?;
// read in place: a copy per stored solution per insertion cost a memcpy each
let test = self.solver().solutions.at(ii)?;
// don't add duplicate solutions (SolValues within ZeroSqrt and ParamValues within ScaledZero)
let (pair, preferred) = self.identical_solutions(solution, &test)?;
let (pair, preferred) = self.identical_solutions(solution, test)?;
if pair == SolutionPairType::Identical {
// save the preferred solution if possible, else better solution - improve tolerances
// (keeping the stored one, `Second`, is writing it back unchanged: nothing to do)
let keep = match preferred {
Some(PreferredSolution::First) => Some(solution),
Some(PreferredSolution::Second) => Some(&test),
None if solution.start.solution_value.abs()
< test.start.solution_value.abs() =>
{
Some(solution)
}
None => None,
Some(PreferredSolution::First) => true,
Some(PreferredSolution::Second) => false,
None => solution.start.solution_value.abs() < test.start.solution_value.abs(),
};
if let Some(keep) = keep {
self.solver_mut().solutions.set_at(ii, keep)?;
if keep {
self.solver_mut().solutions.set_at(ii, solution)?;
}
return Ok(());
}
@@ -2253,10 +2250,11 @@ pub trait GlobalSolve<'t, O: SolutionObject + 't> {
// points contained in intervals get absorbed.
// disjoint intervals remain disjoint.
// point solutions remain distinct.
let mut min = s.solutions.get_at(k)?;
let mut max = s.solutions.get_at(j)?;
let combined =
combine_min_max(op, &mut min, &mut max, combine_contiguous_ranges)?;
// in place: combine_min_max changes the pair only on its split path,
// after every fallible step, so an error leaves both as they were, as
// the copies did
let (min, max) = s.solutions.pair_mut(k, j)?;
let combined = combine_min_max(op, min, max, combine_contiguous_ranges)?;
if let Some(combined) = combined {
s.solutions.set_at(k, &combined)?;
s.solutions.remove_at(j, 1);
@@ -2264,8 +2262,6 @@ pub trait GlobalSolve<'t, O: SolutionObject + 't> {
break;
}
// (the split of a range around a tight point is made in place)
s.solutions.set_at(k, &min)?;
s.solutions.set_at(j, &max)?;
j += 1;
}
+55
View File
@@ -1538,6 +1538,37 @@ impl<O: SolutionObject> SolutionArray<O> {
.ok_or_else(|| bad_index("geom::solution_array::SolutionArray::get_at"))
}
/// Solution `index`, borrowed (`GetAt` without the copy).
///
/// # Errors
///
/// [`SolidError::InvalidInput`] for an index past the size.
pub fn at(&self, index: usize) -> Result<&Solution<O>> {
self.data
.get(index)
.ok_or_else(|| bad_index("geom::solution_array::SolutionArray::at"))
}
/// Solutions `i` and `j` (`i < j`), both borrowed mutably.
///
/// # Errors
///
/// [`SolidError::InvalidInput`] unless `i < j` and `j` is within the size.
pub fn pair_mut(&mut self, i: usize, j: usize) -> Result<(&mut Solution<O>, &mut Solution<O>)> {
const OP: &str = "geom::solution_array::SolutionArray::pair_mut";
if i >= j {
return Err(bad_index(OP));
}
let (lo, hi) = self
.data
.split_at_mut_checked(j)
.ok_or_else(|| bad_index(OP))?;
match (lo.get_mut(i), hi.first_mut()) {
(Some(a), Some(b)) => Ok((a, b)),
_ => Err(bad_index(OP)),
}
}
/// Replaces solution `index` (`SetAt`).
///
/// # Errors
@@ -2232,4 +2263,28 @@ mod tests {
assert_eq!(r.solution_type, SolutionType::Unknown);
assert_eq!(r.start.parameters[0], 1.0);
}
/// `at` borrows what `get_at` copies; `pair_mut` hands out two distinct solutions and refuses
/// a pair out of order, the same index twice or an index past the size.
#[test]
fn borrowed_access() {
let mut a: SolutionArray<Obj> = SolutionArray::new();
a.add(&single(1.0, &[1.0]));
a.add(&single(2.0, &[2.0]));
a.add(&single(3.0, &[3.0]));
assert_eq!(*a.at(1).unwrap(), a.get_at(1).unwrap());
assert!(matches!(a.at(3), Err(SolidError::InvalidInput { .. })));
let (x, y) = a.pair_mut(0, 2).unwrap();
assert_eq!((x.start.solution_value, y.start.solution_value), (1.0, 3.0));
x.start.solution_value = 10.0;
y.start.solution_value = 30.0;
assert_eq!(a.get_at(0).unwrap().start.solution_value, 10.0);
assert_eq!(a.get_at(2).unwrap().start.solution_value, 30.0);
for (i, j) in [(1, 1), (2, 1), (0, 3)] {
assert!(matches!(
a.pair_mut(i, j),
Err(SolidError::InvalidInput { .. })
));
}
}
}
+1 -13
View File
@@ -459,8 +459,6 @@ pub struct UpstreamFixes {
pub curve_loop_normal_from_area: bool,
/// `CalcRailIntEdgeuse`'s retry rail search holds a variable round's radius only where its first search does (at a corner calculated as constant radius), so the rail misses the arc it would meet only at that radius and the corner cuts the round off against the body; needs `fillet_nx1_end_rib_global_merge` there (WR-300, #1023). `RailRailIntersect` holds the radii at their end values only there too (its callers `CalcRailIntRail` and `CalcRailRailInterpolation`'s cubic rail interpolant): held at any other corner, two variable rounds meeting at a box's corner met where the laws are smaller and the trace from there failed with 1001 (the fork's tangency-labs/smlib@465b62b, the rule of 1d06159).
pub fillet_retry_radius_as_first: bool,
/// `TestRollOver` joins a rail's neighbouring intervals on its face or the face's boundary before the rollover, so a rail along its face's rim does not fail "Unable to Process Rollover" (WR-301, #485).
pub fillet_rail_rim_on_face: bool,
/// The fillet executive refuses a blend whose rails leave the faces they roll on (`RailsFitTheirFaces`, no room) (WR-302, #1024/#841).
pub fillet_refuse_rails_leaving_faces: bool,
/// `create_fillet_brep` refuses blends that take the whole of a face they roll on (`FacesOutlastTheirBlends`, no room) (WR-320, #879).
@@ -477,8 +475,6 @@ pub struct UpstreamFixes {
pub fillet_corner_ball_center_from_axes: bool,
/// `SmFilletCorner::MakeVariableRadiusFillet` refuses to turn a four-sided corner into a variable-radius fillet when the new fillet's rail along the opposite fillet leaves that fillet's surface (more than ten times the corner's tolerance from its natural domain): the rail replaces the opposite edge, which bounds the opposite fillet's face, so that face would end off its own edge. The corner is then the patch on its exact edges. At a drafted pocket's top corners the rail ran 0.42 off the corner round at R3, 10 degrees (WR-645, #968).
pub fillet_corner_variable_radius_rail_on_opposite_fillet: bool,
/// `CalcTorusFilletGeom` takes the round's span as the turn from its start section to its end one, without taking `IsArc`'s start angle off it: an `SmCircle`'s start angle is measured from the circle's own frame (a boolean's circle framed on a cylinder's seam starts at 60 or 115 degrees), so the torus came out short by it and the end rib found no fillet surface at the far end (1001 at an Nx1 corner) (WR-568; the fork's tangency-labs/smlib@985faae).
pub fillet_torus_span_from_start_section: bool,
/// `CalcTorusFilletGeom` revolves a linear section into a cone (WR-421, #420).
pub fillet_chamfer_cone_on_arc: bool,
/// With `fillet_chamfer_cone_on_arc`, `CalcTorusFilletGeom` stores a linear section's revolution as the `SmCone` it is: the revolution's NURBS form is copied (`SmBSplineSurface::Copy`) and passed through `CopyAndAddAnalytics`, and when that gives a cone it is the fillet surface and the rails are made on it (as a straight edge's round is stored as its cylinder); anything else stays the surface of revolution. Without it a chamfer round a hole's rim is a surface of revolution, which reads back as a rational B-spline, not the cone it is (WR-572, #1503; the fork's tangency-labs/smlib@b5743ec).
@@ -487,11 +483,7 @@ pub struct UpstreamFixes {
pub corner_blend_coons_patch_tangent_to_sides: bool,
/// `SmFilletCorner::CreateCornerPatch` marks the patch sides that lie on a face of the body (a fillet edge with an original face) and `sm_CreateCoonsSrf` gives a corner between such a side and a blend's side the blend side's twist instead of the mean of the two: a bicubic Coons patch meets a side's cross-boundary derivative only where the corner twists are the other sides' own, so the mean tilted the patch off the face's tangent plane beside that side and it crossed the face (0.037 out of a bar's side face where the bar's top-edge round meets the concave round at a cylinder it runs tangent into, r 2). Corners between two face sides or two blend sides keep the mean (WR-613, #1836; the fork's tangency-labs/smlib@7fd9fb0 (commit not in fork history at 26cc451), from the issue's diff).
pub corner_patch_face_side_twist: bool,
/// A torus blend whose section centre is on the arc's axis turns rail points into the edge ends' half-planes (WR-422, #417).
pub fillet_torus_center_on_axis: bool,
/// `CalcTorusFilletGeom` moves a section's rail point that lies within the solver's tolerance (`GetThisApproxTol3d`) of the edge arc's axis onto the axis: it turns about the axis in a circle smaller than the tolerance, so the rail collapses to the axis's point and the surface of revolution is exactly singular there, where upstream left the point off the axis by the model's residues (1.2e-11 after a sketch's 1e-10) and the surface nearly singular, which `smlib_brep_check` refused (`SmSurface` #5, Bad Surf Pole Approximation; a round along an earlier round's end arc of the ball's own radius) (WR-592; the fork's tangency-labs/smlib@994f129). Whether the result then passes the check depends on the input's residues: on the bar `tests/wr592_torus_rail_on_axis.rs` rebuilds, the pinned SMLib with this hunk alone and the port both leave `SmSurfOfRevolution` #7 failing by roundoff, where the fork's own input passes (`plan/findings/blend.fillet_solver_p2--wr592-revolution-polar-start-angle.md`).
pub fillet_torus_rail_on_axis: bool,
/// The fillet solver decides whether a constant round is exact by one classification of its spine (the curve the ball's centre runs on) from its faces' kinds and placement, read as the natural quadrics they lie on (plane, sphere, circular cylinder or cone, an offset of one): a line (two planes; a plane or cylinder parallel to a cylinder's axis) is built as the cylinder, a circle about a common axis (coaxial quadrics, a plane square to the axis, a sphere's centre on it) as the section turned about that axis, a point (two cylinders as wide as the ball, axes meeting) as the ball's piece; every other round is traced. This replaces upstream's try-and-fall-through chain (`CanMakeAnalytic`, planes and cones only, then cylinder, torus and with `fillet_corner_ball_patch` the sphere, each deciding from the edge's shape) at `CalcFilletGeom` and at `ReCalcFilletGeom` (where a line round is rebuilt as its cylinder, not the cone), and a builder's failure is then an error, not a trace. So a round on a sphere is exact, and a circular round's frame comes from the faces' axis, not the edge's `IsArc` (WR-669; the fork's tangency-labs/smlib@b5f2867 and 7ac037e). The torus and ball builders' own flags still apply inside them: the fork's behaviour is this with `fillet_torus_span_from_start_section`, `fillet_chamfer_cone_on_arc`, `fillet_chamfer_cone_stored_as_cone`, `fillet_torus_center_on_axis`, `fillet_torus_rail_on_axis` and `fillet_corner_ball_center_from_axes` on (a chamfer round a circle is traced without `fillet_chamfer_cone_on_arc`). A conic spine (a plane with a cylinder or cone at any other angle; two cylinders or cones touching a common ball the same way round) is built as the exact rational surface of the ball's arcs, checked against the ball before it is kept (the fork's tangency-labs/smlib@0061c41 and 1b5f1e7); the fork's nodal-quartic rounds are classified but still traced here. A line round's cylinder is built on its exact spine, the line its offsets meet in, its rails on the faces' feet of it, and keeps that frame instead of the stations' centres and a frame refitted from its net (#2405, solidcore-native).
/// The fillet solver decides whether a constant round is exact by one classification of its spine (the curve the ball's centre runs on) from its faces' kinds and placement, read as the natural quadrics they lie on (plane, sphere, circular cylinder or cone, an offset of one): a line (two planes; a plane or cylinder parallel to a cylinder's axis) is built as the cylinder, a circle about a common axis (coaxial quadrics, a plane square to the axis, a sphere's centre on it) as the section turned about that axis, a point (two cylinders as wide as the ball, axes meeting) as the ball's piece; every other round is traced. This replaces upstream's try-and-fall-through chain (`CanMakeAnalytic`, planes and cones only, then cylinder, torus and with `fillet_corner_ball_patch` the sphere, each deciding from the edge's shape) at `CalcFilletGeom` and at `ReCalcFilletGeom` (where a line round is rebuilt as its cylinder, not the cone), and a builder's failure is then an error, not a trace. So a round on a sphere is exact, and a circular round's frame comes from the faces' axis, not the edge's `IsArc` (WR-669; the fork's tangency-labs/smlib@b5f2867 and 7ac037e). The torus and ball builders' own flags still apply inside them: the fork's behaviour is this with `fillet_chamfer_cone_on_arc`, `fillet_chamfer_cone_stored_as_cone` and `fillet_corner_ball_center_from_axes` on (a chamfer round a circle is traced without `fillet_chamfer_cone_on_arc`). A conic spine (a plane with a cylinder or cone at any other angle; two cylinders or cones touching a common ball the same way round) is built as the exact rational surface of the ball's arcs, checked against the ball before it is kept (the fork's tangency-labs/smlib@0061c41 and 1b5f1e7); the fork's nodal-quartic rounds are classified but still traced here. A line round's cylinder is built on its exact spine, the line its offsets meet in, its rails on the faces' feet of it, and keeps that frame instead of the stations' centres and a frame refitted from its net (#2405, solidcore-native).
pub fillet_exact_round_classes: bool,
/// `SmFilletExecutive::CreateFilletBrep` stitches the fillet body keeping a fillet's own edge where an edge of corner-patch faces only meets an edge of other fillet faces only (`SmFilletPatchStitchCallback`): an N-sided corner patch is bounded by its surfaces' isoparametric curves, the approximations it was fitted to, and the default choice between the two near-equal distances left one half of a set-back section the exact arc and the other the polynomial approximation, off the round (WR-546, #1377; the fork's tangency-labs/smlib@1072d1b).
pub fillet_patch_keeps_fillet_edges: bool,
@@ -1065,12 +1057,8 @@ pub struct UpstreamFixes {
pub imprint_reuses_coincident_face_edge: bool,
/// `SmFilletSolver::ComputePointValues` chooses its finite-difference step for each point (1e-3 at a tangent point, 1e-5 otherwise), not once per process as the function-local static `sdStaticStep`, so a blend does not depend on the blends before it ([`Kernel::fillet_point_step`] is then neither read nor set, and an existing latch is kept as it is) (ND-3, #1173; the fork's tangency-labs/smlib@9e17f64 (was 0a05860)).
pub fillet_point_step_per_point: bool,
/// `SmFilletSolver::CalcFilletGeom` starts a trace whose first through point (after the reversal for a tangent last point) is a tangent point toward the next through point, the chord between their points on the first offset surface (when longer than the tolerance), not along the filleted edge's end tangent: the offsets touch at a tangent point, and that tangent can be the line along which they touch to second order, so the first step lay on both offsets within tolerance, the trace missed the next point and ran round a cylinder (a bar's top edge rounded into a cylinder tangent to the bar's side, refused as fillet error 8) (WR-603; the fork's tangency-labs/smlib@68fb916).
pub fillet_trace_from_tangent_point_toward_next: bool,
/// `SmBSplineSurface::CopyAndAddAnalytics` keeps an exact copy where the recognised analytic surface does not give the same points at the same parameters (7 x 7 grid), so a face split by `MakeEdgeInFaceWithVU` keeps trims that still lie on its edges (WR-450, #387; the fork's tangency-labs/smlib@7af1987 (was 276b509)).
pub analytic_copy_keeps_parameterization: bool,
/// `SmFilletSolver::ComputePointValues` sets a tangent point's curve tangent from the forward finite-difference step alone (along the trace, the previous point's tangent), and from the backward step only when the forward one finds nothing, not from their central difference: the intersection curve ends or branches at a tangent point, and where the offset surfaces touch only to within the model's accuracy (a sketch's 1e-10 residues) the backward step lands on the near-tangent crossing beyond it, 18 degrees off the curve, so every step after the point failed the angle test and the trace crept on in steps down to 8e-10 (repeated rail control points, an internal pole) (WR-595; the fork's tangency-labs/smlib@f8fd83e). Use with `fillet_trace_from_tangent_point_toward_next`, as the fork has both: alone, the forward step from a trace's tangent first point runs along the edge's end tangent, and `Fillet/nurb_cone.smb` edge 8 at r 0.5, which upstream builds, fails.
pub fillet_tangent_point_one_sided_step: bool,
/// `SmFace::ComputePreciseProperties` scales the volume and moment error bounds by the body size (ten times the root of the estimated area, or the origin's distance when larger), not 1.0.1's tenth of it, so the integration converges at tight accuracies instead of returning `SM_ERR` (WR-472; the SolidCraft fork's tangency-labs/smlib@073d4740).
pub precise_properties_body_length_scale: bool,
/// `SmVertex::IsTopologicalVertex` ([`crate::topology::vertex::is_topological_vertex`])
+187
View File
@@ -13,6 +13,7 @@
//! | [`Transform::concat`] | `SmTransform::ConcatTransform` / `smgu_4x4Mult`|
//! | [`Transform::transform_point`] | `SmTransform::TransformPoint` |
//! | [`Transform::transform_vector`] | `SmTransform::TransformVector` |
//! | [`Transform::rotation`], [`Transform::uniform_scaling`], [`Transform::reflection`] | (not upstream) |
//! | [`EFF_ZERO`] etc. | `SM_EFF_ZERO` etc. |
//! | [`is_infinite_param`] | `SM_IS_INFINITE` |
//! | [`max2`], [`min2`], [`max3`], [`min3`], [`max4`], [`min4`] | `smos_Max`, `smos_Min`, `smos_3Max`, `smos_3Min`, `smos_4Max`, `smos_4Min` |
@@ -409,10 +410,196 @@ impl Transform {
}
}
/// The operation name of the refusals of the maps built from their numbers.
const OP_MAPS: &str = "math::Transform";
/// Whether every coordinate of `v` is finite.
fn finite3(v: Vec3) -> bool {
v.x.is_finite() && v.y.is_finite() && v.z.is_finite()
}
/// Not upstream: the rigid motions and scalings an application asks for by their numbers
/// (a turn about a line, a scale about a point, a mirror in a plane), built here so the
/// layers above the kernel only pass the numbers on. Each is in the row-vector convention
/// of [`Transform`]: rows 0 to 2 the images of x, y and z, row 3 the translation.
impl Transform {
/// The linear map whose rows are `x`, `y` and `z`, about the point `fixed`, which it
/// keeps: `p' = (p - fixed) A + fixed`, its translation `fixed - fixed A`.
fn about(fixed: Point3, x: Vec3, y: Vec3, z: Vec3) -> Self {
let image = x * fixed.x + y * fixed.y + z * fixed.z;
Self::from_axes(x, y, z, fixed - image)
}
/// The turn by `angle` radians, right-handed about `axis`, about the line through
/// `point` along `axis` (any length, made unit here): Rodrigues' rotation formula,
/// `R = c I + s [k]x + (1 - c) k kᵀ` with `k` the unit axis, `c` and `s` the cosine and
/// sine of the angle, transposed for the row-vector convention.
///
/// # Errors
///
/// `InvalidInput` for a point, axis or angle not finite, or an axis whose squared
/// length is below [`EFF_ZERO_SQ`].
pub fn rotation(point: Point3, axis: Vec3, angle: f64) -> crate::error::Result<Self> {
use crate::error::SolidError;
if !(finite3(point) && finite3(axis) && angle.is_finite()) {
return Err(SolidError::invalid_input(
OP_MAPS,
"a turn's numbers not finite",
));
}
let k = axis
.unitize()
.map_err(|_| SolidError::invalid_input(OP_MAPS, "a turn's axis of no length"))?;
let (s, c) = angle.sin_cos();
let v = 1.0 - c;
let (x, y, z) = (k.x, k.y, k.z);
Ok(Self::about(
point,
Vec3::new(c + x * x * v, x * y * v + z * s, x * z * v - y * s),
Vec3::new(x * y * v - z * s, c + y * y * v, y * z * v + x * s),
Vec3::new(x * z * v + y * s, y * z * v - x * s, c + z * z * v),
))
}
/// The scale by `factor` in every direction about `centre`, which it keeps. A negative
/// factor turns the scale through the centre as well.
///
/// # Errors
///
/// `InvalidInput` for a centre or factor not finite, or a factor of zero (a map with
/// no inverse).
pub fn uniform_scaling(centre: Point3, factor: f64) -> crate::error::Result<Self> {
use crate::error::SolidError;
if !(finite3(centre) && factor.is_finite()) || factor == 0.0 {
return Err(SolidError::invalid_input(
OP_MAPS,
"a scale's numbers not finite, or a factor of zero",
));
}
Ok(Self::about(
centre,
Vec3::new(factor, 0.0, 0.0),
Vec3::new(0.0, factor, 0.0),
Vec3::new(0.0, 0.0, factor),
))
}
/// The mirror in the plane through `origin` square to `normal` (any length, made unit
/// here): `p' = p - 2 ((p - origin)·n) n`, its linear part `I - 2 n nᵀ` (symmetric, so
/// the same in either convention) and its translation `2 (origin·n) n`. For a normal
/// along an axis every entry is exact, so the mirror twice is the identity exactly.
///
/// # Errors
///
/// `InvalidInput` for an origin or normal not finite, or a normal whose squared length
/// is below [`EFF_ZERO_SQ`].
pub fn reflection(origin: Point3, normal: Vec3) -> crate::error::Result<Self> {
use crate::error::SolidError;
if !(finite3(origin) && finite3(normal)) {
return Err(SolidError::invalid_input(
OP_MAPS,
"a mirror's numbers not finite",
));
}
let n = normal
.unitize()
.map_err(|_| SolidError::invalid_input(OP_MAPS, "a mirror's normal of no length"))?;
let row = |e: Vec3, ni: f64| e - n * (2.0 * ni);
Ok(Self::from_axes(
row(Vec3::X, n.x),
row(Vec3::Y, n.y),
row(Vec3::Z, n.z),
n * (2.0 * origin.dot(n)),
))
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The largest difference between two matrices' entries.
fn apart(a: &Transform, b: &Transform) -> f64 {
a.m.iter()
.flatten()
.zip(b.m.iter().flatten())
.map(|(x, y)| (x - y).abs())
.fold(0.0, f64::max)
}
#[test]
fn a_quarter_turn_about_z_takes_x_to_y_and_keeps_its_line() {
use crate::geom::certified::Verdict;
let q = core::f64::consts::FRAC_PI_2;
let turn = Transform::rotation(Point3::ZERO, Vec3::new(0.0, 0.0, 2.0), q).unwrap();
let want = Transform::from_axes(Vec3::Y, -Vec3::X, Vec3::Z, Point3::ZERO);
assert!(apart(&turn, &want) <= 1e-15, "{turn:?}");
// about the line through (10, 0, 0) along z: that point stays, (11, 0, 0) goes to
// (10, 1, 0)
let p = Point3::new(10.0, 0.0, 0.0);
let turn = Transform::rotation(p, Vec3::Z, q).unwrap();
assert!(turn.transform_point(p).distance(p) <= 1e-14);
let image = turn.transform_point(Point3::new(11.0, 0.0, 0.0));
assert!(
image.distance(Point3::new(10.0, 1.0, 0.0)) <= 1e-14,
"{image:?}"
);
// a turn about a skew axis keeps lengths and its axis
let axis = Vec3::new(1.0, 2.0, 2.0);
let turn = Transform::rotation(p, axis, 0.7).unwrap();
assert!(
turn.transform_point(p + axis * 3.0)
.distance(p + axis * 3.0)
<= 1e-12
);
for r in turn.row_lengths() {
assert!((r - 1.0).abs() <= 1e-14);
}
assert_eq!(turn.is_regular(1e-9), Verdict::Yes);
}
#[test]
fn a_scale_keeps_its_centre_and_a_mirror_twice_is_the_identity() {
let c = Point3::new(1.0, -2.0, 3.0);
let scale = Transform::uniform_scaling(c, 2.5).unwrap();
assert!(scale.transform_point(c).distance(c) <= 1e-14);
let p = scale.transform_point(c + Vec3::X);
assert!(p.distance(c + Vec3::X * 2.5) <= 1e-14, "{p:?}");
// the mirror in y = 5, an axis normal of any length: every entry exact
let mirror =
Transform::reflection(Point3::new(7.0, 5.0, -1.0), Vec3::new(0.0, 3.0, 0.0)).unwrap();
assert_eq!(
mirror.transform_point(Point3::new(1.0, 2.0, 3.0)),
Point3::new(1.0, 8.0, 3.0)
);
assert_eq!(mirror.concat(&mirror), Transform::IDENTITY);
// a slanted mirror keeps its plane's points and reverses its normal
let n = Vec3::new(1.0, 1.0, 0.0);
let mirror = Transform::reflection(c, n).unwrap();
assert!(mirror.transform_point(c).distance(c) <= 1e-14);
let back = mirror.transform_point(c + n);
assert!(back.distance(c - n) <= 1e-14, "{back:?}");
}
#[test]
fn maps_from_numbers_refuse_what_is_not_finite_or_has_no_size() {
let bad = |r: crate::error::Result<Transform>| matches!(r, Err(crate::error::SolidError::InvalidInput { operation, .. }) if operation == OP_MAPS);
assert!(bad(Transform::rotation(Point3::ZERO, Vec3::ZERO, 1.0)));
assert!(bad(Transform::rotation(Point3::ZERO, Vec3::Z, f64::NAN)));
assert!(bad(Transform::rotation(
Point3::new(f64::INFINITY, 0.0, 0.0),
Vec3::Z,
1.0
)));
assert!(bad(Transform::uniform_scaling(Point3::ZERO, 0.0)));
assert!(bad(Transform::uniform_scaling(Point3::ZERO, f64::NAN)));
assert!(bad(Transform::reflection(Point3::ZERO, Vec3::ZERO)));
assert!(bad(Transform::reflection(
Point3::ZERO,
Vec3::new(f64::NAN, 1.0, 0.0)
)));
}
#[test]
fn a_rigid_motion_is_regular_and_a_flattening_is_not() {
use crate::geom::certified::Verdict;
+1
View File
@@ -31,6 +31,7 @@ pub mod iges;
pub(crate) mod index;
pub mod knots;
pub mod math;
pub mod n_sided;
pub mod nurbs_crv;
pub mod nurbs_srf;
pub mod spiral;
+5 -5
View File
@@ -119,7 +119,7 @@ fn bad(reason: &'static str) -> SolidError {
/// equal is the polynomial curve on its points divided by that weight, exactly (a record
/// spline's kernel curve carries a weight of 1 on every point). Only unequal weights make it
/// rational.
fn nurb_of(c: &BSplineCurve) -> Result<Curve> {
pub(crate) fn nurb_of(c: &BSplineCurve) -> Result<Curve> {
let mut nurb = c
.nurb
.clone()
@@ -182,7 +182,7 @@ fn surface(net: Vec<Vec<Vec3>>, p: usize, q: usize, u: &[f64], v: &[f64]) -> Res
}
/// `c` moved by minus `o`, into the network's own frame.
fn moved(c: &Curve, o: CPoint) -> Result<Curve> {
pub(crate) fn moved(c: &Curve, o: CPoint) -> Result<Curve> {
let pw = c
.cpts()
.iter()
@@ -192,7 +192,7 @@ fn moved(c: &Curve, o: CPoint) -> Result<Curve> {
}
/// `s` moved by minus `o`, into the network's own frame.
fn moved_surface(s: &Surface, o: CPoint) -> Result<Surface> {
pub(crate) fn moved_surface(s: &Surface, o: CPoint) -> Result<Surface> {
let net = s
.rows()
.map(|row| {
@@ -206,7 +206,7 @@ fn moved_surface(s: &Surface, o: CPoint) -> Result<Surface> {
}
/// The largest coordinate of `points`.
fn largest<'a>(points: impl Iterator<Item = &'a CPoint>) -> f64 {
pub(crate) fn largest<'a>(points: impl Iterator<Item = &'a CPoint>) -> f64 {
points
.map(|c| c.x.abs().max(c.y.abs()).max(c.z.abs()))
.fold(0.0_f64, f64::max)
@@ -226,7 +226,7 @@ fn room<T>(count: usize) -> Result<Vec<T>> {
}
/// Whether `x` is at most `tol`; a NaN is not.
fn within(x: f64, tol: f64) -> bool {
pub(crate) fn within(x: f64, tol: f64) -> bool {
matches!(x.partial_cmp(&tol), Some(Ordering::Less | Ordering::Equal))
}
+311
View File
@@ -0,0 +1,311 @@
//! An n-sided patch bounded by curves meeting end to end round one loop, certified: the kernel's
//! fill (`N_FillNSidedHole`, [`BSplineSurface::create_n_sided_patch`]) splits each boundary
//! curve at its middle and fills the hole with N four-sided patches round a centre, each bounded
//! by halves of two boundary curves and two inner curves from their middles to the centre.
//!
//! Upstream: none (not upstream; solidcore-native). The fill is upstream's; the certificate is
//! this module's.
//!
//! | Rust | Upstream |
//! |---------------|----------|
//! | [`n_sided`] | none |
//!
//! What is certified, and what is not:
//! - **The boundary**: each half of each boundary curve against the patch side it is (the side
//! whose certified distance to it is least, either way round, both on `[0, 1]`):
//! [`certified_distance`], plus the rounding of extracting the side and splitting the curve by
//! knot insertion (`γ_(12 (d + 1))` of twice the largest coordinate each, a coordinate error
//! `e` moving a point by at most `2 e`), counted in the loop's own frame (minus its first
//! control point, each moved coordinate rounding once). The largest is returned.
//! - **The inner joins (G0)**: every patch side that is no boundary half against the nearest
//! side of another patch, certified the same way; the largest must be within the tolerance.
//! - **Tangency across the inner curves (G1)** is the fill's aim, within its angle tolerance,
//! and is **not** certified here.
//!
//! Deviations: none (no upstream counterpart). Non-rational curves only (a curve whose weights
//! are all one value is its polynomial form). No recursion.
use crate::error::{Result, SolidError};
use crate::nurbs::b_spline_curve::BSplineCurve;
use crate::nurbs::b_spline_surface::BSplineSurface;
use crate::nurbs::crv_basic::Curve;
use crate::nurbs::crv_deviation::certified_distance;
use crate::nurbs::geom::Interval;
use crate::nurbs::gordon::{largest, moved, moved_surface, nurb_of, within};
use crate::nurbs::math::gamma;
use crate::nurbs::srf_basic::{Direction, Surface};
use crate::nurbs::srf_tool::p1::extract_iso_crv;
const OP: &str = "nurbs::n_sided::n_sided";
/// The fewest curves of an n-sided loop.
pub const MIN_SIDES: usize = 3;
/// The most curves of an n-sided loop.
pub const MAX_SIDES: usize = 64;
/// The angle (degrees) within which the fill matches the cross-boundary derivatives it builds
/// across its inner curves: the fill's own tests' value.
const ANGLE_DEG: f64 = 1.0;
/// The N patches of an n-sided fill and its certificates.
#[derive(Debug, Clone)]
pub struct NSided {
/// The patches, one per boundary curve.
pub patches: Vec<BSplineSurface>,
/// The largest certified distance between a boundary curve's half and its patch side.
pub bound: f64,
/// The largest certified distance between the two patch sides of an inner join.
pub joins: f64,
}
/// Why a loop has no certified n-sided patch here.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum NSidedRefusal {
/// A curve is rational (its weights not all one value): the fill takes polynomial curves.
Rational,
/// A boundary curve is not proved within the tolerance of the patches: the bound.
PastTolerance {
/// The largest certified distance.
bound: f64,
},
/// Two patches are not proved to meet within the tolerance along an inner curve: the
/// largest certified gap.
Joins {
/// The largest certified gap between two patches' sides.
gap: f64,
},
}
fn bad(reason: &'static str) -> SolidError {
SolidError::invalid_input(OP, reason)
}
/// `c` on `[0, 1]`, as it runs or reversed.
fn on_unit(c: &Curve, reversed: bool) -> Result<Curve> {
let mut c = c.clone();
if reversed {
c.reverse()?;
}
c.reparam_to_interval(Interval::new(0.0, 1.0))?;
Ok(c)
}
/// The four sides of patch `s`, each on `[0, 1]`.
fn sides(s: &Surface) -> Result<[Curve; 4]> {
let b = s.parameter_bounds();
Ok([
on_unit(&extract_iso_crv(s, b.vb, Direction::U)?, false)?,
on_unit(&extract_iso_crv(s, b.vt, Direction::U)?, false)?,
on_unit(&extract_iso_crv(s, b.ul, Direction::V)?, false)?,
on_unit(&extract_iso_crv(s, b.ur, Direction::V)?, false)?,
])
}
/// The least certified distance from `c` (on `[0, 1]`) to `side` either way round.
fn nearest(c: &Curve, side: &Curve) -> Result<f64> {
let forward = certified_distance(c, side)?;
let backward = certified_distance(c, &on_unit(side, true)?)?;
Ok(forward.min(backward))
}
/// The n-sided fill of `curves` (each running on from the one before, the last back to the
/// first), its boundary and inner joins certified within `tol` (module docs).
///
/// # Errors
///
/// `InvalidInput` for fewer than [`MIN_SIDES`] or more than [`MAX_SIDES`] curves, a curve
/// without its NURB or `tol` not finite and positive; those of the fill (curves that do not
/// meet within its model tolerance, ...), the extraction, the split and the certificates.
pub fn n_sided(
curves: &[BSplineCurve],
tol: f64,
) -> Result<std::result::Result<NSided, NSidedRefusal>> {
if !(MIN_SIDES..=MAX_SIDES).contains(&curves.len()) {
return Err(bad("fewer than three or more than 64 curves"));
}
if !(tol.is_finite() && tol > 0.0) {
return Err(bad("a tolerance not finite and positive"));
}
let nurbs: Vec<Curve> = curves.iter().map(nurb_of).collect::<Result<_>>()?;
if nurbs.iter().any(Curve::is_rational) {
return Ok(Err(NSidedRefusal::Rational));
}
// the fill, on copies of the polynomial curves (it changes its inputs)
let mut bounding: Vec<BSplineCurve> = nurbs
.iter()
.map(|c| BSplineCurve::from_gw_nurb(3, c))
.collect();
let mut across: Vec<Option<BSplineCurve>> = bounding.iter().map(|_| None).collect();
let patches =
BSplineSurface::create_n_sided_patch(&mut bounding, &mut across, ANGLE_DEG, None)?;
// the loop's own frame
let origin = nurbs
.first()
.and_then(|c| c.cpts().first().copied())
.ok_or_else(|| bad("a curve without points"))?;
let curves_local: Vec<Curve> = nurbs
.iter()
.map(|c| moved(c, origin))
.collect::<Result<_>>()?;
let mut local: Vec<Surface> = Vec::new();
for p in &patches {
let s = p
.nurb
.as_ref()
.ok_or_else(|| bad("a patch without its NURB"))?;
local.push(moved_surface(s, origin)?);
}
let size = largest(
curves_local
.iter()
.flat_map(Curve::cpts)
.chain(local.iter().flat_map(Surface::rows).flatten()),
);
let degree = curves_local
.iter()
.map(Curve::degree)
.chain(local.iter().map(|s| {
let (p, q) = s.degrees();
p.max(q)
}))
.max()
.unwrap_or(0);
// splitting a curve and extracting a side (knot insertion each), and the two moves
let rounding = 2.0 * (4.0 * gamma(12 * (degree + 1)) * size) + 2.0 * (2.0 * gamma(1) * size);
let all_sides: Vec<[Curve; 4]> = local.iter().map(sides).collect::<Result<_>>()?;
// which patch sides are boundary halves
let mut boundary_side = vec![[false; 4]; all_sides.len()];
let mut bound = 0.0_f64;
for c in &curves_local {
let (a, b) = c.param_bounds();
let (first, second) = c.split(0.5 * (a + b))?;
for half in [first, second] {
let half = on_unit(&half, false)?;
let mut best: Option<(f64, usize, usize)> = None;
for (i, four) in all_sides.iter().enumerate() {
for (j, side) in four.iter().enumerate() {
let d = nearest(&half, side)?;
if best.is_none_or(|(b, _, _)| d < b) {
best = Some((d, i, j));
}
}
}
let Some((d, i, j)) = best else {
return Err(bad("a fill without patches"));
};
if let Some(flag) = boundary_side.get_mut(i).and_then(|f| f.get_mut(j)) {
*flag = true;
}
bound = bound.max((d + rounding) * (1.0 + gamma(3)));
}
}
if !within(bound, tol) {
return Ok(Err(NSidedRefusal::PastTolerance { bound }));
}
// the inner joins: each side that is no boundary half against the nearest side of another
// patch
let mut joins = 0.0_f64;
for (i, four) in all_sides.iter().enumerate() {
for (j, side) in four.iter().enumerate() {
if boundary_side
.get(i)
.and_then(|f| f.get(j))
.copied()
.unwrap_or(true)
{
continue;
}
let mut best = f64::INFINITY;
for (other, theirs) in all_sides.iter().enumerate() {
if other == i {
continue;
}
for s in theirs {
best = best.min(nearest(side, s)?);
}
}
joins = joins.max((best + rounding) * (1.0 + gamma(3)));
}
}
if !within(joins, tol) {
return Ok(Err(NSidedRefusal::Joins { gap: joins }));
}
Ok(Ok(NSided {
patches,
bound,
joins,
}))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::math::Vec3;
use crate::nurbs::math::CPoint;
/// The polynomial curve of degree `p` on `points` with clamped uniform knots.
fn spline(points: &[[f64; 3]], p: usize) -> BSplineCurve {
let n = points.len();
let mut knots = vec![0.0; p + 1];
for i in 1..n - p {
knots.push(i as f64 / (n - p) as f64);
}
knots.extend(vec![1.0; p + 1]);
let pw = points
.iter()
.map(|q| CPoint::from_point(Vec3::new(q[0], q[1], q[2])))
.collect();
BSplineCurve::from_gw_nurb(3, &Curve::new(pw, p, knots).unwrap())
}
/// The corners of a regular pentagon of radius 10 in z = 0.
fn pentagon() -> Vec<[f64; 3]> {
(0..5)
.map(|i| {
let a = std::f64::consts::TAU * f64::from(i) / 5.0;
[10.0 * a.cos(), 10.0 * a.sin(), 0.0]
})
.collect()
}
#[test]
fn a_pentagon_of_lines_is_filled_flat() {
let p = pentagon();
let lines: Vec<BSplineCurve> = (0..5).map(|i| spline(&[p[i], p[(i + 1) % 5]], 1)).collect();
let f = n_sided(&lines, 1e-6).unwrap().unwrap();
assert_eq!(f.patches.len(), 5);
assert!(
f.bound <= 1e-9 && f.joins <= 1e-9,
"{} {}",
f.bound,
f.joins
);
// planar data, planar patches: every control point in z = 0
for s in &f.patches {
for c in s.nurb.as_ref().unwrap().rows().flatten() {
assert!(c.z.abs() < 1e-9, "{c:?}");
}
}
}
#[test]
fn rational_curves_are_refused() {
let p = pentagon();
let mut curves: Vec<BSplineCurve> =
(0..5).map(|i| spline(&[p[i], p[(i + 1) % 5]], 1)).collect();
let pw = vec![
CPoint::new(p[0][0], p[0][1], 0.0, 1.0),
CPoint::new(2.0 * p[1][0], 2.0 * p[1][1], 0.0, 2.0),
];
curves[0] =
BSplineCurve::from_gw_nurb(3, &Curve::new(pw, 1, vec![0.0, 0.0, 1.0, 1.0]).unwrap());
assert_eq!(
n_sided(&curves, 1e-6).unwrap().unwrap_err(),
NSidedRefusal::Rational
);
}
}
+116 -1
View File
@@ -42,6 +42,8 @@
//! | [`Surface::average_len`], [`AverageLen`] | `N_SrfGetAverageLen` |
//! | [`Direction`] | `NL_UDIR` / `NL_VDIR` / `NL_UVDIR` |
//! | [`WEIGHT_MIN`], [`WEIGHT_MAX`], [`WEIGHT_TOL`] | `NL_WMIN`, `NL_WMAX`, `NL_WTOL` |
//! | [`Surface::displace_cpts`] | none |
//! | [`falloff_weight`], [`FalloffShape`] | none |
//!
//! Deviations:
//! - [`Surface`] owns its control net and knot arrays (upstream points at caller- or
@@ -92,7 +94,7 @@ use crate::nurbs::geom::{
dist_pt_pt, dist_signed_pt_plane, plane_pt_normal_to_implicit, Rectangle,
};
use crate::nurbs::knots::{KnotVector, MAX_DEGREE};
use crate::nurbs::math::{combine2_pts, combine4_pts, CPoint, MODEL_TOL, NOW};
use crate::nurbs::math::{combine2_pts, combine4_pts, CPoint, MODEL_TOL, NOW, NOZ};
/// Minimum valid weight (`NL_WMIN`).
pub const WEIGHT_MIN: f64 = 1.0e-03;
@@ -107,6 +109,7 @@ const OP_NEW: &str = "nurbs::srf_basic::Surface::new";
const MIXED_RATIONALITY: &str = "control points mix rational and non-rational";
const OP_WITH_INDICES: &str = "nurbs::srf_basic::Surface::with_indices";
const OP_CPT: &str = "nurbs::srf_basic::Surface::cpt";
const OP_DISPLACE: &str = "nurbs::srf_basic::Surface::displace_cpts";
const OP_SET_CPTS: &str = "nurbs::srf_basic::Surface::set_cpts_and_knots";
const OP_SET_SIZE: &str = "nurbs::srf_basic::Surface::set_size_indices";
const OP_COUNTS: &str = "nurbs::srf_basic::Surface::counts_are_valid";
@@ -449,6 +452,45 @@ impl Surface {
self.rows().map(<[CPoint]>::to_vec).collect()
}
/// Moves control points by fractions of one displacement (not upstream): each
/// `((i, j), t)` of `moves` moves the Euclidean point of `Pw[i][j]` by `t * delta`, its
/// weight kept (a rational point's weighted coordinates move by `w t delta`). A point
/// listed twice moves by the sum of its fractions. All or nothing: on a refusal the net
/// is unchanged.
///
/// # Errors
///
/// `InvalidInput` for an index past the net, a fraction or `delta` not finite, a 2D net
/// (no `z`), or a moved point not finite.
pub fn displace_cpts(&mut self, moves: &[((usize, usize), f64)], delta: Vec3) -> Result<()> {
if !(delta.x.is_finite() && delta.y.is_finite() && delta.z.is_finite()) {
return Err(invalid(OP_DISPLACE, "a displacement not finite"));
}
let mut next = self.clone();
for &((i, j), t) in moves {
if !t.is_finite() {
return Err(invalid(OP_DISPLACE, "a fraction not finite"));
}
let c = next.cpt_mut(i, j)?;
if c.z == NOZ {
return Err(invalid(OP_DISPLACE, "a 2D control net"));
}
let w = if c.w == NOW { 1.0 } else { c.w };
let moved = CPoint {
x: c.x + w * t * delta.x,
y: c.y + w * t * delta.y,
z: c.z + w * t * delta.z,
w: c.w,
};
if !(moved.x.is_finite() && moved.y.is_finite() && moved.z.is_finite()) {
return Err(invalid(OP_DISPLACE, "a moved point not finite"));
}
*c = moved;
}
*self = next;
Ok(())
}
/// The u knots `U[0..=r]` (`N_SrfGetKnots`).
#[must_use]
pub fn u_knots(&self) -> &[f64] {
@@ -1095,6 +1137,31 @@ fn rescale(u: &mut [f64], r: usize, p: usize, lo: f64, hi: f64) -> Result<()> {
Ok(())
}
/// How a soft move's share falls with a control point's index distance from the moved points
/// (not upstream), with `s = d / (radius + 1)` for a point `d` index steps away.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FalloffShape {
/// `1 - s`.
Linear,
/// `1 - (3 s² - 2 s³)`: flat at the moved points and at the radius's edge.
Smooth,
}
/// The fraction of a soft move ([`Surface::displace_cpts`]) taken by a control point `d` index
/// steps from the nearest moved point (not upstream): 1 at `d = 0`, falling by `shape` with
/// `s = d / (radius + 1)`, and 0 past `radius`.
#[must_use]
pub fn falloff_weight(shape: FalloffShape, radius: u32, d: u32) -> f64 {
if d > radius {
return 0.0;
}
let s = f64::from(d) / (f64::from(radius) + 1.0);
match shape {
FalloffShape::Linear => 1.0 - s,
FalloffShape::Smooth => 1.0 - s * s * (3.0 - 2.0 * s),
}
}
/// Makes the surfaces rationality-compatible (`N_MakeSrfsRatCompatible`): when any
/// is rational, every non-rational one gets weights `1.0`.
pub fn make_rat_compatible(surfaces: &mut [Surface]) {
@@ -1223,6 +1290,54 @@ mod tests {
.unwrap()
}
#[test]
fn falloff_is_one_at_the_moved_points_and_zero_past_the_radius() {
for shape in [FalloffShape::Linear, FalloffShape::Smooth] {
assert_eq!(falloff_weight(shape, 0, 0), 1.0);
assert_eq!(falloff_weight(shape, 0, 1), 0.0);
assert_eq!(falloff_weight(shape, 3, 0), 1.0);
assert_eq!(falloff_weight(shape, 3, 4), 0.0);
assert_eq!(falloff_weight(shape, 3, u32::MAX), 0.0);
let w: Vec<f64> = (0..=3).map(|d| falloff_weight(shape, 3, d)).collect();
assert!(w.windows(2).all(|p| p[1] < p[0]), "{shape:?}: {w:?}");
}
// s = 1/4 at one step of radius 3
assert_eq!(falloff_weight(FalloffShape::Linear, 3, 1), 0.75);
assert_eq!(
falloff_weight(FalloffShape::Smooth, 3, 1),
1.0 - 0.0625 * 2.5
);
}
#[test]
fn displaced_points_move_by_their_fraction_of_the_delta() {
let mut s = bilinear();
let d = Vec3::new(0.0, 0.0, 2.0);
// a point listed twice moves by the sum of its fractions
s.displace_cpts(&[((1, 1), 0.5), ((0, 0), 1.0), ((1, 1), 0.25)], d)
.unwrap();
assert_eq!(s.cpt(0, 0).unwrap(), CPoint::new(0.0, 0.0, 2.0, NOW));
assert_eq!(s.cpt(1, 1).unwrap(), CPoint::new(1.0, 1.0, 1.5, NOW));
assert_eq!(s.cpt(0, 1).unwrap(), CPoint::new(0.0, 1.0, 0.0, NOW));
assert_eq!(s.cpt(1, 0).unwrap(), CPoint::new(1.0, 0.0, 0.0, NOW));
// a rational point keeps its weight; its weighted coordinates move by w t delta
let mut r = bilinear();
r.non_rat_to_rat();
*r.cpt_mut(1, 0).unwrap() = CPoint::new(2.0, 0.0, 0.0, 2.0);
r.displace_cpts(&[((1, 0), 1.0)], d).unwrap();
assert_eq!(r.cpt(1, 0).unwrap(), CPoint::new(2.0, 0.0, 4.0, 2.0));
// all or nothing: a bad move after a good one leaves the net as it was
let before = bilinear();
let mut t = before.clone();
assert!(t.displace_cpts(&[((0, 0), 1.0), ((2, 0), 1.0)], d).is_err());
assert!(t.displace_cpts(&[((0, 0), f64::NAN)], d).is_err());
assert!(t
.displace_cpts(&[((0, 0), 1.0)], Vec3::new(f64::INFINITY, 0.0, 0.0))
.is_err());
assert!(t.displace_cpts(&[((0, 0), f64::MAX)], d).is_err());
assert_eq!(t, before);
}
#[test]
fn construct_and_access() {
let s = bilinear();
@@ -219,6 +219,10 @@ pub struct OffsetExecutive {
/// face and by each of its face uses, recorded as the face is offset.
extension_reaches: BTreeMap<FaceId, f64>,
faceuse_extension_reaches: BTreeMap<FaceuseId, f64>,
/// Not upstream: the edge bounds the local operation's reaches have found, each edge's
/// taken again from here for its other face ([`super::reach::EdgeBounds`]); forgotten
/// when the local surfaces are set and when the relations are cleared.
edge_bounds: super::reach::EdgeBounds,
/// Not upstream: the faces of a local operation that move to surfaces of their own
/// (the others keep theirs), for their neighbours' reach
/// ([`super::reach::local_extension_reach`]).
@@ -289,6 +293,7 @@ impl OffsetExecutive {
extended_surfaces: BTreeMap::new(),
extension_reaches: BTreeMap::new(),
faceuse_extension_reaches: BTreeMap::new(),
edge_bounds: super::reach::EdgeBounds::default(),
moved_faces: Vec::new(),
kept_edges: BTreeSet::new(),
kept_edgeuses: BTreeSet::new(),
@@ -406,6 +411,7 @@ impl OffsetExecutive {
self.local_faces = faces.to_vec();
self.local_surfaces = surfaces;
self.moved_faces = moved.to_vec();
self.edge_bounds.clear();
}
/// Not upstream: in a local operation, records the edges of `original` that keep their
@@ -608,6 +614,7 @@ impl OffsetExecutive {
self.blend_to_faces.remove_all();
self.extension_reaches.clear();
self.faceuse_extension_reaches.clear();
self.edge_bounds.clear();
}
/// Records how far the surface of original face `face` must be extended for its
@@ -669,6 +676,7 @@ impl OffsetExecutive {
&moved,
&kept,
self.tangency_tol_radians,
&mut self.edge_bounds,
)?
// A new edge must cross each neighbouring surface's boundary, not sit on it:
// the mate and the intersection identify points within the body's tolerance,
@@ -4138,6 +4146,20 @@ mod tests {
assert_eq!(executive.kept_edgeuses.len(), 16);
}
/// The reach's edge bounds ([`super::super::reach::EdgeBounds`]) live for one local
/// operation: setting its surfaces forgets them, and so does clearing its relations.
#[test]
fn a_local_operations_edge_bounds_are_forgotten_with_its_surfaces() {
let mut executive = OffsetExecutive::new(OffsetOperation::LocalOperation, 0.125, 1.0e-6);
executive.edge_bounds.hold(EdgeId::from_index(3));
assert_eq!(executive.edge_bounds.held(), 1);
executive.set_local_surfaces(&[], Vec::new(), &[]);
assert_eq!(executive.edge_bounds.held(), 0);
executive.edge_bounds.hold(EdgeId::from_index(3));
executive.clear_relations();
assert_eq!(executive.edge_bounds.held(), 0);
}
#[test]
fn a_box_face_margin_is_its_offset_corner_reach() {
let mut kernel = Kernel::default();
@@ -56,6 +56,7 @@ use crate::surface::side::BoxSide;
use crate::surface::surface::{Surface, SurfaceKind};
use crate::topology::edgeuse::{p1 as eu, p3 as eu3};
use crate::topology::{edge, loopuse, Brep, EdgeuseId, FaceId, FaceuseId};
use std::collections::BTreeMap;
mod net;
@@ -841,7 +842,8 @@ impl Dv {
}
/// The trace of a plane in a normal section, or of two planes' line: the foot `x0` of the
/// edge point on it, its squared distance `foot2` from the point, and its unit direction.
/// edge point on it, its squared distance `foot2` from the point, and its direction `u`,
/// unit only within rounding (a point `x0 + τ u` is `|τ| |u|` from the foot, not `|τ|`).
#[derive(Debug, Clone, Copy)]
struct Trace {
x0: Dv,
@@ -1107,8 +1109,11 @@ fn plan_of(line: &Trace, q: &AlongLine, quadric: &Implicit, tolerance: f64) -> P
/// The squared distances from the edge point of the meetings `plan` takes (each a bound on
/// the reach: the meeting taken is the nearest), or `None` where the plan takes none or its
/// formula is not defined on these enclosures.
/// formula is not defined on these enclosures. A meeting at `τ` is `foot² + τ² |u|²` from
/// the point: `|u|²` enclosed as `u·u`, since the direction is unit only within rounding,
/// and an upper bound taken as if `|u| = 1` could sit an ulp below the distance.
fn planned(plan: &Plan, line: &Trace, q: &AlongLine) -> Option<Vec<Di>> {
let unit = line.u.dot(line.u);
match plan {
Plan::Roots {
positive,
@@ -1119,7 +1124,7 @@ fn planned(plan: &Plan, line: &Trace, q: &AlongLine) -> Option<Vec<Di>> {
.iter()
.map(|&i| {
let tau = taus.get(i).copied().flatten()?;
Some(line.foot2 + tau * tau)
Some(line.foot2 + tau * tau * unit)
})
.collect()
}
@@ -1128,7 +1133,7 @@ fn planned(plan: &Plan, line: &Trace, q: &AlongLine) -> Option<Vec<Di>> {
return None;
}
let tau = (Di::constant(0.0) - q.b) / (Di::constant(2.0) * q.a);
Some(vec![line.foot2 + tau * tau])
Some(vec![line.foot2 + tau * tau * unit])
}
Plan::Cover => {
if !q.a.excludes_zero() {
@@ -1146,7 +1151,7 @@ fn planned(plan: &Plan, line: &Trace, q: &AlongLine) -> Option<Vec<Di>> {
v: Ivl::from(tau),
d: Ivl::whole(),
};
Some(vec![line.foot2 + tau * tau])
Some(vec![line.foot2 + tau * tau * unit])
}
Plan::Apart | Plan::Unknown => None,
}
@@ -1403,6 +1408,118 @@ fn point_and_tangent(cn: &NurbsCurve, s: Ivl) -> Result<Option<(Dv, Dv)>> {
Ok(Some((Dv::of(d0, d1), Dv::of(d1, d2))))
}
/// What an edge's bound in [`local_extension_reach`] is of: a moved face's surface's distance
/// from the edge (the shift), or how far past the edge a plane and a quadric beside it meet
/// (the reach), by the faces whose surfaces they are, the pair in id order.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum EdgeQuantity {
Shift(Option<FaceId>),
Meeting(Option<FaceId>, Option<FaceId>),
}
/// A bound [`edge_interval_maximum`] found, with what it was found on, as bits
/// ([`bound_inputs`]).
#[derive(Debug, Clone)]
struct EdgeBound {
on: Vec<u64>,
bound: Option<(f64, bool)>,
}
/// Everything an edge's bound is a function of besides the quantity, as bits: the edge's
/// interval, the tolerance, the curve's degree, knots and homogeneous control points (each
/// list after its length), and the bits of the surfaces the quantity is of ([`exact_bits`]).
/// Two asks with the same bits ask the same function of the same numbers.
fn bound_inputs(
cn: &NurbsCurve,
interval: crate::geom::extent1d::Extent1d,
tolerance: f64,
surfaces: &[u64],
) -> Vec<u64> {
let count = |n: usize| u64::try_from(n).unwrap_or(u64::MAX);
let mut on = vec![
interval.min().to_bits(),
interval.max().to_bits(),
tolerance.to_bits(),
count(cn.p),
count(cn.n),
count(cn.u.len()),
];
on.extend(cn.u.iter().map(|x| x.to_bits()));
on.push(count(cn.pw.len()));
on.extend(cn.pw.iter().map(|x| x.to_bits()));
on.push(count(surfaces.len()));
on.extend_from_slice(surfaces);
on
}
/// The bits of a surface's exact class an edge's bound is of ([`EdgeBounds`]): a tag for
/// its kind, then its data; `None` for a class no edge's bound by pieces is of (a plane as
/// an [`Implicit`], a torus, a B-spline face), which is then bounded and not recorded.
fn exact_bits(c: &Exact) -> Option<Vec<u64>> {
let v = |p: Vec3| [p.x.to_bits(), p.y.to_bits(), p.z.to_bits()];
let mut bits = Vec::new();
match *c {
Exact::Plane(t) => {
bits.push(0);
bits.extend(v(t.point));
bits.extend(v(t.normal));
bits.extend(v(t.raw));
}
Exact::Quadric(Implicit::Cone {
origin,
axis,
radius,
tan_angle,
}) => {
bits.push(1);
bits.extend(v(origin));
bits.extend(v(axis));
bits.extend([radius.to_bits(), tan_angle.to_bits()]);
}
Exact::Quadric(Implicit::Sphere { centre, radius }) => {
bits.push(2);
bits.extend(v(centre));
bits.push(radius.to_bits());
}
Exact::Quadric(Implicit::Plane { .. } | Implicit::Torus { .. }) | Exact::Net(_) => {
return None;
}
}
Some(bits)
}
/// The edge bounds one local operation has found ([`local_extension_reach`]), by edge and
/// quantity: an edge is met from both its faces, and the second takes the first's bound
/// rather than bounding the edge again, the same function of the same curve, so the same
/// bits. Kept by the operation (the offset executive, from its local surfaces being set
/// until its relations are cleared), never across operations.
#[derive(Debug, Clone, Default)]
pub struct EdgeBounds(BTreeMap<(crate::topology::EdgeId, EdgeQuantity), EdgeBound>);
impl EdgeBounds {
/// Forgets every bound.
pub fn clear(&mut self) {
self.0.clear();
}
}
#[cfg(test)]
impl EdgeBounds {
/// How many bounds are held.
pub(super) fn held(&self) -> usize {
self.0.len()
}
/// Holds a bound for edge `e` (its shift by no face, on no inputs).
pub(super) fn hold(&mut self, e: crate::topology::EdgeId) {
let bound = EdgeBound {
on: Vec::new(),
bound: None,
};
self.0.insert((e, EdgeQuantity::Shift(None)), bound);
}
}
/// A piece of an edge's parameters `[t0, t1]` with a certified upper bound on it, ordered by
/// that bound (the most is taken first in [`edge_interval_maximum`]); `halvable` false
/// where a halving left a half undecided, so the piece keeps its own bound.
@@ -1469,21 +1586,52 @@ fn spans(cn: &NurbsCurve, interval: crate::geom::extent1d::Extent1d) -> Vec<(f64
/// spend them on the spans taken first and leave a later one undecided, refusing the whole
/// edge, though every piece was bounded or apart within the caps. Taking the largest bound
/// first spends them where the answer is.
///
/// Each edge is met from both its faces, and the second asks for the same bound: `bounds`
/// holds what the local operation has found ([`EdgeBounds`]), and a bound is taken from it
/// for the same quantity `of` where everything it is a function of has the same bits
/// ([`bound_inputs`]: the curve, interval, tolerance, and `surfaces`, the bits of the
/// surfaces `f` is of). With no `surfaces` the edge is bounded and nothing recorded.
fn edge_interval_maximum(
b: &Brep,
e: crate::topology::EdgeId,
f: &dyn Fn((Dv, Dv), (Dv, Dv), Ivl) -> PieceBound,
tolerance: f64,
bounds: &mut EdgeBounds,
of: EdgeQuantity,
surfaces: Option<&[u64]>,
) -> Result<Option<(f64, bool)>> {
let Some(cn) = edge_nurbs(b, e)? else {
return Ok(None);
};
let mut undecided = spans(&cn, edge::interval(b, e)?);
let interval = edge::interval(b, e)?;
let Some(surfaces) = surfaces else {
return interval_maximum(&cn, interval, f, tolerance);
};
let on = bound_inputs(&cn, interval, tolerance, surfaces);
if let Some(found) = bounds.0.get(&(e, of)) {
if found.on == on {
return Ok(found.bound);
}
}
let bound = interval_maximum(&cn, interval, f, tolerance)?;
bounds.0.insert((e, of), EdgeBound { on, bound });
Ok(bound)
}
/// [`edge_interval_maximum`]'s bound over `interval` of the curve `cn`.
fn interval_maximum(
cn: &NurbsCurve,
interval: crate::geom::extent1d::Extent1d,
f: &dyn Fn((Dv, Dv), (Dv, Dv), Ivl) -> PieceBound,
tolerance: f64,
) -> Result<Option<(f64, bool)>> {
let mut undecided = spans(cn, interval);
// the bound on a piece; a bound that is not finite bounds nothing
let bound_of = |t0: f64, t1: f64| -> Result<PieceBound> {
let tm = 0.5 * (t0 + t1);
let over = point_and_tangent(&cn, Ivl::new(t0, t1))?;
let mid = point_and_tangent(&cn, Ivl::from(tm))?;
let over = point_and_tangent(cn, Ivl::new(t0, t1))?;
let mid = point_and_tangent(cn, Ivl::from(tm))?;
Ok(match (over, mid) {
(Some(over), Some(mid)) => match f(over, mid, Ivl::checked(t0 - tm, t1 - tm)) {
PieceBound::Value { upper, .. } if !upper.is_finite() => PieceBound::Unknown,
@@ -1688,18 +1836,14 @@ fn straight_edges_between(
}
/// Where the line of a kept straight edge ([`StraightEdge`]) meets the plane `(o, ν)`,
/// from the vertex `p`: the line's point `x0 + τ u` (`x0` the foot of `p`, `u` the unit
/// direction, `τ = (o − x0)·ν / (u·ν)`) at `√(|x0 − p|² + τ²)`, raised by the curve's
/// distance from the line over the sine of their angle (`dev |ν| / |u·ν|`); nowhere near
/// where the line is within `tangent_sine` of parallel to the plane (as two planes'
/// line, [`planes_line`]); not decided where the enclosures do not settle it.
/// from the vertex `p`: [`line_meets_plane`] on the line from the edge's first control
/// point along its unit direction (unit within rounding).
fn straight_edge_meets_plane(
p: Vec3,
line: StraightEdge,
plane: Tangent,
tangent_sine: f64,
) -> CornerMeeting {
let pv = Dv::constant(p);
let a = Dv::constant(line.a);
let d = Dv::constant(line.b).sub(a);
let dd = d.dot(d);
@@ -1707,6 +1851,24 @@ fn straight_edge_meets_plane(
return CornerMeeting::Uncertified;
}
let u = d.scale(Di::constant(1.0) / dd.sqrt());
line_meets_plane(Dv::constant(p), a, u, line.dev, plane, tangent_sine)
}
/// Where the line `a + s u` meets the plane `(o, ν)`, from `p`: its point `M = x0 + τ u`
/// (`x0 = a + u (u·(p − a))`, the foot of `p` where `|u| = 1`, a point of the line in any
/// case; `τ = (o − x0)·ν / (u·ν)`) at `|M − p|`, enclosed as it is, so it holds whatever
/// `|u|` (the direction is unit only within rounding), raised by the curve's distance `dev`
/// from the line over the sine of their angle, `|u·ν| / (|u| |ν|)`; nowhere near where the
/// line is within `tangent_sine` of parallel to the plane (as two planes' line,
/// [`planes_line`]); not decided where the enclosures do not settle it.
fn line_meets_plane(
pv: Dv,
a: Dv,
u: Dv,
dev: f64,
plane: Tangent,
tangent_sine: f64,
) -> CornerMeeting {
let x0 = a.add(u.scale(pv.sub(a).dot(u)));
let nu = Dv::constant(plane.raw);
let nn = nu.dot(nu).sqrt();
@@ -1718,9 +1880,9 @@ fn straight_edge_meets_plane(
return CornerMeeting::Nowhere;
}
let tau = Dv::constant(plane.point).sub(x0).dot(nu) / un;
let foot = x0.sub(pv);
let far = (foot.dot(foot) + tau * tau).v.hi.max(0.0);
let slack = (Ivl::from(line.dev) * nn.v / Ivl::from(un.v.mig())).hi;
let to = x0.add(u.scale(tau)).sub(pv);
let far = to.dot(to).v.hi.max(0.0);
let slack = (Ivl::from(dev) * nn.v * u.dot(u).v.sqrt() / Ivl::from(un.v.mig())).hi;
let reach = round_up(round_up(sqrt(far)) + slack);
if reach.is_finite() {
CornerMeeting::Within(reach)
@@ -1869,6 +2031,9 @@ fn kept_straight_edge_corner(
/// left undecided past the caps, has no certified bound here:
/// [`OffsetFailure::ReachUncertified`].
///
/// An edge's bound by pieces is taken from `bounds` where the operation already found it from
/// the edge's other face ([`EdgeBounds`]), and recorded there otherwise.
///
/// # Errors
///
/// [`OffsetFailure::ReachUncertified`] as above; [`SolidError::InvalidInput`] for a
@@ -1882,6 +2047,7 @@ pub fn local_extension_reach(
moved: &[(FaceId, &Surface)],
kept: &[(FaceId, &Surface)],
tangency_tol_radians: f64,
bounds: &mut EdgeBounds,
) -> Result<Option<LocalReach>> {
let tangent_sine = tangency_tol_radians.abs().min(crate::math::PI / 2.0).sin();
let tolerance = b.tolerance();
@@ -1966,26 +2132,33 @@ pub fn local_extension_reach(
let hull = |f: &dyn Fn(Vec3) -> f64| -> Result<f64> {
edge_maximum(b, e, f, tolerance)?.ok_or_else(uncertified)
};
let pieces =
|f: &dyn Fn((Dv, Dv), (Dv, Dv), Ivl) -> PieceBound| -> Result<(f64, bool)> {
edge_interval_maximum(b, e, f, tolerance)?.ok_or_else(uncertified)
};
// a moved net's move is bounded by the meetings on it, below
let shift_of = |c: &Exact| -> Result<Option<f64>> {
match c {
Exact::Plane(t) => hull(&|x| plane_distance(x, *t)).map(Some),
Exact::Quadric(q) => {
let piece = |over: (Dv, Dv), mid: (Dv, Dv), dt: Ivl| {
quadric_distance_piece(over.0, mid.0, dt, q)
};
Ok(Some(pieces(&piece)?.0))
}
Exact::Net(_) => Ok(None),
}
let pieces = |f: &dyn Fn((Dv, Dv), (Dv, Dv), Ivl) -> PieceBound,
bounds: &mut EdgeBounds,
of: EdgeQuantity,
surfaces: Option<&[u64]>|
-> Result<(f64, bool)> {
edge_interval_maximum(b, e, f, tolerance, bounds, of, surfaces)?
.ok_or_else(uncertified)
};
// a moved net's move is bounded by the meetings on it, below
let shift_of =
|c: &Exact, face: Option<FaceId>, bounds: &mut EdgeBounds| -> Result<Option<f64>> {
match c {
Exact::Plane(t) => hull(&|x| plane_distance(x, *t)).map(Some),
Exact::Quadric(q) => {
let piece = |over: (Dv, Dv), mid: (Dv, Dv), dt: Ivl| {
quadric_distance_piece(over.0, mid.0, dt, q)
};
let surfaces = exact_bits(c);
let of = EdgeQuantity::Shift(face);
Ok(Some(pieces(&piece, bounds, of, surfaces.as_deref())?.0))
}
Exact::Net(_) => Ok(None),
}
};
let mut net_moved = false;
if own_moved {
match shift_of(&own)? {
match shift_of(&own, own_face, bounds)? {
Some(shift) => found.shift = found.shift.max(shift),
None => net_moved = true,
}
@@ -1998,7 +2171,7 @@ pub fn local_extension_reach(
}
let other = class_of(b, other_face)?;
if other_moved {
match shift_of(&other)? {
match shift_of(&other, other_face, bounds)? {
Some(shift) => found.shift = found.shift.max(shift),
None => net_moved = true,
}
@@ -2019,14 +2192,26 @@ pub fn local_extension_reach(
let piece = |over: (Dv, Dv), mid: (Dv, Dv), dt: Ivl| {
plane_quadric_piece(over, mid, dt, plane, q, tolerance)
};
pieces(&piece)?
let pair =
EdgeQuantity::Meeting(own_face.min(other_face), own_face.max(other_face));
// the plane's, then the quadric's: the same from either face
let surfaces = exact_bits(&Exact::Plane(plane))
.zip(exact_bits(&Exact::Quadric(*q)))
.map(|(mut a, c)| {
a.extend(c);
a
});
pieces(&piece, bounds, pair, surfaces.as_deref())?
}
(&Exact::Plane(plane), Exact::Net(n)) | (Exact::Net(n), &Exact::Plane(plane)) => {
let guess = std::cell::Cell::new(None);
let piece = |over: (Dv, Dv), mid: (Dv, Dv), dt: Ivl| {
plane_net_piece(over, mid, dt, plane, n, &guess)
};
pieces(&piece)?
// a net's bound is not recorded: it carries no bits to key it on
let pair =
EdgeQuantity::Meeting(own_face.min(other_face), own_face.max(other_face));
pieces(&piece, bounds, pair, None)?
}
(Exact::Quadric(_) | Exact::Net(_), Exact::Quadric(_) | Exact::Net(_)) => {
return Err(uncertified())
@@ -2469,9 +2654,10 @@ mod tests {
} else {
box_face(&brep, n).1
};
let found = local_extension_reach(&k, &brep, fu, &moved, &[], 1e-3)
.unwrap()
.unwrap();
let found =
local_extension_reach(&k, &brep, fu, &moved, &[], 1e-3, &mut EdgeBounds::default())
.unwrap()
.unwrap();
(found.reach, found.shift)
};
[at(Vec3::Z), at(-Vec3::Y), at(-Vec3::X), at(-Vec3::Z)]
@@ -2542,9 +2728,17 @@ mod tests {
n,
));
let moved = [(moved_face, &plane)];
let found = local_extension_reach(&k, &brep, across, &moved, &[], 1e-3)
.unwrap()
.unwrap();
let found = local_extension_reach(
&k,
&brep,
across,
&moved,
&[],
1e-3,
&mut EdgeBounds::default(),
)
.unwrap()
.unwrap();
let (reach, shift) = (found.reach, found.shift);
assert!((reach - delta * sqrt(1.5)).abs() < 1e-12, "{reach}");
assert!((shift - delta).abs() < 1e-12, "{shift}");
@@ -2557,7 +2751,9 @@ mod tests {
let mut kk = Kernel::default();
let (brep, faceuses) = box_faceuses(&mut kk, false);
for (_, fu) in faceuses {
let found = local_extension_reach(&k, &brep, fu, &[], &[], 1e-3).unwrap();
let found =
local_extension_reach(&k, &brep, fu, &[], &[], 1e-3, &mut EdgeBounds::default())
.unwrap();
assert_eq!(found, Some(LocalReach::default()));
}
}
@@ -2613,7 +2809,8 @@ mod tests {
kept: &[(FaceId, &Surface)],
) -> Option<OffsetFailure> {
let fu = faceuse_of(b, f);
let e = local_extension_reach(k, b, fu, moved, kept, 1e-3).unwrap_err();
let e = local_extension_reach(k, b, fu, moved, kept, 1e-3, &mut EdgeBounds::default())
.unwrap_err();
match e.failure().map(|f| &f.reason) {
Some(crate::error::Reason::Offset(why)) => Some(*why),
_ => None,
@@ -2622,9 +2819,17 @@ mod tests {
/// Each face's reach with `moved`.
fn reach_of(k: &Kernel, b: &Brep, f: FaceId, moved: &[(FaceId, &Surface)]) -> LocalReach {
local_extension_reach(k, b, faceuse_of(b, f), moved, &[], 1e-3)
.unwrap()
.unwrap()
local_extension_reach(
k,
b,
faceuse_of(b, f),
moved,
&[],
1e-3,
&mut EdgeBounds::default(),
)
.unwrap()
.unwrap()
}
/// `x` is a certified upper bound of `want` within the B-rep's tolerance.
@@ -2734,13 +2939,104 @@ mod tests {
};
let piece =
|over: (Dv, Dv), mid: (Dv, Dv), dt: Ivl| quadric_distance_piece(over.0, mid.0, dt, &q);
let (at_seam, apart) = edge_interval_maximum(&b, seam, &piece, b.tolerance())
.unwrap()
.unwrap();
let (at_seam, apart) = edge_interval_maximum(
&b,
seam,
&piece,
b.tolerance(),
&mut EdgeBounds::default(),
EdgeQuantity::Shift(None),
None,
)
.unwrap()
.unwrap();
assert!(bounds(&b, at_seam, 1.0), "{at_seam}");
assert!(!apart);
}
/// An edge's bound is taken from [`EdgeBounds`] only for the same quantity on the same
/// inputs: the seam of a cylinder r 1 against the cylinder r 2, its pieces counted. The
/// second ask gives the first's bits and bounds no piece; another surface, tolerance or
/// quantity bounds the edge again, as does an ask after a clear or with no surfaces.
#[test]
fn an_edge_bound_is_reused_only_on_the_same_inputs() {
let mut k = Kernel::default();
let (b, side, _, seam, wider) = seamed_cylinder(&mut k, 2.0);
let c = exact_class(&k, &wider).unwrap().unwrap();
let Exact::Quadric(q) = c else {
panic!("the cylinder r 2 is a quadric");
};
let calls = std::cell::Cell::new(0_usize);
let piece = |over: (Dv, Dv), mid: (Dv, Dv), dt: Ivl| {
calls.set(calls.get() + 1);
quadric_distance_piece(over.0, mid.0, dt, &q)
};
let ask = |bounds: &mut EdgeBounds, tol: f64, of: EdgeQuantity, on: Option<&[u64]>| {
let before = calls.get();
let found = edge_interval_maximum(&b, seam, &piece, tol, bounds, of, on)
.unwrap()
.unwrap();
(found.0.to_bits(), found.1, calls.get() - before)
};
let surfaces = exact_bits(&c).unwrap();
let mut other = surfaces.clone();
*other.last_mut().unwrap() ^= 1;
let (tol, shift) = (b.tolerance(), EdgeQuantity::Shift(Some(side)));
let mut held = EdgeBounds::default();
let first = ask(&mut held, tol, shift, Some(&surfaces));
assert!(first.2 > 0, "{first:?}");
assert_eq!(
ask(&mut held, tol, shift, Some(&surfaces)),
(first.0, first.1, 0)
);
assert!(ask(&mut held, tol, shift, Some(&other)).2 > 0);
assert_eq!(ask(&mut held, tol, shift, Some(&other)).2, 0);
assert!(ask(&mut held, 2.0 * tol, shift, Some(&other)).2 > 0);
let none = EdgeQuantity::Shift(None);
assert!(ask(&mut held, tol, none, Some(&surfaces)).2 > 0);
assert_eq!(
ask(&mut held, tol, none, Some(&surfaces)),
(first.0, first.1, 0)
);
held.clear();
assert_eq!(held.held(), 0);
assert_eq!(ask(&mut held, tol, none, Some(&surfaces)), first);
for _ in 0..2 {
assert_eq!(ask(&mut EdgeBounds::default(), tol, shift, None), first);
}
let mut unrecorded = EdgeBounds::default();
assert_eq!(ask(&mut unrecorded, tol, shift, None), first);
assert_eq!(unrecorded.held(), 0);
}
/// What a bound is recorded on ([`bound_inputs`]) tells curves apart by their bits: a
/// line from 0 to 1 against the same line with its first knot −0 (equal as numbers), a
/// control point moved, or a weight changed; the same curve gives the same bits.
#[test]
fn a_changed_curve_is_another_bound() {
let line = |u0: f64, x1: f64, w1: f64| NurbsCurve {
p: 1,
n: 2,
u: vec![u0, 0.0, 1.0, 1.0],
pw: vec![0.0, 0.0, 0.0, 1.0, x1 * w1, 0.0, 0.0, w1],
};
let interval = crate::geom::extent1d::Extent1d::from_raw(0.0, 1.0);
let on = |c: &NurbsCurve| bound_inputs(c, interval, 1e-5, &[7]);
let base = on(&line(0.0, 1.0, 1.0));
assert_eq!(on(&line(0.0, 1.0, 1.0)), base);
for other in [
line(-0.0, 1.0, 1.0),
line(0.0, 1.5, 1.0),
line(0.0, 1.0, 2.0),
] {
assert_ne!(on(&other), base, "{other:?}");
}
assert_ne!(
bound_inputs(&line(0.0, 1.0, 1.0), interval, 1e-5, &[8]),
base
);
}
/// The side of a cylinder r 1 given the coaxial cylinder one rounding wider: every
/// face is reached with no refusal, by a certified bound (the true meetings are a
/// rounding past the circles: the bound stays within the B-rep's tolerance of that
@@ -2753,9 +3049,17 @@ mod tests {
let (b, side, caps, _, wider) = seamed_cylinder(&mut k, r);
let moved = [(side, &wider)];
for f in [side, caps[0], caps[1]] {
let found = local_extension_reach(&k, &b, faceuse_of(&b, f), &moved, &[], 1e-3)
.unwrap()
.unwrap();
let found = local_extension_reach(
&k,
&b,
faceuse_of(&b, f),
&moved,
&[],
1e-3,
&mut EdgeBounds::default(),
)
.unwrap()
.unwrap();
assert!(found.reach >= 0.0 && found.reach < 1e-3, "{f:?}: {found:?}");
assert!(bounds(&b, found.shift, 0.0), "{f:?}: {found:?}");
assert!(found.apart.is_empty(), "{f:?}: {found:?}");
@@ -2806,9 +3110,10 @@ mod tests {
let slack = b.tolerance() + 1e-9;
for f in [bottom, round] {
let fu = faceuse_of(&b, f);
let found = local_extension_reach(&k, &b, fu, &moved, &[], 1e-3)
.unwrap()
.unwrap();
let found =
local_extension_reach(&k, &b, fu, &moved, &[], 1e-3, &mut EdgeBounds::default())
.unwrap()
.unwrap();
for (name, x) in [("reach", found.reach), ("move", found.shift)] {
assert!(
x >= want - 1e-9 && x <= want + slack,
@@ -2818,9 +3123,10 @@ mod tests {
assert!(found.apart.is_empty());
}
let fu = faceuse_of(&b, side);
let found = local_extension_reach(&k, &b, fu, &moved, &[], 1e-3)
.unwrap()
.unwrap();
let found =
local_extension_reach(&k, &b, fu, &moved, &[], 1e-3, &mut EdgeBounds::default())
.unwrap()
.unwrap();
assert!(found.reach <= slack && found.shift <= slack, "{found:?}");
}
@@ -2988,9 +3294,17 @@ mod tests {
));
let moved = [(piece, &raised)];
let at = |f: FaceId| {
local_extension_reach(&k, &b, faceuse_of(&b, f), &moved, &[], 1e-3)
.unwrap()
.unwrap()
local_extension_reach(
&k,
&b,
faceuse_of(&b, f),
&moved,
&[],
1e-3,
&mut EdgeBounds::default(),
)
.unwrap()
.unwrap()
};
let stays = at(twin);
assert_eq!(stays.apart.len(), 1, "{stays:?}");
@@ -3009,7 +3323,9 @@ mod tests {
let (b, _, _, _) = rounded_cube(&mut k);
for f in bp1::faces(&b, None).unwrap() {
let fu = faceuse_of(&b, f);
let found = local_extension_reach(&k, &b, fu, &[], &[], 1e-3).unwrap();
let found =
local_extension_reach(&k, &b, fu, &[], &[], 1e-3, &mut EdgeBounds::default())
.unwrap();
assert_eq!(found, Some(LocalReach::default()));
}
}
@@ -3041,9 +3357,17 @@ mod tests {
let moved = [(side, &into)];
let kept = [(round, &net)];
for f in [side, round, bottom] {
let found = local_extension_reach(&k, &b, faceuse_of(&b, f), &moved, &kept, 1e-3)
.unwrap()
.unwrap();
let found = local_extension_reach(
&k,
&b,
faceuse_of(&b, f),
&moved,
&kept,
1e-3,
&mut EdgeBounds::default(),
)
.unwrap()
.unwrap();
assert!(bounds(&b, found.reach, 1.0), "{f:?}: {found:?}");
assert!(bounds(&b, found.shift, 0.1), "{f:?}: {found:?}");
assert!(found.apart.is_empty(), "{f:?}: {found:?}");
@@ -3081,9 +3405,17 @@ mod tests {
let piece = |over: (Dv, Dv), mid: (Dv, Dv), dt: Ivl| {
plane_net_piece(over, mid, dt, into, &net, &guess)
};
let (reach, apart) = edge_interval_maximum(&b, tangent, &piece, b.tolerance())
.unwrap()
.unwrap();
let (reach, apart) = edge_interval_maximum(
&b,
tangent,
&piece,
b.tolerance(),
&mut EdgeBounds::default(),
EdgeQuantity::Shift(None),
None,
)
.unwrap()
.unwrap();
assert!(bounds(&b, reach, 1.0) && !apart, "{reach}");
}
@@ -3108,10 +3440,18 @@ mod tests {
let moved = [(bottom, &tilted)];
let kept = [(round, &net)];
let reach = |f: FaceId| {
local_extension_reach(&k, &b, faceuse_of(&b, f), &moved, &kept, 1e-3)
.unwrap()
.unwrap()
.reach
local_extension_reach(
&k,
&b,
faceuse_of(&b, f),
&moved,
&kept,
1e-3,
&mut EdgeBounds::default(),
)
.unwrap()
.unwrap()
.reach
};
let at_round = reach(round);
assert!(bounds(&b, at_round, 0.45), "{at_round}");
@@ -3151,9 +3491,17 @@ mod tests {
let piece = |over: (Dv, Dv), mid: (Dv, Dv), dt: Ivl| {
plane_net_piece(over, mid, dt, tilted, &net, &guess)
};
let (reach, apart) = edge_interval_maximum(&b, arc, &piece, b.tolerance())
.unwrap()
.unwrap();
let (reach, apart) = edge_interval_maximum(
&b,
arc,
&piece,
b.tolerance(),
&mut EdgeBounds::default(),
EdgeQuantity::Shift(None),
None,
)
.unwrap()
.unwrap();
assert!(bounds(&b, reach, 0.45) && !apart, "{reach}");
}
@@ -3186,4 +3534,66 @@ mod tests {
assert_eq!(xs(&left), vec![0.0, 0.5, 1.5]);
assert_eq!(xs(&right), vec![1.5, 2.5, 4.0]);
}
/// A meeting's distance takes the trace direction's length: the unit sphere and the line
/// `x0 + τ u` through its centre with `u = (2, 0, 0)` (far from unit, to show the
/// factor; a trace's own `u` is unit within rounding), its edge point `f` off the centre.
/// The roots `τ = ±½` are the points `(±1, 0, 0)`, `√(f² + 1)` from the edge point; as
/// if `|u| = 1` the bound would be `√(f² + ¼)`, below it.
#[test]
fn a_meeting_is_as_far_as_its_point_whatever_the_direction_length() {
let sphere = Implicit::Sphere {
centre: Vec3::new(0.0, 0.0, 0.0),
radius: 1.0,
};
for f in [0.0, 0.5, 3.0] {
let line = Trace {
x0: Dv::constant(Vec3::new(0.0, 0.0, 0.0)),
foot2: Di::constant(f * f),
u: Dv::constant(Vec3::new(2.0, 0.0, 0.0)),
};
let q = along_line(&sphere, &line).unwrap();
let plan = plan_of(&line, &q, &sphere, 1e-5);
assert!(matches!(plan, Plan::Roots { .. }), "{plan:?}");
let found = planned(&plan, &line, &q).unwrap();
assert_eq!(found.len(), 2, "{found:?}");
let exact = f * f + 1.0;
for g in found {
assert!(g.v.lo <= exact && exact <= g.v.hi, "{f}: {g:?} {exact}");
}
}
}
/// A line's meeting with a plane is as far as its point, whatever the direction's
/// length: the line through the origin along `u = (2, 0, 0)` (far from unit, to show
/// it; an edge's own `u` is unit within rounding) meets the plane `x = 3` at `(3, 0, 0)`,
/// `√5` from `p = (1, 1, 0)`; `x0 = (4, 0, 0)` is a point of the line but not `p`'s foot.
/// A curve `dev` off the line, which is square to the plane, adds `dev` (the sine is
/// `|u·ν| / (|u| |ν|)` = 1), not `dev / 2`.
#[test]
fn a_line_meets_a_plane_as_far_as_its_point_whatever_the_direction_length() {
let plane = Tangent {
point: Vec3::new(3.0, 0.0, 0.0),
normal: Vec3::new(1.0, 0.0, 0.0),
raw: Vec3::new(1.0, 0.0, 0.0),
};
for dev in [0.0, 0.1] {
let met = line_meets_plane(
Dv::constant(Vec3::new(1.0, 1.0, 0.0)),
Dv::constant(Vec3::new(0.0, 0.0, 0.0)),
Dv::constant(Vec3::new(2.0, 0.0, 0.0)),
dev,
plane,
1e-3,
);
let exact = 5.0_f64.sqrt() + dev;
let CornerMeeting::Within(reach) = met else {
panic!("{dev}: {met:?}");
};
assert!(
reach >= exact && reach <= exact * (1.0 + 1e-12),
"{dev}: {reach} {exact}"
);
}
}
}
@@ -19,6 +19,7 @@
//! | [`PrimitiveCreation::create_sphere_no_pole`] | `CreateSphereNoPole` |
//! | [`PrimitiveCreation::create_cone_elliptic_ends`] | `CreateConeEllipticEnds` |
//! | [`analytic_rotational_sweep`] | `sm_CreateAnalyticRotationalSweep` |
//! | [`coplanar_with_axis`] | (not upstream, WR-776) |
//!
//! Deviations: missing topology edits return Unported at their first call.
//! Mutations after that call are deferred; no success is claimed for those paths.
@@ -26,6 +27,11 @@
//! them. `SmBrep` is caller-owned; edits retain upstream edit-mode side effects.
//! Face construction uses caller notification records, attributes and identity keys.
//! No recursion.
//! Not upstream (WR-776): [`analytic_rotational_sweep`] makes its cone only for a line in
//! one plane with the axis ([`coplanar_with_axis`]); upstream makes the cone through the
//! end circles of a line skew to the axis too, which sweeps a hyperboloid of one sheet. A
//! skew line is refused here (a described failure, `SM_ERR` status) before any edit, so
//! the caller gives it its surface of revolution.
//! Not upstream (WR-565, tangency-labs/solidcore#1477, opt-in
//! `Kernel::fixes.linear_sweep_arc_is_cylinder`; SolidCraft change, tangency-labs/smlib
//! 12918e9): [`PrimitiveCreation::create_linear_sweep`] makes a circular arc short of a
@@ -1431,9 +1437,35 @@ impl PrimitiveCreation {
}
}
/// Whether the segment from `start` to `end` lies in one plane with the axis through
/// `axis_base` along `axis`, within `tol`: the lines are parallel (the sine of their
/// angle within [`EFF_ZERO`]), or their common perpendicular is no longer than `tol` (one
/// not finite is not). Only such a line, neither parallel nor square to the axis, sweeps a
/// cone; a skew one sweeps a hyperboloid of one sheet, its radius about the axis the root
/// of a quadratic in the axial coordinate, not linear in it (WR-776).
#[must_use]
pub(crate) fn coplanar_with_axis(
axis_base: crate::math::Point3,
axis: Vec3,
start: crate::math::Point3,
end: crate::math::Point3,
tol: f64,
) -> bool {
let line = end - start;
let normal = line.cross(axis);
let sine = normal.length();
if sine <= EFF_ZERO * line.length() * axis.length() {
return true;
}
((start - axis_base).dot(normal) / sine).abs() <= tol
}
/// Create an analytic rotational sweep (`sm_CreateAnalyticRotationalSweep`).
/// Lines on the axis, interior axis crossings and unsuitable circular arcs return
/// `SM_ERR`; topology construction defers at its exact owning unit.
/// `SM_ERR`; topology construction defers at its exact owning unit. Not upstream
/// (WR-776): a line skew to the axis and neither parallel nor square to it
/// ([`coplanar_with_axis`] at the B-rep's tolerance) fails, described, before any edit,
/// where upstream makes it the cone through its end circles.
pub fn analytic_rotational_sweep(
k: &mut Kernel,
brep: &mut Brep,
@@ -1514,6 +1546,14 @@ pub fn analytic_rotational_sweep(
)),
};
} else {
// Not upstream (WR-776): a line skew to the axis sweeps a hyperboloid, not the
// cone through its end circles
if !coplanar_with_axis(axis_base, axis, ends.start, ends.end, tol) {
return Err(SolidError::primitive_failed(
op,
"the line is skew to the axis: it sweeps a hyperboloid, not a cone",
));
}
let (bottom_radius, bottom_parameter) =
line_point_distance(axis_base, axis, ends.start);
let (top_radius, top_parameter) = line_point_distance(axis_base, axis, ends.end);
@@ -703,6 +703,69 @@ mod tests {
)
}
/// WR-776: a line skew to the axis sweeps a hyperboloid of one sheet. Every point of
/// the result is as far from the axis as the profile's point at the same axial
/// coordinate, both measured here from the profile's and the axis's own numbers.
/// Upstream's one-curve special case made the cone through the end circles, 3.85 mm
/// off its hyperboloid half way along this line; with the analytic-faces fix or
/// without, the line is its surface of revolution.
#[test]
fn a_line_skew_to_the_axis_revolves_onto_its_hyperboloid() {
let (p0, p1) = ([600.0, 0.0, 0.0], [700.0, 0.0, 200.0]);
let o = [500.0, -50.0, 0.0];
let n = (40.0_f64 * 40.0 + 80.0 * 80.0 + 300.0 * 300.0).sqrt();
let a = [40.0 / n, 80.0 / n, 300.0 / n];
let dot = |u: [f64; 3], v: [f64; 3]| u[0] * v[0] + u[1] * v[1] + u[2] * v[2];
// the axial coordinate of p and its distance from the axis
let polar = |p: [f64; 3]| {
let q = [p[0] - o[0], p[1] - o[1], p[2] - o[2]];
let h = dot(q, a);
(h, (dot(q, q) - h * h).max(0.0).sqrt())
};
let ((h0, _), (h1, _)) = (polar(p0), polar(p1));
let profile = segment(p0, p1);
for analytic in [false, true] {
let mut k = Kernel::default();
k.fixes.revolve_analytic_profile_faces = analytic;
let sheet = create_rotational_sweep(
&mut k,
&[Some(&profile)],
Point3::new(o[0], o[1], o[2]),
Vec3::new(a[0], a[1], a[2]),
360.0,
false,
)
.unwrap();
let faces = crate::topology::brep::p1::faces(&sheet, None).unwrap();
assert!(!faces.is_empty());
let mut worst = 0.0_f64;
for f in faces {
let surface = crate::topology::face::p2::surface(&sheet, f)
.unwrap()
.expect("a surface");
let d = surface.natural_uv_domain_with_nurb(&k).unwrap();
for i in 0..=24 {
for j in 0..=24 {
let uv = crate::geom::vector2d::Point2::new(
d.u_min() + (d.u_max() - d.u_min()) * f64::from(i) / 24.0,
d.v_min() + (d.v_max() - d.v_min()) * f64::from(j) / 24.0,
);
let x = surface.evaluate_point(&k, uv).unwrap();
let (h, r) = polar([x.x, x.y, x.z]);
let t = (h - h0) / (h1 - h0);
assert!((-1e-9..=1.0 + 1e-9).contains(&t), "past the profile: {x:?}");
let on = [0, 1, 2].map(|c| p0[c] + t * (p1[c] - p0[c]));
worst = worst.max((r - polar(on).1).abs());
}
}
}
assert!(
worst <= 1e-6,
"{worst} mm off the hyperboloid (fix {analytic})"
);
}
}
/// A full turn of a half annulus about its diameter is a hollow ball with the
/// opt-in; upstream makes the inner ball a second solid.
#[test]
+37 -8
View File
@@ -160,7 +160,7 @@ pub struct RailedLoft {
/// A section curve as plain numbers: degree, full knots, Euclidean control points, weights
/// (`None` for a polynomial curve).
#[derive(Debug, Clone)]
struct Plain {
pub(crate) struct Plain {
degree: usize,
knots: Vec<f64>,
points: Vec<Point3>,
@@ -361,7 +361,7 @@ fn wire_point(curves: &[(Plain, NurbCurve)], g: f64) -> Result<Point3> {
}
/// A station's curves, and per rail its parameter and point there.
type StationAt = (Vec<Plain>, Vec<(f64, Point3)>);
pub(crate) type StationAt = (Vec<Plain>, Vec<(f64, Point3)>);
/// An affine move: `x` goes to `to + m (x - from)`.
struct Affine {
@@ -710,11 +710,11 @@ impl<'a> Loft<'a> {
/// A wire of the loft: a section's curves or a station's, at loft parameter `(span, f)`, and
/// per rail the rail's parameter and point there.
struct Wire {
span: usize,
f: f64,
curves: Vec<Plain>,
hits: Vec<(f64, Point3)>,
pub(crate) struct Wire {
pub(crate) span: usize,
pub(crate) f: f64,
pub(crate) curves: Vec<Plain>,
pub(crate) hits: Vec<(f64, Point3)>,
}
/// The loft through `sections` guided by `rails` (module docs).
@@ -759,6 +759,35 @@ pub fn loft_rails(
hits,
});
}
skin_and_certify(
k,
wires,
spans,
rails,
opts,
&mut |k: &Kernel, i: usize, f: f64| loft.station(k, i, f),
)
}
/// The loft's skin-and-certificate loop (module docs, "Where the stations go, and the
/// certificate"): from the wires `wires` (the sections, each with its rails' crossings, `spans`
/// spans between them), a station placed by `station(k, span, f)` at each span's middle, the
/// wires skinned, each rail bounded against the skin on every span between neighbouring wires,
/// and a span whose bound is above the tolerance given a station at its middle, until every
/// span is certified; past [`RailLoftOptions::max_wires`] refused, naming the bound reached.
///
/// # Errors
///
/// [`crate::error::sweep::SweepFailure::RailPastStationCap`] past the wire cap; those of
/// `station`, the skin and the bound.
pub(crate) fn skin_and_certify(
k: &mut Kernel,
mut wires: Vec<Wire>,
spans: usize,
rails: &[&Curve],
opts: &RailLoftOptions,
station: &mut dyn FnMut(&Kernel, usize, f64) -> Result<StationAt>,
) -> Result<RailedLoft> {
// one station at each span's middle
let mut pending: Vec<(usize, f64)> = (0..spans).map(|i| (i, 0.5)).collect();
// the largest bound the last skin proved (none before the first)
@@ -776,7 +805,7 @@ pub fn loft_rails(
.into_error());
}
for (i, f) in pending.drain(..) {
let (curves, hits) = loft.station(k, i, f)?;
let (curves, hits) = station(&*k, i, f)?;
wires.push(Wire {
span: i,
f,

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