Compare commits

..

84 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
bherbruck 9a84ab0102 Cargo.lock: solidcraft-format at 0.40.1, the workspace version (#15221)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 20:18:49 -05:00
bherbruck 1ecfb4ef30 Release 0.40.1 (#15213)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 20:11:25 -05:00
jyviko d063e81eff Format machinery and design container in a shared crate: solidcraft-format (#14997)
* Format machinery in a shared crate: solidcraft-format, designs and drawings registered with it

What every versioned file format needs moves out of crates/doc into the new shared crate
`solidcraft-format` (crates/format, serde_json only): a family's format tag and its legacy tag,
the version registry (the hand-written steps, then one `v<N>/` folder per format with a file per
change, then the pending changes that make the next number), reading a file's format and its
`format_pending`, telling a file a newer build wrote, the upgrade that runs each step once, and,
for a build script, the generated lists of the change files (`build::lists`). Steps are
JSON-level and return the app's own error; the crate's refusals are `FormatError`, which
`DocError` takes as `Invalid` with the same text.

crates/doc keeps everything that is SolidCraft's: its 22 hand-written steps and their history,
every v*/ and pending/ change file unchanged, the DESIGN and DRAWING families (now values of the
shared type, with the app's name for messages), the one alias a format-29 design may carry for
the engineering material cards (now data on the family instead of a rule in the machinery),
`read`, `decode` and every check, and newer_file. `format::Family`, `Newer`, `FORMAT`,
`format_string`, `version_of` and `pending_names` keep their names, so no caller changes; the
number range check of a family's file is doc's own (`CheckNumbers`). build.rs lists the change
files through the shared crate and keeps its FAMILIES for the release script.

Nothing about reading or writing a file changes: the same steps run in the same order, with the
same messages and the same generated lists. The format tests that built a `Formats` and called
its upgrade now build the family around it (`Family { formats, ..DESIGN }`) and call the
family's, with the same expectations. The crate has its own tests of the registry and the
upgrade, and a README; xtask's layer table lists it as shared.

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

* Shared format crate: newer-file keeping and the ZIP container, SolidCraft's design on them

Two more pieces of the file machinery move into `solidcraft-format`, each with what is SolidCraft's
left in its app crates:

- `newer`: opening a file a newer build saved without losing what this build can't read.
  `NewerFile` (its notice, warnings and report, worded with the app's name), the comparison of
  the file with what was read of it and the putting back on saving (`compare`, `restore_into`),
  the sections and list elements left out and kept (`keep_section`, `keep_element`,
  `leave_out_unreadable`, `read_around`). crates/doc keeps what is a design's: a feature it can't
  read stays on the timeline as a placeholder, a parameter is left out, the sections a design
  can't open without, the fields a container adds; `newer_file::NewerFile` is the shared type
  and `newer_file::newer_file(&newer)` makes one for SolidCraft.
- `container`: the ZIP package (`Writer`, `Reader`): parts never extracted to paths, the
  directory checked on opening, content-addressed blobs `blobs/<sha256>.<ext>` hash-checked when
  read, every size within the app's `Limits`, room for optional caches, `Compression`, and the
  `Thumbnail` keyed to its file. crates/io keeps its budgets (the preference-driven limits, the
  STEP and mesh rules), the payload encodings (meshes, point clouds), the HDR and texture
  manifests, the model cache, the preview's meshes and its container tag; `DesignCompression` is
  the shared `Compression`, and the shared crate's refusals read as a design container's.

Files are read and written as before, byte for byte: the same parts, names, levels and keys, the
same checks in the same order, the same messages. The io and doc tests are unchanged. The shared
crate has its own tests of the container (parts, blobs, budgets, the thumbnail's key) and of
newer-file keeping.

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

* io: the model cache key covers the shared format crate

The saved model is read through solidcraft-format now, so a change there must invalidate
cached models as a change in doc does.

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

* Cargo.lock: solidcraft-format at the workspace's 0.40.0

The merge with main kept the new crate's lock entry at 0.40.0-rc.4 while
main's release moved the workspace to 0.40.0, so every --locked cargo call
refused to update the lock.

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-12 00:39:47 +00:00
thocsmith 86e93510f0 Show Dimensions: a dimension is sized from whichever sketch direction reaches the screen (#14826) (#15197)
A view where the sketch's y direction did not project skipped the
dimension, though its x direction (all the code before #14881 used)
did. mm_per_pixel now takes the longer of the directions that project
and gives no size only when neither does.

Co-authored-by: thocsmith <193096300+thocsmith@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 00:31:33 +00:00
CaRLymx f9bb74f155 Catalan (ca): translate 190 messages (#15194)
Catch-up with `en.json` at `c37dc53d3`.

Co-authored-by: carlymx <24634598+carlymx@users.noreply.github.com>
2026-11-12 00:19:10 +00:00
CaRLymx 06f8f6a358 Spanish (es-ES): translate 170 messages (#15192)
Catch-up with `en.json` at `c37dc53d3`.

Co-authored-by: carlymx <24634598+carlymx@users.noreply.github.com>
2026-11-12 00:17:04 +00:00
dudedagger42 7371a086b4 Loft rails take sections whose curves differ in kind (#12512) (#13909)
* Loft rails: tests for sections whose curves differ in kind (#12512)

A half-disc (arc and line) lofted to itself along a straight rail, the
half-disc to a rectangle along a bulging rail, and a quarter pie to a
third-of-a-turn pie: all refused on main ("curves differ in kind").

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

* Loft rails: take sections whose curves differ in kind (#12512)

loft_rails::railed required every section's k-th curve to have the same
degree, knots and weights, so a line against an arc (or two arcs of
different turns) was refused. The sections' columns are now brought to
one form first (unified: each curve on [0, 1], raised to the column's
highest degree Bezier piece by piece, then given the union of the knots
by Boehm insertion; every curve unchanged, checked by a unit test), and
a station between sections takes its control points straight between
the sections' and its weights between theirs, so the weights need not
agree either.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-12 00:15:17 +00:00
Edigorin 82155898d9 Shell: build a shell on a cone with an apex instead of failing with status 1001 (#11774) (#14981)
* claim: #11774

* Cone: an offset truncated at its apex keeps the surviving end's parameters

The offset of a cone whose radius passes zero is the cone truncated at its
new apex, reparameterised to the matching part of its NURB domain. The
matching part sat at the domain's minimum end whatever end the radius
collapsed at, so a cone whose apex end collapses (a revolved cone, whose bot
radius is 0) got the part at the wrong end. Corresponding points did not
keep their (u, v), the rim's UV moved, and a shell could not pair the rim
edge use with its offset copy: the rim kept its raw normal-offset curve
instead of the mitre circle, and the loop-gap closure refused the step it
left behind (reported as status 1001, tangency-labs/solidcraft#11774).

Anchor the matching part at the end that survives: the top's end when the
bot radius passes zero, the bot's end when the top radius does. The
geometry is unchanged; only the parameters move. The regression test pins
both branches.

The kernel corpus gains the issue's recipe as a case, with its render, and
the finding is recorded in plan/findings.

Fixes tangency-labs/solidcraft#11774

Co-Authored-By: MiMo-V2.6-Pro <noreply@xiaomi.com>

* Cone: the truncated offset's box is anchored with the inside-out reversal

Review of the previous change (CodeRabbit on #14981) found two things: an
inside-out cone's NURB runs the generator backwards
(`cone_convert_uv_from_nurbs_to_step` maps the STEP minimum from the NURB
maximum), so the surviving end of an inside-out cone sits at the box's other
end and the anchor is the collapsed end XOR the inside-out flag; and the
regression test derived its expectations from the returned cone's height,
which would accept a wrong slope.

The anchor now carries the inside-out reversal. The test derives its
expectations from the input's radius law instead, and covers both collapse
ends and both inside-out values against the S + d n contract. Extending it
to truncated input cones surfaced a second, pre-existing defect in the same
branch (the kept height flattens a truncated input cone's slope); per this
repository's rules that is filed as an issue with its closed-form analysis
and the golden moves it causes, not folded in here.

Fixes tangency-labs/solidcraft#11774

Co-Authored-By: MiMo-V2.6-Pro <noreply@xiaomi.com>

---------

Co-authored-by: MiMo-V2.6-Pro <noreply@xiaomi.com>
2026-11-12 00:14:59 +00:00
bherbruck 34cbfbc668 Loft through parallel sections no longer dips behind its first section, so a carved top joins onto its slab again (#15176)
* Loft: sections on parallel planes are skinned at their planes' distances, so the loft stays between its end planes

A loft through three or more sections on parallel planes took chord-length parameters
across the sections, from the whole motion of their control points. Where the sections
move far sideways for a small step up (a carved top's first steps: 10 to 24 mm sideways
for 0.6 mm up), the cubic interpolant of the height overshot: the side dipped below the
first section's plane and crossed its own start cap. Joining such a loft onto the body
whose face it starts on then failed ("the mapped B-rep does not validate: outer loop is
not an edge loop", or "the union would delete face ..."), or came out wrong.

The skin now takes each section's parameter as its plane's distance from the first over
the last's. Every section's height is then one linear function of the parameter, which
the interpolation reproduces exactly: the side faces' control points lie between the end
planes, so by the convex hull the loft does too. Sections that are not all parallel, or
not in order along the normal, keep the chord-length parameters.

Results that change: lofts through three or more parallel sections whose spacing differs
from their chord lengths take a slightly different shape between the sections (the same
sections, interpolated at different parameters). One golden moves:
boolean.api_brep issue1036_coincident_skins_intersection_measured, two degree-1 skins
whose ruled surfaces are the same point set under any parameters; its volume moves
26136.268243625735 -> 26136.26817288386 (2.7e-9 relative, the integrator's 1e-6
accuracy) and one face area 3788.658561467813 -> 3788.6586869951957 (3.3e-8, against
1e-4).

Tests: solidcore tests/skin_parallel_sections.rs (control points within the slab; union
with a slab under the first section adds the volumes; both fail without the change, the
first at a control point 0.24 below the first plane, the second with a negative union
volume) and the app regression examples/regressions/loft_join_carved_top_on_slab.json
(the join's volume is the slab's plus the loft's built as its own body; 0.40.0 fails it
with the error above).

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

* Loft: plane-distance parameters only where the chord-length skin leaves its end planes

Review of the previous commit: plane-distance parameters by default reshape lofts that
build correctly today (a waist r 20/10/20 at z 0/5/40 pinches through its axis; another
four-section loft grows 29%), and the parallel test is a hard cutoff.

The skin now takes upstream's chord-length parameters as before, and checks that skin's
control points against the slab between the first and last section planes (within the
B-rep's tolerance; by the convex hull the skin is in the slab when they are). Only when
one lies outside, on parallel sections in order, is every layer skinned again at the
planes' distances, where the height is linear in the parameter and the skin stays in the
slab exactly. Every loft whose chord-length skin stays between its end planes rebuilds bit
for bit; the carved top, whose control points fall 0.24 below its first plane, is still
fixed.

The boolean.api_brep golden moved by the previous commit is back to main's (no golden
changes: 283 units unchanged). New test: the waist and the four-section loft keep the
volumes measured with main's skin (16791.357595192487 and 272283.3927004977).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 00:12:19 +00:00
Jeff Jenner 7938171505 Groove: cut the bottom rim of a rounded shell without a gap (#14866)
Join each rim-band profile segment at the preceding arc's evaluated endpoint. Add a regression using the reported rounded shell dimensions and check the cutter, result, and removed volume.

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-11-12 00:06:34 +00:00
dudedagger42 e3c27f0ba2 Sweep: say the profile reaches past a bend's centre, not SMLib status 1025 (#12126) (#13893)
* Sweep: say the profile reaches past a bend's centre instead of SMLib status 1025 (#12126)

SMLib sweeps each path arc as a turn about the arc's axis and refuses a
profile that crosses that axis with SM_ERR_AXIS_INSIDE_FACE (1025). The
kernel passed the bare status on; it now names the tightest bend's radius.

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

* Sweep: the 1025 message asks the profile to clear every bend it turns towards, the tightest given as context (#12126)

Review on #13893: the tightest bend is not always the one the profile crosses.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 00:06:17 +00:00
bherbruck 7c4b6c31fc ops: a controlled sweep through the kernel's stations, its walls named by the skin's trace (#13664) (#15098)
`sweep::controlled::sweep_controlled(profile, profile_normal, path, laws)` sweeps closed profile loops along a path of lines, arcs and helices by laws (orientation perpendicular, parallel or by a direction; twist; end scale; up to two guide rails, fitted by scale or stretch), capped at both ends, and returns which faces are the start and end caps and which profile curve each wall was swept from.

Every number is the kernel's: where the stations go (`sweep::stations::fractions`, SolidCraft's fit convention), each station's frame and map (`sweep::stations::stations`), and the skin through them. The walls are named by the kernel's trace of its own skin (`create_skin_primitive_with_sources`, #15062), not by the skin's face order, which is not the profile's (a square's wall 0 held its fourth side): each wall must hold one curve of each section, the same profile curve throughout, or the sweep is refused by name. A curve with a kink of its own sweeps a wall on each side of it.

SolidCraft's limits on the laws (four turns of twist, end scale 0.01 to 100, the least crossing angle and sine) live here and reach the kernel as its laws' limits; the kernel refuses only what has no sweep.

Refused, by name: a path with corners and a closed path (each its own construction: #13639, #13638). The cubic skin's distance from the law surface between stations is not certified (exact where the sections vary linearly along a straight path, as an end scale does; #13637).


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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 00:05:44 +00:00
bherbruck 8273865c91 blend: plane_section without cancellation, so a tilted boss's round is its definition; drop the sampled bare-net fallback (#15037) (#15123)
plane_section took the principal axes of the section's quadratic form from
sqrt(tr^2/4 - det) and (a12, lambda - a11). Both cancel as the section nears a
circle, and the conic misses the offset cylinder by about eps / a^2. The #14000
case is a 1 mm round at the foot of an r5 boss on a 40 x 40 x 10 block, its
axis tilted a few degrees. There the spine's Bernstein coefficient was 1.06e-11
against a bound of 8.0e-13 at 1 deg, and 4.4e-11 against 8.0e-13 at 0.25 deg.
check_exact_net was right to refuse it.

The eigenvalues are now m +- hypot(h, a12) with h = (a11 - a22) / 2. Each axis
is written in the form whose terms add, and it is the same axis, sign included,
that upstream's form gives in exact arithmetic. Across tilts 0.25 to 5 deg, the
largest spine coefficient is 4.3e-14 and the largest coefficient/bound ratio is
0.067. Every round certifies with its exact definition.

With that fixed, keep_round_if_proved's fallback goes. It kept a round whose
certificate failed as a bare net with no definition and no bound, whenever 17 x
17 samples (net_miss) found the net within tolerance, which breaks §7.1, §7.4
and §7.6. A round now keeps its definition only once recertify proves it, and a
refusal refuses the fillet. Removed: keep_round_if_proved, ExactRound::miss,
net_miss, MISS_SAMPLES and Implicit::distance, which only net_miss used.

Test: wr669_exact_conic_rounds::a_round_on_a_boss_tilted_a_few_degrees_is_its_definition
covers the boss at 0.25 to 5 deg. Each tilt builds, both root rounds are Conic exact
definitions (bound 0, recertified, record round trip bit for bit), and the
volume added matches the divergence theorem over the closed-form canal surface
within 1e-3 (measured: 6e-10). Before the fix, 0 of 2 rounds kept their
definition at every tilt up to 2.5 deg, and 1 of 2 at 3 and 4 deg.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 00:05:36 +00:00
FinnDaCaptain 08ad6f10c2 GPU path tracer: shaders compile in seconds, not up to 21.6 minutes and 61 GB of memory (#14105) (#14834)
* GPU path tracer: one inlined candidate test per BVH walk

Native compilers inline every call, so each call site of the compensated
candidate arithmetic is another full copy in the driver's input (#14105).
Reach the expensive helpers from fewer sites while computing the same values:

- trace_precise_within runs its three candidate policies (ordinary origin,
  far candidate from the rounded origin, exact anchored deltas) through one
  test in a small policy loop; the exact policy forms its two bound deltas and
  three vertex deltas at one precise_point_delta call site.
- edge_weights evaluates the three edge functions at one difference_products
  call site; the ordered walk, origin_on_boundary, origin_boundary and the
  ordering reference use it. origin_boundary's six homogeneous coordinates
  share one call site too.
- trace_precise no longer dispatches an origin without a low part to
  trace_ordered_within: trace_precise_within then takes exactly the ordered
  walk's origin, pad, error bound and candidate test, so main inlines one
  walk fewer.

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

* GPU path tracer: soft-float and split helpers reach each callee once

Every precise delta and every compensated product difference inlines its
integer arithmetic, so the helpers' own call sites multiply into every
caller (#14105). Restructure them to reach each expensive callee from one
call site, computing the same values in the same order:

- fp_sub_barrier_bits takes its exact cases from fp_sub_exact and falls back
  to fp_sub_bits at one call site instead of two.
- anchored_delta_bits runs offset, anchor and their sum as three steps that
  share one fp_add_bits; the final integer addition stays the reassociation
  barrier.
- precise_point_delta evaluates its three components at one
  anchored_delta_component call site.
- split_difference_products runs its five possible sums as steps sharing one
  split_add and one split_rescale; split_two_sum is folded in.

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

* GPU path tracer: fewer precise call sites around the walks

The integrator and origin query still reached the inlined precise arithmetic
from neighbouring call sites that compute related values (#14105). Share one
call site for each pair, computing the same values:

- direct_light evaluated volume_transmittance_precise for the shadow ray and,
  for a punctual light, again along the physical segment, keeping the second;
  it now evaluates only the one it keeps.
- volume_transmittance_precise forms both box-bound deltas at one call site.
- At an emitter hit, the physical and scatter segments share one
  ray_segment_precise; at a surface hit, the travelled and absorbing segments
  share one precise_point_delta (the second only inside a medium, as before).
- origin_boundary's six homogeneous coordinates and three edge weights share
  one difference_products.

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

* GPU path tracer: denoise guides inline two BVH walks and compile one at a time; identical scene programs compile once (#14105)

Native Vulkan compilers inline every call site, and their time and memory grow faster
than the inlined size. Host-side and denoise changes, with unchanged results:

- denoise_guides traces the camera ray as event 0 of its existing event loop, so the
  camera ray and its optical continuations share one trace_precise_within call, one
  emitter test and one surface look. The camera ray has no low origin, so the walk
  takes its ordinary candidate branch, the arithmetic of trace(), as it does for any
  ray without a low origin. The guide entry inlines 2 BVH walks instead of 3, and
  7,906 statements instead of 11,703 (naga statements, each call site counting its
  callee again). The new test guides_inline_two_bvh_walks asserts exactly 2, so a
  renamed walk function fails it instead of passing on zero.
- The guide pipeline compiles under one_compile_at_a_time like the path programs, so
  it no longer runs beside another compile. It and prepare_scene_pipeline take the
  job's CancelToken: a job cancelled while it waited for another compile returns
  before compiling.
- Scene programs are cached by what is compiled. Nested media are a uniform and an
  opaque scene with maps runs the full mapped program, so neither compiles a second
  copy of a program the device already has (2 + 3 slots instead of 5 + 6). The test
  host_readiness_includes_unmapped_non_nested_glass is renamed
  host_readiness_includes_unmapped_glass, as it now also checks that a nested-media
  job runs the ready unmapped program.

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

* GPU path tracer: a size budget for what the heavy entry points inline (#14105)

main_inlines_few_bvh_walks counts the BVH walks main inlines, not their size, so
#14105's growth got through: the walks' candidate tests grew while their number stayed
the same, and a native Vulkan driver then took up to 17 minutes and 61 GB to compile the
path programs.

heavy_entry_points_inline_few_statements parses each heavy program with naga and counts
the statements a native compiler inlines into its entry point: every statement, nested
blocks included, and at every call site the callee's body again. Each budget is about
1.5 times the count with the fix and far below the count before it:

  entry point            before    fixed    budget
  path main             196,450   28,414    45,000
  path optical_resolve   54,888    7,777    12,000
  denoise_guides         70,517    7,906    12,000
  caustic main          211,786   33,386    50,000
  caustic photon_emit   107,085   14,782    22,000

The walk count now uses the same visitor of inlined statements. denoise::GUIDES is
visible to the path tests, so the guard reads the guide program's own source.

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

* Docs: captured scene options select at most three GPU path programs (#14105)

Nested media are a uniform and an opaque scene with maps runs the mapped program, so
the scene options select one of three cached programs now, not six.

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

* GPU path tracer tests: the shared-walk reference keeps a frozen ordered walker (#14105)

shared_bvh_node_walk_preserves_original_ordinary_and_compensated_hit_bits
compares trace_precise_within with a frozen copy of the old policy. For an
origin without a low part that copy called the shader's trace_ordered_within,
which now forms its edge weights at one edge_weights call site. Those cases
compared the new walk with new code, while the comment still called the
reference the byte-identical old walker.

The reference now carries the old ordered walker as legacy_ordered_within:
trace_ordered_within as it was before these commits, apart from its name,
with three direct difference_products calls per candidate. The comment says
what is frozen. The entry still inlines three walks: 10,109 naga statements
with each call site counting its callee again, against 9,070 when it called
the shader's ordered walk and 60,315 before these commits.

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

* GPU path tracer: a denoised job that waited for the guide compile uses its pipelines (#14105)

Pipelines::new took the process-wide compile lock for the guide program but
released it before the warm-ups, and Pipelines::shared published the
pipelines only after them. A second denoised job on the same executor that
found nothing published waited for the lock and then compiled the guide
program again: after the first compile instead of beside it, so its setup
took both compiles in a row.

Pipelines::shared now takes the lock, checks again for published pipelines
and holds the lock until it publishes, as prepare_scene_pipeline does for the
scene programs. A job that waited uses the pipelines the first job built, and
a job cancelled while it waited still returns before compiling. A driver
that compiles at the first dispatch, the case SETUP_TIMEOUT allows for, now
does that under the lock too. Pipelines::new is main's again.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 23:51:05 +00:00
bherbruck 83ba62f22f query::tangent_edge: the #13575 review fixes, a tight pcurve box and a centred net normal (tangency-labs/solidcraft#14611) (#15005)
Follow-up to #13575 (the proved smooth-edge test, solidcore#3141): jyviko's review of #3175,
item by item, and the cost of deciding a procedural round's edges.

Review fixes:
- Piece boundaries are no longer found as rounded roots. A piece is one polynomial only where
  the pcurve's enclosed (u, v) box meets no interior knot line; otherwise it is bounded over its
  box (the hull over the cells it meets) and halved, to MAX_CELL_DEPTH.
- The face's evaluator clamps a point past its net's domain (b_spline_surface_n_lib's
  evaluate_simple, the cone's evaluate_step, ProceduralBlend::evaluate), so the certificate is
  about the clamped surface: the box is clamped as the evaluator clamps it, a box straddling an
  end is not one cell, and at a point a clamp moving the face's point more than the edge's
  tolerance from the net's is refused by name (a periodic face's pcurve a period off). A
  pcurve 1e-16 past its net's end (a fillet's round) is taken. Planes are exempt.
- nurbs::math::gamma/UNIT throughout, each factor's operation count at its comment;
  definitions-and-certificates.md 4.4 has the row.
- MAX_POINTS per piece; a piece past it is Undecided, not an error. Undecided names its place
  (at).
- edge_turn::leaving_derivative is the one "direction an edge leaves its vertex in" (mixed.rs's
  corner calls it, bit for bit); edge_turn::leaving read the span below a knot at an edge's start,
  now from inside the edge. convex_at takes the caller's smooth angle and says None where the
  faces are not proved to part by more (tangent faces have no convexity).
- ops: blend::tangent_chain(body, order, edges, passed_over, angle) -> Chain is the one public
  edge walk (tangent_chain_ordered is gone); the cheap questions (turn, convexity) run before
  the certified one; candidates in record order; passed_over validated.
- least_angle_along is dropped: no consumer, not a bound.
- mixed.rs's corner reads its leaving direction from edge_turn's one evaluation (leaving_tangent,
  which names each case), keeping all three of main's typed refusals (the edge off the vertex, its
  curve missing, the evaluation without its first derivative).
- The decision polls the kernel's cancel token every 64 points (POLL_POINTS, the mass
  enclosure's stride; the first point polls too), the definitions-and-certificates.md
  bounded-work rule, so a long tangent_all_along, and ops' tangent_chain over it, can be
  cancelled (CodeRabbit). Tests: a box's square edge, NotTangent, then Cancelled once the token
  is (between planes nothing else polls, so this is the loop's own poll); the procedural round's
  root edges at kernel level with the token already cancelled: Cancelled on each.
- A face whose B-spline net is in power-basis form (BSplineSurfaceForm::Polynomial: coefficients,
  not control points) is refused by name rather than read as a control net (CodeRabbit). Test:
  a box face's net set to that form.

Cost (circle_root: a pipe sunk in a plate, its root rounded r 2 as a procedural blend; the two
root edges at half a degree):
- A blend's margin is Bounded, Loose (halved) or NotEnclosed (the definition's enclosure fails:
  halved at most MAX_UNENCLOSED_SPLITS = 4 times, then Undecided; never Tangent without a
  bounded margin).
- The margin is the smaller of acos(cos_lo) and, where acute and both lengths are proved
  positive, asin(sin_hi): an enclosure of width w costs about sqrt(2 w) in the angle by the
  cosine, about w by the sine.
- The pcurve's box over a piece is the hull of its Bezier control points cut to the piece by de
  Casteljau in intervals (#14611), as p + l (q - p), the spans' rounding (Boehm's knot insertion,
  gamma_(12 r + 1) per coordinate) added after the cut; intersected with the evaluation and the
  active control points' hull (exact for weight 1).
- The net's N_w over a box of one cell is also enclosed by the mean value theorem on the cell's
  polynomial, N_w(m) + N_w,u(box)(u - m_u) + N_w,v(box)(v - m_v), intersected with the direct
  enclosure; each cell's points are moved by -c w first (N_w is invariant under the move:
  (A - c w)_u w - (A - c w) w_u = A_u w - A w_u), the moved points enclosed.

Counts (60 s cap per edge; decided parts, parts left undecided as too loose):

| | before | after |
|---|---|---|
| edge 35: parts / nodes | 1,070,867 / 52.5 M (300 s cap) | 15,350 / 0.73 M in 30 s |
| edge 35: undecided as too loose | 254,486 | 0 |
| edge 46: parts / nodes | 3,116,262 / 3.1 M (300 s cap) | 19,563 / 0.36 M in 24 s |
| edge 46: undecided as too loose | 286,720 | 0 |
| edge 46 margin at s = 30.094, half width 1e-6 | 1.3e-3 | 3.3e-8 |

Not covered:
- Both root edges stay Undecided at s = 87.04: there the pipe's foot crosses its net's C1 quarter
  knot (u = 15.708, multiplicity 2) and definition_partials_over_box refuses the box
  (FEET_BOX_CONTINUITY). a_procedural_round_is_smooth and
  a_procedural_round_s_edges_carry_its_margin assert that verdict exactly (Undecided (pi, pi) at
  87.04, 3 parts not enclosed on each edge), so they fail loudly when the knot's box is enclosed
  (a follow-up).
- On main itself (a65ecb00, run cmp2-13575-followup, each side its own target; edge 35 / 46),
  main's loop: 20,409 / 52,632 parts, 0 / 0 decided, 10,202 / 26,314 left too loose, and both
  edges end on the point budget with "an edge too costly to decide" (13.1 s / 31.0 s). With this
  change: 15,350 / 19,563 parts, 7,681 / 9,787 decided, 0 too loose, 3 / 3 not enclosed (all at the
  knot), 732,739 / 363,962 points, and a named verdict, Undecided at 87.04 (21.7 s / 19.0 s).
  The tight box removes every too-loose part; the edges are not yet Tangent all along, as #14611
  asks, so this is part of it: the knot's box is what is left.
- ops' tangent_chain is now the walk in crate::tangent, not blend_refusal::tangent_chain. On
  a_cancelled_long_fillet_stops_promptly's 20-turn coil the chain of its helical edge is 40 edges
  along the helix (the kernel's walk gave the seed alone); the test still returns Cancelled (3.2 ms
  after Cancel, run print-13575-followup).
- The margin's centred form for the definition's normal: not needed on these edges (its
  enclosure is tight), so not built.

Tests: the cell rule and the clamp (unit); a pcurve 1e-15 past / before / on a knot line of a
hand-made two-span face (Tangent / NotTangent / not Tangent), 1e-12 past the domain taken, 0.5
past refused; a cylinder's pcurve a period off refused; the Markov term, the Q floor and the
bound's upper end (unit); walls 0.3 degrees apart at 0.5 / 0.2 / 0.3 degrees (Tangent /
NotTangent / Undecided at a place on the edge); the sine form linear against the cosine's root
(unit); a line pcurve's box is its piece (containment both ways, with the code's own bounds);
convex_at None at a round's tangent ends; ops: undecided walls listed, a chain stopping where
the boss's base turns convex, a sheet's one-face edges not joining.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 23:51:02 +00:00
jyviko 7081f99b1c UI kit: the view toast is a kit component; the history bar's reorder can be turned off (#15003)
* UI kit: the view toast is a kit component, the viewport draws it from there

The bottom toast for hints, results, refusals and work in progress (its card, the dot or
spinner before its text, its wrapping kit buttons, the × with its countdown ring) moves from
the app's viewport into `solidcraft_ui_kit::toast`, unchanged: `show(ui, free, place, text,
lead, actions, extras)` draws it from plain data and Tokens and returns what was clicked and
where it drew; `Place` (bottom centre or the corner column, `WIDTH` wide), `Action`, `Lead`,
`Extras`, `Shown`, the button layouts and the countdown come with it. The × publishes its
handle through the widget registry, as before.

The viewport keeps what is its own (the Notices preference `ToastPlace`, the corner column's
free rect) and calls the kit's toast through `paint_toast*`, the kit's names re-exported under
the old ones, so every call site and test is as it was and the drawing is the same.

Tests: the kit's button layouts, countdown and the card's place in the free part.

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

* History bar: Bar::reorder, off for a history whose steps cannot move

With reorder false a step senses clicks only: no drag, no drop bar, no
Action::Reorder; the marker still drags. SolidCraft's timeline passes true,
as before. Test: with reorder off a dragged step returns nothing and a click
still selects.

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

* UI kit toast: its padding and spinner from Tokens, rustfmt

The toast moved from the viewport into ui_kit/, where the form-metric guard
also checks vec2 literals: its 28/8 text inset, 40/16 padding and 12-point
spinner now come from space_14, space_8 and space_12 (same values). The
viewport's spinner allowance covered the toast's spinner, which left with it.

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 19:05:54 -05:00
jyviko 61f8aa0854 solidcore: transform a curve; the curve seen in two views (#15178)
* solidcore: a free curve under an affine map

curve::transform(curve, matrix) -> curve: the spline or rational spline
with every control point carried by the kernel's N_CrvTransform, its
degree, knots and weights as they were, so the image is exact up to the
rounding of each carried point (none for 0 and ±1 entries on a spline).
The matrix is Body::transform's (rows 0-2 the images of the axes, row 3
the translation, p' = p·A + t), its entries moved to the kernel's column
places; a matrix Transform::is_regular does not prove regular at
REGULAR_RATIO (singular, projective, not finite) is an Argument before
the curve is read. Reflections, non-uniform scales and shears are maps
like any other; a closed periodic curve stays closed, so the record's
spline is taken on any knots (curve_knots::net_of; nurb_of adds the
clamped check on it).

Tests: a cubic reflected about y = 0 has its CVs mirrored exactly and its
knots unchanged, twice is the curve again; a rational arc keeps its
weights and knots; a quarter turn and a move follow Body::transform's
convention exactly; a closed curve keeps its repeated points; singular,
projective and NaN matrices and a line are refused.

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

* solidcore: the curve seen in two views

intersect::views::curves_from_views(k, a, a_dir, b, b_dir): the 3D
curves seen as a along a_dir and as b along b_dir, where the two curves'
extrusions meet. Each curve is moved back along its unit direction and
swept forward (create_extrude) from -reach to +reach, and the two sheets
are intersected (intersect_surfaces); the meeting curves come back as
B-splines (from_parameters::b_spline_part, now crate-visible). The reach
is a bound, not a search: a point seen on both, p + s a = q + t b, has
|s| sin(theta) <= |q - p| <= D, D the diagonal of the box around both
control polygons (the convex hull of a positive-weight NURBS curve), so
each sheet reaches REACH_MARGIN (2) times D / sin(theta) on either side
and the meeting curves end on the curves' own ends. Directions not
finite, of no length or within EFF_ZERO_RAD of parallel are
InvalidInput; views that do not meet are an intersect failure with the
phrase NO_MEETING (views_do_not_meet reads it), never an empty Ok.
intersect_surfaces' own phrase is NO_SURFACE_INTERSECTION.

solidcore-ops: curve::two_view(a, a_dir, b, b_dir) -> Vec<Curve>
converts the record splines (curve_knots::net_of) and the meeting curves
(from_parameters::record_of), Argument for bad or parallel directions
and Empty when the views do not meet.

Tests (kernel and ops): a side view z = f(x) along y and a plan view
y = g(x) along z, their control points evenly spaced in x so the
parameter of each sampled x is closed form: every sampled point of the
result lies on both extrusions within the default approximation
tolerance (the gap along the view's axis allowed 1 + slope times it),
and the result spans the side view's whole run; parallel, zero and NaN
directions are refused; plan and side runs apart in x do not meet.

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

* solidcore: the curve from two views is traced to the kernel's tolerance

curves_from_views intersected its two sheets through intersect_surfaces,
which leaves the tolerance to the intersector's default: a thousandth of
the size of the surfaces' domains (SurfaceIntersector's constructor).
The sheets reach 2 D / sin(theta) past the curves, so at model scale
(curves about 170 mm across) that default is near a millimetre and the
span test accepted a five-point Hermite curve 5.75e-3 off both views,
against the 5e-6 its docs state. At the 10 mm scale of the first test
the angle test alone held it close, so nothing showed.

New intersect_surfaces_within(k, s1, s2, approx_tol_3d): intersect_surfaces
with the tolerance passed on to GlobalSurfaceIntersect (intersect_surfaces
calls it with None, unchanged). curves_from_views passes the kernel's
default approximation tolerance, approx_tol3d(k, USE_DEFAULT), half its
zone tolerance; the docs say the spans are checked at interior samples
(docs/ssi.md 4.3), not certified.

Tests (kernel and ops): the reported side and plan views, quadratic
Beziers about 170 mm across. The result is evaluated by de Boor's
algorithm on its own knots and control points, and the views by
Bernstein's form with the parameter at each x found by bisection, none of
the kernel's evaluators: at 513 parameters per curve each point is within
2 x 5e-6 x (1 + slope) of both views along their other axis, and the
curve runs from the plan view's start to the side view's end.

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>
2026-11-11 19:05:36 -05:00
ChrisNo 94242dcf1d Projected nearest point on a curve: return the end of the interval when the answer is there (#13658) (#14964)
* claim: #13658

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

* Projected nearest point on a curve: return the end of the interval when the answer is there (#13658)

A projected nearest-point (or farthest-point) query on a curve looked only
for parameters where the projected distance is stationary. An end of the
query interval where the distance is still falling (rising) is no such
point, so a point whose answer is that end got an empty success, or a
farther (nearer) stationary point when the curve had one.

The span solve now adds an end of the solver's interval as a solution when
the projected distance grows (shrinks) from it into the interval. An end
that is not the constrained minimum (maximum) is not added, however wide
the tolerance.

`find_side_of_curve` keeps its failure for a point beyond the end of the
curve: the nearest point there is the end, the line to the point is not
square to the curve, and such a point is on neither side. Trim results do
not change.

Evidence: a temporary dense-sampling check (4001 samples per query, not
part of this commit) on all 283 golden units. On main a5a698c96, of 7109
projected point/curve solves, 335 without a target distance returned
nothing and 22 returned a point the samples beat. With this change: 47
(36 of them the 1e-12 knot span fixtures of #13679) and 0. No returned
answer was a non-extremum in either run.

Goldens re-blessed (old and new values of every case are in the pull
request):
- curve.curve_p1, 30 cases: 17 go from no answer to the end of the
  interval; 12 replace a stationary point by an end that is nearer
  (farther); 1 keeps its minimum and adds an end within the tolerance.
- intersect.topology_solver_p1, 22 cases: 19 go from no answer to one; 3
  replace an answer by a nearer one.
- intersect.topology_solver_p2: the 4 static_operation_matrix_1_15 cases
  gain their one answer; the 8 subdivided_1_14_1 cases leave
  known_failing.json with one answer per vertex (12 and 40).
- blend.hcr gen0392: a silhouette vertex that projects onto the end of a
  curve within tolerance is now found.

The C++ defect is recorded as WR-775.

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

* Projected nearest point on a curve: the #13677 curve is tested at the issue's own tolerance (#13658)

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

* Projected nearest point on a curve: inside tangent at an interval's ends, and a closed curve has no end (#13658)

Two points from review.

The test that decides whether an end of the interval is the nearest
(farthest) point used the tangent the span solve already had. At a knot
where the curve turns, that tangent belongs to the span that starts at the
knot, so for an interval that ends at a corner it was the tangent outside
the interval. The test now evaluates the tangent on the interval's side of
each end.

`find_side_of_curve` treated an answer at an end of the curve as a point
beyond the end. A closed curve has no such end. The side of a point nearest
a corner of a closed curve depended on where its seam was: found when the
corner was inside the curve, a failure when it was the seam. Closed curves
are now exempt, and both give the same side.

No golden record changes: all 283 units pass as blessed in the first commit.

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

* Projected nearest point on a curve: a schematic of the interval-end case in WR-775 (#13658)

Adds a two-panel figure to the WR-775 note: the nearest point inside the
interval, which the search for feet finds, and the nearest point at the
interval end, which it missed. Drawn in VectorCraft; the SVG keeps live
text and its editable source sits next to it.

No code or golden changes.

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

---------

Co-authored-by: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 23:24:11 +00:00
Jackson Oursland 9ebf0e996e Operations panel: each operation says how it ended; an agent's Stop stops it (#9501)
An operation that ended faded out of the panel with a full bar however
it ended: a failure looked like a success, and only the status toast
said otherwise. Each now reports its outcome where it ends (a file
operation landing, opening behind or refused; a feature dialog's OK
applied, refused or cancelled; a history command, a delete's check or
a feature's editor; a save; an agent's command; an opened design's
meshing). Its row fades out done (a full bar), failed (red, staying
2.5 s, the error on hover) or cancelled (where its bar stopped); one
that ends without saying fades as it was. ui.operations lists those
that ended in the last minute with their outcome and error, so an agent
needn't poll its own request to learn it. One never seen under way
(done within the app's wait) isn't listed.

An agent's command computing off the window thread now evaluates under
a watched progress: its row shows the features evaluated for a single
command, and Stop (the panel's X, the chip, ui.operations) cancels the
evaluation at its next feature instead of leaving it to compute unseen.
Its reply is passed on to the agent as before, seen on the way for its
outcome.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 23:06:07 +00:00
bherbruck c37dc53d3b Kernel rig: the plan job's checkout fetches history without file contents, so it stops timing out (#15171)
Select the rig areas cloned the full history of every file (fetch-depth 0) on a 2-vCPU runner with a
5-minute timeout; on merge groups the checkout alone ran past it and the job was cancelled (run
43107650521: checkout 21:58:10-22:03:07, cancelled), leaving the queue's rig red. The diff only needs
commits and trees, so pull requests and merge groups now check out with filter blob:none; the
timeout is 10 minutes as a margin.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 22:52:39 +00:00
jyviko 865497c702 theme: an isoparm colour, lighter than a body's edges (#15143)
Tokens::isoparm colours the isoparm lines drawn on a surface: lighter
than its edges, so they read as the surface's flow, not its boundary.
Light and Dark set it halfway from body_edge to body (about half the
edges' contrast on a face); with_viewport carries it with the other view
colours. Theme files take an `isoparm` key; a file that sets body or
body_edge without it gets the same halfway colour from its own, and a
file setting no colours keeps its base's exactly. Every built-in theme
sets it: halfway between its edge and body colours, and its edge colour
in the themes limited to their own palettes (1-bit, 1-bit dark, CGA).
Tests: on both bases the isoparm colour is below the edges' contrast on
the body and at least 1.5 against the view's background; a theme file's
fallback, its own value, and a file with no colours. The colour-vision
coverage list names it with the edges, told apart by width.

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>
2026-11-11 22:35:52 +00:00
Andrzej Błasik 4ee4ccc2d5 Polish translation: the 300 texts that were missing, and CAD terms as Polish engineers name them (#15156)
The Polish catalog lacked 300 of the English catalog's texts, so the app showed
them in English: the operations panel, preferences, rendering, command names and
others. All of them now have a Polish text.

Existing entries that used two Polish words for one term, or a word that means
something else, now use one established term: Intersect is no longer the word
for Trim, Canvas and construction line have one name each, Stock is the blank
and not the machining allowance, Interference is the word the command already
uses, Weldments and the As-Built joint match their commands, Combine is a
boolean operation, and the flat-point set screw has its trade name.
2026-11-11 22:29:05 +00:00
Ahmad Khan 88e2c88e34 3D printing: the print-face tint shows only while Send to Slicer is open, and is no longer saved in the design (#11512) (#11559)
* claim: #11512

* Tests: the print-face tint shows only while Send to Slicer is open, and print faces are saved only when kept (#11512)

Failing tests first. They expect the view to tint print faces only while the
Send to Slicer window is open, a save to leave print faces out of the design,
and a design saved with a print face to open without it. The new Keep print
faces preference (keep_print_faces, off) keeps the old behaviour. The fields
are added without behaviour so the tests build.

* Send to Slicer: tint print faces only while its window is open, and save them only when kept (#11512)

The brown print-face tint stayed on the model after Send to Slicer closed, and
the print face was saved in the design, so the tint came back every time the
design was opened. Users took it for geometry or a selection.

Now the view tints print faces only while the Send to Slicer window is open.
A save leaves print faces out of the file, and a design saved with one opens
without it; the rest of the design opens as saved, with no unsaved change and
no undo step. The design on screen keeps its print faces for the session.

The new preference Keep print faces (keep_print_faces, Preferences > General >
Slicer, off) brings back the old behaviour: the tint shows at all times and
print faces are saved and opened with the design. Scripts, the command line
and MCP keep print faces as before: Session::keep_print_faces is on unless the
app turns it off.

* The print-face test names Vec3 from solidcore_record, where main now keeps it

* A design a host imports comes without its print faces unless they are kept, like one opened from a file

* The Ctrl+B print face scenario turns on Keep print faces, so its save still holds the print face

---------

Co-authored-by: Ahmad Khan <ahmad.khan@shiritai.co>
2026-11-11 22:20:08 +00:00
bherbruck 242ae73389 Cuts through a body's edge no longer fail the seam check when the sketch sits a hair off the edge (#13295) (#14849)
* Seam check: a seam edge ending within its vertex's tolerance across the seam is no near miss (#13295)

A circle sketched on a plane through a bar's edge, a few micrometres off it,
and cut into the bar leaves the cut wall's seam edge ending on the bar's edge
that far across the seam. The vertex's 1e-5 tolerance covers it, and the
loop closes there. HasNearMisses counted the gap against a fixed 1e-9, so the
check refused the cut (face test 36, "Face Near missing Seam").

A vertex that ends one of the seam's own coincident edges is now a near miss
only past its own tolerance, unless another edge at that vertex runs beside
the seam (its middle within the tolerance of the seam). That pair would bound
a sliver, which test 36 is for. A coincident edge beside the seam, and any
vertex end not on the seam's own edge, count as before.

- New ops test: the bar cut with the circle's centre 1e-9 to 1e-4 off the
  edge gives one valid solid at every offset, with the volume bounded. At
  1e-6 the cut is no solid, before this change too (#14840).
- New action_matrix case: the issue's recipe, cutting across a hole.
- issue_2538: its face test 36 named a corner (the seam edge ending 8.5e-6
  across, tolerance 1.7e-5, no edge beside), not its sliver. The sliver is
  still refused, through edge test 9. Re-pinned.
- wr579: the flag-off controls' face test 36 was the same corner pattern at
  the tube ends. Re-pinned with no test 36.
- All 283 goldens units pass unchanged, and the kernel rig passes.

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

* Tests: the #13295 action_matrix case as rustfmt lays it out

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 22:12:03 +00:00
Andrzej Błasik fc403974be Agent commands: the window no longer freezes when you edit the design while the agent pointer is showing a batch (#15155)
With the agent cursor shown, the batch was computed on a copy of the design while the pointer moved. If you edited the design meanwhile, that result no longer matched, and the batch was computed again on the window thread when the pointer arrived, so the window stopped answering for as long as the batch takes to compute.

Now the cursor path does what a command without the cursor already did: a result computed for an older design is computed again off the window thread, and the batch lands after your edit once that is in.
2026-11-11 22:06:18 +00:00
bherbruck 6cc9d44fa5 PR checks: flag a kernel fix that also edits the existing tests or goldens it is judged by (#15148)
* PR checks: a kernel change that edits existing test or golden lines must say why

A new "Test change check" job in pr-checks.yml fails a pull request that changes kernel source
(Rust under solidcore/ or crates/smlib/, not tests or goldens) and also changes or deletes
existing lines of a test file or golden expectation, unless the description has a line
`Test change: <reason>`. Added tests pass, as do imports, comments, blank lines and lines that
were only moved or re-indented, and the shrink-only known-failing and quarantine ledgers.

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

* test change check: count re-added lines one for one, skip block comments, treat *.test.js as tests

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 22:00:23 +00:00
Homero Thompson 3cd75d5b52 UI: the 3D mouse picture follows the Source assignment and the driver mapping (#14337) (#14890)
* UI: the 3D mouse picture reads the device the way the view reads it

The drawn cap in the corner of the 3D view showed the device's raw
readings: the picture ignored the per-axis Invert choices and the axis
assignment the user configured, so a device whose tilt arrives reversed
showed its cap leaning the wrong way, the opposite of what the view did.

The picture now goes through the same two steps the view does: the row
takes the motion it reads, and the user's own inversion is applied to it.

Task: https://github.com/tangency-labs/solidcraft/issues/14337

* UI: the 3D mouse picture follows the driver's axis mapping too (#14337)

The corner picture read the device as the calibration, the axis assignment and the
user's own inversions had it, but not the driver's physical-to-logical mapping, so it
disagreed with the view wherever that mapping sends an axis somewhere else: 3DxWare's
object mode, which every device without a profile gets, swaps the tilt and the twist,
so the tilt the view rolls on read as a twist in the picture.

The picture now takes the mapping navigation really used (app.space_mouse.mapping,
the driver's profile when there is one) and shows each motion on the axis the view
drives it with.

As the maintainer asks in #14337, the user's own inversions stay with the view: they
are a matter of taste, and the picture shows the device, not the view.

https://github.com/tangency-labs/solidcraft/issues/14337

---------

Co-authored-by: vdirienzo <noreply@github.com>
Co-authored-by: vdirienzo <vdirienzo@users.noreply.github.com>
2026-11-11 21:51:01 +00:00
bherbruck 91695f7488 Layers check: cap each crate's paths into solidcore internals so the count only falls (#15147)
* layers check: count each crate's paths into solidcore internals, and let the count only fall

The seam list allows `crates/smlib -> solidcore` and `crates/geom -> solidcore` at the crate
level, so any number of paths into solidcore's internals passed (#15031). `cargo xtask layers`
now also counts, per crate, every path into a solidcore crate that is not public (each leaf of a
use tree, macro bodies included; comments and strings excluded) and compares it with
xtask/solidcore-paths.txt: a count that goes up fails, and one that goes down must be lowered in
the same pull request. `cargo xtask layers --paths [crate]` lists the counted paths.

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

* layers path count: count `use ::solidcore::...`, fail on unreadable directory entries, reject zero baseline lines

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 21:50:06 +00:00
Brett Lawson b7efc29dbc Sweep along a path with corners: join the runs' pieces in a balanced union, roughly halving dense polyline sweeps (#14451) (#15097)
* Sweep: claim the product side of #14451 (dense polyline paths)

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

* Sweep along a path with corners: join the runs' pieces in a balanced union (#14451)

A sweep along a path with corners unioned each run's piece onto the body
built so far, so every union took in the growing body: along a densely
sampled polyline (181 points, 180 runs) that is 16,289 pieces' worth of
union operands, growing with the square of the points. The pieces are now
joined neighbours first in a balanced tree of the same union (1,380 for
180 runs). The solid is the same point set; each union is still
Brep::boolean with its tolerance growth.

Tests: a 33-point rail-along-polyline sweep counts the union operands
(160, against 527 joined one by one) and checks the analytic volume; a
unit test checks union_tree joins only neighbours.

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

* Sweep corner union: keep union_tree private to smlib_sweeps.rs, with its unit test in the file (#14451)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 21:41:13 +00:00
Homero Thompson d231a38b92 Sketch: test that a line starts on the projected edges of a picked extruded face (#14796) (#15056)
The Line tool's first click must start a line on the edges a sketch projects
from the face it sits on. On a face picked in the viewport, the projected
edges are linked geometry and the click's snap has no sketch point id until
the command materializes it.

Adds the case the existing line-start tests leave out: a rectangle drawn in a
sketch and extruded, a new sketch picked on the extruded face, and a line
started on each of the four edges that face projects.

Co-authored-by: vdirienzo <vdirienzo@users.noreply.github.com>
2026-11-11 21:33:45 +00:00
leValdemar19 722b3bf40e Assistant: the sign-in box scrolls above the message box in a short docked panel (#15088)
* claim: #15087

* Assistant: the sign-in box scrolls above the message box in a short docked panel (#15087)

With the default panel layout, the "Sign in to the agent" box was drawn below
the agent list's scroll area instead of inside it. When an agent offers two
ways to sign in, the box was taller than a small window's docked Assistant: it
pushed the message box off the bottom, cut off "I've signed in: connect
again", and the new-chat tip was drawn over the message box.

The box now scrolls inside that area in both layouts, and while sign-in is
needed the area may use everything above the message box.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 21:31:23 +00:00
bherbruck 074ab8fc1c Copy works again on the desktop: eframe's clipboard feature is back on (#13349) (#15144)
eframe 0.37 builds egui-winit without its clipboard unless asked, and the
workspace turns eframe's defaults off, so every copy on the desktop (the error
text, Copy diagnostic information, Ctrl+C in a field) reached the platform and
went nowhere. Pasting broke the same way. 0.36 asked for the feature itself.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 21:29:57 +00:00
bherbruck 058b9dd9ca solidcore: retire fillet_cleanup_removes_input_slits (#14246, batch A3) (#15117)
With WR-459's keep, the fillet clean-up now always checks a kept edge with one
face on both sides (a slit) and needs two faces for the kept-faces test
(WR-602, tangency-labs/smlib@cac11ea). The flag, its preset line, fixes.json
entry, goldens enable_fix arm and test settings go; the off half of the
fork_cac11ea test is deleted. issue_423's keep test is re-pinned: its kept
edge is a slit that WR-602 removes (1 one-face edge with and without the keep);
the lost two-face coverage is #15059. No goldens change (283/283).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 21:18:44 +00:00
bherbruck b29d39cd1b solidcore: derive the extension's iso limit and the meridian allowance from counted roundings (#15039) (#15122)
certify_iso (surface/extend/analytic.rs) held an extended analytic's iso to
8 gamma_32 (mag + |height| |w|), a factor described as tuned. The limit is now
the count of the test's roundings, at the size of each iso point's stencil
(the net points its de Boor levels read): gamma_(12L+6) A_j for the point and
its test, plus the plane's own (a parallel's height read off the first point,
gamma_(12L+7); a meridian's normal unit(axis x far), gamma_(12L+8) + gamma_12),
rounded outward by (1 + gamma_16). The count is in the doc comment and in the
section 4.4 table.

meridian_rounding (surface/sphere.rs) took the fitted centre's error equal to
a point's, 4 gamma_K |P| / r. IsArc's end angle moves 6.02 delta / r for a
uniformly sampled half turn (5.98 for two rational quarter turns) when its
four points move delta along the gradient, so that allowance did not bound its
case. The allowance is now S 1.75 (gamma_K + gamma_33) |P| + 3.5 gamma_4 |P| +
gamma_31 pi r, with S the end angle's first-order conditioning read off the
fit (end_angle_conditioning, from the circumcircle's M^-1), so a fit whose
samples close in is not assumed well conditioned.

Tests: the iso limit's test perturbs a parallel's point by 0.8 and 1.2 of the
counted limit; the_meridian_allowance_bounds_its_fit reproduces the 6.02 and
5.98 sensitivities, shows them past the old 4, and checks S against them.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 21:01:52 +00:00
leValdemar19 eb02f190c0 Box: changing the size of a box placed by its centre keeps it centred (#14487) (#14603)
* claim: #14487

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

* Box: a box placed by its centre stays centred when its size changes (#14487)

solid.box with center saved only the corner it computed, so a later length,
width or height grew the box from that corner. The Box feature now saves
centred: true with the centre as its point, and building it takes half of
each size off that centre. The Box dialog reopens such a box with Center
chosen, and redefining it passes center again. Saving the flag is a new
design-format change (format/pending/box-centred.rs).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 20:53:55 +00:00
Brett Lawson 95293cf846 solidcore: blend failures carry a typed reason (part 10: standard solver p2) (#15011)
* solidcore: claim blend typed errors part 10 (standard solver p2)

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

* solidcore: standard solver p2 failures carry a typed blend reason (part 10)

The 28 bare statuses in blend/fillet_standard_solver/p2.rs become
BlendFailure Failed/Missing, each keeping its operation, status and
kind; as_sm_err maps to "a stage the solver calls failed" as p1's does,
still passing Unported/Cancelled/InvalidInput/PortInvariant through. The
file's tests pin the typed reasons (parallel offset normals, the missing
pseudo-B-rep, the missing reference curve) and read the status. The
file leaves the WAITING ratchet.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 20:33:55 +00:00
bherbruck d9853de778 solidcore: the body check checks a face whose loops have no UV trims (#15040) (#15121)
check_face returned Ok for a face whose edge uses had no UV trim curves, so the
body check passed it without any test, and the same body failed Loop
Orientation once an operation had made the trims: a check's answer depended on
whether a cache was filled (definitions-and-certificates.md §7.2).

check_face now makes the missing trims and checks the loops like any other
face; a face whose trims cannot be made fails the UVTrimCurve test (report 1)
instead of passing. The body check (validate_pointers) does this on a copy
standing for the body (clone_standing_for_self, made once, at the first such
face), so checking does not change the body it checks and no operation's
result moves.

Golden: boolean.api_brep issue2177_crescent_on_top_of_rim_checked reports one
more failure, Loop Orientation (TrimmingTools #3) on a face that was not
checked before. That body is upstream's broken union (an edge 38.16 off its
face, #59/#82 already reported), and its record is unchanged.

Test: issue_1419_brep_check::a_face_without_uv_trims_is_checked_like_one_with_them
(a planar sheet with its outer loop reversed fails the same lines without
trims as with them; a sound one passes either way). It fails on main.

Known failing: issue_2972's quarter shell at r 1e4, t 1e-3 is not an outward
solid once its faces are checked: two loops come out zero area in
compute_loop_orientation. Main fails it the same way once the trims are made.
Split out as the_far_thin_shell_is_an_outward_solid, tracked in #15107.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 20:33:53 +00:00
Brett Lawson 75c8acac5b solidcore: blend failures carry a typed reason (part 8: the rest of fillet_geom) (#14972)
* solidcore: claim typed blend failures for the rest of fillet_geom

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

* solidcore: the rest of fillet_geom's failures carry a typed blend reason (part 8)

Converts the 89 bare statuses of blend/fillet_geom/p3.rs (27), p6.rs (53),
surface_plane_curve.rs (3) and tangent_end_rib.rs (6) to BlendFailure:
Missing for null dereferences (NER) and Failed for SE(SM_ERR), each with its
own phrase. The local nul/fail closures take the phrase. Each site keeps its
operation, status, kind and entity, so every decision that reads a status
behaves as before. The four files leave the WAITING ratchet.

Pin tests in each file's own test module check the exact typed failures and
their statuses (tangent_end_rib gains a test module for its one). The
fillet_solver p5 test that matched a bare Upstream from re_calc_fillet_geom
reads the typed failure, and the blend.fillet_geom_p6 goldens runner reads
the status through blend_status.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 20:24:37 +00:00
bherbruck d415d2b496 docs(rfc): RFC 0002 plugins, record decisions and phase 1 review gate (#15110)
* docs(rfc): record plugin decisions in RFC 0002

Records the owner's decisions (one MCP command surface, WebAssembly as the
only runtime, no network at first, host-drawn UI through #15100), reserves
the net permission for signed official plugins, maps phases to PRs, and adds
a design-review gate on the phase 1 PR.

Refs #1221.

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

* docs(rfc): note #4980 closed in RFC 0002

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

* docs(rfc): add agent-first section to RFC 0002

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

* docs(rfc): add agent-first to the RFC 0002 decisions list

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

* docs(rfc): add shared-across-Craft-apps section to RFC 0002

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 20:01:29 +00:00
bherbruck f6d044089c solidcore: the pipe's bend check is refusal-only; restore the cases' own inputs and bounds (#15038) (#15104)
path_bends_within decided at 16 samples per knot span whether a pipe folds, and
its false let the pipe through. It is now bend_found_within, returning the
parameter of the bend it found: a found bend refuses the pipe, none found proves
nothing and the pipe is built unchecked as upstream builds it (§7.6). The
refusals it makes are unchanged. A certified lower bound on the radius of
curvature per span is noted as open work.

#14640 changed general_path_sweep's ledger case to pass: the balance wire's
spline fitted open instead of closed, the pipe 0.5 instead of 0.75. The case is
restored as written, in its own test marked known failing: the closed fit bends
with radius 0.041, so the 0.75 pipe is refused. The ledger test keeps every
other case and leaves path-spline to that test.

issue_3093's volumes were compared by an overlap of enclosures of any width
after #13680, where the case had compared numbers to 1e-9 relative. Each volume
is now enclosed to a quarter of 1e-9 of itself and every point of one enclosure
must be within 1e-9 of every point of the other. Done that way, the enclosure
proves the cap case's pinned 757.499499825162 (the estimator's answer at
accuracy 1e-6) 3.0e-9 too large, so the case pins the certified volume,
757.499497557, with its provenance.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 19:57:01 +00:00
bherbruck 9e805b7bdc blend certificate: a cone's continued part is checked off the axis back to its end, so a box wholly past the apex is refused (#14284) (#15092)
The apex guard proved the continued part's own points off the cone's axis. A feet's box lying wholly past the net's end gives a part that does not touch the end; past the apex such a part lies on the other nappe, off the axis itself, and was accepted (CodeRabbit on #14986). The check now runs over the part taken back to the end it lies past (each direction's range hulled with that domain end): that set is connected and holds points of the end, which are the face's, so proved off the axis it lies on the face's nappe, and a part wholly past the apex is refused, since the way back crosses the axis. One more order-0 enclosure, only for a cone's part past an end.


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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 19:30:50 +00:00
Nathan Dane b6aeff5c17 Move Render panels to the shared host (#11312) (#14800)
* claim: #11312

* Move Render panels to the shared host (#11312)

* Exercise the short Render Assistant viewport
2026-11-11 19:13:33 +00:00
Brett Lawson a2e7947eac solidcore: three routines return their warning with the result instead of as an error (#15020) (#15023)
* solidcore: claim #15020, three routines return their warning with the result

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

* solidcore: three routines return their warning with the result instead of as an error

Plane::intersect_with_plane returns its near-singular warning
(PlaneIntersectWarning, upstream SM_ERR_WARNING) as a third value;
CurvePropertyGs::local_solve_branch_with_note returns the corner-on-axis note
(CurvePropertyNote, upstream return TRUE); orient_sheet_surfaces returns
Option<OrientSheetNote> (upstream final TRUE). Callers that stopped on the old
status stop on the note through into_error/stop_on_warning/stop_on_note, which
keep the status, kind and operation, so results and refusals are unchanged.
The surface ratchet allows nothing, brep/p9.rs holds 0, curve/curve/p3.rs one
fewer.

Fixes #15020

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 19:03:03 +00:00
bherbruck 47c57ecee6 CI: the web offline test no longer blocks while #14591 is open, and prints how the leaked request was served (#15074)
The check 'the open v3 page was given another build's file after the update' still fails a queue batch now and then, on main that has the takeover fix, with the page record naming v3's own copy. The step reports a failure as a warning instead of failing the job until #14591 is fixed, and the failure message now says whether the request went through the worker (resource timing workerStart) and what the registration and controller were, to show the path the file took.

Changes a default: no

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 18:56:44 +00:00
Brett Lawson 96c894966b solidcore: blend failures carry a typed reason (part 9: the solvers) (#14973)
* solidcore: claim blend typed errors part 6 (fillet_geom p4, p5)

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

* solidcore: fillet_geom p4 and p5 failures carry a typed blend reason (part 6)

Converts the 176 bare statuses of blend/fillet_geom/p4.rs (63) and p5.rs
(113) to BlendFailure: Missing for null dereferences (NER) and Failed for
SE(SM_ERR), each with its own phrase. The local nul/null closures take the
phrase. Each site keeps its operation, status, kind and entity, so every
decision that reads a status behaves as before. Both files leave the
WAITING ratchet. The pin tests in p4's and p5's test modules check the
exact typed failures and their statuses; the tests in fillet_geom/p3,
fillet_solver/p4 and fillet_solver/p5 that matched a bare Upstream from
these routines read the typed failure.

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

* solidcore: fillet_solver p4 test reads calc_cross_section's typed failure

The self-intersection test's CalcCrossSection case matched a bare
SM_ERR_NULL_POINTER Upstream; it now checks the typed blend failure (the
edge has no original face) and the status it keeps. The p4 and p5 pin
tests drop an unneeded path qualification clippy rejects.

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

* solidcore: claim blend typed errors part 9 (the solvers)

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

* solidcore: fillet solver failures carry a typed blend reason (part 9)

The bare statuses in blend/fillet_solver/, blend/fillet_custom_solver.rs
and blend/fillet_standard_solver/{p1,surface_surface,touch_circle}.rs
become BlendFailure Failed/Missing, each keeping its operation, status
and kind. Local nul/none/missing closures take a phrase per call. Tests
and the fillet_solver p2/p3/p5 goldens runners read the status
(upstream_status / blend_status) in place of matching Upstream; pin
tests check the typed reasons. The fifteen files leave the WAITING
ratchet.

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

* solidcore: trim_rail builds its missing-curve failures lazily

clippy's or_fun_call: the two uv trim curve checks use ok_or_else.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 18:54:07 +00:00
Brett Lawson 3a6fcb7aa3 solidcore: blend failures carry a typed reason (part 6: fillet_geom p4, p5) (#14952)
* solidcore: claim blend typed errors part 6 (fillet_geom p4, p5)

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

* solidcore: fillet_geom p4 and p5 failures carry a typed blend reason (part 6)

Converts the 176 bare statuses of blend/fillet_geom/p4.rs (63) and p5.rs
(113) to BlendFailure: Missing for null dereferences (NER) and Failed for
SE(SM_ERR), each with its own phrase. The local nul/null closures take the
phrase. Each site keeps its operation, status, kind and entity, so every
decision that reads a status behaves as before. Both files leave the
WAITING ratchet. The pin tests in p4's and p5's test modules check the
exact typed failures and their statuses; the tests in fillet_geom/p3,
fillet_solver/p4 and fillet_solver/p5 that matched a bare Upstream from
these routines read the typed failure.

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

* solidcore: fillet_solver p4 test reads calc_cross_section's typed failure

The self-intersection test's CalcCrossSection case matched a bare
SM_ERR_NULL_POINTER Upstream; it now checks the typed blend failure (the
edge has no original face) and the status it keeps. The p4 and p5 pin
tests drop an unneeded path qualification clippy rejects.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 18:54:04 +00:00
ChrisNo 491a71759a Trim by a projected curve: stop with an error when a face's side of the curve cannot be found, instead of keeping the wrong face (#13679) (#14701)
* claim: #13679

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

* Trim by a projected curve: stop with an error when a face's side of the curve cannot be found (#13679)

ProjectAndTrim votes each face's side of the projected curve with
sm_FindSideOfCurve at its vertices and edge midpoints, then at a ray witness.
Every failure of that call was read as a point on the curve. On a cutter with
a 1.5e-12 knot span the projected minimum returns no solution for points
whose nearest curve point is on that span, so a face whose every vote failed
was taken to lie opposite the reference point: the trim returned success with
the wrong face kept or deleted.

p1::side_of_curve now tells the cases apart. A point on the curve, and a
point with no solution where the curve may be nearest at an end (a point
beyond a finite cutter), stay undecided votes. No solution where the curve
measures nearer at a knot or at one of 64 steps of its interval than at both
ends, by more than the vote tolerance, is a missed nearest point: the trim is
refused before any face is removed. find_side_of_curve keeps its one status.

Goldens: topology.brep_p10, 8 records re-blessed, nothing else moves in the
283 units.
- raw_vote_midpoint_gap{1.5e-12,1.9e-12}_trim0: face_counts [1,0] -> [1,1].
  The lens is on the reference side (its edges are Beziers ending on the
  cutter's middle piece with the inner control point below it), so deleting
  it under KeepPoint was wrong; the trim now refuses and leaves it.
- raw_vote_midpoint_gap{1.5e-12,1.9e-12}_trim1: [1,1] with success (wrong,
  the face should go under DeletePoint) -> [1,1] refused.
- raw_vote_vertex_gap{1.5e-12,1.9e-12}_trim0/trim1: [1,1] and [1,0] with
  success -> [1,1] refused. These were right on one vote of eight; the other
  seven projections had no solution.

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

---------

Co-authored-by: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 18:44:46 +00:00
Ehuntabi 2336beb6c4 Export Current View as DXF: write round holes and arcs as circles and arcs, not many short lines (#14916) (#14935)
* claim: #14916

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

* Export Current View as DXF: write round holes and arcs as circles and arcs, not many short lines (#14916)

The view drawing is built from short straight lines, so a round hole reached
the DXF as a ring of LINE entities and a milling program could not pick it as
a circle. The command takes a new option, `curves`: with "arcs", each ring of
visible lines that lies on one circle is written as a CIRCLE and each run as
an ARC. A polygon with few sides stays as lines. The default, "lines", writes
the same file as before.

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 18:26:22 +00:00
P'tite Tête 513e44a5f5 test: Kernel cases for a revolved dome 1e-8 off joined flush (#3011) and a cut 1e-4 apart (#3012) (#14077)
* test: Kernel cases for a revolved dome 1e-8 off joined flush (#3011) and a cut 1e-4 apart (#3012)

- booleans/3011-revolved-dome-1e-8-off-joined-flush-to-a-cylinder, -joined-to-its-mirror: expected 6806.784 and 4188.790 mm3, one body; not operation-expressible while the join is refused in SolidCraft (#3011 open)
- booleans/3012-filleted-boxes-1e-4-apart-cut, -reversed: expected 0.03 mm3, one body each; pass since solidcore#3074, kept as regression guards
- known_failing.json unchanged; manifest booleans 125 -> 129 cases

* test: Kernel cases: the two 3011 domes replay and pass since #14348, with renders

- 3011 entries switched to replayed (operation_expressible and expressible true, result the last boolean's output); rig, outcome and traced_ops dropped as for the 3012 entries
- what: the check refused the result until #13713 was fixed (#14348)
- manifest.json: booleans expressible 114, not_expressible 12, operation_expressible 117, chain 9 -> 3 and 12 -> 1, the two 3011 reasons removed
- renders of the four cases' results drawn with solidcore-diag from KERNEL_CASES_SMB

---------

Co-authored-by: P'tite Tête <asso@ptitetete.com>
2026-11-11 18:25:52 +00:00
bherbruck 0e7913f9f7 skin: say which profile curves each side face was swept from, traced through the construction (tangency-labs/solidcore#3139) (#15062)
`create_skin_primitive_with_sources` builds the skin `create_skin_primitive` builds, bit for bit, and returns, for each side face, the indices of the curves it was swept from.

The trace runs through the construction:
1. Each curve's edge in its planar profile face.
2. That edge's segment of the loop's joined B-spline (`create_by_joining_with_breaks`).
3. The segment's span of knots.
4. The knots each face spans across the sections after the G1 split.

These are compared exactly, with no tolerance and no geometry. The answer holds where every section's joined curve has the same degree and knots: the skin's compatibility step is then the identity, which the trace checks against the surface's own knots. Otherwise it returns `SkinSources::NotTraced { why }`: open profiles, sections that are not knot-identical, or knots the skin moved. A controlled sweep's sections are affine images of one profile, so it is always traced. The default paths are unchanged, and no golden moves.

Written by w2949 in the swarm's batch tree (e271549a87), carried onto main by w2695: two hunks re-placed on main's typed errors (the b_spline_curve p2 module doc, the skin's layer-count check). Review fixes (w2878): a failed read or a construction that stopped before the side faces now has its own reason (JoinedCurveUnreadable, SurfaceKnotsUnreadable, FaceDomainUnreadable, SideFacesNotReached) instead of borrowing another's; the planar faces' edge-to-curve zip is count-checked (a mismatch records no edge: EdgeUntraced, never a wrong wall); the duplicate api_surfaces table row is dropped; every test body is compared bit for bit plain against traced; SkinMovedKnots and EdgeUntraced are unit-tested.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 18:20:18 +00:00
ABDULLAH 5d0b21b7e0 Arabic: vertical term in the text alignment row, real first letters in the dialog-button help, clearer file-wait note, Select term in Construct (#15081) 2026-11-11 18:17:26 +00:00
bherbruck e808acf31d Quarantine render_workspace native_preparation_disconnected_retirement_... (#15077) (#15078)
It failed two queue batches whose PRs do not touch it, 'disconnected cleanup keeps real credit owned' (left 0, right 267473), and did not fail locally in 200 runs.

Changes a default: no

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 18:06:03 +00:00
Nathan Dane c39966aae0 Move Bill of Materials to the shared panel host (#11315) (#14806)
* claim: #11315

* Move Bill of Materials to the shared panel host (#11315)
2026-11-11 17:57:14 +00:00
bherbruck bef6cc1918 blend certificate: a cone's net continues past its end along its generator, off its apex (#14284) (#14986)
#14284 continued a support's net past its end where its kind is a line in that direction (a plane, a cylinder, an extrusion) and refused a cone. A cone is a line in v for each u too: P(u, v_e) + (v - v_e) P_v(u, v_e) is the cone's own generator through its edge point, so the end span's polynomial continued is the cone there, as it is the cylinder's line.

EndContinuation::of now continues a cone along its generator (the same swap_uv rule as the cylinder) and carries its axis (ConeAxis, the canonical frame's origin and z axis, from the cone's own data) when the cone is not a cylinder. The generator continued past the apex is the other nappe, which is not the face: a part of a box past an end is enclosed only where its points are proved off the axis (some coordinate of their radial vector P - O - ((P - O).Z) Z bounded away from 0 over the part). The part is connected and holds the end's points, so it then lies on the face's nappe. Otherwise it is refused as FootPastDomainEnd, as any end the kind does not continue.


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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 17:45:40 +00:00
Brett Lawson 4d47ac033f solidcore: blend ease and rational-section failures say what failed (#15024)
The setback ease (`session/ease.rs`) and the rational fillet section
(`fillet_exact/rational.rs`) sent every failure through one local helper
with one fixed phrase. The helpers now take the phrase, and each of the
67 calls names what was missing or what failed. Variants, statuses and
`upstream_status()` are unchanged.

Part of #15019.


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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 17:26:10 +00:00
Brett Lawson 9b5db6c002 solidcore: blend failures carry a typed reason (part 5: blend corners and setbacks) (#14947)
* solidcore: claim blend typed errors part 5 (blend corners and setbacks)

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

* solidcore: blend corner and setback failures carry a typed reason (part 5)

The 101 bare statuses in blend/blend_corner.rs, blend/blend_corner/* and
blend/blend_setback.rs become BlendFailure Failed/Missing with a phrase, keeping
each site's operation and status. A setback Refusal's SM_ERR now carries its
reason as the failure's phrase. The nine files leave the ratchet's WAITING list.
Pin tests in each file's own test module.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 17:26:08 +00:00
0rdnungsamt 08be6d6743 Schemas: Insert STEP describes its exact parameters and reply (#14918) (#14951)
file.insert_step gets a typed input contract (InsertStepParams) and an output
schema (StepInserted) in a new schema/insert_step.rs. Each key is read where
the handler reads it and fails with the handler's message; `up` and `names` are
the shared types doc.open publishes and `matrix` and `tolerance` those of
occurrence.move, so the three commands describe one shape. add_step now builds
its reply from the reply type, as add_mesh does, so the reply type is the one
place the reply's shape is written; Placed::report, which no import uses now,
is gone. The command's line leaves schema/unconverted.txt.

Nothing changes for a call that works today: a call with a `name` that is not
text, which the handler reads as if it were left out, is now reported in warn
and refused in strict, as the Insert Mesh conversion (#13341) already does.
2026-11-11 17:16:13 +00:00
jyviko a4b94a84a0 solidcore: free curves fitted and refined, bounded patches, isoparms (#14913)
* solidcore: modules for fitted curves, knots and degree, bounded patches and isoparms

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

* solidcore: modules for a curve set's plane, the two-rail sweep and record curves as sweep curves

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

* solidcore: a free curve's knot inserted or removed, its degree raised

curve_knots::insert_knot(curve, t), remove_knot(curve, t, tol) and
set_degree(curve, degree) on a record spline or rational spline, through
the kernel's knot insertion, knot removal with its error bound and degree
elevation (The NURBS Book, A5.1, A5.8, A5.9). A removal past tol is
refused with NoEffect; a lower degree is refused as Unsupported until the
kernel bounds how far a reduction moves the curve.

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

* solidcore: a free curve through its edit points

curve_fit::ep_curve(points, degree): the kernel's global interpolation
at chord-length parameters, a clamped B-spline on [0, 1] through every
point, refusing repeated, non-finite or too few points and a bad degree.

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

* solidcore: a typed refusal for patch boundary curves that do not meet, with the gap

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

* solidcore: patches bounded by curves, a Coons or n-sided boundary patch and a ruled surface

surface::boundary turns record curves meeting end to end round a loop into a
bilinear Coons patch (four) or the kernel's n-sided fill (three, five and more:
N four-sided faces stitched round a centre); surface::ruled joins two curves.
Each curve is taken over its natural range and turned to run on from the one
before; joins are proved by the kernel's distance within the zone tolerance,
else Error::BoundaryGap names the join and the gap.

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

* solidcore: the uniform knots of a closed curve made from control points

knots::periodic_uniform(count, degree): count + 2 degree + 1 knots
(i - degree) / count, unclamped, so a polygon that repeats its first
degree points after its last makes a curve on [0, 1] joining itself with
continuity degree - 1 (The NURBS Book, 12.2).

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

* solidcore: a certified bound of how far apart two B-spline curves are

crv_deviation::certified_distance(a, b): both cut into Bezier pieces over
the union of their breakpoints and raised to one degree; on each common
span the difference lies in the hull of the control points' differences
(Farouki and Rajan 1987), so the largest of them bounds |a(t) - b(t)| at
every t, with the rounding of the insertions, the elevation and the norms
carried as Higham's gamma. Non-rational 3D curves on one clamped domain.

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

* solidcore: a knot removed or a degree lowered within a certified tolerance

curve_knots::remove_knot and set_degree(curve, degree, tol) now make the
change and keep it only when the kernel's certified distance from the
curve as it was (crv_deviation::certified_distance) is within tol;
otherwise they refuse with the new Error::PastTolerance { bound }. A
lower degree runs the kernel's reduction once per degree. Rational
splines are refused for both until the bound takes them.

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

* solidcore: closed free curves, and a curve rebuilt with a certified bound

curve_fit::closed_cv_curve(points, degree): the periodic B-spline on the
points, its polygon repeating its first degree points after its last, on
the kernel's unclamped uniform knots on [0, 1], smooth across its seam;
curve::move_cv moves a repeated point with its copy.

curve_fit::rebuild(curve, degree, spans) -> (curve, bound): the curve
sampled at even steps of its parameter on [0, 1], fitted by least squares
at those parameters on uniform clamped knots through its ends, with the
kernel's certified bound of the distance between the two at every
parameter. Rational and closed periodic curves are refused for now.

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

* solidcore: a sheet's interior isoparms for display, clipped to each face's boundary

isoparms(sheet, u_count, v_count, tolerance) draws u_count lines of constant u
and v_count of constant v, evenly inside each face's parameter box, cut where
they cross the face's boundary (even-odd over its loops) and sampled by the
chord rules curve_points uses. The surfaces and the (u, v) boundaries are the
mesher's own: plan_at's steps up to the face boundaries are now
mesher::prepare, which plan_at calls, so the lines follow what is drawn.

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

* solidcore: a certified bound of how far an interpolant misses its points

crv_deviation::interpolation_bound(curve, params, points): the largest
computed distance between the curve at each parameter and its point,
rounded up, plus the evaluation's rounding (gamma_(12 p + 4) of the net's
largest coordinate, the count eval::collapse gives de Boor's levels).

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

* solidcore: free-curve fits refuse a degree past their points and report their bounds

From the review of the curve operations:
- ep_curve and closed_cv_curve refuse a degree not below the number of
  points instead of lowering it; ep_curve returns its certified
  interpolation bound (crv_deviation::interpolation_bound) with the curve,
  tested through its points to 1e-12.
- remove_knot returns the certified distance from the curve as it was;
  set_degree returns its bound (None for a raised rational spline, which
  the bound does not take yet).
- Tests: an inserted knot within the gamma bound at 201 samples, a repeated
  knot removed one copy at a time, every derivative below p meeting across
  a closed curve's seam for degrees 1 to 5.

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

* solidcore: boundary patches of four curves (three: one side at a corner), sides reproduced exactly

Per review: opposite sides are made compatible (the kernel's
make_curves_compatible: one degree, range and knot vector, exactly) before the
bilinear Coons patch, so its sides are the curves themselves; three curves are
the degenerate case with the fourth side collapsed to the corner where the
third meets the first; five or more are refused (Unsupported, no n-sided patch
yet). ruled states its parametrisation, takes a reversed flag, and makes both
curves compatible first. The join tolerance is documented: the kernel's zone
tolerance, against its certified distance.

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

* solidcore: isoparms are display points, not geometry, as their docs now say

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

* solidcore: the plane a set of curves lies in, within a tolerance, certified

curve::plane_of::plane_of(curves, tol) -> Option<PlaneOf { origin,
normal, deviation }>: every curve lies in the hull of points its record
gives (a segment's ends, control points, Euclidean points of a rational
spline, the parallelogram round a conic), so the curves are within tol of
the plane when those points are, each distance bounded with its rounding
(gamma). The plane is plane_of_net's construction: through the first
point, square to the two chords that span the most. None when not planar
within tol or when the points span no plane (one point or one line).

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

* solidcore: the plane of a set of free curves at the zone tolerance

curve_plane::plane_of(curves) -> Option<CurvePlane { origin, normal }>:
the kernel's certified curve::plane_of::plane_of at the model's zone
tolerance, for an extrusion along a profile's normal or a section's
plane; None when not proved planar or when the curves span no plane.

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

* solidcore: a closed curve's display points close and sag within tolerance across its seam

A test of curve_points on a closed periodic B-spline (unclamped uniform
knots, its polygon wrapped, as curve_fit::closed_cv_curve makes it) of
degrees 2, 3 and 5, at a chord and a display tolerance: the polyline's
ends are one point; it is the mesher's own closed-edge sampling (bisect
from four segments) point for point, uncapped; every chord, the seam's
among them, within tolerance at the points the sampling checks; and the
curve on either side of the seam near the chords that meet there.

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

* solidcore: a record circle over an interval as an arc's definition

curve::arc_of::arc_of_circle(frame, radius, interval) gives the ArcDefinition
of the record's circle: centre the origin, reference x, axis x × y, the same
angles; the frame proved unit and square, at most a full turn.

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

* solidcore: record curves as sweep curves, exactly

sweep::Curve::from_record(curve, interval): a line as its points at the
interval's ends, a polyline as a line per segment cut to the interval, a
circle as an Arc (arc_of_circle), an ellipse or a spline as its B-spline
trimmed to the interval; traced intersection curves refused.

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

* solidcore: the patch tests may use the closed forms they check against

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

* solidcore: isoparms clip to the boundary as the planner reads it, poles and seams included

The planner's loop fixes (a loop opened at a pole, a winding face cut into one
period, a whole sphere's pole-to-pole loop opened, holes moved beside the outer
loop, and the free parameter split at a pole) are now one function,
mesher::polygons, which plan_face calls unchanged and isoparms clips its lines
to. So lines on spheres, sphere caps and faces through a pole run whole and
stop at the face. Tests on a whole sphere, a half sphere and a pocketed sphere.

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

* solidcore: isoparm runs pair their crossings with as_chunks

Clippy 1.98 (chunks_exact_to_as_chunks) asks for the array chunks.

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

* solidcore: plane_of's tests borrow one curve as a slice (clippy)

std::slice::from_ref(&x) in place of &[x.clone()] (CI #14913).

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

* solidcore: periodic knots are not clamped, and are read with the general basis

CI #14913: two tests took the clamped-only routines to a periodic curve.
- knots::periodic_uniform's test validated its vector with
  KnotVector::validate, upstream's clamped check (both end knots of
  multiplicity p + 1), which refuses any unclamped vector by definition.
  The test now asserts that refusal, and the doc says periodic knots are
  not for the clamped-only routines.
- curve_fit's closed-curve test read the start with the NLib eval, whose
  N_BasisEval special-cases u == U[p] as a clamped end (N = 1, 0, ...) and
  so answered P0 = (0, 0). The true start of the uniform cubic on the
  square's CVs is (P0 + 4 P1 + P2) / 6 = (50/6, 10/6): the expectation
  stands, read now through derivs (the general basis, the one the seam
  continuity test already passes with).

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

* solidcore: a polyline's interval past its range is refused, not cut short

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

* solidcore: a circle's frame is the arc's only within the arc's own frame error

A frame square to 1e-9 is not the record's circle at a large radius once the
arc's placement makes it orthonormal; the frame is now proved unit and square to
frame_error, a relative bound, so the arc is the record's circle at any radius.

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

* solidcore: the basis at an unclamped end of a domain is the general one

N_BasisEval sets the first (last) basis function to 1 at u == U[p]
(U[m - p]) whatever the knots: right at a clamped end only. On a periodic
curve's domain end it answered the end control point, so the NLib eval of
a closed curve neither started where it does nor closed (review of
#14913). KnotVector::basis_eval takes the shortcut only at a clamped end
(U[0] == U[p], U[m - p] == U[m]), the same bits as upstream there, and
evaluates an unclamped end by the general recurrence. Tests: the uniform
periodic cubic's basis 1/6, 4/6, 1/6 at both ends (it was 1, 0, 0, 0),
the clamped shortcut kept, and the closed square's eval at 0 and 1 at its
true start.

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

* solidcore: isoparms take the body's size, so their tolerance and boundary are the plan's

plan raises the chord tolerance to a millionth of the body; isoparms read it at
size 0, so their edge chords could differ from the drawn ones. isoparms now
take the size plan takes.

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

* solidcore: periodic knots are allocated within the budget, fallibly

periodic_uniform collected count + 2 degree + 1 knots from an iterator,
which reserves from its size hint and aborts on a count too large for
memory (review of #14913). The count is now checked against
limits::MAX_ALLOC_BYTES (InvalidInput past it) and reserved with
try_reserve (NurbsFailure::Memory on failure). Test: usize::MAX / 2
points is refused rather than aborting.

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

* solidcore: a face whose poles cannot be told gets no isoparms, as it gets no mesh

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

* solidcore: an isoparm its sampling bound stops is refused, not handed over past tolerance

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

* solidcore: a moved CV takes its copy along only on periodic knots

move_cv coupled a repeated control point with its copy whenever the knots
were unclamped at the start and the last degree points repeated the
first: repeated points on unclamped knots that are not periodic (a
quadratic on [-2, -1, 0, 1, 2, 4, 5, 6] with points A B C A B, which the
record accepts) moved point 3 when point 0 was edited (review of
#14913). New kernel check knots::is_periodic(knots, degree): unclamped,
and every knot n places on one period past it (U[i + n] - U[i] = T for
i = 0..=2 degree, within the rounding of computed knots), the relation
under which the wrapped polygon closes; move_cv requires it. Tests: the
contract on periodic_uniform knots (also shifted and scaled) and its
refusals, and the open unclamped polygon editing point 0 alone. The
periodic_uniform test checks this contract rather than the clamped
validator.

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

* solidcore: plane_of refuses a bound it cannot form instead of dropping it

With finite points whose chords' cross product overflows (a polyline
through 0, 1e200 x and 1e200 y), the normal came out NaN, every distance
bound NaN, and f64::max dropped them, so a plane with a NaN normal and
deviation 0 was returned (review of #14913). The chord, the normal and
each bound are now checked finite before the bound is taken: a query
failure otherwise, never a plane. Test with those points.

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

* solidcore: the curve bounds refuse numbers not finite

interpolation_bound took a NaN data point or parameter into a NaN
distance that f64::max dropped, returning a finite bound for invalid data;
certified_distance likewise for a NaN control point (review of #14913).
Both check every control point, knot, parameter and data point first
(InvalidInput). Tests for each.

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

* solidcore: the curve bounds check the degree before any arithmetic on it

crv_basic::Curve::new takes any degree, and certified_distance computed
p + 1 from it before refusing anything: a degree of usize::MAX overflowed
(review of #14913). Both bounds now refuse a degree outside 1..=MAX_DEGREE,
or knots that do not number the points plus the degree plus one, first
(InvalidInput). Test with usize::MAX.

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

* solidcore: the curve bounds take distances as scaled norms

A difference of 1e-200 squared underflows to 0, so a line of that length
and a point 1e-200 off it got a bound of rounding only, below the gap;
the control-point distances of certified_distance likewise (review of
#14913). Both now bound each distance by a scaled norm (the components
divided by the largest before squaring, gamma_8 in all, plus the
subnormals a subnormal product can lose), and refuse a difference that
overflows rather than letting f64::max drop it. Tests at 1e-200 for both.

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

* solidcore: certified_distance counts its rounding in the curves' own frame

The rounding of the refinement, extraction and elevation was counted on
the nets' largest coordinate, translation included, so a unit-sized curve
near x = 1e9 compared with itself got a bound above 1e-5, enough for
curve_knots to refuse an exact change at 1e-6 (review of #14913). Both
curves are now moved by minus the first's first control point before
any of that arithmetic runs there (the difference is the same in either
frame; each moved coordinate's rounding, gamma_2 of the size, is added),
so the bound follows their size. Test: the wave at 1e9 against itself
within 1e-9.

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

* solidcore: a certified bound of how far apart two B-spline surfaces are

srf_deviation::certified_distance(a, b), the surface form of
crv_deviation's: both moved into their own frame, cut into Bezier patches
over the union of their breakpoints in u and v and raised to one pair of
degrees; on each common patch the difference lies in the hull of the
control points' differences, so the largest (a scaled norm) bounds
|a(u, v) - b(u, v)| everywhere, with the rounding of the move, the
insertions and the elevations as Higham's gamma. Non-rational 3D
surfaces on one clamped domain; numbers not finite, bad degrees and other
domains refused. Tests: itself and its refinements within rounding, a
moved CV bounded by its move and holding every sampled gap, the bound
independent of placement at 1e9, refusals.

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

* solidcore: a sheet's control net

surface_cvs::control_net(sheet) -> Net { rows, cols, points }: the net
of a record of one untrimmed face on a non-rational B-spline surface.
Untrimmed is proved from the record alone: the face's domain is the
surface's natural box, one loop, every pcurve a spline whose control
points all lie on one side of the box (so, by their hull, the pcurve
does); anything else is refused as a trimmed face (Error::Unsupported),
another record as Error::Argument.

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

* solidcore: the Gordon surface of a compatible curve network, certified

nurbs::gordon (Gordon 1969; The NURBS Book §10.5): S = L1 + L2 - T, the
u-curves skinned in v, the v-curves in u, less the tensor interpolant of the
crossings, at the network's common crossing parameters (cross degree
min(3, count - 1), averaged knots). Crossings come from the kernel's curve
intersector and are proved within the tolerance at the common parameters, else
the network is refused naming the pair; each input curve is certified against
the surface's iso-curve (certified_distance plus the extraction's rounding) and
the largest bound returned. Rational curves and networks out of order refused.

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

* solidcore: typed refusals for a curve network's crossings, naming the pair

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

* solidcore: network::gordon, the Gordon surface through record curves with its certified bound

ops checks the family sizes, converts the record curves as surface::boundary
does (its helpers now shared within the crate), hands the kernel the zone
tolerance and names its refusals (NoCrossing, NetworkGap, PastTolerance,
Unsupported for rational curves, Argument for a family out of order).

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

* solidcore: a sheet's control points moved, and a sheet rebuilt with a certified bound

surface_cvs::move_cvs(sheet, moves): the untrimmed sheet's net with the
points named moved, knots and degrees kept, as a new one-face sheet
(surface::sheet). surface_cvs::rebuild(sheet, deg_u, deg_v, spans_u,
spans_v, tol) -> (sheet, bound): the surface on [0, 1]^2 sampled at even
steps, four per control point each way, fitted by least squares through
its boundary on uniform clamped knots, column by column along u then row
by row along v (approx_knots, The NURBS Book 9.4.3), and kept only when
the kernel's certified distance from the surface
(srf_deviation::certified_distance) is within tol, else PastTolerance.
Tests: a moved CV changes the sheet only over its own span, a bicubic
rebuilt into its own space is itself within rounding, past-tolerance and
bad arguments refused.

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

* solidcore: Gordon certificates in the network's own frame, checked on a patch and a closed grid

The crossing gaps and the curve-to-iso-curve bounds were counted on the
coordinates, so a network far from the origin got a bound of its distance
from it; they are now counted in the network's frame (minus its first control
point, each move's rounding included), as certified_distance is.
Tests: a cubic patch with interior knots in u, Bezier in v, is given back by
its iso-curves to 1e-9 on a grid (four u-curves skinned by cubics reproduce
its v, and L2 = T); a closed 3 x 3 grid whose outer curves meet at their ends
builds with one crossing per pair; the bilinear network 2e5 out keeps a bound
of its own size.

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

* solidcore: KnotVector::basis_eval answers as N_BasisEval again (reverts 00bfb3fe19)

The goldens are the SMLib reference: nlib.funcs_basic changed in 37 of
3130 cases (edge_unclamped, edge_mult_above_degree, gen0020, gen0035, ...)
once basis_eval stopped taking its end shortcut at an unclamped end. A
shared NLib routine keeps upstream's answers; its end shortcut is back,
its doc and periodic_uniform's saying that it reads a domain's ends as
clamped ones, so a periodic curve is evaluated through the general basis
(derivs, eval::CurveEval). The closed-curve test reads the start and the
end of the periodic cubic, (P0 + 4 P1 + P2) / 6, through derivs.

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

* solidcore: interpolation_bound reads a curve through the general basis

interpolation_bound evaluated through the NLib eval, whose basis keeps
upstream's clamped reading of a domain's ends (N_BasisEval), so a
periodic curve's bound at its ends measured from the end control points
(review of #14913, curve_fit.rs:69). It now evaluates through derivs at
order 0 (A2.3, right for any knots; the rounding count unchanged). Test:
the periodic cubic on a square, bound within rounding of its true start
(P0 + 4 P1 + P2) / 6 at both 0 and 1.

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

* solidcore: a sheet on unclamped knots is not sampled for a rebuild

surface_cvs::rebuild sampled the surface through the kernel's surface
evaluation, whose basis keeps upstream's clamped reading of a domain's
ends (N_BasisEval), and the reparametrisation then clamped the knots by
overwriting them, so a periodic sheet would have been rebuilt from wrong
samples under a bound that held for another surface (review of #14913,
curve_fit.rs:69). Knots not clamped are now refused (Error::Argument)
before anything is evaluated. Test: the bicubic sheet with unclamped u
knots on the same domain reads as untrimmed and is refused.

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

* solidcore: isoparms drawn on the display triangles, never behind them

Isoparms were sampled on the true surface, which lies behind the shaded chords
where the surface is concave toward the viewer (a revolve's neck), so the
viewport's face bias buried them in stretches. Each line is now cut by the
face's planned triangles in (u, v) and each piece mapped linearly onto its
triangle, with the node points the mesh evaluates (TessPlan::nodes, factored
out of evaluate, which calls it unchanged). Every point lies on a triangle of
the mesh. The clipping and sampling of their own are gone, with the prepare
accessors only they used. Test: a torus's inner neck, every point on a
triangle of the evaluated mesh within 1e-9.

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

* solidcore: Gordon's tolerance checks refuse NaN without a negated comparison

Clippy 1.98 (neg_cmp_op_on_partial_ord, cast_lossless): the checks now say
what they mean through a small within() that takes a NaN as not within, and
the patch test converts its integers with f64::from.

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

* solidcore: a Gordon network takes record splines: equal weights are polynomial

A record spline's kernel curve carries a weight of 1 on every point, which the
NURB marks rational, so every network holding a spline was refused as
rational (sc-b, through SurfaceCraft). A curve whose weights are all one value
is now taken in its polynomial form (exact); only unequal weights are
rational. Tests: curves weighted 1 throughout build, unequal weights are still
refused; in ops, a network of clamped cubic splines and lines builds.

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

* solidcore: surface_cvs's span test asserts with assert_ne (clippy)

clippy::manual_assert_eq on #14913: assert!(a != b) becomes assert_ne!.

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

* solidcore: isoparm pieces join by the mesh's shared sides and nodes, and run into poles

CI (#14913): a sphere's meridians stopped short of its poles, and pieces were
joined by a distance along the line. Each piece now records the side or node
of the triangulation each end crosses, and pieces join only where one ends on
the very side or node the next starts on. A polyline ending on a side at a node
where the surface collapses along the line (a pole it runs into, by the
planner's classifier within the plan's chord tolerance) is carried along that
side to the node, so meridians reach the poles. The test's needless crate::
qualification is dropped.

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

* solidcore: isoparm walk in clippy's forms (field name, while let, if let)

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

* solidcore: Gordon's working arrays are reserved fallibly, their counts checked

The panic-site inventory flagged eight Vec::with_capacity(x + 1): the + 1 can
overflow and the allocation abort. They now go through room(), checked_add
and the kernel's try_reserve_total (the allocation bound), refused as
InvalidInput.

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 17:15:03 +00:00
Brett Lawson a3d747c88d solidcore: blend failures carry a typed reason (part 2: fillet_geom p1, p2) (#14933)
* solidcore: claim blend typed errors part 2 (fillet_geom p1, p2)

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

* solidcore: fillet_geom p1 and p2 failures carry a typed blend reason (part 2)

Converts the 168 bare statuses of blend/fillet_geom/p1.rs (62) and p2.rs
(106) to BlendFailure: Missing for null dereferences, Failed for SE(SM_ERR),
BadIntersections for sm_TwoPntsIntersection's empty start-point drop and
NotConverged for FindParameterAtArcLength under SER. Each keeps its operation,
status, kind and entity. Both files leave the WAITING ratchet; the tests that
matched Upstream read the typed failure and upstream_status.

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

* solidcore: tests and the fillet_geom_p3 runner read p1/p2 failures by status

Tests in fillet_geom p3/p4/p5, fillet_solver p3 and p5 and wr682 that matched
a bare Upstream from a p1/p2 routine now read upstream_status; the
blend.fillet_geom_p3 goldens runner reads goldens::output::blend_status, so
its rows stay the same.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 17:02:20 +00:00
bherbruck 30b7a56a94 Web: inserting a file from the browser's file panel uses the staged loader (#15042)
Opening a file from the panel already used it; inserting ran the import on the window thread, so a large STEP insert stopped the page from drawing until it was done. From #2322.

Co-authored-by: InfraInnovator <168700057+InfraInnovator@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 16:52:42 +00:00
bherbruck aa309e727a STEP import: keep a face's pcurves inside its domain when its NURBS form ends on a quarter line (#15029)
Port of #14262 to solidcore-step. Where a face's extent ends within the file's tolerance of a quarter line, form::surface_within ends the form on that line, but the face's pcurves were not moved to match: a pcurve the kernel projected (SmlibTrims) could start up to that tolerance before the form, outside the face's domain, and SMLib's check refused it ("Edge endpoints within face domain", tests 70 and 71). onto_form moves a pcurve control point past the form's end by at most the form parameter of the tolerance onto the end; the pcurve then lies on the form, and raise_edges measures it against its edge. A point further out is left for the record check.

Co-authored-by: Nachumi Eliyahu <nachumi@a2zoperations.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 16:52:39 +00:00
Andrzej Błasik a0578030d3 solidcore: the body check skips coincidence queries that certified span boxes rule out (#14250) (#14910)
The coincident-topology search of the body check makes one geometric query
for every vertex/face and edge/face pair whose bounding boxes meet. A
free-form face has one box for the whole face, so on a body with many
B-spline faces nearly every pair is queried.

Before a query on a face whose surface is a stored B-spline, the search now
asks the face's certified boxes: the control-point boxes of its knot spans,
each split 3 times as a Bezier patch. An edge on a stored B-spline curve gets
the same boxes; any other curve uses its own box. A query is skipped only
when the boxes are farther apart than 2 * tol plus the rounding allowance of
the splits, so no pair can come from it: pairs, reports and their order are
unchanged. Faces on closed-form surfaces are not pruned.

Edge/face queries on rows of B-spline panels: 8 panels 112 -> 108, 32 panels
1,984 -> 684. The bicycle grip of #14250 regenerates in 23.5-25.4 s instead
of 44.1-46.4 s with identical output.
2026-11-11 16:43:41 +00:00
bherbruck 73bad21e10 ops: a boolean's operands keep their solid proof, and the next boolean uses it (#14830 fix B) (#14996)
A body a boolean proved to be a solid keeps that proof (Outward, from is_valid_and_outward, with
the kernel settings it was made under), and the next boolean that takes the body unchanged as an
operand uses it instead of deciding the volume's sign again. Any mutable access through the
body's lock drops the proof, and a proof made under other settings is not taken; the manifold
test always runs. volume_sign gets a profile scope so the test counts decisions: brep-bench's
drilled plate (9 cuts) goes from 27 volume-sign decisions to 19. Goldens unchanged.


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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 16:18:49 +00:00
MarkusRe 49928ebffd reach: a plane beside a bare B-spline net has a certified reach (#13722) (#15009)
* test: planes moved beside bare B-spline nets build sound bodies (#2978)

The goldens cases gen0000, gen0021, gen0041 and gen0048 of
topology.brep_local_operation on Solids/extrusion.smb, two of whose walls
are extrusions with no analytic label (of a cubic B-spline, and of a
rational curve of lines and an arc): walls given their tangent planes, and
the top tilted, build prisms of the volume their caps give (ten times the
bottom cap's area; the cap's area times the tilted plane's height over its
centroid). And gen0076 on Solids/pyramid.smb, the base moved up beside
sides stored as bare nets: the similar pyramid above it, the volume times
((h - d) / h)^3. On local-operations they are refused as ReachUncertified.

And the five cases of #3042 on Solids/nurb_fillet.smb (face0_same,
face4_same, their attributed twins, fc0004): a sound manifold solid, or
refused with the input as it came in.

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

* reach: a plane beside a bare B-spline net has a certified reach (#2978)

local_extension_reach bounded a plane against a labelled plane, cylinder,
cone or sphere, and against a kept B-spline face only along its
isocurves (SplineFace), so a local operation beside an extrusion with no
label, a moved net, or a corner of a plane with a quadric and a net or
with two nets was refused as ReachUncertified: on Solids/extrusion.smb
(main before the local operation built gen0000, gen0021, gen0041, gen0048
of topology.brep_local_operation) and on Solids/pyramid.smb, whose four
sides are bare B-spline nets.

- A B-spline surface or extrusion with no label and no definition is a
  bare net (reach/net.rs), a plane stored as a NURBS among them.
- A plane beside a net: in each normal section of the edge the plane's
  trace x0 + tau u meets the net at a (u, v, tau) proved by Krawczyk's
  operator for every section of a piece of the edge's parameters; the
  reach is sqrt(|x0 - p|^2 + tau^2), and the pieces are halved as for a
  quadric. Past a side the net is the extension the executive builds
  (Ruled: H + s' H_v homogeneous, one reparameterisation for every row;
  past a corner H + s' H_u + t' H_v + s' t' H_uv where both sides' rows end
  in one weight ratio). A closed direction runs on round its seam.
- Corners: two planes' line meets a net; where a boundary row of the net
  lies on one of the planes, the row meets the other plane (1 x 1); a
  plane, a quadric and a net (2 x 2); a plane and two nets (4 x 4). A
  moved net's move is bounded by the meetings on it.
- Where no meeting is proved (a plane moved off a round kept as its net)
  the reach is still ReachUncertified; so is a net past a side whose rows
  end in different weight ratios (past the rows' reach the extension takes
  the product form or is left short), and a net with unclamped ends or an
  inner knot of full multiplicity. A row within the B-rep's tolerance of a
  plane is taken as on it, as two planes within it are one.
- The isocurve bound (SplineFace, spline_face(_of), spline_reach(_along),
  spline_corner, SPLINE_STRIPS, SPLINE_REACH_TRIES, Exact::Spline) is
  replaced by the net's, as jyviko decided on #2978: one construction for
  every net, kept or moved, at edges and corners.

Tests: the round kept as its net is reached sqrt(2 r d) = 1 as on its
cylinder (was: refused, now the case for a plane moved off it), and its
tangent edge alone is bounded by sqrt(0.1^2 + 0.99) = 1; the tilted end
face beside the round's net reaches 0.45 at the round, also at the corner
where the side holds the net's boundary row (the bound now within the
B-rep's tolerance), and the net alone along its rulings 0.45 (the
isocurve test, on the net); the Ruled extension model in reach/net.rs is
checked against the extension extended_sides_keeping_parameters builds,
past each side and corner; the creased roof on the defective cube now
reaches the gate, which refuses it as Unsound with the input kept;
closed-form unit tests of the net's meetings.

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

* goldens: bless topology.brep_local_operation, seven cases built beside nets (#2978)

Seven cases that the reach refused (ReachUncertified, status 1001, the
input put back as it came in) are built now, each a manifold solid that
passed the local operation's gate; nothing else moves in any unit.

  case     file                 was        now (volume)
  gen0000  Solids/extrusion.smb refused    647.1111714853831
  gen0021  Solids/extrusion.smb refused    687.0724147222363
  gen0031  Solids/extrusion.smb refused    500.08548890558274
  gen0041  Solids/extrusion.smb refused    580.9394352608608
  gen0048  Solids/extrusion.smb refused    623.0293856814408
  gen0059  Solids/extrusion.smb refused    462.59544953870954
  gen0076  Solids/pyramid.smb   refused    945813.736759979

Why they are right: gen0000, gen0021 and gen0048 (walls given planes) are
the prisms of their caps, ten times the bottom cap's area; gen0041 (the top
tilted) is the cap's area times the plane's height over its centroid;
gen0076 (the base moved up 1.31) is the similar pyramid above it,
V0 ((h - d)/h)^3: all within 1e-5 (tests/issue_2978_local_reach_beside_nets.rs).
gen0031 and gen0059 tilt a wall out of the vertical, so no prism formula
holds; they are manifold, pass the gate's check against the input, and are
drawn in the pull request.

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

* findings: the local operation's reach beside bare B-spline nets (#2978)

The cause, the bound and its Ruled extension model, the sketch of a
normal section (VectorCraft source beside it), and the seven cases of
topology.brep_local_operation that build now, each before (refused, the
input kept) and after, drawn by solidcore-diag from the same view.

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

* test: the volume check builds on main through brep_volume_default (#2978)

`validation::volume` never landed on main, so the test did not build.
A local `volume` helper integrates the solid with
`mass::integrator::properties::brep_volume_default` at the test's
accuracy.

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

* test: Newton settles far from the origin, guards take only refusals (#2978)

CodeRabbit on #3071:
- a quarter cylinder of radius 0.002 at (1000, 1000) is met by a line
  through its axis (Newton's stop test was relative to |y|, which the
  rounding of points that far out never meets);
- the #3042 guards accept only an offset refusal, not any error rolled
  back.
The third point (a boundary row is the corner's only where it passes the
vertex) is tested with its fix, which adds the query the test calls.

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

* reach: a corner's row passes its vertex; Newton settles anywhere; past a side, the tangent lines (#2978)

- A boundary row of a net is the corner's curve only where it lies on the
  plane and passes the vertex (Net::rows_through: a point of the row,
  enclosed, within the B-rep's tolerance of it); a row on the plane that
  runs elsewhere no longer bounds the corner.
- Newton's start for Krawczyk's box stops where its step no longer
  shrinks and returns its last iterate, with no stop relative to the
  parameters, which a part far from the origin never meets; the box alone
  decides what is proved.
- Past a side the net runs on the lines G + d G_v along the side's
  tangents: the extension the executive builds where the rows end in one
  weight ratio, and the same lines on other parameters where they do not.
  The homogeneous form H + s' H_v had a pole behind the side for rows of
  different ratios, and Krawczyk's boxes there did not contract
  (solidcore-ops issue_2878_spline_round_draft).

Its test: a saddle net whose row v = 0 lies on z = 0, which also holds
its line x = 5; at a vertex on that line the row is not taken, at a
vertex on the row it is.

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

* test: a line through a net's corner past two sides of uneven end weights is met (#2978)

A biquadratic bump whose rows end in different weight ratios across
every side, as a variable round's do: the line along its normal through
the corner (1, 0) meets it there. Krawczyk's box about that meeting runs
past both sides, so the extension past the corner must be enclosed. It
is not yet (a corner is enclosed only where both sides' rows end in one
ratio), and the test fails. solidcore-ops issue_2878_spline_round_draft
is refused the same way, at the end of the round's x = 0 section.

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

* reach: a net's corner past two sides is enclosed, either extension past its u side (#2978)

solidcore-ops issue_2878_spline_round_draft (a box with a variable round
drafted at its ends) was refused as ReachUncertified at the edge where
the round ends at x = 0: its end vertex, where the round runs tangent
into the front face, is the corner (u1, v0) of the round's net, so the
meeting there needs Krawczyk's box past both sides. A corner was enclosed
only where both sides' rows end in one weight ratio, and a variable
round's never do.

The executive extends the u sides first and the v sides on what they
left (`extended_sides`), each Ruled. Past a u side it builds either the
homogeneous lines `H + s (H_u + mu H)` or, past their reach, the product
form `S + s S_u`; projected these are `G + sigma G_u`, `sigma = s / (1 + s
kappa)` with `kappa = (p / h) C / W >= 0` along the side (`C = sum N_j (rho
w_n - w_(n-1))`), and `kappa = 0`. Past a v side either form is the lines
`E + t E_v` on other parameters. So a corner is `E + t E_v` with `E_v =
G_v + sigma G_uv + sigma_v G_u`, which carries the u side's parameters:
the two forms give two corners. Which one is built depends on the
distance asked, which the reach does not know, so kappa is taken over its
hull with 0: Krawczyk's operator over enclosures holding both forms holds
each form's own, and a box it proves holds a root of whichever is built.
Where the rows end in one ratio kappa is 0, and the model is the one
before. The test of the model against the built extension now also runs
on a net whose rows end in different ratios, past its sides and corners.

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

* test: a net is taken only where it ends on its surface's side (#2978)

From CodeRabbit on #14066, measured: a quadratic strip given the u knots
0, 0, 0, 1 - 1e-13, 1, 1, 1 is stored with 0, 0, 0, a, a, a, a: the
knot within the scaled zero (1e-12) becomes a repeat, and the end knot
stands four times for degree 2. The surface ends on its last row (z = 1
at u = a), the net's enclosure at that side on the row before it (z =
0): `clamped_and_continuous` asks the last p + 1 knots to be equal, not
the end knot's multiplicity to be p + 1. The test asks what the net
encloses at the side to hold the surface's point there, or the net not
to be taken.

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

* reach: a net is taken only where each end knot stands exactly p + 1 times (#2978)

`clamped_and_continuous` asked the first and last p + 1 knots to be
equal, not the end knot's multiplicity to be p + 1. A longer run (a span
narrower than the knots' scaled zero, stored as a repeat) ends the
surface on its last control row alone, at the end knot, while the net,
whose sides are its end rows, ends on the row before: what it enclosed
at that side was not the surface's. Such a net is no longer taken, so
the reach beside it is refused (ReachUncertified), as for any other
unclamped net. From CodeRabbit on #14066; the guard it proposed (the
domain against the knots' ends) holds here, as the stored surface's
knots are the net's.

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

* goldens: bless topology.brep_local_operation, eight cases built beside nets (#2978)

Eight cases that main's reach refuses (ReachUncertified, status 1001, the
input put back as it came in) are built now, each a manifold solid that
passed the local operation's gate:

  case     file                 main      now (volume)
  gen0000  Solids/extrusion.smb refused   647.1111714853831
  gen0021  Solids/extrusion.smb refused   687.0724147222363
  gen0031  Solids/extrusion.smb refused   500.08548890558274
  gen0041  Solids/extrusion.smb refused   580.9394342209
  gen0048  Solids/extrusion.smb refused   623.0293856814408
  gen0059  Solids/extrusion.smb refused   462.59544953870954
  gen0070  Solids/extrusion.smb refused   661.6156461503
  gen0076  Solids/pyramid.smb   refused   945813.736759979

Why they are right: gen0000, gen0021 and gen0048 (walls given planes) are
the prisms of their caps, ten times the bottom cap's area; gen0041 (the top
tilted) is the cap's area times the plane's height over its centroid;
gen0076 (the base moved up 1.31) is the similar pyramid above it,
V0 ((h - d)/h)^3: all within 1e-5 (tests/issue_2978_local_reach_beside_nets.rs).
gen0031, gen0059 and gen0070 tilt a wall out of the vertical, so no prism
formula holds; they are manifold, pass the gate's check against the input,
and are drawn in the finding.
The volumes were measured before the rebase onto main; since then the
bodies' sampled geometry moved by at most 7e-16 relative (rounding), the
graphs and records not at all.

One case is refused on both sides and only names another reason:
gen0015 (Solids/extrusion.smb) is refused by main's reach (ReachUncertified
on face 0, status 1001); with the reach certified it reaches the corner
split, which main now always runs, and is refused there
(offset_executive::step_gaps, EdgeVanishes on edge 2, status 1043). The
input is put back either way; only the record's status moves.

Every other case of the unit is unchanged within the goldens' 1e-9.

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

* reach: the docs name the nets and vertex triples left without a bound (#2978)

`exact_class` takes a bare net only where `Net::of` does, and
`local_extension_reach` lists what it refuses at a vertex: any three
faces but the kinds it bounds, even three that all stay, and a net
`Net::of` turns away.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 16:18:01 +00:00
MarkusRe a00b1a73af solidcore: lines where two parallel cylinders cross are clipped to both faces by default (retire two flags, B24) (#14954)
* claim: #14246 (B24: cone_parallel_line_midpoint, cone_parallel_line_domains)

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

* solidcore: parallel-cylinder lines are clipped to both faces on the default kernel (test first)

The issue 711 tests and the parallel-cylinder tests in surface/cone/p3.rs
now run on Kernel::default(), without setting cone_parallel_line_midpoint
or cone_parallel_line_domains. On main they fail: the default kernel
builds each line over the first cylinder's full height, tests it at its
bottom end, and so drops lines in faces trimmed above that end or keeps
lines that run past a face's top.

The test that pinned the second hole's failure on the default kernel
goes: it asserted the defect.

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

* solidcore: retire cone_parallel_line_midpoint and cone_parallel_line_domains (WR-259)

Two parallel cylinders cross in lines along their axis. Cone::
intersect_with_cone now always clips each line to both requested face
domains (converted to STEP) and tests it at its middle. The old path built
each line over the first cylinder's full axial extent and tested its
bottom end: a face trimmed above that end lost the line, and a face
shorter than the cylinder got a line running past it.

- delete both UpstreamFixes fields, SolidCraft's preset lines, their
  fixes.json entries, the goldens case flag arm and every test setting
  of them;
- fixtures/solidcraft/3665-column-in-channel.json: the four cases no
  longer ask for the retired flag;
- WR-259 addendum naming the retirement.

SolidCraft and the kernel rig already ran with both flags on.

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

* solidcore: bless surface.cone_p3 for parallel-cylinder lines clipped to both faces

151 rows of surface.cone_p3 move; no other unit does (all 283 units at
the fix: 282 pass, surface.cone_p3 changed=151, added, removed and broken 0).

- 140 rows: main tested each line at its bottom end, below a face whose
  domain starts higher, and returned no lines; now 1 or 2 lines spanning
  the overlap of both faces' axial ranges (checked by evaluating each
  surface at its domain corners).
- 3 rows (fuzz_axis_overflow_zero_y*): the second cone's y axis unitizes
  to zero (WR-159), so its surface lies at y = 1.6, 1.6 or more from the
  first cylinder's axis (radius 1, tolerance 0.1). The faces share no
  point; main's two lines are gone.
- 8 rows (rejected_*, no polar converter): still refused, status_1007
  to status_1006, from the domain conversion that now runs first.

Every row with its old and new curves:
plan/findings/surface.cone_p3--parallel-cylinder-lines-clipped-by-default-2026-11-11.md

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

* solidcore: parallel-cylinder tests check both ends on both cylinders, coaxial faces and the refusal

- assert_axial_lines checks both ends of every line on both cylinders,
  and the trimmed-domain tests assert the curve types;
- coaxial cylinders: faces apart along the axis share nothing; faces
  that overlap still get boundary curves on the common wall;
- a cone with a zero y axis (no polar converter) refuses the
  intersection when its face domain is converted;
- the issue 711 cut bodies pass the Level 2 check;
- the comments say what the clipped line's middle guarantees.

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

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
2026-11-11 16:17:58 +00:00
MarkusRe fbfdb21cf1 Agents and scripts: Render Image and Check Render Request describe their replies (#13311) (#14963)
* Render Image and Check Render Request: a test that both describe their replies (#13311)

Fails until the two commands publish an output schema.

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

* Agents and scripts: Render Image and Check Render Request describe their replies (#13311)

Render Image (render.start) replies with the queued job's status, the same JobState that
Render Progress gives for a job, plus ignored_controls for a raster or CPU request. Check
Render Request (render.preflight) replies in the form of its request: RuntimeChecked for
raster and CPU ray tracing, GpuChecked for progressive: true, with the sections both share
in Checked. The handler builds both replies from these types instead of json! literals, and
so does the window's own preparation of the two commands (render_preparation), which had its
own copy of Render Image's reply. The CPU and GPU denoising texts are one function.

The render crate has no JsonSchema, so its sections (the request, resolved, admission,
options, the CPU limits) are published as objects, as schema/render_jobs.rs does. The JSON
of both replies is unchanged. Both commands leave schema/unconverted.txt.

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

* Render Image and Check Render Request: a queued job's reply never fails, and the tests pin each form's keys (#13311)

Once Render Image has queued its job, building the typed reply falls back to the status as
the render crate gives it rather than returning an error, so a caller never retries a job
that runs. The reply tests now check each form's exact keys, as the handler gave them
before its reply had a type, and the CPU-only and GPU denoising fields. The environment,
CPU profile and transport descriptions say what a raster request and a sky get. Found in
review.

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

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
2026-11-11 16:14:56 +00:00
Brett Lawson 018cdc012b solidcore: one bare-status scanner for every typed-errors ratchet (#14991)
Every typed-errors ratchet had its own copy of the bare-status scanner,
and each cut a file at its first test module, so library code after it
was not counted: blend/fillet_solver/p4.rs holds 38 bare sites, but the
ratchet saw 6.

The new test-only error/bare_scan.rs blanks comments and literals, then
removes every #[cfg(test)] item wherever it sits (and skips the files of
test-only out-of-line modules) before counting. Every ratchet now uses
it, the per-area copies are gone, topology, heal and mass walk their
whole area instead of fixed file lists, and sweep counts nl_ser too.
Counts are unchanged except blend's p4.rs, from 6 to 38.


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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 16:08:37 +00:00
496 changed files with 47145 additions and 8477 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
+3
View File
@@ -10,6 +10,9 @@ Changes a default: no
Changes a default: yes — <what>, old behaviour kept behind preference <name>
Docs-only and bot PRs are exempt. -->
<!-- A kernel change (solidcore/, crates/smlib/) that changes or deletes lines of an existing test or golden expectation needs a line saying why the old test was wrong (adding tests needs nothing):
Test change: <reason> -->
## Validation
What did you run, and what were the results? Include expected and measured values for geometry changes.
+5 -1
View File
@@ -446,7 +446,11 @@ jobs:
- name: Design format pending changes
run: bash scripts/test-format-pending.sh
- name: Web app offline use and updates (headless Chrome)
run: cargo xtask web-offline-test
# Not blocking while #14591 is open: the check "the open v3 page was given another build's
# file after the update" fails in about one queue batch in a few, with no PR to blame. A
# failure is reported as a warning (and still printed in this step's log), not as a failure.
run: |
cargo xtask web-offline-test || echo "::warning title=Web app offline test failed (non-blocking, #14591)::cargo xtask web-offline-test failed; see this step's log. Tracked in #14591."
- name: Affected-package selection self-test
run: python3 -I scripts/ci/test_affected.py
- name: Merge-of-main detection self-test
+6 -2
View File
@@ -84,7 +84,8 @@ jobs:
# Draft pull requests run no CI, as in ci.yml.
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
runs-on: blacksmith-2vcpu-ubuntu-2404
timeout-minutes: 5
# The checkout fetches history without file contents (below), so this takes well under a minute.
timeout-minutes: 10
outputs:
# The shard matrix (JSON list of areas and the ladder; [] when nothing is selected).
shards: ${{ steps.plan.outputs.shards }}
@@ -93,8 +94,11 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# A pull request's diff needs its base's history.
# A pull request's diff needs its base's history: the commits and trees, not every file
# version. A full clone of every blob took over the 5-minute timeout on a 2-vCPU runner and
# cancelled the merge queue's rig selection (Select the rig areas) again and again.
fetch-depth: ${{ (github.event_name == 'pull_request' || github.event_name == 'merge_group') && '0' || '1' }}
filter: ${{ (github.event_name == 'pull_request' || github.event_name == 'merge_group') && 'blob:none' || '' }}
- name: Selection self-test
run: python3 -I scripts/ci/test_kernel_rig_plan.py
- id: plan
+19 -3
View File
@@ -1,6 +1,7 @@
# Three quick checks on every pull request (CONTRIBUTING.md, "Sending a change"): no second open PR
# for an issue a lower-numbered one already closes, the "Changes a default:" line is filled in, and
# a PR adding to the showcase shows a picture. None is a required status check. The logic is in scripts/pr-checks/pr-checks.js (tested in ci.yml).
# Four quick checks on every pull request (CONTRIBUTING.md, "Sending a change"): no second open PR
# for an issue a lower-numbered one already closes, the "Changes a default:" line is filled in, a PR
# adding to the showcase shows a picture, and a kernel fix that changes or deletes existing test or
# golden lines says why on a "Test change:" line. None is a required status check. The logic is in scripts/pr-checks/pr-checks.js (tested in ci.yml).
name: PR checks
on:
@@ -63,6 +64,21 @@ jobs:
const r = await checks.checkPicture({ github, context, core });
core.info(JSON.stringify(r));
test-change:
name: Test change check
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
sparse-checkout: scripts/pr-checks
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const checks = require('./scripts/pr-checks/pr-checks.js');
const r = await checks.checkTestChange({ github, context, core });
core.info(JSON.stringify(r));
# Labelling needs write access, which a fork PR's token never has, so it is its own job and best
# effort: a fork PR just doesn't get the `changes-default` label from here.
label:
+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:
Generated
+139 -38
View File
@@ -730,18 +730,42 @@ dependencies = [
"thiserror 1.0.69",
]
[[package]]
name = "calloop"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43b32313e937c8721c9d17ee9bf60699a6402b4b7c2cb6ac1bee0ffcaa4b4e5e"
dependencies = [
"bitflags 2.13.2",
"polling",
"rustix 1.1.6",
"tracing",
]
[[package]]
name = "calloop-wayland-source"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20"
dependencies = [
"calloop",
"calloop 0.13.0",
"rustix 0.38.44",
"wayland-backend",
"wayland-client",
]
[[package]]
name = "calloop-wayland-source"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa"
dependencies = [
"calloop 0.14.5",
"rustix 1.1.6",
"wayland-backend",
"wayland-client",
]
[[package]]
name = "cbc"
version = "0.1.2"
@@ -1282,6 +1306,7 @@ dependencies = [
"profiling",
"raw-window-handle",
"serde",
"smithay-clipboard",
"web-time",
"webbrowser",
"windows-sys 0.61.2",
@@ -4173,8 +4198,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016"
dependencies = [
"bitflags 2.13.2",
"calloop",
"calloop-wayland-source",
"calloop 0.13.0",
"calloop-wayland-source 0.3.0",
"cursor-icon",
"libc",
"log",
@@ -4191,6 +4216,44 @@ dependencies = [
"xkeysym",
]
[[package]]
name = "smithay-client-toolkit"
version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0"
dependencies = [
"bitflags 2.13.2",
"calloop 0.14.5",
"calloop-wayland-source 0.4.1",
"cursor-icon",
"libc",
"log",
"memmap2",
"rustix 1.1.6",
"thiserror 2.0.21",
"wayland-backend",
"wayland-client",
"wayland-csd-frame",
"wayland-cursor",
"wayland-protocols",
"wayland-protocols-experimental",
"wayland-protocols-misc",
"wayland-protocols-wlr",
"wayland-scanner",
"xkeysym",
]
[[package]]
name = "smithay-clipboard"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226"
dependencies = [
"libc",
"smithay-client-toolkit 0.20.0",
"wayland-backend",
]
[[package]]
name = "smol_str"
version = "0.2.2"
@@ -4259,7 +4322,7 @@ dependencies = [
[[package]]
name = "solidcraft"
version = "0.40.0"
version = "0.40.1"
dependencies = [
"block2 0.6.2",
"eframe",
@@ -4302,11 +4365,11 @@ dependencies = [
[[package]]
name = "solidcraft-3dx-client"
version = "0.40.0"
version = "0.40.1"
[[package]]
name = "solidcraft-cli"
version = "0.40.0"
version = "0.40.1"
dependencies = [
"image",
"log",
@@ -4322,11 +4385,11 @@ dependencies = [
[[package]]
name = "solidcraft-color"
version = "0.40.0"
version = "0.40.1"
[[package]]
name = "solidcraft-control"
version = "0.40.0"
version = "0.40.1"
dependencies = [
"getrandom 0.3.4",
"serde",
@@ -4335,7 +4398,7 @@ dependencies = [
[[package]]
name = "solidcraft-control-egui"
version = "0.40.0"
version = "0.40.1"
dependencies = [
"egui",
"image",
@@ -4345,7 +4408,7 @@ dependencies = [
[[package]]
name = "solidcraft-derive"
version = "0.40.0"
version = "0.40.1"
dependencies = [
"proc-macro2",
"quote",
@@ -4354,7 +4417,7 @@ dependencies = [
[[package]]
name = "solidcraft-doc"
version = "0.40.0"
version = "0.40.1"
dependencies = [
"log",
"serde",
@@ -4364,6 +4427,7 @@ dependencies = [
"solidcore-tess",
"solidcraft-color",
"solidcraft-derive",
"solidcraft-format",
"solidcraft-geom",
"solidcraft-kernel",
"solidcraft-profile",
@@ -4376,7 +4440,7 @@ dependencies = [
[[package]]
name = "solidcraft-dynamics"
version = "0.40.0"
version = "0.40.1"
dependencies = [
"serde",
"serde_json",
@@ -4387,7 +4451,7 @@ dependencies = [
[[package]]
name = "solidcraft-engine"
version = "0.40.0"
version = "0.40.1"
dependencies = [
"image",
"log",
@@ -4411,9 +4475,18 @@ dependencies = [
"web-time",
]
[[package]]
name = "solidcraft-format"
version = "0.40.1"
dependencies = [
"serde_json",
"sha2 0.10.9",
"zip",
]
[[package]]
name = "solidcraft-geom"
version = "0.40.0"
version = "0.40.1"
dependencies = [
"serde",
"serde_json",
@@ -4426,7 +4499,7 @@ dependencies = [
[[package]]
name = "solidcraft-gpu"
version = "0.40.0"
version = "0.40.1"
dependencies = [
"image",
"log",
@@ -4451,7 +4524,7 @@ dependencies = [
[[package]]
name = "solidcraft-io"
version = "0.40.0"
version = "0.40.1"
dependencies = [
"cadmpeg-core",
"cadmpeg-ir",
@@ -4469,6 +4542,7 @@ dependencies = [
"solidcore-record",
"solidcraft-color",
"solidcraft-doc",
"solidcraft-format",
"solidcraft-geom",
"solidcraft-kernel",
"solidcraft-profile",
@@ -4479,7 +4553,7 @@ dependencies = [
[[package]]
name = "solidcraft-kernel"
version = "0.40.0"
version = "0.40.1"
dependencies = [
"log",
"proptest",
@@ -4500,7 +4574,7 @@ dependencies = [
[[package]]
name = "solidcraft-look"
version = "0.40.0"
version = "0.40.1"
dependencies = [
"egui",
"log",
@@ -4512,7 +4586,7 @@ dependencies = [
[[package]]
name = "solidcraft-mcp"
version = "0.40.0"
version = "0.40.1"
dependencies = [
"image",
"serde_json",
@@ -4526,14 +4600,14 @@ dependencies = [
[[package]]
name = "solidcraft-mcp-server"
version = "0.40.0"
version = "0.40.1"
dependencies = [
"serde_json",
]
[[package]]
name = "solidcraft-profile"
version = "0.40.0"
version = "0.40.1"
dependencies = [
"serde_json",
"web-time",
@@ -4541,7 +4615,7 @@ dependencies = [
[[package]]
name = "solidcraft-render"
version = "0.40.0"
version = "0.40.1"
dependencies = [
"flate2",
"image",
@@ -4564,7 +4638,7 @@ dependencies = [
[[package]]
name = "solidcraft-sketch"
version = "0.40.0"
version = "0.40.1"
dependencies = [
"harfrust",
"serde",
@@ -4578,7 +4652,7 @@ dependencies = [
[[package]]
name = "solidcraft-smlib"
version = "0.40.0"
version = "0.40.1"
dependencies = [
"flate2",
"log",
@@ -4592,18 +4666,18 @@ dependencies = [
[[package]]
name = "solidcraft-space-mouse"
version = "0.40.0"
version = "0.40.1"
dependencies = [
"serde",
]
[[package]]
name = "solidcraft-testkit"
version = "0.40.0"
version = "0.40.1"
[[package]]
name = "solidcraft-theme"
version = "0.40.0"
version = "0.40.1"
dependencies = [
"egui",
"serde",
@@ -4613,7 +4687,7 @@ dependencies = [
[[package]]
name = "solidcraft-ui-egui"
version = "0.40.0"
version = "0.40.1"
dependencies = [
"arboard",
"egui",
@@ -4655,7 +4729,7 @@ dependencies = [
[[package]]
name = "solidcraft-ui-kit"
version = "0.40.0"
version = "0.40.1"
dependencies = [
"arboard",
"egui",
@@ -4678,14 +4752,14 @@ dependencies = [
[[package]]
name = "solidcraft-ui-registry"
version = "0.40.0"
version = "0.40.1"
dependencies = [
"egui",
]
[[package]]
name = "solidcraft-ui-text"
version = "0.40.0"
version = "0.40.1"
dependencies = [
"log",
"serde_json",
@@ -4695,7 +4769,7 @@ dependencies = [
[[package]]
name = "solidcraft-viewport"
version = "0.40.0"
version = "0.40.1"
dependencies = [
"image",
"serde",
@@ -4707,7 +4781,7 @@ dependencies = [
[[package]]
name = "solidcraft-viewport-egui"
version = "0.40.0"
version = "0.40.1"
dependencies = [
"egui",
"egui-wgpu",
@@ -4718,7 +4792,7 @@ dependencies = [
[[package]]
name = "solidcraft-viewport-gpu"
version = "0.40.0"
version = "0.40.1"
dependencies = [
"log",
"naga",
@@ -4734,7 +4808,7 @@ dependencies = [
[[package]]
name = "solidcraft-web"
version = "0.40.0"
version = "0.40.1"
dependencies = [
"eframe",
"egui",
@@ -5052,6 +5126,7 @@ version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"log",
"pin-project-lite",
"tracing-attributes",
"tracing-core",
@@ -5452,6 +5527,32 @@ dependencies = [
"wayland-scanner",
]
[[package]]
name = "wayland-protocols-experimental"
version = "20250721.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1"
dependencies = [
"bitflags 2.13.2",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wayland-scanner",
]
[[package]]
name = "wayland-protocols-misc"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8"
dependencies = [
"bitflags 2.13.2",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wayland-scanner",
]
[[package]]
name = "wayland-protocols-plasma"
version = "0.3.12"
@@ -6057,7 +6158,7 @@ dependencies = [
"bitflags 2.13.2",
"block2 0.5.1",
"bytemuck",
"calloop",
"calloop 0.13.0",
"cfg_aliases",
"concurrent-queue",
"core-foundation 0.9.4",
@@ -6078,7 +6179,7 @@ dependencies = [
"raw-window-handle",
"redox_syscall 0.4.1",
"rustix 0.38.44",
"smithay-client-toolkit",
"smithay-client-toolkit 0.19.2",
"smol_str",
"tracing",
"unicode-segmentation",
@@ -6220,7 +6321,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.40.0"
version = "0.40.1"
dependencies = [
"flate2",
"proc-macro2",
+3 -1
View File
@@ -6,7 +6,7 @@ members = ["crates/*", "apps/*", "xtask"]
exclude = ["solidcore", "vendor"]
[workspace.package]
version = "0.40.0"
version = "0.40.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -25,6 +25,8 @@ solidcraft-render = { path = "crates/render" }
solidcraft-dynamics = { path = "crates/dynamics" }
solidcraft-gpu = { path = "crates/gpu" }
solidcraft-doc = { path = "crates/doc" }
# Versioned file formats: the format tag, the version registry and its upgrade, a newer file told
solidcraft-format = { path = "crates/format" }
solidcraft-io = { path = "crates/io" }
solidcraft-engine = { path = "crates/engine" }
solidcraft-ui-egui = { path = "crates/ui-egui" }
@@ -1750,3 +1750,31 @@ fn the_ribbed_lid_builds_the_same_body_every_time() {
assert_eq!(fingerprint(), first);
}
}
// #13295: a circle sketched on the plane at 45 degrees through a bar's step edge, 7.9e-6 off
// that edge (as the reporter's sketch had it), cut through all across a hole. The cut wall's
// seam edge ends on the bar's edge a few micrometres across the seam, inside its vertex's
// tolerance, and the check refused the result ("Face Near missing Seam").
#[test]
fn a_circle_cut_from_a_slanted_plane_through_a_step_edge_beside_a_hole() {
let mut s = Session::default();
let (y0, z1, yc, z2) = (16.423877938511588, 13.06859205815335, 12.527075811882755, 19.566787029154483);
let p = [[0.0, 0.0], [y0, 0.0], [y0, z1], [yc, z1], [yc, z2], [0.0, z2]];
s.execute("sketch.create", &json!({"plane": "YZ"})).unwrap();
for i in 0..6 {
s.execute("sketch.line", &json!({"points": [p[i], p[(i + 1) % 6]]})).unwrap();
}
s.execute("sketch.finish", &json!({})).unwrap();
s.execute("solid.extrude", &json!({"distance": 100, "operation": "new"})).unwrap();
let start = s.doc.features.len();
let hole = json!({"base": [20.421614627576027, y0 + 5.0, 6.534296029076675], "axis": [0, -1, 0], "radius": 3.5355339059327378, "height": y0 + 10.0, "operation": "cut", "targets": ["Body1"]});
s.execute("solid.cylinder", &hole).unwrap();
let r = std::f64::consts::FRAC_1_SQRT_2;
let plane = json!({"origin": [0, yc - yc * r, z2 + yc * r], "x_dir": [1, 0, 0], "y_dir": [0, r, -r]});
s.execute("sketch.create", &json!({"plane": plane, "name": "Slant"})).unwrap();
s.execute("sketch.circle.center", &json!({"center": [20.4216146277246, 12.527083680740917], "radius": 4.716990566008734})).unwrap();
s.execute("sketch.finish", &json!({})).unwrap();
s.execute("solid.extrude", &json!({"sketch": "Slant", "through_all": true, "direction": "negative", "operation": "cut", "targets": ["Body1"]}))
.unwrap();
check(&s, start, &BTreeMap::new()).unwrap_or_else(|(kind, e)| panic!("{kind}: {e}"));
}
+3 -3
View File
@@ -904,9 +904,9 @@ mod web {
self.files.panel.set(false);
}
"insert" => {
if !solidcraft_ui_egui::mesh_units::ask(&mut self.app, &path, true) {
self.app.insert_path(&path);
}
// The staged loader, as Open uses: a large STEP insert keeps the page drawing
// (it asks an STL's or OBJ's units first, as the synchronous insert did).
self.app.insert_path_async(&path);
self.files.panel.set(false);
}
"download" => match vfs::read(&path, solidcraft_engine::io::MAX_DESIGN_BYTES) {
+3 -1
View File
@@ -42,7 +42,9 @@ solidcraft-look = { workspace = true }
solidcraft-ui-text = { workspace = true }
solidcraft-theme = { workspace = true }
solidcraft-space-mouse = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links"] }
# "clipboard" is not a default here: eframe 0.37 builds egui-winit without it, and then every
# Copy and Paste on the desktop silently does nothing (see src/clipboard_tests.rs).
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links", "clipboard"] }
egui = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
+13
View File
@@ -0,0 +1,13 @@
//! The desktop build keeps the system clipboard.
//!
//! eframe 0.37 builds egui-winit without its clipboard unless asked, and the workspace turns
//! eframe's defaults off: every Copy (the error text, Copy diagnostic information, Ctrl+C in a
//! field) then reaches the platform as a command that nothing carries out. The UI tests see the
//! copied text in egui's output and still pass, so this is checked where the build is described.
#[test]
fn the_desktop_build_enables_eframes_clipboard() {
let manifest = include_str!("../Cargo.toml");
let eframe = manifest.lines().find(|line| line.trim_start().starts_with("eframe =")).expect("the eframe dependency");
assert!(eframe.contains("\"clipboard\""), "eframe without its clipboard feature drops every copy on the desktop: {eframe}");
}
+2
View File
@@ -20,6 +20,8 @@
#[cfg(not(target_os = "macos"))]
#[path = "../../../assets/icons/app/shared/source/app_icon.rs"]
mod app_icon;
#[cfg(test)]
mod clipboard_tests;
mod come_forward;
mod control_server;
#[cfg(target_os = "linux")]
+6 -6
View File
@@ -890,7 +890,7 @@
"Variable Radius": "نصف قطر متغير",
"Variable radius": "نصف قطر متغير",
"Velocity": "السرعة المتجهة",
"Vertical": "عمودي",
"Vertical": "رأسي",
"Vertices": "الرؤوس",
"View": "عرض",
"View navigator": "متصفح العرض",
@@ -2653,12 +2653,12 @@
"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": "أزرار مربعات الحوار",
"prefs.dialog_first_letter_buttons": "أزرار نوافذ الحوار تستجيب لحرفها الأول",
"prefs.dialog_first_letter_buttons_help": "عند فتح نافذة حوار، كتابة الحرف الأول من تسمية الزر تضغطه: حرف «ا» يضغط زر «إلغاء» مثلًا. الحرف الذي يناسب زرَّين لا يفعل شيئًا، وحقل النص المركَّز عليه يحتفظ بالتضغطة. عند الإيقاف: الكتابة لا تفعل شيئًا للأزرار، كما كان سابقًا.",
"prefs.dialog_first_letter_buttons_help": "عند فتح نافذة حوار، تضغط كتابةُ الحرف الأول من تسمية الزر ذلك الزر: «م» لزر «موافق» و«إ» لزر «إلغاء». الحرف الذي يناسب زرَّين لا يفعل شيئًا، وحقل النص النشط يحتفظ بضغطة المفتاح. عند الإيقاف: الكتابة لا تؤثر في الأزرار، كما كان سابقًا.",
"prefs.dialogs": "الحوارات",
"prefs.dialogs_edge": "عند حافة العرض",
"prefs.dialogs_left_column": "في العمود الأيسر",
@@ -2686,7 +2686,7 @@
"prefs.extrude_view_isometric": "التحويل إلى العرض المتساوي القياس عند فتح البثق",
"prefs.extrude_view_return": "العودة إلى العرض السابق عند إغلاق البثق",
"prefs.file_operations_wait": "انتظار فتح الملفات",
"prefs.file_operations_wait_note": "أثناء فتح ملف أو إدراجه أو إعادة قراءة الروابط، ينتظر التصميم خلف بطاقة تعرض التقدم وزر الإلغاء. عند الإيقاف: يظهر الملف في لوحة العمليات ويمكنك متابعة العمل؛ يُطبَّق الإدراج بعد أن تنتهي مما تفعله، ويُفتح الملف المفتوح في الأثناء في علامة تبويب جديدة.",
"prefs.file_operations_wait_note": "أثناء فتح ملف أو إدراجه أو إعادة قراءة الروابط، ينتظر التصميم خلف بطاقة تعرض تقدّم الملف وزر «إلغاء». عند الإيقاف: يظهر الملف في لوحة العمليات ويمكنك متابعة العمل؛ يُطبَّق الإدراج بعد أن تنتهي مما تفعله، والملف الذي تفتحه في هذه الأثناء يُفتح في علامة تبويب جديدة.",
"prefs.fillet_legacy_radius": "استخدام نصف القطر الافتراضي السابق للتقويس المصمت",
"prefs.fillet_legacy_radius.help": "معطّل افتراضيًا. عند التفعيل، يفتح التقويس المصمت الجديد على الرقم 2 مقروءًا بوحدات التصميم («2 بوصة» في تصميم بالبوصة)، كما كان سابقًا؛ وعند الإيقاف، يفتح على نصف القطر الافتراضي للأداة، 2 مم، معروضًا بوحدات التصميم («0.07874 بوصة» في تصميم بالبوصة).",
"prefs.follows_os": "يتبع النظام",
@@ -5443,7 +5443,7 @@
"ui.construct_auto.no_fit": "اختر مراجع أخرى: لا توجد طريقة تبني من هذه المراجع.",
"ui.construct_auto.pick": "اختر ما تبني منه: الطريقة تتبع اختياراتك.",
"ui.construct_auto.refs": "المراجع",
"ui.construct_auto.select": "اختر أولًا المراجع التي تبني منها",
"ui.construct_auto.select": "حدد أولًا المراجع التي ستبني منها",
"ui.construct_axes": "المحاور",
"ui.construct_planes": "المستويات",
"ui.construct_points": "النقاط",
@@ -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",
+192 -10
View File
@@ -247,6 +247,7 @@
"Drag to move the hole; click to remove it": "Arrossegueu per moure el forat; feu clic per treure'l",
"Drag to move the rest across the model. It snaps to sketch points on the face.": "Arrossegueu per moure el recolzament pel model. S'ajusta als punts del bocet de la cara.",
"Drag to move the snap fit across the model. It snaps to sketch points on the face.": "Arrossegueu per moure l'encaix a pressió pel model. S'ajusta als punts del bocet de la cara.",
"Drag to place it on another face.": "Arrossegueu-lo per col·locar-lo en una altra cara.",
"Drag to point the catch in another direction.": "Arrossegueu per orientar el retén en una altra direcció.",
"Drag to turn the rest in 5° steps. Hold Alt or Ctrl to turn it freely.": "Arrossegueu per girar el recolzament de 5° en 5°. Manteniu Alt o Ctrl per girar-lo lliurement.",
"Drive Joint": "Accionar la unió",
@@ -513,6 +514,7 @@
"Offset Y": "Desplaçament Y",
"Offset Z": "Desplaçament Z",
"On your computer": "Al vostre equip",
"One more stretch of the picked edge, each with its own angle, height and turns": "Un tram més de l'aresta triada, cadascun amb el seu angle, altura i girs",
"Opacity": "Opacitat",
"Open GitHub": "Obrir GitHub",
"Open GitHub draft": "Obrir l'esborrany de GitHub",
@@ -634,6 +636,7 @@
"Remove the track": "Eliminar la pista",
"Remove this bend": "Treure aquest plegat",
"Remove this point": "Eliminar aquest punt",
"Remove this segment": "Eliminar aquest segment",
"Rename Feature": "Canviar el nom de l'operació",
"Rename Folder": "Canviar el nom de la carpeta",
"Render Stage": "Escenari de renderitzat",
@@ -723,6 +726,7 @@
"Section position": "Posició de la secció",
"Sections": "Seccions",
"Sections (one sketch each)": "Seccions (un bocet cadascuna)",
"Segments": "Segments",
"Select Features": "Seleccionar les operacions",
"Select Other": "Seleccionar-ne un altre",
"Select Other: hold the left button still on the model (or right-click › Select Other…) to list everything under the cursor; click again on the same spot to select the next thing there. Double-click the middle button to go Home (Preferences › Navigation).": "Seleccionar-ne un altre: manteniu el botó esquerre quiet sobre el model (o feu clic amb el botó dret › Seleccionar-ne un altre…) per llistar tot el que hi ha sota el punter; torneu a fer clic al mateix lloc per seleccionar el següent element. Feu doble clic amb el botó central per tornar a Inici (Preferències › Navegació).",
@@ -772,6 +776,7 @@
"Spacing must be a non-zero number": "L'espaiat ha de ser un nombre diferent de zero",
"Specialise": "Especialitzar",
"Speed": "Velocitat",
"Split Edge": "Dividir aresta",
"Splitting tool": "Eina de divisió",
"Square": "Quadrat",
"Stainless Steel": "Acer inoxidable",
@@ -932,13 +937,20 @@
"ai.http.confirm": "Permetre per a aquest servei",
"ai.http.warning": "La clau de l'API i la conversa s'enviaran sense xifrar. Qualsevol persona amb accés a la xarxa les pot llegir o canviar. Feu-ho servir només en una xarxa de confiança.",
"ai.prefs.allow_control": "Permetre que es connectin agents d'IA",
"ai.prefs.allow_control_note": "Un canal local (MCP). No surt res d'aquest ordinador.\n\nObre un canal de control només en aquest equip (127.0.0.1, port 27878 o el següent lliure). El programa que es connecti també ha de presentar un token secret que només pot llegir el vostre compte d'usuari, generat de nou a cada arrencada. S'aplica la propera vegada que s'iniciï SolidCraft. Desactivat: no s'escolta res tret que inicieu SolidCraft amb --control.",
"ai.prefs.allow_control_note": "Un canal local (MCP). No surt res d'aquest ordinador.\n\nObre un canal de control només en aquest equip (127.0.0.1, al port de sota; 27878 pren el següent lliure quan està en ús). El programa que es connecti també ha de presentar un token secret que només pot llegir el vostre compte d'usuari, generat de nou a cada arrencada. S'aplica a l'instant. Desactivat: no s'escolta res tret que inicieu SolidCraft amb --control.",
"ai.prefs.availability": "Disponibilitat",
"ai.prefs.background_commands": "Executar les ordres de l'agent en segon pla",
"ai.prefs.connect_apps": "Posar SolidCraft a disposició de les aplicacions d'IA d'aquest equip",
"ai.prefs.connect_apps_note": "Desactivat: treu les entrades que havia afegit.\n\nEn iniciar SolidCraft, l'afegeix com a servidor MCP a les aplicacions d'IA que trobi en aquest equip (OpenCode, Claude Desktop, Claude Code, Cursor, Codex, Gemini CLI, Copilot CLI, Windsurf, Zed, VS Code), perquè un agent el pugui fer servir sense configurar res. Només s'afegeix l'entrada solidcraft o se n'actualitza el camí; una entrada que hàgiu canviat es deixa intacta, i el fitxer es copia abans. Desactivat: s'eliminen les entrades que havia afegit. Ajuda › Connectar assistents d'IA… ho fa a mà.",
"ai.prefs.connect_at_start": "Connectar el darrer agent en iniciar",
"ai.prefs.connect_at_start_note": "Inicia en segon pla l'agent que vau connectar per última vegada (o el predeterminat) cada vegada que s'inicia SolidCraft, perquè estigui a punt per xatejar. No quan SolidCraft s'inicia amb --control. Si no es pot connectar, l'Assistent mostra Connexió fallida i un botó Reintentar.",
"ai.prefs.control_failed": "No s'ha pogut iniciar el canal de control: {error}",
"ai.prefs.control_from_launch": "Iniciat amb --control al port {port} per a aquesta execució. Els canvis d'aquí s'apliquen la propera vegada que SolidCraft s'iniciï sense --control.",
"ai.prefs.control_listening": "Escoltant a 127.0.0.1:{port}",
"ai.prefs.control_not_listening": "No està escoltant",
"ai.prefs.control_port": "Port",
"ai.prefs.control_port_note": "El port al qual es connecten els agents d'IA en aquest equip (127.0.0.1). 27878 és el predeterminat: quan està en ús, es pren el següent lliure. Qualsevol altre port s'usa exactament. Apunteu el vostre client MCP al mateix port, per exemple solidcraft-cli mcp --connect 127.0.0.1:7878.",
"ai.prefs.control_port_taken": "El port {port} està en ús per un altre programa (o una altra finestra de SolidCraft). Trieu un altre port.",
"ai.prefs.follow_camera": "Girar la càmera cap als bocets",
"ai.prefs.follow_camera_off": "El seguiment automàtic de la càmera està desactivat.",
"ai.prefs.follow_camera_on": "La càmera gira cap als bocets que crea l'agent.",
@@ -999,6 +1011,13 @@
"browser.sketches_to_parts_not_on_face": "no en una cara de peça",
"browser.sketches_to_parts_part_missing": "la seva peça ha desaparegut",
"browser.sketches_to_parts_result": "S'han mogut {moved} bocets a la peça on són; {stayed} s'han quedat: {why}",
"browser.straighten_angle": "Direcció de l'aresta",
"browser.straighten_choose": "Trieu Horitzontal, Vertical o Angle per a l'aresta i premeu Redreçar.",
"browser.straighten_click_a": "Feu clic a la imatge en un extrem d'una aresta que hagi de quedar recta, o escriviu-ne les coordenades.",
"browser.straighten_click_b": "Feu clic a l'altre extrem de l'aresta, o escriviu-ne les coordenades.",
"browser.straighten_horizontal": "Horitzontal",
"browser.straighten_title": "Redreçar: {name}",
"browser.straighten_vertical": "Vertical",
"browser.units": "Unitats: {units}",
"cam.flat": "Cara plana",
"cam.follower": "Seguidor",
@@ -1028,6 +1047,8 @@
"cmd.appearance_pick_up": "Agafar l'aparença",
"cmd.appearance_replace": "Substituir l'aparença",
"cmd.assembly_bom": "Llista de materials",
"cmd.assembly_constraint_create": "Restringir",
"cmd.assembly_constraint_edit": "Editar la restricció",
"cmd.assembly_mass_properties": "Propietats de massa de l'ensamblatge",
"cmd.body.print_face": "Cara d'impressió",
"cmd.body_rename": "Canviar el nom del cos",
@@ -1046,6 +1067,7 @@
"cmd.canvas_delete": "Eliminar el llenç",
"cmd.canvas_edit": "Editar el llenç",
"cmd.canvas_insert": "Llenç",
"cmd.canvas_straighten": "Redreçar el llenç",
"cmd.canvas_to_sketch": "Llenç a bocet",
"cmd.component_activate": "Activar el component",
"cmd.component_break_link": "Trencar el vincle",
@@ -1087,7 +1109,10 @@
"cmd.config_rule": "Regla de configuració",
"cmd.config_rules": "Regles de configuració",
"cmd.config_table": "Mostrar la taula de configuracions",
"cmd.construct.axis": "Eix",
"cmd.construct.plane": "Pla",
"cmd.construct.plane.axis_point": "Pla per eix i punt",
"cmd.construct.point": "Punt",
"cmd.construct_axis_edge": "Eix per una aresta",
"cmd.construct_axis_normal_to_face": "Eix perpendicular a una cara",
"cmd.construct_axis_through_cylinder": "Eix per un cilindre, con o tor",
@@ -1150,6 +1175,7 @@
"cmd.file_save_mesh": "Desar com a malla",
"cmd.file_send_to_slicer": "Enviar al laminador",
"cmd.file_slicers": "Laminadors instal·lats",
"cmd.frame_end_cap": "Tapa d'extrem",
"cmd.frame_member": "Element estructural",
"cmd.frame_profile_insert": "Inserir un perfil",
"cmd.frame_profile_list": "Biblioteca de perfils",
@@ -1170,6 +1196,7 @@
"cmd.inspect_isocurve": "Anàlisi d'isocorbes",
"cmd.inspect_minimum_radius": "Anàlisi de radi mínim",
"cmd.inspect_parameter_sweep": "Escombrat de paràmetres",
"cmd.inspect_parameter_variants": "Comparar variants de paràmetres",
"cmd.inspect_silhouette": "Silueta",
"cmd.inspect_zebra": "Anàlisi de franges de zebra",
"cmd.joint_as_built": "Unió segons la posició actual",
@@ -1223,6 +1250,10 @@
"cmd.motion_trace": "Traça del moviment",
"cmd.motion_trace_edit": "Editar la traça del moviment",
"cmd.motion_trace_keep": "Conservar la traça del moviment",
"cmd.note_add": "Afegir una nota",
"cmd.note_delete": "Eliminar la nota",
"cmd.note_list": "Llistar les notes",
"cmd.note_update": "Editar la nota",
"cmd.occurrence_align": "Alinear la instància",
"cmd.occurrence_copy": "Enganxar (instància)",
"cmd.occurrence_drag": "Arrossegar la instància",
@@ -1348,6 +1379,7 @@
"cmd.render_start": "Renderitzar la imatge",
"cmd.render_status": "Progrés del renderitzat",
"cmd.select_clear": "Esborrar la selecció",
"cmd.selection_get": "Obtenir la selecció",
"cmd.settings_profile_apply": "Aplicar el perfil d'ajustos",
"cmd.settings_profile_delete": "Eliminar el perfil d'ajustos",
"cmd.settings_profile_export": "Exportar el perfil d'ajustos",
@@ -1511,6 +1543,7 @@
"cmd.timeline_suppress": "Suprimir l'operació",
"cmd.ui.assistant": "Alternar l'Assistent",
"cmd.ui.palette": "Cercar ordres",
"cmd.ui.toggle_visibility": "Mostrar/amagar la selecció",
"cmd.view.navigator": "Navegador de vistes personalitzat",
"cmd.view.render_browser": "Explorador de render",
"cmd.view.render_dock": "Moure l'acoblament del render",
@@ -1568,6 +1601,7 @@
"color.step_down": "Baixar un pas",
"color.step_up": "Pujar un pas",
"color.value": "Valor, la brillantor (percentatge)",
"component.error.ambiguous_name": "Diversos components es diuen `{name}`. Feu servir un ID numèric de component: {ids}.",
"config.column_filter_empty": "No hi ha columnes coincidents",
"config.rule_parameter_columns": "Només es llisten els paràmetres amb una columna a la taula. Afegiu-ne una amb Afegir una columna (+) per fer-la servir aquí.",
"config.rule_suppression_columns": "Només es llisten les operacions amb una columna Suprimir a la taula. Afegiu-ne una amb Afegir una columna (+) per controlar-la aquí.",
@@ -1608,6 +1642,7 @@
"diagnostic.reselect_reference": "{feature}: la referència `{reference}` ja no existeix tal com es va triar; torneu-la a seleccionar",
"dialog.bodies_count": "{count} cossos",
"dialog.clearance_invalid": "la folgança ha de ser una longitud de 0 o més",
"dialog.component_origin": "{placement} · {item}",
"dialog.compute": "Calcular",
"dialog.compute_again": "Calcular de nou",
"dialog.computing": "Calculant… {seconds} s",
@@ -1620,10 +1655,12 @@
"dialog.dimension_step_expression": "Només s'avança amb un nombre; una expressió es queda tal com s'escriu",
"dialog.dimension_step_up": "Pujar {step}",
"dialog.edit_title": "EDITAR {title}",
"dialog.hole_held_off_its_line": "L'aresta de referència manté aquest forat sobre una línia que surt de la cara abans del punt triat. Trieu on la línia travessa la cara, o traieu l'aresta per moure el forat lliurement.",
"dialog.no_shown_body": "No hi ha cap cos mostrat per canviar: mostreu-ne un o trieu Cos nou",
"dialog.pattern_sketch_not_object": "Trieu cossos o operacions: un bocet no és cap de les dues coses. Repetiu les seves corbes amb el Patró o la Simetria del propi bocet, o extruïu-lo primer.",
"dialog.primitive_point_tip": "Un punt que omple X, Y i Z: feu clic aquí i després feu clic en un vèrtex, una aresta o una cara del model, un punt d'un bocet o l'origen.",
"dialog.reference_edge_tip": "Una aresta recta de la cara triada per mesurar-hi la distància de sota: feu clic aquí i després feu clic a l'aresta.",
"dialog.right_click_pick": "Seleccioneu {input} primer",
"dialog.select": "↖ Seleccionar",
"dialog.selected_count": "↖ {count} seleccionats",
"dialog.selection_constraint": "Restricció {id}",
@@ -1637,9 +1674,12 @@
"document.changed_on_disk": "{name} ha canviat al disc",
"document.changed_on_disk_detail": "Un altre programa o un altre equip ha modificat {name} des que el vau obrir o desar per última vegada. Sobreescriure substitueix aquests canvis pels vostres; Desar una còpia conserva tots dos.",
"document.copy_name": "Còpia de {name}",
"document.discard_row": "Descartar",
"document.finish_save_prompt": "Acabeu de respondre l'avís de desar abans d'obrir un altre disseny.",
"document.newer_file": "Desat amb una versió més recent de SolidCraft (format {format}; aquesta versió llegeix fins a {reads}). Si falta alguna cosa, actualitzeu.",
"document.newer_file_change": "Desat amb una versió més recent de SolidCraft (format {format} amb `{change}`, que aquesta versió no coneix). Si falta alguna cosa, actualitzeu.",
"document.newer_file_not_read": "No llegit: {what}. En desar es conserva.",
"document.not_saved_yet": "Encara sense desar",
"document.open_anyway": "Obrir de tota manera",
"document.open_elsewhere": "{name} ja està obert",
"document.open_elsewhere_detail": "{name} està obert a SolidCraft {app} a {who}, obert {when}. Si deseu tots dos, un de vosaltres perdrà la feina. Obriu-lo en només lectura per veure'l sense sobreescriure'l.",
@@ -1649,6 +1689,9 @@
"document.read_only_save": "Aquest disseny s'ha obert en només lectura: deseu-lo com a fitxer nou",
"document.save_changes": "Voleu desar els canvis de {name}?",
"document.save_copy": "Desar una còpia…",
"document.save_many": "Voleu desar els canvis de {n} dissenys?",
"document.save_row": "Desar",
"document.save_selected": "Desar els seleccionats",
"document.tab_unsaved_changes": "Canvis sense desar",
"document.unsaved_changes": "{name} té canvis sense desar. Si no els deseu, es perdran.",
"drive.rigid": "{joint} és una unió rígida: manté les seves peces quietes, així que no té res a accionar. Per deixar-les moure, canvieu-ne el tipus a Editar la unió: Revolució per girar, Lliscador per lliscar, Cilíndrica per totes dues.",
@@ -1668,6 +1711,8 @@
"dxf.tolerance": "Tolerància de corba",
"dxf.tolerance_help": "Només cossos visibles i bocets mostrats. Les corbes fan servir segments rectes. Una tolerància més petita dóna corbes més fines i consumeix més temps i memòria.",
"each rail's curves must be in one sketch": "les corbes de cada guia han de ser en un sol bocet",
"error.end_cap_faces": "Trieu almenys una cara d'extrem oberta d'un element estructural",
"error.end_cap_unresolved": "L'extrem {ends} d'aquesta tapa ja no és al model. Torneu a triar les cares d'extrem",
"feature computation ended without a result": "el càlcul de l'operació s'ha acabat sense resultat",
"file.open.drawing_filter": "Dissenys, fitxers CAD, malles i dibuixos",
"file.open.portal_fallback.no_portal_service": "El selector de fitxers que s'ha obert no és el propi de l'escriptori: el servei del portal de l'escriptori no està disponible.",
@@ -1679,6 +1724,7 @@
"file_load.inserting": "Inserint la geometria",
"file_load.link_item": "{name} ({done} de {total})",
"file_load.opened": "S'ha obert {name}",
"file_load.opened_behind": "{name} s'ha obert en una pestanya nova",
"file_load.opened_in_tab": "S'ha obert {name} en una pestanya nova",
"file_load.opening": "Obrint el disseny",
"file_load.preparing_thumbnail": "Preparant la miniatura…",
@@ -1726,11 +1772,13 @@
"help.config.table": "Mostra la taula de configuració: una fila per variant, una columna per paràmetre o operació, i si el disseny encara coincideix amb la fila activa. Feu-ho servir per revisar les variants d'un disseny.",
"help.connect_note": "Reinicieu cada assistent perquè carregui el servidor de SolidCraft. Cada fitxer s'ha desat com a .bak abans de modificar-lo.",
"help.connect_title": "Connectar assistents d'IA",
"help.construct.axis": "Crea un eix de construcció amb el mètode que demanin les vostres seleccions: al llarg d'una aresta o línia, per un forat o una cara rodona, on es troben dos plans, per dos punts, o perpendicular a una cara per un punt. Feu-lo servir quan conegueu les referències i no el mètode.",
"help.construct.axis.edge": "Crea un eix de construcció al llarg d'una aresta recta o una línia de bocet. Feu-lo servir com a eix per a una revolució, un patró circular o una unió.",
"help.construct.axis.normal_to_face": "Crea un eix de construcció per un punt, perpendicular a una cara. Feu-lo servir com a eix que surt recte d'una superfície.",
"help.construct.axis.through_cylinder": "Crea un eix de construcció pel centre d'una cara de cilindre, con o tor, o una aresta circular. Feu-lo servir per girar o fer un patró d'operacions al voltant d'una peça rodona.",
"help.construct.axis.two_planes": "Crea un eix de construcció on es troben dos plans o cares planes. Feu-lo servir quan l'eix que necessiteu és la línia entre dues cares.",
"help.construct.axis.two_points": "Crea un eix de construcció per dos punts, vèrtexs o punts de bocet. Feu-lo servir per a un eix que va entre dues posicions conegudes.",
"help.construct.plane": "Crea un pla de construcció amb el mètode que demanin les vostres seleccions: desplaçat respecte d'un pla, per un punt, entre dos plans, per línies o punts, tangent a una cara corba, o perpendicular a una trajectòria. Feu-lo servir quan conegueu les referències i no el mètode.",
"help.construct.plane.along_path": "Crea un pla de construcció perpendicular a una aresta o corba, en una posició al llarg seu. Feu-lo servir per bocetar un perfil per a un escombrat o una secció d'una trajectòria.",
"help.construct.plane.at_angle": "Crea un pla de construcció girat un angle des d'un pla o cara, al voltant d'un eix o aresta. Activeu «Fer servir només la direcció de l'eix» per projectar l'eix sobre la base i pivotar al voltant d'un punt d'aquest pla (l'origen de la base quan no se'n tria cap). Feu-lo servir per a bocets inclinats, com un suport angular.",
"help.construct.plane.axis_point": "Crea un pla de construcció que conté un eix i un punt fora d'aquest eix. Trieu un origen o eix de construcció, una aresta recta o línia de bocet, i després un vèrtex, punt de bocet o punt de construcció. Un punt a l'eix no pot definir un pla únic.",
@@ -1740,6 +1788,7 @@
"help.construct.plane.tangent": "Crea un pla de construcció que toca un cilindre, con, esfera o tor en un punt. Feu-lo servir per bocetar al costat d'una peça rodona.",
"help.construct.plane.three_points": "Crea un pla de construcció per tres punts. Feu-lo servir per a un pla definit per posicions del model, com tres cantonades.",
"help.construct.plane.two_edges": "Crea un pla de construcció per dues arestes rectes o línies que són en un mateix pla. Feu-lo servir per a un pla a través d'una peça, com per dues arestes oposades.",
"help.construct.point": "Crea un punt de construcció amb el mètode que demanin les vostres seleccions: en un vèrtex, al centre d'un cercle o una esfera, al llarg d'una aresta, on es troben dues línies o tres plans, o on una línia travessa un pla. Feu-lo servir quan conegueu les referències i no el mètode.",
"help.construct.point.along_path": "Crea un punt de construcció en una posició al llarg d'una aresta o corba. Feu-lo servir com a referència a mig camí d'una trajectòria.",
"help.construct.point.center": "Crea un punt de construcció al centre d'una aresta circular, cercle o arc de bocet, esfera o tor. Feu-lo servir com a referència per a forats, eixos i unions.",
"help.construct.point.edge_plane": "Crea un punt de construcció on una aresta o línia travessa un pla o cara plana. Feu-lo servir per marcar on una trajectòria creua un pla.",
@@ -1765,6 +1814,7 @@
"help.file.send_to_slicer": "Desa els cossos seleccionats com a fitxer 3MF i obre'l al laminador que trieu. Feu-ho servir per passar del model a la impressió en un sol pas.",
"help.font_details": "La interfície fa servir les fonts integrades d'egui (Ubuntu Light i Hack, sota la Ubuntu Font Licence i la llicència MIT/Bitstream Vera; Noto Emoji i la font d'icones emoji, sota l'OFL i MIT). El text dels bocets fa servir Liberation Sans (SIL OFL 1.1). El paquet opcional de fonts de The Crafting Apps, craft-fonts (BIZ UDPGothic, Shippori Mincho, BIZ UDMincho, Noto Sans CJK SC, Noto Sans Arabic; SIL OFL 1.1), no s'inclou a les compilacions de SolidCraft.",
"help.fonts": "Fonts",
"help.frame.end_cap": "Tanca els extrems oberts dels elements estructurals amb plaques amb la forma del contorn del perfil. Feu-lo servir per segellar tubs, angulars i bigues, a sobre de l'extrem o encastat-hi.",
"help.frame.member": "Col·loca un perfil, com un tub o una extrusió d'alumini, al llarg de les línies d'un bocet d'estructura, un element per línia. Feu-ho servir per construir bastiments, prestatges i estructures soldades.",
"help.inspect.accessibility": "Coloreja les cares que una eina pot assolir des d'una direcció i les que no. Feu-ho servir per comprovar una peça per a mecanitzat o motlle des d'aquell costat.",
"help.inspect.center_of_mass": "Mostra el centre de masses dels cossos, cadascun per separat i tots junts, amb densitat uniforme. Feu-ho servir per comprovar l'equilibri i on bolcarà una peça.",
@@ -1891,13 +1941,13 @@
"help.sketch.construction_line": "Crea una línia de construcció infinita que passa per dos punts. Feu-la servir per estructurar un bocet sense partir perfils.",
"help.sketch.construction_line.angle": "Crea una línia de construcció infinita amb un angle respecte de l'eix X del bocet. Trieu el punt i escriviu l'angle, o trieu-ne la direcció.",
"help.sketch.construction_line.bisector": "Trieu dues línies rectes del bocet que es tallin i després una de les dues bisectrius de l'angle. El resultat segueix les seves línies d'origen.",
"help.sketch.construction_line.horizontal": "Crea una línia de construcció horitzontal infinita que passa per un punt. La seva restricció horitzontal segueix les edicions posteriors.",
"help.sketch.construction_line.parallel": "Trieu una línia recta del bocet i col·loqueu una línia de construcció paral·lela infinita. Feu clic amb el botó dret per triar una altra referència.",
"help.sketch.construction_line.horizontal": "Crea una línia de construcció horitzontal infinita que passi per un punt, o escriviu-ne la distància a l'eix X del bocet. Una distància escrita es converteix en una cota controladora. La seva restricció horitzontal segueix les edicions posteriors.",
"help.sketch.construction_line.parallel": "Trieu una línia recta de bocet i després col·loqueu una línia de construcció paral·lela infinita o escriviu-ne el desplaçament. Un desplaçament escrit es converteix en una distància controladora. Feu clic amb el botó dret per triar una altra referència.",
"help.sketch.construction_line.perpendicular": "Trieu una línia recta del bocet i col·loqueu una línia de construcció perpendicular infinita. Feu clic amb el botó dret per triar una altra referència.",
"help.sketch.construction_line.tangent_at": "Crea una tangent en un punt d'un cercle o arc circular, o en un extrem d'una spline o cònica oberta. Trieu la corba i després el punt de contacte.",
"help.sketch.construction_line.tangent_external": "Trieu un cercle o arc circular, després un punt exterior i una solució de tangent. Les previsualitzacions només mostren contactes a l'arc dibuixat.",
"help.sketch.construction_line.tangent_two": "Trieu dos cercles o arcs circulars i després una tangent comuna a la previsualització. Els contactes han de ser a les corbes dibuixades.",
"help.sketch.construction_line.vertical": "Crea una línia de construcció vertical infinita que passa per un punt. La seva restricció vertical segueix les edicions posteriors.",
"help.sketch.construction_line.vertical": "Crea una línia de construcció vertical infinita que passi per un punt, o escriviu-ne la distància a l'eix Y del bocet. Una distància escrita es converteix en una cota controladora. La seva restricció vertical segueix les edicions posteriors.",
"help.sketch.copy": "Copia les corbes i punts de bocet seleccionats, juntament amb les restriccions i cotes que hi ha entre ells. Enganxeu-los en aquest bocet o en un altre.",
"help.sketch.create": "Inicia un bocet 2D nou en un pla o en una cara plana del model. Cada extrusió, revolució i escombrat comença amb un bocet.",
"help.sketch.cut": "Copia la geometria de bocet seleccionada i l'esborra del bocet, en un sol pas de desfer. Enganxeu-la en un altre lloc d'aquest bocet o en un altre.",
@@ -2241,6 +2291,9 @@
"measure.at": "A",
"measure.item": "Element {number}",
"measure.line_to_centre": "Línia al centre",
"measure.perimeter": "Perímetre",
"measure.perimeter_inner": "Perímetre interior",
"measure.perimeter_outer": "Perímetre exterior",
"measure.quick_copied": "Mesura copiada",
"measure.quick_copy": "Feu clic per copiar la mesura",
"measure.quick_edges": "{count} arestes",
@@ -2282,6 +2335,8 @@
"nav.hfss.help": "Alt+arrossegar esquerre desplaça, Majúscules+Alt+arrossegar esquerre fa zoom, Ctrl+arrossegar esquerre en un espai buit orbita",
"nav.inventor.help": "Arrossegar amb el botó central desplaça, Majúscules+central orbita, Ctrl+central fa zoom i arrossegar amb el dret orbita",
"nav.maya.help": "Alt+arrossegar esquerre orbita, Alt+arrossegar central desplaça, Alt+arrossegar dret fa zoom; arrossegar central també desplaça",
"nav.maya_horizontal_zoom.help": "Alt+arrossegar amb el botó dret: cap a la dreta apropa, cap a l'esquerra allunya. Desactiveu-ho per tornar al zoom arrossegant en vertical.",
"nav.maya_horizontal_zoom.label": "Maya: zoom arrossegant en horitzontal",
"nav.nx.help": "Arrossegar amb el botó central orbita, Majúscules+central desplaça (central+dret de NX), Ctrl+central fa zoom (central+esquerre de NX); arrossegar amb el dret deixa la càmera quieta",
"nav.openscad.help": "Arrossegar amb el botó esquerre en un espai buit orbita, Majúscules+esquerre selecciona amb un rectangle i arrossegar geometria del bocet l'edita; arrossegar amb el dret o el central desplaça, i Majúscules+dret o Majúscules+central fa zoom",
"nav.orbit_cursor_wrap.help": "Un arrossegament d'òrbita que arriba a la vora de la vista mou el cursor a la vora oposada i continua girant. Desactivat: l'arrossegament s'atura a la vora de la pantalla. No té efecte a Wayland ni al navegador.",
@@ -2412,6 +2467,16 @@
"not a move": "no és un moviment",
"not a number": "no és un nombre",
"open a sketch first": "obriu primer un bocet",
"operations.cancel": "Cancel·lar {name}",
"operations.cancelled": "{name} cancel·lat",
"operations.cancelling": "Cancel·lant…",
"operations.cannot_cancel": "{name} no es pot cancel·lar",
"operations.feature": "Operació",
"operations.feature_edit": "Editar l'operació",
"operations.meshing": "Carregant les peces",
"operations.queued": "A la cua",
"operations.stopped": "Cancel·lat",
"operations.waiting": "A punt",
"parameters.apply_live": "Aplicar cada canvi en sortir del camp",
"parameters.apply_live_tip": "Desactivat: els canvis esperen fins que premeu Aplicar o D'acord, i llavors el disseny es reconstrueix una sola vegada per a tots. Cancel·lar o Escape els descarta.",
"parameters.held": "{count} canvis esperant Aplicar",
@@ -2500,6 +2565,7 @@
"prefs.appearance.viewport_colours": "Colors de la vista 3D",
"prefs.appearance.viewport_logo": "Visor i logotip",
"prefs.applies_on_restart": "S'aplica en reiniciar",
"prefs.assemblies": "Ensamblatges",
"prefs.assistant": "Assistent",
"prefs.assistant_connecting": "Connectant",
"prefs.assistant_follow_help": "Els bocets Mirar cap a mouen la vista amb el punter.",
@@ -2552,6 +2618,10 @@
"prefs.choose_font": "Triar un fitxer de tipus de lletra…",
"prefs.click": "Clic",
"prefs.command_dialog_values": "Diàlegs d'ordres",
"prefs.constraint_glyph_size": "Mida dels glifs de restricció",
"prefs.constraint_glyph_size_note": "Com de grans es dibuixen els glifs de restricció i les seves lletres o icones en un bocet. El 100 % és la mida habitual; els glifs conserven aquesta mida en pantalla amb qualsevol zoom.",
"prefs.construct_menu_groups": "Agrupar les eines de Construir per tipus d'entitat",
"prefs.construct_menu_groups_help": "Mostra els encapçalaments Plans, Eixos i Punts. Desactivat, es manté l'ordre original del menú.",
"prefs.contact_shading": "Ombrejat de contacte suau",
"prefs.contact_shading_note": "Ombrejat suau on les peces s'acosten.",
"prefs.context_menu": "Menú contextual",
@@ -2583,10 +2653,12 @@
"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",
"prefs.dialog_first_letter_buttons": "Els botons de diàleg responen a la seva primera lletra",
"prefs.dialog_first_letter_buttons_help": "Amb un diàleg obert, escriure la primera lletra de l'etiqueta d'un botó el prem: O per a OK, C per a Cancel·la. Una lletra que encaixa en dos botons no fa res, i un camp de text amb el focus conserva la pulsació. Desactivat: escriure no afecta els botons, com abans.",
"prefs.dialogs": "Diàlegs",
"prefs.dialogs_edge": "A la vora de la vista",
"prefs.dialogs_left_column": "A la columna esquerra",
@@ -2600,12 +2672,23 @@
"prefs.dimension_values_window": "En una finestra",
"prefs.display": "Pantalla",
"prefs.edges": "Arestes i ombrejat",
"prefs.export": "Exportar",
"prefs.export_name": "Nom del fitxer d'exportació",
"prefs.export_name.body": "Nom del cos exportat (el primer quan n'hi ha diversos)",
"prefs.export_name.design": "Nom del disseny",
"prefs.export_name_hint": "p. ex., {design}-{body}",
"prefs.export_name_sample": "En exportar aquest disseny s'ofereix: {name}",
"prefs.export_name_tip": "El nom de fitxer que ofereixen Fitxer › Exportar i Desar com a malla. Se substitueixen aquests marcadors:\n{placeholders}\nUna propietat del disseny que no estigui definida s'omet. Deixeu el camp buit per oferir el nom del disseny o, en una malla, el del cos.",
"prefs.extend": "Extensions",
"prefs.extra_navigation": "Ctrl + arrossegar amb el botó esquerre sobre un espai buit orbita; manteniu l'Espai per desplaçar.",
"prefs.extrude_arrow_face_stops": "La fletxa d'extrusió s'atura a les cares",
"prefs.extrude_arrow_face_stops_note": "En arrossegar la fletxa de distància d'Extruir, s'atura a les cares planes paral·leles al perfil, com el costat oposat d'un bloc que s'està tallant, i ressegueix la cara. Desactivat: la fletxa només es mou a passos rodons.",
"prefs.extrude_view_isometric": "Girar la vista a isomètrica en obrir Extruir",
"prefs.extrude_view_return": "Tornar a la vista anterior en tancar Extruir",
"prefs.file_operations_wait": "Esperar que s'obrin els fitxers",
"prefs.file_operations_wait_note": "Mentre s'obre o s'insereix un fitxer, o es tornen a llegir els vincles, el disseny espera darrere una targeta amb el progrés del fitxer i Cancel·la. Desactivat: el fitxer apareix al panell d'operacions i podeu seguir treballant; una inserció s'aplica quan acabeu el que esteu fent, i un fitxer obert mentrestant va a una pestanya nova.",
"prefs.fillet_legacy_radius": "Usar el radi predeterminat anterior d'Arrodoniment de sòlid",
"prefs.fillet_legacy_radius.help": "Desactivat de manera predeterminada. Activat, un nou Arrodoniment de sòlid s'obre amb el número 2 llegit en les unitats del disseny («2 in» en un disseny en polzades), com abans; desactivat, s'obre amb el radi que porta l'eina, 2 mm, mostrat en les unitats del disseny («0.07874 in» en un disseny en polzades).",
"prefs.follows_os": "Segueix el sistema operatiu",
"prefs.font": "Tipus de lletra",
"prefs.font_fallback": "Els glifs que no té el tipus de lletra triat es prenen del tipus integrat, així cap etiqueta no mostra un requadre buit.",
@@ -2629,6 +2712,17 @@
"prefs.gesture_pan": "Desplaçar",
"prefs.gesture_zoom": "Zoom",
"prefs.graphics": "Gràfics",
"prefs.graphics_api": "API gràfica",
"prefs.graphics_api_auto": "Automàtica",
"prefs.graphics_api_help": "Trieu l'API gràfica que s'usa per obrir SolidCraft. Si el proper inici no pot obrir la finestra, es restaura la tria anterior.",
"prefs.graphics_api_override": "Una opció d'inici controla l'API gràfica. La tria desada s'aplicarà quan SolidCraft s'obri normalment.",
"prefs.graphics_api_restart_note": "S'aplica després de reiniciar. Els dissenys sense desar es gestionen abans de tancar.",
"prefs.graphics_api_restart_unavailable": "Reinicieu SolidCraft per aplicar l'API gràfica desada.",
"prefs.graphics_api_running": "Actual: {current}. Triada per al proper inici: {selected}.",
"prefs.graphics_api_save_failed": "No s'ha pogut desar la tria d'API gràfica: {error}",
"prefs.graphics_api_start_failed": "No s'ha pogut preparar l'API gràfica triada: {error}",
"prefs.graphics_api_start_reverted": "L'inici anterior amb {requested} no va obrir la finestra. SolidCraft ha restaurat {previous}.",
"prefs.graphics_api_unavailable": "La selecció de l'API gràfica està disponible a l'aplicació d'escriptori.",
"prefs.graphics_card": "Targeta gràfica",
"prefs.graphics_card_auto": "Automàtica",
"prefs.graphics_card_missing": "La targeta gràfica desada no està disponible; s'usa Automàtica.",
@@ -2672,6 +2766,8 @@
"prefs.history_sketches_in_order": "En ordre",
"prefs.history_sketches_note": "Amb Bocets a Sota la seva operació, l'historial de l'esquerra llista cada bocet un nivell endins, sota la primera operació construïda a partir d'ell, sempre que hi hagi prou amplada per als noms. La línia de temps de baix manté sempre l'ordre de creació.",
"prefs.history_sketches_under_feature": "Sota la seva operació",
"prefs.history_steps_component": "Passos de l'historial per component",
"prefs.history_steps_component_note": "Amb un component actiu o seleccionat, els controls de l'Historial recorren les operacions d'aquest component, tal com les mostra l'Historial de baix. Desactivat: recorren totes les operacions del disseny. L'Historial de l'esquerra o sota el navegador sempre recorre totes les operacions.",
"prefs.hold_stop_snapping": "Mantenir premut per aturar l'ajust",
"prefs.home_fit_size": "Mida d'Inici i Ajustar",
"prefs.home_fit_size_note": "Quant de la vista omple el model quan Inici, Ajustar, un disseny obert o un canvi de vista del navegador l'enquadren, tant si l'ajust de sota està activat com si no. 100 %: omple la vista entre els panells, la brúixola i la barra de navegació.",
@@ -2687,6 +2783,8 @@
"prefs.invert_curved_roll_arrows_note": "Les dues fletxes corbes a la cantonada del cub giren la vista dins del pla de la pantalla. Activades: la fletxa horària gira en sentit antihorari i l'antihorària gira en sentit horari. Desactivades: cada fletxa gira la vista cap on es corba.",
"prefs.invert_navigator_arrows": "Invertir les fletxes de rotació del cub de vista",
"prefs.invert_navigator_arrows_note": "Les fletxes al voltant del cub orbiten la càmera cap on apunten, com a Fusion i Inventor: la fletxa de l'esquerra apunta a la dreta i gira la càmera a la dreta. Desactivades: una fletxa gira el cub cap al costat on és, així la fletxa de l'esquerra gira la càmera a l'esquerra. Les fletxes de gir corbes no en queden afectades.",
"prefs.joint_drag_trackball": "Girar les peces d'una unió de ròtula com un trackball en arrossegar-les",
"prefs.joint_drag_trackball_help": "Arrossegar una peça que subjecta una unió de ròtula la gira amb el gir més petit possible al voltant de la unió, com si rodés una bola de la mida de la peça. Desactivat: el punt que heu agafat segueix exactament el punter, i la peça també pot girar sobre ell.",
"prefs.keep_unsaved_also_asks": "Continuar preguntant quan es tanca la pestanya",
"prefs.keep_unsaved_also_asks_tip": "Amb això desactivat, la pestanya es tanca sense la finestra del navegador un cop la còpia de cada disseny sense desar està al dia.",
"prefs.keep_unsaved_in_browser": "Conservar la feina sense desar en aquest navegador",
@@ -2762,6 +2860,8 @@
"prefs.not_on_gpu": "no en aquesta GPU",
"prefs.on": "Activat",
"prefs.opaque": "Opac",
"prefs.operations_panel": "Mostrar les operacions a la vista",
"prefs.operations_panel_note": "Les operacions llargues (obrir un fitxer, calcular una operació, una ordre de l'agent) es mostren a la cantonada inferior dreta de la vista amb el seu progrés i un botó per cancel·lar-les. Desactivat: un avís de «Calculant», com abans.",
"prefs.orbit_snap": "Manteniu el Majúscules mentre orbiteu per ajustar a un eix.",
"prefs.orientation": "Orientació",
"prefs.origin_plane_names": "Noms dels plans d'origen",
@@ -2842,6 +2942,8 @@
"prefs.ribbon_tabs_only": "Mostrar només les pestanyes de la cinta",
"prefs.ribbon_tabs_only_note": "La cinta es plega fins a la seva fila de pestanyes. En fer clic en una pestanya es mostren els seus panells sobre la vista fins que comença una ordre o el punter en surt. «Només les pestanyes de la cinta», a la pestanya Vista o amb la seva drecera, ho commuta.",
"prefs.right_click": "Clic dret",
"prefs.save_prompt_one_at_a_time": "Preguntar per cada disseny sense desar per separat en tancar-ne diversos",
"prefs.save_prompt_one_at_a_time_help": "Mostrar un avís de desar per cada disseny sense desar en lloc d'una llista combinada.",
"prefs.saving": "Inici i desament",
"prefs.scale_note": "També Ctrl + / Ctrl − a la finestra. Els botons fan 28 px al 100 %.",
"prefs.scale_text": "Escala i text",
@@ -2878,6 +2980,8 @@
"prefs.sketch.center_arc": "L'arc pel centre segueix el cursor",
"prefs.sketch.click_selects": "El clic selecciona",
"prefs.sketch.conflict_colour": "Color de conflicte",
"prefs.sketch.cursor_coordinates": "Mostrar les coordenades del cursor en un bocet",
"prefs.sketch.cursor_coordinates_note": "Al costat del punter, la seva X i Y des de l'origen del bocet, també sense cap eina en marxa. Desactivat: només mentre una eina col·loca un punt.",
"prefs.sketch.drawing": "Dibuix",
"prefs.sketch.ellipse_axes": "Dibuixar les el·lipses amb els seus eixos",
"prefs.sketch.ellipse_axes_note": "Els eixos major i menor com a línies de construcció, per poder acotar els semieixos i els eixos. Desactivat: només l'el·lipse, i D a sobre seu dóna el radi menor allà on es tiri el valor, dibuixat i etiquetat R com abans.",
@@ -2889,14 +2993,20 @@
"prefs.sketch.faces_edges": "Cares i arestes",
"prefs.sketch.hide_glyphs": "Amagar els glifs en allunyar-se",
"prefs.sketch.marks": "Marques al bocet",
"prefs.sketch.max_points": "Bocet més gran",
"prefs.sketch.max_points_note": "El màxim de punts o corbes que admet un bocet: 20000 de manera predeterminada, com a molt 200000. Inserir SVG i Inserir DXF refusen un dibuix més gran. Un bocet gran es resol i es dibuixa més lentament.",
"prefs.sketch.max_points_unit": "punts",
"prefs.sketch.note": "Dibuix, ajust i selecció.",
"prefs.sketch.opening_closing": "Obertura i tancament",
"prefs.sketch.prefer_free_points": "Preferir punts mòbils quan se superposen",
"prefs.sketch.project_options": "Demanar primer les opcions de Projectar",
"prefs.sketch.recent_snap": "Ajustar on es creuen dues guies",
"prefs.sketch.select_3d_curve_points": "Seleccionar punts de corbes de bocet 3D",
"prefs.sketch.select_3d_curve_points_help": "Mostra i permet seleccionar punts de les corbes de bocet 3D dibuixades com a referències de construcció.",
"prefs.sketch.select_other_hidden": "Seleccionar-ne un altre arriba a la geometria oculta",
"prefs.sketch.shortcut_cycles_modes": "La drecera canvia de mode en tornar-la a prémer",
"prefs.sketch.shortcut_cycles_modes_tip": "Desactivat: torna a iniciar l'eina.",
"prefs.sketch.snap_to_visible_grid": "Ajustar als encreuaments visibles de la quadrícula",
"prefs.sketch.snapping_guides": "Ajust i guies",
"prefs.sketch.state_icons": "Icones d'estat",
"prefs.sketch.tracking_guides": "Guies de centre i punt mitjà",
@@ -2924,6 +3034,7 @@
"prefs.sketch_selection": "Bocet i selecció",
"prefs.sketch_selection_heading": "Bocet i selecció",
"prefs.sketch_selection_note": "Com s'ajusten els bocets i com seleccionen els clics.",
"prefs.sketch_snap_to_visible_grid_note": "Un punt d'un bocet s'ajusta als encreuaments de les línies que dibuixa la quadrícula quan el punter n'apunta un, i si no, a la retícula més fina entre ells. Desactivat: el punt s'ajusta només a la retícula més fina, com abans.",
"prefs.sketch_state_icons": "Icones dels bocets amb el color del seu estat",
"prefs.sketch_state_icons_note": "A l'historial i a l'explorador, la icona d'un bocet pren el color del seu estat (insuficientment restringit, completament restringit o en conflicte) i la seva informació en diu l'estat. Desactivat: tots els bocets tenen la mateixa icona.",
"prefs.sketch_tab_always": "Mantenir visible la pestanya Bocet",
@@ -3445,12 +3556,12 @@
"render.cpu_subsurface_denoise_reason": "La dispersió subsuperficial de CPU necessita la reducció de soroll desactivada. Es conserven els vostres ajustos.",
"render.cpu_subsurface_help": "La dispersió redirigeix la llum dins del vidre tancat. Les taxes positives necessiten il·luminació indirecta amb la reducció de soroll desactivada. La taxa és de col·lisions per mm; 0 no afegeix dispersió. Un biaix de fase positiu afavoreix les direccions cap endavant; un de negatiu, les cap enrere.",
"render.cpu_summary": "Ray tracing de CPU · reflexos · HDR · llums direccionals i puntuals",
"render.cpu_threads": "Processos de CPU",
"render.cpu_threads": "Workers de CPU",
"render.cpu_threads_auto": "Automàtic",
"render.cpu_threads_custom": "Personalitzat",
"render.cpu_threads_help": "Automàtic fa servir els processos de CPU disponibles. Un procés s'executa en sèrie. Més processos poden necessitar més memòria.",
"render.cpu_threads_help": "Automàtic fa servir els workers de CPU disponibles. Un worker s'executa en sèrie. Més workers poden necessitar més memòria.",
"render.cpu_threads_single": "Un",
"render.cpu_threads_web": "El renderitzat al navegador fa servir un procés cooperatiu. Es conserva l'elecció de processos desada per a l'escriptori.",
"render.cpu_threads_web": "El renderitzat al navegador fa servir un worker cooperatiu. Es conserva la tria de workers desada per a l'escriptori.",
"render.cpu_transparent_help": "El PNG transparent treu el fons i conserva la cobertura de les vores. L'opacitat del cos i de la cara ha de mantenir-se al 100 %.",
"render.cpu_volume_enable_path": "Activar la il·luminació indirecta",
"render.cpu_volume_enable_path_plan": "Ray tracing de CPU · profunditat {depth} · {spp} mostres/píxel · llavor {seed} · ombres traçades. Conserva l'atmosfera definida i el pressupost d'imatge.",
@@ -3625,6 +3736,7 @@
"render.error_pbr_opacity": "el PBR opac requereix opacitat 1",
"render.error_physical_glass": "la recepta triada no és vidre físic",
"render.error_preview_device": "la vista prèvia del producte requereix un dispositiu instal·lat amb búfer de profunditat",
"render.error_product_placement": "col·locació de producte no vàlida",
"render.error_product_triangle": "triangle de producte no vàlid",
"render.error_provisional_identity": "identitat provisional de renderitzat o nombre de mostres no vàlida",
"render.error_quality_bounds": "la projecció de qualitat supera els límits natius",
@@ -3708,6 +3820,10 @@
"render.gpu_path_retry": "Torna-ho a provar",
"render.gpu_seed": "Llavor aleatòria",
"render.gpu_unavailable": "El path tracing de GPU encara no està a punt en aquest dispositiu.",
"render.graphics_api_restart_vulkan": "Reiniciar amb Vulkan",
"render.graphics_api_restart_warning": "Canviar l'API gràfica reinicia SolidCraft i us demana de desar els dissenys sense desar. Si la nova API no pot obrir la finestra, es restaura la tria anterior al proper inici.",
"render.graphics_api_settings": "Configuració gràfica",
"render.graphics_api_vulkan_required": "El path tracing a la GPU necessita Vulkan en aquesta sessió. Reinicieu amb Vulkan o seguiu amb un mètode d'imatge per CPU.",
"render.ground_apply": "Ajustar i desar el terra",
"render.ground_gap": "Espai lliure explícit",
"render.ground_linear_rgb": "Reflectància difusa · RGB lineal",
@@ -4709,6 +4825,8 @@
"sketch.hint.spline3d": "Spline 3D: feu clic als punts d'ajust en 3D; clic dret o Esc per acabar",
"sketch.hint.trim": "Retallar: feu clic al tros de corba que voleu treure, o arrossegueu pels trossos que voleu treure",
"sketch.make_selection": "Convertir la selecció en {linetype}",
"sketch.move_released": "Mogut: s'han deixat anar {count} restriccions que el lligaven al que es queda ({names}).",
"sketch.move_released_one": "Mogut: s'ha deixat anar {count} restricció que el lligava al que es queda ({names}).",
"sketch.open_ends": "Oberts {count}",
"sketch.status_conflict": "restriccions en conflicte",
"sketch.status_fully_constrained": "totalment restringit",
@@ -4943,6 +5061,7 @@
"ui.agent_chat_allow_once": "Permetre una vegada",
"ui.agent_chat_allowed": "Permès",
"ui.agent_chat_answered": "Resposta: {option}",
"ui.agent_chat_answered_in": "Respost en {time}",
"ui.agent_chat_approval": "Permetre que l'agent faci això?",
"ui.agent_chat_attach_selection": "Adjuntar la selecció",
"ui.agent_chat_attach_selection_tip": "Envia amb el missatge el que estigui seleccionat al model (cares, arestes, operacions, cossos) perquè l'agent sàpiga a què us referiu",
@@ -4971,6 +5090,7 @@
"ui.agent_chat_ctx_bodies": "Cossos",
"ui.agent_chat_ctx_elements": "Elements",
"ui.agent_chat_ctx_features": "Operacions",
"ui.agent_chat_ctx_gone": "Ja no és al disseny: s'ha canviat o eliminat.",
"ui.agent_chat_ctx_more": "+{n} més",
"ui.agent_chat_ctx_other": "Altres",
"ui.agent_chat_custom_heading": "Model personalitzat",
@@ -4984,6 +5104,7 @@
"ui.agent_chat_dock_right": "Acoblar a la dreta",
"ui.agent_chat_drop_hint": "Deixeu anar per adjuntar-ho al proper missatge",
"ui.agent_chat_edit": "Editar",
"ui.agent_chat_ended_after": "Acabat després de {time}",
"ui.agent_chat_exited": "L'agent s'ha aturat.",
"ui.agent_chat_failed_start": "No s'ha pogut iniciar l'agent: {error}",
"ui.agent_chat_file_cad_not_read": "{name}: un fitxer CAD s'importa al disseny, no es llegeix",
@@ -5021,6 +5142,7 @@
"ui.agent_chat_model_no_tools": "{model} no pot usar les eines de SolidCraft, així que només pot xatejar. Trieu un model amb suport d'eines.",
"ui.agent_chat_model_rejected": "L'agent no ha acceptat {model}: {error}",
"ui.agent_chat_model_search": "Cercar models…",
"ui.agent_chat_model_search_or_id": "Cerqueu o escriviu qualsevol ID de model…",
"ui.agent_chat_model_tip": "El model que usa l'agent. La vostra tria es recorda per a aquest agent.",
"ui.agent_chat_models_failed": "No s'han pogut llistar els models: {error}",
"ui.agent_chat_models_heading": "Models disponibles",
@@ -5032,6 +5154,7 @@
"ui.agent_chat_next_picture": "Imatge següent",
"ui.agent_chat_no_cli": "No s'ha trobat solidcraft-cli al costat de l'aplicació, així que l'agent no pot accedir al disseny.",
"ui.agent_chat_no_control": "El punt de connexió del xat de l'agent no s'ha iniciat, així que l'agent no pot accedir al disseny.",
"ui.agent_chat_no_design_hint": "L'agent s'executa sense les eines de SolidCraft, així que no pot llegir ni canviar el disseny: no s'ha trobat solidcraft-cli al costat de l'aplicació. Instal·leu SolidCraft amb solidcraft-cli al costat (a Windows, el MSI o el zip portàtil) o definiu SOLIDCRAFT_CLI amb la seva ruta, i torneu a connectar.",
"ui.agent_chat_no_images": "Aquest agent no admet imatges",
"ui.agent_chat_no_picture": "No hi ha cap imatge al porta-retalls",
"ui.agent_chat_no_tool_call": "El model no ha fet servir les eines de SolidCraft, així que el disseny no ha canviat. Trieu un model que admeti crides a eines; vegeu Models locals a la guia del xat de l'agent.",
@@ -5046,7 +5169,7 @@
"ui.agent_chat_permission": "L'agent demana executar: {title}",
"ui.agent_chat_pick": "Agent",
"ui.agent_chat_pick_context": "Triar context",
"ui.agent_chat_pick_context_tip": "Trieu cares, arestes, operacions i cossos del model perquè l'agent els miri i premeu D'acord",
"ui.agent_chat_pick_context_tip": "Trieu cares, arestes, operacions i cossos del model i premeu OK: s'afegeixen al missatge com una fitxa on és el cursor (escriure @ fa el mateix)",
"ui.agent_chat_placeholder": "Demaneu a l'agent que modifiqui el disseny…",
"ui.agent_chat_placeholder_busy": "Posa un missatge a la cua per quan l'agent acabi (Ctrl+Retorn: enviar ara)",
"ui.agent_chat_pop_out": "Obrir en una finestra pròpia, que podeu moure a una altra pantalla",
@@ -5078,16 +5201,26 @@
"ui.agent_chat_setup_title": "Configurar un assistent",
"ui.agent_chat_side": "Costat del xat de l'agent",
"ui.agent_chat_signed_in": "sessió iniciada",
"ui.agent_chat_snap_none_found": "No s'ha trobat cap agent de codi. El paquet Snap està confinat, així que no pot veure ni iniciar els agents de codi instal·lats en aquest equip. Per usar-los, instal·leu SolidCraft des de l'AppImage o el paquet .deb o .rpm. Un agent que usa una API compatible amb OpenAI també funciona al paquet Snap: configureu-ne un amb Afegir agent.",
"ui.agent_chat_span_h": "{h} h {min} min",
"ui.agent_chat_span_min": "{n} min",
"ui.agent_chat_span_s": "{n} s",
"ui.agent_chat_state_auth": "Cal iniciar sessió",
"ui.agent_chat_state_busy": "Treballant…",
"ui.agent_chat_state_exited": "Agent aturat",
"ui.agent_chat_state_failed": "Connexió fallida",
"ui.agent_chat_state_no_design": "Sense accés al disseny",
"ui.agent_chat_state_off": "Sense connexió",
"ui.agent_chat_state_ready": "A punt",
"ui.agent_chat_state_starting": "Iniciant…",
"ui.agent_chat_stop": "Aturar",
"ui.agent_chat_stop_tip": "Aturar l'agent i tot el que hagi iniciat",
"ui.agent_chat_stop_turn_tip": "Atura l'agent (Esc). Els missatges a la cua es queden a la cua.",
"ui.agent_chat_thinking_for": "Pensant durant {time}",
"ui.agent_chat_tool_running_for": "en execució durant {time}",
"ui.agent_chat_tools_not_started": "L'agent s'executa sense les eines de SolidCraft, així que no pot llegir ni canviar el disseny: s'ha trobat solidcraft-cli, però les seves eines no s'han iniciat (l'error del xat explica per què). Torneu a connectar; si continua passant, reinstal·leu SolidCraft.",
"ui.agent_chat_transcript_assistant": "Assistent:",
"ui.agent_chat_transcript_user": "Usuari:",
"ui.agent_chat_turn_limit": "Canvis per missatge",
"ui.agent_chat_turn_unlimited": "Sense límit",
"ui.agent_chat_undo_changes": "Desfer aquests canvis",
@@ -5244,7 +5377,9 @@
"ui.browser_bodies_one": "{count} cos",
"ui.browser_bodies_other": "{count} cossos",
"ui.browser_collapse": "Contraure l'explorador",
"ui.browser_collapse_below": "Contraure a sota",
"ui.browser_expand": "Expandir l'explorador",
"ui.browser_expand_below": "Expandir a sota",
"ui.browser_title": "Explorador",
"ui.calibrate": "Calibrar",
"ui.cancel": "Cancel·la",
@@ -5274,6 +5409,7 @@
"ui.collapse": "Contraure",
"ui.collinear": "Colineal",
"ui.colour_vision": "Visió del color",
"ui.colour_vision_bends": "Plecs",
"ui.colour_vision_deutan": "Deuteranòpsia",
"ui.colour_vision_deutan_tip": "Per a una percepció del verd feble o absent",
"ui.colour_vision_failed": "Fallit",
@@ -5303,14 +5439,24 @@
"ui.conic_curve": "Corba cònica",
"ui.constant": "Constant",
"ui.constraints_conflict": "Conflicte de restriccions",
"ui.construct_auto.chosen": "Triat a partir de les vostres seleccions. Trieu una altra referència, o traieu-ne una, per canviar el mètode.",
"ui.construct_auto.no_fit": "Trieu altres referències: cap mètode es pot construir amb aquestes.",
"ui.construct_auto.pick": "Trieu amb què construir-lo: el mètode es dedueix de les vostres seleccions.",
"ui.construct_auto.refs": "Referències",
"ui.construct_auto.select": "Seleccioneu primer les referències amb què construir",
"ui.construct_axes": "Eixos",
"ui.construct_planes": "Plans",
"ui.construct_points": "Punts",
"ui.construction": "Construcció",
"ui.construction.angle_hint": "Línia de construcció: feu clic en un punt i escriviu un angle o trieu una direcció",
"ui.construction.axis_hint": "Línia de construcció: feu clic en un punt o escriviu la seva distància a l'eix del bocet",
"ui.construction.bisector_hint": "Bisectriu: trieu dues línies rectes que es tallin i després la solució volguda",
"ui.construction.common_hint": "Tangent comuna: trieu dos cercles o arcs i després la solució volguda",
"ui.construction.contact_hint": "Tangent: trieu un cercle, arc o spline o cònica oberta i després el punt de contacte (corbes lliures: extrems)",
"ui.construction.external_hint": "Tangent: trieu un cercle o arc, un punt exterior i després la solució volguda",
"ui.construction.free_hint": "Línia de construcció: feu clic en dos punts; feu clic amb el botó dret per reiniciar",
"ui.construction.normal_needs_length": "Una línia infinita és sempre de construcció: escriviu una longitud per dibuixar una línia normal",
"ui.construction.parallel_hint": "Línia de construcció: trieu una línia recta i després feu clic o escriviu el desplaçament; feu clic amb el botó dret per canviar la referència",
"ui.construction.point_hint": "Línia de construcció: feu clic en el punt per on ha de passar",
"ui.construction.reference_hint": "Línia de construcció: trieu una línia recta i després un punt; feu clic amb el botó dret per canviar la referència",
"ui.construction.ribbon_label": "Línies X",
@@ -5453,6 +5599,17 @@
"ui.ellipse": "El·lipse",
"ui.emboss_wrap_note": "Els perfils de línies ortogonals tangents a un cilindre compatible s'embolcallen automàticament. Trieu una paret per triar un destí.",
"ui.end": "Fi",
"ui.end_cap.chamfer": "Xamfrà de cantonada",
"ui.end_cap.direction": "Direcció",
"ui.end_cap.faces": "Cares d'extrem",
"ui.end_cap.inset": "Reducció",
"ui.end_cap.inset_kind": "Reduir per",
"ui.end_cap.inset_ratio": "Proporció de reducció",
"ui.end_cap.inward": "Cap endins",
"ui.end_cap.length": "Longitud",
"ui.end_cap.outward": "Cap enfora",
"ui.end_cap.ratio": "Proporció de gruix",
"ui.end_cap_title": "TAPA D'EXTREM",
"ui.end_radius": "Radi final",
"ui.end_scale": "Escala final",
"ui.ends": "Extrems",
@@ -5473,7 +5630,10 @@
"ui.explode_needs_two_components": "La vista explosionada separa components, així que el disseny en necessita dos o més (Component nou, Inserir un disseny o Crear components a partir de cossos)",
"ui.exportDxf": "Exportar DXF…",
"ui.export_ellipsis_keymap": "Exportar…",
"ui.export_glb": "Exportar GLB",
"ui.export_keymap_tip": "Les tecles, els favorits de la cerca S i la barra d'eines, com a fitxer",
"ui.export_selects_only_body": "Seleccionar l'únic cos en exportar una malla",
"ui.export_selects_only_body_tip": "Exportar STL, Exportar 3MF i Enviar al laminador comencen amb el cos del disseny seleccionat quan només n'hi ha un.",
"ui.extend": "Estendre",
"ui.extent": "Extensió",
"ui.extent_type": "Tipus d'extensió",
@@ -5533,6 +5693,8 @@
"ui.finest": "Molt fina",
"ui.finish_sketch": "Acabar el bocet",
"ui.first_dimension_scales": "La primera cota escala el dibuix",
"ui.first_dimension_scales_about_origin": "Escalar respecte de l'origen del bocet",
"ui.first_dimension_scales_about_origin_tip": "Activat: el dibuix sempre creix o s'encongeix respecte de l'origen del bocet, així que una forma dibuixada lluny de l'origen es mou. Desactivat: un dibuix que res no lliga a l'origen s'escala respecte del seu propi centre i es queda on es va dibuixar.",
"ui.first_dimension_scales_tip": "Activat: la primera cota de longitud d'un bocet escala el dibuix que mesura, de manera que la seva forma no canvia. Desactivat: la cota només acota allò que mesura.",
"ui.fit": "Ajustar",
"ui.fix_unfix": "Fixar/alliberar",
@@ -5908,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",
@@ -5955,6 +6117,7 @@
"ui.project_references_construction_note": "Projectar, Intersecar, Incloure geometria 3D i les arestes de cara d'un bocet nou sobre una cara creen geometria de construcció: referències sobre les quals dibuixar, excloses dels perfils. Continuen vinculades al model.",
"ui.properties": "Propietats",
"ui.properties.copy_matrix": "Copiar la matriu",
"ui.properties.copy_value": "Feu clic per copiar",
"ui.properties.first_moments": "Primers moments",
"ui.properties.inertia": "Moment d'inèrcia",
"ui.properties.inertia_note": "Respecte al centre de masses, en eixos globals (g·{unit}²)",
@@ -5967,6 +6130,7 @@
"ui.properties.measuring_failed": "El mesurament s'ha aturat abans d'acabar.",
"ui.properties.principal_axis": "Eix principal {number}",
"ui.properties.principal_moments": "Moments principals",
"ui.properties.value_copied": "Valor copiat",
"ui.pull_direction": "Direcció d'estirada",
"ui.quality": "Qualitat",
"ui.quantity": "Quantitat",
@@ -5987,11 +6151,14 @@
"ui.relink_design_changed": "L'ensamblatge ha canviat mentre trieu un origen. Torneu a iniciar Revincular l'origen.",
"ui.relink_done": "Origen revinculat a totes les instàncies.",
"ui.relink_not_linked": "Aquest component no està vinculat a cap disseny d'origen.",
"ui.relink_one_done": "Origen revinculat en aquesta instància.",
"ui.relink_select_one": "Seleccioneu primer un component vinculat o una de les seves instàncies.",
"ui.relink_source_all": "Revincular l'origen (totes les instàncies)…",
"ui.relink_source_one": "Revincular l'origen…",
"ui.removeFromQuickAccess": "Treure de l'accés ràpid",
"ui.removeFromToolbar": "Treure de la barra d'eines",
"ui.removeSection": "Treure la secció",
"ui.renameSelectedBodies": "Canviar el nom dels cossos seleccionats",
"ui.render": "Renderitzar",
"ui.renderer": "Motor de renderitzat",
"ui.repeat": "Repetir",
@@ -6022,6 +6189,8 @@
"ui.ribbon_pin_footer": "Les ordres fixades es mostren a la cinta. Passeu el punter per una fila per fixar-la o deixar-la de fixar.",
"ui.ribbon_workspace": "Espai de treball",
"ui.right": "Dreta",
"ui.right_click_confirms": "El botó dret confirma les entrades dels diàlegs",
"ui.right_click_confirms_tip": "Amb un diàleg obert, un clic amb el botó dret a la vista passa a la seva propera entrada buida, i a l'última executa OK",
"ui.save": "Desar",
"ui.save_as": "Desar com a",
"ui.save_mesh_no_slicer_open": "No s'ha trobat cap laminador: trieu Personalitzat… i trieu el vostre programa de laminatge.",
@@ -6081,6 +6250,16 @@
"ui.sketch_intersect_selection_filter": "Filtre de selecció",
"ui.sketch_keys_only": "Mantenir les tecles del model fora dels bocets",
"ui.sketch_keys_only_tip": "Mentre editeu un bocet, només s'executen les tecles de bocet i les que funcionen a tot arreu; una tecla de model com E o F no fa res, així una tecla perduda no pot tancar un bocet pesat.",
"ui.sketch_links_added_curve": "{command} ha afegit 1 corba al bocet.",
"ui.sketch_links_added_curves": "{command} ha afegit {count} corbes al bocet.",
"ui.sketch_links_added_curves_and_points": "{command} ha afegit {curves} i {points} al bocet.",
"ui.sketch_links_added_point": "{command} ha afegit 1 punt al bocet.",
"ui.sketch_links_added_points": "{command} ha afegit {count} punts al bocet.",
"ui.sketch_links_curve_count": "{count} corbes",
"ui.sketch_links_curve_count_one": "1 corba",
"ui.sketch_links_hidden": "El resultat s'amaga perquè {option} està desactivat a la paleta del bocet.",
"ui.sketch_links_point_count": "{count} punts",
"ui.sketch_links_point_count_one": "1 punt",
"ui.sketch_modifiers": "Modificadors del bocet",
"ui.sketch_project_geometry": "Geometria",
"ui.sketch_report.all_constrained": "Tot està totalment restringit.",
@@ -6120,6 +6299,7 @@
"ui.step_values": "Incrementar valors amb les fletxes i la roda",
"ui.step_values_tip": "En un camp de valor on esteu escrivint, Amunt/Avall canvien un nombre simple d'1 en 1 (Majúscules: 10, Alt: 0,1), i la roda del ratolí sobre el camp fa el mateix. Les expressions es deixen tal com s'escriuen.",
"ui.stitch_tolerance_hint": "estricta",
"ui.straighten": "Redreçar",
"ui.stretch": "Estirar",
"ui.supersampling": "Supermostreig (SSAA)",
"ui.supersampling_adaptive": "Només quan la vista està quieta",
@@ -6193,6 +6373,8 @@
"ui.view_angles_yaw": "Rumb",
"ui.view_angles_zero": "Posa {axis} a 0°",
"ui.view_centre": "Centre de la vista",
"ui.view_compass_tip": "Vista des de {axis}. Torneu-hi a fer clic per invertir-la. Arrossegueu per orbitar.",
"ui.view_compass_tip_snap": "Vista des de {axis}. Torneu-hi a fer clic per invertir-la. Arrossegueu per orbitar; manteniu Majúscules per ajustar.",
"ui.viewport_status_clear": "Desactivar {name}",
"ui.viewport_status_clear_hint": "Desactivar {name} · Esc",
"ui.viewport_status_edges": "{count} arestes",
+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",
+171 -32
View File
@@ -247,6 +247,7 @@
"Drag to move the hole; click to remove it": "Arrastra para mover el agujero; haz clic para quitarlo",
"Drag to move the rest across the model. It snaps to sketch points on the face.": "Arrastra para mover el refuerzo por el modelo. Se ajusta a los puntos del boceto en la cara.",
"Drag to move the snap fit across the model. It snaps to sketch points on the face.": "Arrastra para mover el encaje a presión por el modelo. Se ajusta a los puntos del boceto en la cara.",
"Drag to place it on another face.": "Arrastra para colocarlo en otra cara.",
"Drag to point the catch in another direction.": "Arrastra para orientar el retén en otra dirección.",
"Drag to turn the rest in 5° steps. Hold Alt or Ctrl to turn it freely.": "Arrastra para girar el refuerzo en pasos de 5°. Mantén Alt o Ctrl para girarlo libremente.",
"Drive Joint": "Accionar unión",
@@ -260,8 +261,8 @@
"EMBOSS": "REALCE",
"EXPLODED VIEW": "VISTA EXPLOSIONADA",
"EXTEND": "EXTENDER",
"Each new shape is also dimensioned from the sketch origin until nothing of it is left free. A shape already attached to the origin or to placed geometry gets none.": "Cada forma nueva también se acota desde el origen del croquis hasta que no quede nada libre. Una forma ya unida al origen o a geometría situada no recibe ninguna.",
"Each shape you draw gets all its dimensions, at the sizes you drew (typed values win). A size the sketch already sets is left out.": "Cada forma que dibujas recibe todas sus cotas con los tamaños dibujados (los valores escritos tienen prioridad). Se omite un tamaño que el croquis ya fija.",
"Each new shape is also dimensioned from the sketch origin until nothing of it is left free. A shape already attached to the origin or to placed geometry gets none.": "Cada forma nueva también se acota desde el origen del boceto hasta que no quede nada libre. Una forma ya unida al origen o a geometría situada no recibe ninguna.",
"Each shape you draw gets all its dimensions, at the sizes you drew (typed values win). A size the sketch already sets is left out.": "Cada forma que dibujas recibe todas sus cotas con los tamaños dibujados (los valores escritos tienen prioridad). Se omite un tamaño que el boceto ya fija.",
"Edge": "Arista",
"Edges": "Aristas",
"Edit": "Editar",
@@ -513,6 +514,7 @@
"Offset Y": "Desfase Y",
"Offset Z": "Desfase Z",
"On your computer": "En tu equipo",
"One more stretch of the picked edge, each with its own angle, height and turns": "Otro tramo más de la arista seleccionada, cada uno con su propio ángulo, altura y giros",
"Opacity": "Opacidad",
"Open GitHub": "Abrir GitHub",
"Open GitHub draft": "Abrir borrador de GitHub",
@@ -634,6 +636,7 @@
"Remove the track": "Eliminar la pista",
"Remove this bend": "Quitar este pliegue",
"Remove this point": "Eliminar este punto",
"Remove this segment": "Quitar este segmento",
"Rename Feature": "Cambiar nombre de la operación",
"Rename Folder": "Cambiar nombre de la carpeta",
"Render Stage": "Escenario de renderizado",
@@ -723,6 +726,7 @@
"Section position": "Posición de la sección",
"Sections": "Secciones",
"Sections (one sketch each)": "Secciones (un boceto cada una)",
"Segments": "Segmentos",
"Select Features": "Seleccionar operaciones",
"Select Other": "Seleccionar otro",
"Select Other: hold the left button still on the model (or right-click › Select Other…) to list everything under the cursor; click again on the same spot to select the next thing there. Double-click the middle button to go Home (Preferences › Navigation).": "Seleccionar otro: mantén pulsado el botón izquierdo sobre el modelo (o haz clic derecho › Seleccionar otro…) para ver todo lo que hay bajo el cursor; vuelve a pulsar en el mismo lugar para seleccionar el siguiente elemento. Haz doble clic con el botón central para volver a Inicio (Preferencias › Navegación).",
@@ -772,6 +776,7 @@
"Spacing must be a non-zero number": "El espaciado debe ser un número distinto de cero",
"Specialise": "Especializar",
"Speed": "Velocidad",
"Split Edge": "Dividir arista",
"Splitting tool": "Herramienta de división",
"Square": "Cuadrado",
"Stainless Steel": "Acero inoxidable",
@@ -932,13 +937,20 @@
"ai.http.confirm": "Permitir para este servicio",
"ai.http.warning": "La clave de API y la conversación se enviarán sin cifrar. Cualquiera con acceso a la red podría leerlas o modificarlas. Úsalo solo en una red de confianza.",
"ai.prefs.allow_control": "Permitir que se conecten agentes de IA (canal de control / MCP)",
"ai.prefs.allow_control_note": "Abre un canal de control solo en este equipo (127.0.0.1, puerto 27878 o el siguiente libre). El programa que se conecte también debe presentar un token secreto que solo puede leer tu cuenta de usuario, generado de nuevo en cada arranque. Se aplica la próxima vez que se inicie SolidCraft. Desactivado: no se escucha nada salvo que inicies SolidCraft con --control.",
"ai.prefs.allow_control_note": "Un canal local (MCP). Nada sale de este equipo.\n\nAbre un canal de control solo en este equipo (127.0.0.1, en el puerto de abajo; 27878 toma el siguiente libre cuando está en uso). El programa que se conecte también debe presentar un token secreto que solo puede leer tu cuenta de usuario, generado de nuevo en cada arranque. Se aplica al momento. Desactivado: no se escucha nada salvo que inicies SolidCraft con --control.",
"ai.prefs.availability": "Controles del Asistente",
"ai.prefs.background_commands": "Ejecutar los comandos del agente en segundo plano",
"ai.prefs.connect_apps": "Poner SolidCraft a disposición de las aplicaciones de IA de este equipo",
"ai.prefs.connect_apps_note": "Al iniciar SolidCraft, lo añade como servidor MCP a las aplicaciones de IA que encuentre en este equipo (OpenCode, Claude Desktop, Claude Code, Cursor, Codex, Gemini CLI, Copilot CLI, Windsurf, Zed, VS Code), para que un agente pueda usarlo sin configurar nada. Solo se añade la entrada solidcraft o se actualiza su ruta; una entrada que hayas cambiado se deja intacta y el archivo se copia antes. Desactivado: se eliminan las entradas que añadió. Ayuda › Conectar asistentes de IA… lo hace a mano.",
"ai.prefs.connect_at_start": "Conectar el último agente usado al iniciar SolidCraft",
"ai.prefs.connect_at_start_note": "Inicia el agente que conectaste por última vez (o el predeterminado) en segundo plano cada vez que se abre SolidCraft",
"ai.prefs.control_failed": "No se ha podido iniciar el canal de control: {error}",
"ai.prefs.control_from_launch": "Iniciado con --control en el puerto {port} para esta ejecución. Los cambios de aquí se aplican la próxima vez que SolidCraft se inicie sin --control.",
"ai.prefs.control_listening": "Escuchando en 127.0.0.1:{port}",
"ai.prefs.control_not_listening": "No está escuchando",
"ai.prefs.control_port": "Puerto",
"ai.prefs.control_port_note": "El puerto al que se conectan los agentes de IA en este equipo (127.0.0.1). 27878 es el predeterminado: cuando está en uso, se toma el siguiente libre. Cualquier otro puerto se usa exactamente. Apunta tu cliente MCP al mismo puerto, por ejemplo solidcraft-cli mcp --connect 127.0.0.1:7878.",
"ai.prefs.control_port_taken": "El puerto {port} está en uso por otro programa (u otra ventana de SolidCraft). Elige otro puerto.",
"ai.prefs.follow_camera": "Girar la cámara hacia los bocetos",
"ai.prefs.follow_camera_off": "El seguimiento automático de la cámara está desactivado.",
"ai.prefs.follow_camera_on": "La cámara gira hacia los bocetos que crea el agente.",
@@ -999,6 +1011,13 @@
"browser.sketches_to_parts_not_on_face": "no en la cara de una pieza",
"browser.sketches_to_parts_part_missing": "falta su pieza",
"browser.sketches_to_parts_result": "Se movieron {moved} bocetos a la pieza en la que están; {stayed} se quedaron: {why}",
"browser.straighten_angle": "Dirección de la arista",
"browser.straighten_choose": "Elige Horizontal, Vertical o Ángulo para la arista y pulsa Enderezar.",
"browser.straighten_click_a": "Haz clic en la imagen en un extremo de una arista que deba quedar recta, o escribe sus coordenadas.",
"browser.straighten_click_b": "Haz clic en el otro extremo de la arista, o escribe sus coordenadas.",
"browser.straighten_horizontal": "Horizontal",
"browser.straighten_title": "Enderezar: {name}",
"browser.straighten_vertical": "Vertical",
"browser.units": "Unidades: {units}",
"cam.flat": "Cara plana",
"cam.follower": "Seguidor",
@@ -1028,6 +1047,8 @@
"cmd.appearance_pick_up": "Tomar apariencia",
"cmd.appearance_replace": "Reemplazar apariencia",
"cmd.assembly_bom": "Lista de materiales",
"cmd.assembly_constraint_create": "Restringir",
"cmd.assembly_constraint_edit": "Editar restricción",
"cmd.assembly_mass_properties": "Propiedades de masa",
"cmd.body.print_face": "Cara de impresión",
"cmd.body_rename": "Cambiar nombre del cuerpo",
@@ -1046,6 +1067,7 @@
"cmd.canvas_delete": "Eliminar lienzo",
"cmd.canvas_edit": "Editar lienzo",
"cmd.canvas_insert": "Lienzo",
"cmd.canvas_straighten": "Enderezar lienzo",
"cmd.canvas_to_sketch": "Lienzo a boceto",
"cmd.component_activate": "Activar componente",
"cmd.component_break_link": "Romper vínculo",
@@ -1087,7 +1109,10 @@
"cmd.config_rule": "Regla de configuración",
"cmd.config_rules": "Reglas de configuración",
"cmd.config_table": "Mostrar tabla de configuraciones",
"cmd.construct.axis": "Eje",
"cmd.construct.plane": "Plano",
"cmd.construct.plane.axis_point": "Plano por eje y punto",
"cmd.construct.point": "Punto",
"cmd.construct_axis_edge": "Eje por arista",
"cmd.construct_axis_normal_to_face": "Eje perpendicular a cara",
"cmd.construct_axis_through_cylinder": "Eje por cilindro/cono/toro",
@@ -1225,6 +1250,10 @@
"cmd.motion_trace": "Trayectoria de movimiento",
"cmd.motion_trace_edit": "Editar traza de movimiento",
"cmd.motion_trace_keep": "Conservar trayectoria de movimiento",
"cmd.note_add": "Añadir nota",
"cmd.note_delete": "Eliminar nota",
"cmd.note_list": "Listar notas",
"cmd.note_update": "Editar nota",
"cmd.occurrence_align": "Alinear instancia",
"cmd.occurrence_copy": "Pegar (instancia)",
"cmd.occurrence_drag": "Arrastrar instancia",
@@ -1350,6 +1379,7 @@
"cmd.render_start": "Renderizar imagen",
"cmd.render_status": "Progreso del renderizado",
"cmd.select_clear": "Borrar selección",
"cmd.selection_get": "Obtener selección",
"cmd.settings_profile_apply": "Aplicar perfil de ajustes",
"cmd.settings_profile_delete": "Eliminar perfil de ajustes",
"cmd.settings_profile_export": "Exportar perfil de ajustes",
@@ -1513,6 +1543,7 @@
"cmd.timeline_suppress": "Suprimir operación",
"cmd.ui.assistant": "Alternar Asistente",
"cmd.ui.palette": "Buscar comandos",
"cmd.ui.toggle_visibility": "Mostrar/ocultar selección",
"cmd.view.navigator": "Navegador de vistas personalizado",
"cmd.view.render_browser": "Explorador de render",
"cmd.view.render_dock": "Mover acople de render",
@@ -1570,6 +1601,7 @@
"color.step_down": "Bajar un paso",
"color.step_up": "Subir un paso",
"color.value": "Valor, el brillo (porcentaje)",
"component.error.ambiguous_name": "Varios componentes se llaman `{name}`. Usa un ID numérico de componente: {ids}.",
"config.column_filter_empty": "Sin columnas coincidentes",
"config.rule_parameter_columns": "Solo se listan los parámetros con una columna en la tabla. Añade una con Añadir columna (+) para usarla aquí.",
"config.rule_suppression_columns": "Solo se listan las operaciones con una columna Suprimir en la tabla. Añade una con Añadir columna (+) para controlarla aquí.",
@@ -1610,6 +1642,7 @@
"diagnostic.reselect_reference": "{feature}: la referencia `{reference}` ya no existe como se seleccionó; vuélvela a seleccionar",
"dialog.bodies_count": "{count} cuerpos",
"dialog.clearance_invalid": "la holgura debe ser una longitud de 0 o más",
"dialog.component_origin": "{placement} · {item}",
"dialog.compute": "Calcular",
"dialog.compute_again": "Calcular de nuevo",
"dialog.computing": "Calculando… {seconds} s",
@@ -1622,6 +1655,7 @@
"dialog.dimension_step_expression": "Solo se puede avanzar con un número; una expresión se queda como se escribe",
"dialog.dimension_step_up": "Subir {step}",
"dialog.edit_title": "EDITAR {title}",
"dialog.hole_held_off_its_line": "La arista de referencia mantiene este agujero sobre una línea que sale de la cara antes del punto seleccionado. Elige dónde cruza la línea la cara, o quita la arista para mover el agujero libremente.",
"dialog.no_shown_body": "No hay cuerpo mostrado para cambiar: muestra uno, o elige Nuevo cuerpo",
"dialog.pattern_sketch_not_object": "Selecciona cuerpos o operaciones: un boceto no es ninguno. Repite sus curvas con el Patrón o Espejo del propio boceto, o extrúdelas primero.",
"dialog.primitive_point_tip": "Un punto que rellena X, Y y Z: haz clic aquí y después en un vértice, una arista o una cara del modelo, en un punto de un boceto o en el origen.",
@@ -1640,9 +1674,12 @@
"document.changed_on_disk": "{name} ha cambiado en el disco",
"document.changed_on_disk_detail": "Otro programa u otro equipo ha modificado {name} desde que lo abriste o lo guardaste por última vez. Sobrescribir sustituye esos cambios por los tuyos; Guardar una copia conserva ambos.",
"document.copy_name": "Copia de {name}",
"document.discard_row": "Descartar",
"document.finish_save_prompt": "Termina de responder al aviso de guardado antes de abrir otro diseño.",
"document.newer_file": "Guardado con una versión más reciente de SolidCraft (formato {format}; esta versión lee hasta {reads}). Si falta algo, actualiza.",
"document.newer_file_change": "Guardado con una versión más reciente de SolidCraft (formato {format} con `{change}`, que esta versión no conoce). Si falta algo, actualiza.",
"document.newer_file_not_read": "No leído: {what}. Al guardar se conserva.",
"document.not_saved_yet": "Aún sin guardar",
"document.open_anyway": "Abrir de todos modos",
"document.open_elsewhere": "{name} ya está abierto",
"document.open_elsewhere_detail": "{name} está abierto en SolidCraft {app} en {who}, abierto {when}. Si guardáis los dos, uno de vosotros perderá su trabajo. Ábrelo en solo lectura para verlo sin sobrescribirlo.",
@@ -1652,6 +1689,9 @@
"document.read_only_save": "Este diseño se abrió en solo lectura: guárdalo como un archivo nuevo",
"document.save_changes": "¿Guardar los cambios de {name}?",
"document.save_copy": "Guardar una copia…",
"document.save_many": "¿Guardar los cambios de {n} diseños?",
"document.save_row": "Guardar",
"document.save_selected": "Guardar seleccionados",
"document.tab_unsaved_changes": "Cambios sin guardar",
"document.unsaved_changes": "{name} tiene cambios sin guardar. Si no los guardas, se perderán.",
"drive.rigid": "{joint} es una junta rígida: mantiene sus partes quietas, así que no tiene nada que impulsar.",
@@ -1684,6 +1724,7 @@
"file_load.inserting": "Insertando geometría",
"file_load.link_item": "{name} ({done} de {total})",
"file_load.opened": "Se ha abierto {name}",
"file_load.opened_behind": "{name} se ha abierto en una pestaña nueva",
"file_load.opened_in_tab": "Se ha abierto {name} en una pestaña nueva",
"file_load.opening": "Abriendo diseño",
"file_load.preparing_thumbnail": "Preparando la miniatura...",
@@ -1731,25 +1772,28 @@
"help.config.table": "Muestra la tabla de configuración: una fila por variante, una columna por parámetro u operación, y si el diseño sigue coincidiendo con la fila activa. Úsalo para revisar las variantes de un diseño.",
"help.connect_note": "Reinicia cada asistente para que cargue el servidor de SolidCraft. Cada archivo se guardó como .bak antes de modificarlo.",
"help.connect_title": "Conectar asistentes de IA",
"help.construct.axis": "Crea un eje de construcción con el método que pidan tus selecciones: a lo largo de una arista o línea, por un agujero o una cara redonda, donde se encuentran dos planos, por dos puntos, o perpendicular a una cara por un punto. Úsalo cuando conozcas las referencias y no el método.",
"help.construct.axis.edge": "Crea un eje de construcción a lo largo de una arista recta o de una línea de boceto. Úsalo como eje de una revolución, un patrón circular o una unión.",
"help.construct.axis.normal_to_face": "Crea un eje de construcción que pasa por un punto, perpendicular a una cara. Úsalo como un eje que sale recto de una superficie.",
"help.construct.axis.normal_to_face": "Crea un eje de construcción que pasa por un punto, perpendicular a una cara. Úsalo como eje que sale recto de una cara.",
"help.construct.axis.through_cylinder": "Crea un eje de construcción por el centro de una cara cilíndrica, cónica o toroidal, o de una arista circular. Úsalo para girar o repetir operaciones alrededor de una pieza redonda.",
"help.construct.axis.two_planes": "Crea un eje de construcción donde se cortan dos planos o caras planas. Úsalo cuando el eje que necesitas sea la línea entre dos caras.",
"help.construct.axis.two_points": "Crea un eje de construcción por dos puntos, vértices o puntos de boceto. Úsalo para un eje que va entre dos posiciones conocidas.",
"help.construct.plane": "Crea un plano de construcción con el método que pidan tus selecciones: con desfase respecto a un plano, por un punto, entre dos planos, por líneas o puntos, tangente a una cara curva, o perpendicular a una trayectoria. Úsalo cuando conozcas las referencias y no el método.",
"help.construct.plane.along_path": "Crea un plano de construcción perpendicular a una arista o curva, en una posición a lo largo de ella. Úsalo para dibujar el perfil de un barrido o una sección de una trayectoria.",
"help.construct.plane.at_angle": "Crea un plano de construcción girado un ángulo respecto a un plano o cara, alrededor de un eje o arista. Activa «Usar solo la dirección del eje» para proyectar el eje sobre la base y pivotar alrededor de un punto de ese plano (el origen de la base cuando no se elige ninguno). Úsalo para bocetos inclinados, como un soporte angular.",
"help.construct.plane.axis_point": "Crea un plano de construcción que contiene un eje y un punto fuera de ese eje. Elige un origen o un eje de construcción, una arista recta o una línea de boceto, y después un vértice, un punto de boceto o un punto de construcción. Un punto del eje no define un plano único.",
"help.construct.plane.midplane": "Crea un plano de construcción a medio camino entre dos planos o caras planas, o que los biseca donde se encuentran en ángulo. Úsalo como plano de simetría para un espejo.",
"help.construct.plane.midplane": "Crea un plano de construcción a medio camino entre dos planos o caras planas, o que los biseca donde se encuentran en ángulo. Úsalo como plano de simetría al reflejar.",
"help.construct.plane.offset": "Crea un plano de construcción paralelo a un plano o una cara plana, a una distancia o por un punto. Úsalo para dibujar a una altura fija sobre una cara.",
"help.construct.plane.perpendicular": "Crea un plano de construcción que contiene una línea o arista y es perpendicular a un plano o una cara. Úsalo para dibujar en vertical sobre un modelo a lo largo de una arista.",
"help.construct.plane.tangent": "Crea un plano de construcción que toca un cilindro, cono, esfera o toro en un punto. Úsalo para dibujar en el lado de una pieza redonda.",
"help.construct.plane.three_points": "Crea un plano de construcción por tres puntos. Úsalo para un plano definido por posiciones del modelo, como tres esquinas.",
"help.construct.plane.two_edges": "Crea un plano de construcción por dos aristas rectas o líneas que están en un mismo plano. Úsalo para un plano que cruza una pieza, por ejemplo por dos aristas opuestas.",
"help.construct.point": "Crea un punto de construcción con el método que pidan tus selecciones: en un vértice, en el centro de un círculo o una esfera, a lo largo de una arista, donde se encuentran dos líneas o tres planos, o donde una línea cruza un plano. Úsalo cuando conozcas las referencias y no el método.",
"help.construct.point.along_path": "Crea un punto de construcción en una posición a lo largo de una arista o curva. Úsalo como referencia a mitad de una trayectoria.",
"help.construct.point.center": "Crea un punto de construcción en el centro de una arista circular, un círculo o arco de boceto, una esfera o un toro. Úsalo como referencia para agujeros, ejes y uniones.",
"help.construct.point.edge_plane": "Crea un punto de construcción donde una arista o línea atraviesa un plano o una cara plana. Úsalo para marcar dónde una trayectoria cruza un plano.",
"help.construct.point.three_planes": "Crea un punto de construcción donde se encuentran tres planos o caras planas. Úsalo como referencia de esquina, incluso donde el modelo no tiene vértice.",
"help.construct.point.two_edges": "Crea un punto de construcción donde se cortan dos aristas o líneas, o donde están más cerca. Úsalo como referencia en una esquina virtual.",
"help.construct.point.two_edges": "Crea un punto de construcción donde se cortan dos aristas o líneas, o donde más se acercan. Úsalo como referencia en una esquina virtual.",
"help.construct.point.vertex": "Crea un punto de construcción sobre un vértice o un punto de boceto. Úsalo como referencia que permanece cuando se oculta el boceto o el cuerpo que lo rodea.",
"help.contact.check": "Lista los pares de piezas de los conjuntos de contacto que se atraviesan en su posición actual. Úsalo para encontrar colisiones antes de accionar uniones o ejecutar un estudio de movimiento.",
"help.contact.create": "Agrupa dos o más piezas en un conjunto de contacto, cuyos cuerpos no pueden atravesarse mientras las uniones se mueven. Úsalo para piezas que deben detenerse al tocarse, como una palanca contra su tope.",
@@ -1771,7 +1815,7 @@
"help.font_details": "La interfaz utiliza las fuentes integradas de egui (Ubuntu Light y Hack, bajo las licencias Ubuntu Font Licence y MIT/Bitstream Vera; Noto Emoji y la fuente de iconos emoji, bajo OFL y MIT). El texto de los bocetos utiliza Liberation Sans (SIL OFL 1.1). El paquete opcional de fuentes de The Crafting Apps, craft-fonts (BIZ UDPGothic, Shippori Mincho, BIZ UDMincho, Noto Sans CJK SC, Noto Sans Arabic; SIL OFL 1.1), no se incluye en las compilaciones de SolidCraft.",
"help.fonts": "Fuentes",
"help.frame.end_cap": "Cierra los extremos abiertos de los miembros de una estructura con placas con la forma del contorno del perfil. Úsalo para tapar tubos, angulares y vigas, sobre el extremo o embutidas en él.",
"help.frame.member": "Coloca un perfil, como un tubo o un perfil de aluminio extruido, a lo largo de las líneas de un boceto de estructura, un miembro por línea. Úsalo para construir bastidores, estanterías y estructuras soldadas.",
"help.frame.member": "Coloca un perfil, como un tubo o una extrusión de aluminio, a lo largo de las líneas de un boceto de estructura, un miembro por línea. Úsalo para construir bastidores, estanterías y estructuras soldadas.",
"help.inspect.accessibility": "Colorea las caras a las que una herramienta puede llegar desde una dirección y las que no. Úsalo para comprobar una pieza para mecanizado o moldeo desde ese lado.",
"help.inspect.center_of_mass": "Muestra el centro de masa de los cuerpos, cada uno por separado y todos juntos, con densidad uniforme. Úsalo para comprobar el equilibrio y dónde volcará una pieza.",
"help.inspect.colored_components": "Da a cada componente su propio color temporal, sin cambiar los aspectos. Úsalo para distinguir de un vistazo las piezas de un ensamblaje.",
@@ -1842,11 +1886,11 @@
"help.parts.library": "Abre las piezas estándar: tornillos, pernos, tuercas, arandelas, tornillos sin cabeza, pasadores cilíndricos y rodamientos en tamaños estándar. Úsalo para añadir una pieza estándar en lugar de modelarla tú.",
"help.parts.resize": "Cambia el tamaño o la longitud de una pieza estándar insertada y la reconstruye; sus uniones la mantienen asentada. Úsalo cuando un tornillo resulta demasiado largo o un agujero necesita otro tamaño.",
"help.plastic.assign_rule": "Asigna una regla de plástico a cuerpos, de modo que sus operaciones de plástico toman su desmoldeo y holgura como valores por defecto. Úsalo para configurar una pieza moldeada una sola vez para todas sus operaciones.",
"help.plastic.boss": "Añade una torreta para tornillo en una cara: un poste con agujero opcional, desmoldeo, redondeo en la base y nervios de apoyo. Úsalo para los puntos de tornillo en carcasas moldeadas o impresas.",
"help.plastic.boss": "Añade un saliente para tornillo en una cara: un poste con agujero opcional, desmoldeo, redondeo en la base y nervios de apoyo. Úsalo para los puntos de tornillo en carcasas moldeadas o impresas.",
"help.plastic.lip": "Adds a lip or a matching groove along the rim of a shelled body. Use it so that two housing halves locate on each other and close tightly.",
"help.plastic.manage_rules": "Crea y edita reglas de plástico: material, espesor de pared, radios, holgura y desmoldeo. Úsalas para mantener coherentes las operaciones de una pieza moldeada.",
"help.plastic.rest": "Añade un apoyo en una cara: un taco redondo o rectangular, macizo o hueco, con desmoldeo. Úsalo como soporte o separador en una carcasa moldeada.",
"help.plastic.snap_fit": "Añade un gancho de encaje a presión en una cara, con su longitud, espesor y resalte. Úsalo para unir piezas sin tornillos.",
"help.plastic.snap_fit": "Añade un gancho de encaje a presión en una cara, con su longitud, espesor y retención. Úsalo para unir piezas sin tornillos.",
"help.pointcloud.insert": "Inserta un escaneo PLY, XYZ o PTS como nube de puntos junto al modelo. Úsala como",
"help.sheet.convert": "Convierte una placa o un cuerpo vaciado de espesor uniforme en un cuerpo de chapa metálica, con pliegues en las aristas que elijas. Úsalo para hacer desplegable una pieza existente.",
"help.sheet.export_bend_table": "Guarda la tabla de plegado de un cuerpo de chapa metálica como CSV o JSON: ángulos, radios y posiciones de los pliegues en el desarrollo. Úsalo para la plegadora o el taller.",
@@ -1857,22 +1901,22 @@
"help.sheet.hem": "Dobla el borde de una chapa sobre sí mismo. Úsalo para bordes seguros y rígidos, sin rebabas cortantes.",
"help.sheet.manage_rules": "Crea y edita reglas de chapa metálica: espesor, radio de plegado, factor K, alivios y holguras. Úsalas para ajustarte al material y a las herramientas de tu taller.",
"help.sheet.path_flange": "Añade una cadena de pestañas a lo largo del borde de una chapa, siguiendo una cadena abierta de líneas de boceto. Úsalo para perfiles como una U o una Z en un solo paso.",
"help.sheet.perforate": "Añade un campo de agujeros sobre toda la chapa plana, en patrón al tresbolillo o cuadrado. Úsalo para rejillas, ventilaciones y paneles más ligeros.",
"help.sheet.perforate": "Añade un campo de agujeros sobre toda la chapa plana, en patrón al tresbolillo o en cuadrícula. Úsalo para rejillas, ventilaciones y paneles más ligeros.",
"help.sheet.refold": "Vuelve a plegar un cuerpo de chapa metálica desplegado. Úsalo tras añadir operaciones a la chapa plana.",
"help.sheet.unfold": "Despliega todos los pliegues de un cuerpo de chapa metálica. Úsalo para añadir agujeros o cortes que deban cruzar un pliegue, y luego vuelve a plegar.",
"help.sketch.arc.center_point": "Dibuja un arco circular alrededor de un punto central, de un punto inicial a un punto final. Úsalo cuando se conoce el centro del arco, como un radio alrededor de un agujero o de un pivote.",
"help.sketch.arc.tangent": "Dibuja un arco que empieza en el extremo de una línea o arco y continúa tangente a él. Úsalo para seguir una cadena con una curva suave en lugar de una esquina.",
"help.sketch.arc.three_point": "Dibuja un arco de un punto inicial a un punto final pasando por un tercer punto. Úsalo cuando el arco deba pasar por puntos dados y no alrededor de un centro conocido.",
"help.sketch.arc3d": "Dibuja un arco circular exacto en el espacio por tres puntos, con el Boceto 3D activado. Úsalo para curvas en trayectorias y guías que salen del plano del boceto.",
"help.sketch.auto_constrain": "Añade las restricciones que el boceto ya casi cumple, como horizontal, paralela o coincidente, y luego lo acota hasta definirlo por completo. Úsalo para fijar de un paso un boceto dibujado a mano alzada.",
"help.sketch.auto_constrain_from_datum": "Funciona como Restricción automática, pero mide las posiciones desde un punto de referencia que eliges en lugar del origen. Úsalo cuando la pieza se organiza a partir de un punto de referencia, como una esquina o un agujero.",
"help.sketch.auto_constrain": "Añade las restricciones que el boceto ya casi cumple, como horizontal, paralela o coincidente, y luego lo acota hasta definirlo por completo. Úsalo para fijar en un solo paso un boceto dibujado a mano alzada.",
"help.sketch.auto_constrain_from_datum": "Funciona como Restringir automáticamente, pero mide las posiciones desde un punto de referencia que eliges en lugar del origen. Úsalo cuando la pieza se organiza a partir de un punto de referencia, como una esquina o un agujero.",
"help.sketch.blend_curve": "Une los extremos de dos curvas con una spline suave tangente a ambas, o también de curvatura continua si lo eliges. Úsalo para salvar un hueco entre curvas sin quiebros.",
"help.sketch.break": "Divide una curva en trozos donde otras curvas la cruzan, o en puntos que colocas sobre ella. Úsalo cuando una parte de una curva necesite su propia restricción o deba borrarse por separado.",
"help.sketch.cancel": "Sale del boceto y descarta todo lo cambiado desde que se creó o se abrió para editarlo. Deshacer recupera los cambios.",
"help.sketch.centerline": "Cambia las líneas entre líneas normales y líneas de eje, ejes que los perfiles dejan fuera. Usa una línea de eje como eje de una revolución, un espejo o una restricción de simetría.",
"help.sketch.chamfer.distance_angle": "Bisela la esquina entre dos líneas, definido por una distancia a lo largo de la primera línea y un ángulo. Úsalo cuando un plano da el chaflán como una longitud y un ángulo, como 2 mm × 30°.",
"help.sketch.centerline": "Cambia las líneas entre líneas normales y líneas de eje, ejes que los perfiles dejan fuera. Usa una línea de eje como eje de una revolución, una simetría o una restricción de simetría.",
"help.sketch.chamfer.distance_angle": "Bisela la esquina entre dos líneas, definida por una distancia a lo largo de la primera línea y un ángulo. Úsalo cuando un plano da el chaflán como una longitud y un ángulo, como 2 mm × 30°.",
"help.sketch.chamfer.equal_distance": "Bisela la esquina entre dos líneas la misma distancia hacia atrás en ambas. Úsalo para el chaflán habitual de 45° en un contorno.",
"help.sketch.chamfer.two_distance": "Bisela la esquina entre dos líneas, definido por una distancia distinta a lo largo de cada línea. Úsalo para chaflanes que no son de 45°, dados como dos longitudes.",
"help.sketch.chamfer.two_distance": "Bisela la esquina entre dos líneas, definida por una distancia distinta a lo largo de cada línea. Úsalo para chaflanes que no son de 45°, dados como dos longitudes.",
"help.sketch.circle.center": "Dibuja un círculo a partir de su centro y su diámetro. Úsalo para agujeros, salientes y otras formas redondas cuyo centro se conoce.",
"help.sketch.circle.three_point": "Dibuja un círculo por tres puntos. Úsalo cuando el círculo deba pasar por puntos dados, por ejemplo de geometría existente.",
"help.sketch.circle.three_tangent": "Dibuja un círculo que toca tres líneas, círculos o arcos. Úsalo para ajustar un círculo en el espacio entre tres curvas, como el círculo inscrito de un triángulo.",
@@ -1897,13 +1941,13 @@
"help.sketch.construction_line": "Crea una línea de construcción infinita a través de dos puntos. Úsala para organizar un boceto.",
"help.sketch.construction_line.angle": "Crea una línea de construcción infinita en un ángulo desde el eje X del boceto. Elige el punto a",
"help.sketch.construction_line.bisector": "Elige dos líneas rectas de boceto que se intersecten, luego elige una de las dos bisectrices del ángulo.",
"help.sketch.construction_line.horizontal": "Crea una línea de construcción horizontal infinita a través de un punto. Su dirección horizontal",
"help.sketch.construction_line.parallel": "Elige una línea recta de boceto, luego coloca una línea de construcción paralela infinita. R",
"help.sketch.construction_line.horizontal": "Crea una línea de construcción horizontal infinita que pase por un punto, o escribe su distancia al eje X del boceto. Una distancia escrita se convierte en una cota controladora. Su restricción horizontal sigue las ediciones posteriores.",
"help.sketch.construction_line.parallel": "Elige una línea recta de boceto y luego coloca una línea de construcción paralela infinita o escribe su desfase. Un desfase escrito se convierte en una distancia controladora. Haz clic con el botón derecho para elegir otra referencia.",
"help.sketch.construction_line.perpendicular": "Elige una línea recta de boceto, luego coloca una línea de construcción perpendicular infinita.",
"help.sketch.construction_line.tangent_at": "Crea una tangente en un punto de un círculo o arco circular, o en un extremo de una curva abierta.",
"help.sketch.construction_line.tangent_external": "Elige un círculo o arco circular, luego un punto externo y una solución de tangente. Th",
"help.sketch.construction_line.tangent_two": "Elige dos círculos o arcos circulares, luego elige una tangente común en la previsualización.",
"help.sketch.construction_line.vertical": "Crea una línea de construcción vertical infinita a través de un punto. Su dirección vertical",
"help.sketch.construction_line.vertical": "Crea una línea de construcción vertical infinita que pase por un punto, o escribe su distancia al eje Y del boceto. Una distancia escrita se convierte en una cota controladora. Su restricción vertical sigue las ediciones posteriores.",
"help.sketch.copy": "Copia las curvas y puntos de boceto seleccionados, junto con las restricciones y cotas entre ellos. Pégalos en este boceto o en otro.",
"help.sketch.create": "Empieza un nuevo boceto 2D en un plano o en una cara plana del modelo. Toda extrusión, revolución y barrido empieza con un boceto.",
"help.sketch.cut": "Copia la geometría de boceto seleccionada y la borra del boceto, en un solo paso de deshacer. Pégala en otro lugar de este boceto o en otro.",
@@ -1914,10 +1958,10 @@
"help.sketch.extend": "Alarga una línea o un arco hasta la curva más cercana que encuentre, o hace crecer dos curvas hasta que se encuentran. Úsalo para cerrar huecos de un perfil tras recortar o editar.",
"help.sketch.fillet": "Redondea la esquina donde se encuentran dos líneas o arcos con un arco de un radio dado. Varias esquinas redondeadas a la vez comparten un radio.",
"help.sketch.finish": "Sale del boceto y vuelve al modelo. Sus perfiles cerrados quedan listos para Extrusión, Revolución y otras operaciones.",
"help.sketch.finish_auto_constrain": "Ejecuta Restricción automática en el boceto y luego lo finaliza. Úsalo para dejar un boceto totalmente definido de un paso.",
"help.sketch.finish_auto_constrain": "Ejecuta Restringir automáticamente en el boceto y luego lo finaliza. Úsalo para dejar un boceto totalmente definido en un solo paso.",
"help.sketch.fit_curves_to_section": "Corta un cuerpo de malla o sólido con el plano del boceto y ajusta líneas y arcos a la sección. Úsalo para reconstruir como boceto editable una pieza escaneada o importada.",
"help.sketch.include_3d": "Trae aristas del modelo, vértices y curvas de otros bocetos a este boceto como referencias vinculadas, aplanando sobre él las que no están en su plano. Úsalo para construir sobre geometría que no está en el plano del boceto.",
"help.sketch.infinite": "Cambia las líneas entre líneas normales y líneas de construcción infinitas que cruzan toda la vista. Úsalas como referencias de disposición que nunca se quedan cortas.",
"help.sketch.infinite": "Cambia las líneas entre líneas normales y líneas de construcción infinitas que cruzan todo el visor. Úsalas como referencias de disposición que nunca se quedan cortas.",
"help.sketch.insert_dxf": "Lee las curvas de un dibujo DXF en un boceto, escaladas, giradas y colocadas donde indiques. Úsalo para construir sobre un dibujo 2D de otro programa, como un contorno para corte láser.",
"help.sketch.insert_svg": "Lee los trazados de un dibujo SVG en un boceto, escalados, girados y colocados donde indiques. Úsalo para convertir un logotipo o un contorno vectorial en curvas que puedas extruir.",
"help.sketch.inspect": "Abre un informe sobre el boceto abierto: cuántos grados de libertad quedan y qué",
@@ -2022,12 +2066,12 @@
"help.view.apply": "Gira la vista a una dirección estándar como frontal, superior o isométrica, o a una vista con nombre guardada. Úsalo para mirar el modelo desde un ángulo fijo.",
"help.view.delete": "Borra una vista con nombre guardada. El modelo no cambia.",
"help.view.fit": "Acerca y desplaza para que todo el modelo, o la selección, llene la vista. Úsalo cuando el modelo se ha salido de la vista.",
"help.view.grid": "Muestra u oculta la cuadrícula en la vista. Úsala como guía de tamaño y orientación al modelar.",
"help.view.grid": "Muestra u oculta la cuadrícula en el visor. Úsala como guía de tamaño y orientación al modelar.",
"help.view.ground_shadow": "Muestra u oculta la sombra del modelo en el suelo. Úsalo para juzgar con más facilidad la altura y la posición.",
"help.view.home": "Devuelve la vista a la dirección de inicio y encuadra el modelo. Úsalo para volver rápido a una vista conocida.",
"help.view.list": "Lista las vistas con nombre guardadas en el diseño. Úsalo para encontrar una vista que guardaste antes.",
"help.view.navigate": "Gira, desplaza o acerca la vista por pasos, como un cuarto de vuelta a la izquierda. Úsalo para mover la cámara con precisión en lugar de arrastrar.",
"help.view.navigation_bar": "Muestra u oculta la barra de navegación en la parte inferior de la vista. Ocúltala para tener más espacio, muéstrala para llegar a las herramientas de vista con el ratón.",
"help.view.navigation_bar": "Muestra u oculta la barra de navegación en la parte inferior del visor. Ocúltala para tener más espacio, muéstrala para llegar a las herramientas de vista con el ratón.",
"help.view.rename": "Da un nombre nuevo a una vista con nombre guardada. La vista en sí no cambia.",
"help.view.ribbon_tabs_only": "Pliega la cinta hasta su fila de pestañas para que la vista recupere el espacio de los paneles. Haz clic en una pestaña para mostrar sus paneles sobre la vista; vuelve a elegir esta opción para mantener toda la cinta visible.",
"help.view.save": "Guarda la posición actual de la cámara como una vista con nombre en el diseño. Úsalo para volver al mismo ángulo más tarde, por ejemplo para capturas de pantalla.",
@@ -2247,6 +2291,9 @@
"measure.at": "En",
"measure.item": "Elemento {number}",
"measure.line_to_centre": "Línea al centro",
"measure.perimeter": "Perímetro",
"measure.perimeter_inner": "Perímetro interior",
"measure.perimeter_outer": "Perímetro exterior",
"measure.quick_copied": "Medición copiada",
"measure.quick_copy": "Haz clic para copiar la medición",
"measure.quick_edges": "{count} aristas",
@@ -2288,6 +2335,8 @@
"nav.hfss.help": "Alt+arrastre izq. desplaza, Mayús+Alt+arrastre izq. hace zoom, Ctrl+arrastre izq. en espacio vacío orbita",
"nav.inventor.help": "Arrastrar con el botón central desplaza, Mayús+central orbita, Ctrl+central hace zoom y arrastrar con el derecho orbita",
"nav.maya.help": "Alt+arrastre izq. orbita, Alt+arrastre central desplaza, Alt+arrastre der. hace zoom; arrastre central también desplaza",
"nav.maya_horizontal_zoom.help": "Alt+arrastrar con el botón derecho: hacia la derecha acerca, hacia la izquierda aleja. Desactívalo para volver al zoom arrastrando en vertical.",
"nav.maya_horizontal_zoom.label": "Maya: zoom arrastrando en horizontal",
"nav.nx.help": "Arrastrar con botón central orbita, Shift+central desplaza (central + derecho de NX), Ctrl+central hace zoom (central + izquierdo de NX); arrastrar con derecho deja la cámara",
"nav.openscad.help": "Arrastrar con el botón izquierdo sobre un espacio vacío orbita, Mayús+izquierdo selecciona con un recuadro y arrastrar geometría del boceto la edita; arrastrar con el derecho o el central desplaza, y Mayús+derecho o Mayús+central hace zoom",
"nav.orbit_cursor_wrap.help": "Un arrastre de órbita que llega al borde de la vista mueve el cursor al borde opuesto y sigue girando. Desactivado: el arrastre se detiene en el borde de la pantalla. No tiene efecto en Wayland ni en el navegador.",
@@ -2418,6 +2467,16 @@
"not a move": "no es un movimiento",
"not a number": "no es un número",
"open a sketch first": "abre primero un boceto",
"operations.cancel": "Cancelar {name}",
"operations.cancelled": "{name} cancelado",
"operations.cancelling": "Cancelando…",
"operations.cannot_cancel": "{name} no se puede cancelar",
"operations.feature": "Operación",
"operations.feature_edit": "Editar operación",
"operations.meshing": "Cargando piezas",
"operations.queued": "En cola",
"operations.stopped": "Cancelado",
"operations.waiting": "Listo",
"parameters.apply_live": "Aplicar en vivo",
"parameters.apply_live_tip": "Aplica los cambios de parámetros mientras los editas",
"parameters.held": "Mantenidos ({count})",
@@ -2506,6 +2565,7 @@
"prefs.appearance.viewport_colours": "Colores de vista 3D",
"prefs.appearance.viewport_logo": "Visor y logo",
"prefs.applies_on_restart": "Se aplica al reiniciar",
"prefs.assemblies": "Ensamblajes",
"prefs.assistant": "Asistente",
"prefs.assistant_connecting": "Conectando",
"prefs.assistant_follow_help": "Los bocetos Mirar mueven la vista con el puntero.",
@@ -2558,6 +2618,10 @@
"prefs.choose_font": "Elegir archivo de fuente…",
"prefs.click": "Clic",
"prefs.command_dialog_values": "Diálogos de comando",
"prefs.constraint_glyph_size": "Tamaño de los glifos de restricción",
"prefs.constraint_glyph_size_note": "Cómo de grandes se dibujan los glifos de restricción y sus letras o iconos en un boceto. El 100 % es el tamaño habitual; los glifos conservan este tamaño en pantalla con cualquier zoom.",
"prefs.construct_menu_groups": "Agrupar las herramientas de Construir por tipo de entidad",
"prefs.construct_menu_groups_help": "Muestra los encabezados Planos, Ejes y Puntos. Desactivado, se mantiene el orden original del menú.",
"prefs.contact_shading": "Sombreado de contacto suave",
"prefs.contact_shading_note": "Sombreado suave donde las piezas se acercan.",
"prefs.context_menu": "Menú contextual",
@@ -2589,10 +2653,12 @@
"prefs.design_tools.imported_files": "Archivos importados",
"prefs.design_tools.note": "Nota",
"prefs.design_tools.off": "Desactivado",
"prefs.design_tools.placement_group": "Apoyo, Resalte, 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",
"prefs.dialog_first_letter_buttons": "Los botones de diálogo responden a su primera letra",
"prefs.dialog_first_letter_buttons_help": "Con un diálogo abierto, escribir la primera letra de la etiqueta de un botón lo pulsa: O para OK, C para Cancelar. Una letra que encaja en dos botones no hace nada, y un campo de texto con el foco conserva la pulsación. Desactivado: escribir no afecta a los botones, como antes.",
"prefs.dialogs": "Diálogos",
"prefs.dialogs_edge": "En el borde de la vista",
"prefs.dialogs_left_column": "En la columna izquierda",
@@ -2606,12 +2672,23 @@
"prefs.dimension_values_window": "En una ventana",
"prefs.display": "Pantalla",
"prefs.edges": "Aristas y sombreado",
"prefs.export": "Exportar",
"prefs.export_name": "Nombre del archivo de exportación",
"prefs.export_name.body": "Nombre del cuerpo exportado (el primero cuando hay varios)",
"prefs.export_name.design": "Nombre del diseño",
"prefs.export_name_hint": "p. ej., {design}-{body}",
"prefs.export_name_sample": "Al exportar este diseño se ofrece: {name}",
"prefs.export_name_tip": "El nombre de archivo que ofrecen Archivo › Exportar y Guardar como malla. Se sustituyen estos marcadores:\n{placeholders}\nUna propiedad del diseño que no esté definida se omite. Deja el campo vacío para ofrecer el nombre del diseño o, en una malla, el del cuerpo.",
"prefs.extend": "Extensiones",
"prefs.extra_navigation": "Ctrl + arrastrar con el botón izquierdo sobre un espacio vacío orbita; mantén Espacio para desplazar.",
"prefs.extrude_arrow_face_stops": "La flecha de Extruir se detiene en las caras",
"prefs.extrude_arrow_face_stops_note": "Al arrastrar la flecha de distancia de Extruir, se detiene en las caras planas paralelas al perfil, como el lado opuesto de un bloque que se corta, y resalta la cara. Desactivado: la flecha solo avanza en pasos redondos.",
"prefs.extrude_view_isometric": "Girar la vista a isométrica al abrir Extrusión",
"prefs.extrude_view_return": "Volver a la vista anterior al cerrar Extrusión",
"prefs.file_operations_wait": "Esperar a que se abran los archivos",
"prefs.file_operations_wait_note": "Mientras se abre o se inserta un archivo, o se vuelven a leer los vínculos, el diseño espera tras una tarjeta con el progreso del archivo y Cancelar. Desactivado: el archivo aparece en el panel de operaciones y puedes seguir trabajando; una inserción se aplica cuando termines lo que estás haciendo, y un archivo abierto mientras tanto va a una pestaña nueva.",
"prefs.fillet_legacy_radius": "Usar el radio predeterminado anterior de Redondeo de sólido",
"prefs.fillet_legacy_radius.help": "Desactivado de forma predeterminada. Activado, un nuevo Redondeo de sólido se abre con el número 2 leído en las unidades del diseño («2 in» en un diseño en pulgadas), como antes; desactivado, se abre con el radio que trae la herramienta, 2 mm, mostrado en las unidades del diseño («0.07874 in» en un diseño en pulgadas).",
"prefs.follows_os": "Sigue al sistema operativo",
"prefs.font": "Fuente",
"prefs.font_fallback": "Los glifos que faltan en la fuente elegida se toman de la fuente integrada, así que ninguna etiqueta muestra un recuadro vacío.",
@@ -2689,6 +2766,8 @@
"prefs.history_sketches_in_order": "En orden",
"prefs.history_sketches_note": "Con Bocetos en Debajo de su operación, el Historial de la izquierda lista cada boceto un nivel dentro, bajo la primera operación construida a partir de él, siempre que haya ancho para nombres. La línea de tiempo en la parte inferior mantiene siempre el orden de creación.",
"prefs.history_sketches_under_feature": "Debajo de su operación",
"prefs.history_steps_component": "Pasos del historial por componente",
"prefs.history_steps_component_note": "Con un componente activo o seleccionado, los controles del Historial recorren las operaciones de ese componente, tal como las muestra el Historial de abajo. Desactivado: recorren todas las operaciones del diseño. El Historial de la izquierda o bajo el navegador siempre recorre todas las operaciones.",
"prefs.hold_stop_snapping": "Mantén pulsado para desactivar el ajuste",
"prefs.home_fit_size": "Tamaño de Inicio y Ajustar",
"prefs.home_fit_size_note": "Cuánto de la vista ocupa el modelo al usar Inicio, Ajustar, un diseño abierto o un cambio de vista del navegador, independientemente de la configuración siguiente. 100 %: llena la vista entre los paneles, la brújula y la barra de navegación.",
@@ -2704,6 +2783,8 @@
"prefs.invert_curved_roll_arrows_note": "Las dos flechas curvas en la esquina del cubo giran la vista en el plano de la pantalla. Activado: la flecha horaria gira antihoraria y la antihoraria gira horaria. Desactivado: cada flecha gira la vista hacia donde se curva.",
"prefs.invert_navigator_arrows": "Invertir las flechas de rotación del cubo de vista",
"prefs.invert_navigator_arrows_note": "Las flechas alrededor del cubo orbitan la cámara hacia donde apuntan, como en Fusion e Inventor: la flecha de la izquierda apunta a la derecha y gira la cámara a la derecha. Desactivado: una flecha rota el cubo hacia el lado en que está, así la flecha izquierda gira la cámara a la izquierda. Las flechas de giro curvo no se ven afectadas.",
"prefs.joint_drag_trackball": "Girar las piezas de una unión de rótula como un trackball al arrastrarlas",
"prefs.joint_drag_trackball_help": "Arrastrar una pieza que sujeta una unión de rótula la gira con el menor giro posible alrededor de la unión, como si rodara una bola del tamaño de la pieza. Desactivado: el punto que agarraste sigue exactamente al puntero, y la pieza también puede girar sobre él.",
"prefs.keep_unsaved_also_asks": "Preguntar igualmente al cerrar la pestaña",
"prefs.keep_unsaved_also_asks_tip": "Con esto desactivado, la pestaña se cierra sin el cuadro del navegador en cuanto la copia de cada diseño sin guardar está al día.",
"prefs.keep_unsaved_in_browser": "Conservar el trabajo sin guardar en este navegador",
@@ -2779,6 +2860,8 @@
"prefs.not_on_gpu": "no en esta GPU",
"prefs.on": "Activado",
"prefs.opaque": "Opaco",
"prefs.operations_panel": "Mostrar las operaciones en la vista",
"prefs.operations_panel_note": "Las operaciones largas (abrir un archivo, calcular una operación, un comando del agente) se muestran en la esquina inferior derecha de la vista con su progreso y un botón para cancelarlas. Desactivado: un aviso de «Calculando», como antes.",
"prefs.orbit_snap": "Mantén Mayús mientras orbitas para ajustar a un eje.",
"prefs.orientation": "Orientación",
"prefs.origin_plane_names": "Nombres de los planos de origen",
@@ -2859,6 +2942,8 @@
"prefs.ribbon_tabs_only": "Mostrar solo las pestañas de la cinta",
"prefs.ribbon_tabs_only_note": "La cinta se pliega hasta su fila de pestañas. Al hacer clic en una pestaña se muestran sus paneles sobre la vista hasta que empieza un comando o el puntero sale de ellos. «Solo pestañas de la cinta», en la pestaña Vista o con su atajo, alterna esta opción.",
"prefs.right_click": "Clic derecho",
"prefs.save_prompt_one_at_a_time": "Preguntar por cada diseño sin guardar por separado al cerrar varios",
"prefs.save_prompt_one_at_a_time_help": "Mostrar un aviso de guardado por cada diseño sin guardar en lugar de una lista combinada.",
"prefs.saving": "Guardado automático y recuperación",
"prefs.scale_note": "También Ctrl + / Ctrl − en la ventana. Los botones miden 28 px al 100%.",
"prefs.scale_text": "Escala y texto",
@@ -2895,8 +2980,8 @@
"prefs.sketch.center_arc": "El arco por centro sigue al cursor",
"prefs.sketch.click_selects": "El clic selecciona",
"prefs.sketch.conflict_colour": "Color de conflicto",
"prefs.sketch.cursor_coordinates": "Mostrar las coordenadas del cursor en un croquis",
"prefs.sketch.cursor_coordinates_note": "Junto al puntero, su X e Y desde el origen del croquis, también sin ninguna herramienta activa. Desactivado: solo mientras una herramienta coloca un punto.",
"prefs.sketch.cursor_coordinates": "Mostrar las coordenadas del cursor en un boceto",
"prefs.sketch.cursor_coordinates_note": "Junto al puntero, su X e Y desde el origen del boceto, también sin ninguna herramienta activa. Desactivado: solo mientras una herramienta coloca un punto.",
"prefs.sketch.drawing": "Dibujo",
"prefs.sketch.ellipse_axes": "Dibujar elipses con sus ejes",
"prefs.sketch.ellipse_axes_note": "Los ejes mayor y menor como líneas de construcción, para poder acotar los semiejes y los ejes. Desactivado: solo la elipse, y al pulsar D sobre ella se da su radio menor allí donde se arrastre el valor, dibujado y etiquetado como R, como antes.",
@@ -2908,14 +2993,20 @@
"prefs.sketch.faces_edges": "Caras y aristas",
"prefs.sketch.hide_glyphs": "Ocultar glifos al alejar",
"prefs.sketch.marks": "Marcas en el boceto",
"prefs.sketch.max_points": "Boceto más grande",
"prefs.sketch.max_points_note": "El máximo de puntos o curvas que admite un boceto: 20000 de forma predeterminada, como mucho 200000. Insertar SVG e Insertar DXF rechazan un dibujo mayor. Un boceto grande se resuelve y se dibuja más despacio.",
"prefs.sketch.max_points_unit": "puntos",
"prefs.sketch.note": "Dibujo, ajuste y selección.",
"prefs.sketch.opening_closing": "Apertura y cierre",
"prefs.sketch.prefer_free_points": "Preferir puntos móviles donde se solapen",
"prefs.sketch.project_options": "Preguntar primero las opciones de Proyectar",
"prefs.sketch.recent_snap": "Ajustar a donde se cruzan dos guías",
"prefs.sketch.select_3d_curve_points": "Seleccionar puntos de curvas de boceto 3D",
"prefs.sketch.select_3d_curve_points_help": "Muestra y permite seleccionar puntos de las curvas de boceto 3D dibujadas como referencias de construcción.",
"prefs.sketch.select_other_hidden": "Seleccionar otro llega a la geometría oculta",
"prefs.sketch.shortcut_cycles_modes": "El atajo cambia de modo al pulsarlo otra vez",
"prefs.sketch.shortcut_cycles_modes_tip": "Desactivado: vuelve a iniciar la herramienta.",
"prefs.sketch.snap_to_visible_grid": "Ajustar a los cruces visibles de la cuadrícula",
"prefs.sketch.snapping_guides": "Ajuste y guías",
"prefs.sketch.state_icons": "Iconos de estado",
"prefs.sketch.tracking_guides": "Guías de centro y punto medio",
@@ -2943,6 +3034,7 @@
"prefs.sketch_selection": "Boceto y selección",
"prefs.sketch_selection_heading": "Boceto y selección",
"prefs.sketch_selection_note": "Cómo se ajustan los bocetos y cómo seleccionan los clics.",
"prefs.sketch_snap_to_visible_grid_note": "Un punto de un boceto se ajusta a los cruces de las líneas que dibuja la cuadrícula cuando el puntero apunta a uno, y si no, a la retícula más fina entre ellos. Desactivado: el punto se ajusta solo a la retícula más fina, como antes.",
"prefs.sketch_state_icons": "Iconos de boceto en el color de su estado",
"prefs.sketch_state_icons_note": "En Historial y el explorador, el icono de un boceto toma el color de su estado (bajo restricción, totalmente restringido o en conflicto) y su info muestra el estado. Desactivado: todos los bocetos tienen el mismo icono.",
"prefs.sketch_tab_always": "Mantener la pestaña Boceto visible",
@@ -3464,12 +3556,12 @@
"render.cpu_subsurface_denoise_reason": "La dispersión subsuperficial por CPU necesita la reducción de ruido desactivada. Se conservan tus ajustes.",
"render.cpu_subsurface_help": "La dispersión redirige la luz dentro del vidrio cerrado. Las tasas positivas necesitan iluminación indirecta con la reducción de ruido desactivada. La tasa es de colisiones por mm; 0 no añade dispersión. Un sesgo de fase positivo favorece las direcciones hacia delante; uno negativo, las hacia atrás.",
"render.cpu_summary": "Trazado CPU · reflejos · HDR · luces direccionales y puntuales",
"render.cpu_threads": "Procesos de CPU",
"render.cpu_threads": "Workers de CPU",
"render.cpu_threads_auto": "Automático",
"render.cpu_threads_custom": "Personalizado",
"render.cpu_threads_help": "Automático usa los procesos de CPU disponibles. Un proceso se ejecuta en serie. Más procesos pueden necesitar más memoria.",
"render.cpu_threads_help": "Automático usa los workers de CPU disponibles. Un worker se ejecuta en serie. Más workers pueden necesitar más memoria.",
"render.cpu_threads_single": "Uno",
"render.cpu_threads_web": "El renderizado en el navegador usa un proceso cooperativo. Se conserva la elección de procesos guardada para el escritorio.",
"render.cpu_threads_web": "El renderizado en el navegador usa un worker cooperativo. Se conserva la elección de workers guardada para el escritorio.",
"render.cpu_transparent_help": "El PNG transparente quita el fondo y conserva la cobertura de los bordes. La opacidad del cuerpo y de la cara debe mantenerse al 100 %.",
"render.cpu_volume_enable_path": "Activar iluminación indirecta",
"render.cpu_volume_enable_path_plan": "Ray tracing en CPU · profundidad {depth} · {spp} muestras/píxel · semilla {seed} · sombras trazadas. Conserva la atmósfera definida y el presupuesto de imagen.",
@@ -3644,6 +3736,7 @@
"render.error_pbr_opacity": "PBR opaco requiere opacidad 1",
"render.error_physical_glass": "la receta seleccionada no es vidrio físico",
"render.error_preview_device": "la previsualización de producto requiere un dispositivo instalado con búfer de profundidad",
"render.error_product_placement": "colocación de producto no válida",
"render.error_product_triangle": "triángulo de producto no válido",
"render.error_provisional_identity": "identidad provisional de render o cuenta de muestras no válida",
"render.error_quality_bounds": "la proyección de calidad supera los límites nativos",
@@ -4968,6 +5061,7 @@
"ui.agent_chat_allow_once": "Permitir una vez",
"ui.agent_chat_allowed": "Permitido",
"ui.agent_chat_answered": "Respuesta: {option}",
"ui.agent_chat_answered_in": "Respondido en {time}",
"ui.agent_chat_approval": "¿Permitir que el agente haga esto?",
"ui.agent_chat_attach_selection": "Adjuntar selección",
"ui.agent_chat_attach_selection_tip": "Envía con el mensaje lo que esté seleccionado en el modelo (caras, aristas, operaciones, cuerpos) para que el agente sepa a qué te refieres",
@@ -4996,6 +5090,7 @@
"ui.agent_chat_ctx_bodies": "Cuerpos",
"ui.agent_chat_ctx_elements": "Elementos",
"ui.agent_chat_ctx_features": "Operaciones",
"ui.agent_chat_ctx_gone": "Ya no está en el diseño: se ha cambiado o eliminado.",
"ui.agent_chat_ctx_more": "+{n} más",
"ui.agent_chat_ctx_other": "Otros",
"ui.agent_chat_custom_heading": "Modelo personalizado",
@@ -5009,6 +5104,7 @@
"ui.agent_chat_dock_right": "Acoplar a la derecha",
"ui.agent_chat_drop_hint": "Suelta para adjuntar a tu próximo mensaje",
"ui.agent_chat_edit": "Editar",
"ui.agent_chat_ended_after": "Finalizado tras {time}",
"ui.agent_chat_exited": "El agente se ha detenido.",
"ui.agent_chat_failed_start": "No se ha podido iniciar el agente: {error}",
"ui.agent_chat_file_cad_not_read": "{name}: un archivo CAD se importa al diseño, no se lee",
@@ -5046,6 +5142,7 @@
"ui.agent_chat_model_no_tools": "{model} no puede usar las herramientas de SolidCraft, así que solo puede chatear. Elige un modelo con soporte de herramientas.",
"ui.agent_chat_model_rejected": "El agente no aceptó {model}: {error}",
"ui.agent_chat_model_search": "Buscar modelos…",
"ui.agent_chat_model_search_or_id": "Busca o escribe cualquier ID de modelo…",
"ui.agent_chat_model_tip": "Modelo que utiliza el agente. Se recuerda tu elección para este agente.",
"ui.agent_chat_models_failed": "No se pudieron listar modelos: {error}",
"ui.agent_chat_models_heading": "Modelos disponibles",
@@ -5057,6 +5154,7 @@
"ui.agent_chat_next_picture": "Imagen siguiente",
"ui.agent_chat_no_cli": "No se ha encontrado solidcraft-cli junto a la aplicación, por lo que el agente no puede acceder al diseño.",
"ui.agent_chat_no_control": "No se ha iniciado el punto de conexión del chat del agente, por lo que no puede acceder al diseño.",
"ui.agent_chat_no_design_hint": "El agente se ejecuta sin las herramientas de SolidCraft, así que no puede leer ni cambiar el diseño: no se ha encontrado solidcraft-cli junto a la aplicación. Instala SolidCraft con solidcraft-cli al lado (en Windows, el MSI o el zip portátil) o define SOLIDCRAFT_CLI con su ruta, y vuelve a conectar.",
"ui.agent_chat_no_images": "Este agente no admite imágenes",
"ui.agent_chat_no_picture": "No hay ninguna imagen en el portapapeles",
"ui.agent_chat_no_tool_call": "El modelo no usó las herramientas de SolidCraft, así que el diseño no cambió. Elige un modelo que soporte llamada a herramientas; véase Modelos locales en la guía del chat del agente.",
@@ -5071,7 +5169,7 @@
"ui.agent_chat_permission": "El agente solicita ejecutar: {title}",
"ui.agent_chat_pick": "Agente",
"ui.agent_chat_pick_context": "Elegir contexto",
"ui.agent_chat_pick_context_tip": "Elige caras, aristas, operaciones y cuerpos del modelo para que el agente los examine y pulsa Aceptar",
"ui.agent_chat_pick_context_tip": "Elige caras, aristas, operaciones y cuerpos del modelo y pulsa OK: se añaden al mensaje como una ficha donde está el cursor (escribir @ hace lo mismo)",
"ui.agent_chat_placeholder": "Pide al agente que modifique el diseño…",
"ui.agent_chat_placeholder_busy": "Pon un mensaje en cola para cuando termine el agente (Ctrl+Intro: enviar ahora)",
"ui.agent_chat_pop_out": "Abrir en su propia ventana, que puedes mover a otra pantalla",
@@ -5104,16 +5202,25 @@
"ui.agent_chat_side": "Lado del chat del agente",
"ui.agent_chat_signed_in": "sesión iniciada",
"ui.agent_chat_snap_none_found": "No se ha encontrado ningún agente de programación. El paquete Snap está confinado, así que no puede ver ni iniciar los agentes de programación instalados en este equipo. Para usarlos, instala SolidCraft desde el paquete AppImage, .deb o .rpm. Un agente que use una API compatible con OpenAI también funciona en el paquete Snap: configúralo con Añadir agente.",
"ui.agent_chat_span_h": "{h} h {min} min",
"ui.agent_chat_span_min": "{n} min",
"ui.agent_chat_span_s": "{n} s",
"ui.agent_chat_state_auth": "Se requiere iniciar sesión",
"ui.agent_chat_state_busy": "Trabajando…",
"ui.agent_chat_state_exited": "Agente detenido",
"ui.agent_chat_state_failed": "Conexión fallida",
"ui.agent_chat_state_no_design": "Sin acceso al diseño",
"ui.agent_chat_state_off": "Sin conexión",
"ui.agent_chat_state_ready": "Listo",
"ui.agent_chat_state_starting": "Iniciando…",
"ui.agent_chat_stop": "Detener",
"ui.agent_chat_stop_tip": "Detener el agente y todo lo que haya iniciado",
"ui.agent_chat_stop_turn_tip": "Detener el agente (Esc). Los mensajes en cola permanecen en cola.",
"ui.agent_chat_thinking_for": "Pensando durante {time}",
"ui.agent_chat_tool_running_for": "en ejecución durante {time}",
"ui.agent_chat_tools_not_started": "El agente se ejecuta sin las herramientas de SolidCraft, así que no puede leer ni cambiar el diseño: se ha encontrado solidcraft-cli, pero sus herramientas no se han iniciado (el error del chat explica por qué). Vuelve a conectar; si sigue pasando, reinstala SolidCraft.",
"ui.agent_chat_transcript_assistant": "Asistente:",
"ui.agent_chat_transcript_user": "Usuario:",
"ui.agent_chat_turn_limit": "Cambios por mensaje",
"ui.agent_chat_turn_unlimited": "Sin límite",
"ui.agent_chat_undo_changes": "Deshacer estos cambios",
@@ -5302,6 +5409,7 @@
"ui.collapse": "Contraer",
"ui.collinear": "Colineal",
"ui.colour_vision": "Visión del color",
"ui.colour_vision_bends": "Pliegues",
"ui.colour_vision_deutan": "Deuteranopía",
"ui.colour_vision_deutan_tip": "Para personas con sensibilidad reducida o ausente al verde",
"ui.colour_vision_failed": "Fallido",
@@ -5331,14 +5439,24 @@
"ui.conic_curve": "Curva cónica",
"ui.constant": "Constante",
"ui.constraints_conflict": "Conflicto entre restricciones",
"ui.construct_auto.chosen": "Elegido a partir de tus selecciones. Elige otra referencia, o quita una, para cambiar el método.",
"ui.construct_auto.no_fit": "Elige otras referencias: ningún método se puede construir con estas.",
"ui.construct_auto.pick": "Elige con qué construirlo: el método se deduce de tus selecciones.",
"ui.construct_auto.refs": "Referencias",
"ui.construct_auto.select": "Selecciona primero las referencias con las que construir",
"ui.construct_axes": "Ejes",
"ui.construct_planes": "Planos",
"ui.construct_points": "Puntos",
"ui.construction": "Construcción",
"ui.construction.angle_hint": "Línea de construcción: haz clic en un punto, luego escribe un ángulo o elige una dirección",
"ui.construction.axis_hint": "Línea de construcción: haz clic en un punto o escribe su distancia al eje del boceto",
"ui.construction.bisector_hint": "Bisectriz: elige dos líneas rectas que se intersecten, luego la solución deseada",
"ui.construction.common_hint": "Tangente común: elige dos círculos/arcos, luego la solución deseada",
"ui.construction.contact_hint": "Tangente: elige un círculo/arco o spline/cónica abierta, luego el punto de contacto (libre)",
"ui.construction.external_hint": "Tangente: elige un círculo o arco, un punto externo, luego la solución deseada",
"ui.construction.free_hint": "Línea de construcción: haz clic en dos puntos; clic derecho para reiniciar",
"ui.construction.normal_needs_length": "Una línea infinita es siempre de construcción: escribe una longitud para dibujar una línea normal",
"ui.construction.parallel_hint": "Línea de construcción: elige una línea recta y luego haz clic o escribe el desfase; haz clic con el botón derecho para cambiar la referencia",
"ui.construction.point_hint": "Línea de construcción: haz clic en el punto por el que debe pasar",
"ui.construction.reference_hint": "Línea de construcción: elige una línea recta, luego un punto; clic derecho para cambiar la",
"ui.construction.ribbon_label": "X-Líneas",
@@ -5512,7 +5630,10 @@
"ui.explode_needs_two_components": "Vista explosada separa los componentes, así el diseño necesita dos o más (Nuevo componente, Insertar diseño o Crear componentes desde cuerpos)",
"ui.exportDxf": "Exportar DXF…",
"ui.export_ellipsis_keymap": "Exportar…",
"ui.export_glb": "Exportar GLB",
"ui.export_keymap_tip": "Exportar las teclas, los favoritos de la búsqueda S y la barra de herramientas como archivo",
"ui.export_selects_only_body": "Seleccionar el único cuerpo al exportar una malla",
"ui.export_selects_only_body_tip": "Exportar STL, Exportar 3MF y Enviar al laminador empiezan con el cuerpo del diseño seleccionado cuando solo hay uno.",
"ui.extend": "Extender",
"ui.extent": "Extensión",
"ui.extent_type": "Tipo de extensión",
@@ -5572,6 +5693,8 @@
"ui.finest": "Muy fina",
"ui.finish_sketch": "Finalizar boceto",
"ui.first_dimension_scales": "La primera cota escala el dibujo",
"ui.first_dimension_scales_about_origin": "Escalar respecto al origen del boceto",
"ui.first_dimension_scales_about_origin_tip": "Activado: el dibujo siempre crece o se encoge respecto al origen del boceto, así que una forma dibujada lejos del origen se mueve. Desactivado: un dibujo que nada ata al origen se escala respecto a su propio centro y se queda donde se dibujó.",
"ui.first_dimension_scales_tip": "Activado: la primera cota de longitud de un boceto escala el dibujo que mide, de modo que su forma no cambia. Desactivado: la cota solo dimensiona lo que mide.",
"ui.fit": "Ajustar",
"ui.fix_unfix": "Fijar/liberar",
@@ -5827,7 +5950,7 @@
"ui.origin": "Origen",
"ui.palette_construction_mode": "Modo de construcción (X)",
"ui.palette_more": "Más",
"ui.palette_sketch_options": "Opciones de croquis",
"ui.palette_sketch_options": "Opciones de boceto",
"ui.pan": "Desplazar",
"ui.pan_zoom_orbit": "Desplazar, zoom y orbitar",
"ui.panel_fixture.apply": "Aplicar",
@@ -5947,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",
@@ -5984,7 +6107,7 @@
"ui.print_face_cleared": "{body}: cara de impresión limpiada",
"ui.print_face_input": "Cara de impresión",
"ui.print_face_pick": "Selecciona una cara plana de un cuerpo sólido para hacerla la cara de impresión",
"ui.print_face_set": "{body}: cara de impresión establecida; Enviar a Laminador coloca el cuerpo sobre ella",
"ui.print_face_set": "{body}: cara de impresión establecida; Enviar al laminador coloca el cuerpo sobre ella",
"ui.profile": "Perfil",
"ui.profile_plane": "Plano de perfil",
"ui.profiles": "Perfiles",
@@ -6028,11 +6151,14 @@
"ui.relink_design_changed": "El ensamblaje cambió mientras se elegía un origen. Inicia Revincular origen de nuevo.",
"ui.relink_done": "Origen revinculado en todas las instancias.",
"ui.relink_not_linked": "Este componente no está vinculado a un diseño de origen.",
"ui.relink_one_done": "Origen revinculado en esta instancia.",
"ui.relink_select_one": "Selecciona primero un componente vinculado o una de sus instancias.",
"ui.relink_source_all": "Revincular origen (todas las instancias)…",
"ui.relink_source_one": "Revincular origen…",
"ui.removeFromQuickAccess": "Quitar de Acceso rápido",
"ui.removeFromToolbar": "Quitar de la barra de herramientas",
"ui.removeSection": "Quitar sección",
"ui.renameSelectedBodies": "Cambiar nombre de los cuerpos seleccionados",
"ui.render": "Renderizar",
"ui.renderer": "Motor de renderizado",
"ui.repeat": "Repetir",
@@ -6124,6 +6250,16 @@
"ui.sketch_intersect_selection_filter": "Filtro de selección",
"ui.sketch_keys_only": "Mantener las teclas del modelo fuera de los bocetos",
"ui.sketch_keys_only_tip": "Mientras editas un boceto, solo se ejecutan las teclas de boceto y las que funcionan en todas partes; una tecla de modelo como E o F no hace nada, así que una tecla perdida no puede cerrar un boceto pesado.",
"ui.sketch_links_added_curve": "{command} ha añadido 1 curva al boceto.",
"ui.sketch_links_added_curves": "{command} ha añadido {count} curvas al boceto.",
"ui.sketch_links_added_curves_and_points": "{command} ha añadido {curves} y {points} al boceto.",
"ui.sketch_links_added_point": "{command} ha añadido 1 punto al boceto.",
"ui.sketch_links_added_points": "{command} ha añadido {count} puntos al boceto.",
"ui.sketch_links_curve_count": "{count} curvas",
"ui.sketch_links_curve_count_one": "1 curva",
"ui.sketch_links_hidden": "El resultado está oculto porque {option} está desactivado en la paleta del boceto.",
"ui.sketch_links_point_count": "{count} puntos",
"ui.sketch_links_point_count_one": "1 punto",
"ui.sketch_modifiers": "Modificadores del boceto",
"ui.sketch_project_geometry": "Geometría",
"ui.sketch_report.all_constrained": "Todo está totalmente restringido.",
@@ -6163,6 +6299,7 @@
"ui.step_values": "Incrementar valores con las flechas y la rueda",
"ui.step_values_tip": "En un campo de valor en el que estés escribiendo, Arriba/Abajo cambian un número simple de 1 en 1 (Mayús: 10, Alt: 0,1), y la rueda del ratón sobre el campo hace lo mismo. Las expresiones se dejan tal como se escriben.",
"ui.stitch_tolerance_hint": "estricta",
"ui.straighten": "Enderezar",
"ui.stretch": "Estirar",
"ui.supersampling": "Supermuestreo (SSAA)",
"ui.supersampling_adaptive": "Solo cuando la vista está inmóvil",
@@ -6236,6 +6373,8 @@
"ui.view_angles_yaw": "Rumbo",
"ui.view_angles_zero": "Poner {axis} a 0°",
"ui.view_centre": "Centro de la vista",
"ui.view_compass_tip": "Vista desde {axis}. Haz clic otra vez para invertir. Arrastra para orbitar.",
"ui.view_compass_tip_snap": "Vista desde {axis}. Haz clic otra vez para invertir. Arrastra para orbitar; mantén Mayús para ajustar.",
"ui.viewport_status_clear": "Desactivar {name}",
"ui.viewport_status_clear_hint": "Desactivar {name} · Esc",
"ui.viewport_status_edges": "{count} aristas",
+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",
@@ -0,0 +1,42 @@
{
"en": {
"prefs.keep_print_faces": "Keep print faces",
"prefs.keep_print_faces_tip": "On: the print face of each body is tinted in the view at all times and saved in the design. Off: it is tinted only while Send to Slicer is open, and designs are saved and opened without print faces."
},
"ar": {
"prefs.keep_print_faces": "الاحتفاظ بأوجه الطباعة",
"prefs.keep_print_faces_tip": "تشغيل: يُلوَّن وجه الطباعة لكل جسم في العرض دائمًا ويُحفظ في التصميم. إيقاف: يُلوَّن فقط أثناء فتح نافذة إرسال إلى برنامج التقطيع، وتُحفظ التصاميم وتُفتح بدون أوجه الطباعة."
},
"de": {
"prefs.keep_print_faces": "Auflageflächen behalten",
"prefs.keep_print_faces_tip": "Ein: Die Auflagefläche jedes Körpers wird in der Ansicht immer eingefärbt und in der Konstruktion gespeichert. Aus: Sie wird nur eingefärbt, solange An Slicer senden geöffnet ist, und Konstruktionen werden ohne Auflageflächen gespeichert und geöffnet."
},
"es": {
"prefs.keep_print_faces": "Conservar caras de impresión",
"prefs.keep_print_faces_tip": "Activado: la cara de impresión de cada cuerpo se tiñe siempre en la vista y se guarda en el diseño. Desactivado: solo se tiñe mientras Enviar al laminador está abierto, y los diseños se guardan y se abren sin caras de impresión."
},
"fr": {
"prefs.keep_print_faces": "Conserver les faces d’impression",
"prefs.keep_print_faces_tip": "Activé : la face d’impression de chaque corps est toujours teintée dans la vue et enregistrée dans la conception. Désactivé : elle n'est teintée que tant qu'Envoyer au slicer est ouvert, et les conceptions sont enregistrées et ouvertes sans faces d’impression."
},
"he": {
"prefs.keep_print_faces": "שמירת פאות הדפסה",
"prefs.keep_print_faces_tip": "מופעל: פאת ההדפסה של כל גוף נצבעת בתצוגה תמיד ונשמרת בתכנון. כבוי: היא נצבעת רק כל עוד חלון שליחה לסלייסר פתוח, ותכנונים נשמרים ונפתחים ללא פאות הדפסה."
},
"ja": {
"prefs.keep_print_faces": "造形面を保持",
"prefs.keep_print_faces_tip": "オン: 各ボディの造形面をビューで常に色付けし、デザインに保存します。オフ: 「スライサーに送る」が開いている間だけ色付けし、デザインは造形面なしで保存・オープンされます。"
},
"ko": {
"prefs.keep_print_faces": "출력 바닥면 유지",
"prefs.keep_print_faces_tip": "켜기: 각 바디의 출력 바닥면을 뷰에 항상 색으로 표시하고 디자인에 저장합니다. 끄기: 슬라이서로 보내기가 열려 있는 동안만 표시하며, 디자인은 출력 바닥면 없이 저장되고 열립니다."
},
"vi": {
"prefs.keep_print_faces": "Giữ mặt in",
"prefs.keep_print_faces_tip": "Bật: mặt in của mỗi khối luôn được tô màu trong khung nhìn và được lưu trong thiết kế. Tắt: mặt in chỉ được tô màu khi cửa sổ Gửi sang phần mềm cắt lớp (Slicer) đang mở, và thiết kế được lưu và mở mà không có mặt in."
},
"zh": {
"prefs.keep_print_faces": "保留打印底面",
"prefs.keep_print_faces_tip": "开启:每个实体的打印底面始终在视图中着色,并保存在设计中。关闭:仅在“发送到切片软件”打开时着色,设计保存和打开时不含打印底面。"
}
}
@@ -0,0 +1,26 @@
{
"en": {
"operations.failed": "Failed"
},
"de": {
"operations.failed": "Fehlgeschlagen"
},
"ja": {
"operations.failed": "失敗"
},
"ko": {
"operations.failed": "실패"
},
"zh": {
"operations.failed": "失败"
},
"ar": {
"operations.failed": "فشل"
},
"he": {
"operations.failed": "נכשל"
},
"vi": {
"operations.failed": "Thất bại"
}
}
+320 -20
View File
@@ -20,7 +20,7 @@
"ABS - White": "ABS – biały",
"ALIGN": "WYRÓWNAJ",
"ARRANGE": "UKŁADANIE",
"AS-BUILT JOINT": "ZŁĄCZE W STANIE ZASTANYM",
"AS-BUILT JOINT": "ZŁĄCZE W BIEŻĄCYM POŁOŻENIU",
"ASSIGN PLASTIC RULE": "PRZYPISZ REGUŁĘ TWORZYWA",
"AXIS PERPENDICULAR TO FACE": "OŚ PROSTOPADŁA DO ŚCIANY",
"AXIS THROUGH CYLINDER": "OŚ PRZEZ WALEC",
@@ -75,7 +75,7 @@
"Arc length": "Długość łuku",
"Area": "Pole powierzchni",
"As Picked": "Jak wybrano",
"As-Built": "W stanie zastanym",
"As-Built": "W bieżącym położeniu",
"Assign Rule": "Przypisz regułę",
"Attachment": "Zaczepienie",
"BLEND SURFACE": "POWIERZCHNIA PRZEJŚCIOWA",
@@ -266,6 +266,7 @@
"Edge": "Krawędź",
"Edges": "Krawędzie",
"Edit": "Edytuj",
"Edit Holes": "Edytuj otwory",
"Edit Profile Sketch": "Edytuj szkic profilu",
"Edit shortcuts": "Edytuj skróty",
"Enable rule": "Włącz regułę",
@@ -513,6 +514,7 @@
"Offset Y": "Odsunięcie Y",
"Offset Z": "Odsunięcie Z",
"On your computer": "Na tym komputerze",
"One more stretch of the picked edge, each with its own angle, height and turns": "Kolejny odcinek wybranej krawędzi, każdy z własnym kątem, wysokością i stroną gięcia",
"Opacity": "Nieprzezroczystość",
"Open GitHub": "Otwórz GitHub",
"Open GitHub draft": "Otwórz szkic zgłoszenia na GitHubie",
@@ -634,6 +636,7 @@
"Remove the track": "Usuń ścieżkę",
"Remove this bend": "Usuń to gięcie",
"Remove this point": "Usuń ten punkt",
"Remove this segment": "Usuń ten odcinek",
"Rename Feature": "Zmień nazwę operacji",
"Rename Folder": "Zmień nazwę folderu",
"Render Stage": "Scenografia renderowania",
@@ -692,7 +695,7 @@
"SNAP FIT": "ZATRZASK",
"SPLIT FACE": "PODZIEL ŚCIANĘ",
"STITCH": "ZSZYJ",
"STOCK": "NADDATEK",
"STOCK": "PÓŁFABRYKAT",
"SURFACE EXTRUDE": "WYCIĄGNIĘCIE POWIERZCHNI",
"SURFACE LOFT": "WYCIĄGNIĘCIE ZŁOŻONE POWIERZCHNI",
"SURFACE QUALITY": "JAKOŚĆ POWIERZCHNI",
@@ -723,6 +726,7 @@
"Section position": "Położenie przekroju",
"Sections": "Przekroje",
"Sections (one sketch each)": "Przekroje (każdy w osobnym szkicu)",
"Segments": "Odcinki",
"Select Features": "Zaznacz operacje",
"Select Other": "Wybierz inny",
"Select Other: hold the left button still on the model (or right-click › Select Other…) to list everything under the cursor; click again on the same spot to select the next thing there. Double-click the middle button to go Home (Preferences › Navigation).": "Wybierz inny: przytrzymaj nieruchomo lewy przycisk na modelu (lub prawy przycisk › Wybierz inny…), aby wyświetlić wszystko pod kursorem; kliknij ponownie w tym samym miejscu, aby zaznaczyć kolejny obiekt. Dwukrotne kliknięcie środkowym przyciskiem przechodzi do widoku głównego (Preferencje › Nawigacja).",
@@ -772,6 +776,7 @@
"Spacing must be a non-zero number": "Odstęp musi być liczbą różną od zera",
"Specialise": "Uszczegółów",
"Speed": "Prędkość",
"Split Edge": "Podziel krawędź",
"Splitting tool": "Narzędzie podziału",
"Square": "Kwadrat",
"Stainless Steel": "Stal nierdzewna",
@@ -939,6 +944,13 @@
"ai.prefs.connect_apps_note": "Przy uruchomieniu SolidCraft dodaje go jako serwer MCP do aplikacji AI znalezionych na tym komputerze (OpenCode, Claude Desktop, Claude Code, Cursor, Codex, Gemini CLI, Copilot CLI, Windsurf, Zed, VS Code), aby agent mógł go używać bez konfiguracji. Dodawany jest tylko wpis solidcraft lub odświeżana jego ścieżka; zmieniony przez ciebie wpis pozostaje nietknięty, a plik jest najpierw kopiowany. Wyłączone: dodane wpisy są usuwane. Pomoc > Połącz asystentów AI… robi to ręcznie.",
"ai.prefs.connect_at_start": "Łącz ostatnio używanego agenta przy uruchomieniu SolidCraft",
"ai.prefs.connect_at_start_note": "Przy każdym uruchomieniu SolidCraft uruchamia w tle ostatnio połączonego agenta (lub domyślnego), aby był gotowy do rozmowy. Nie działa, gdy SolidCraft jest uruchomiony z --control. Jeśli nie uda się połączyć, Asystent pokaże komunikat „Połączenie nieudane” i przycisk Ponów.",
"ai.prefs.control_failed": "Nie udało się uruchomić kanału sterowania: {error}",
"ai.prefs.control_from_launch": "Uruchomiono z --control na porcie {port} na czas tej sesji. Zmiany tutaj zadziałają przy następnym uruchomieniu SolidCraft bez --control.",
"ai.prefs.control_listening": "Nasłuchuje na 127.0.0.1:{port}",
"ai.prefs.control_not_listening": "Nie nasłuchuje",
"ai.prefs.control_port": "Port",
"ai.prefs.control_port_note": "Port, z którym łączą się agenty AI na tym komputerze (127.0.0.1). Domyślny to 27878: gdy jest zajęty, używany jest następny wolny port. Każdy inny port jest używany bez zmian. Ustaw ten sam port w kliencie MCP, np. solidcraft-cli mcp --connect 127.0.0.1:7878.",
"ai.prefs.control_port_taken": "Port {port} jest zajęty przez inny program (lub inne okno SolidCraft). Wybierz inny port.",
"ai.prefs.follow_camera": "Obracaj kamerę do szkiców",
"ai.prefs.follow_camera_off": "Automatyczne podążanie kamery jest wyłączone.",
"ai.prefs.follow_camera_on": "Kamera obraca się przodem do szkiców tworzonych przez agenta.",
@@ -978,11 +990,12 @@
"browser.calibrate_click_a": "Kliknij obraz, aby wskazać punkt A, lub wpisz jego współrzędne.",
"browser.calibrate_click_b": "Kliknij obraz, aby wskazać punkt B, lub wpisz jego współrzędne.",
"browser.calibrate_title": "Kalibruj: {name}",
"browser.canvas_locked": "Płótno jest zablokowane. Najpierw je odblokuj.",
"browser.capture_many": "Przesunięto komponenty ({count}), ale ich położenia nie zostały zapamiętane.",
"browser.capture_note": "Zapamiętaj położenie zachowuje nowe położenie, a Przywróć położenie wraca do poprzedniego.",
"browser.capture_one": "Przesunięto komponent, ale jego położenie nie zostało zapamiętane.",
"browser.component_name": "Komponent{number}",
"browser.edit_canvas_title": "Edytuj kanwę: {name}",
"browser.edit_canvas_title": "Edytuj płótno: {name}",
"browser.edit_point_cloud_title": "Edytuj chmurę punktów: {name}",
"browser.move_title": "Przesuń: {name}",
"browser.needs_repair": "{name}: wymaga naprawy",
@@ -998,6 +1011,13 @@
"browser.sketches_to_parts_not_on_face": "nie na ścianie części",
"browser.sketches_to_parts_part_missing": "ich część już nie istnieje",
"browser.sketches_to_parts_result": "Przeniesione szkice do części, na których leżą: {moved}; pozostały: {stayed}: {why}",
"browser.straighten_angle": "Kierunek krawędzi",
"browser.straighten_choose": "Wybierz dla krawędzi opcję Pozioma, Pionowa lub Kąt, a potem naciśnij Wyprostuj.",
"browser.straighten_click_a": "Kliknij obraz na jednym końcu krawędzi, która powinna być prosta, lub wpisz współrzędne tego końca.",
"browser.straighten_click_b": "Kliknij drugi koniec krawędzi lub wpisz jego współrzędne.",
"browser.straighten_horizontal": "Pozioma",
"browser.straighten_title": "Wyprostuj: {name}",
"browser.straighten_vertical": "Pionowa",
"browser.units": "Jednostki: {units}",
"cam.flat": "Płaska ściana",
"cam.follower": "Popychacz",
@@ -1027,6 +1047,8 @@
"cmd.appearance_pick_up": "Pobierz wygląd",
"cmd.appearance_replace": "Zastąp wygląd",
"cmd.assembly_bom": "Zestawienie materiałowe",
"cmd.assembly_constraint_create": "Wiązanie",
"cmd.assembly_constraint_edit": "Edytuj wiązanie",
"cmd.assembly_mass_properties": "Właściwości masowe",
"cmd.body.print_face": "Ściana podstawy druku",
"cmd.body_rename": "Zmień nazwę bryły",
@@ -1045,6 +1067,7 @@
"cmd.canvas_delete": "Usuń płótno",
"cmd.canvas_edit": "Edytuj płótno",
"cmd.canvas_insert": "Płótno",
"cmd.canvas_straighten": "Wyprostuj płótno",
"cmd.canvas_to_sketch": "Szkic z płótna",
"cmd.component_activate": "Aktywuj komponent",
"cmd.component_break_link": "Zerwij łącze",
@@ -1086,7 +1109,10 @@
"cmd.config_rule": "Reguła konfiguracji",
"cmd.config_rules": "Reguły konfiguracji",
"cmd.config_table": "Pokaż tabelę konfiguracji",
"cmd.construct.axis": "Oś",
"cmd.construct.plane": "Płaszczyzna",
"cmd.construct.plane.axis_point": "Płaszczyzna przez oś i punkt",
"cmd.construct.point": "Punkt",
"cmd.construct_axis_edge": "Oś przez krawędź",
"cmd.construct_axis_normal_to_face": "Oś prostopadła do ściany",
"cmd.construct_axis_through_cylinder": "Oś przez walec/stożek/torus",
@@ -1149,6 +1175,7 @@
"cmd.file_save_mesh": "Zapisz jako siatkę",
"cmd.file_send_to_slicer": "Wyślij do slicera",
"cmd.file_slicers": "Zainstalowane slicery",
"cmd.frame_end_cap": "Zaślepka",
"cmd.frame_member": "Element ramy",
"cmd.frame_profile_insert": "Wstaw profil",
"cmd.frame_profile_list": "Biblioteka profili",
@@ -1169,6 +1196,7 @@
"cmd.inspect_isocurve": "Analiza izokrzywych",
"cmd.inspect_minimum_radius": "Analiza promienia minimalnego",
"cmd.inspect_parameter_sweep": "Przegląd wartości parametru",
"cmd.inspect_parameter_variants": "Porównaj warianty parametrów",
"cmd.inspect_silhouette": "Sylwetka",
"cmd.inspect_zebra": "Analiza zebry",
"cmd.joint_as_built": "Złącze w bieżącym położeniu",
@@ -1207,6 +1235,7 @@
"cmd.material_properties_directional": "Kierunkowy moduł sprężystości",
"cmd.material_properties_validate": "Sprawdź dane materiału fizycznego",
"cmd.mesh_convert": "Konwertuj siatkę",
"cmd.model_body_features": "Lista operacji bryły",
"cmd.model_edges": "Lista krawędzi",
"cmd.model_faces": "Lista ścian",
"cmd.model_threads": "Lista gwintów",
@@ -1221,6 +1250,10 @@
"cmd.motion_trace": "Ślad ruchu",
"cmd.motion_trace_edit": "Edytuj ślad ruchu",
"cmd.motion_trace_keep": "Zachowaj ślad ruchu",
"cmd.note_add": "Dodaj notatkę",
"cmd.note_delete": "Usuń notatkę",
"cmd.note_list": "Lista notatek",
"cmd.note_update": "Edytuj notatkę",
"cmd.occurrence_align": "Wyrównaj wystąpienie",
"cmd.occurrence_copy": "Wklej (wystąpienie)",
"cmd.occurrence_drag": "Przeciągnij wystąpienie",
@@ -1235,8 +1268,11 @@
"cmd.panel.dismiss": "Zamknij podgląd panelu",
"cmd.panel.float": "Odepnij panel",
"cmd.panel.focus": "Ustaw fokus na panelu",
"cmd.panel.focus.next": "Ustaw fokus na następnym panelu",
"cmd.panel.focus.previous": "Ustaw fokus na poprzednim panelu",
"cmd.panel.list": "Lista paneli",
"cmd.panel.move": "Przenieś panel",
"cmd.panel.move.next_zone": "Przenieś panel do następnej strefy",
"cmd.panel.native_close": "Zadokuj ponownie sekcję panelu",
"cmd.panel.open": "Otwórz panel",
"cmd.panel.peek": "Podejrzyj zwinięty panel",
@@ -1244,6 +1280,12 @@
"cmd.panel.reset_pair": "Resetuj podział paneli",
"cmd.panel.resize": "Zmień rozmiar sekcji panelu",
"cmd.panel.resize_pair": "Zmień podział paneli",
"cmd.panel.sash.down": "Przesuń podział paneli w dół",
"cmd.panel.sash.left": "Przesuń podział paneli w lewo",
"cmd.panel.sash.reset": "Resetuj podział panelu z fokusem",
"cmd.panel.sash.right": "Przesuń podział paneli w prawo",
"cmd.panel.sash.up": "Przesuń podział paneli w górę",
"cmd.panel.toggle": "Przełącz panel z fokusem",
"cmd.parameters_add": "Dodaj parametr użytkownika",
"cmd.parameters_change": "Zmień parametry",
"cmd.parameters_comment": "Komentarz parametru",
@@ -1337,6 +1379,7 @@
"cmd.render_start": "Renderuj obraz",
"cmd.render_status": "Postęp renderowania",
"cmd.select_clear": "Wyczyść zaznaczenie",
"cmd.selection_get": "Pobierz zaznaczenie",
"cmd.settings_profile_apply": "Zastosuj profil ustawień",
"cmd.settings_profile_delete": "Usuń profil ustawień",
"cmd.settings_profile_export": "Eksportuj profil ustawień",
@@ -1357,16 +1400,17 @@
"cmd.sheet_refold": "Zegnij ponownie",
"cmd.sheet_rules": "Lista reguł blach",
"cmd.sheet_unfold": "Rozegnij",
"cmd.sketch.construction_line": "Prosta konstrukcyjna",
"cmd.sketch.construction_line.angle": "Prosta konstrukcyjna pod kątem",
"cmd.sketch.construction_line": "Linia konstrukcyjna",
"cmd.sketch.construction_line.angle": "Linia konstrukcyjna pod kątem",
"cmd.sketch.construction_line.bisector": "Dwusieczna kąta",
"cmd.sketch.construction_line.horizontal": "Pozioma prosta konstrukcyjna",
"cmd.sketch.construction_line.parallel": "Równoległa prosta konstrukcyjna",
"cmd.sketch.construction_line.perpendicular": "Prostopadła prosta konstrukcyjna",
"cmd.sketch.construction_line.horizontal": "Pozioma linia konstrukcyjna",
"cmd.sketch.construction_line.parallel": "Równoległa linia konstrukcyjna",
"cmd.sketch.construction_line.perpendicular": "Prostopadła linia konstrukcyjna",
"cmd.sketch.construction_line.tangent_at": "Styczna w punkcie krzywej",
"cmd.sketch.construction_line.tangent_external": "Styczna przez punkt zewnętrzny",
"cmd.sketch.construction_line.tangent_two": "Wspólna styczna",
"cmd.sketch.construction_line.vertical": "Pionowa prosta konstrukcyjna",
"cmd.sketch.construction_line.vertical": "Pionowa linia konstrukcyjna",
"cmd.sketch.replace": "Zastąp szkic",
"cmd.sketch_arc3d": "Łuk 3D",
"cmd.sketch_arc_center_point": "Łuk ze środka",
"cmd.sketch_arc_tangent": "Łuk styczny",
@@ -1498,6 +1542,8 @@
"cmd.timeline_stop": "Zatrzymaj historię",
"cmd.timeline_suppress": "Wygaś operację",
"cmd.ui.assistant": "Przełącz Asystenta",
"cmd.ui.palette": "Szukaj poleceń",
"cmd.ui.toggle_visibility": "Pokaż/ukryj zaznaczenie",
"cmd.view.navigator": "Własny nawigator widoku",
"cmd.view.render_browser": "Przeglądarka renderowania",
"cmd.view.render_dock": "Przenieś dok renderowania",
@@ -1555,6 +1601,7 @@
"color.step_down": "Zmniejsz",
"color.step_up": "Zwiększ",
"color.value": "Jasność (procent)",
"component.error.ambiguous_name": "Kilka komponentów ma nazwę `{name}`. Użyj liczbowego identyfikatora komponentu: {ids}.",
"config.column_filter_empty": "Brak pasujących kolumn",
"config.rule_parameter_columns": "Wymienione są tylko parametry mające kolumnę w tabeli. Dodaj ją przyciskiem Dodaj kolumnę (+), aby użyć jej tutaj.",
"config.rule_suppression_columns": "Wymienione są tylko operacje mające w tabeli kolumnę Wygaś. Dodaj ją przyciskiem Dodaj kolumnę (+), aby nią tutaj sterować.",
@@ -1591,10 +1638,11 @@
"crash.report": "Zgłoś…",
"crash.unclean_explanation": "Wykryto przy późniejszym uruchomieniu; dokładny czas zamknięcia nie został zapisany. Nieprawidłowe zamknięcie może też wynikać z wyłączenia systemu lub wymuszonego zatrzymania.",
"delete.and_more": "… i jeszcze {count}",
"diagnostic.empty_boolean_component": "w komponencie '{component}' nie ma bryły do wycięcia lub przycięcia; narzędzie może nachodzić na '{body}' w komponencie '{other}' – aktywuj ten komponent lub wybierz tę bryłę jako cel",
"diagnostic.empty_boolean_component": "w komponencie '{component}' nie ma bryły do wycięcia lub przecięcia; narzędzie może nachodzić na '{body}' w komponencie '{other}' – aktywuj ten komponent lub wybierz tę bryłę jako cel",
"diagnostic.reselect_reference": "{feature}: odniesienie `{reference}` już nie istnieje w postaci, w jakiej je wybrano; wybierz je ponownie",
"dialog.bodies_count": "Bryły: {count}",
"dialog.clearance_invalid": "luz musi być długością równą 0 lub większą",
"dialog.component_origin": "{placement} · {item}",
"dialog.compute": "Oblicz",
"dialog.compute_again": "Oblicz ponownie",
"dialog.computing": "Obliczanie… {seconds} s",
@@ -1607,6 +1655,7 @@
"dialog.dimension_step_expression": "Zmieniać można tylko liczbę; wyrażenie pozostaje tak, jak je wpisano",
"dialog.dimension_step_up": "W górę o {step}",
"dialog.edit_title": "EDYCJA: {title}",
"dialog.hole_held_off_its_line": "Krawędź odniesienia trzyma ten otwór na linii, która opuszcza ścianę przed wskazanym punktem. Wskaż miejsce, w którym linia przecina ścianę, albo usuń krawędź, aby swobodnie przesuwać otwór.",
"dialog.no_shown_body": "Brak widocznej bryły do zmiany: pokaż którąś lub wybierz opcję Nowa bryła",
"dialog.pattern_sketch_not_object": "Wybierz bryły lub operacje: szkic nie jest ani jednym, ani drugim. Powiel jego krzywe własnym szykiem lub lustrem szkicu albo najpierw go wyciągnij.",
"dialog.primitive_point_tip": "Punkt, który wypełnia X, Y i Z: kliknij tutaj, a potem kliknij wierzchołek, krawędź lub ścianę modelu, punkt szkicu albo początek układu.",
@@ -1625,9 +1674,12 @@
"document.changed_on_disk": "{name} zmienił się na dysku",
"document.changed_on_disk_detail": "{name} został zmieniony przez inny program lub komputer od chwili otwarcia lub ostatniego zapisu. Nadpisz zastępuje te zmiany twoimi, a Zapisz kopię zachowuje obie wersje.",
"document.copy_name": "{name} — kopia",
"document.discard_row": "Odrzuć",
"document.finish_save_prompt": "Zanim otworzysz kolejny projekt, odpowiedz na pytanie o zapisanie.",
"document.newer_file": "Zapisano w nowszym SolidCraft (format {format}, ta wersja czyta do {reads}). Jeśli czegoś brakuje, zaktualizuj program.",
"document.newer_file_change": "Zapisano w nowszym SolidCraft (format {format} ze zmianą `{change}`, której ta wersja nie zna). Jeśli czegoś brakuje, zaktualizuj program.",
"document.newer_file_not_read": "Nie odczytano: {what}. Zapisanie to zachowa.",
"document.not_saved_yet": "Jeszcze nie zapisano",
"document.open_anyway": "Otwórz mimo to",
"document.open_elsewhere": "{name} jest już otwarty",
"document.open_elsewhere_detail": "{name} jest otwarty w SolidCraft {app} na {who}, otwarty {when}. Jeśli oboje zapiszecie, ktoś straci swoją pracę. Otwórz go tylko do odczytu, aby go obejrzeć bez nadpisywania.",
@@ -1637,6 +1689,9 @@
"document.read_only_save": "Ten projekt otwarto tylko do odczytu: zapisz go jako nowy plik",
"document.save_changes": "Zapisać zmiany w {name}?",
"document.save_copy": "Zapisz kopię…",
"document.save_many": "Zapisać zmiany w projektach ({n})?",
"document.save_row": "Zapisz",
"document.save_selected": "Zapisz zaznaczone",
"document.tab_unsaved_changes": "Niezapisane zmiany",
"document.unsaved_changes": "{name} ma niezapisane zmiany. Jeśli ich nie zapiszesz, zostaną utracone.",
"drive.rigid": "{joint} to złącze sztywne: utrzymuje swoje części nieruchomo, więc nie ma czym sterować. Aby mogły się poruszać, zmień jego typ w oknie Edytuj złącze: Obrotowe do obrotu, Przesuwne do przesuwu, Walcowe do obu.",
@@ -1656,6 +1711,8 @@
"dxf.tolerance": "Tolerancja krzywych",
"dxf.tolerance_help": "Tylko widoczne bryły i pokazane szkice. Krzywe są przybliżane odcinkami. Mniejsza tolerancja daje dokładniejsze krzywe, ale zajmuje więcej czasu i pamięci.",
"each rail's curves must be in one sketch": "krzywe każdej prowadnicy muszą leżeć w jednym szkicu",
"error.end_cap_faces": "Wybierz co najmniej jedną otwartą ścianę końcową elementu ramy",
"error.end_cap_unresolved": "Nie ma już w modelu końców tej zaślepki (nr {ends}). Wybierz ponownie ściany końcowe",
"feature computation ended without a result": "obliczanie operacji zakończyło się bez wyniku",
"file.open.drawing_filter": "Projekty, pliki CAD, siatki i rysunki",
"file.open.portal_fallback.no_portal_service": "Otwarte okno wyboru pliku nie pochodzi z pulpitu: usługa portalu pulpitu jest niedostępna.",
@@ -1667,6 +1724,7 @@
"file_load.inserting": "Wstawianie geometrii",
"file_load.link_item": "{name} ({done} z {total})",
"file_load.opened": "Otwarto {name}",
"file_load.opened_behind": "Otwarto {name} w nowej karcie",
"file_load.opened_in_tab": "Otwarto {name} w nowej karcie",
"file_load.opening": "Otwieranie projektu",
"file_load.preparing_thumbnail": "Przygotowywanie miniatury…",
@@ -1714,11 +1772,13 @@
"help.config.table": "Pokazuje tabelę konfiguracji: jeden wiersz na wariant, jedną kolumnę na parametr lub operację oraz to, czy projekt nadal odpowiada aktywnemu wierszowi. Służy do przeglądu wariantów projektu.",
"help.connect_note": "Uruchom ponownie każdego asystenta, aby wczytał serwer SolidCraft. Przed zmianą każdy plik zapisano jako kopię .bak.",
"help.connect_title": "Połącz asystentów AI",
"help.construct.axis": "Tworzy oś konstrukcyjną metodą wynikającą z wybranych elementów: wzdłuż krawędzi lub linii, przez otwór lub okrągłą ścianę, na przecięciu dwóch płaszczyzn, przez dwa punkty albo prostopadle do ściany przez punkt. Służy wtedy, gdy znasz elementy odniesienia, a nie metodę.",
"help.construct.axis.edge": "Tworzy oś konstrukcyjną wzdłuż prostej krawędzi lub linii szkicu. Służy jako oś obrotu, szyku kołowego lub złącza.",
"help.construct.axis.normal_to_face": "Tworzy oś konstrukcyjną przez punkt, prostopadłą do ściany. Daje oś wychodzącą prostopadle z powierzchni.",
"help.construct.axis.through_cylinder": "Tworzy oś konstrukcyjną przez środek ściany walcowej, stożkowej lub torusa albo okrągłej krawędzi. Służy do obracania lub powielania operacji wokół okrągłej części.",
"help.construct.axis.two_planes": "Tworzy oś konstrukcyjną w miejscu spotkania dwóch płaszczyzn lub płaskich ścian. Użyj, gdy potrzebna oś to linia między dwiema ścianami.",
"help.construct.axis.two_points": "Tworzy oś konstrukcyjną przez dwa punkty, wierzchołki lub punkty szkicu. Przydaje się, gdy oś ma biec między dwoma znanymi położeniami.",
"help.construct.plane": "Tworzy płaszczyznę konstrukcyjną metodą wynikającą z wybranych elementów: odsuniętą od płaszczyzny, przez punkt, między dwiema płaszczyznami, przez linie lub punkty, styczną do zakrzywionej ściany albo prostopadłą do ścieżki. Służy wtedy, gdy znasz elementy odniesienia, a nie metodę.",
"help.construct.plane.along_path": "Tworzy płaszczyznę konstrukcyjną prostopadłą do krawędzi lub krzywej, w wybranym miejscu wzdłuż niej. Służy do szkicowania profilu przeciągnięcia lub przekroju ścieżki.",
"help.construct.plane.at_angle": "Tworzy płaszczyznę konstrukcyjną obróconą o kąt względem płaszczyzny lub ściany, wokół osi lub krawędzi. Włącz opcję Użyj tylko kierunku osi, aby zrzutować oś na bazę i obracać wokół punktu na tej płaszczyźnie (początku bazy, gdy żaden nie jest wybrany). Służy do szkiców pochylonych, np. ukośnego wspornika.",
"help.construct.plane.axis_point": "Tworzy płaszczyznę konstrukcyjną zawierającą oś i punkt poza nią. Wybierz oś początku układu lub konstrukcyjną, prostą krawędź lub linię szkicu, a potem wierzchołek, punkt szkicu lub punkt konstrukcyjny. Punkt leżący na osi nie wyznacza jednoznacznie płaszczyzny.",
@@ -1728,6 +1788,7 @@
"help.construct.plane.tangent": "Tworzy płaszczyznę konstrukcyjną styczną do walca, stożka, sfery lub torusa w punkcie. Służy do szkicowania na boku okrągłej części.",
"help.construct.plane.three_points": "Tworzy płaszczyznę konstrukcyjną przez trzy punkty. Przydaje się, gdy płaszczyznę wyznaczają punkty modelu, np. trzy narożniki.",
"help.construct.plane.two_edges": "Tworzy płaszczyznę konstrukcyjną przez dwie proste krawędzie lub linie leżące w jednej płaszczyźnie. Pozwala poprowadzić płaszczyznę w poprzek części, np. przez dwie przeciwległe krawędzie.",
"help.construct.point": "Tworzy punkt konstrukcyjny metodą wynikającą z wybranych elementów: w wierzchołku, w środku okręgu lub sfery, wzdłuż krawędzi, na przecięciu dwóch linii lub trzech płaszczyzn albo tam, gdzie linia przecina płaszczyznę. Służy wtedy, gdy znasz elementy odniesienia, a nie metodę.",
"help.construct.point.along_path": "Tworzy punkt konstrukcyjny w wybranym miejscu wzdłuż krawędzi lub krzywej. Służy jako odniesienie w dowolnym miejscu ścieżki.",
"help.construct.point.center": "Tworzy punkt konstrukcyjny w środku okrągłej krawędzi, okręgu lub łuku szkicu, sfery albo torusa. Służy jako odniesienie dla otworów, osi i złączy.",
"help.construct.point.edge_plane": "Tworzy punkt konstrukcyjny tam, gdzie krawędź lub linia przechodzi przez płaszczyznę lub płaską ścianę. Służy do oznaczenia miejsca, w którym ścieżka przecina płaszczyznę.",
@@ -1753,6 +1814,7 @@
"help.file.send_to_slicer": "Zapisuje zaznaczone bryły jako plik 3MF i otwiera go w wybranym slicerze. Służy do przejścia od modelu do druku jednym krokiem.",
"help.font_details": "Interfejs używa wbudowanych czcionek egui (Ubuntu Light i Hack na licencjach Ubuntu Font Licence oraz MIT/Bitstream Vera; Noto Emoji i czcionka ikon emoji na licencjach OFL i MIT). Tekst szkicu używa Liberation Sans (SIL OFL 1.1). Opcjonalny pakiet czcionek The Crafting Apps, craft-fonts (BIZ UDPGothic, Shippori Mincho, BIZ UDMincho, Noto Sans CJK SC, Noto Sans Arabic; SIL OFL 1.1), nie jest dołączany do kompilacji SolidCraft.",
"help.fonts": "Czcionki",
"help.frame.end_cap": "Zamyka otwarte końce elementów ramy płytkami w kształcie obrysu profilu, nałożonymi na koniec lub w niego wpuszczonymi. Służy do zaślepiania rur, kątowników i belek.",
"help.frame.member": "Umieszcza profil, np. rurę lub kształtownik aluminiowy, wzdłuż linii szkicu ramy, jeden element na linię. Służy do budowy ram, regałów i konstrukcji spawanych.",
"help.inspect.accessibility": "Koloruje ściany, do których narzędzie dociera z jednego kierunku, i te, do których nie. Służy do sprawdzenia części pod kątem obróbki lub formowania z tej strony.",
"help.inspect.center_of_mass": "Pokazuje środek masy brył, każdej osobno i wszystkich razem, przy jednorodnej gęstości. Służy do sprawdzenia wyważenia i miejsca, w którym część się przechyli.",
@@ -2018,6 +2080,7 @@
"help.view.visual_style": "Przełącza sposób rysowania modelu: cieniowany z krawędziami lub bez, krawędziowy albo z krawędziami niewidocznymi. Użyj modelu krawędziowego, aby zajrzeć do wnętrza, a cieniowanego dla czystego wyglądu.",
"history.sketch_under_constrained": "Niedostatecznie związany (stopnie swobody: {count})",
"history.sketch_under_constrained_one": "Niedostatecznie związany (stopnie swobody: {count})",
"hole_folder.applies_to": "Zmiany dotyczą otworów w folderze {folder} ({count}); każdy zachowuje swoje miejsce.",
"hole_presets.confirm_delete": "Potwierdź usunięcie",
"hole_presets.delete": "Usuń",
"hole_presets.delete_prompt": "Usunąć ten własny szablon? Istniejące otwory pozostaną bez zmian.",
@@ -2228,6 +2291,9 @@
"measure.at": "W punkcie",
"measure.item": "Element {number}",
"measure.line_to_centre": "Linia do środka",
"measure.perimeter": "Obwód",
"measure.perimeter_inner": "Obwód wewnętrzny",
"measure.perimeter_outer": "Obwód zewnętrzny",
"measure.quick_copied": "Skopiowano pomiar",
"measure.quick_copy": "Kliknij, aby skopiować pomiar",
"measure.quick_edges": "Krawędzie: {count}",
@@ -2269,6 +2335,8 @@
"nav.hfss.help": "Alt+przeciąganie lewym przesuwa, Shift+Alt+lewy powiększa, Ctrl+przeciąganie lewym na pustym miejscu obraca widok",
"nav.inventor.help": "Przeciąganie środkowym przesuwa, Shift+środkowy obraca widok, Ctrl+środkowy powiększa, przeciąganie prawym obraca widok",
"nav.maya.help": "Alt+przeciąganie lewym obraca widok, Alt+środkowy przesuwa, Alt+prawy powiększa; przeciąganie środkowym także przesuwa",
"nav.maya_horizontal_zoom.help": "Alt + przeciąganie prawym przyciskiem: w prawo przybliża, w lewo oddala. Wyłącz, aby przywrócić powiększanie przeciąganiem w pionie.",
"nav.maya_horizontal_zoom.label": "Maya: powiększanie przeciąganiem w poziomie",
"nav.nx.help": "Przeciąganie środkowym obraca widok, Shift+środkowy przesuwa (w NX środkowy + prawy), Ctrl+środkowy powiększa (w NX środkowy + lewy); przeciąganie prawym nie rusza kamery",
"nav.openscad.help": "Przeciąganie lewym na pustym miejscu obraca widok, Shift+przeciąganie lewym zaznacza prostokątem, przeciąganie geometrii szkicu ją edytuje; przeciąganie prawym lub środkowym przesuwa, Shift+prawy lub Shift+środkowy powiększa",
"nav.orbit_cursor_wrap.help": "Gdy obracanie przeciąganiem dojdzie do krawędzi widoku, kursor przeskakuje na przeciwną krawędź, a obrót trwa dalej. Wyłączone: przeciąganie zatrzymuje się na krawędzi ekranu. Nie działa w Wayland ani w przeglądarce.",
@@ -2399,6 +2467,16 @@
"not a move": "to nie jest przesunięcie",
"not a number": "to nie jest liczba",
"open a sketch first": "najpierw otwórz szkic",
"operations.cancel": "Anuluj: {name}",
"operations.cancelled": "Anulowano: {name}",
"operations.cancelling": "Anulowanie…",
"operations.cannot_cancel": "Nie można anulować: {name}",
"operations.feature": "Operacja",
"operations.feature_edit": "Edycja operacji",
"operations.meshing": "Wczytywanie części",
"operations.queued": "W kolejce",
"operations.stopped": "Anulowano",
"operations.waiting": "Gotowe",
"parameters.apply_live": "Stosuj każdą zmianę po opuszczeniu pola",
"parameters.apply_live_tip": "Wyłączone: zmiany czekają na kliknięcie Zastosuj lub OK, a potem projekt przebudowuje się raz dla wszystkich. Anuluj lub Escape je odrzuca.",
"parameters.held": "Zmiany do zastosowania: {count}",
@@ -2420,12 +2498,13 @@
"parts.dimension.width": "Szerokość",
"parts.family.bearing": "Łożysko kulkowe zwykłe (obrys)",
"parts.family.button_head_cap_screw": "Śruba z łbem półokrągłym z gniazdem sześciokątnym",
"parts.family.countersunk_socket_screw": "Śruba z łbem stożkowym z gniazdem sześciokątnym",
"parts.family.dowel_pin": "Kołek walcowy",
"parts.family.hex_bolt": "Śruba z łbem sześciokątnym",
"parts.family.hex_cap_screw_inch": "Śruba z łbem sześciokątnym (calowa)",
"parts.family.hex_nut": "Nakrętka sześciokątna",
"parts.family.hex_nut_inch": "Nakrętka sześciokątna (calowa)",
"parts.family.set_screw": "Wkręt dociskowy z gniazdem sześciokątnym, płaski",
"parts.family.set_screw": "Wkręt dociskowy z gniazdem sześciokątnym z końcem płaskim",
"parts.family.socket_head_cap_screw": "Śruba z łbem walcowym z gniazdem sześciokątnym",
"parts.family.washer": "Podkładka zwykła",
"parts.family.washer_sae": "Podkładka zwykła, SAE (wąska)",
@@ -2486,6 +2565,7 @@
"prefs.appearance.viewport_colours": "Kolory widoku 3D",
"prefs.appearance.viewport_logo": "Widok i logo",
"prefs.applies_on_restart": "Działa po ponownym uruchomieniu",
"prefs.assemblies": "Złożenia",
"prefs.assistant": "Asystent",
"prefs.assistant_connecting": "Łączenie",
"prefs.assistant_follow_help": "Patrz na szkice przesuwa widok razem ze wskaźnikiem.",
@@ -2518,6 +2598,8 @@
"prefs.bottom_centre": "Dół pośrodku",
"prefs.bottom_right": "Dół z prawej",
"prefs.browser": "Przeglądarka",
"prefs.browser_body_features": "Operacje pod bryłami",
"prefs.browser_body_features_note": "Każda bryła w przeglądarce rozwija się na operacje z Historii, które ją zbudowały, w kolejności Historii.",
"prefs.browser_components_open": "Komponenty startują rozwinięte",
"prefs.browser_components_open_note": "Każdy komponent w przeglądarce startuje rozwinięty, jak dotąd, zamiast zwinięty do czasu otwarcia.",
"prefs.browser_eye_left": "Oko obok strzałki zwijania",
@@ -2536,6 +2618,10 @@
"prefs.choose_font": "Wybierz plik czcionki…",
"prefs.click": "Kliknięcie",
"prefs.command_dialog_values": "Okna poleceń",
"prefs.constraint_glyph_size": "Rozmiar symboli wiązań",
"prefs.constraint_glyph_size_note": "Jak duże są rysowane w szkicu symbole wiązań oraz ich litery lub ikony. 100% to zwykły rozmiar; symbole zachowują ten rozmiar na ekranie przy każdym powiększeniu.",
"prefs.construct_menu_groups": "Grupuj narzędzia Konstrukcja według rodzaju elementu",
"prefs.construct_menu_groups_help": "Pokazuje nagłówki Płaszczyzny, Osie i Punkty. Wyłączone: menu zachowuje pierwotną kolejność.",
"prefs.contact_shading": "Miękkie cieniowanie kontaktowe",
"prefs.contact_shading_note": "Miękkie cieniowanie tam, gdzie części są blisko siebie.",
"prefs.context_menu": "Menu kontekstowe",
@@ -2558,15 +2644,21 @@
"prefs.design_tools.degrees": "Stopnie",
"prefs.design_tools.double_click_help": "Lub aby edytować połączony komponent w miejscu.",
"prefs.design_tools.ghost": "Pokazuj poprzednie miejsce przesuniętych części jako widmo",
"prefs.design_tools.hole_group": "Grupuj otwory utworzone razem",
"prefs.design_tools.hole_group_help": "Otwory utworzone za jednym razem trafiają do wspólnego folderu w Historii, na początku zwiniętego; rozwiń go, aby edytować pojedynczy otwór.",
"prefs.design_tools.hole_moves_help": "Ponowne wybranie ściany przesuwa otwór zamiast go usuwać.",
"prefs.design_tools.hole_several": "Kilka otworów na jednej ścianie",
"prefs.design_tools.hole_several_help": "Wybranie innego miejsca na ścianie dodaje kolejny otwór; wybranie otworu go usuwa.",
"prefs.design_tools.hole_snaps_help": "Środek ściany i środki jej okrągłych krawędzi.",
"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",
"prefs.dialog_first_letter_buttons": "Przyciski okien dialogowych reagują na pierwszą literę",
"prefs.dialog_first_letter_buttons_help": "Gdy okno dialogowe jest otwarte, wpisanie pierwszej litery etykiety przycisku naciska go: O dla OK, A dla Anuluj. Litera pasująca do dwóch przycisków nic nie robi, a aktywne pole tekstowe zachowuje naciśnięty klawisz. Wyłączone: pisanie nie wpływa na przyciski, jak dotąd.",
"prefs.dialogs": "Okna dialogowe",
"prefs.dialogs_edge": "Przy krawędzi widoku",
"prefs.dialogs_left_column": "W lewej kolumnie",
@@ -2580,12 +2672,23 @@
"prefs.dimension_values_window": "W oknie",
"prefs.display": "Wyświetlanie",
"prefs.edges": "Krawędzie i cieniowanie",
"prefs.export": "Eksport",
"prefs.export_name": "Nazwa eksportowanego pliku",
"prefs.export_name.body": "Nazwa eksportowanej bryły (pierwszej, gdy jest ich kilka)",
"prefs.export_name.design": "Nazwa projektu",
"prefs.export_name_hint": "np. {design}-{body}",
"prefs.export_name_sample": "Eksport tego projektu zaproponuje: {name}",
"prefs.export_name_tip": "Nazwa pliku proponowana przez Plik › Eksportuj i Zapisz jako siatkę. Te symbole zastępcze są zamieniane:\n{placeholders}\nWłaściwość projektu, która nie jest ustawiona, jest pomijana. Zostaw pole puste, aby proponować nazwę projektu, a dla siatki – nazwę bryły.",
"prefs.extend": "Rozszerz",
"prefs.extra_navigation": "Ctrl+przeciąganie lewym na pustym miejscu obraca widok; przytrzymaj Spację, aby przesuwać.",
"prefs.extrude_arrow_face_stops": "Strzałka wyciągnięcia zatrzymuje się na ścianach",
"prefs.extrude_arrow_face_stops_note": "Przeciąganie strzałki odległości w wyciągnięciu zatrzymuje się na płaskich ścianach równoległych do profilu, np. na drugiej stronie wycinanego bloku, i obrysowuje ścianę. Wyłączone: strzałka przesuwa się tylko o okrągłe kroki.",
"prefs.extrude_view_isometric": "Obracaj widok do izometrycznego przy otwarciu wyciągnięcia",
"prefs.extrude_view_return": "Wracaj do poprzedniego widoku po zamknięciu wyciągnięcia",
"prefs.file_operations_wait": "Czekaj na otwarcie plików",
"prefs.file_operations_wait_note": "Gdy plik jest otwierany lub wstawiany albo łącza są ponownie odczytywane, projekt czeka pod nakładką z postępem pliku i przyciskiem Anuluj. Wyłączone: plik pojawia się w panelu zadań i możesz dalej pracować; wstawienie nastąpi, gdy skończysz bieżącą czynność, a plik otwarty w międzyczasie trafi do nowej karty.",
"prefs.fillet_legacy_radius": "Używaj poprzedniego domyślnego promienia zaokrąglenia bryły",
"prefs.fillet_legacy_radius.help": "Domyślnie wyłączone. Gdy włączone, nowe zaokrąglenie bryły otwiera się z liczbą 2 odczytaną w jednostkach projektu („2 in” w projekcie calowym), jak dotąd; gdy wyłączone, otwiera się z domyślnym promieniem narzędzia, 2 mm, pokazanym w jednostkach projektu („0.07874 in” w projekcie calowym).",
"prefs.follows_os": "Zgodnie z systemem",
"prefs.font": "Czcionka",
"prefs.font_fallback": "Znaki, których brakuje w wybranej czcionce, są brane z czcionki wbudowanej, więc żadna etykieta nie pokaże pustego prostokąta.",
@@ -2609,6 +2712,17 @@
"prefs.gesture_pan": "Przesuwanie",
"prefs.gesture_zoom": "Powiększanie",
"prefs.graphics": "Grafika",
"prefs.graphics_api": "API grafiki",
"prefs.graphics_api_auto": "Automatycznie",
"prefs.graphics_api_help": "Wybierz API grafiki, z którym uruchamia się SolidCraft. Jeśli przy następnym uruchomieniu okno się nie otworzy, przywracany jest poprzedni wybór.",
"prefs.graphics_api_override": "API grafiki jest wymuszone przy uruchomieniu. Zapisany wybór zadziała, gdy SolidCraft zostanie uruchomiony normalnie.",
"prefs.graphics_api_restart_note": "Działa po ponownym uruchomieniu. Przed zamknięciem pojawi się pytanie o niezapisane projekty.",
"prefs.graphics_api_restart_unavailable": "Uruchom ponownie SolidCraft, aby zastosować zapisane API grafiki.",
"prefs.graphics_api_running": "Bieżące: {current}. Wybrane na następne uruchomienie: {selected}.",
"prefs.graphics_api_save_failed": "Nie udało się zapisać wyboru API grafiki: {error}",
"prefs.graphics_api_start_failed": "Nie udało się przygotować wybranego API grafiki: {error}",
"prefs.graphics_api_start_reverted": "Poprzednie uruchomienie z {requested} nie otworzyło okna. SolidCraft przywrócił {previous}.",
"prefs.graphics_api_unavailable": "Wybór API grafiki jest dostępny w aplikacji desktopowej.",
"prefs.graphics_card": "Karta graficzna",
"prefs.graphics_card_auto": "Automatycznie",
"prefs.graphics_card_missing": "Zapisana karta graficzna jest niedostępna; używane jest ustawienie Automatycznie.",
@@ -2640,6 +2754,8 @@
"prefs.history": "Historia",
"prefs.history_bar_groups": "Zwijaj pasek Historii w grupy",
"prefs.history_bar_groups_note": "W długiej historii ciąg operacji jednego komponentu zwija się w jedną etykietę, a foldery operacji są na początku zwinięte. Wstawione złożenie STEP trafia do folderu Grupa. Wyłączone: widać każdą operację, foldery są na początku rozwinięte, a folder nie powstaje.",
"prefs.history_folder_kind_icon": "Ikona folderu według jego operacji",
"prefs.history_folder_kind_icon_note": "Folder Historii, którego wszystkie operacje są jednego rodzaju, pokazuje ich ikonę.",
"prefs.history_group_joints": "Grupuj złącza w Historii",
"prefs.history_group_joints_note": "Dwa lub więcej złączy są pokazywane jako jeden przycisk z ich liczbą, a kliknięcie pokazuje każde z nich. Wyłączone pokazuje każde złącze.",
"prefs.history_origin_bodies": "Pokazuj początek układu i bryły na górze",
@@ -2650,6 +2766,8 @@
"prefs.history_sketches_in_order": "W kolejności",
"prefs.history_sketches_note": "Gdy ustawienie Szkice ma wartość „Pod swoją operacją”, Historia po lewej pokazuje każdy szkic o poziom głębiej, pod pierwszą operacją z niego zbudowaną, o ile panel jest dość szeroki na nazwy. Oś czasu na dole zawsze zachowuje kolejność tworzenia.",
"prefs.history_sketches_under_feature": "Pod swoją operacją",
"prefs.history_steps_component": "Kroki Historii według komponentu",
"prefs.history_steps_component_note": "Gdy komponent jest aktywny lub wybrany, elementy sterujące Historii przechodzą przez operacje tego komponentu, tak jak pokazuje je Historia na dole. Wyłączone: przechodzą przez wszystkie operacje projektu. Historia po lewej lub pod przeglądarką zawsze przechodzi przez wszystkie operacje.",
"prefs.hold_stop_snapping": "Przytrzymaj, aby wyłączyć przyciąganie",
"prefs.home_fit_size": "Rozmiar przy poleceniach Widok główny i Dopasuj",
"prefs.home_fit_size_note": "Jaką część widoku wypełnia model, gdy kadrują go polecenia Widok główny i Dopasuj, otwarcie projektu lub zmiana widoku w nawigatorze, niezależnie od ustawienia poniżej. 100%: wypełnia widok między panelami, kompasem i paskiem nawigacji.",
@@ -2665,6 +2783,8 @@
"prefs.invert_curved_roll_arrows_note": "Dwie zakrzywione strzałki w rogu kostki przechylają widok w płaszczyźnie ekranu. Włączone: strzałka zgodna z ruchem wskazówek zegara przechyla przeciwnie, a przeciwna – zgodnie. Wyłączone: każda strzałka przechyla widok w stronę, w którą jest zakrzywiona.",
"prefs.invert_navigator_arrows": "Odwróć strzałki obrotu kostki widoku",
"prefs.invert_navigator_arrows_note": "Strzałki wokół kostki obracają kamerę w kierunku, w który wskazują, jak w Fusion i Inventor: strzałka po lewej wskazuje w prawo i obraca kamerę w prawo. Wyłączone: strzałka obraca kostkę w stronę, po której się znajduje, więc lewa strzałka obraca kamerę w lewo. Zakrzywione strzałki przechylenia nie są zmieniane.",
"prefs.joint_drag_trackball": "Obracaj części na złączu kulistym jak trackball przy przeciąganiu",
"prefs.joint_drag_trackball_help": "Przeciąganie części trzymanej przez złącze kuliste obraca ją o najmniejszy obrót wokół złącza, jak toczenie kuli wielkości części. Wyłączone: chwycony punkt dokładnie podąża za wskaźnikiem, a część może się też wokół niego obracać.",
"prefs.keep_unsaved_also_asks": "Mimo to pytaj przy zamykaniu karty",
"prefs.keep_unsaved_also_asks_tip": "Gdy wyłączone, karta zamyka się bez okna przeglądarki, jeśli kopia każdego niezapisanego projektu jest aktualna.",
"prefs.keep_unsaved_in_browser": "Przechowuj niezapisaną pracę w tej przeglądarce",
@@ -2740,11 +2860,14 @@
"prefs.not_on_gpu": "niedostępne na tym GPU",
"prefs.on": "Włączone",
"prefs.opaque": "Nieprzezroczysta",
"prefs.operations_panel": "Pokazuj trwające zadania w widoku",
"prefs.operations_panel_note": "Długie zadania (otwieranie pliku, obliczanie operacji, polecenie agenta) pojawiają się w prawym dolnym rogu widoku z postępem i przyciskiem, który je anuluje. Wyłączone: komunikat Obliczanie, jak dotąd.",
"prefs.orbit_snap": "Przytrzymaj Shift podczas obracania widoku, aby przyciągnąć do osi.",
"prefs.orientation": "Orientacja",
"prefs.origin_plane_names": "Nazwy płaszczyzn początku układu",
"prefs.origin_plane_names_note": "Nazwy pokazywane w przeglądarce, oknach dialogowych i listach. {top} to XY, {front} to XZ, a {right} to YZ, przy osi Z w górę. Polecenia, skrypty i zapisane projekty zachowują XY, XZ i YZ.",
"prefs.overall_speed": "Szybkość ogólna",
"prefs.panel_layout.recovery": "Nie udało się przywrócić zapisanych układów paneli. Zapisane dane są zachowane; układ Klasyczny pozostaje dostępny.",
"prefs.parameter_list_rows": "Wiersze listy parametrów",
"prefs.parameter_list_rows_tip": "Maksymalna liczba parametrów, które strzałka w dół wyświetla w pustym polu wyrażenia.",
"prefs.persistent_ribbon_pressed": "Wyróżniaj aktywne polecenia wstążki",
@@ -2806,6 +2929,8 @@
"prefs.remember_dialog_values.help": "Domyślnie wyłączone. Gdy włączone, po udanym poleceniu jego bezpieczne wartości i opcje są używane ponownie przy następnym otwarciu tego samego polecenia, aż do zamknięcia SolidCraft.",
"prefs.render_legacy_image_defaults": "Używaj poprzednich ustawień domyślnych obrazu renderowania",
"prefs.render_legacy_image_defaults_help": "Zachowuje dotychczasowy ręczny tryb pracy: nowe ustawienia używają rasteryzacji CPU, śledzenie promieni na CPU zachowuje stałe próbkowanie, a wyrenderowane wyniki otwierasz ręcznie. Dopasowuje widok przy wejściu do środowiska Renderowanie, przełącza ujęcia bez przywracania ich kamer i używa poprzedniego kadrowania i nawigacji. Wyłącza automatyczny podgląd obrazu na żywo. Zapisane ustawienia pozostają bez zmian.",
"prefs.render_legacy_memory_admission": "Używaj poprzednich kontroli pamięci renderowania",
"prefs.render_legacy_memory_admission_help": "Dopuszcza natywne renderowania w stałych limitach pamięci współdzielonej i hosta, nawet gdy ich oszacowanie przekracza dostępną pamięć RAM. Limity urządzenia i kontrole przydziału nadal obowiązują.",
"prefs.render_previous_geometry_budget": "Używaj poprzedniego budżetu geometrii podglądu renderowania",
"prefs.render_previous_geometry_budget_help": "Przywraca poprzedni stały limit geometrii (512 MiB na 64-bitowym komputerze stacjonarnym, 128 MiB w innych przypadkach) zamiast budżetu zależnego od urządzenia.",
"prefs.reports_saved": "Zapisane: {n}",
@@ -2817,6 +2942,8 @@
"prefs.ribbon_tabs_only": "Pokazuj tylko karty wstążki",
"prefs.ribbon_tabs_only_note": "Wstążka zwija się do wiersza kart. Kliknięcie karty pokazuje jej panele nad widokiem, dopóki nie uruchomisz polecenia lub wskaźnik ich nie opuści. Włącza się to i wyłącza poleceniem Tylko karty wstążki na karcie Widok lub jego skrótem.",
"prefs.right_click": "Kliknięcie prawym",
"prefs.save_prompt_one_at_a_time": "Pytaj osobno o każdy niezapisany projekt przy zamykaniu kilku",
"prefs.save_prompt_one_at_a_time_help": "Pokazuje osobne pytanie o zapisanie dla każdego niezapisanego projektu zamiast wspólnej listy.",
"prefs.saving": "Autozapis i odzyskiwanie",
"prefs.scale_note": "Także Ctrl + / Ctrl − w oknie. Przyciski mają 28 px przy 100%.",
"prefs.scale_text": "Skala i tekst",
@@ -2832,6 +2959,8 @@
"prefs.select_other_hidden": "Zaznaczaj ukrytą geometrię poleceniem Wybierz inny",
"prefs.select_other_hidden_help": "Włączone: uwzględnia ukryte bryły, szkice i zasłonięte krawędzie w pobliżu wskaźnika. Wyłączone: używa pierwotnej listy widocznej geometrii pod wskaźnikiem.",
"prefs.selecting": "Zaznaczanie",
"prefs.shared_panel_layout": "Używaj wspólnego układu paneli",
"prefs.shared_panel_layout_help": "Pokazuje istniejące panele Przeglądarka, Historia i Asystent jako przesuwalne panele. Zmiana działa po zastosowaniu preferencji.",
"prefs.shift": "Shift",
"prefs.shift_add_ctrl_toggle": "Shift tylko dodaje; Ctrl / Cmd przełącza",
"prefs.shift_add_note": "Włączone: Shift dodaje, Ctrl / Cmd przełącza. Wyłączone (domyślnie): oba przełączają.",
@@ -2856,20 +2985,28 @@
"prefs.sketch.drawing": "Rysowanie",
"prefs.sketch.ellipse_axes": "Rysuj elipsy z osiami",
"prefs.sketch.ellipse_axes_note": "Oś wielka i mała jako linie konstrukcyjne, aby można było wymiarować półosie i osie. Wyłączone: sama elipsa, a D na niej daje promień małej półosi, gdziekolwiek przeciągnąć wartość, rysowany i oznaczony R jak dotąd.",
"prefs.sketch.escape_twice_finishes": "Podwójne Esc kończy szkic",
"prefs.sketch.escape_twice_finishes_note": "Dwa szybkie naciśnięcia Esc kończą szkic. Wyłączone: Esc tylko anuluje narzędzie lub zaznaczenie, a szkic kończy polecenie Zakończ szkic.",
"prefs.sketch.exit_keep": "Zachowaj",
"prefs.sketch.exit_previous": "Wróć do poprzedniego widoku",
"prefs.sketch.exit_unless_rotated": "Chyba że obrócono",
"prefs.sketch.faces_edges": "Ściany i krawędzie",
"prefs.sketch.hide_glyphs": "Ukrywaj symbole przy oddaleniu",
"prefs.sketch.marks": "Oznaczenia w szkicu",
"prefs.sketch.max_points": "Największy szkic",
"prefs.sketch.max_points_note": "Najwięcej punktów lub krzywych w jednym szkicu: domyślnie 20000, maksymalnie 200000. Wstaw SVG i Wstaw DXF odrzucają większy rysunek. Duży szkic wolniej się rozwiązuje i rysuje.",
"prefs.sketch.max_points_unit": "punktów",
"prefs.sketch.note": "Rysowanie, przyciąganie i zaznaczanie.",
"prefs.sketch.opening_closing": "Otwieranie i zamykanie",
"prefs.sketch.prefer_free_points": "Preferuj ruchome punkty tam, gdzie się nakładają",
"prefs.sketch.project_options": "Najpierw pytaj o opcje rzutowania",
"prefs.sketch.recent_snap": "Przyciągaj do przecięcia dwóch linii pomocniczych",
"prefs.sketch.select_3d_curve_points": "Zaznaczaj punkty na krzywych szkicu 3D",
"prefs.sketch.select_3d_curve_points_help": "Pokazuje punkty na narysowanych krzywych szkicu 3D i pozwala je zaznaczać jako odniesienia konstrukcyjne.",
"prefs.sketch.select_other_hidden": "Wybierz inny sięga do ukrytej geometrii",
"prefs.sketch.shortcut_cycles_modes": "Ponowny skrót przełącza tryb",
"prefs.sketch.shortcut_cycles_modes_tip": "Wyłączone: uruchamia narzędzie od nowa.",
"prefs.sketch.snap_to_visible_grid": "Przyciągaj do widocznych przecięć siatki",
"prefs.sketch.snapping_guides": "Przyciąganie i linie pomocnicze",
"prefs.sketch.state_icons": "Ikony stanu",
"prefs.sketch.tracking_guides": "Linie pomocnicze do środków i punktów środkowych",
@@ -2897,6 +3034,7 @@
"prefs.sketch_selection": "Szkic i zaznaczanie",
"prefs.sketch_selection_heading": "Szkic i zaznaczanie",
"prefs.sketch_selection_note": "Jak działa przyciąganie w szkicach i jak kliknięcia zaznaczają.",
"prefs.sketch_snap_to_visible_grid_note": "Punkt w szkicu przyciąga się do przecięć linii rysowanych przez siatkę, gdy wskaźnik jest blisko takiego przecięcia, a w przeciwnym razie do gęstszej siatki między nimi. Wyłączone: punkt przyciąga się tylko do gęstszej siatki, jak dotąd.",
"prefs.sketch_state_icons": "Ikony szkiców w kolorze ich stanu",
"prefs.sketch_state_icons_note": "W Historii i przeglądarce ikona szkicu przyjmuje kolor jego stanu (niedostatecznie związany, w pełni związany lub w konflikcie), a podpowiedź podaje ten stan. Wyłączone: każdy szkic ma tę samą ikonę.",
"prefs.sketch_tab_always": "Zawsze pokazuj kartę Szkic",
@@ -3041,7 +3179,7 @@
"refused.face_freeform": "Jedna z jej ścian o dowolnym kształcie nie nadaje się do modelowania.",
"refused.face_rounded": "Jedna z jej zaokrąglonych ścian nie nadaje się do modelowania.",
"refused.faces_apart": "Jej ściany nie stykają się dość dokładnie, by nadawała się do modelowania.",
"refused.intersect": "Nie udało się przyciąć z {body}.",
"refused.intersect": "Nie udało się wyznaczyć przecięcia z {body}.",
"refused.join": "Nie udało się połączyć z {body}.",
"refused.repair": "Napraw część",
"refused.show_face": "Pokaż ścianę",
@@ -3198,8 +3336,10 @@
"render.category_paint": "Farba",
"render.category_plastic": "Tworzywo",
"render.category_wood": "Drewno",
"render.caustics_area_lights": "Kaustyka od świateł powierzchniowych",
"render.caustics_depth": "Głębokość ścieżki kaustyki",
"render.caustics_enable": "Kaustyka szkła od świateł punktowych i kierunkowych",
"render.caustics_environment": "Kaustyka od otoczenia",
"render.caustics_help": "Liczba fotonów decyduje o szumie i nakładzie pracy. Promień zbierania zamienia ostre szczegóły na gładsze oszacowanie. CPU wymaga oświetlenia pośredniego.",
"render.caustics_path_required": "Kaustyka na CPU wymaga oświetlenia pośredniego.",
"render.caustics_photons": "Fotony",
@@ -3416,6 +3556,12 @@
"render.cpu_subsurface_denoise_reason": "Rozpraszanie podpowierzchniowe na CPU wymaga wyłączonego odszumiania. Twoje ustawienia zostają zachowane.",
"render.cpu_subsurface_help": "Rozpraszanie zmienia kierunek światła wewnątrz zamkniętego szkła. Wartości dodatnie wymagają oświetlenia pośredniego i wyłączonego odszumiania. Współczynnik to liczba zderzeń na mm; 0 nie dodaje rozpraszania. Dodatnie przesunięcie fazy faworyzuje kierunki do przodu, ujemne do tyłu.",
"render.cpu_summary": "Śledzenie promieni na CPU · odbicia · HDR · światła kierunkowe i punktowe",
"render.cpu_threads": "Wątki CPU",
"render.cpu_threads_auto": "Automatycznie",
"render.cpu_threads_custom": "Własne",
"render.cpu_threads_help": "Automatycznie używa dostępnych wątków CPU. Jeden wątek renderuje sekwencyjnie. Więcej wątków może wymagać więcej pamięci.",
"render.cpu_threads_single": "Jeden",
"render.cpu_threads_web": "Renderowanie w przeglądarce internetowej używa jednego wątku kooperacyjnego. Zapisany wybór wątków dla wersji desktopowej zostaje zachowany.",
"render.cpu_transparent_help": "Przezroczysty PNG usuwa tło i zachowuje pokrycie krawędzi. Nieprzezroczystość brył i ścian musi pozostać na 100%.",
"render.cpu_volume_enable_path": "Włącz oświetlenie pośrednie",
"render.cpu_volume_enable_path_plan": "Śledzenie promieni na CPU · głębokość ścieżki {depth} · próbki na piksel: {spp} · ziarno {seed} · śledzone cienie. Zachowuje przygotowaną atmosferę i budżet obrazu.",
@@ -3424,6 +3570,40 @@
"render.cpu_volume_method_incompatible": "Tych ustawień świateł, próbkowania lub atmosfery nie można przenieść do tej metody. Twoje ustawienia zostają zachowane.",
"render.cpu_volume_path_required": "Włącz oświetlenie pośrednie, aby użyć atmosfery CPU.",
"render.cpu_warm_cool": "Ciepłe i chłodne",
"render.denoise_class_background": "Pominięte: tło",
"render.denoise_class_coverage": "Pominięte: częściowe pokrycie",
"render.denoise_class_degenerate": "Pominięte: zdegenerowana ścieżka",
"render.denoise_class_depth": "Pominięte: limit głębokości ścieżki",
"render.denoise_class_emitter": "Pominięte: źródło światła",
"render.denoise_class_exit": "Pominięte: niezgodne wyjście",
"render.denoise_class_identity": "Pominięte: limit identyfikatorów bufora pomocniczego",
"render.denoise_class_lens": "Pominięte: głębia ostrości",
"render.denoise_class_material": "Pominięte: nieobsługiwany materiał",
"render.denoise_class_nested": "Pominięte: zagnieżdżony ośrodek",
"render.denoise_class_optical": "Kwalifikujące się: powierzchnia po załamaniu",
"render.denoise_class_reflection": "Pominięte: odbijający punkt końcowy",
"render.denoise_class_surface": "Kwalifikujące się: powierzchnia",
"render.denoise_class_tir": "Pominięte: całkowite wewnętrzne odbicie",
"render.denoise_class_transmission": "Pominięte: dalsze przepuszczanie",
"render.denoise_class_unavailable": "Pominięte: brak bufora pomocniczego",
"render.denoise_class_volume": "Pominięte: rozpraszanie objętościowe",
"render.denoise_diagnostics_counts": "Kwalifikujące się: {eligible} · Pominięte: {skipped}",
"render.denoise_diagnostics_enable": "Zbieraj diagnostykę odszumiania",
"render.denoise_diagnostics_grid": "Liczby pikseli przy rozmiarze wewnętrznym {width} × {height}.",
"render.denoise_diagnostics_help": "Zbiera liczby kwalifikujących się i pominiętych pikseli, nawet gdy odszumianie jest wyłączone. Liczby dotyczą wewnętrznego rozmiaru renderowania; maska używa rozmiaru wyjściowego.",
"render.denoise_diagnostics_title": "Diagnostyka odszumiania",
"render.denoise_export_mask": "Eksportuj maskę PNG",
"render.denoise_firefly": "Tłum pojedyncze jasne piksele",
"render.denoise_format_mask": "Maska PNG",
"render.denoise_mask_copy_help": "Wyeksportuj maskę odszumiania jako PNG albo wróć do wyrenderowanego obrazu, aby go skopiować.",
"render.denoise_mask_unavailable": "Włącz diagnostykę odszumiania i wyrenderuj obraz, aby obejrzeć jego maskę.",
"render.denoise_optical_filter": "Filtruj lustra i szkło",
"render.denoise_optical_filter_help": "Filtruje kwalifikujące się światło odbite i przepuszczone. Wyłącz, aby odszumiać tylko nieprzezroczyste powierzchnie.",
"render.denoise_passes": "Przebiegi filtra",
"render.denoise_settings_help": "Więcej przebiegów wygładza większy obszar. Siła określa, jak mocno filtruje każdy przebieg. Nieobsługiwane ścieżki optyczne pozostają niefiltrowane.",
"render.denoise_strength": "Siła odszumiania",
"render.denoise_view_image": "Wyrenderowany obraz",
"render.denoise_view_mask": "Maska odszumiania",
"render.diag_bodies": "Widoczne bryły",
"render.diag_job_details": "Uchwycone szczegóły zadania",
"render.diag_last_check": "Ostatnia kontrola wstępna",
@@ -3556,6 +3736,7 @@
"render.error_pbr_opacity": "nieprzezroczysty PBR wymaga nieprzezroczystości 1",
"render.error_physical_glass": "wybrany przepis nie jest fizycznym szkłem",
"render.error_preview_device": "podgląd produktu wymaga zainstalowanego urządzenia z buforem głębi",
"render.error_product_placement": "nieprawidłowe umiejscowienie produktu",
"render.error_product_triangle": "nieprawidłowy trójkąt produktu",
"render.error_provisional_identity": "nieprawidłowy tymczasowy identyfikator renderowania lub liczba próbek",
"render.error_quality_bounds": "odwzorowanie jakości przekracza natywne granice",
@@ -3629,6 +3810,7 @@
"render.glass_model_help": "Chropowatość szkła rozprasza przepuszczane promienie. Dyspersja i barwa warstwy używają trzech pasm długości fal. Chropowate arkusze wymagają wyłączenia prostego przepuszczania cieni.",
"render.glass_roughness": "Chropowatość szkła",
"render.gpu_depth": "Maksymalna głębokość ścieżki",
"render.gpu_job_shader_wait": "Przygotowanie shaderów może potrwać kilka minut",
"render.gpu_options_help": "Próbki, głębokość, ziarno i ekspozycja są zapisywane z tymi ustawieniami. Zapisywana jest tylko aktywna metoda obrazu.",
"render.gpu_path_crashed": "SolidCraft zamknął się podczas ostatniego przygotowywania na tej karcie graficznej, najpewniej w sterowniku grafiki, więc nie będzie tego robić ponownie sam. Wybierz zamiast tego śledzenie promieni na CPU albo spróbuj ponownie (aktualizacja sterownika też powoduje ponowną próbę).",
"render.gpu_path_failed": "Śledzenie ścieżek na GPU jest niedostępne",
@@ -3638,6 +3820,10 @@
"render.gpu_path_retry": "Spróbuj ponownie",
"render.gpu_seed": "Ziarno losowania",
"render.gpu_unavailable": "Śledzenie ścieżek na GPU nie jest gotowe na tym urządzeniu.",
"render.graphics_api_restart_vulkan": "Uruchom ponownie z Vulkanem",
"render.graphics_api_restart_warning": "Zmiana API grafiki uruchamia ponownie SolidCraft i prosi o zapisanie niezapisanych projektów. Jeśli nowe API nie otworzy okna, przy następnym uruchomieniu przywracany jest poprzedni wybór.",
"render.graphics_api_settings": "Ustawienia grafiki",
"render.graphics_api_vulkan_required": "Śledzenie ścieżek na GPU wymaga w tej sesji Vulkana. Uruchom ponownie z Vulkanem albo dalej używaj metody obrazu na CPU.",
"render.ground_apply": "Dopasuj i zapisz podłoże",
"render.ground_gap": "Jawny odstęp",
"render.ground_linear_rgb": "Rozproszony współczynnik odbicia · liniowe RGB",
@@ -3977,6 +4163,8 @@
"render.material_transient": "Tymczasowe nadpisania optyczne dla tej karty dokumentu. Nie są zapisywane w projekcie, ustawieniach renderowania ani bibliotece materiałów.",
"render.material_v_up": "Oś V tekstury skierowana w obrazie w górę",
"render.material_weight": "Waga lakieru (0–1)",
"render.metal_energy_compensation": "Odzyskuj odbicie chropowatego metalu",
"render.metal_energy_compensation_help": "Kompensuje światło tracone między mikrościankami chropowatego metalu. Renderowanie na CPU wymaga oświetlenia pośredniego.",
"render.method_cpu": "Śledzenie promieni na CPU",
"render.method_gpu": "Śledzenie ścieżek na GPU",
"render.method_limit_raster": "Rasteryzacja używa stałego, nieprzezroczystego podglądu studyjnego. Aby użyć obsługiwanych ustawień optycznych, wybierz jawną metodę śledzenia promieni.",
@@ -4105,6 +4293,8 @@
"render.quality_denoise_on": "Redukcja szumu: wł.",
"render.quality_draft": "Robocza",
"render.quality_high": "Wysoka",
"render.quality_legacy_samples": "Zapisana wartość: {samples} spp. Nowo wybierane wartości są ograniczone do {maximum} spp.",
"render.quality_preset_limit": "To ustawienie wstępne przekracza limit wyboru 16384 spp. Wybierz liczbę próbek do 16384.",
"render.quality_projection_help": "Ten przegląd zmienia tylko rozdzielczość, budżet próbek, głębokość ścieżki, ziarno i redukcję szumu. Pozostałe ustawienia zostają zachowane; budżet nie gwarantuje zbieżności.",
"render.quality_review": "Przejrzyj budżet próbkowania",
"render.quality_summary": "{width} × {height} · próbki: {samples} · głębokość {depth} · ziarno {seed} · {denoise}",
@@ -4252,6 +4442,7 @@
"render.show_shots_label": "Pokaż pasek ujęć",
"render.size_4k": "4K",
"render.size_4k_uhd": "4K UHD",
"render.size_8k_uhd": "8K UHD",
"render.size_custom": "Niestandardowy…",
"render.size_full_hd": "Full HD",
"render.size_hd": "HD",
@@ -4414,6 +4605,11 @@
"render.subsurface_enable": "Rozpraszanie wewnętrzne metodą błądzenia losowego",
"render.subsurface_help": "Tylko zamknięty dielektryk. Rozpraszanie i absorpcja RGB to osobne współczynniki na mm. Ścieżki rozpraszają się wewnątrz rzeczywistej geometrii, a potem załamują lub odbijają na granicy. Wyłącz proste przepuszczanie cieni, odszumianie powierzchni i objętość zewnętrzną; użyj jednej warstwy Fresnela. To nie jest shader dyfuzji, siatka gęstości ani zmierzony materiał. Gęste ośrodki mogą wymagać więcej próbek i większej głębokości ścieżki.",
"render.subsurface_rate": "Częstość zderzeń przy rozpraszaniu",
"render.supersampling": "Supersampling",
"render.supersampling_dimensions": "Renderuje {width} × {height} i zmniejsza do {output_width} × {output_height}. Używa czterokrotnie więcej pikseli.",
"render.supersampling_help": "Renderuje w dwukrotnej szerokości i wysokości, a potem zmniejsza w świetle liniowym. Rozmiar wyjściowy pozostaje bez zmian.",
"render.supersampling_off": "Wył.",
"render.supersampling_x2": "2×",
"render.surface_import.author": "Autor",
"render.surface_import.busy": "Importowanie obrazu…",
"render.surface_import.complete": "Wybierz obraz i wpisz jego autora, źródło i licencję.",
@@ -4427,6 +4623,8 @@
"render.surface_import.saved_cpu": "Zapisz mapy, aby używać ich przy renderowaniu na CPU.",
"render.surface_import.source": "Źródło",
"render.surface_import.title": "Importuj obraz",
"render.texture_filtering": "Filtruj szczegóły tekstur",
"render.texture_filtering_help": "Używa poziomów mipmap i filtrowania anizotropowego, aby zmniejszyć aliasing tekstur przy pomniejszeniu. Zużywa więcej pamięci; wyłączenie zachowuje poprzednie filtrowanie.",
"render.tone_aces_fitted": "ACES (dopasowane)",
"render.tone_reinhard": "Reinhard",
"render.transmission_scope": "Szkło GPU obsługuje certyfikowane rozłączne ośrodki. Nieograniczone zagnieżdżanie ośrodków i bezszumowy estymator przepuszczania nie są dostępne.",
@@ -4627,6 +4825,8 @@
"sketch.hint.spline3d": "Splajn 3D: klikaj punkty dopasowania w 3D; prawy przycisk lub Esc kończy",
"sketch.hint.trim": "Przytnij: kliknij fragment krzywej do usunięcia lub przeciągnij przez fragmenty do usunięcia",
"sketch.make_selection": "Zamień zaznaczenie na {linetype}",
"sketch.move_released": "Przesunięto. Zwolnione wiązania z geometrią, która została na miejscu ({count}): {names}.",
"sketch.move_released_one": "Przesunięto. Zwolniono {count} wiązanie z geometrią, która została na miejscu ({names}).",
"sketch.open_ends": "Otwarte: {count}",
"sketch.status_conflict": "wiązania w konflikcie",
"sketch.status_fully_constrained": "w pełni związany",
@@ -4861,6 +5061,7 @@
"ui.agent_chat_allow_once": "Zezwól raz",
"ui.agent_chat_allowed": "Zezwolono",
"ui.agent_chat_answered": "Odpowiedź: {option}",
"ui.agent_chat_answered_in": "Odpowiedź po {time}",
"ui.agent_chat_approval": "Zezwolić agentowi na to działanie?",
"ui.agent_chat_attach_selection": "Dołącz zaznaczenie",
"ui.agent_chat_attach_selection_tip": "Wyślij z wiadomością to, co zaznaczono w modelu (ściany, krawędzie, operacje, bryły), aby agent wiedział, o co chodzi",
@@ -4889,6 +5090,7 @@
"ui.agent_chat_ctx_bodies": "Bryły",
"ui.agent_chat_ctx_elements": "Elementy",
"ui.agent_chat_ctx_features": "Operacje",
"ui.agent_chat_ctx_gone": "Nie ma już w projekcie: zostało zmienione lub usunięte.",
"ui.agent_chat_ctx_more": "+{n} więcej",
"ui.agent_chat_ctx_other": "Inne",
"ui.agent_chat_custom_heading": "Własny model",
@@ -4902,6 +5104,7 @@
"ui.agent_chat_dock_right": "Zadokuj po prawej",
"ui.agent_chat_drop_hint": "Upuść, aby dołączyć do następnej wiadomości",
"ui.agent_chat_edit": "Edytuj",
"ui.agent_chat_ended_after": "Zakończono po {time}",
"ui.agent_chat_exited": "Agent się zatrzymał.",
"ui.agent_chat_failed_start": "Nie udało się uruchomić agenta: {error}",
"ui.agent_chat_file_cad_not_read": "{name}: plik CAD jest importowany do projektu, a nie odczytywany",
@@ -4939,6 +5142,7 @@
"ui.agent_chat_model_no_tools": "{model} nie może używać narzędzi SolidCraft, więc może tylko rozmawiać. Wybierz model obsługujący narzędzia.",
"ui.agent_chat_model_rejected": "Agent nie przyjął modelu {model}: {error}",
"ui.agent_chat_model_search": "Szukaj modeli…",
"ui.agent_chat_model_search_or_id": "Szukaj lub wpisz dowolny identyfikator modelu…",
"ui.agent_chat_model_tip": "Model używany przez agenta. Twój wybór jest zapamiętywany dla tego agenta.",
"ui.agent_chat_models_failed": "Nie udało się pobrać listy modeli: {error}",
"ui.agent_chat_models_heading": "Dostępne modele",
@@ -4950,6 +5154,7 @@
"ui.agent_chat_next_picture": "Następny obraz",
"ui.agent_chat_no_cli": "Nie znaleziono solidcraft-cli obok aplikacji, więc agent nie ma dostępu do projektu.",
"ui.agent_chat_no_control": "Punkt końcowy czatu z agentem się nie uruchomił, więc agent nie ma dostępu do projektu.",
"ui.agent_chat_no_design_hint": "Agent działa bez narzędzi SolidCraft, więc nie może odczytać ani zmienić projektu: nie znaleziono solidcraft-cli obok aplikacji. Zainstaluj SolidCraft razem z solidcraft-cli (w systemie Windows: instalator MSI lub przenośny zip) albo ustaw w SOLIDCRAFT_CLI jego ścieżkę, a potem połącz ponownie.",
"ui.agent_chat_no_images": "Ten agent nie przyjmuje obrazów",
"ui.agent_chat_no_picture": "W schowku nie ma obrazu",
"ui.agent_chat_no_tool_call": "Model nie użył narzędzi SolidCraft, więc projekt nie został zmieniony. Wybierz model obsługujący wywoływanie narzędzi; zobacz Modele lokalne w przewodniku czatu z agentem.",
@@ -4997,16 +5202,25 @@
"ui.agent_chat_side": "Strona czatu z agentem",
"ui.agent_chat_signed_in": "zalogowano",
"ui.agent_chat_snap_none_found": "Nie znaleziono agenta programistycznego. Pakiet Snap działa w izolacji, więc nie widzi agentów programistycznych zainstalowanych na tym komputerze i nie może ich uruchomić. Aby z nich korzystać, zainstaluj SolidCraft z pakietu AppImage, .deb lub .rpm. Agent korzystający z API zgodnego z OpenAI działa także w pakiecie Snap: skonfiguruj go przyciskiem Dodaj agenta.",
"ui.agent_chat_span_h": "{h} h {min} min",
"ui.agent_chat_span_min": "{n} min",
"ui.agent_chat_span_s": "{n} s",
"ui.agent_chat_state_auth": "Wymagane logowanie",
"ui.agent_chat_state_busy": "Pracuje…",
"ui.agent_chat_state_exited": "Agent zatrzymany",
"ui.agent_chat_state_failed": "Połączenie nieudane",
"ui.agent_chat_state_no_design": "Brak dostępu do projektu",
"ui.agent_chat_state_off": "Brak połączenia",
"ui.agent_chat_state_ready": "Gotowy",
"ui.agent_chat_state_starting": "Uruchamianie…",
"ui.agent_chat_stop": "Zatrzymaj",
"ui.agent_chat_stop_tip": "Zatrzymaj agenta i wszystko, co uruchomił",
"ui.agent_chat_stop_turn_tip": "Zatrzymaj agenta (Esc). Wiadomości w kolejce pozostają w kolejce.",
"ui.agent_chat_thinking_for": "Myśli od {time}",
"ui.agent_chat_tool_running_for": "działa od {time}",
"ui.agent_chat_tools_not_started": "Agent działa bez narzędzi SolidCraft, więc nie może odczytać ani zmienić projektu: znaleziono solidcraft-cli, ale jego narzędzia się nie uruchomiły (przyczynę podaje błąd w czacie). Połącz ponownie; jeśli to się powtarza, zainstaluj ponownie SolidCraft.",
"ui.agent_chat_transcript_assistant": "Asystent:",
"ui.agent_chat_transcript_user": "Użytkownik:",
"ui.agent_chat_turn_limit": "Zmiany na wiadomość",
"ui.agent_chat_turn_unlimited": "Bez limitu",
"ui.agent_chat_undo_changes": "Cofnij te zmiany",
@@ -5214,7 +5428,7 @@
"ui.comb_length_tip": "Długość zębów: wielokrotność długości automatycznej, przy której najdłuższy ząb ma jedną piątą rozmiaru pokazanych krzywych.",
"ui.comb_teeth": "Zęby",
"ui.comb_teeth_tip": "Liczba zębów każdego grzebienia, rozmieszczonych równo wzdłuż krzywej. Więcej zębów pokazuje dokładniej, jak zmienia się krzywizna.",
"ui.combine": "Kombinacja",
"ui.combine": "Operacja logiczna",
"ui.coming_later": "wkrótce",
"ui.command_done": "Wykonano: {command}",
"ui.command_palette_hint": "Wpisz polecenie, np. {example} – lub naciśnij S, aby wyszukać",
@@ -5225,14 +5439,24 @@
"ui.conic_curve": "Krzywa stożkowa",
"ui.constant": "Stały",
"ui.constraints_conflict": "Konflikt wiązań",
"ui.construct_auto.chosen": "Wybrano na podstawie wskazanych elementów. Wskaż inne odniesienie lub usuń jedno, aby zmienić metodę.",
"ui.construct_auto.no_fit": "Wskaż inne odniesienia: z tych nie da się zbudować elementu żadną metodą.",
"ui.construct_auto.pick": "Wskaż, z czego budować: metoda wynika z wybranych elementów.",
"ui.construct_auto.refs": "Odniesienia",
"ui.construct_auto.select": "Najpierw zaznacz odniesienia, z których budować",
"ui.construct_axes": "Osie",
"ui.construct_planes": "Płaszczyzny",
"ui.construct_points": "Punkty",
"ui.construction": "Konstrukcyjne",
"ui.construction.angle_hint": "Linia konstrukcyjna: kliknij punkt, a potem wpisz kąt lub wskaż kierunek",
"ui.construction.axis_hint": "Linia konstrukcyjna: kliknij punkt lub wpisz jego odległość od osi szkicu",
"ui.construction.bisector_hint": "Dwusieczna: wybierz dwie przecinające się proste linie, a potem żądane rozwiązanie",
"ui.construction.common_hint": "Wspólna styczna: wybierz dwa okręgi/łuki, a potem żądane rozwiązanie",
"ui.construction.contact_hint": "Styczna: wybierz okrąg/łuk lub otwarty splajn/krzywą stożkową, a potem punkt styczności (krzywe dowolne: punkty końcowe)",
"ui.construction.external_hint": "Styczna: wybierz okrąg lub łuk, punkt zewnętrzny, a potem żądane rozwiązanie",
"ui.construction.free_hint": "Linia konstrukcyjna: kliknij dwa punkty; prawy przycisk zaczyna od nowa",
"ui.construction.normal_needs_length": "Linia nieskończona jest zawsze konstrukcyjna: wpisz długość, aby narysować zwykłą linię",
"ui.construction.parallel_hint": "Linia konstrukcyjna: wybierz prostą linię, a potem kliknij lub wpisz odsunięcie; prawy przycisk zmienia odniesienie",
"ui.construction.point_hint": "Linia konstrukcyjna: kliknij punkt, przez który ma przechodzić",
"ui.construction.reference_hint": "Linia konstrukcyjna: wybierz prostą linię, a potem punkt; prawy przycisk zmienia odniesienie",
"ui.construction.ribbon_label": "Linie pom.",
@@ -5375,6 +5599,17 @@
"ui.ellipse": "Elipsa",
"ui.emboss_wrap_note": "Ortogonalne profile liniowe styczne do jednego obsługiwanego walca owijają się automatycznie. Wybierz ściankę, aby wskazać cel.",
"ui.end": "Koniec",
"ui.end_cap.chamfer": "Faza naroży",
"ui.end_cap.direction": "Kierunek",
"ui.end_cap.faces": "Ściany końcowe",
"ui.end_cap.inset": "Wpuszczenie",
"ui.end_cap.inset_kind": "Wpuszczenie według",
"ui.end_cap.inset_ratio": "Współczynnik wpuszczenia",
"ui.end_cap.inward": "Do wewnątrz",
"ui.end_cap.length": "Długość",
"ui.end_cap.outward": "Na zewnątrz",
"ui.end_cap.ratio": "Stosunek do grubości",
"ui.end_cap_title": "ZAŚLEPKA",
"ui.end_radius": "Promień końcowy",
"ui.end_scale": "Skala końcowa",
"ui.ends": "Końce",
@@ -5395,7 +5630,10 @@
"ui.explode_needs_two_components": "Widok rozstrzelony odsuwa od siebie komponenty, więc projekt potrzebuje co najmniej dwóch (Nowy komponent, Wstaw projekt lub Utwórz komponenty z brył)",
"ui.exportDxf": "Eksportuj DXF…",
"ui.export_ellipsis_keymap": "Eksportuj…",
"ui.export_glb": "Eksportuj GLB",
"ui.export_keymap_tip": "Klawisze, ulubione z okna S i pasek narzędzi, jako plik",
"ui.export_selects_only_body": "Zaznaczaj jedyną bryłę przy eksporcie siatki",
"ui.export_selects_only_body_tip": "Eksportuj STL, Eksportuj 3MF i Wyślij do slicera zaczynają z zaznaczoną bryłą projektu, gdy jest ona jedyna.",
"ui.extend": "Wydłuż",
"ui.extent": "Zakres",
"ui.extent_type": "Typ zakresu",
@@ -5455,6 +5693,8 @@
"ui.finest": "Najdokładniejsza",
"ui.finish_sketch": "Zakończ szkic",
"ui.first_dimension_scales": "Pierwszy wymiar skaluje rysunek",
"ui.first_dimension_scales_about_origin": "Skaluj względem początku układu szkicu",
"ui.first_dimension_scales_about_origin_tip": "Włączone: rysunek zawsze rośnie lub maleje względem początku układu szkicu, więc kształt narysowany z dala od niego się przesuwa. Wyłączone: rysunek, którego nic nie wiąże z początkiem układu, skaluje się względem własnego środka i zostaje tam, gdzie go narysowano.",
"ui.first_dimension_scales_tip": "Włączone: pierwszy wymiar długości w szkicu skaluje mierzony rysunek, więc jego kształt pozostaje ten sam. Wyłączone: wymiar zmienia tylko to, co mierzy.",
"ui.fit": "Dopasuj",
"ui.fix_unfix": "Ustal/zwolnij",
@@ -5485,7 +5725,7 @@
"ui.heading_chamfer": "FAZA",
"ui.heading_circular_pattern": "SZYK KOŁOWY",
"ui.heading_coil": "SPIRALA",
"ui.heading_combine": "KOMBINACJA",
"ui.heading_combine": "OPERACJA LOGICZNA",
"ui.heading_cone": "STOŻEK",
"ui.heading_configure": "KONFIGURACJA",
"ui.heading_constraints": "WIĄZANIA",
@@ -5509,7 +5749,7 @@
"ui.heading_home": "WIDOK GŁÓWNY",
"ui.heading_insert": "WSTAW",
"ui.heading_inspect": "SPRAWDŹ",
"ui.heading_interference": "INTERFERENCJA",
"ui.heading_interference": "KOLIZJA",
"ui.heading_loft": "WYCIĄGNIĘCIE ZŁOŻONE",
"ui.heading_make": "WYTWARZANIE",
"ui.heading_measure": "POMIAR",
@@ -5529,6 +5769,7 @@
"ui.heading_plastic": "TWORZYWA",
"ui.heading_position": "POŁOŻENIE",
"ui.heading_prepare": "PRZYGOTOWANIE",
"ui.heading_press_pull": "WCIŚNIJ/WYCIĄGNIJ",
"ui.heading_rectangular_pattern": "SZYK PROSTOKĄTNY",
"ui.heading_revolve": "OBRÓT",
"ui.heading_right": "PRAWY",
@@ -5551,7 +5792,7 @@
"ui.heading_torus": "TORUS",
"ui.heading_utilities": "NARZĘDZIA",
"ui.heading_utility": "NARZĘDZIE",
"ui.heading_weldments": "SPAWANIE",
"ui.heading_weldments": "KONSTRUKCJE SPAWANE",
"ui.height": "Wysokość",
"ui.help": "Pomoc",
"ui.hide": "Ukryj",
@@ -5605,7 +5846,7 @@
"ui.insert_design.unsaved": "Niezapisane zmiany",
"ui.inspect": "Sprawdź",
"ui.instances": "Wystąpienia",
"ui.intersect": "Przytnij",
"ui.intersect": "Przecięcie",
"ui.intersection_curve_second_pick": "Krzywa przecięcia: kliknij drugą ścianę lub krzywą (prawy przycisk zaczyna od nowa)",
"ui.iso_4753": "ISO 4753",
"ui.isolate": "Izoluj",
@@ -5665,6 +5906,8 @@
"ui.mesh_format_3mf": "3MF",
"ui.mesh_format_3mf_tagged": "3MF ze znacznikami ścian",
"ui.mesh_format_stl": "STL",
"ui.mesh_import_origin": "Przenieś do początku układu",
"ui.mesh_import_origin_tip": "Wyśrodkowuje siatkę w X i Y, z podstawą na Z = 0. Gdy wyłączone, siatka zachowuje współrzędne zapisane w pliku.",
"ui.minor_radius": "Promień mniejszy",
"ui.mirror": "Lustro",
"ui.mirror_line": "Linia odbicia",
@@ -5769,6 +6012,42 @@
"ui.panel_host.surface": "Panel",
"ui.panel_host.tab": "Karta panelu",
"ui.panel_host.tabs": "Wszystkie karty",
"ui.panel_layout.applied": "Zastosowano {name} w środowisku {workspace}",
"ui.panel_layout.cancel": "Anuluj",
"ui.panel_layout.confirm_replace": "Zastąpić „{name}”?",
"ui.panel_layout.copy": "Kopiuj",
"ui.panel_layout.current": "Bieżący układ",
"ui.panel_layout.custom": "Własny",
"ui.panel_layout.custom_hint": "Panele przesunięto względem układu „{name}”.",
"ui.panel_layout.details": "Szczegóły",
"ui.panel_layout.layout_workspace": "Środowisko układu",
"ui.panel_layout.page_defaults_hint": "Resetuje ustawienia tej strony. Rozmieszczenie paneli i zapisane układy zostają zachowane.",
"ui.panel_layout.panel_layout": "Układ paneli",
"ui.panel_layout.panel_style": "Styl paneli",
"ui.panel_layout.replace": "Zastąp układ",
"ui.panel_layout.replace_current": "Zastąp bieżącym",
"ui.panel_layout.replace_short": "Zastąp",
"ui.panel_layout.reset": "Resetuj środowisko",
"ui.panel_layout.reset_hint": "Przywraca domyślne rozmieszczenie paneli w środowisku {workspace}. Zapisane układy zostają zachowane.",
"ui.panel_layout.reset_short": "Resetuj",
"ui.panel_layout.restore_failed": "Nie udało się przywrócić układu",
"ui.panel_layout.save": "Zapisz układ",
"ui.panel_layout.save_as": "Zapisz jako",
"ui.panel_layout.save_as_tile": "Zapisz jako…",
"ui.panel_layout.selected": "zaznaczony",
"ui.panel_layout.suggested_name": "Układ {n}",
"ui.panel_layout.try_again": "Spróbuj ponownie",
"ui.panel_layout.validation.builtin": "Wbudowanych układów nie można zmieniać",
"ui.panel_layout.validation.duplicate": "Ta nazwa jest już używana",
"ui.panel_layout.validation.empty": "Wpisz nazwę",
"ui.panel_layout.validation.full": "Zapisano 64 z 64. Wybierz jeden do zastąpienia.",
"ui.panel_layout.validation.invalid": "Użyj 1–64 znaków, bez znaków sterujących",
"ui.panel_layout.workspace.classic": "Klasyczny",
"ui.panel_layout.workspace.modelling": "Modelowanie",
"ui.panel_layout.workspace.render": "Renderowanie",
"ui.panel_layout.workspace.review": "Przegląd",
"ui.panel_layout.workspace.sketch": "Szkic",
"ui.panel_layout.your_layout": "twój układ",
"ui.parallel": "Równoległość",
"ui.parameters": "Parametry",
"ui.part_size_needs_a_part": "Zaznacz wstawioną część znormalizowaną (śrubę, nakrętkę, podkładkę lub kołek), aby zmienić jej rozmiar. Aby powiększyć lub pomniejszyć bryłę, użyj polecenia Skala.",
@@ -5791,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",
@@ -5834,8 +6113,11 @@
"ui.profiles": "Profile",
"ui.project": "Rzutuj",
"ui.project_face_edges_sketch": "Rzutuj krawędzie ściany do nowych szkiców",
"ui.project_references_construction": "Rzutuj odniesienia jako geometrię konstrukcyjną",
"ui.project_references_construction_note": "Rzutuj, Przecięcie, Dołącz geometrię 3D oraz krawędzie ściany nowego szkicu na ścianie tworzą geometrię konstrukcyjną: odniesienia do rysowania, pomijane w profilach. Pozostają połączone z modelem.",
"ui.properties": "Właściwości",
"ui.properties.copy_matrix": "Kopiuj macierz",
"ui.properties.copy_value": "Kliknij, aby skopiować",
"ui.properties.first_moments": "Momenty statyczne",
"ui.properties.inertia": "Moment bezwładności",
"ui.properties.inertia_note": "Względem środka masy, w osiach globalnych (g·{unit}²)",
@@ -5848,6 +6130,7 @@
"ui.properties.measuring_failed": "Pomiar zatrzymał się przed zakończeniem.",
"ui.properties.principal_axis": "Oś główna {number}",
"ui.properties.principal_moments": "Główne momenty bezwładności",
"ui.properties.value_copied": "Skopiowano wartość",
"ui.pull_direction": "Kierunek wyjmowania",
"ui.quality": "Jakość",
"ui.quantity": "Liczba",
@@ -5868,8 +6151,10 @@
"ui.relink_design_changed": "Złożenie zmieniło się podczas wyboru źródła. Uruchom ponownie polecenie Połącz ponownie ze źródłem.",
"ui.relink_done": "Ponownie połączono źródło dla wszystkich wystąpień.",
"ui.relink_not_linked": "Ten komponent nie jest połączony z projektem źródłowym.",
"ui.relink_one_done": "Ponownie połączono źródło dla tego wystąpienia.",
"ui.relink_select_one": "Najpierw zaznacz jeden połączony komponent lub jedno z jego wystąpień.",
"ui.relink_source_all": "Połącz ponownie ze źródłem (wszystkie wystąpienia)…",
"ui.relink_source_one": "Połącz ponownie ze źródłem…",
"ui.removeFromQuickAccess": "Usuń z szybkiego dostępu",
"ui.removeFromToolbar": "Usuń z paska narzędzi",
"ui.removeSection": "Usuń przekrój",
@@ -5878,6 +6163,8 @@
"ui.renderer": "Renderer",
"ui.repeat": "Powtórz",
"ui.repeat_nothing": "Nie ma jeszcze czego powtórzyć: najpierw uruchom narzędzie lub okno.",
"ui.replace_sketch.from_file": "Z pliku projektu…",
"ui.replace_sketch.no_sketches": "Ten projekt nie ma szkicu do pobrania",
"ui.replace_with_link_done": "Kopie są teraz umiejscowieniami połączonego projektu.",
"ui.report_a_bug": "Zgłoś błąd",
"ui.report_characters_left_one": "Pozostało znaków: {count}",
@@ -5963,6 +6250,16 @@
"ui.sketch_intersect_selection_filter": "Filtr zaznaczania",
"ui.sketch_keys_only": "Nie używaj klawiszy modelu w szkicach",
"ui.sketch_keys_only_tip": "Podczas edycji szkicu działają tylko klawisze szkicu i klawisze działające wszędzie; klawisz modelu, np. E lub F, nic nie robi, więc przypadkowe naciśnięcie nie zamknie rozbudowanego szkicu.",
"ui.sketch_links_added_curve": "Polecenie {command} dodało do szkicu 1 krzywą.",
"ui.sketch_links_added_curves": "Polecenie {command} dodało krzywe do szkicu ({count}).",
"ui.sketch_links_added_curves_and_points": "Polecenie {command} dodało do szkicu: {curves} i {points}.",
"ui.sketch_links_added_point": "Polecenie {command} dodało do szkicu 1 punkt.",
"ui.sketch_links_added_points": "Polecenie {command} dodało punkty do szkicu ({count}).",
"ui.sketch_links_curve_count": "krzywe ({count})",
"ui.sketch_links_curve_count_one": "1 krzywa",
"ui.sketch_links_hidden": "Wynik jest ukryty, bo opcja {option} jest wyłączona w palecie szkicu.",
"ui.sketch_links_point_count": "punkty ({count})",
"ui.sketch_links_point_count_one": "1 punkt",
"ui.sketch_modifiers": "Modyfikatory szkicu",
"ui.sketch_project_geometry": "Geometria",
"ui.sketch_report.all_constrained": "Wszystko jest w pełni związane.",
@@ -6002,6 +6299,7 @@
"ui.step_values": "Zmieniaj wartości klawiszami strzałek i kółkiem",
"ui.step_values_tip": "W polu wartości, w którym piszesz, strzałki w górę i w dół zmieniają zwykłą liczbę o 1 (Shift: 10, Alt: 0,1), podobnie kółko myszy nad polem. Wyrażenia pozostają tak, jak je wpisano.",
"ui.stitch_tolerance_hint": "ciasna",
"ui.straighten": "Wyprostuj",
"ui.stretch": "Rozciągnij",
"ui.supersampling": "Supersampling (SSAA)",
"ui.supersampling_adaptive": "Tylko gdy widok jest nieruchomy",
@@ -6075,6 +6373,8 @@
"ui.view_angles_yaw": "Odchylenie",
"ui.view_angles_zero": "Ustaw {axis} na 0°",
"ui.view_centre": "Środek widoku",
"ui.view_compass_tip": "Widok od strony {axis}. Kliknij ponownie, aby odwrócić. Przeciągnij, aby obracać widok.",
"ui.view_compass_tip_snap": "Widok od strony {axis}. Kliknij ponownie, aby odwrócić. Przeciągnij, aby obracać widok; przytrzymaj Shift, aby przyciągać.",
"ui.viewport_status_clear": "Wyłącz {name}",
"ui.viewport_status_clear_hint": "Wyłącz {name} · Esc",
"ui.viewport_status_edges": "Krawędzie: {count}",
+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"))]
+5
View File
@@ -19,9 +19,14 @@ solidcraft-kernel = { workspace = true }
solidcraft-profile = { workspace = true }
solidcraft-render = { workspace = true }
solidcraft-derive = { workspace = true }
solidcraft-format = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true, features = ["raw_value"] }
thiserror = { workspace = true }
log = { workspace = true }
sha2 = { workspace = true }
web-time = { workspace = true }
# Lists the format change files (build.rs).
[build-dependencies]
solidcraft-format = { workspace = true }
+8 -72
View File
@@ -1,18 +1,12 @@
//! Lists the format changes of each family of files versioned by `src/format.rs`: designs in
//! `src/format/`, drawings in `src/drawing/format/` (#3890). One file per change, in `v<N>/` once a
//! release fixed its number, else in `pending/` (see `src/format/pending/README.md`). The lists are
//! generated so that a change adds a file and never edits a line another change also edits.
//! generated (`solidcraft_format::build`) so that a change adds a file and never edits a line
//! another change also edits.
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use std::path::PathBuf;
/// A family's change files: their folder, the last format before them, and the prefix of the
/// family's lists in `format.rs`.
struct Family {
folder: &'static str,
base: u32,
list_prefix: &'static str,
}
use solidcraft_format::build::Family;
/// Every family, each read by `scripts/fold-format-pending.sh` too. A design's upgrade steps are
/// written out in `format.rs` up to 22 (`JSON_STEPS`); a drawing's first format is 1.
@@ -21,68 +15,10 @@ const FAMILIES: [Family; 2] =
fn main() {
let manifest = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap());
let mut out = String::new();
let mut mods = String::new();
for family in &FAMILIES {
lists(family, &manifest, &mut out, &mut mods);
let lists = solidcraft_format::build::lists(&manifest, &FAMILIES).unwrap_or_else(|e| panic!("{e}"));
for folder in &lists.watch {
println!("cargo:rerun-if-changed={}", folder.display());
}
out.push_str(&mods);
let dest = PathBuf::from(std::env::var("OUT_DIR").unwrap()).join("format_parts.rs");
std::fs::write(dest, out).unwrap();
}
/// A family's `FOLDED` and `PENDING` lists; its folded formats follow on from its base.
fn lists(family: &Family, manifest: &Path, out: &mut String, mods: &mut String) {
let Family { folder, base, list_prefix } = family;
let root = manifest.join(folder);
println!("cargo:rerun-if-changed={}", root.display());
let mut folded: Vec<(u32, PathBuf)> = std::fs::read_dir(&root)
.unwrap_or_else(|e| panic!("{}: {e}", root.display()))
.filter_map(|entry| {
let path = entry.ok()?.path();
let version = path.file_name()?.to_str()?.strip_prefix('v')?.parse().ok()?;
path.is_dir().then_some((version, path))
})
.collect();
folded.sort();
for (i, (version, _)) in folded.iter().enumerate() {
let expected = base + 1 + i as u32;
assert_eq!(*version, expected, "{folder}/: expected v{expected}/ next, found v{version}/ (versions are contiguous)");
}
// Module names start with the family's (`drawing_pending_…`), so two families' changes of one
// name never clash.
let module = list_prefix.to_ascii_lowercase();
let _ = writeln!(
out,
"/// The changes of each format after the base, oldest first (`{folder}/v<N>/`).\nconst {list_prefix}FOLDED: &[(u32, &[Part])] = &["
);
for (version, dir) in &folded {
let _ = writeln!(out, " ({version}, &[");
parts(dir, &format!("{module}v{version}"), out, mods);
out.push_str(" ]),\n");
}
let _ = writeln!(out, "];\n\n/// The changes no release has a number for yet (`{folder}/pending/`).\nconst {list_prefix}PENDING: &[Part] = &[");
parts(&root.join("pending"), &format!("{module}pending"), out, mods);
out.push_str("];\n\n");
}
/// The `Part`s of the change files in `dir`, in file-name order, and a module for each.
fn parts(dir: &Path, prefix: &str, out: &mut String, mods: &mut String) {
let Ok(entries) = std::fs::read_dir(dir) else { return };
let mut files: Vec<PathBuf> = entries.filter_map(|e| e.ok().map(|e| e.path())).filter(|p| p.extension().is_some_and(|x| x == "rs")).collect();
files.sort();
for path in files {
let name = path.file_stem().and_then(|s| s.to_str()).unwrap_or_default().to_string();
assert!(
!name.is_empty() && name.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-'),
"{}: name a format change file with lowercase letters, digits and dashes",
path.display()
);
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.trim_start().starts_with("//!"), "{}: start a format change with a `//!` note", path.display());
let module = format!("{prefix}_{}", name.replace('-', "_"));
let step = if text.contains("fn step(") { format!("Some({module}::step)") } else { "None".into() };
let _ = writeln!(out, " Part {{ name: {name:?}, step: {step} }},");
let _ = writeln!(mods, "#[path = {:?}]\nmod {module};", path.display().to_string());
}
std::fs::write(dest, lists.code).unwrap();
}
+1
View File
@@ -721,6 +721,7 @@ mod tests {
width: "5 mm".into(),
height: "10 mm".into(),
operation: crate::Operation::NewBody,
centred: false,
},
None,
)
+9
View File
@@ -866,6 +866,7 @@ pub enum FeatureKind {
tangent_chain: bool,
},
Box {
/// The box's lowest corner, or its centre when `centred` is set.
#[serde(with = "point_expr")]
#[walk(placement)]
corner: PointExpr,
@@ -877,6 +878,10 @@ pub enum FeatureKind {
height: Expr,
#[serde(default)]
operation: Operation,
/// Placed by its centre: `corner` is the centre, so a new length, width or height grows the box
/// on both sides and it stays centred (#14487).
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
centred: bool,
},
Cylinder {
#[serde(with = "point_expr")]
@@ -1192,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 {
+1 -1
View File
@@ -37,7 +37,7 @@ use serde::{Deserialize, Serialize};
use serde_json::Value;
use solidcraft_geom::drawing_sheet::{self, DrawingBorderProfile};
use crate::format::DRAWING;
use crate::format::{CheckNumbers as _, DRAWING};
use crate::ids::{self, IdSource};
use crate::{DocError, Metadata, Result, expr};
+35 -12
View File
@@ -6081,6 +6081,12 @@ fn hole_references(f: &Feature) -> &[crate::HoleReference] {
/// Where a cylinder stands: its base, or with reference edges (#6380) at their distances in the
/// plane of the flat face at its base, square to its axis.
/// A box's lowest corner from its stored point and sides: the point itself, or the box's centre
/// when it was placed by its centre (#14487).
fn box_low_corner(at: Vec3, size: Vec3, centred: bool) -> Vec3 {
if centred { at - size * 0.5 } else { at }
}
fn cylinder_base(vals: &BTreeMap<String, Value>, st: &ModelState, f: &Feature, base: &crate::PointExpr, axis: Vec3) -> Result<Vec3> {
let at = point(vals, base)?;
with_references(vals, st, f, at, -axis, true)
@@ -7436,9 +7442,9 @@ fn feature_tools_with_constructor(
let plane = Plane::from_normal(start, t).ok_or_else(|| DocError::Invalid("pipe profile plane".into()))?;
Ok(unnamed(vec![kernel::sweep(&plane, &pipe_region(vals, r, wall)?, &segs)?]))
}
FeatureKind::Box { corner, length, width, height, .. } => {
FeatureKind::Box { corner, length, width, height, centred, .. } => {
let s = Vec3::new(val(vals, length, Kind::Length)?, val(vals, width, Kind::Length)?, val(vals, height, Kind::Length)?);
let corner = point(vals, corner)?;
let corner = box_low_corner(point(vals, corner)?, s, *centred);
Ok(vec![crate::lineage::box_tool(kernel::box_solid(corner, corner + s)?).with_recipe(tool_recipe(doc, f, vals))])
}
FeatureKind::Cylinder { base, axis, radius, height, .. } => Ok(vec![
@@ -9709,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();
@@ -9729,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)));
}
@@ -9987,9 +9999,9 @@ fn eval_feature(
}
Ok(())
}
FeatureKind::Box { corner, length, width, height, operation } => {
FeatureKind::Box { corner, length, width, height, operation, centred } => {
let s = Vec3::new(val(vals, length, Kind::Length)?, val(vals, width, Kind::Length)?, val(vals, height, Kind::Length)?);
let corner = point(vals, corner)?;
let corner = box_low_corner(point(vals, corner)?, s, *centred);
let b = kernel::box_solid(corner, corner + s)?;
let b = if matches!(operation, Operation::NewBody | Operation::Join) { b.with_box_certificate(f.id)? } else { b };
let handled = more::sheet_primitive_cut(f, st, &b, more::PrimitivePrism::Box { lo: corner, hi: corner + s }, *operation, false, warning)?;
@@ -11189,6 +11201,7 @@ mod world_placement_cache_tests {
width: "3 mm".into(),
height: "4 mm".into(),
operation: crate::Operation::NewBody,
centred: false,
},
Some("AuthoredBox"),
)
@@ -11225,7 +11238,14 @@ mod world_placement_cache_tests {
let mut boxes = |comp: u64, operation: Operation, corner: Vec3| {
let id = doc
.add_feature(
FeatureKind::Box { corner: crate::point_expr(corner), length: "10".into(), width: "10".into(), height: "2".into(), operation },
FeatureKind::Box {
corner: crate::point_expr(corner),
length: "10".into(),
width: "10".into(),
height: "2".into(),
operation,
centred: false,
},
None,
)
.unwrap();
@@ -11375,6 +11395,7 @@ mod chain_tests {
width: "30".into(),
height: "20".into(),
operation: Operation::NewBody,
centred: false,
},
None,
)
@@ -11421,6 +11442,7 @@ mod chain_tests {
width: w.into(),
height: h.into(),
operation,
centred: false,
};
doc.add_feature(block(Vec3::new(0.0, 0.0, 20.0), ["40", "20", "20"], Operation::NewBody), None).unwrap();
doc.add_feature(block(Vec3::new(20.0, 0.0, 0.0), ["20", "20", "20"], Operation::Join), None).unwrap();
@@ -11467,6 +11489,7 @@ mod chain_tests {
width: "20".into(),
height: "20".into(),
operation: Operation::NewBody,
centred: false,
};
doc.add_feature(solid, None).unwrap();
doc.add_feature(fillet(Vec3::new(20.0, 10.0, 20.0), "2", true), None).unwrap();
+13 -4
View File
@@ -574,6 +574,7 @@ fn plate_with_holes(doc: &mut Document, comp: u64, tools: u64, holes: usize) ->
width: "10".into(),
height: "4".into(),
operation: Operation::NewBody,
centred: false,
};
let plate = doc.add_feature(kind, None).unwrap();
doc.feature_mut(plate).unwrap().component = comp;
@@ -582,7 +583,7 @@ fn plate_with_holes(doc: &mut Document, comp: u64, tools: u64, holes: usize) ->
let body = model.state().bodies.iter().find(|b| b.feature == plate).unwrap().name.clone();
for k in 0..holes {
let corner = point_expr(Vec3::new(10.0 * k as f64 + 4.0, 4.0, -1.0));
let kind = FeatureKind::Box { corner, length: "2".into(), width: "2".into(), height: "6".into(), operation: Operation::Cut };
let kind = FeatureKind::Box { corner, length: "2".into(), width: "2".into(), height: "6".into(), operation: Operation::Cut, centred: false };
let id = doc.add_feature(kind, None).unwrap();
let f = doc.feature_mut(id).unwrap();
f.component = tools;
@@ -842,6 +843,7 @@ fn shared_evaluation_requires_its_actual_model_and_unmodified_geometry_inputs()
width: "3".into(),
height: "4".into(),
operation: Operation::NewBody,
centred: false,
},
None,
)
@@ -987,7 +989,8 @@ fn saved_states_read_back_their_sketch_keys() {
doc.add_feature(line_sketch(PlaneRef::Origin { name: "XY".into() }, Vec2::new(f64::from(i) + 1.0, 1.0)), None).unwrap();
for j in 0..3 {
let corner = point_expr(Vec3::new(f64::from(i) * 20.0, f64::from(j) * 20.0, 0.0));
let kind = FeatureKind::Box { corner, length: "5".into(), width: "5".into(), height: "5".into(), operation: Operation::NewBody };
let kind =
FeatureKind::Box { corner, length: "5".into(), width: "5".into(), height: "5".into(), operation: Operation::NewBody, centred: false };
doc.add_feature(kind, None).unwrap();
}
}
@@ -1073,13 +1076,14 @@ fn blocks_with_cuts(doc: &mut Document, comp: u64, blocks: usize, cuts: usize) {
let y = 100.0 * comp as f64;
for k in 0..blocks {
let corner = point_expr(Vec3::new(20.0 * k as f64, y, 0.0));
let kind = FeatureKind::Box { corner, length: "10".into(), width: "10".into(), height: "4".into(), operation: Operation::NewBody };
let kind =
FeatureKind::Box { corner, length: "10".into(), width: "10".into(), height: "4".into(), operation: Operation::NewBody, centred: false };
let id = doc.add_feature(kind, None).unwrap();
doc.feature_mut(id).unwrap().component = comp;
}
for k in 0..cuts {
let corner = point_expr(Vec3::new(20.0 * k as f64 + 4.0, y + 4.0, -1.0));
let kind = FeatureKind::Box { corner, length: "2".into(), width: "2".into(), height: "6".into(), operation: Operation::Cut };
let kind = FeatureKind::Box { corner, length: "2".into(), width: "2".into(), height: "6".into(), operation: Operation::Cut, centred: false };
let id = doc.add_feature(kind, None).unwrap();
doc.feature_mut(id).unwrap().component = comp;
}
@@ -1281,6 +1285,7 @@ fn block(doc: &mut Document, name: &str, corner: Vec3, size: [f64; 3]) {
width: size[1].to_string().into(),
height: size[2].to_string().into(),
operation: Operation::NewBody,
centred: false,
},
None,
)
@@ -1344,6 +1349,7 @@ fn a_cut_still_runs_where_its_tool_touches_grazes_or_meets_a_curved_face() {
width: "40".into(),
height: "10".into(),
operation: Operation::Cut,
centred: false,
};
assert_eq!(cut_work(&mut doc, along).cut, 1);
// A dome: a sphere's cap rising 10 mm above the circle it stands on. A thin tool drilled into
@@ -1357,6 +1363,7 @@ fn a_cut_still_runs_where_its_tool_touches_grazes_or_meets_a_curved_face() {
width: "60".into(),
height: "40".into(),
operation: Operation::Cut,
centred: false,
};
doc.add_feature(below, None).unwrap();
let mut model = Model::new();
@@ -2026,6 +2033,7 @@ fn an_ambiguous_target_is_refused_before_a_sheet_is_machined() {
width: "10".into(),
height: "2".into(),
operation: Operation::NewBody,
centred: false,
},
None,
)
@@ -2040,6 +2048,7 @@ fn an_ambiguous_target_is_refused_before_a_sheet_is_machined() {
width: "5".into(),
height: "20".into(),
operation: Operation::Cut,
centred: false,
},
None,
)
+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")));
}
+80 -214
View File
@@ -243,6 +243,30 @@ use serde_json::Value;
use crate::{DocError, Document, Result};
pub use solidcraft_format::{Alias, Newer};
/// A kind of file whose format is versioned here: designs, and drawings (#3890). Each family has
/// its own format tag, its own numbers and its own folder of change files (`build.rs` lists them);
/// reading a file's format, telling a file a newer build saved and the upgrade are the same code
/// for every family, the shared `solidcraft_format`.
pub type Family<'a> = solidcraft_format::Family<'a, DocError>;
/// A family's upgrade path: the steps written out by hand (designs' up to [`HAND_WRITTEN`]), the
/// folded formats after them (each a list of changes) and the pending changes, which make the
/// next format.
pub(crate) type Formats<'a> = solidcraft_format::Formats<'a, DocError>;
/// One change to a family's file format: a file in its `v<N>/` or `pending/` folder.
pub(crate) type Part = solidcraft_format::Part<DocError>;
impl From<solidcraft_format::FormatError> for DocError {
fn from(e: solidcraft_format::FormatError) -> Self {
match e {
solidcraft_format::FormatError::Invalid(why) => DocError::Invalid(why),
}
}
}
/// The format this build writes: the last folded one, or the next while changes are pending.
pub const FORMAT: u32 = FORMATS.current();
@@ -250,125 +274,54 @@ pub const FORMAT: u32 = FORMATS.current();
/// of [`Part`]s (`src/format/`, listed by `build.rs`).
const HAND_WRITTEN: u32 = 22;
/// One change to a family's file format: a file in its `v<N>/` or `pending/` folder.
pub(crate) struct Part {
/// The file's name, which a file written while the change was pending lists.
pub name: &'static str,
/// How a file from before the change is rewritten as it opens, if it is.
pub step: Option<JsonStep>,
}
include!(concat!(env!("OUT_DIR"), "/format_parts.rs"));
/// Every format this build knows and how to upgrade a design through them.
const FORMATS: Formats<'static> = Formats { hand: &JSON_STEPS, folded: FOLDED, pending: PENDING };
/// A kind of file whose format is versioned here: designs, and drawings (#3890). Each family has
/// its own format tag, its own numbers and its own folder of change files (`build.rs` lists them);
/// reading a file's format, telling a file a newer build saved and the upgrade are the same code
/// for every family.
pub struct Family<'a> {
/// What a file of the family is called in messages.
noun: &'static str,
/// The tag before the number in a file's `format`.
prefix: &'static str,
/// The tag files saved before the rename carry: read, never written (docs/compatibility.md).
legacy_prefix: Option<&'static str>,
/// The format of a file that names none: designs saved before format strings are format 1; a
/// drawing always names its own.
untagged: Option<u32>,
formats: Formats<'a>,
}
/// Another spelling a design of format 29 may carry: the personal format-29 renderer's name for
/// the engineering material cards. Its card schema is unchanged; recognising the alias must never
/// grant support to another reader, so it counts only while this build has the change.
const DESIGN_ALIASES: [Alias; 1] = [Alias { format: 29, name: "physical-material-cards", known_as: "material-engineering-cards" }];
/// Designs (`solidcraft/N`): the upgrade steps written out up to format 22, then the change files
/// in `src/format/`.
pub const DESIGN: Family<'static> =
Family { noun: "design", prefix: "solidcraft/", legacy_prefix: Some(LEGACY_FORMAT_PREFIX), untagged: Some(1), formats: FORMATS };
pub const DESIGN: Family<'static> = Family {
app: "SolidCraft",
noun: "design",
prefix: "solidcraft/",
legacy_prefix: Some(LEGACY_FORMAT_PREFIX),
untagged: Some(1),
aliases: &DESIGN_ALIASES,
formats: FORMATS,
};
/// Drawings (`solidcraft-drawing/N`, #3890): format 1 is the first drawing record, and every later
/// change is a file in `src/drawing/format/`.
pub const DRAWING: Family<'static> = Family {
app: "SolidCraft",
noun: "drawing",
prefix: "solidcraft-drawing/",
legacy_prefix: None,
untagged: None,
aliases: &[],
formats: Formats { hand: &[], folded: DRAWING_FOLDED, pending: DRAWING_PENDING },
};
impl Family<'_> {
/// The format this build writes: the last folded one, or the next while changes are pending.
pub const fn current(&self) -> u32 {
self.formats.current()
}
/// The names of the pending changes this build writes into a file (`format_pending`).
pub fn pending_names(&self) -> Vec<String> {
self.formats.pending.iter().map(|p| p.name.to_string()).collect()
}
/// The format tag of format `n`.
pub fn format_string(&self, n: u32) -> String {
format!("{}{n}", self.prefix)
}
/// The version in one of this family's format tags; `None` for any other tag, another
/// family's included.
pub fn version_of(&self, s: &str) -> Option<u32> {
s.strip_prefix(self.prefix).or_else(|| self.legacy_prefix.and_then(|p| s.strip_prefix(p)))?.parse().ok()
}
/// Read a file's format and take its `format_pending` out of its JSON, before anything else
/// in it is read: a file that isn't this family's is refused here. One a newer build saved is
/// not: [`Family::newer`] says so.
pub fn take_version(&self, v: &mut Value) -> Result<(u32, Option<Vec<String>>)> {
let noun = self.noun;
let not_ours = |what: String| DocError::Invalid(format!("not a SolidCraft {noun} ({what})"));
let Some(obj) = v.as_object() else { return Err(not_ours("expected a JSON object".into())) };
let version = match obj.get("format") {
None => self.untagged.ok_or_else(|| not_ours("no format".into()))?,
Some(Value::String(f)) => self.version_of(f).ok_or_else(|| not_ours(format!("format `{f}`")))?,
Some(_) => return Err(not_ours("format is not a string".into())),
};
let applied = take_names(v, "format_pending", noun)?;
if version == 0 {
return Err(not_ours("format 0".into()));
}
Ok((version, applied))
}
/// What says a file of format `version` with the changes `applied` came from a newer build:
/// a number past this build's, or a change this build doesn't have anywhere in its history.
/// `None` for a file this build fully knows.
pub fn newer(&self, version: u32, applied: Option<&[String]>) -> Option<Newer> {
self.formats.newer(version, applied)
}
/// Bring a file of format `version` up to this build's; `applied` is its `format_pending`
/// (both from [`Family::take_version`]).
pub fn upgrade(&self, v: &mut Value, version: u32, applied: Option<&[String]>) -> Result<()> {
self.formats.upgrade(self.noun, v, version, applied)
}
/// Every number a file of the family holds is in range ([`check_numbers`]), but for its
/// top-level `kept` field: other tools' data (a design's `metadata`, a drawing's `properties`),
/// kept as it is and never read as a number (#1015); parsing already bounds its nesting.
pub(crate) fn check_numbers(&self, v: &Value, kept: &str) -> Result<()> {
match v {
Value::Object(m) => m.iter().filter(|(k, _)| k.as_str() != kept).try_for_each(|(_, x)| check_numbers(x, self.noun, 1)),
v => check_numbers(v, self.noun, 0),
}
}
/// Every number a file of a family holds is in range ([`check_numbers`]), but for its top-level
/// `kept` field: other tools' data (a design's `metadata`, a drawing's `properties`), kept as it is
/// and never read as a number (#1015); parsing already bounds its nesting.
pub(crate) trait CheckNumbers {
fn check_numbers(&self, v: &Value, kept: &str) -> Result<()>;
}
/// How a file shows a newer build saved it ([`Family::newer`]).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Newer {
/// The number the file says.
pub format: u32,
/// The number this build reads up to.
pub reads_up_to: u32,
/// The changes the file names that this build doesn't have.
pub unknown_changes: Vec<String>,
impl CheckNumbers for Family<'_> {
fn check_numbers(&self, v: &Value, kept: &str) -> Result<()> {
match v {
Value::Object(m) => m.iter().filter(|(k, _)| k.as_str() != kept).try_for_each(|(_, x)| check_numbers(x, self.noun(), 1)),
v => check_numbers(v, self.noun(), 0),
}
}
}
/// The names of the pending changes this build writes into a design (`format_pending`).
@@ -376,97 +329,6 @@ pub fn pending_names() -> Vec<String> {
DESIGN.pending_names()
}
/// A family's upgrade path: the steps written out by hand (designs' up to [`HAND_WRITTEN`]), the
/// folded formats after them (each a list of changes) and the pending changes, which make the
/// next format.
struct Formats<'a> {
hand: &'a [JsonStep],
folded: &'a [(u32, &'a [Part])],
pending: &'a [Part],
}
impl Formats<'_> {
const fn current(&self) -> u32 {
self.hand.len() as u32 + 1 + self.folded.len() as u32 + if self.pending.is_empty() { 0 } else { 1 }
}
/// The changes format `version` is made of, if it is made of changes.
fn parts(&self, version: u32) -> Option<&[Part]> {
let after = version.checked_sub(self.hand.len() as u32 + 2)? as usize;
match self.folded.get(after) {
Some((_, parts)) => Some(parts),
None if after == self.folded.len() && !self.pending.is_empty() => Some(self.pending),
None => None,
}
}
/// A concept is implemented only when this reader registers its descriptor.
fn knows_part_name(&self, name: &str) -> bool {
self.folded.iter().flat_map(|(_, parts)| *parts).chain(self.pending).any(|part| part.name == name)
}
/// Read-only spelling from the personal format-29 renderer. Its card schema is
/// unchanged; recognizing the alias must never grant support to another reader.
fn canonical_applied_name<'name>(&self, version: u32, name: &'name str) -> &'name str {
if version == 29 && name == "physical-material-cards" && self.knows_part_name("material-engineering-cards") {
"material-engineering-cards"
} else {
name
}
}
/// [`Family::newer`]. Changes are recognized across the entire history: a branch's pending
/// change may have landed at a later number. A list beside a format that isn't made of changes
/// means nothing (a tool or a test set an older number on a newer file).
fn newer(&self, version: u32, applied: Option<&[String]>) -> Option<Newer> {
let current = self.current();
let listed = applied.filter(|_| version > current || self.parts(version).is_some()).unwrap_or_default();
let unknown_changes: Vec<String> =
listed.iter().filter(|name| !self.knows_part_name(self.canonical_applied_name(version, name))).cloned().collect();
(version > current || !unknown_changes.is_empty()).then_some(Newer { format: version, reads_up_to: current, unknown_changes })
}
/// Bring a `noun` of format `version` up to [`Formats::current`]. `applied` is the file's
/// `format_pending`: the changes of its format it already has, when it was written while they
/// were pending; the others' steps run first, and already-applied changes never run again at
/// their later number. A file past this build's number has no steps to run.
fn upgrade(&self, noun: &str, v: &mut Value, version: u32, applied: Option<&[String]>) -> Result<()> {
if version == 0 {
return Err(DocError::Invalid(format!("not a SolidCraft {noun} (format 0)")));
}
// A list beside a format that isn't made of changes is ignored, like any field an upgrade
// drops.
let applied = applied.filter(|_| self.parts(version).is_some());
let already_applied =
|part: &Part| applied.is_some_and(|names| names.iter().any(|name| self.canonical_applied_name(version, name) == part.name));
if applied.is_some() {
for part in self.parts(version).unwrap_or_default().iter().filter(|part| !already_applied(part)) {
part.step.map_or(Ok(()), |step| step(v))?;
}
}
for from in version..self.current() {
match self.hand.get(from as usize - 1) {
Some(step) => step(v)?,
None => {
for part in self.parts(from + 1).unwrap_or_default().iter().filter(|part| !already_applied(part)) {
part.step.map_or(Ok(()), |step| step(v))?;
}
}
}
}
Ok(())
}
}
/// Take a short list of names out of a file's JSON: its `format_pending` (the pending changes it
/// has).
fn take_names(v: &mut Value, key: &str, noun: &str) -> Result<Option<Vec<String>>> {
let Some(list) = v.as_object_mut().and_then(|o| o.remove(key)) else { return Ok(None) };
let invalid = || DocError::Invalid(format!("damaged {noun}: `{key}` is not a short list of names"));
let list = list.as_array().filter(|l| l.len() <= 1000).ok_or_else(invalid)?;
list.iter().map(|n| n.as_str().filter(|n| n.len() <= 128).map(str::to_string).ok_or_else(invalid)).collect::<Result<_>>().map(Some)
}
pub fn format_string(n: u32) -> String {
DESIGN.format_string(n)
}
@@ -482,7 +344,7 @@ pub fn version_of(s: &str) -> Option<u32> {
/// JSON-level upgrades up to [`HAND_WRITTEN`]: entry `i` turns format `i + 1` into `i + 2`.
/// Never add to it: a new format is a file in `src/format/pending/`.
type JsonStep = fn(&mut Value) -> Result<()>;
type JsonStep = solidcraft_format::Step<DocError>;
const JSON_STEPS: [JsonStep; HAND_WRITTEN as usize - 1] = [
|_| Ok(()),
v2_to_v3,
@@ -1362,30 +1224,30 @@ mod tests {
#[test]
fn pending_changes_apply_in_order_after_older_formats() {
let pending = [traced("a-first"), traced("b-second")];
let formats = Formats { hand: &JSON_STEPS, folded: FOLDED, pending: &pending };
let formats = Family { formats: Formats { hand: &JSON_STEPS, folded: FOLDED, pending: &pending }, ..DESIGN };
let next = HAND_WRITTEN + FOLDED.len() as u32 + 1;
assert_eq!(formats.current(), next);
for version in [1, HAND_WRITTEN, next - 1] {
let mut v = serde_json::json!({"features": [], "assembly": {"joints": [{"flip": false}]}});
formats.upgrade("design", &mut v, version, None).unwrap();
formats.upgrade(&mut v, version, None).unwrap();
assert_eq!(trace_of(&v), ["a", "b"], "from format {version}");
// The hand-written steps ran too: format 3's joints were flipped on the way up.
assert_eq!(v["assembly"]["joints"][0]["flip"], Value::Bool(version < 4), "from format {version}");
}
// A design of the pending number written before `b-second` existed gets just that step.
let mut v = serde_json::json!({});
formats.upgrade("design", &mut v, next, Some(&["a-first".to_string()])).unwrap();
formats.upgrade(&mut v, next, Some(&["a-first".to_string()])).unwrap();
assert_eq!(trace_of(&v), ["b"]);
// One that has both, or that this build wrote, needs nothing.
let mut v = serde_json::json!({});
formats.upgrade("design", &mut v, next, Some(&["a-first".into(), "b-second".into()])).unwrap();
formats.upgrade(&mut v, next, Some(&["a-first".into(), "b-second".into()])).unwrap();
assert_eq!(trace_of(&v), Vec::<&str>::new());
// A change this build doesn't know is a newer build's: said, and kept.
let said = formats.newer(next, Some(&["c-later".into()])).unwrap();
assert_eq!(said, Newer { format: next, reads_up_to: next, unknown_changes: vec!["c-later".into()] });
// A list on a format that isn't made of changes means nothing.
let mut v = serde_json::json!({});
formats.upgrade("design", &mut v, HAND_WRITTEN, Some(&["a-first".into()])).unwrap();
formats.upgrade(&mut v, HAND_WRITTEN, Some(&["a-first".into()])).unwrap();
assert_eq!(trace_of(&v), ["a", "b"]);
}
@@ -1397,16 +1259,16 @@ mod tests {
let mut folded: Vec<(u32, &[Part])> = FOLDED.to_vec();
let next = HAND_WRITTEN + folded.len() as u32 + 1;
folded.push((next, &parts));
let formats = Formats { hand: &JSON_STEPS, folded: &folded, pending: &[] };
let formats = Family { formats: Formats { hand: &JSON_STEPS, folded: &folded, pending: &[] }, ..DESIGN };
assert_eq!(formats.current(), next);
let mut old = serde_json::json!({});
formats.upgrade("design", &mut old, HAND_WRITTEN, None).unwrap();
formats.upgrade(&mut old, HAND_WRITTEN, None).unwrap();
assert_eq!(trace_of(&old), ["a", "b"]);
let mut nightly = serde_json::json!({});
formats.upgrade("design", &mut nightly, next, Some(&["a-first".into()])).unwrap();
formats.upgrade(&mut nightly, next, Some(&["a-first".into()])).unwrap();
assert_eq!(trace_of(&nightly), ["b"]);
let mut released = serde_json::json!({});
formats.upgrade("design", &mut released, next, None).unwrap();
formats.upgrade(&mut released, next, None).unwrap();
assert_eq!(trace_of(&released), Vec::<&str>::new());
}
@@ -1415,11 +1277,11 @@ mod tests {
let hand: [JsonStep; 27] = [|_| Ok(()); 27];
let first = [traced("a-first")];
let cards = [traced("material-engineering-cards")];
let pending = Formats { hand: &hand, folded: &[(29, &first)], pending: &cards };
let folded = Formats { hand: &hand, folded: &[(29, &first), (30, &cards)], pending: &[] };
let pending = Family { formats: Formats { hand: &hand, folded: &[(29, &first)], pending: &cards }, ..DESIGN };
let folded = Family { formats: Formats { hand: &hand, folded: &[(29, &first), (30, &cards)], pending: &[] }, ..DESIGN };
for formats in [pending, folded] {
let mut file = serde_json::json!({"trace": ["b"]});
formats.upgrade("design", &mut file, 29, Some(&["physical-material-cards".into()])).unwrap();
formats.upgrade(&mut file, 29, Some(&["physical-material-cards".into()])).unwrap();
assert_eq!(trace_of(&file), ["b", "a"], "never replay existing card semantics");
}
}
@@ -1428,7 +1290,7 @@ mod tests {
fn personal_card_alias_without_support_is_a_newer_builds() {
let hand: [JsonStep; 27] = [|_| Ok(()); 27];
let first = [traced("a-first")];
let formats = Formats { hand: &hand, folded: &[(29, &first)], pending: &[] };
let formats = Family { formats: Formats { hand: &hand, folded: &[(29, &first)], pending: &[] }, ..DESIGN };
let said = formats.newer(29, Some(&["physical-material-cards".into()])).unwrap();
assert_eq!(said.unknown_changes, ["physical-material-cards"]);
}
@@ -1439,7 +1301,7 @@ mod tests {
let first = [traced("a-first")];
let second = [traced("b-second")];
let cards = [traced("material-engineering-cards")];
let formats = Formats { hand: &hand, folded: &[(28, &first), (29, &second), (30, &cards)], pending: &[] };
let formats = Family { formats: Formats { hand: &hand, folded: &[(28, &first), (29, &second), (30, &cards)], pending: &[] }, ..DESIGN };
for version in [28, 30] {
let said = formats.newer(version, Some(&["physical-material-cards".into()])).unwrap();
assert_eq!(said.unknown_changes, ["physical-material-cards"]);
@@ -1451,7 +1313,7 @@ mod tests {
let hand: [JsonStep; 27] = [|_| Ok(()); 27];
let first = [traced("a-first")];
let cards = [traced("material-engineering-cards")];
let formats = Formats { hand: &hand, folded: &[(29, &first)], pending: &cards };
let formats = Family { formats: Formats { hand: &hand, folded: &[(29, &first)], pending: &cards }, ..DESIGN };
for names in
[["physical-material-cards".into(), "unknown-card-codec".into()], ["unknown-card-codec".into(), "physical-material-cards".into()]]
{
@@ -1463,14 +1325,14 @@ mod tests {
fn alias_and_canonical_markers_never_reapply_the_card_migration() {
let hand: [JsonStep; 27] = [|_| Ok(()); 27];
let first = [traced("a-first"), traced("material-engineering-cards")];
let formats = Formats { hand: &hand, folded: &[(29, &first)], pending: &[] };
let formats = Family { formats: Formats { hand: &hand, folded: &[(29, &first)], pending: &[] }, ..DESIGN };
for names in [
vec!["physical-material-cards".into()],
vec!["physical-material-cards".into(), "material-engineering-cards".into()],
vec!["material-engineering-cards".into(), "physical-material-cards".into()],
] {
let mut file = serde_json::json!({"trace": ["b"]});
formats.upgrade("design", &mut file, 29, Some(&names)).unwrap();
formats.upgrade(&mut file, 29, Some(&names)).unwrap();
assert_eq!(trace_of(&file), ["b", "a"]);
}
}
@@ -1480,10 +1342,10 @@ mod tests {
let hand: [JsonStep; 27] = [|_| Ok(()); 27];
let first = [traced("a-first")];
let cards = [traced("material-engineering-cards")];
let formats = Formats { hand: &hand, folded: &[(29, &first)], pending: &cards };
let formats = Family { formats: Formats { hand: &hand, folded: &[(29, &first)], pending: &cards }, ..DESIGN };
let mut file = serde_json::json!({"trace": ["a"], "metadata": {"format_pending": ["physical-material-cards"]}});
let metadata = file["metadata"].clone();
formats.upgrade("design", &mut file, 29, None).unwrap();
formats.upgrade(&mut file, 29, None).unwrap();
assert_eq!(trace_of(&file), ["a", "b"]);
assert_eq!(file["metadata"], metadata);
}
@@ -1495,7 +1357,7 @@ mod tests {
let parts = [traced("a-first")];
let later = [traced("b-second")];
for pending in [&[][..], &later[..]] {
let formats = Formats { hand: &[], folded: &[(2, &parts)], pending };
let formats = Family { formats: Formats { hand: &[], folded: &[(2, &parts)], pending }, ..DESIGN };
assert_eq!(formats.newer(2, Some(&["experimental-card".into()])).unwrap().unknown_changes, ["experimental-card"]);
}
}
@@ -1506,14 +1368,14 @@ mod tests {
fn pending_concepts_moved_to_later_formats_are_not_reapplied() {
let first = [traced("a-first")];
let later = [traced("b-second")];
let pending = Formats { hand: &[], folded: &[(2, &first)], pending: &later };
let folded = Formats { hand: &[], folded: &[(2, &first), (3, &later)], pending: &[] };
let pending = Family { formats: Formats { hand: &[], folded: &[(2, &first)], pending: &later }, ..DESIGN };
let folded = Family { formats: Formats { hand: &[], folded: &[(2, &first), (3, &later)], pending: &[] }, ..DESIGN };
for formats in [pending, folded] {
let mut experimental = serde_json::json!({"trace": ["b"]});
formats.upgrade("design", &mut experimental, 2, Some(&["b-second".into()])).unwrap();
formats.upgrade(&mut experimental, 2, Some(&["b-second".into()])).unwrap();
assert_eq!(trace_of(&experimental), ["b", "a"]);
let mut released = serde_json::json!({"trace": ["a"]});
formats.upgrade("design", &mut released, 2, None).unwrap();
formats.upgrade(&mut released, 2, None).unwrap();
assert_eq!(trace_of(&released), ["a", "b"], "ordinary released files still get later migrations");
}
}
@@ -1943,6 +1805,7 @@ mod tests {
width: "4".into(),
height: "6".into(),
operation: Operation::NewBody,
centred: false,
},
None,
)
@@ -2208,6 +2071,7 @@ mod tests {
width: "20".into(),
height: "5".into(),
operation: crate::Operation::NewBody,
centred: false,
},
None,
)
@@ -2333,6 +2197,7 @@ mod tests {
width: "10".into(),
height: "10".into(),
operation: crate::Operation::NewBody,
centred: false,
},
Some(&format!("Box{i}")),
)
@@ -2363,6 +2228,7 @@ mod tests {
width: "10".into(),
height: "10".into(),
operation: crate::Operation::NewBody,
centred: false,
},
None,
)
@@ -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).
+5
View File
@@ -0,0 +1,5 @@
//! Designs save `centred: true` on a box placed by its centre (`solid.box` with `center`); its
//! `corner` is then that centre, so a later length, width or height keeps the box centred. Older
//! designs have no such flag: their boxes keep their stored corner and need no migration. The last
//! release opens such a design with a warning about the newer format, reads the centre as the
//! corner and keeps what it can't read (`crates/doc/src/newer_file.rs`).
+21 -326
View File
@@ -11,7 +11,9 @@
//! with what was read.
//!
//! All of these are named in [`NewerFile::not_understood`] and go back into the file when it is
//! saved ([`restore`]), as they came. Saving keeps the file's own format number and change list:
//! saved ([`restore`]), as they came. What keeps them is the shared `solidcraft_format::newer`; what
//! is a design's (its features kept as placeholders, its parameters left out, the sections a design
//! can't open without) is here. Saving keeps the file's own format number and change list:
//! the file still holds what the newer build wrote, and a lower number would make that build
//! upgrade its own data as if it were old. What can't be put back because the design changed around
//! it ([`not_kept`]) is said in the reply to the save.
@@ -21,16 +23,19 @@ use std::sync::Arc;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use solidcraft_format::newer::{leave_out_unreadable, looks_default, read_around, short};
use crate::format::{self, DESIGN, Newer};
use crate::{DocError, Document, Result};
pub use solidcraft_format::newer::NewerFile;
/// The app, as a newer file's notice names it.
const APP: &str = "SolidCraft";
/// How a [`RawFeature`] is written while it is inside this build; [`restore`] unwraps it.
const FOREIGN_TAG: &str = "newer_file_feature";
/// Sections a design can't open without: filled in when the file's can't be read.
const CORE: [&str; 3] = ["name", "params", "features"];
/// The most sections left out one at a time to find the one a design can't be read with.
const MAX_SECTION_TRIES: usize = 64;
/// Fields a design container adds beside what a design holds; not unknown to this build.
const CONTAINER_ONLY: [&str; 6] = ["step_blob", "mesh_blob", "image_blob", "points_blob", "hdr_blob", "texture_blob"];
@@ -51,132 +56,16 @@ impl RawFeature {
}
}
/// What the file says and holds that this build doesn't read.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct NewerFile {
/// The number the file says.
pub format: u32,
/// The number this build reads up to.
pub reads_up_to: u32,
/// The changes the file names that this build doesn't have.
pub unknown_changes: Vec<String>,
/// What this build couldn't read, by name.
pub not_understood: Vec<String>,
/// Fields known records lost in typed reading.
kept: Option<Kept>,
/// Top-level sections left out whole.
sections: Vec<Section>,
/// Elements left out of a top-level list: its key and the element.
elements: Vec<(String, Value)>,
/// The parts of a design container this build has no use for: name and bytes. A blob only one
/// of the kept pieces refers to is one of them.
pub parts: Vec<(String, Arc<[u8]>)>,
/// The file's `container.json`, when this build doesn't know what it says.
pub container: Option<Value>,
}
#[derive(Clone, Debug, PartialEq)]
struct Section {
key: String,
value: Value,
/// This build's record of the section, without the file's: while it is unchanged, the file's
/// goes back.
empty: Option<Value>,
}
/// The fields of a record that typed reading dropped.
#[derive(Clone, Debug, PartialEq)]
enum Kept {
Field(Value),
Fields(BTreeMap<String, Kept>),
Items(Vec<(Anchor, Kept)>),
}
/// Which element of a list some kept fields belong to.
#[derive(Clone, Debug, PartialEq)]
enum Anchor {
Id(u64),
Name(String),
/// Its place, good only while the list keeps its length.
Index {
at: usize,
of: usize,
},
}
impl NewerFile {
/// What to tell the user, whatever else is said.
pub fn notice(&self) -> String {
let reads = self.reads_up_to;
let format = self.format;
match self.unknown_changes.first() {
Some(change) if format <= reads => {
format!(
"Saved by a newer SolidCraft (format {format} with `{change}`, which this version doesn't know). If something's missing, update."
)
}
_ => format!("Saved by a newer SolidCraft (format {format}, this version reads up to {reads}). If something's missing, update."),
}
}
/// Every line to report: the notice, then what couldn't be read, by name.
pub fn warnings(&self) -> Vec<String> {
let unread = self.not_understood.iter().map(|what| format!("Not read: {what}. Saving keeps it."));
std::iter::once(self.notice()).chain(unread).collect()
}
/// What a reply says about it.
pub fn report(&self) -> Value {
json!({
"format": self.format,
"reads_up_to": self.reads_up_to,
"unknown_changes": self.unknown_changes,
"not_understood": self.not_understood,
"notice": self.notice(),
})
}
/// Whether the next save needs a container to keep what the file holds: plain JSON has no room
/// for parts beside the design.
pub fn needs_container(&self) -> bool {
!self.parts.is_empty()
}
/// Whether anything kept back names this text (a blob's name in a container).
pub fn mentions(&self, text: &str) -> bool {
fn value(v: &Value, text: &str) -> bool {
match v {
Value::String(s) => s == text,
Value::Array(a) => a.iter().any(|x| value(x, text)),
Value::Object(o) => o.values().any(|x| value(x, text)),
_ => false,
}
}
fn kept(k: &Kept, text: &str) -> bool {
match k {
Kept::Field(v) => value(v, text),
Kept::Fields(m) => m.values().any(|x| kept(x, text)),
Kept::Items(l) => l.iter().any(|(_, x)| kept(x, text)),
}
}
self.kept.as_ref().is_some_and(|k| kept(k, text))
|| self.sections.iter().any(|s| value(&s.value, text))
|| self.elements.iter().any(|(_, v)| value(v, text))
}
/// Say that a section the container reader couldn't finish reading is left out.
/// `empty` is what the design writes for the section while it holds nothing, if anything.
pub fn keep_section(&mut self, key: &str, value: Value, empty: Option<Value>, why: &str) {
self.not_understood.push(format!("section `{key}` ({why})"));
self.sections.push(Section { key: key.into(), value, empty });
}
/// What a design `newer` says a newer build saved holds that this build doesn't read: nothing
/// left out of it yet (a design container's reader adds what it leaves out).
pub fn newer_file(newer: &Newer) -> NewerFile {
NewerFile::new(APP, newer)
}
/// Read a design a newer build saved, or say why it truly can't be read.
pub(crate) fn read(mut raw: Value, version: u32, applied: Option<Vec<String>>, newer: Newer) -> Result<Document> {
let applied_names = applied.as_deref();
let mut file =
NewerFile { format: newer.format, reads_up_to: newer.reads_up_to, unknown_changes: newer.unknown_changes.clone(), ..NewerFile::default() };
let mut file = newer_file(&newer);
let (mut doc, read_as) = match format::decode(raw.clone(), version, applied_names) {
Ok(doc) => (doc, raw),
Err(_) => {
@@ -195,12 +84,7 @@ pub(crate) fn read(mut raw: Value, version: u32, applied: Option<Vec<String>>, n
// Compared with what was read, the file's own shape after the same upgrade.
DESIGN.upgrade(&mut upgraded, version, applied_names)?;
let known = serde_json::to_value(&doc).map_err(|e| DocError::Invalid(format!("design file: {e}")))?;
let mut paths = Vec::new();
file.kept = vanished(&upgraded, &known, "", &mut paths);
file.not_understood.extend(paths);
for section in &mut file.sections {
section.empty = known.get(&section.key).cloned();
}
file.compare(&upgraded, &known, FOREIGN_TAG, &CONTAINER_ONLY);
if newer.format > newer.reads_up_to {
// The file's own number and list: it still holds what that build wrote.
doc.format = DESIGN.format_string(newer.format);
@@ -242,72 +126,27 @@ fn salvage(
Ok(()) => true,
Err(why) => {
let name = param.get("name").and_then(Value::as_str).unwrap_or("unnamed");
file.not_understood.push(format!("parameter `{name}` ({})", short(&why.to_string())));
file.elements.push(("params".into(), param.clone()));
let note = format!("parameter `{name}` ({})", short(&why.to_string(), "design"));
file.keep_element("params", param.clone(), note);
false
}
});
}
// Any other section, by itself.
let keys: Vec<String> = top.keys().cloned().collect();
for key in &keys {
let value = top.get(key).cloned().unwrap_or_default();
if let Err(why) = piece(key, &value) {
move_out(top, key, &why.to_string(), file);
}
}
leave_out_unreadable(top, file, "design", piece);
if !top.get("name").is_some_and(Value::is_string) {
top.insert("name".into(), json!("Untitled"));
}
for key in ["params", "features"] {
top.entry(key).or_insert_with(|| json!([]));
}
let attempt = |top: &Map<String, Value>| format::decode(Value::Object(top.clone()), version, applied);
let first = match attempt(top) {
Ok(doc) => return Ok(doc),
Err(why) => why,
};
// Sections that are fine by themselves but not together with the rest.
let keys: Vec<String> = top.keys().filter(|k| !CORE.contains(&k.as_str())).cloned().collect();
// Each try reads the whole design: a file of many sections goes straight to the last resort.
for key in keys.iter().filter(|_| keys.len() <= MAX_SECTION_TRIES) {
let mut without = top.clone();
without.remove(key);
if let Ok(doc) = attempt(&without) {
move_out(top, key, &first.to_string(), file);
return Ok(doc);
}
}
let mut bare = top.clone();
bare.retain(|key, _| CORE.contains(&key.as_str()));
match attempt(&bare) {
Ok(doc) => {
for key in &keys {
move_out(top, key, &first.to_string(), file);
}
Ok(doc)
}
Err(_) => Err(first.to_string()),
}
}
/// Leave a section out and keep it. A section the design needs is left empty.
fn move_out(top: &mut Map<String, Value>, key: &str, why: &str, file: &mut NewerFile) {
let Some(value) = top.remove(key) else { return };
file.not_understood.push(format!("section `{key}` ({})", short(why)));
file.sections.push(Section { key: key.into(), value, empty: None });
}
/// The first line of a reader's error, without the list of every kind it knows.
fn short(why: &str) -> &str {
let why = why.split(", expected").next().unwrap_or(why);
why.strip_prefix("design file: ").unwrap_or(why)
read_around(top, file, &CORE, "design", |top: &Map<String, Value>| format::decode(Value::Object(top.clone()), version, applied))
}
fn feature_note(feature: &Value, why: &str) -> String {
let name = feature.get("name").and_then(Value::as_str).unwrap_or("unnamed");
let kind = feature.get("type").and_then(Value::as_str).unwrap_or("unnamed");
let why = short(why);
let why = short(why, "design");
if why.starts_with("unknown variant") {
format!("feature `{name}`, a `{kind}` feature this version doesn't know")
} else {
@@ -362,160 +201,16 @@ fn unwrapped(feature: &mut Value) {
*feature = Value::Object(raw);
}
/// A value a writer leaves out without losing anything: not worth telling anyone about.
fn looks_default(v: &Value) -> bool {
match v {
Value::Null => true,
Value::Bool(b) => !b,
Value::Number(n) => n.as_f64() == Some(0.0),
Value::String(s) => s.is_empty(),
Value::Array(a) => a.is_empty(),
Value::Object(o) => o.is_empty(),
}
}
/// What `raw` has that `known`, what was read of it, doesn't. `paths` gets those fields' names.
fn vanished(raw: &Value, known: &Value, path: &str, paths: &mut Vec<String>) -> Option<Kept> {
match (raw, known) {
(Value::Object(raw), Value::Object(known)) => {
if known.get("type").and_then(Value::as_str) == Some(FOREIGN_TAG) {
return None;
}
let mut kept = BTreeMap::new();
for (key, value) in raw {
if CONTAINER_ONLY.contains(&key.as_str()) || (path.is_empty() && matches!(key.as_str(), "format" | "format_pending")) {
continue;
}
let inner = if path.is_empty() { key.clone() } else { format!("{path}.{key}") };
match known.get(key) {
None => {
if !looks_default(value) {
paths.push(format!("field `{inner}`"));
}
kept.insert(key.clone(), Kept::Field(value.clone()));
}
Some(known) => {
if let Some(k) = vanished(value, known, &inner, paths) {
kept.insert(key.clone(), k);
}
}
}
}
(!kept.is_empty()).then_some(Kept::Fields(kept))
}
(Value::Array(raw), Value::Array(known)) => {
let mut items = Vec::new();
for (at, element) in raw.iter().enumerate() {
let anchor = anchor_of(element).unwrap_or(Anchor::Index { at, of: raw.len() });
let Some(partner) = find(known, &anchor) else { continue };
let label = match (element.get("name").and_then(Value::as_str), &anchor) {
(Some(name), _) => format!("{path}[{name}]"),
(None, Anchor::Id(id)) => format!("{path}[{id}]"),
_ => format!("{path}[{at}]"),
};
if let Some(k) = vanished(element, partner, &label, paths) {
items.push((anchor, k));
}
}
(!items.is_empty()).then_some(Kept::Items(items))
}
_ => None,
}
}
fn anchor_of(element: &Value) -> Option<Anchor> {
if let Some(id) = element.get("id").and_then(Value::as_u64) {
return Some(Anchor::Id(id));
}
element.get("name").and_then(Value::as_str).map(|name| Anchor::Name(name.to_string()))
}
fn find<'a>(list: &'a [Value], anchor: &Anchor) -> Option<&'a Value> {
match anchor {
Anchor::Id(id) => list.iter().find(|x| x.get("id").and_then(Value::as_u64) == Some(*id)),
Anchor::Name(name) => list.iter().find(|x| x.get("name").and_then(Value::as_str) == Some(name)),
Anchor::Index { at, of } => (list.len() == *of).then(|| list.get(*at)).flatten(),
}
}
fn find_mut<'a>(list: &'a mut [Value], anchor: &Anchor) -> Option<&'a mut Value> {
match anchor {
Anchor::Id(id) => list.iter_mut().find(|x| x.get("id").and_then(Value::as_u64) == Some(*id)),
Anchor::Name(name) => list.iter_mut().find(|x| x.get("name").and_then(Value::as_str) == Some(name)),
Anchor::Index { at, of } => (list.len() == *of).then(|| list.get_mut(*at)).flatten(),
}
}
/// Put the kept fields back. An element that is gone was deleted here, its fields with it; one
/// whose place can't be told any more, and a field whose record is gone, go into `lost`.
fn apply(kept: &Kept, value: &mut Value, path: &str, lost: &mut Vec<String>) {
match kept {
Kept::Field(_) => {}
Kept::Fields(fields) => {
let Some(object) = value.as_object_mut() else {
lost.push(format!("field `{path}`"));
return;
};
for (key, sub) in fields {
let inner = if path.is_empty() { key.clone() } else { format!("{path}.{key}") };
match (sub, object.get_mut(key)) {
(Kept::Field(v), None) => {
object.insert(key.clone(), v.clone());
}
(Kept::Field(_), Some(_)) => {}
(sub, Some(inner_value)) => apply(sub, inner_value, &inner, lost),
// The list it was in is empty and not written: its elements were deleted.
(Kept::Items(_), None) => {}
(_, None) => lost.push(format!("field `{inner}`")),
}
}
}
Kept::Items(items) => {
let Some(list) = value.as_array_mut() else {
lost.push(format!("field `{path}`"));
return;
};
for (anchor, sub) in items {
match (find_mut(list, anchor), anchor) {
(Some(element), _) => apply(sub, element, path, lost),
(None, Anchor::Index { at, .. }) => lost.push(format!("element {at} of `{path}`")),
(None, _) => {}
}
}
}
}
}
/// Write back what the design holds of the file a newer build saved, into `value`, the design's
/// JSON. Returns what couldn't go back ([`not_kept`]).
pub fn restore(doc: &Document, value: &mut Value) -> Vec<String> {
let mut lost = Vec::new();
let Some(file) = &doc.newer_file else { return lost };
let Some(file) = &doc.newer_file else { return Vec::new() };
if let Some(Value::Array(features)) = value.get_mut("features") {
for feature in features.iter_mut().filter(|f| f.get("type").and_then(Value::as_str) == Some(FOREIGN_TAG)) {
unwrapped(feature);
}
}
if let Some(kept) = &file.kept {
apply(kept, value, "", &mut lost);
}
if let Some(top) = value.as_object_mut() {
for section in &file.sections {
if top.get(&section.key) == section.empty.as_ref() {
top.insert(section.key.clone(), section.value.clone());
} else {
lost.push(format!("section `{}`, replaced by what was edited here", section.key));
}
}
for (key, element) in &file.elements {
let name = element.get("name");
match top.entry(key.clone()).or_insert_with(|| json!([])) {
Value::Array(list) if name.is_none() || !list.iter().any(|x| x.get("name") == name) => list.push(element.clone()),
_ => lost.push(format!("`{key}` element {}, replaced by what was made here", name.and_then(Value::as_str).unwrap_or("unnamed"))),
}
}
}
lost
file.restore_into(value)
}
/// What of the file a newer build saved the next save can't keep, because the design was changed
+1 -1
View File
@@ -91,7 +91,7 @@ pub(super) fn local_geometry(f: &Feature, components: &std::collections::HashMap
match &f.kind {
// A cylinder placed by reference edges follows the body it stands on.
FeatureKind::Cylinder { references, .. } if !references.is_empty() => false,
FeatureKind::Box { corner: _, length: _, width: _, height: _, operation: Operation::NewBody }
FeatureKind::Box { corner: _, length: _, width: _, height: _, operation: Operation::NewBody, centred: _ }
| FeatureKind::Cylinder { base: _, axis: _, radius: _, height: _, operation: Operation::NewBody, references: _ }
| FeatureKind::Sphere { center: _, radius: _, operation: Operation::NewBody }
| FeatureKind::Torus { center: _, major: _, minor: _, operation: Operation::NewBody }
+1
View File
@@ -333,6 +333,7 @@ mod tests {
width: "3 mm".into(),
height: "4 mm".into(),
operation: crate::Operation::NewBody,
centred: false,
};
doc.add_feature(block, Some("Block")).unwrap();
let mut model = crate::Model::default();
+6 -1
View File
@@ -285,6 +285,7 @@ fn set_back_restores_everything_when_states_share_bodies() {
width: "10".into(),
height: height.into(),
operation: Operation::NewBody,
centred: false,
},
None,
)
@@ -393,6 +394,7 @@ fn rolling_a_large_unchanged_timeline_forward_reuses_its_geometry() {
width: "10".into(),
height: "10".into(),
operation: Operation::NewBody,
centred: false,
},
None,
)
@@ -944,6 +946,7 @@ fn errors_are_reported_not_fatal() {
width: "1".into(),
height: "1".into(),
operation: Operation::NewBody,
centred: false,
},
None,
)
@@ -1026,6 +1029,7 @@ fn primitives_and_combine() {
width: "10".into(),
height: "10".into(),
operation: Operation::NewBody,
centred: false,
},
None,
)
@@ -1441,6 +1445,7 @@ fn empty_boolean_targets_without_overlap_keep_plain_message() {
width: "1".into(),
height: "1".into(),
operation,
centred: false,
},
None,
)
@@ -1455,7 +1460,7 @@ fn empty_boolean_targets_without_overlap_keep_plain_message() {
/// A box of `size` at `corner`, made with `operation`.
fn cube(doc: &mut Document, corner: Vec3, size: [f64; 3], operation: Operation) -> u64 {
let [length, width, height] = size.map(|x| Expr::new(x.to_string()));
doc.add_feature(FeatureKind::Box { corner: point_expr(corner), length, width, height, operation }, None).unwrap()
doc.add_feature(FeatureKind::Box { corner: point_expr(corner), length, width, height, operation, centred: false }, None).unwrap()
}
/// The bodies of `doc` evaluated, by name with their volumes and pieces, and how feature
@@ -321,6 +321,27 @@ fn shallow_loft_cut_from_the_top_face() {
assert!((got - want).abs() < 1e-9 * want, "{got} vs {want}");
}
/// A carved top lofted through six closed spline sections from its slab's top face and joined
/// onto the slab. The loft lies on and above that face and shares only it with the slab, so the
/// joined top is exactly the slab plus the loft built as its own body.
#[test]
fn carved_top_lofted_onto_its_slab_joins() {
let script = include_str!("../../../examples/regressions/loft_join_carved_top_on_slab.json");
let joined = replay(script);
assert_eq!(joined.model.state().bodies.len(), 1);
let mut apart: Value = serde_json::from_str(script).unwrap();
let loft = apart["commands"].as_array_mut().unwrap().last_mut().unwrap();
loft["params"]["operation"] = json!("new_body");
loft["params"]["body_name"] = json!("Arch");
loft["params"].as_object_mut().unwrap().remove("targets");
let mut s = Session::default();
s.run_script(&apart).unwrap();
let (slab, arch) = (volume(&s, "Top"), volume(&s, "Arch"));
assert!(slab > 0.0 && arch > 0.0, "{slab} {arch}");
let got = volume(&joined, "Top");
assert!((got - (slab + arch)).abs() < 1e-7 * got, "{got} vs {slab} + {arch}");
}
#[test]
fn oblique_cut_through_a_small_arc_matches_a_split() {
let scripts = [
+12 -22
View File
@@ -71,7 +71,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.icon("box")
.contract(&BOX_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params("length, width, height: expr; corner?: [x,y,z] | center?: [x,y,z] (numbers in mm or length expressions; default: the origin); operation?, touching?, targets?: [body], name?, body_name?"),
.params("length, width, height: expr; corner?: [x,y,z] | center?: [x,y,z] (numbers in mm or length expressions; default: the origin; a box placed by its center stays centred there when its size changes); operation?, touching?, targets?: [body], name?, body_name?"),
CommandSpec::new("solid.cylinder", "Cylinder", crate::checked!("solid.cylinder", prim_cylinder))
.at("SOLID", "CREATE")
.icon("cylinder")
@@ -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")
@@ -1534,27 +1534,14 @@ fn prim_box(s: &mut Session, p: &Value) -> Result<Value> {
for (e, n) in [(&l, "length"), (&w, "width"), (&h, "height")] {
check_expr(s, e, Kind::Length, cmd, n)?;
}
let corner = match (point_param(s, p, "corner", cmd)?, point_param(s, p, "center", cmd)?) {
(Some(c), _) => c,
// The corner half a size back from the centre (numbers stay numbers, in mm). The centre's
// bare numbers are millimetres, the sizes' the design's units.
(None, Some([cx, cy, cz])) => {
let unit = typed_length_unit(s);
let mm_per_unit = solidcraft_doc::expr::unit_info(&unit).map_or(1.0, |(_, k)| k);
let back = |c: Expr, size: &Expr| -> Expr {
match (c.parse::<f64>(), size.parse::<f64>()) {
(Ok(a), Ok(b)) => format!("{}", a - b * mm_per_unit / 2.0).into(),
_ => {
let [c, size] = length_operands(s, [(&c, "mm"), (size, &unit)]);
format!("({c}) - ({size}) / 2").into()
}
}
};
[back(cx, &l), back(cy, &w), back(cz, &h)]
}
_ => point_expr(Vec3::ZERO),
// A box placed by its centre keeps that centre, so a later length, width or height grows it on
// both sides (#14487).
let (corner, centred) = match (point_param(s, p, "corner", cmd)?, point_param(s, p, "center", cmd)?) {
(Some(c), _) => (c, false),
(None, Some(c)) => (c, true),
_ => (point_expr(Vec3::ZERO), false),
};
add_feature(s, p, FeatureKind::Box { corner, length: l, width: w, height: h, operation: operation(p, cmd)? })
add_feature(s, p, FeatureKind::Box { corner, length: l, width: w, height: h, operation: operation(p, cmd)?, centred })
}
fn prim_cylinder(s: &mut Session, p: &Value) -> Result<Value> {
@@ -2959,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")?;
@@ -2968,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)?;
@@ -2977,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.
+124 -39
View File
@@ -28,15 +28,16 @@ use crate::schema::file::{
RecoverResult, RecoveredDesign, RecoveryDiscardParams, RecoveryDiscardResult, RecoveryListParams, RecoveryListResult,
};
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};
use crate::{EngineError, Result, Session};
const INSERT_MESH_CONTRACT: TypedContract<InsertMeshParams> = TypedContract::new();
const INSERT_STEP_CONTRACT: TypedContract<InsertStepParams> = TypedContract::new();
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("file.new", "New Design", crate::checked!("file.new", new_doc))
@@ -53,9 +54,11 @@ pub static COMMANDS: &[CommandSpec] = &[
.contract(&OPEN_CONTRACT)
.output(output_schema_for::<OpenResult>)
.params("path: .sc design (or a .solidcraft design from an earlier release), .dxf/.svg drawing (opens as a new, unsaved design with an XY sketch, using sketch.insert_dxf/sketch.insert_svg units and defaults), or a .step/.stp/.igs/.fcstd/.3dm/.3mf/.stl/.obj/.glb/.gltf file (STEP, IGES, FreeCAD, Rhino, 3MF, STL, OBJ or glTF; opens as a new design holding the file as a component named after it, as file.insert_step and file.insert_mesh make), or a .ply/.xyz/.pts point cloud (opens as a new design holding it, as pointcloud.insert makes; its `units` and `max_points` as there); up?: z|y for a STEP, IGES, FreeCAD, Rhino or glTF file (see file.insert_step and file.insert_mesh); names?: auto|solid|product|instance for a STEP, IGES, FreeCAD or Rhino file (see file.insert_step); units?: for an STL or OBJ file (see file.insert_mesh); origin?: bool for a mesh file (see file.insert_mesh); folder?: bool for a STEP, IGES, FreeCAD or Rhino file (see file.insert_step); read_only?: bool (a design: saving to its own file is refused, file.save_as keeps the changes elsewhere) → open_elsewhere: {host, user, app, opened} when another SolidCraft has the design open (its lock file is fresh); newer_file: {format, reads_up_to, unknown_changes, not_understood, notice} and warnings when a newer SolidCraft saved it: it opens all the same, and what this version can't read is named there and kept by saving"),
CommandSpec::new("file.insert_step", "Insert STEP", insert_step)
CommandSpec::new("file.insert_step", "Insert STEP", crate::checked!("file.insert_step", insert_step))
.at("SOLID", "INSERT")
.icon("import")
.contract(&INSERT_STEP_CONTRACT)
.output(output_schema_for::<StepInserted>)
.params("path: STEP, IGES, FreeCAD .FCStd or Rhino .3dm file (its bodies join the design as an Import base feature in a new component named after the file, placed in the active component; an assembly's products become nested components, a repeated part one component with an occurrence per placement, all held as one rigid group until component.unlock → component, component_name, occurrence, subcomponents?, rigid_joints?, group?: the folder); folder?: bool (default false; true: an import that brings more than one item into the history (its Import feature and the rigid joints holding its parts) goes into a new history folder named `Group #N`); name? (the feature's and the component's); up?: z|y (default z: the file's axes are kept; y: the file has Y up, so the import's component is turned a quarter turn about X, Y becoming Z → stand_up: its occurrence); names?: auto|solid|product|instance (default auto: keep meaningful solid names, replace placeholders with the nearest named product, else instance; solid: the solid's own name; product: the part's name; instance: the occurrence's name, else the product's); matrix?: [12 or 16 numbers] (where the new component's occurrence sits in the active component, after `up`: a rigid row-major 3x4 rotation-and-translation or 4x4 matrix, flat or as rows, as occurrence.move takes it; a scale, shear or mirror is refused before the file is read); tolerance? (see occurrence.move)"),
CommandSpec::new("file.insert_mesh", "Insert Mesh", crate::checked!("file.insert_mesh", insert_mesh))
.at("SOLID", "INSERT")
@@ -98,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`.
@@ -113,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()
@@ -138,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")
@@ -175,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();
@@ -287,23 +290,29 @@ fn add_step(s: &mut Session, path: &str, name: Option<&str>, options: &StepOptio
let created = created_bodies(s, id);
let folder = if options.folder { import_folder(s, id, &placed)? } else { None };
let fname = s.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default();
let mut out = json!({"path": path, "feature": id, "name": fname, "bodies": created, "created_bodies": created, "warnings": sf.warnings, "system": sf.import.system});
if let Some((up, exporter)) = exporter_up(&sf.import) {
out["exporter"] = json!(exporter);
out["exporter_up"] = json!(up);
}
placed.report(&mut out);
if let Some(turned) = turned {
out["stand_up"] = json!(turned);
}
if let Some(folder) = folder {
out["group"] = json!(folder);
}
if let Some(mark) = profiled {
let report = crate::schema::diagnostics::ProfileReport::new(&mark.report(), 20);
out["profile"] = serde_json::to_value(report).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
}
Ok(out)
let known = exporter_up(&sf.import);
let reply = StepInserted {
path: path.to_string(),
feature: id,
name: fname,
bodies: created.clone(),
created_bodies: created,
warnings: sf.warnings,
system: sf.import.system.clone(),
exporter: known.map(|(_, exporter)| exporter.to_string()),
exporter_up: known.map(|(up, _)| up.to_string()),
component: placed.component,
component_name: placed.name.clone(),
occurrence: placed.occurrence,
subcomponents: (placed.components > 0).then_some(placed.components),
rigid_joints: (placed.components > 0).then_some(placed.joints.len()),
stand_up: turned,
group: folder,
transform: None,
profile: profiled.map(|mark| crate::schema::diagnostics::ProfileReport::new(&mark.report(), 20)),
param_warnings: Vec::new(),
};
serde_json::to_value(reply).map_err(|e| EngineError::Other(format!("{path}: {e}")))
}
/// An import that brings more than one item into the history (its Import feature and the rigid
@@ -644,6 +653,13 @@ fn opened_design(
s.replace_design(opened);
s.disk = disk;
s.read_only = read_only;
// A design saved with print faces (by an older version, or with them kept) opens without
// them when they are not kept (#11512). The rest of it opens as saved, and dropping them is
// neither an unsaved change nor a step to undo.
if !s.keep_print_faces && !s.doc.print_faces.is_empty() {
s.doc_mut().print_faces.clear();
s.mark_saved();
}
let errors = s.model.results.iter().filter(|r| r.error.is_some()).count();
let now = solidcraft_io::design_lock::now_secs();
let open_elsewhere = solidcraft_io::design_lock::held_elsewhere(std::path::Path::new(path), now).map(|lock| OpenElsewhere {
@@ -964,6 +980,10 @@ impl Session {
if !self.visibility.is_empty() {
design.to_mut().visibility = self.visibility.clone();
}
// Print faces not kept stay with this session: the file is saved without them (#11512).
if !self.keep_print_faces && !design.print_faces.is_empty() {
design.to_mut().print_faces.clear();
}
// A sketch edited with the timeline rolled to it: the file holds the position the edit
// returns to, not the temporary rollback (#14010).
if let Some(marker) = self.sketch_restore_marker() {
@@ -1202,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()))
}
@@ -1212,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,
@@ -1222,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,
@@ -1252,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> {
@@ -1264,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> {
@@ -1412,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")]
{
@@ -1435,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.
+103 -14
View File
@@ -9,7 +9,6 @@
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use serde_json::{Value, json};
use solidcore_record::Vec3;
use solidcraft_doc::joints::{Joint, JointKind, JointOrigin, Snap};
use solidcraft_doc::kernel::{StepImport, StepNode};
@@ -187,7 +186,109 @@ fn node_key(imp: &StepImport, node: &StepNode, depth: usize) -> u64 {
h.finish()
}
/// The import's component, as the command reports it.
/// 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 {
pub component: u64,
pub occurrence: Option<u64>,
@@ -198,18 +299,6 @@ pub(crate) struct Placed {
pub joints: Vec<u64>,
}
impl Placed {
pub fn report(&self, out: &mut Value) {
out["component"] = json!(self.component);
out["component_name"] = json!(self.name);
out["occurrence"] = json!(self.occurrence);
if self.components > 0 {
out["subcomponents"] = json!(self.components);
out["rigid_joints"] = json!(self.joints.len());
}
}
}
/// The component an import makes: named `name`, in the active component at its origin.
fn new_component(s: &mut Session, name: &str) -> Result<(u64, Option<u64>)> {
let parent = s.active_component;
+2
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};
@@ -112,6 +113,7 @@ mod print_face;
mod render;
pub(crate) use render::{
PreparedCapture, prepare_capture_with_metadata, read_render_request, render_cancel_params, render_form_refusal, render_saved_job_refusal,
render_started,
};
pub(crate) use render_ui::batch_request;
mod render_ui;
+32
View File
@@ -423,6 +423,38 @@ mod tests {
assert!(g.execute("plastic.lip", &json!({"face": [40, 25, 0], "width": 1, "height": 2})).is_err(), "a face without an inner edge");
}
#[test]
fn groove_on_bottom_rim_of_rounded_shell() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": solidcore_record::Plane::XY, "name": "Outline"}));
run(
&mut s,
"sketch.rectangle.rounded",
json!({"p0": [-25.263865881072945, -28.541660374585607], "p1": [25.263865881072945, 28.541660374585607], "radius": 3}),
);
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "Outline", "distance": 10}));
run(&mut s, "solid.shell", json!({"faces": [[0, 0, 0]], "thickness": 2}));
let state = s.model.state();
let shell = &state.body("Body1").unwrap().body;
assert!(shell.validity().is_empty(), "shell: {:?}", shell.validity());
let (plane, band) = solidcraft_kernel::rim_band(shell, Vec3::new(0.0, -27.541660374585607, 0.0), 1.5, 0.0, false).unwrap();
let cutter = solidcraft_kernel::extrude(&plane, &[band], -2.0, 0.0).unwrap();
for body in &cutter {
assert!(body.validity().is_empty(), "cutter: {:?}", body.validity());
}
let before = volume(&mut s);
let result = s.execute("plastic.lip", &json!({"face": [0, -27.541660374585607, 0], "width": 1.5, "height": 2, "type": "groove"}));
assert!(result.is_ok(), "{result:?}");
let area = |w: f64, h: f64, r: f64| w * h - (4.0 - PI) * r * r;
let w = 50.52773176214589;
let h = 57.083320749171214;
let groove_area = area(w - 1.0, h - 1.0, 2.5) - area(w - 4.0, h - 4.0, 1.0);
let removed = before - volume(&mut s);
assert!((removed - groove_area * 2.0).abs() < 0.05, "removed {removed}, expected {}", groove_area * 2.0);
assert!(s.model.state().body("Body1").unwrap().body.validity().is_empty());
}
/// A lip follows its rim face when a parameter moves it (#11209): it was kept by a point
/// only, so a taller box left the point below the rim and the lip failed.
#[test]
+190 -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();
@@ -296,6 +297,193 @@ mod tests {
assert!(opened.print_faces.is_empty());
}
/// A file in a fresh folder of the system's temporary folder, for `case`.
fn temp_file(case: &str) -> String {
let dir = std::env::temp_dir().join(format!("solidcraft-print-faces-{case}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("temporary folder");
dir.join("block.solidcraft").to_string_lossy().into_owned()
}
#[test]
fn without_keep_print_faces_a_save_leaves_them_out_and_the_session_keeps_them() {
let mut s = block();
s.keep_print_faces = false;
run(&mut s, "body.print_face", json!({"body": "Block", "point": [10, 5, 5]}));
let path = temp_file("save");
run(&mut s, "file.save", json!({"path": path}));
// The design on screen still has it, and is not left with unsaved changes.
assert!(s.doc.print_faces.contains_key("Block"));
assert!(!s.is_dirty());
let mut opened = Session::default();
run(&mut opened, "doc.open", json!({"path": path}));
assert!(opened.doc.print_faces.is_empty(), "the file has no print face");
assert_eq!(opened.doc.features.len(), s.doc.features.len());
assert_eq!(opened.doc.params, s.doc.params);
// With keep_print_faces on (the engine's default), the file keeps it, as before.
s.keep_print_faces = true;
run(&mut s, "file.save", json!({"path": path}));
let mut opened = Session::default();
run(&mut opened, "doc.open", json!({"path": path}));
assert_eq!(opened.doc.print_faces, s.doc.print_faces);
}
#[test]
fn a_design_saved_with_print_faces_opens_without_them_unless_they_are_kept() {
let mut s = block();
run(&mut s, "material.assign", json!({"bodies": ["Block"], "material": "Brass"}));
run(&mut s, "body.print_face", json!({"body": "Block", "point": [10, 5, 5]}));
// Saved as an older version saved it: with the print face in the file.
let path = temp_file("older");
run(&mut s, "file.save", json!({"path": path}));
let mut opened = Session { keep_print_faces: false, ..Session::default() };
run(&mut opened, "doc.open", json!({"path": path}));
assert!(opened.doc.print_faces.is_empty(), "the stored print face is dropped as the design opens");
assert!(!opened.is_dirty(), "dropping it is not an unsaved change");
assert!(opened.undo.is_empty(), "nor a step to undo");
// Everything else the file holds is there.
assert_eq!(opened.doc.features.len(), s.doc.features.len());
assert_eq!(opened.doc.params, s.doc.params);
assert_eq!(opened.doc.materials, s.doc.materials);
assert_eq!(opened.model.state().bodies.len(), 1);
// The setting is the user's: it survives the open.
assert!(!opened.keep_print_faces);
// Kept, the design opens with its print face.
let mut kept = Session::default();
run(&mut kept, "doc.open", json!({"path": path}));
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();
File diff suppressed because one or more lines are too long
+119 -50
View File
@@ -2,6 +2,7 @@ use crate::schema::render_jobs::{
ExportFormat, ImageCopied, ImageExported, Job, JobProgressReply, JobState, PreparationCancelled, QueueReply, RenderCancelled, RenderProgress,
ResultPinned,
};
use crate::schema::render_start::{Checked, Denoising, GpuChecked, IndirectClamp, RenderChecked, RenderStarted, RuntimeChecked};
use crate::{Result, Session, params::bad, render_jobs::job_error};
use serde::Deserialize;
use serde_json::{Value, json};
@@ -95,7 +96,7 @@ struct ProgressiveStart {
pub(crate) struct Prepared {
pub(crate) replace: bool,
pub(crate) submission: Option<solidcraft_render::job::PreparedSubmission>,
pub(crate) reply: Value,
pub(crate) reply: RenderChecked,
}
fn lower_led(input: &mut Start, command: &str) -> Result<()> {
@@ -662,25 +663,31 @@ pub(crate) fn prepare_with_metadata(
(true, false) => vec!["thin_sheet", "closed_box"],
(false, _) => Vec::new(),
};
let mut reply = json!({"ignored_controls":ignored,"stage_panels":settings.stage.len(),"stage_transport":stage_transport, "cpu_budget":budget,"cpu_limits":budget.limits(),"environment":environment_info,"textures":request.textures.as_ref().map(|maps|maps.summary()),"retained_request_bytes":retained_request_bytes,"resolved":resolved,"admission":admission,"request":request,"scene_generation":s.revision,"host":host.kind(),"transparency":transparency,"refraction":refraction,"dielectric_boundaries":dielectric_boundaries});
if !coats.is_empty() {
reply["coats"] = json!(coats);
}
if let Some(options) = request.raytrace.as_ref().filter(|options| options.denoise) {
reply["denoising"] = json!({
"method":"edge_aware_atrous", "backend":"native_cpu", "passes":options.denoise_settings.passes,
"steps":denoise_steps(options.denoise_settings.passes), "settings":options.denoise_settings,
"diagnostics_requested":options.denoise_diagnostics,
"outlier_policy":if options.denoise_settings.firefly_suppression {DENOISE_OUTLIER_POLICY} else {"disabled"},
"optical_policy":if options.denoise_settings.optical_filter {DENOISE_OPTICAL_POLICY} else {DENOISE_OPAQUE_ONLY_POLICY},
"guides":"deterministic_center_ray_albedo_normal_camera_relative_position_depth",
"bypass":if options.denoise_settings.optical_filter {"unsupported or ambiguous optical paths, emitter, background and partial coverage"} else {"all optical paths, emitter, background and partial coverage"},
"limitation":"biased spatial filter; optical guides do not decompose reflected and transmitted radiance or reconstruct depth-of-field correspondences",
"raw_linear_accumulator_preserved":true,"raw_linear_exr_preserved":true,
"output_stage":"linear_before_tone_map", "work_units":"one pixel visit per pass, each with at most 25 taps",
"checkpoints":"every complete published pass uses the same filter"
});
}
let denoising = request.raytrace.as_ref().filter(|options| options.denoise);
let checked = Checked {
resolved: retyped(command, &resolved)?,
admission: retyped(command, &admission)?,
request: retyped(command, request)?,
scene_generation: s.revision,
host: retyped(command, &host.kind())?,
stage_panels: settings.stage.len(),
environment: environment_info.as_ref().map(|info| retyped(command, info)).transpose()?,
textures: request.textures.as_ref().map(|maps| retyped(command, &maps.summary())).transpose()?,
transparency,
refraction,
dielectric_boundaries,
coats: (!coats.is_empty()).then(|| retyped(command, &coats)).transpose()?,
denoising: denoising.map(|options| denoising_reply(command, &options.denoise_settings, options.denoise_diagnostics, true)).transpose()?,
param_warnings: Vec::new(),
};
let reply = RenderChecked::Runtime(RuntimeChecked {
checked,
ignored_controls: ignored,
stage_transport,
cpu_budget: retyped(command, &budget)?,
cpu_limits: retyped(command, &budget.limits())?,
retained_request_bytes,
});
Ok(Prepared { replace: input.replace, submission: Some(submission), reply })
}
@@ -907,30 +914,40 @@ fn prepare_progressive(
.and_then(|bytes| bytes.checked_add(retained_options_bytes))
.filter(|bytes| *bytes <= host.job_limit_bytes())
.ok_or_else(|| bad(command, "GPU work and retained metadata exceed the per-job limit"))?;
let reply = json!({"stage_panels":settings.stage.len(),"resolved":resolved,"admission":admission,"request":request,"textures":request.textures.as_ref().map(|resources|resources.summary()),"scene_generation":s.revision,"host":host.kind(),"transparency":if refraction {"physical_transmission"} else if input.transparent_background {"coverage_alpha"} else if input.progressive {"opaque_only"} else {"sorted_alpha_approximation"},"options":options,"retained_options_bytes":retained_options_bytes,"environment":request.environment.as_ref().map(|source|source.info()),"refraction":refraction,"dielectric_boundaries": if resolved.executor.as_str() == "gpu-path" {vec!["thin_sheet","closed_box","closed_mesh"]} else {Vec::<&str>::new()}});
let mut reply = reply;
if !coats.is_empty() {
reply["coats"] = json!(coats);
}
if options.denoise {
reply["denoising"] = json!({
"method":"edge_aware_atrous", "passes":options.denoise_settings.passes,
"steps":denoise_steps(options.denoise_settings.passes), "settings":options.denoise_settings,
"diagnostics_requested":options.denoise_diagnostics,
"outlier_policy":if options.denoise_settings.firefly_suppression {DENOISE_OUTLIER_POLICY} else {"disabled"},
"optical_policy":if options.denoise_settings.optical_filter {DENOISE_OPTICAL_POLICY} else {DENOISE_OPAQUE_ONLY_POLICY},
"guides":"deterministic_center_ray_albedo_normal_position_depth",
"bypass":if options.denoise_settings.optical_filter {"unsupported or ambiguous optical paths, emitter, background and partial coverage"} else {"all optical paths, emitter, background and partial coverage"},
"limitation":"optical guides do not decompose reflected and transmitted radiance or reconstruct depth-of-field correspondences",
"raw_linear_accumulator_preserved":true, "output_stage":"linear_before_tone_map"
});
}
if let Some(limit) = options.clamp_indirect {
reply["indirect_clamp"] = json!({
"limit":limit, "from_bounce":1, "keeps_hue":true, "biased":true,
"limitation":"dims real caustics and glints; the raw linear EXR master is clamped too; optical glass light connections resolved after the path are not clamped"
});
}
let transparency = if refraction {
"physical_transmission"
} else if input.transparent_background {
"coverage_alpha"
} else if input.progressive {
"opaque_only"
} else {
"sorted_alpha_approximation"
};
let dielectric_boundaries = if resolved.executor.as_str() == "gpu-path" { vec!["thin_sheet", "closed_box", "closed_mesh"] } else { Vec::new() };
let checked = Checked {
resolved: retyped(command, &resolved)?,
admission: retyped(command, &admission)?,
request: retyped(command, request)?,
scene_generation: s.revision,
host: retyped(command, &host.kind())?,
stage_panels: settings.stage.len(),
environment: request.environment.as_ref().map(|source| retyped(command, source.info())).transpose()?,
textures: request.textures.as_ref().map(|resources| retyped(command, &resources.summary())).transpose()?,
transparency,
refraction,
dielectric_boundaries,
coats: (!coats.is_empty()).then(|| retyped(command, &coats)).transpose()?,
denoising: options.denoise.then(|| denoising_reply(command, &options.denoise_settings, options.denoise_diagnostics, false)).transpose()?,
param_warnings: Vec::new(),
};
let indirect_clamp = options.clamp_indirect.map(|limit| IndirectClamp {
limit,
from_bounce: 1,
keeps_hue: true,
biased: true,
limitation: "dims real caustics and glints; the raw linear EXR master is clamped too; optical glass light connections resolved after the path are not clamped",
});
let reply = RenderChecked::Gpu(GpuChecked { checked, options: retyped(command, &options)?, retained_options_bytes, indirect_clamp });
Ok(Prepared { replace: input.replace, submission: Some(submission), reply })
}
@@ -949,8 +966,43 @@ fn ignored_controls(settings: &RenderSetup) -> Vec<&'static str> {
ignored
}
/// How the denoising filter of a CPU ray tracing or GPU request works, for its preflight reply.
fn denoising_reply(command: &str, settings: &solidcraft_render::job::DenoiseSettings, diagnostics: bool, cpu: bool) -> Result<Denoising> {
Ok(Denoising {
method: "edge_aware_atrous",
backend: cpu.then_some("native_cpu"),
passes: settings.passes,
steps: denoise_steps(settings.passes),
settings: retyped(command, settings)?,
diagnostics_requested: diagnostics,
outlier_policy: if settings.firefly_suppression { DENOISE_OUTLIER_POLICY } else { "disabled" },
optical_policy: if settings.optical_filter { DENOISE_OPTICAL_POLICY } else { DENOISE_OPAQUE_ONLY_POLICY },
guides: if cpu {
"deterministic_center_ray_albedo_normal_camera_relative_position_depth"
} else {
"deterministic_center_ray_albedo_normal_position_depth"
},
bypass: if settings.optical_filter {
"unsupported or ambiguous optical paths, emitter, background and partial coverage"
} else {
"all optical paths, emitter, background and partial coverage"
},
limitation: if cpu {
"biased spatial filter; optical guides do not decompose reflected and transmitted radiance or reconstruct depth-of-field correspondences"
} else {
"optical guides do not decompose reflected and transmitted radiance or reconstruct depth-of-field correspondences"
},
raw_linear_accumulator_preserved: true,
raw_linear_exr_preserved: cpu.then_some(true),
output_stage: "linear_before_tone_map",
work_units: cpu.then_some("one pixel visit per pass, each with at most 25 taps"),
checkpoints: cpu.then_some("every complete published pass uses the same filter"),
})
}
pub(super) fn preflight(s: &mut Session, params: &Value) -> Result<Value> {
prepare(s, params, "render.preflight", &solidcraft_render::job::CancelToken::default()).map(|p| p.reply)
let cmd = "render.preflight";
super::typed_reply(cmd, prepare(s, params, cmd, &solidcraft_render::job::CancelToken::default())?.reply)
}
pub(super) fn start(s: &mut Session, params: &Value) -> Result<Value> {
@@ -964,11 +1016,28 @@ pub(super) fn start(s: &mut Session, params: &Value) -> Result<Value> {
params.get("setup").and_then(Value::as_str).map(str::to_owned),
)
.map_err(job_error)?;
let mut status = json!(status);
if let (Some(out), Some(ignored)) = (status.as_object_mut(), p.reply.get("ignored_controls")) {
out.insert("ignored_controls".into(), ignored.clone());
}
Ok(status)
Ok(started("render.start", &status, &p.reply))
}
/// What Render Image returns once its job is queued: the job's `status`, and for a runtime
/// request the ignored controls of its preflight reply `checked`. The window's own preparation
/// (`render_preparation`) returns it too.
pub(crate) fn started(command: &str, status: &solidcraft_render::job::JobStatus, checked: &RenderChecked) -> Value {
let ignored_controls = match checked {
RenderChecked::Runtime(runtime) => Some(runtime.ignored_controls.clone()),
RenderChecked::Gpu(_) => None,
};
// The job is queued already, so this never fails: should the status ever not read as a
// `JobState`, the reply is the status as the render crate gives it, as before the reply type.
let typed =
retyped(command, status).and_then(|state| super::typed_reply(command, RenderStarted { state, ignored_controls: ignored_controls.clone() }));
typed.unwrap_or_else(|_| {
let mut reply = json!(status);
if let (Some(out), Some(ignored)) = (reply.as_object_mut(), ignored_controls) {
out.insert("ignored_controls".into(), json!(ignored));
}
reply
})
}
#[derive(Deserialize)]
+12 -1
View File
@@ -2,6 +2,8 @@
//! screen pixels, grid, selection highlights or tessellation diagonals are exported. The lines
//! come from [`crate::mesh_lines`]; this command chooses the bodies and writes the DXF.
#[path = "view_dxf_arcs.rs"]
mod arcs;
#[path = "view_dxf_sketch.rs"]
pub(super) mod sketch;
@@ -26,7 +28,7 @@ pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new(CMD, "Export Current Vi
.noundo()
.contract(&CONTRACT)
.output(output_schema_for::<ViewDxfResult>)
.params("camera: Camera (current view); hidden?: omit|dashed (default omit); tolerance?: 0.0001..1 mm (curve chord tolerance, default 0.01); zero?: target|origin (where the drawing's 0,0 is: the camera target, default, or the model origin as seen from the camera); path?: .dxf file (else returns dxf text). Visible placed bodies and shown sketches' profile curves (no construction geometry, points, dimensions or constraints); 2D LINE/LWPOLYLINE entities on VISIBLE/HIDDEN layers (sketch curves on SKETCH), mm; finished sketches are hidden behind bodies, the active sketch is drawn on top; perspective projected onto the camera target plane; no viewport crop or overlays.")];
.params("camera: Camera (current view); hidden?: omit|dashed (default omit); tolerance?: 0.0001..1 mm (curve chord tolerance, default 0.01); curves?: lines|arcs (lines, default, writes the short lines the view is built from; arcs writes visible circles and arcs as CIRCLE and ARC); zero?: target|origin (where the drawing's 0,0 is: the camera target, default, or the model origin as seen from the camera); path?: .dxf file (else returns dxf text). Visible placed bodies and shown sketches' profile curves (no construction geometry, points, dimensions or constraints); 2D LINE/LWPOLYLINE entities on VISIBLE/HIDDEN layers (sketch curves on SKETCH), mm; finished sketches are hidden behind bodies, the active sketch is drawn on top; perspective projected onto the camera target plane; no viewport crop or overlays.")];
fn export(s: &mut Session, p: &Value) -> Result<Value> {
let cam: Camera =
@@ -54,6 +56,12 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
Some(Value::String(v)) if v == "origin" => true,
_ => return Err(bad(CMD, "zero must be target or origin")),
};
let as_arcs = match p.get("curves") {
None => false,
Some(Value::String(v)) if v == "lines" => false,
Some(Value::String(v)) if v == "arcs" => true,
_ => return Err(bad(CMD, "curves must be lines or arcs")),
};
let tolerance = match p.get("tolerance") {
None => 0.01,
Some(v) => v.as_f64().filter(|t| (0.0001..=1.0).contains(t)).ok_or_else(|| bad(CMD, "tolerance must be 0.0001..1 mm"))?,
@@ -104,6 +112,9 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
entities.push(DxfEntity { layer: layer.into(), shape: DxfShape::Line(line.a, line.b) });
}
let mut entities = connect(entities);
if as_arcs {
entities = arcs::recognise(entities, tolerance);
}
let sketch = sketch_entities(drawing.sketch, dashed, &mut entities);
let mut layers = vec![DxfLayer::new("VISIBLE", 7)];
if dashed {
+216
View File
@@ -0,0 +1,216 @@
//! Circles and arcs for the view drawing. The drawing's curved edges come from a tessellation, so
//! a round hole arrives as a ring of short straight lines. A machining program or a CAD program
//! can't pick that as a circle. This finds the chains of visible lines that are a tessellated
//! circle or arc and writes them as `CIRCLE` and `ARC` entities; every other line stays a line.
use std::collections::{BTreeMap, HashMap};
use solidcore_record::Vec2;
use solidcraft_geom::prelude::*;
use solidcraft_io::{DxfEntity, DxfShape};
/// A closed ring needs this many segments to count as a circle, an open run this many to count as
/// an arc. A hexagon or an octagon has every corner on a circle too; the chord check below turns
/// them away, and these minimums keep a few lines from being read as a curve by chance.
const MIN_CLOSED: usize = 8;
const MIN_OPEN: usize = 4;
/// The visible lines of `entities` with the tessellated circles and arcs among them replaced by
/// `CIRCLE` and `ARC`. `tolerance` is the chord tolerance the drawing was made with: a chain is a
/// curve only when its chords sag from the circle by no more than about that much.
pub(super) fn recognise(entities: Vec<DxfEntity>, tolerance: f64) -> Vec<DxfEntity> {
let mut rest = Vec::new();
let mut segments: Vec<(Vec2, Vec2)> = Vec::new();
for e in entities {
match e {
DxfEntity { layer, shape: DxfShape::Line(a, b) } if layer == "VISIBLE" => segments.push((a, b)),
other => rest.push(other),
}
}
let extent = segments.iter().flat_map(|(a, b)| [a, b]).fold(1.0_f64, |s, p| s.max(p.x.abs()).max(p.y.abs()));
// The two faces of a hole project to rings a hair apart; points that close are one point.
snap(&mut segments, (tolerance * 1e-2).max(extent * 1e-9));
let eps = extent * 1e-8;
let key = |p: Vec2| [(p.x / eps).round() as i64, (p.y / eps).round() as i64];
// The same segment twice (the rim of a hole seen on both of its faces) must not make a
// junction of every point of the ring.
let mut seen = std::collections::BTreeSet::new();
segments.retain(|(a, b)| {
let (ka, kb) = (key(*a), key(*b));
ka != kb && seen.insert(if ka <= kb { (ka, kb) } else { (kb, ka) })
});
let mut nodes: BTreeMap<[i64; 2], Vec<usize>> = BTreeMap::new();
for (i, (a, b)) in segments.iter().enumerate() {
nodes.entry(key(*a)).or_default().push(i);
nodes.entry(key(*b)).or_default().push(i);
}
let mut used = vec![false; segments.len()];
let mut out = rest;
for start in 0..segments.len() {
if used.get(start).copied().unwrap_or(true) {
continue;
}
let chain = chain_from(start, &segments, &nodes, &mut used, &key);
match fit(&chain.points, chain.closed, tolerance) {
Some(shape) => out.push(DxfEntity { layer: "VISIBLE".into(), shape }),
None => {
for w in chain.points.windows(2) {
if let [a, b] = w {
out.push(DxfEntity { layer: "VISIBLE".into(), shape: DxfShape::Line(*a, *b) });
}
}
}
}
}
out
}
/// Moves every point to the first point seen within `reach` of it, so near-coincident points of the
/// drawing become exactly equal.
fn snap(segments: &mut [(Vec2, Vec2)], reach: f64) {
let mut reps: Vec<Vec2> = Vec::new();
let mut cells: HashMap<(i64, i64), Vec<usize>> = HashMap::new();
let mut at = |p: Vec2| -> Vec2 {
let (cx, cy) = ((p.x / reach).floor() as i64, (p.y / reach).floor() as i64);
for dx in -1..=1 {
for dy in -1..=1 {
for i in cells.get(&(cx + dx, cy + dy)).into_iter().flatten() {
if let Some(r) = reps.get(*i)
&& (*r - p).len() <= reach
{
return *r;
}
}
}
}
reps.push(p);
cells.entry((cx, cy)).or_default().push(reps.len() - 1);
p
};
for (a, b) in segments.iter_mut() {
*a = at(*a);
*b = at(*b);
}
}
struct Chain {
points: Vec<Vec2>,
closed: bool,
}
/// The run of segments through `start`, continued while a point joins exactly two segments.
fn chain_from(
start: usize,
segments: &[(Vec2, Vec2)],
nodes: &BTreeMap<[i64; 2], Vec<usize>>,
used: &mut [bool],
key: &impl Fn(Vec2) -> [i64; 2],
) -> Chain {
if let Some(u) = used.get_mut(start) {
*u = true;
}
let Some((a, b)) = segments.get(start).copied() else { return Chain { points: Vec::new(), closed: false } };
let mut points = vec![a, b];
let mut closed = false;
for pass in 0..2 {
while let Some(end) = points.last().copied() {
let Some(joined) = nodes.get(&key(end)).filter(|n| n.len() == 2) else { break };
let Some(next) = joined.iter().copied().find(|i| used.get(*i) == Some(&false)) else {
// Both segments are used: the chain came back to where it began.
closed = pass == 0 && points.len() > 2 && points.first().is_some_and(|f| key(*f) == key(end));
break;
};
let Some((p, q)) = segments.get(next).copied() else { break };
if let Some(u) = used.get_mut(next) {
*u = true;
}
points.push(if key(p) == key(end) { q } else { p });
}
if closed {
break;
}
points.reverse();
}
if !closed && points.len() > 2 && matches!((points.first(), points.last()), (Some(f), Some(l)) if key(*f) == key(*l)) {
closed = true;
}
Chain { points, closed }
}
/// The circle or arc a chain of points is a tessellation of, or `None`.
fn fit(points: &[Vec2], closed: bool, tolerance: f64) -> Option<DxfShape> {
let segments = points.len().saturating_sub(1);
if segments < if closed { MIN_CLOSED } else { MIN_OPEN } {
return None;
}
// A closed ring repeats its first point at the end; the fit takes each point once.
let pts: &[Vec2] = if closed { points.get(..segments)? } else { points };
let n = pts.len() as f64;
let (mx, my) = (pts.iter().map(|p| p.x).sum::<f64>() / n, pts.iter().map(|p| p.y).sum::<f64>() / n);
let (mut suu, mut suv, mut svv, mut suuu, mut svvv, mut suvv, mut svuu) = (0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0);
for p in pts {
let (u, v) = (p.x - mx, p.y - my);
suu += u * u;
suv += u * v;
svv += v * v;
suuu += u * u * u;
svvv += v * v * v;
suvv += u * v * v;
svuu += v * u * u;
}
let det = suu * svv - suv * suv;
if det.abs() < 1e-12 * (suu * svv).abs().max(1e-300) {
return None; // a straight run
}
let (b1, b2) = (0.5 * (suuu + suvv), 0.5 * (svvv + svuu));
let (uc, vc) = ((b1 * svv - b2 * suv) / det, (suu * b2 - suv * b1) / det);
let r = (uc * uc + vc * vc + (suu + svv) / n).sqrt();
let c = Vec2::new(mx + uc, my + vc);
if !r.is_finite() || r <= 0.0 {
return None;
}
// Every point sits on the circle.
let slack = 1e-4 * r + 1e-6;
if pts.iter().any(|p| ((*p - c).len() - r).abs() > slack) {
return None;
}
// Consecutive points go the same way round, and each chord sags from the circle by no more than
// the drawing's own chord tolerance (twice it, for rounding): a polygon's sides sag far more.
let angle = |p: Vec2| (p.y - c.y).atan2(p.x - c.x);
let mut sweep = 0.0;
let mut direction = 0.0_f64;
for w in points.windows(2) {
let (a, b) = (*w.first()?, *w.get(1)?);
let mut d = angle(b) - angle(a);
while d > std::f64::consts::PI {
d -= std::f64::consts::TAU;
}
while d < -std::f64::consts::PI {
d += std::f64::consts::TAU;
}
if d == 0.0 || (direction != 0.0 && d.signum() != direction) {
return None;
}
direction = d.signum();
let half_chord = (a.dist(b) / 2.0).min(r);
if r - (r * r - half_chord * half_chord).sqrt() > 2.0 * tolerance {
return None;
}
sweep += d;
}
if closed {
return ((sweep.abs() - std::f64::consts::TAU).abs() < 1e-6).then_some(DxfShape::Circle { c, r });
}
if sweep.abs() >= std::f64::consts::TAU - 1e-6 {
return None;
}
let (first, last) = (angle(*points.first()?), angle(*points.last()?));
// A DXF arc runs counter-clockwise from its first angle to its second.
let (from, to) = if sweep > 0.0 { (first, last) } else { (last, first) };
// A start a rounding error below 0 is 0, not 360.
let degrees = |a: f64| Some(a.to_degrees().rem_euclid(360.0)).map(|d| if 360.0 - d < 1e-9 { 0.0 } else { d }).unwrap_or(0.0);
Some(DxfShape::Arc { c, r, a0: degrees(from), a1: degrees(to) })
}
+66
View File
@@ -285,3 +285,69 @@ fn view_dxf_zero_origin_puts_the_model_origin_at_the_drawing_zero() {
let err = s.execute(CMD, &json!({"camera": camera(StandardView::Top), "zero": "corner"})).unwrap_err().to_string();
assert!(err.contains("zero must be target or origin"), "{err}");
}
/// The lines of a regular polygon with `n` sides round `c`, from angle `from` to `to`.
/// Over a whole turn the last line returns to the first point and the ring is closed.
fn ring(c: Vec2, r: f64, n: usize, from: f64, to: f64) -> Vec<DxfEntity> {
let at = |i: usize| {
let t = from + (to - from) * i as f64 / n as f64;
Vec2::new(c.x + r * t.cos(), c.y + r * t.sin())
};
(0..n).map(|i| DxfEntity { layer: "VISIBLE".into(), shape: DxfShape::Line(at(i), at(i + 1)) }).collect()
}
#[test]
fn view_dxf_arcs_turn_a_tessellated_circle_into_a_circle_and_keep_polygons_as_lines() {
let tau = std::f64::consts::TAU;
let c = Vec2::new(3., -2.);
// A fine ring is a circle; coarse rings (an octagon, even a 32-gon) sag too far from the
// circle for the 0.01 mm the drawing was made with, so they are polygons and stay lines.
let fine = arcs::recognise(ring(c, 10., 64, 0., tau), 0.01);
assert_eq!(fine.len(), 1, "{fine:?}");
let DxfShape::Circle { c: found, r } = &fine[0].shape else { panic!("{fine:?}") };
assert!((*found - c).len() < 1e-9 && (r - 10.).abs() < 1e-9, "{fine:?}");
for sides in [6, 8, 32] {
let polygon = arcs::recognise(ring(c, 10., sides, 0., tau), 0.01);
assert_eq!(polygon.len(), sides, "{sides} sides");
assert!(polygon.iter().all(|e| matches!(e.shape, DxfShape::Line(..))));
}
}
#[test]
fn view_dxf_arcs_turn_a_run_of_lines_into_an_arc_that_runs_counter_clockwise() {
let c = Vec2::new(0., 0.);
let quarter = std::f64::consts::FRAC_PI_2;
// The run goes round clockwise, from 90 to 0 degrees: the DXF arc names 0 first, then 90.
let run = arcs::recognise(ring(c, 10., 40, quarter, 0.), 0.01);
assert_eq!(run.len(), 1, "{run:?}");
let DxfShape::Arc { r, a0, a1, .. } = &run[0].shape else { panic!("{run:?}") };
assert!((r - 10.).abs() < 1e-9 && a0.abs() < 1e-6 && (a1 - 90.).abs() < 1e-6, "{run:?}");
// A straight run, and a few lines, are never an arc.
let straight: Vec<DxfEntity> = (0..10)
.map(|i| DxfEntity { layer: "VISIBLE".into(), shape: DxfShape::Line(Vec2::new(i as f64, 0.), Vec2::new(i as f64 + 1., 0.)) })
.collect();
assert_eq!(arcs::recognise(straight, 0.01).len(), 10);
assert_eq!(arcs::recognise(ring(c, 10., 3, 0., quarter), 0.01).len(), 3);
}
#[test]
fn view_dxf_curves_arcs_write_a_round_hole_as_one_circle_and_the_default_still_writes_lines() {
let mut s = sample();
// The sample's blind hole is hidden from above; this one goes right through.
run(&mut s, "solid.cylinder", json!({"radius":5,"height":10,"base":[20,15,0],"operation":"cut","targets":["Plate"]}));
let top = camera(StandardView::Top);
let geometry = |v: &Value| solidcraft_io::read_dxf(v["dxf"].as_str().unwrap()).unwrap();
let as_lines = geometry(&run(&mut s, CMD, json!({"camera": top})));
assert!(as_lines.iter().all(|g| matches!(g, solidcraft_io::Geom2::Line(..))), "the default is unchanged");
let as_arcs = geometry(&run(&mut s, CMD, json!({"camera": top, "curves": "arcs"})));
let circles: Vec<_> = as_arcs.iter().filter_map(|g| if let solidcraft_io::Geom2::Circle(c, r) = g { Some((*c, *r)) } else { None }).collect();
// The hole is centred on the camera target (20, 15), so it is the circle at 0,0.
assert_eq!(circles.len(), 1, "{as_arcs:?}");
assert!(circles[0].0.len() < 1e-3 && (circles[0].1 - 5.).abs() < 1e-4, "{circles:?}");
// The plate's four sides stay four lines, and the hole's ring no longer adds dozens of lines.
let lines = as_arcs.iter().filter(|g| matches!(g, solidcraft_io::Geom2::Line(..))).count();
assert_eq!(lines, 4, "{as_arcs:?}");
assert!(as_lines.len() > 40);
let err = s.execute(CMD, &json!({"camera": top, "curves": "splines"})).unwrap_err().to_string();
assert!(err.contains("curves must be lines or arcs"), "{err}");
}
+23 -1
View File
@@ -175,6 +175,27 @@ fn a_box_placed_by_its_centre_reads_its_sizes_in_the_design_units() {
keeps_shape_through(&mut s, &["in", "mm"]);
}
/// A box placed by its centre grows on both sides when its size changes later, also after a save and
/// reopen; a box placed by its corner keeps growing from that corner (#14487).
#[test]
fn a_box_placed_by_its_centre_stays_centred_when_its_size_changes() {
let near = |a: [f64; 6], b: [f64; 6]| a.iter().zip(&b).all(|(x, y)| (x - y).abs() < 1e-6);
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 20, "width": 10, "height": 10, "center": [0, 0, 0], "name": "Box", "body_name": "A"}));
run(&mut s, "solid.box", json!({"length": 20, "width": 10, "height": 10, "corner": [50, 0, 0], "name": "Corner", "body_name": "B"}));
assert!(near(shape(&mut s, "A").bbox, [-10.0, -5.0, -5.0, 10.0, 5.0, 5.0]), "{:?}", shape(&mut s, "A"));
run(&mut s, "timeline.edit", json!({"feature": "Box", "set": {"length": "30"}}));
let a = shape(&mut s, "A");
assert!(near(a.bbox, [-15.0, -5.0, -5.0, 15.0, 5.0, 5.0]), "{a:?}");
assert!(rel(a.volume, 3000.0) < 1e-9, "{a:?}");
run(&mut s, "timeline.edit", json!({"feature": "Corner", "set": {"length": "30"}}));
assert!(near(shape(&mut s, "B").bbox, [50.0, 0.0, 0.0, 80.0, 10.0, 10.0]), "{:?}", shape(&mut s, "B"));
let mut reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
run(&mut reopened, "timeline.edit", json!({"feature": "Box", "set": {"width": "20", "height": "4"}}));
assert!(near(shape(&mut reopened, "A").bbox, [-15.0, -10.0, -2.0, 15.0, 10.0, 2.0]), "{:?}", shape(&mut reopened, "A"));
keeps_shape_through(&mut reopened, &["in", "mm"]);
}
#[test]
fn press_pull_keeps_its_distance_when_the_units_change() {
// A face pushed in: the cut's depth is the distance's size.
@@ -224,7 +245,8 @@ fn bare_numbers_beside_a_unitless_expression_stay_bare() {
assert_eq!(inputs(&s, 0)[2], "(16) / (2 * 4)", "the revolutions");
assert!(s.model.result(s.doc.features[0].id).is_some_and(|r| r.error.is_none()));
run(&mut s, "solid.box", json!({"length": 20, "width": 10, "height": 10, "center": ["2 * 5", 0, 0], "body_name": "B"}));
assert_eq!(s.doc.features[1].kind.point_expr().map(|p| p[0].to_string()), Some("(2 * 5) - (20) / 2".to_string()));
// The centre is kept as typed; the box stays centred on it (#14487).
assert_eq!(s.doc.features[1].kind.point_expr().map(|p| p[0].to_string()), Some("2 * 5".to_string()));
let b = shape(&mut s, "B");
assert!(b.bbox[0].abs() < 1e-6 && (b.bbox[3] - 20.0).abs() < 1e-6, "{b:?}");
}
+1 -1
View File
@@ -794,7 +794,7 @@ fn primitive_placement(kind: &solidcraft_doc::FeatureKind, cmd: &str) -> Vec<(&'
use solidcraft_doc::FeatureKind as Feature;
let point = placement_point;
match (kind, cmd) {
(Feature::Box { corner, .. }, "solid.box") => vec![("corner", point(corner))],
(Feature::Box { corner, centred, .. }, "solid.box") => vec![(if *centred { "center" } else { "corner" }, point(corner))],
(Feature::Sphere { center, .. }, "solid.sphere") | (Feature::Torus { center, .. }, "solid.torus") => vec![("center", point(center))],
(Feature::Cylinder { base, axis, .. }, "solid.cylinder") | (Feature::Coil { base, axis, .. }, "solid.coil") => {
vec![("base", point(base)), ("axis", pt(axis.0))]
+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!({}));
+8 -1
View File
@@ -29,7 +29,14 @@ fn generated_assembly(parts: usize) -> Document {
let mut made = Vec::new();
for i in 0..parts {
let o = Vec3::new(30.0 * (i % 20) as f64, 30.0 * (i / 20) as f64, 0.0);
let block = FeatureKind::Box { corner: point_expr(o), length: mm(20.0), width: mm(12.0), height: mm(6.0), operation: Operation::NewBody };
let block = FeatureKind::Box {
corner: point_expr(o),
length: mm(20.0),
width: mm(12.0),
height: mm(6.0),
operation: Operation::NewBody,
centred: false,
};
made.push((i, doc.add_feature(block, Some(&format!("Plate {i}"))).unwrap()));
for (k, x) in [2.0, 9.0, 16.0].into_iter().enumerate() {
let pin = FeatureKind::Cylinder {
+24 -7
View File
@@ -538,6 +538,12 @@ pub struct Session {
/// How the host uploads a model to EasyPrint (`file.open_in_easyprint`); `None`: it can't. It
/// belongs to the session like `param_check`, so it survives opening and starting designs.
pub easyprint_upload: Option<EasyPrintUpload>,
/// Whether print faces (`body.print_face`) are saved with the design and kept when one is
/// opened (#11512). On by default, so scripts and the command line keep them; the app sets it
/// from its "Keep print faces" preference, which is off unless the user turns it on. Off: a
/// save leaves them out of the file and an opened design starts without them. The user's,
/// like `param_check`, so it survives opening and starting designs.
pub keep_print_faces: bool,
/// After a live frame: its sketch, the document it left and the revision. Its links were
/// brought up to date by that frame, so the gesture's next frame needn't project them again
/// while nothing else has run (#3222).
@@ -795,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)
}
@@ -843,6 +856,7 @@ impl Session {
project_construction: false,
param_check: schema::ParamCheck::Off,
easyprint_upload: None,
keep_print_faces: true,
live_links: None,
active_component: 0,
active_occurrence_path: None,
@@ -909,6 +923,7 @@ impl Session {
let single_surface_analysis = self.single_surface_analysis;
let param_check = self.param_check;
let easyprint_upload = self.easyprint_upload.take();
let keep_print_faces = self.keep_print_faces;
let render_viewport = self.render_viewport.clone();
let render_jobs = self.render_jobs.scratch();
let checkpoints = std::mem::take(&mut self.checkpoints);
@@ -929,6 +944,7 @@ impl Session {
self.set_single_surface_analysis(single_surface_analysis);
self.param_check = param_check;
self.easyprint_upload = easyprint_upload;
self.keep_print_faces = keep_print_faces;
self.render_viewport = render_viewport;
self.render_jobs = render_jobs;
self.checkpoints = checkpoints;
@@ -1994,6 +2010,7 @@ impl Session {
project_construction: self.project_construction,
param_check: self.param_check,
easyprint_upload: self.easyprint_upload.clone(),
keep_print_faces: self.keep_print_faces,
live_links: None,
active_component: self.active_component,
active_occurrence_path: self.active_occurrence_path.clone(),
+3 -7
View File
@@ -4,7 +4,7 @@ pub mod batch;
pub(crate) mod batch_payload;
use crate::{Result, Session, cmd::PreparedCapture};
use serde_json::{Value, json};
use serde_json::Value;
use solidcraft_render::job::CancelToken;
use std::{collections::BTreeMap, sync::Arc};
@@ -144,16 +144,12 @@ fn finish_inner(session: &mut Session, prepared: &mut Prepared, ticket: Option<b
captured.check_cancel().map_err(crate::render_jobs::job_error)?;
if prepared.kind == Kind::Check {
prepared.completed = true;
return Ok(std::mem::take(&mut prepared.capture.reply));
return serde_json::to_value(&prepared.capture.reply).map_err(|e| crate::params::bad(prepared.kind.command(), e.to_string()));
}
let status = session
.render_jobs
.try_start_prepared_inner(session.revision, &mut prepared.capture.submission, prepared.capture.replace, prepared.shot.clone(), ticket)
.map_err(crate::render_jobs::job_error)?;
prepared.completed = true;
let mut status = json!(status);
if let (Some(out), Some(ignored)) = (status.as_object_mut(), prepared.capture.reply.get("ignored_controls")) {
out.insert("ignored_controls".into(), ignored.clone());
}
Ok(status)
Ok(crate::cmd::render_started(prepared.kind.command(), &status, &prepared.capture.reply))
}
+2 -1
View File
@@ -37,7 +37,8 @@ pub struct BoxParams {
#[serde(default, deserialize_with = "present")]
#[schemars(with = "PointExpr3")]
pub corner: Option<PointExpr3>,
/// The box's centre, instead of `corner`.
/// The box's centre, instead of `corner`. The box stays centred there when its length, width
/// or height changes later.
#[serde(default, deserialize_with = "present")]
#[schemars(with = "PointExpr3")]
pub center: Option<PointExpr3>,
+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}");
+245
View File
@@ -0,0 +1,245 @@
//! Contract and reply type for `file.insert_step` in `cmd/file.rs` (#14918).
//!
//! The handler reads its parameters from the raw JSON, so each key here reads as the handler
//! reads it and fails with the handler's message. `up` and `names` are the shared types `doc.open`
//! describes, and `matrix` and `tolerance` those of `occurrence.move`. Whether the file is one of
//! the kinds it takes, and whether a `matrix` is a rigid move, stay the handler's to refuse:
//! `matrix::rigid` names what it rejects, in its own words.
//!
//! `path` is optional in the type and required in the schema, so a call without it gets the
//! handler's "`path` is required" rather than the check's wording. The keys the handler does not
//! read for a call are left unread, in the order it looks at them:
//!
//! - anything but `path` for a file whose extension it refuses, which it answers before reading
//! another key;
//! - `tolerance` without `matrix`, which is where the handler passes it to `matrix::rigid`.
//!
//! `tolerance` itself is read as given, as `occurrence.move` reads it: the schema gives it its
//! shape only where the handler reads it, and `matrix::tolerance` is the one that refuses a bad
//! value, in its own words.
//!
//! A value of the wrong type that the handler reads as if the key were left out (a `name` that is
//! not text) is refused by the contract, as schemas.md has it: that difference is what warn and
//! strict report. A call with several problems may get a different one first in strict than with
//! the check off (the check goes key by key, the handler in its own order), except that a bad
//! `path` is always the first.
use std::borrow::Cow;
use schemars::{JsonSchema, Schema, SchemaGenerator};
use serde::de::Error as _;
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::{Value, json};
use super::check::{located, whole};
use super::component::{MatrixParam, MatrixTolerance};
use super::diagnostics::ProfileReport;
use super::file::{NamesParam, UpParam};
use super::types::not_blank_pattern;
/// What `folder` says when it is neither true nor false, as `step_options` has it.
const FOLDER_MESSAGE: &str =
"`folder` must be true (an import that brings more than one item goes into a `Group #N` history folder) or false (no folder)";
/// `path` as `path_arg` reads it: text that is not blank and under 4096 bytes, refused with the
/// handler's own message.
fn path_key<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
match Value::deserialize(d)? {
Value::String(path) if crate::cmd::usable_path(&path) => Ok(Some(path)),
_ => Err(D::Error::custom(whole("`path` is required"))),
}
}
/// `folder`: true or false, `null` as left out, with the handler's message for anything else.
fn folder_key<'de, D: Deserializer<'de>>(d: D) -> Result<Option<bool>, D::Error> {
let value = Value::deserialize(d)?;
if value.is_null() {
return Ok(None);
}
value.as_bool().map(Some).ok_or_else(|| D::Error::custom(whole(FOLDER_MESSAGE)))
}
/// `matrix`: as given, `null` as left out, which is how the handler has it.
fn matrix_key<'de, D: Deserializer<'de>>(d: D) -> Result<Option<MatrixParam>, D::Error> {
let value = Value::deserialize(d)?;
if value.is_null() {
return Ok(None);
}
Ok(Some(MatrixParam(value)))
}
/// The keys `file.insert_step` reads. See [`InsertStepParams`], which leaves out the keys the
/// handler does not read for the call.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields, transform = insert_step_schema)]
pub struct InsertStepKeys {
// Optional in the type and required in the schema (`insert_step_schema`): left out, the
// handler refuses the call with its own message.
/// The file to insert: `.step`, `.stp`, `.igs`, `.iges`, `.fcstd` or `.3dm` (any case), not
/// blank and fewer than 4096 UTF-8 bytes. Any other kind of file is refused.
#[serde(default, deserialize_with = "path_key", skip_serializing_if = "Option::is_none")]
#[schemars(with = "String")]
pub path: Option<String>,
/// The Import feature's name, and the new component's. Default (also when blank): the root
/// product's name in the file, else the file's.
#[serde(default)]
pub name: Option<String>,
/// Which way is up in the file: `z` (its axes are kept, the default) or `y` (the file has Y
/// up, so the import's component is turned a quarter turn about X and its Y becomes Z).
#[serde(default)]
pub up: Option<UpParam>,
/// What the file's bodies are named after: `auto` (the default), `solid`, `product` or
/// `instance`.
#[serde(default)]
pub names: Option<NamesParam>,
/// true puts an import that brings more than one item into the history (its Import feature
/// and the rigid joints holding its parts) into a new history folder named `Group #N`.
/// Default: false.
#[serde(default, deserialize_with = "folder_key", skip_serializing_if = "Option::is_none")]
#[schemars(with = "Option<bool>")]
pub folder: Option<bool>,
/// Where the import goes in the active component, as a rigid move applied after `up`: a
/// row-major 3×4 (12 numbers) or 4×4 (16 numbers) matrix, flat or as rows, as
/// `occurrence.move` takes it. The reply gives the placement it made as `transform`.
#[serde(default, deserialize_with = "matrix_key", skip_serializing_if = "Option::is_none")]
#[schemars(with = "Option<MatrixParam>")]
pub matrix: Option<MatrixParam>,
/// With `matrix` only: the largest accepted |RᵀR − I| per entry, above 0 and at most 0.001.
/// Default: 1e-9. Read nowhere else, and `matrix::rigid` is the one that refuses both.
#[serde(default)]
pub tolerance: Option<MatrixTolerance>,
}
/// Leaves out the keys the handler does not read for this call, in the order it looks at them.
fn leave_unread(value: &mut Value) {
let Some(keys) = value.as_object_mut() else { return };
let Some(path) = keys.get("path").and_then(Value::as_str).filter(|path| crate::cmd::usable_path(path)) else {
return;
};
if !solidcraft_io::is_step_path(path) {
// A file of another kind is refused before any other key is read.
for key in ["name", "up", "names", "folder", "matrix", "tolerance"] {
keys.remove(key);
}
return;
}
if !keys.get("matrix").is_some_and(|matrix| !matrix.is_null()) {
// `matrix::tolerance` is read only where `matrix` is.
keys.remove("tolerance");
}
}
/// Insert a STEP, IGES, FreeCAD or Rhino file into the design as an Import base feature, in a new
/// component named after the file and placed in the active component. An assembly's products
/// become nested components held as one rigid group until `component.unlock`, and a part the file
/// places more than once becomes one component with an occurrence per placement.
#[derive(Debug)]
pub struct InsertStepParams(pub InsertStepKeys);
impl<'de> Deserialize<'de> for InsertStepParams {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let mut value = Value::deserialize(d)?;
// A missing or unusable `path` is the handler's first refusal, whatever else is wrong.
if let Some(path) = value.as_object().map(|keys| keys.get("path"))
&& !path.and_then(Value::as_str).is_some_and(crate::cmd::usable_path)
{
let only = json!({"path": path.cloned().unwrap_or(Value::Null)});
return serde_path_to_error::deserialize::<_, InsertStepKeys>(&only).map(InsertStepParams).map_err(|e| D::Error::custom(located(&e)));
}
leave_unread(&mut value);
serde_path_to_error::deserialize(&value).map(InsertStepParams).map_err(|e| D::Error::custom(located(&e)))
}
}
impl JsonSchema for InsertStepParams {
fn schema_name() -> Cow<'static, str> {
"InsertStepParams".into()
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
let mut schema = InsertStepKeys::json_schema(generator);
schema.insert("description".into(), INSERT_STEP.into());
schema
}
}
const INSERT_STEP: &str = "Insert a STEP, IGES, FreeCAD or Rhino file into the design as an Import base feature, in a new component named after the file and placed in the active component. An assembly's products become nested components held as one rigid group until `component.unlock`, and a part the file places more than once becomes one component with an occurrence per placement.";
/// `path` is required, as the handler has it (not blank, fewer than 4096 bytes; `maxLength`
/// counts characters, so 4095 is as close as the schema gets). `tolerance` has its shape only
/// where the handler reads it: with `matrix`, which is the handler's to refuse.
fn insert_step_schema(schema: &mut Schema) {
super::motion::require(schema, "path");
let Some(props) = schema.get_mut("properties").and_then(Value::as_object_mut) else { return };
props.insert(
"path".into(),
json!({
"description": props.get("path").and_then(|path| path.get("description")).cloned().unwrap_or_default(),
"type": "string",
"maxLength": 4095,
"pattern": not_blank_pattern(),
}),
);
let shape = props.get_mut("tolerance").map(Value::take).unwrap_or_default();
props.insert("tolerance".into(), json!({"description": shape.get("description").cloned().unwrap_or_default()}));
schema.insert("anyOf".into(), json!([{"properties": {"matrix": {"type": "null"}}}, {"properties": {"tolerance": shape}}]));
}
/// What `file.insert_step` returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct StepInserted {
/// The file, as given.
pub path: String,
/// The Import feature's id.
pub feature: u64,
/// The feature's name.
pub name: String,
/// The bodies the import made, by name.
pub bodies: Vec<String>,
/// The same names as `bodies`.
pub created_bodies: Vec<String>,
/// What the file reader noticed and put right.
pub warnings: Vec<String>,
/// The program the file's header names as writing it.
pub system: String,
/// The exporter the file names, and which way up its files are known to be (`y` or `z`). Only
/// for a program whose files are known to need standing up.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exporter: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exporter_up: Option<String>,
/// The new component's id.
pub component: u64,
/// The new component's name.
pub component_name: String,
/// The component's occurrence in the active component; null when it has none.
pub occurrence: Option<u64>,
/// How many components were made below it (an assembly's parts and sub-assemblies). Only when
/// there are some.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subcomponents: Option<usize>,
/// How many rigid joints hold its contents together. Only when `subcomponents` is given.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rigid_joints: Option<usize>,
/// With `up: y`: the occurrence that was turned.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stand_up: Option<u64>,
/// With `folder: true`: the history folder the import's feature and joints went into. Only
/// when the import has joints to put in one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub group: Option<u64>,
/// With `matrix`: the import's placement in the active component after the move, as the design
/// stores a placement: 4 rows of 4 numbers, its translation in the last row.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub transform: Option<[[f64; 4]; 4]>,
/// While the profiler records: the import's share of what it recorded, as `diagnostics.profile`
/// reports it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile: Option<ProfileReport>,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
#[cfg(test)]
#[path = "insert_step_tests.rs"]
mod tests;
@@ -0,0 +1,333 @@
use std::path::{Path, PathBuf};
use serde_json::{Value, json};
use solidcore_record::Vec3;
use solidcraft_kernel::{ExportBody, ExportProduct, StepHeader, box_solid, step_export_products};
use super::{InsertStepParams, StepInserted};
use crate::Session;
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
#[test]
fn schemas_and_the_key_check_agree() {
assert_keys(input_schema_for::<InsertStepParams>(), &["folder", "matrix", "name", "names", "path", "tolerance", "up"]);
}
#[test]
fn strict_keeps_todays_errors() {
let long = "x".repeat(4096);
assert_strict_keeps_todays_errors(&[
("file.insert_step", json!({})),
("file.insert_step", json!({"path": ""})),
("file.insert_step", json!({"path": " "})),
("file.insert_step", json!({"path": 5})),
("file.insert_step", json!({"path": null})),
("file.insert_step", json!({"path": long})),
// A file of another kind is refused before any other key is read.
("file.insert_step", json!({"path": "x.stl"})),
("file.insert_step", json!({"path": "x.stl", "up": "q", "names": 5, "folder": "x", "matrix": 5, "tolerance": 5})),
("file.insert_step", json!({"path": "x.3mf", "name": 5})),
("file.insert_step", json!({"path": "x.txt", "up": "y"})),
("file.insert_step", json!({"path": "X.STL", "up": "y"})),
// The keys of a CAD file, read in the handler's order.
("file.insert_step", json!({"path": "a.step", "up": "q"})),
("file.insert_step", json!({"path": "a.step", "up": 5})),
("file.insert_step", json!({"path": "a.stp", "up": "y", "names": "bogus"})),
("file.insert_step", json!({"path": "a.igs", "names": 5})),
("file.insert_step", json!({"path": "a.iges", "names": []})),
("file.insert_step", json!({"path": "a.fcstd", "folder": "yes"})),
("file.insert_step", json!({"path": "a.3dm", "folder": 1})),
// `matrix` is the handler's to read; `tolerance` only with it.
("file.insert_step", json!({"path": "a.step", "matrix": "turn it"})),
("file.insert_step", json!({"path": "a.step", "matrix": [2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 2, 0]})),
("file.insert_step", json!({"path": "a.step", "matrix": [1, 2, 3], "tolerance": "loose"})),
("file.insert_step", json!({"path": "a.step", "matrix": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], "tolerance": 0})),
("file.insert_step", json!({"path": "a.step", "matrix": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], "tolerance": 0.01})),
// A file that isn't there.
("file.insert_step", json!({"path": "does-not-exist.step"})),
("file.insert_step", json!({"path": "does-not-exist.step", "up": "Z", "names": " Auto ", "folder": false})),
]);
}
fn parse(p: Value) -> Result<InsertStepParams, String> {
serde_json::from_value(p).map_err(|e| e.to_string())
}
/// The keys read as the handler reads them: the same words in any case, `null` as left out, and
/// the keys it doesn't read in a call left unread, so a bad one there is no problem.
#[test]
fn the_types_read_each_key_as_the_handler_does() {
let keys = |p: Value| parse(p).unwrap_or_else(|e| panic!("{e}")).0;
let k = keys(json!({"path": "a.step", "name": "Mine", "up": " Y ", "names": " Solid ", "folder": true}));
assert_eq!(k.path.as_deref(), Some("a.step"));
assert_eq!(k.name.as_deref(), Some("Mine"));
assert_eq!(k.up, Some(super::UpParam(true)));
assert_eq!(k.names.map(|names| names.0), Some(solidcraft_io::StepNames::Solid));
assert_eq!(k.folder, Some(true));
assert_eq!(keys(json!({"path": "a.iges", "up": "Z"})).up, Some(super::UpParam(false)));
assert_eq!(keys(json!({"path": "a.3dm", "names": " Instance "})).names.map(|names| names.0), Some(solidcraft_io::StepNames::Instance));
assert!(keys(json!({"path": "a.step"})).folder.is_none());
let none = keys(json!({"path": "a.step", "name": null, "up": null, "names": null, "folder": null, "matrix": null, "tolerance": null}));
assert!(none.name.is_none() && none.up.is_none() && none.names.is_none() && none.folder.is_none() && none.matrix.is_none());
for matrix in [json!([0, -1, 0, 100, 1, 0, 0, 0, 0, 0, 1, 0]), json!({"m": 1}), json!("turn")] {
assert_eq!(keys(json!({"path": "a.step", "matrix": matrix.clone()})).matrix.map(|m| m.0), Some(matrix.clone()), "{matrix}");
}
assert_eq!(
keys(json!({"path": "a.step", "matrix": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], "tolerance": 0.001})).tolerance.map(|t| t.0),
Some(json!(0.001))
);
// Not read: `tolerance` without a `matrix`, and everything but `path` for a file the command
// refuses before it reads another key.
for p in [
json!({"path": "a.step", "tolerance": "loose"}),
json!({"path": "a.step", "matrix": null, "tolerance": []}),
json!({"path": "a.stl", "up": "sideways", "names": 5, "folder": 5, "matrix": 5, "tolerance": 5}),
json!({"path": "x.txt", "name": 5, "up": 1, "names": 5, "folder": 5, "matrix": 5, "tolerance": 5}),
] {
parse(p.clone()).unwrap_or_else(|e| panic!("{p}: {e}"));
}
parse(json!({"path": "A.STP", "up": "y", "names": "auto", "folder": false})).unwrap_or_else(|e| panic!("{e}"));
assert!(parse(json!({"path": "A.STP", "up": "y", "names": "x"})).is_err());
let k = keys(json!({"path": "a.step", "tolerance": 5}));
assert!(k.tolerance.is_none() && k.matrix.is_none());
// With `matrix` it is read as given, as `occurrence.move` reads it: the published schema
// shapes it, and `matrix::tolerance` is the one that refuses a bad one, in its own words
// (see `strict_keeps_todays_errors`).
let with_matrix = json!({"path": "a.step", "matrix": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], "tolerance": "loose"});
assert_eq!(keys(with_matrix.clone()).tolerance.map(|t| t.0), Some(json!("loose")));
assert!(crate::schema::issues("file.insert_step", &with_matrix).is_empty(), "the check leaves the refusal to the handler");
// Read, and refused with the handler's message.
let refused = |p: Value| crate::schema::issues("file.insert_step", &p).into_iter().next().map(|i| i.message).unwrap_or_default();
for (p, message) in [
(json!({"path": "a.step", "up": "x"}), "`up` must be \"z\" (the file's axes are kept) or \"y\" (the file has Y up: its Y becomes Z)"),
(json!({"path": "a.step", "up": "y", "names": "x"}), "`names` must be \"auto\", \"solid\", \"product\" or \"instance\""),
(
json!({"path": "a.step", "folder": 0}),
"`folder` must be true (an import that brings more than one item goes into a `Group #N` history folder) or false (no folder)",
),
(json!({}), "`path` is required"),
(json!({"path": " "}), "`path` is required"),
(json!({"up": "x", "path": 5}), "`path` is required"),
(json!({"up": "x", "names": 5, "folder": 5}), "`path` is required"),
] {
let e = refused(p.clone());
assert!(e.starts_with(message), "{p}: {e}");
}
}
/// The check names what is wrong, in the handler's words, and a key the handler would ignore is
/// still a known key.
#[test]
fn the_check_names_what_is_wrong() {
let issues = |p: Value| crate::schema::issues("file.insert_step", &p).into_iter().map(|i| i.message).collect::<Vec<_>>();
assert_eq!(issues(json!({"path": "a.step", "toleranc": 1})), ["unknown key `toleranc`; did you mean `tolerance`?"]);
assert_eq!(
issues(json!({"path": "a.step", "up": "q"})),
["`up` must be \"z\" (the file's axes are kept) or \"y\" (the file has Y up: its Y becomes Z)"]
);
assert_eq!(issues(json!({"path": "a.step", "name": 5})), ["`name` must be text"]);
assert_eq!(issues(json!({})), ["`path` is required"]);
// `name` is read as text or not at all; a number is ignored by the handler but is a mistake.
assert_eq!(issues(json!({"path": "a.step", "name": null})), Vec::<String>::new());
// The kind of file, and whether a matrix is a rigid move, are the handler's to refuse.
assert!(issues(json!({"path": "a.stl", "up": "y", "names": 5, "folder": "x", "matrix": 5})).is_empty());
assert!(issues(json!({"path": "a.step", "matrix": "turn it", "tolerance": 5})).is_empty(), "no `matrix` given means no `tolerance`");
assert!(issues(json!({"path": "a.step", "matrix": [2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 2, 0]})).is_empty());
}
/// The published schema: `path` is required and shaped as the handler reads it; `tolerance` has
/// its shape only where it is read.
#[test]
fn the_schema_takes_what_the_handler_reads() {
let schema = input_schema_for::<InsertStepParams>();
let schema = schema.as_value();
let matrix = json!([0, -1, 0, 100, 1, 0, 0, 0, 0, 0, 1, 0]);
for (p, takes) in [
(json!({"path": "a.step"}), true),
(json!({"path": "a.STP", "name": "N", "up": "y", "names": "auto", "folder": true}), true),
(json!({"path": "a.iges", "up": null, "names": null, "folder": null, "matrix": null, "tolerance": null}), true),
(json!({"path": "a.fcstd", "matrix": matrix, "tolerance": 1e-9}), true),
(json!({"path": "a.3dm", "matrix": [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]], "tolerance": 0.001}), true),
// Which kinds of file the command takes is the handler's to refuse, so the schema asks
// nothing of the extension; `tolerance` without `matrix` is the case it does describe.
(json!({"path": "a.stl", "up": "y", "names": "auto", "folder": true}), true),
(json!({"path": "a.step", "tolerance": "x"}), true),
(json!({"path": "a.step", "matrix": null, "tolerance": []}), true),
(json!({}), false),
(json!({"path": 5}), false),
(json!({"path": "a.step", "name": 5}), false),
(json!({"path": "a.stl", "up": 5}), false),
(json!({"path": "a.step", "up": 5}), false),
(json!({"path": "a.step", "up": "q"}), false),
(json!({"path": "a.step", "names": 5}), false),
(json!({"path": "a.step", "up": "y", "names": "other"}), false),
(json!({"path": "a.step", "folder": "yes"}), false),
(json!({"path": "a.step", "folder": 1}), false),
// A matrix is shaped as `occurrence.move` publishes it; `matrix::rigid` still decides
// whether it is a rigid move.
(json!({"path": "a.step", "matrix": "turn it"}), false),
(json!({"path": "a.step", "matrix": [1, 2, 3]}), false),
(json!({"path": "a.step", "matrix": matrix, "tolerance": "x"}), false),
(json!({"path": "a.step", "matrix": matrix, "tolerance": 0}), false),
(json!({"path": "a.step", "matrix": matrix, "tolerance": 0.01}), false),
] {
assert_eq!(conforms(schema, schema, &p), takes, "{p}");
}
}
/// `up`, `names`, `matrix` and `tolerance` are the shapes `doc.open` and `occurrence.move`
/// publish, not copies of them; `path` is shaped as the handler reads it.
#[test]
fn the_shared_shapes_are_the_ones_published() {
let mine = input_schema_for::<InsertStepParams>();
let mine = mine.as_value();
let open = input_schema_for::<crate::schema::file::OpenParams>();
let open = open.as_value();
let move_ = input_schema_for::<crate::schema::component::MoveParams>();
let move_ = move_.as_value();
for name in ["UpParam", "NamesParam"] {
let shape = &mine["$defs"][name];
assert!(shape.is_object(), "{name} is in my schema");
assert_eq!(shape, &open["$defs"][name], "{name} is doc.open's shape");
}
for name in ["MoveMatrix", "MatrixTolerance"] {
let shape = &mine["$defs"][name];
assert!(shape.is_object(), "{name} is in my schema");
assert_eq!(shape, &move_["$defs"][name], "{name} is occurrence.move's shape");
}
assert_eq!(mine["properties"]["path"]["pattern"], json!(crate::schema::types::not_blank_pattern()));
assert_eq!(mine["properties"]["path"]["maxLength"], json!(4095));
}
/// A fresh directory for one test's STEP files.
fn folder(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("solidcraft-insert-step-schema-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap_or_else(|e| panic!("{e}"));
dir
}
fn write_step(dir: &Path, name: &str, products: &[ExportProduct<'_>]) -> String {
let text = step_export_products(products, 0, &StepHeader::default()).unwrap_or_else(|e| panic!("{e}"));
let path = dir.join(name);
std::fs::write(&path, text).unwrap_or_else(|e| panic!("{e}"));
path.to_string_lossy().to_string()
}
/// A STEP file holding one solid: a box 10 mm on a side.
fn block_file(dir: &Path) -> String {
let block = box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0)).unwrap();
let part = |name: &str, label: &str, body| ExportProduct {
name: name.into(),
bodies: vec![ExportBody { name: label.into(), body, color: None }],
children: Vec::new(),
};
write_step(dir, "block.step", &[part("Block", "Block", &block)])
}
/// A STEP file holding an assembly: a plate 40 wide, 4 high and 20 deep with two posts standing
/// on it, as a Y-up file has it, so the import makes components and rigid joints of its own.
fn frame_file(dir: &Path) -> String {
let plate = box_solid(Vec3::ZERO, Vec3::new(40.0, 4.0, 20.0)).unwrap();
let post = box_solid(Vec3::ZERO, Vec3::new(4.0, 30.0, 4.0)).unwrap();
let at = |x: f64, y: f64, z: f64| [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [x, y, z, 1.0]];
let part = |name: &str, label: &str, body| ExportProduct {
name: name.into(),
bodies: vec![ExportBody { name: label.into(), body, color: None }],
children: Vec::new(),
};
let products = [
ExportProduct {
name: "Frame".into(),
bodies: Vec::new(),
children: vec![
(1, at(0.0, 0.0, 0.0), "Plate-1".into()),
(2, at(10.0, 4.0, 8.0), "Post-1".into()),
(2, at(26.0, 4.0, 8.0), "Post-2".into()),
],
},
part("Plate", "Plate", &plate),
part("Post", "Post", &post),
];
write_step(dir, "frame.step", &products)
}
fn matches(reply: &Value) {
let schema = output_schema_for::<StepInserted>();
let schema = schema.as_value();
assert!(conforms(schema, schema, reply), "{reply} doesn't match {schema}");
}
/// Every form the command takes, run in Warn: each reply matches its output schema and carries no
/// warning (Warn runs a call the contract refuses anyway, so this shows the contract takes each
/// call). The keys the handler leaves unread are given bad values on purpose.
#[test]
fn replies_match_the_output_schema() {
let dir = folder("replies");
let block = block_file(&dir);
let frame = frame_file(&dir);
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
let mut run = |p: Value| {
let r = s.execute("file.insert_step", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
assert!(r.get("param_warnings").is_none(), "{p}: {r}");
matches(&r);
r
};
let plain = run(json!({"path": block}));
assert_eq!(plain["component_name"], "block", "{plain}");
assert_eq!(plain["name"], "Block", "{plain}");
assert!(plain["bodies"] == plain["created_bodies"] && plain["occurrence"].is_u64(), "{plain}");
assert!(plain.get("subcomponents").is_none() && plain.get("rigid_joints").is_none(), "{plain}");
assert!(plain.get("stand_up").is_none() && plain.get("group").is_none() && plain.get("transform").is_none(), "{plain}");
let named = run(json!({"path": block, "name": "Brake", "up": " Z ", "names": " Solid ", "folder": false}));
assert_eq!(named["component_name"], "Brake", "{named}");
assert_eq!(named["name"], "Brake", "{named}");
run(json!({"path": block, "name": null, "up": null, "names": null, "folder": null, "matrix": null, "tolerance": null}));
// Keys left unread, with values that would be refused if they were read.
run(json!({"path": block, "tolerance": "x"}));
run(json!({"path": block, "matrix": null, "tolerance": []}));
let stood = run(json!({"path": frame}));
assert!(stood["subcomponents"].is_u64() && stood["rigid_joints"].is_u64(), "{stood}");
assert!(stood.get("stand_up").is_none(), "{stood}");
let turned = run(json!({"path": frame, "up": " y "}));
assert_eq!(turned["stand_up"], turned["occurrence"], "{turned}");
let grouped = run(json!({"path": frame, "up": "z", "names": " Instance ", "folder": true}));
assert!(grouped["group"].is_u64() && grouped.get("stand_up").is_none(), "{grouped}");
let quarter = json!([0, -1, 0, 100, 1, 0, 0, 0, 0, 0, 1, 0]);
let placed = run(json!({"path": frame, "up": "y", "matrix": quarter, "tolerance": 1e-9}));
assert_eq!(placed["transform"][3][0], json!(100.0), "{placed}");
// A misspelt key still runs in Warn, says so, and the reply still matches.
let warned = s.execute("file.insert_step", &json!({"path": block, "toleranc": 1})).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(warned["param_warnings"], json!(["unknown key `toleranc`; did you mean `tolerance`?"]), "{warned}");
matches(&warned);
}
/// In Strict a call that works today is not refused, and one that fails fails with the handler's
/// message (see `strict_keeps_todays_errors`).
#[test]
fn strict_runs_the_calls_that_work() {
let dir = folder("strict");
let block = block_file(&dir);
let frame = frame_file(&dir);
let mut s = Session { param_check: ParamCheck::Strict, ..Session::default() };
for p in [
json!({"path": block}),
json!({"path": block, "name": "Brake"}),
json!({"path": block, "up": "z", "names": "auto", "folder": true}),
json!({"path": block, "tolerance": "x"}),
json!({"path": block, "matrix": null, "tolerance": []}),
json!({"path": frame, "up": "y", "names": "instance", "folder": true}),
json!({"path": frame, "matrix": [0, -1, 0, 100, 1, 0, 0, 0, 0, 0, 1, 0], "tolerance": 1e-9}),
] {
s.execute("file.insert_step", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
}
let e = s.execute("file.insert_step", &json!({"path": block, "uints": 1})).err().map(|e| e.to_string()).unwrap_or_default();
assert!(e.contains("unknown key `uints`"), "{e}");
}
+1
View File
@@ -74,6 +74,7 @@ pub mod features_more;
pub mod file;
pub mod frame;
pub mod insert_mesh;
pub mod insert_step;
pub mod inspect;
pub mod interference;
pub mod joint_sweep;
+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 {
+165 -3
View File
@@ -1,5 +1,6 @@
//! The contract for Render Image and Check Render Request in `cmd/render/jobs.rs` (#13311). Both
//! commands take the same image request; `render.preflight` only checks it.
//! The contract and reply types for Render Image and Check Render Request in
//! `cmd/render/jobs.rs` (#13311). Both commands take the same image request; `render.preflight`
//! only checks it.
//!
//! The handler reads a request in one of two forms. With `progressive: true` it is the GPU form
//! (`ProgressiveStart`); otherwise it is the runtime form (`Start`), for the raster and CPU
@@ -20,15 +21,18 @@
//!
//! The render crate has no `JsonSchema`. The schema types each top-level key and publishes the
//! render crate's option sections (`raytrace`, `settings`, `lens` and the others) as JSON objects.
//! The replies do the same: what the engine says about the request is typed, and the render
//! crate's sections (the request, what it resolved to, its cost and options) are objects.
use std::borrow::Cow;
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
use serde::de::Error as _;
use serde::{Deserialize, Deserializer};
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::{Map, Value, json};
use super::check::whole;
use super::render_jobs::JobState;
/// The keys only the GPU form takes. The runtime form refuses them.
pub(crate) const GPU_ONLY: &[&str] = &[
@@ -346,6 +350,164 @@ impl JsonSchema for RenderRequest {
}
}
/// What Render Image (`render.start`) returns: the queued job's status, as Render Progress gives
/// it for a job, without the shot queue.
#[derive(Debug, Serialize, JsonSchema)]
pub struct RenderStarted {
#[serde(flatten)]
pub state: JobState,
/// For a runtime request: the setup's controls the raster image executor leaves out
/// (`exposure_ev`, `environment_rotation`, `environment_intensity`, those not at their neutral
/// value), as in Check Render Request. Left out for a GPU request.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ignored_controls: Option<Vec<&'static str>>,
}
/// What Check Render Request (`render.preflight`) returns: the request as a job would run it, and
/// what it would cost, without queuing anything. The reply has the request's form.
#[derive(Debug, Serialize, JsonSchema)]
#[serde(untagged)]
pub enum RenderChecked {
Runtime(RuntimeChecked),
Gpu(GpuChecked),
}
/// Check Render Request for a runtime request (raster or CPU ray tracing).
#[derive(Debug, Serialize, JsonSchema)]
pub struct RuntimeChecked {
#[serde(flatten)]
pub checked: Checked,
/// The setup's controls the raster image executor leaves out: `exposure_ev`,
/// `environment_rotation` and `environment_intensity`, those not at their neutral value. Empty
/// for CPU ray tracing, which refuses them.
pub ignored_controls: Vec<&'static str>,
/// How the CPU ray tracer carries light, in words: direct and mirror light only, or path
/// lighting through a finite number of bounces, through glass too where it refracts. A raster
/// request gets the first.
pub stage_transport: &'static str,
/// The CPU resource profile: `compact`, `studio` or `workstation`. Always `compact` for a
/// raster request.
pub cpu_budget: String,
/// That profile's image, geometry, memory and work limits, as the render crate gives them.
pub cpu_limits: Map<String, Value>,
/// The bytes the request's lights, environment and resource metadata keep while the job
/// runs, in bytes. `admission` counts them.
pub retained_request_bytes: u64,
}
/// Check Render Request for a GPU request (`progressive: true`).
#[derive(Debug, Serialize, JsonSchema)]
pub struct GpuChecked {
#[serde(flatten)]
pub checked: Checked,
/// The job's options, as the render crate gives them.
pub options: Map<String, Value>,
/// The bytes the options keep while the job runs. `admission` counts them.
pub retained_options_bytes: u64,
/// With `clamp_indirect`: how indirect light is clamped.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub indirect_clamp: Option<IndirectClamp>,
}
/// What both forms of Check Render Request return.
#[derive(Debug, Serialize, JsonSchema)]
pub struct Checked {
/// The backend and executor the request resolved to, with the image's size and format, as the
/// render crate gives them.
pub resolved: Map<String, Value>,
/// The cost the job was admitted with (triangles, scratch and total bytes, and the host's
/// memory where it has one), as the render crate gives it.
pub admission: Map<String, Value>,
/// The image request the job would run, as the render crate gives it.
pub request: Map<String, Value>,
/// The design revision the scene was captured from.
pub scene_generation: u64,
/// The image host that would run it: `native_worker` or `cooperative`.
pub host: String,
/// How many passive stage panels the setup places.
pub stage_panels: usize,
/// The environment's metadata, as the render crate gives it: an HDR's, or for a GPU request
/// also a gradient sky's. `null` without one, and for a CPU sky or constant environment, which
/// are in `request`.
pub environment: Option<Map<String, Value>>,
/// The surface maps the scene uses, as the render crate sums them up, or `null` without any.
pub textures: Option<Map<String, Value>>,
/// How transparency is rendered: `sorted_alpha_approximation` (raster), `opaque_only`,
/// `coverage_alpha` (a transparent background) or `physical_transmission` (glass that
/// refracts).
pub transparency: &'static str,
/// Whether glass in the scene refracts.
pub refraction: bool,
/// The glass shapes the executor refracts through: `thin_sheet`, `closed_box` and
/// `closed_mesh`. Empty for an executor without glass, and for a runtime request without it.
pub dielectric_boundaries: Vec<&'static str>,
/// The setup's clear coats, as saved. Left out without any.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub coats: Option<Vec<Map<String, Value>>>,
/// With denoising on: how the image is denoised. Left out otherwise.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub denoising: Option<Denoising>,
/// 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>,
}
/// How a denoised image is filtered.
#[derive(Debug, Serialize, JsonSchema)]
pub struct Denoising {
/// The filter: `edge_aware_atrous`.
pub method: &'static str,
/// For CPU ray tracing: `native_cpu`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub backend: Option<&'static str>,
/// How many passes the filter makes, 1 to 3.
pub passes: u32,
/// Each pass's step between taps, in pixels: 1, 2 and 4.
pub steps: Vec<u32>,
/// The denoise settings, as the render crate gives them.
pub settings: Map<String, Value>,
/// Whether the request asked for the guide mask (`denoise_diagnostics`).
pub diagnostics_requested: bool,
/// What happens to isolated bright pixels, in words, or `disabled`.
pub outlier_policy: &'static str,
/// How mirrors and glass are filtered, in words.
pub optical_policy: &'static str,
/// The guide images the filter follows.
pub guides: &'static str,
/// What the filter leaves as rendered, in words.
pub bypass: &'static str,
/// What the filter can't do, in words.
pub limitation: &'static str,
/// Always true: the unfiltered linear image is kept.
pub raw_linear_accumulator_preserved: bool,
/// For CPU ray tracing, always true: a kept linear EXR is unfiltered.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub raw_linear_exr_preserved: Option<bool>,
/// Where in the pipeline the filter runs: `linear_before_tone_map`.
pub output_stage: &'static str,
/// For CPU ray tracing: the filter's work, in words.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub work_units: Option<&'static str>,
/// For CPU ray tracing: which checkpoint images are filtered, in words.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub checkpoints: Option<&'static str>,
}
/// How a GPU request's indirect light is clamped (`clamp_indirect`).
#[derive(Debug, Serialize, JsonSchema)]
pub struct IndirectClamp {
/// The largest contribution a path keeps.
pub limit: f64,
/// The bounce the clamp starts from: 1, the first indirect one.
pub from_bounce: u32,
/// Always true: a clamped contribution keeps its hue.
pub keeps_hue: bool,
/// Always true: the clamp darkens real caustics and glints.
pub biased: bool,
/// What the clamp changes beyond the image, in words.
pub limitation: &'static str,
}
#[cfg(test)]
#[path = "render_start_tests.rs"]
mod tests;
+185 -3
View File
@@ -1,13 +1,13 @@
use std::collections::BTreeSet;
use serde_json::{Value, json};
use solidcraft_render::job::CpuHost;
use solidcraft_render::job::{Admission, CancelToken, CpuHost, ImageHost, ImageRequest, ImageTask, ImageWork, JobId, JobOptions};
use super::{GPU_ONLY, RUNTIME_ONLY, RenderRequest};
use super::{GPU_ONLY, RUNTIME_ONLY, RenderChecked, RenderRequest, RenderStarted};
use crate::Session;
use crate::schema::check::schema_keys;
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
use crate::schema::{ParamCheck, input_schema_for};
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
const IDS: [&str; 2] = ["render.start", "render.preflight"];
@@ -22,6 +22,16 @@ fn both_commands_describe_their_parameters() {
}
}
/// #13311: `engine.describe` gives Render Image and Check Render Request their replies too.
#[test]
fn both_commands_describe_their_replies() {
let mut s = Session::default();
for id in IDS {
let described = s.execute("engine.describe", &json!({"command": id})).unwrap_or_else(|e| panic!("{e}"));
assert!(described["output"].is_object(), "{id}: {described}");
}
}
/// The fields of the handler's reader for one form, from serde's message for an unknown key,
/// which lists them all.
fn handler_fields(progressive: bool) -> BTreeSet<String> {
@@ -285,6 +295,178 @@ fn the_check_takes_what_the_schema_allows() {
}
}
/// A GPU executor without a GPU, as in `cmd/render/job_tests.rs`: the cooperative CPU host's
/// executor, named `gpu-path` and taking progressive requests.
#[cfg(not(target_arch = "wasm32"))]
struct NumericalGpu;
#[cfg(not(target_arch = "wasm32"))]
impl solidcraft_render::job::ImageExecutor for NumericalGpu {
fn descriptor(&self) -> solidcraft_render::job::Result<solidcraft_render::ExecutorDescriptor> {
let mut descriptor = CpuHost::cooperative().descriptor()?;
descriptor.id = solidcraft_render::ExecutorId::new("gpu-path").unwrap_or_else(|e| panic!("{e}"));
descriptor.profiles[0].features.extend([solidcraft_render::RenderFeature::Progressive, solidcraft_render::RenderFeature::Hdri]);
Ok(descriptor)
}
fn admit(
&self,
scene: &solidcraft_render::ProductScene,
request: &ImageRequest,
options: JobOptions,
) -> solidcraft_render::job::Result<Admission> {
options.validate()?;
solidcraft_render::job::admit(scene, &ImageRequest { environment: None, textures: None, ..request.clone() })
}
fn begin(
&self,
scene: solidcraft_render::ProductScene,
request: ImageRequest,
_: JobOptions,
cancel: CancelToken,
) -> solidcraft_render::job::Result<Box<dyn ImageWork>> {
Ok(Box::new(ImageTask::new(scene, ImageRequest { environment: None, textures: None, ..request }, cancel)?))
}
}
/// The keys of Check Render Request's reply in either form, then only in the runtime or the GPU
/// form, and of its `denoising` in either form (CPU ray tracing adds four), as the handler gave
/// them before its reply had a type.
const SHARED_KEYS: &[&str] = &[
"admission",
"dielectric_boundaries",
"environment",
"host",
"refraction",
"request",
"resolved",
"scene_generation",
"stage_panels",
"textures",
"transparency",
];
const RUNTIME_KEYS: &[&str] = &["cpu_budget", "cpu_limits", "ignored_controls", "retained_request_bytes", "stage_transport"];
const GPU_KEYS: &[&str] = &["options", "retained_options_bytes"];
const DENOISING_KEYS: &[&str] = &[
"bypass",
"diagnostics_requested",
"guides",
"limitation",
"method",
"optical_policy",
"outlier_policy",
"output_stage",
"passes",
"raw_linear_accumulator_preserved",
"settings",
"steps",
];
/// An object's keys, sorted.
fn keys(v: &Value) -> Vec<String> {
v.as_object().map(|o| o.keys().cloned().collect()).unwrap_or_default()
}
/// `keys`, sorted.
fn sorted(keys: &[&str]) -> Vec<String> {
let mut keys: Vec<String> = keys.iter().map(|k| k.to_string()).collect();
keys.sort();
keys
}
/// `params` run as `id` in Warn: the reply, which must match `schema` and have no warnings.
fn reply(s: &mut Session, id: &str, params: &Value, schema: &schemars::Schema) -> Value {
let r = s.execute(id, params).unwrap_or_else(|e| panic!("{id} {params}: {e}"));
assert!(conforms(schema.as_value(), schema.as_value(), &r), "{id} {params}: {r} doesn't match {}", schema.as_value());
assert!(r.get("param_warnings").is_none(), "{id} {params}: {r}");
r
}
/// #13311: both commands reply as their output schemas say, in Warn without warnings, in either
/// form: a raster request, one whose setup has a control the raster executor leaves out, and GPU
/// requests with and without denoising and the indirect clamp. Render Image's reply is the queued
/// job's status, with the ignored controls of a runtime request; Check Render Request's has the
/// sections of its form.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn replies_match_the_output_schemas() {
let (checked, started) = (output_schema_for::<RenderChecked>(), output_schema_for::<RenderStarted>());
let small = json!({"width": 16, "height": 16});
for (p, ignored) in [
(json!({"settings": small}), Some(json!([]))),
(json!({"setup": "Bright", "transparent_background": true}), Some(json!(["exposure_ev"]))),
(json!({"settings": small, "progressive": true}), None),
(json!({"settings": small, "progressive": true, "denoise": true, "clamp_indirect": 8}), None),
] {
let mut s = Session::default();
s.execute("solid.box", &json!({"length": 20, "width": 10, "height": 5})).unwrap_or_else(|e| panic!("{e}"));
let bright = json!({"name": "Bright", "set": {"width": 16, "height": 16, "exposure_ev": 1}});
s.execute("render.settings.set", &bright).unwrap_or_else(|e| panic!("{e}"));
s.set_render_job_host(Some(CpuHost::cooperative())).unwrap_or_else(|e| panic!("{e}"));
let gpu = ImageHost::with_executor(std::sync::Arc::new(NumericalGpu)).unwrap_or_else(|e| panic!("{e}"));
s.register_render_job_host(gpu).unwrap_or_else(|e| panic!("{e}"));
s.param_check = ParamCheck::Warn;
let r = reply(&mut s, "render.preflight", &p, &checked);
assert_eq!(r.get("ignored_controls"), ignored.as_ref(), "{p}: {r}");
let gpu = p["progressive"] == true;
let mut expected: Vec<&str> = SHARED_KEYS.iter().chain(if gpu { GPU_KEYS } else { RUNTIME_KEYS }).copied().collect();
if p["denoise"] == true {
expected.extend(["denoising", "indirect_clamp"]);
assert_eq!(keys(&r["denoising"]), sorted(DENOISING_KEYS), "{r}");
assert_eq!(keys(&r["indirect_clamp"]), sorted(&["biased", "from_bounce", "keeps_hue", "limit", "limitation"]), "{r}");
}
assert_eq!(keys(&r), sorted(&expected), "{p}");
assert_eq!(r["resolved"]["executor"], if gpu { "gpu-path" } else { "cpu-product" }, "{p}: {r}");
let job = reply(&mut s, "render.start", &p, &started);
assert_eq!(job.get("ignored_controls"), ignored.as_ref(), "{p}: {job}");
if !gpu {
// The cooperative host doesn't run a job until it is pumped, so its status is as queued.
let id: JobId = serde_json::from_value(job["job"].clone()).unwrap_or_else(|e| panic!("{e}"));
let mut status = json!(s.render_jobs.status(id).unwrap_or_else(|e| panic!("{e}")));
status["ignored_controls"] = ignored.clone().unwrap_or_default();
assert_eq!(job, status);
}
}
}
/// #13311: CPU ray tracing replies as the output schemas say too, with the sections only it has:
/// the CPU filter's denoising and a setup's clear coats.
#[cfg(all(feature = "raytrace-cpu", not(target_arch = "wasm32")))]
#[test]
fn cpu_ray_tracing_replies_match_the_output_schemas() {
let mut s = Session::default();
s.execute("solid.box", &json!({"body_name": "Panel", "length": 20, "width": 20, "height": 2})).unwrap_or_else(|e| panic!("{e}"));
let options =
json!({"progressive": {"samples_per_pixel": 1, "seed": 7}, "lights": [{"direction": [0, 0, 1], "color": [1, 1, 1], "intensity": 1}]});
let mut denoised = options.clone();
denoised["denoise"] = json!(true);
let set = |options: &Value| json!({"width": 8, "height": 8, "job": {"method": "cpu_raytrace", "options": options}});
s.execute("render.settings.set", &json!({"name": "Denoised", "set": set(&denoised)})).unwrap_or_else(|e| panic!("{e}"));
let coat = json!({"name": "Coat", "body_index": 0, "body_name": "Panel", "expected_revision": s.revision,
"coat": {"weight": 0.5, "roughness": 0.5, "ior": 1.5}, "set": set(&options)});
s.execute("render.coat.bind", &coat).unwrap_or_else(|e| panic!("{e}"));
s.set_render_job_host(Some(CpuHost::for_platform().unwrap_or_else(|e| panic!("{e}")))).unwrap_or_else(|e| panic!("{e}"));
s.param_check = ParamCheck::Warn;
let (checked, started) = (output_schema_for::<RenderChecked>(), output_schema_for::<RenderStarted>());
for (setup, section) in [("Denoised", "denoising"), ("Coat", "coats")] {
let p = json!({"setup": setup, "replace": true});
let r = reply(&mut s, "render.preflight", &p, &checked);
assert_eq!(r["resolved"]["executor"], "cpu-raytrace", "{r}");
let mut expected: Vec<&str> = SHARED_KEYS.iter().chain(RUNTIME_KEYS).copied().collect();
expected.push(section);
assert_eq!(keys(&r), sorted(&expected), "{setup}");
if section == "denoising" {
let cpu_only = ["backend", "checkpoints", "raw_linear_exr_preserved", "work_units"];
assert_eq!(keys(&r["denoising"]), sorted(&[DENOISING_KEYS, &cpu_only].concat()), "{r}");
assert_eq!(r["denoising"]["backend"], "native_cpu", "{r}");
assert_eq!(r["denoising"]["guides"], "deterministic_center_ray_albedo_normal_camera_relative_position_depth", "{r}");
}
let job = reply(&mut s, "render.start", &p, &started);
assert_eq!(job["ignored_controls"], json!([]), "{job}");
}
}
/// #13311: in Warn, a request the contract takes runs as it did with the check off and gets no
/// warnings: Check Render Request's reply is the same, and Render Image queues a job. Among them
/// a saved setup with a job and an inline one, which the handler lowers itself.
+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"});
+27
View File
@@ -277,6 +277,28 @@ fn hidden<'de, D: Deserializer<'de>>(d: D) -> Result<Option<HiddenLines>, D::Err
read_as(d, "hidden must be omit or dashed")
}
/// How the drawing's curved edges are written.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DxfCurves {
/// As short straight lines, as the view is tessellated.
Lines,
/// Round holes and arcs as `CIRCLE` and `ARC` entities.
Arcs,
}
fn curves_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "How curved edges are written: `lines` writes the short straight lines the view is built from; `arcs` writes the visible circles and arcs as CIRCLE and ARC entities, which a milling or CAD program can pick as circles. Default: lines.",
"enum": ["lines", "arcs"],
"default": "lines"
})
}
fn curves<'de, D: Deserializer<'de>>(d: D) -> Result<Option<DxfCurves>, D::Error> {
read_as(d, "curves must be lines or arcs")
}
/// Where the drawing's zero point is.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
@@ -355,6 +377,11 @@ pub struct ExportViewDxfParams {
#[serde(default, deserialize_with = "zero", skip_serializing_if = "Option::is_none")]
#[schemars(schema_with = "zero_schema")]
pub zero: Option<DxfZero>,
/// How curved edges are written: as short lines or, for visible circles and arcs, as CIRCLE
/// and ARC. Default: lines.
#[serde(default, deserialize_with = "curves", skip_serializing_if = "Option::is_none")]
#[schemars(schema_with = "curves_schema")]
pub curves: Option<DxfCurves>,
/// The `.dxf` file to write. Default: the reply carries the DXF text instead.
#[serde(default, deserialize_with = "export_path", skip_serializing_if = "Option::is_none")]
#[schemars(schema_with = "path_schema")]

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