Compare commits

...

31 Commits

Author SHA1 Message Date
Brennen Herbruck 30a306b376 Loft: the parallel-planes check never fails a loft that builds with chord-length parameters
`SectionPlanes::of` asks only whether each section face is a plane, so it reads the
surface's kind as stored (`kind()`) instead of building a NURB for it
(`kind_with_nurb`), which could fail. And an error from the check or from the
plane-distance rebuild now keeps the chord-length skins, as upstream builds them; only
cancellation and unported calls still stop the loft. A loft that builds on main builds
the same here whatever the check meets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 19:11:47 -05:00
Brennen Herbruck a78e36bd9e 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>
2026-11-11 18:43:47 -05:00
Brennen Herbruck 1982f7dfa3 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>
2026-11-11 18:43:47 -05: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
193 changed files with 8778 additions and 2054 deletions
+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:
Generated
+95 -5
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"
@@ -5052,6 +5115,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 +5516,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 +6147,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 +6168,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",
@@ -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 -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")]
@@ -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"
}
}
+318 -18
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,7 +2644,11 @@
"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",
@@ -2567,6 +2657,8 @@
"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.",
@@ -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}",
+1
View File
@@ -721,6 +721,7 @@ mod tests {
width: "5 mm".into(),
height: "10 mm".into(),
operation: crate::Operation::NewBody,
centred: false,
},
None,
)
+5
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")]
+22 -5
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![
@@ -9987,9 +9993,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 +11195,7 @@ mod world_placement_cache_tests {
width: "3 mm".into(),
height: "4 mm".into(),
operation: crate::Operation::NewBody,
centred: false,
},
Some("AuthoredBox"),
)
@@ -11225,7 +11232,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 +11389,7 @@ mod chain_tests {
width: "30".into(),
height: "20".into(),
operation: Operation::NewBody,
centred: false,
},
None,
)
@@ -11421,6 +11436,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 +11483,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,
)
+4
View File
@@ -1943,6 +1943,7 @@ mod tests {
width: "4".into(),
height: "6".into(),
operation: Operation::NewBody,
centred: false,
},
None,
)
@@ -2208,6 +2209,7 @@ mod tests {
width: "20".into(),
height: "5".into(),
operation: crate::Operation::NewBody,
centred: false,
},
None,
)
@@ -2333,6 +2335,7 @@ mod tests {
width: "10".into(),
height: "10".into(),
operation: crate::Operation::NewBody,
centred: false,
},
Some(&format!("Box{i}")),
)
@@ -2363,6 +2366,7 @@ mod tests {
width: "10".into(),
height: "10".into(),
operation: crate::Operation::NewBody,
centred: false,
},
None,
)
@@ -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`).
+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 = [
+8 -21
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")
@@ -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> {
+11
View File
@@ -654,6 +654,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 {
@@ -974,6 +981,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() {
+57
View File
@@ -296,6 +296,63 @@ 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);
}
#[test]
fn renaming_the_body_keeps_its_print_face() {
let mut s = block();
+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))]
+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 {
+10
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).
@@ -843,6 +849,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 +916,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 +937,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 +2003,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(),
+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>,
@@ -143,6 +143,7 @@ fn placement_dependent_cross_component_cuts_still_evaluate_and_undo() {
width: "10".into(),
height: "4".into(),
operation: Operation::NewBody,
centred: false,
},
None,
)
@@ -159,6 +160,7 @@ fn placement_dependent_cross_component_cuts_still_evaluate_and_undo() {
width: "2".into(),
height: "6".into(),
operation: Operation::Cut,
centred: false,
},
None,
)
+1
View File
@@ -18,6 +18,7 @@ fn model(body_count: usize) -> (Document, Model) {
width: "20".into(),
height: "30".into(),
operation: Operation::NewBody,
centred: false,
},
None,
)
+3
View File
@@ -1045,6 +1045,7 @@ mod tests {
width: "20".into(),
height: "30".into(),
operation: Operation::NewBody,
centred: false,
},
None,
)
@@ -1433,6 +1434,7 @@ mod tests {
width: "30".into(),
height: "10".into(),
operation: Operation::NewBody,
centred: false,
},
None,
)
@@ -1556,6 +1558,7 @@ mod tests {
width: "30".into(),
height: "10".into(),
operation: Operation::NewBody,
centred: false,
},
None,
)
+71 -12
View File
@@ -82,6 +82,34 @@ pub(crate) use imp::{
revolve, screw, screw_radial, sphere, sweep, sweep_controlled,
};
/// `parts` joined in order in a balanced tree: each part with its neighbour, then each pair with
/// the next pair, and so on, a part left over at the end of a level carried up as it is. Every
/// join is of two neighbouring stretches, the left one first, and every part takes part in about
/// log2(n) joins; joining each part onto all those before it instead hands the growing whole to
/// every join, n²/2 parts' worth of work in all. None for no parts.
fn union_tree<T>(parts: Vec<T>, mut join: impl FnMut(T, T) -> Result<T>) -> Result<Option<T>> {
let mut level = parts;
while level.len() > 1 {
let mut next = Vec::with_capacity(level.len().div_ceil(2));
let mut it = level.into_iter();
while let Some(a) = it.next() {
next.push(match it.next() {
Some(b) => join(a, b)?,
None => a,
});
}
level = next;
}
Ok(level.pop())
}
#[cfg(test)]
thread_local! {
/// The runs' pieces the unions joining a cornered sweep's runs have taken in on this thread,
/// both operands counted: how the joining's work grows with the number of corners.
pub(crate) static UNION_LOAD: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// Refuses what no SMLib sweep does, before any work.
pub(crate) fn refuse(what: &str) -> KernelError {
not_supported(&format!("SMLib {what}"))
@@ -951,6 +979,17 @@ mod imp {
a.boolean(b, op).map_err(|e| failed(what, e))?.ok_or_else(|| failed(what, "nothing is left"))
}
/// The union of the runs' pieces, in a balanced tree ([`union_tree`]) of the plain union.
fn joined_runs(parts: Vec<Brep>) -> Result<Brep> {
let held: Vec<(Brep, usize)> = parts.into_iter().map(|b| (b, 1)).collect();
let joined = union_tree(held, |(a, m), (b, n)| {
#[cfg(test)]
UNION_LOAD.with(|l| l.set(l.get() + m + n));
Ok((combined(&a, &b, solidcraft_smlib::BooleanOp::Union, "sweep corner")?, m + n))
})?;
joined.map(|(b, _)| b).ok_or_else(|| KernelError::Invalid("empty path".into()))
}
/// A sweep along a path with corners: each smooth run of it swept on its own, and the runs
/// joined at each corner. Turning with the path, the profile turns at a corner about the
/// line square to both directions, and the runs meet in a mitre: each run is swept on
@@ -992,7 +1031,7 @@ mod imp {
let closed = end.dist(start) <= 1e-6;
let closing = (closed && last_t.dot(first_t) < (1e-3f64).cos()).then_some(first_t);
let mut pose = *plane;
let mut body: Option<Brep> = None;
let mut parts: Vec<Brep> = Vec::with_capacity(runs.len());
let mut inside = Vec::new();
// The mitre the next run starts from: its point, its normal (along the run) and the turn.
let mut entry: Option<(Vec3, Vec3, f64)> = match closing {
@@ -1057,10 +1096,7 @@ mod imp {
}
(piece, moved_plane(&placed(&from, &last), t1 * -post))
};
body = Some(match body {
None => piece,
Some(b) => combined(&b, &piece, solidcraft_smlib::BooleanOp::Union, "sweep corner")?,
});
parts.push(piece);
// The profile at the run's end, then turned through the corner onto the next run.
pose = at_end;
entry = None;
@@ -1070,7 +1106,7 @@ mod imp {
entry = Some((b1, m, turn));
}
}
let brep = body.ok_or_else(|| KernelError::Invalid("empty path".into()))?;
let brep = joined_runs(parts)?;
let body = body_of(brep, "sweep")?;
let roles = roles(&body, plane, (!closed).then_some(&pose), &seeds(plane, pieces, |q| q), &inside, false)?;
with_history(body, &roles, pieces)
@@ -1615,7 +1651,7 @@ mod imp {
_ => None,
};
let mut before = 0.0f64;
let mut body: Option<Brep> = None;
let mut parts: Vec<Brep> = Vec::with_capacity(runs.len());
let mut inside = Vec::new();
for (ri, run) in runs.iter().enumerate() {
let segs = path.get(run.clone()).ok_or_else(|| KernelError::Invalid("path run".into()))?;
@@ -1689,10 +1725,7 @@ mod imp {
piece = combined(&piece, &half_space(b1, m, size)?, solidcraft_smlib::BooleanOp::Subtract, "sweep mitre")?;
}
}
body = Some(match body {
None => piece,
Some(b) => combined(&b, &piece, solidcraft_smlib::BooleanOp::Union, "sweep corner")?,
});
parts.push(piece);
// The section at the run's end, turned through the corner onto the next run.
at_start = end_map;
entry = None;
@@ -1703,7 +1736,7 @@ mod imp {
}
before += length;
}
let brep = body.ok_or_else(|| KernelError::Invalid("empty path".into()))?;
let brep = joined_runs(parts)?;
if closed {
// Back at the start, the section has to be the profile again (a whole number of its
// symmetries of twist).
@@ -1968,3 +2001,29 @@ mod imp {
with_history(body, &roles, pieces)
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::panic)]
mod tests {
/// The corner pieces of a sweep along a path are joined in a balanced tree (#14451): every join
/// is of two neighbouring stretches, left first, and for 180 runs (a 181-point polyline) the
/// joins take in 180 · 8 = 1440 pieces' worth of operands at most, where joining each piece onto
/// the body so far takes in 2 + 3 + … + 180 = 16,289.
#[test]
fn corner_pieces_are_joined_neighbours_first_in_a_balanced_tree() {
let mut load = 0;
let mut joins = 0;
let whole = super::union_tree((0..180).map(|i| i..i + 1).collect(), |a: std::ops::Range<usize>, b: std::ops::Range<usize>| {
assert_eq!(a.end, b.start, "joined {a:?} with {b:?}, not its neighbour");
load += a.len() + b.len();
joins += 1;
Ok(a.start..b.end)
})
.unwrap();
assert_eq!(whole, Some(0..180));
assert_eq!(joins, 179);
assert!(load <= 180 * 8, "{load}");
assert_eq!(super::union_tree(Vec::<usize>::new(), |a, _| Ok(a)).unwrap(), None);
assert_eq!(super::union_tree(vec![7], |a, _| Ok(a)).unwrap(), Some(7));
}
}
+27
View File
@@ -144,6 +144,33 @@ fn a_parallel_rail_along_a_polyline_turns_with_its_corners() {
assert!(close(m.volume, volume, 1e-6), "volume {} vs {volume}", m.volume);
}
/// A densely sampled polyline (#14451): 33 points, 32 runs, 31 mitres. The runs' pieces are
/// unioned in a balanced tree, so each takes part in at most ⌈log2 32⌉ = 5 unions (160 pieces'
/// worth of union operands in all, where unioning each onto the body so far takes in
/// 2 + 3 + … + 32 = 527 and grows with the square of the points), and the solid is the same
/// oblique cylinders: volume πr² · L · cos(d/2).
#[test]
fn a_rail_along_a_dense_polyline_joins_its_runs_in_a_balanced_union() {
let n = 32;
let arc = |radius: f64| -> Vec<Vec3> {
(0..=n)
.map(|i| {
let a = PI / 2.0 * f64::from(i) / f64::from(n);
Vec3::new(30.0 - radius * a.cos(), 0.0, radius * a.sin())
})
.collect()
};
crate::smlib_sweeps::UNION_LOAD.with(|l| l.set(0));
let b = sweep_path_with(&Plane::XY, &circle(5.0), &arc(30.0), &railed(vec![arc(35.0)], RailFit::Scale)).unwrap();
let load = crate::smlib_sweeps::UNION_LOAD.with(std::cell::Cell::get);
assert_eq!(load, 32 * 5, "union operands, in runs' pieces");
let m = measure(&b).unwrap();
let d = PI / 2.0 / f64::from(n);
let length = f64::from(n) * 60.0 * (d / 2.0).sin();
let volume = PI * 25.0 * length * (d / 2.0).cos();
assert!(close(m.volume, volume, 1e-6), "volume {} vs {volume}", m.volume);
}
/// Two rails bulging apart along arcs (radius 25 about (∓5, 0, 20), through x = ±10 at z = 0
/// and z = 40) carry a line between them: the sheet lies in the XZ plane between the arcs, area
/// 2(a√(R² − a²) + R² asin(a/R)) − 400 with a = 20.
+1
View File
@@ -22,6 +22,7 @@
"bend_up": "#ffffff",
"body": "#000000",
"body_edge": "#ffffff",
"isoparm": "#ffffff",
"body_selected": "#ffffff",
"border": "#ffffff",
"box_crossing": "#ffffff",
+1
View File
@@ -22,6 +22,7 @@
"bend_up": "#000000",
"body": "#ffffff",
"body_edge": "#000000",
"isoparm": "#000000",
"body_selected": "#000000",
"border": "#000000",
"box_crossing": "#000000",
+1
View File
@@ -27,6 +27,7 @@
"bend_up": "#257231",
"body": "#c2beb8",
"body_edge": "#403d38",
"isoparm": "#817e78",
"body_selected": "#de8d6b",
"border": "#d9d5d0",
"box_crossing": "#257231",
+1
View File
@@ -27,6 +27,7 @@
"bend_up": "#d4902f",
"body": "#382309",
"body_edge": "#ffbc48",
"isoparm": "#9c7029",
"body_selected": "#543615",
"border": "#533812",
"box_crossing": "#d4902f",
+1
View File
@@ -27,6 +27,7 @@
"bend_up": "#6ee0bb",
"body": "#8baccb",
"body_edge": "#eef8ff",
"isoparm": "#bdd2e5",
"body_selected": "#58bbdf",
"border": "#315777",
"box_crossing": "#6ee0bb",
+1
View File
@@ -27,6 +27,7 @@
"bend_up": "#a6e3a1",
"body": "#586382",
"body_edge": "#477dc8",
"isoparm": "#5070a5",
"body_selected": "#365280",
"border": "#45475a",
"box_crossing": "#a6e3a1",
+1
View File
@@ -22,6 +22,7 @@
"bend_up": "#55ff55",
"body": "#aa00aa",
"body_edge": "#55ffff",
"isoparm": "#55ffff",
"body_selected": "#ffff55",
"border": "#55ffff",
"box_crossing": "#ff55ff",
+1
View File
@@ -27,6 +27,7 @@
"bend_up": "#89cf8e",
"body": "#8b8378",
"body_edge": "#1b1814",
"isoparm": "#534e46",
"body_selected": "#b8603f",
"border": "#403930",
"box_crossing": "#89cf8e",
+1
View File
@@ -27,6 +27,7 @@
"bend_up": "#257231",
"body": "#c8c0b3",
"body_edge": "#423e36",
"isoparm": "#857f75",
"body_selected": "#e2967a",
"border": "#e0d8c9",
"box_crossing": "#257231",
+1
View File
@@ -27,6 +27,7 @@
"bend_up": "#36934d",
"body": "#b6bbc3",
"body_edge": "#393d44",
"isoparm": "#787c84",
"body_selected": "#79afea",
"border": "#c6c6c8",
"box_crossing": "#39a75b",
+1
View File
@@ -27,6 +27,7 @@
"bend_up": "#83c69b",
"body": "#68717d",
"body_edge": "#2878a9",
"isoparm": "#487593",
"body_selected": "#245674",
"border": "#484d55",
"box_crossing": "#83c69b",
+1
View File
@@ -27,6 +27,7 @@
"bend_up": "#39d45c",
"body": "#0b3818",
"body_edge": "#4dff79",
"isoparm": "#2c9c49",
"body_selected": "#18542b",
"border": "#285333",
"box_crossing": "#39d45c",
+1
View File
@@ -40,6 +40,7 @@
"construction_geom": "#e97926",
"body": "#a89984",
"body_edge": "#1d2021",
"isoparm": "#635d53",
"manipulator": "#83a598",
"preview_add": "#83a598",
"preview_new": "#fabd2f",
+1
View File
@@ -39,6 +39,7 @@
"construction_geom": "#9b3407",
"body": "#bdae93",
"body_edge": "#3c3836",
"isoparm": "#7d7365",
"manipulator": "#076678",
"preview_add": "#458588",
"preview_new": "#d79921",
+1
View File
@@ -27,6 +27,7 @@
"bend_up": "#40e080",
"body": "#555555",
"body_edge": "#0070d0",
"isoparm": "#2b6393",
"body_selected": "#004c80",
"border": "#a0a0a0",
"box_crossing": "#40e080",
+1
View File
@@ -27,6 +27,7 @@
"bend_up": "#a3be8c",
"body": "#657991",
"body_edge": "#5e81ac",
"isoparm": "#627d9f",
"body_selected": "#34607a",
"border": "#4c566a",
"box_crossing": "#a3be8c",
+1
View File
@@ -27,6 +27,7 @@
"bend_up": "#50fa7b",
"body": "#656581",
"body_edge": "#8456b8",
"isoparm": "#755e9d",
"body_selected": "#563c79",
"border": "#50546d",
"box_crossing": "#50fa7b",
+1
View File
@@ -27,6 +27,7 @@
"bend_up": "#89cf8e",
"body": "#74777b",
"body_edge": "#0f1113",
"isoparm": "#424447",
"body_selected": "#c56241",
"border": "#2f3235",
"box_crossing": "#89cf8e",
+1
View File
@@ -27,6 +27,7 @@
"bend_up": "#98c379",
"body": "#abb2bf",
"body_edge": "#181a1f",
"isoparm": "#62666f",
"body_selected": "#61afef",
"border": "#3e4451",
"box_crossing": "#98c379",
+1
View File
@@ -24,6 +24,7 @@
"bend_up": "#a5b9a5",
"body": "#93989c",
"body_edge": "#60666b",
"isoparm": "#7a7f84",
"body_selected": "#3b4852",
"border": "#3d4145",
"box_window": "#9eafbd",
+1
View File
@@ -27,6 +27,7 @@
"bend_up": "#1e8c32",
"body": "#adadb6",
"body_edge": "#292933",
"isoparm": "#6b6b75",
"body_selected": "#9999cc",
"border": "#888888",
"box_crossing": "#28a050",
+1
View File
@@ -23,6 +23,7 @@
"bend_up": "#859900",
"body": "#586e75",
"body_edge": "#346e88",
"isoparm": "#466e7f",
"body_selected": "#073642",
"border": "#586e75",
"box_crossing": "#859900",
+1
View File
@@ -27,6 +27,7 @@
"bend_up": "#859900",
"body": "#b5b6a0",
"body_edge": "#346e88",
"isoparm": "#759294",
"body_selected": "#bdd9da",
"border": "#b6b3a1",
"box_crossing": "#859900",
+1
View File
@@ -27,6 +27,7 @@
"bend_up": "#008000",
"body": "#c0c0c0",
"body_edge": "#000000",
"isoparm": "#606060",
"body_selected": "#8080ff",
"border": "#808080",
"box_crossing": "#008000",
+1
View File
@@ -27,6 +27,7 @@
"bend_up": "#9ece6a",
"body": "#4f6089",
"body_edge": "#4775ce",
"isoparm": "#4b6bac",
"body_selected": "#304e7e",
"border": "#414868",
"box_crossing": "#9ece6a",
-1
View File
@@ -198,7 +198,6 @@ pub(crate) fn fixes() -> UpstreamFixes {
f.delete_vertex_joins_seam_iso_curve = true;
f.box_face_failures_propagate = true;
f.ellipse_adjust_step_interval_keeps_turns = true;
f.fillet_cleanup_removes_input_slits = true;
f.fillet_rail_end_on_continued_surface = true;
f.glue_vertices_joins_every_region = true;
f.fillet_removal_takes_ring_fillets = true;
+3 -2
View File
@@ -43,7 +43,7 @@ use solidcore::topology::brep::p1 as brep_p1;
use solidcore::topology::brep::p2::{is_manifold_solid, is_sheet};
use solidcore::topology::brep::p3::{new_brep, with_kernel_callbacks};
use solidcore::topology::brep::p8::create_face_from_surface;
use solidcore::topology::brep::p9::orient_sheet_surfaces;
use solidcore::topology::brep::p9::{OrientSheetNote, orient_sheet_surfaces};
use solidcore::topology::brep::p10::merge_curves_on_surface_through_surface;
use solidcore::topology::brep::planar_section::face_planar_section_curves;
use solidcore::topology::brep_cache::CacheObject;
@@ -497,7 +497,8 @@ pub(crate) fn split_body_by_sheet(body: &Body, sheet: &Body, face: usize) -> Res
// the sheet's own attributes, so its faces' tags see each swap.
let turned = with_kernel_callbacks(k, &mut copy, |k, b, env| orient_sheet_surfaces(k, b, Some(f), !flip, env));
copy.refresh_cached_topology();
Ok(match turned {
// the orientation's note fails the side as its status 1 did
Ok(match OrientSheetNote::stop_on_note(turned) {
Ok(()) => Ok(copy),
Err(e) => Err(status(e)),
})
+18
View File
@@ -1086,6 +1086,9 @@ pub struct Tokens {
// ---- bodies and live previews ----
pub body: Color32,
pub body_edge: Color32,
/// Isoparm lines drawn on a surface: lighter than its edges, so they read as the surface's
/// flow, not its boundary.
pub isoparm: Color32,
/// Command manipulators (direction arrows, handles).
pub manipulator: Color32,
/// Allowed assembly motion, separate from the selected face's blue.
@@ -1452,6 +1455,7 @@ impl Tokens {
construction_geom: view.construction_geom,
body: view.body,
body_edge: view.body_edge,
isoparm: view.isoparm,
manipulator: view.manipulator,
joint_manipulator: view.joint_manipulator,
joint_axis: view.joint_axis,
@@ -1632,6 +1636,20 @@ pub fn panel_motion_ease(progress: f32) -> f32 {
mod tests {
use super::*;
/// A surface's isoparms show on its faces less than its edges do, and stay visible against the
/// view's background (seen at the sheet's silhouette), on both bases.
#[test]
fn isoparms_are_lighter_than_edges_and_visible_on_the_background() {
for t in [Tokens::light(), Tokens::dark()] {
let (iso, edge) = (contrast_ratio(t.isoparm, t.body), contrast_ratio(t.body_edge, t.body));
assert!(iso >= 1.5 && iso < edge, "isoparm {iso:.2} against edge {edge:.2} on the body");
for background in [t.viewport_top, t.viewport_bottom] {
let ratio = contrast_ratio(t.isoparm, background);
assert!(ratio >= 1.5, "isoparm {ratio:.2} on the viewport background {background:?}");
}
}
}
#[test]
fn panel_motion_is_bounded_and_reduced_or_invalid_inputs_finish_immediately() {
let ctx = egui::Context::default();
+3
View File
@@ -189,6 +189,8 @@ impl Tokens {
construction_geom: icon_primary(strokes::CONSTRUCTION_PRIMARY[0]),
body: Color32::from_rgb(176, 186, 198),
body_edge: Color32::from_rgb(40, 44, 52),
// Halfway from the edge colour to the body's: about half the edges' contrast.
isoparm: Color32::from_rgb(108, 115, 125),
manipulator: Color32::from_rgb(40, 110, 220),
joint_manipulator: Color32::from_rgb(235, 157, 0),
joint_axis: Color32::from_rgb(0, 95, 110),
@@ -429,6 +431,7 @@ impl Tokens {
t.grid_major = Color32::from_rgb(73, 79, 90);
t.body = Color32::from_rgb(152, 156, 162);
t.body_edge = Color32::from_rgb(16, 16, 18);
t.isoparm = Color32::from_rgb(84, 86, 90);
t.sketch = Color32::from_rgb(92, 150, 255);
t.sketch_fixed = Color32::from_rgb(232, 234, 238);
t.sketch_halo = Color32::from_rgb(28, 32, 40);
+9 -7
View File
@@ -1395,8 +1395,11 @@ fn top_controls(chat: &mut AgentChat, view: &PanelView, ui: &mut egui::Ui, state
new_chat_confirmation(chat, ui);
}
// Shared top controls must yield scroll space to the pinned composer, including
// when discovery lists several agents or a long message grows the editor.
let picker_max = if shared {
// when discovery lists several agents or a long message grows the editor. The
// sign-in box scrolls in the same place in either layout (#15087), and may use
// everything above the composer: there is no conversation to show until it is done.
let needs_auth = state == State::NeedsAuth;
let picker_max = if shared || needs_auth {
let t = Tokens::get();
let composer = view.composer_height.unwrap_or(48.0 + t.control_height * 3.0 + t.space_8 * 2.0 + t.space_6 * 3.0);
(ui.available_height() - composer - ui.spacing().item_spacing.y).max(0.0)
@@ -1416,13 +1419,10 @@ fn top_controls(chat: &mut AgentChat, view: &PanelView, ui: &mut egui::Ui, state
ui_kit::assistant_controls(ui, |ui| switcher(chat, view, ui));
}
picker(chat, view, ui);
if shared && state == State::NeedsAuth {
if needs_auth {
inset(ui, |ui| auth_box(chat, ui));
}
});
if !shared && state == State::NeedsAuth {
inset(ui, |ui| auth_box(chat, ui));
}
ui.cursor().top() - top - picker.inner_rect.height().min(picker.content_size.y) + picker.content_size.y
}
@@ -2202,7 +2202,9 @@ fn auth_box(chat: &mut AgentChat, ui: &mut egui::Ui) {
}
}
ui.add_space(solidcraft_theme::Tokens::get().space_4);
if ui_kit::button(ui, tr("ui.agent_chat_auth_retry"), ui_kit::Button::Normal).clicked() {
let retry = ui_kit::button(ui, tr("ui.agent_chat_auth_retry"), ui_kit::Button::Normal);
solidcraft_ui_registry::publish_handle("agent_chat:auth_retry", retry.rect.center());
if retry.clicked() {
chat.actions.push(PanelAction::Connect);
}
});
+53
View File
@@ -5571,6 +5571,59 @@ fn shared_assistant_body(h: &crate::scenario::Harness) -> egui::Rect {
egui::Rect::from_min_size(egui::pos2(body.origin.x, body.origin.y), egui::vec2(body.size.width, body.size.height))
}
/// #15087: an agent that asks to sign in two ways, in the docked Assistant of a small window, with
/// the default panel layout and the shared one. The sign-in box scrolls inside the panel, and the
/// message box stays in view below it with nothing drawn over it.
#[test]
fn a_long_sign_in_box_scrolls_inside_a_short_docked_assistant() {
use solidcraft_ui_registry::{handle_at, rect_of};
for shared in [false, true] {
let (mut h, _script) = shared_assistant_harness();
h.app.preferences.shared_panel_layout = shared;
h.app.ui.show_browser = true;
h.app.ui.show_timeline = true;
h.call("ui.resize", json!({"width":960,"height":600}));
let terminal = |id: &str, name: &str, flag: &str| acp::AuthMethod {
id: id.into(),
name: name.into(),
description: format!("Sign in with {name}"),
kind: acp::AuthKind::Terminal { args: vec!["--cli".into(), "auth".into(), "login".into(), flag.into()], command: None },
};
h.app.agent_chat.argv = vec!["standin-agent".into()];
h.app.agent_chat.auth = vec![terminal("console", "Console", "--console"), terminal("subscription", "Subscription", "--claudeai")];
// A new chat: its tip is drawn below the sign-in box, where the conversation goes.
h.app.agent_chat.items.clear();
h.app.agent_chat.input.clear();
h.app.agent_chat.state = Some(State::NeedsAuth);
h.frames(8);
assert_eq!(h.app.panel_runtime().is_some(), shared);
let window = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(960.0, 600.0));
let body = if shared { shared_assistant_body(&h) } else { rect_of("agent_chat:panel").expect("the docked Assistant") };
let composer = rect_of("agent_chat:composer_footer").expect("the message box");
let providers = rect_of("agent_chat:providers").expect("the sign-in box's scroll area");
let conversation = rect_of("agent_chat:conversation").expect("the conversation");
assert!(window.expand(1.0).contains_rect(body), "shared {shared}: the Assistant {body:?} fits the window");
assert!(body.expand(1.0).contains_rect(composer), "shared {shared}: the message box {composer:?} stays inside {body:?}");
assert!(providers.bottom() <= composer.top() + 1.0, "shared {shared}: the sign-in box {providers:?} ends above the message box {composer:?}");
assert!(
conversation.bottom() <= composer.top() + 1.0,
"shared {shared}: the conversation {conversation:?} ends above the message box {composer:?}"
);
for _ in 0..10 {
if handle_at("agent_chat:auth_retry").is_some_and(|at| providers.contains(at)) {
break;
}
h.call("ui.scroll", json!({"x":providers.center().x,"y":providers.center().y,"delta":-300}));
h.frames(4);
}
let at = handle_at("agent_chat:auth_retry").expect("the I've signed in button is drawn");
assert!(
providers.contains(at),
"shared {shared}: scrolling the sign-in box brings I've signed in: connect again {at:?} into view {providers:?}"
);
}
}
#[test]
fn shared_assistant_wide_and_compact_present_real_regions_without_losing_the_composer_selection() {
use solidcraft_engine::panels::{Destination, InstanceId, LayoutAction, Mutation, SectionId, SectionLocation, Size, Zone};
+21 -2
View File
@@ -435,7 +435,7 @@ pub fn step(app: &mut SolveApp, ctx: &egui::Context) {
let (glide, click) = (job.glide, job.click);
ctx.request_repaint();
let Some(&(to, _)) = job.stops.front() else {
if job.ahead.as_ref().is_none_or(crate::control_job::Ahead::finished) {
if ahead_is_in(app) {
finish(app, ctx);
}
return;
@@ -456,7 +456,7 @@ pub fn step(app: &mut SolveApp, ctx: &egui::Context) {
job.from = to;
job.started = now;
job.clicked = false;
if job.stops.is_empty() && job.ahead.as_ref().is_none_or(crate::control_job::Ahead::finished) {
if job.stops.is_empty() && ahead_is_in(app) {
finish(app, ctx);
}
}
@@ -470,6 +470,25 @@ fn command_label(job: &Job) -> String {
}
}
/// The held request's evaluation ahead is in, for the design as it is now. One a person's edit
/// went past is computed again off the window thread (as for a [`crate::control_job::ControlJob`]),
/// so the run for real finds everything computed.
fn ahead_is_in(app: &mut SolveApp) -> bool {
let Some(job) = app.agent.job.as_ref() else { return false };
let Some(ahead) = job.ahead.as_ref() else { return true };
if !ahead.finished() {
return false;
}
if !ahead.stale(app) {
return true;
}
let Some(again) = ahead.restart(app, &job.params, &job.ticket) else { return true };
if let Some(job) = app.agent.job.as_mut() {
job.ahead = Some(again);
}
false
}
/// Run the held request now (as a call without animation) and send its reply.
fn finish(app: &mut SolveApp, ctx: &egui::Context) {
let Some(job) = app.agent.job.take() else { return };
@@ -633,6 +633,7 @@ const NO_MEANING: &[(&str, &str)] = &[
("origin_point", "the origin, alone"),
("construction_plane", "a plane"),
("body_edge", "the model's edges"),
("isoparm", "a surface's flow lines: a lighter shade of its edges, told apart by width"),
("sketch_halo", "neutral rim beneath sketch curves; the curves carry the state colours"),
("grid", "the grid"),
("grid_major", "the grid"),
+83 -5
View File
@@ -245,13 +245,21 @@ pub(crate) struct Steps {
now: AtomicUsize,
labels: Vec<String>,
total: usize,
/// The evaluation's progress inside the step it is on, and its Cancel: Stop stops it at its
/// next feature rather than leaving it to compute unseen.
pub(crate) progress: Arc<solidcraft_engine::doc::progress::Progress>,
}
impl Steps {
fn new(app: &SolveApp, request: &Value, commands: &[(String, Value)]) -> Arc<Steps> {
// A batch counts all its steps, also those after the ones computed ahead.
let total = request.get("commands").and_then(Value::as_array).map_or(commands.len(), Vec::len).max(commands.len());
Arc::new(Steps { now: AtomicUsize::new(0), labels: commands.iter().map(|(c, _)| label(app, c)).collect(), total })
Arc::new(Steps {
now: AtomicUsize::new(0),
labels: commands.iter().map(|(c, _)| label(app, c)).collect(),
total,
progress: Default::default(),
})
}
/// What the chip says: the command running, and in a batch which step it is
@@ -333,6 +341,11 @@ pub struct Ahead {
pub(crate) label: String,
/// How far it has got.
pub(crate) steps: Arc<Steps>,
/// What it computes, and for which design: `doc` and `revision`, as in [`Work::Ahead`].
commands: Vec<(String, Value)>,
doc: Arc<solidcraft_engine::doc::Document>,
revision: u64,
tries: u8,
}
impl Ahead {
@@ -344,10 +357,26 @@ impl Ahead {
if !app.preferences.agent_off_thread || app.file_load_holds() || app.file_picker.is_some() {
return None;
}
let commands = ahead_commands(method, request);
Self::start(app, request, ticket, ahead_commands(method, request), 0)
}
fn start(app: &SolveApp, request: &Value, ticket: &Ticket, commands: Vec<(String, Value)>, tries: u8) -> Option<Ahead> {
let label = label(app, &commands.first()?.0);
let (done, kept, steps) = evaluate_ahead(app, request, ticket, commands)?;
Some(Ahead { done, kept, started: crate::now_ms(), label, steps })
let (done, kept, steps) = evaluate_ahead(app, request, ticket, commands.clone())?;
let (doc, revision) = (app.session.doc.clone(), app.session.revision);
Some(Ahead { done, kept, started: crate::now_ms(), label, steps, commands, doc, revision, tries })
}
/// A person's edit went first (as for [`Work::Ahead`]): this was computed for another design.
pub(crate) fn stale(&self, app: &SolveApp) -> bool {
(!Arc::ptr_eq(&app.session.doc, &self.doc) || app.session.revision != self.revision) && self.tries < MAX_TRIES
}
/// Computed again for the design as it is now, off the window thread.
pub(crate) fn restart(&self, app: &SolveApp, request: &Value, ticket: &Ticket) -> Option<Ahead> {
let again = Self::start(app, request, ticket, self.commands.clone(), self.tries + 1)?;
ticket.computing(false);
Some(Ahead { started: self.started, ..again })
}
/// The evaluation is in (or its worker ended).
@@ -426,6 +455,11 @@ impl SolveApp {
/// and nothing of it is applied (its worker finishes on its own, unseen). Requests waiting
/// behind it go on.
pub fn stop_agent_command(&mut self) {
// Its evaluation stops at its next feature: nothing of it is wanted any more.
if let Some(steps) = self.agent_steps() {
steps.progress.cancel();
}
crate::operations::ended(self, "agent", crate::operations::Outcome::Cancelled);
let stopped = match self.control_job.take() {
Some(job) => {
job.req.ticket.stop();
@@ -549,6 +583,9 @@ impl SolveApp {
/// Each frame: exports that are done reply; a design change whose evaluation is in runs.
/// Those their client cancelled are answered as stopped.
pub(crate) fn poll_control_jobs(&mut self, ctx: &egui::Context) {
if !self.agent_replies.is_empty() {
self.forward_agent_replies();
}
if !self.control_answering.is_empty() {
let mut i = 0;
while i < self.control_answering.len() {
@@ -614,6 +651,47 @@ impl SolveApp {
}
}
self.big_operation |= crate::now_ms() - job.started >= crate::recovery_ui::BIG_OPERATION_MS;
self.run_with_kept(Some(kept), |app| app.run_control(ctx, job.req));
// Its reply goes to the agent as ever, seen on the way for the operations panel.
let mut req = job.req;
let (tap, tapped) = std::sync::mpsc::channel();
let reply_to = std::mem::replace(&mut req.reply, tap);
self.run_with_kept(Some(kept), |app| app.run_control(ctx, req));
self.agent_replies.push((tapped, reply_to));
self.forward_agent_replies();
}
/// Pass the replies of agents' commands that computed off the window thread on to the agent,
/// noting each command's outcome (a reply held for later goes on when it comes).
fn forward_agent_replies(&mut self) {
let mut i = 0;
while i < self.agent_replies.len() {
let Some((tapped, _)) = self.agent_replies.get(i) else { break };
match tapped.try_recv() {
Ok(reply) => {
let (_, reply_to) = self.agent_replies.remove(i);
// A batch stopped by a failing step answers `ok` with that step in `failed`.
let failed_step = reply["result"]["failed"].as_object().map(|step| {
let (command, error) = (step.get("command").and_then(Value::as_str), step.get("error").and_then(Value::as_str));
error.map_or_else(|| "a step failed".to_string(), |e| command.map_or_else(|| e.to_string(), |c| crate::friendly_error(c, e)))
});
let outcome = if let Some(why) = failed_step {
crate::operations::Outcome::Failed(why)
} else if reply["ok"] == true {
crate::operations::Outcome::Done
} else if reply["stopped"] == true {
crate::operations::Outcome::Cancelled
} else {
crate::operations::Outcome::Failed(reply["error"].as_str().unwrap_or("failed").to_string())
};
crate::operations::ended(self, "agent", outcome);
let _ = reply_to.send(reply);
}
// Dropped unanswered: the agent's own receiver sees that too, as before.
Err(TryRecvError::Disconnected) => {
self.agent_replies.remove(i);
}
Err(TryRecvError::Empty) => i += 1,
}
}
}
}
+29 -4
View File
@@ -257,7 +257,16 @@ impl SolveApp {
self.deferred = Some(job);
Ok(Value::Null)
}
Start::Now(evaluated, kept) => self.run_evaluated(id, params, evaluated.as_ref(), kept),
Start::Now(evaluated, kept) => {
let r = self.run_evaluated(id, params, evaluated.as_ref(), kept);
// One that waited its turn (a row) and then ran at once ends here.
let outcome = match &r {
Ok(_) => crate::operations::Outcome::Done,
Err(e) => crate::operations::Outcome::Failed(crate::friendly_error(id, e)),
};
crate::operations::ended(self, &format!("history:{serial}"), outcome);
r
}
}
}
@@ -357,10 +366,17 @@ impl SolveApp {
Ok(replies) => Some(replies),
};
let Some(check) = self.delete_check.take() else { return };
use crate::operations::{Outcome, ended};
match replies {
None => self.set_status("the delete could not be checked", true),
None => {
ended(self, "delete_check", Outcome::Failed("the delete could not be checked".into()));
self.set_status("the delete could not be checked", true);
}
Some(_) if check.revision != self.session.revision => check.then.restart(self),
Some(replies) => check.then.finish(self, replies, Some(check.kept)),
Some(replies) => {
ended(self, "delete_check", Outcome::Done);
check.then.finish(self, replies, Some(check.kept));
}
}
}
@@ -374,14 +390,23 @@ impl SolveApp {
if let Some(mut job) = self.deferred.take() {
let (id, params) = std::mem::take(&mut job.command);
let superseded = replaced_by_a_later_one(&id) && self.deferred_queue.first().is_some_and(|(q, _, _)| *q == id);
let key = format!("history:{}", job.serial);
if job.revision != self.session.revision {
// The design changed meanwhile: computed again for the design as it is now.
let _ = self.run_heavy_as(&id, params, job.serial);
} else if !superseded {
// This is playback's own completed step, not a manual history edit.
let playback = self.timeline_playback.take();
let _ = self.run_evaluated(&id, params, job.evaluated.take().flatten().as_ref(), job.kept.take());
let r = self.run_evaluated(&id, params, job.evaluated.take().flatten().as_ref(), job.kept.take());
self.timeline_playback = playback;
let outcome = match r {
Ok(_) => crate::operations::Outcome::Done,
Err(e) => crate::operations::Outcome::Failed(crate::friendly_error(&id, &e)),
};
crate::operations::ended(self, &key, outcome);
} else {
// A later one of the same kind does it (where the marker ends is all that counts).
crate::operations::ended(self, &key, crate::operations::Outcome::Done);
}
}
}
+17 -6
View File
@@ -4336,6 +4336,14 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
Err(error) => d.refuse(app, error),
}
}
// Not computing again: the computation ended, applied or refused (saying why).
if app.applying.is_none() {
let outcome = match &d.error {
Some(why) if !applied => crate::operations::Outcome::Failed(why.clone()),
_ => crate::operations::Outcome::Done,
};
crate::operations::ended(app, "apply", outcome);
}
}
if applied && apply_again {
for input in &mut d.inputs {
@@ -4350,9 +4358,9 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
if applied && d.editing.is_none() {
remember(app, &d);
}
// Cancel while computing: the result goes (its worker runs on to its end).
if cancel && !applied {
app.applying = None;
// Cancel while computing: the result goes (its worker stops at its next feature).
if cancel && !applied && app.applying.take().is_some() {
crate::operations::ended(app, "apply", crate::operations::Outcome::Cancelled);
}
if !keep || cancel {
app.session.feature_edit_sketch = None;
@@ -4634,7 +4642,9 @@ pub fn cancel(app: &mut SolveApp) {
if let Some(pending) = &mut app.pending_feature_edit {
pending.cancel();
}
app.applying = None;
if app.applying.take().is_some() {
crate::operations::ended(app, "apply", crate::operations::Outcome::Cancelled);
}
if let Some(mut d) = app.dialog.take() {
crate::dialogs_motion::closed(app, &mut d, false);
if let Kind::Text(k) = &mut d.kind {
@@ -7442,7 +7452,7 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
}
d
}
FeatureKind::Box { corner, length, width, height, operation } => {
FeatureKind::Box { corner, length, width, height, operation, centred } => {
let mut d = start("solid.box")?;
d.kind = Kind::Primitive {
cmd: "solid.box",
@@ -7451,7 +7461,8 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
at: None,
refs: Default::default(),
};
d.extra.insert("corner".into(), pt_expr(corner));
// A box placed by its centre is edited by that centre, so it stays centred (#14487).
d.extra.insert(if *centred { "center" } else { "corner" }.into(), pt_expr(corner));
d
}
FeatureKind::Cylinder { base, axis, radius, height, operation, references } => {
+5
View File
@@ -59,6 +59,11 @@ pub struct SaveMesh {
}
impl SaveMesh {
/// Send to Slicer (or Open with EasyPrint), not Save As Mesh.
pub(crate) fn sends(&self) -> bool {
self.open
}
/// The chosen format, as `file.save_mesh` names it (`stl`, `3mf`, `3mf-tagged`).
#[cfg(test)]
pub(crate) fn format_name(&self) -> &'static str {
+12
View File
@@ -335,6 +335,12 @@ impl Documents {
self.active_tab
}
/// Whether the tab numbered `tab` is still open: the one shown, or one behind it. A file
/// operation bound to a tab that is gone has nothing left to land in.
pub(crate) fn tab_open(&self, tab: u64) -> bool {
self.active_tab == tab || self.slots.iter().flatten().any(|slot| slot.tab == tab)
}
/// Number of open designs (at least the active one).
pub fn count(&self) -> usize {
self.slots.len().max(1)
@@ -456,6 +462,7 @@ fn swap_in(app: &mut SolveApp, mut slot: DocSlot) -> DocSlot {
// So does the host's pre-command check: every tab is held to it.
app.session.set_command_check(out.session.command_check());
app.session.set_single_surface_analysis(app.preferences.single_surface_analysis);
crate::print_face_ui::sync(&mut app.session, &app.preferences);
// Views cache by revision: the incoming design must not look unchanged.
let before = app.session.revision;
app.session.invalidate_view(out_rev);
@@ -731,6 +738,11 @@ pub fn import(app: &mut SolveApp, p: &Value) -> Result<Value, String> {
if let Some(name) = p.get("name").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()) {
doc.name = name.to_string();
}
// Like a design opened from a file, it comes in without its print faces unless they are kept
// (#11512); dropped before the session starts, so it still opens saved with nothing to undo.
if !app.preferences.keep_print_faces {
doc.print_faces.clear();
}
let replaced = app.docs.active;
open_tab(app, Session::opened(doc, None))?;
close(app, replaced, true);
+7 -1
View File
@@ -146,22 +146,28 @@ impl SolveApp {
ctx.request_repaint_after(std::time::Duration::from_millis(16));
return;
};
use crate::operations::{Outcome, ended};
match result {
Ok((original, rolled)) => {
// A stale result is discarded, never restarted against a newer design.
// Adoption preserves the live history stacks and current selection.
let restore = if pending.feature.is_some() { rolled } else { original.scratch() };
if let Err(error) = self.session.restore_editor(&pending.source, restore) {
ended(self, "feature_edit", Outcome::Failed(error.to_string()));
self.set_status(error.to_string(), true);
return;
}
ended(self, "feature_edit", if pending.opens() { Outcome::Done } else { Outcome::Cancelled });
if let Some(feature) = pending.feature {
self.open_feature_edit(feature, pending.marker, original);
} else if let Some(command) = pending.next_command {
self.start(&command);
}
}
Err(error) => self.set_status(error, true),
Err(error) => {
ended(self, "feature_edit", Outcome::Failed(error.clone()));
self.set_status(error, true);
}
}
}
}
+166 -18
View File
@@ -39,6 +39,7 @@ use solidcraft_engine::{Session, doc::Document, doc::progress::Progress};
use crate::SolveApp;
use crate::control::ControlResponse;
use crate::operations::Outcome;
use crate::ui_kit;
/// What a file operation does with its file.
@@ -278,6 +279,10 @@ impl Running {
pub struct FileLoad {
queued: VecDeque<Request>,
running: Option<Running>,
/// Results that are in and wait to land: their design's tab is not shown, or the person is in
/// the middle of something. They do not hold the worker, so the next request starts while
/// they wait, and they land as soon as they can.
waiting: Vec<Running>,
/// The result of an insert that runs again for the design as it is now, kept until it is in:
/// the file it read stays cached ([`solidcraft_engine::kernel::step_import_shared`]), so the
/// second run doesn't read it again.
@@ -308,14 +313,19 @@ impl FileLoad {
}
/// No active or queued request; cancelled import workers may still be unwinding.
pub fn idle(&self) -> bool {
self.running.is_none() && self.queued.is_empty()
self.running.is_none() && self.waiting.is_empty() && self.queued.is_empty()
}
/// Signal cancellation, release the import slot and drop queued requests.
pub fn cancel(&mut self) {
self.cancel_running();
for q in self.queued.drain(..) {
for w in std::mem::take(&mut self.waiting) {
w.request.answer(Err("file operation cancelled".into()));
}
for q in std::mem::take(&mut self.queued) {
q.answer(Err("file operation cancelled".into()));
}
// Nothing is left to run again for.
self.warm = None;
}
/// Signal the running request's cancellation and release its slot; queued requests go on.
pub(crate) fn cancel_running(&mut self) {
@@ -337,9 +347,16 @@ impl FileLoad {
/// Drop the `index`th queued request (from 0), answering it as cancelled.
pub(crate) fn cancel_queued(&mut self, index: usize) {
if let Some(q) = self.queued.remove(index) {
self.drop_warm(q.serial);
q.answer(Err("file operation cancelled".into()));
}
}
/// Forget a retry's cached session once its request is gone (cancelled, or its tab closed).
fn drop_warm(&mut self, serial: u64) {
if self.warm.as_ref().is_some_and(|(warm, _)| *warm == serial) {
self.warm = None;
}
}
/// The operations panel's view of the requests: the running one (if any), then the queued
/// ones in the order they will run. Each is its command, its file's name, how far it is
/// (`done` of `total`, when that is known) and whether it was cancelled.
@@ -363,7 +380,14 @@ impl FileLoad {
waiting: r.ready.as_ref().is_some_and(Result::is_ok),
..entry(&r.request)
});
running.into_iter().chain(self.queued.iter().map(entry)).collect()
let waiting = self.waiting.iter().map(|r| FileOperation {
steps: steps(r),
queued: false,
cancelled: r.cancelled(),
waiting: true,
..entry(&r.request)
});
running.into_iter().chain(waiting).chain(self.queued.iter().map(entry)).collect()
}
}
@@ -653,6 +677,19 @@ fn start(app: &mut SolveApp) {
if app.file_load.running.is_some() {
return;
}
// A request for a tab that was closed has nowhere to land: answer it so it does not wait for
// a tab number that will never match again and hold the queue.
let mut at = 0;
while at < app.file_load.queued.len() {
let orphan = app.file_load.queued[at].tab.is_some_and(|tab| !app.docs.tab_open(tab));
if orphan {
if let Some(request) = app.file_load.queued.remove(at) {
orphaned_request(app, request);
}
} else {
at += 1;
}
}
// The first that can start: one for a design in a tab not shown waits for it.
let shown = app.docs.active_tab();
let Some(next) = app.file_load.queued.iter().position(|r| r.tab.is_none_or(|tab| tab == shown)) else { return };
@@ -665,6 +702,7 @@ fn start(app: &mut SolveApp) {
return;
}
if request.design.as_deref().is_some_and(|d| !app.session.path.as_deref().is_some_and(|p| crate::documents::same_path(p, d))) {
crate::operations::ended(app, &format!("file:{}", request.serial), Outcome::Failed("the design it was for is no longer shown".into()));
request.answer(Err("the design it was for is no longer shown".into()));
return;
}
@@ -673,6 +711,7 @@ fn start(app: &mut SolveApp) {
&& !crate::documents::active_is_blank(app)
{
let e = "too many open designs; close a tab before opening another";
crate::operations::ended(app, &format!("file:{}", request.serial), Outcome::Failed(e.into()));
app.set_status(e, true);
if let Kind::Recover { id, dir } = &request.kind {
reoffer(app, id, dir.as_deref());
@@ -688,6 +727,7 @@ fn start(app: &mut SolveApp) {
// evaluated under the user's validation mode.
scratch.set_command_check(app.session.command_check());
scratch.set_validation(app.preferences.validation);
crate::print_face_ui::sync(&mut scratch, &app.preferences);
if insert {
scratch.selection = app.session.selection.clone();
}
@@ -800,6 +840,7 @@ fn start(app: &mut SolveApp) {
}
Err(e) => {
let e = format!("could not start file operation: {e}");
crate::operations::ended(app, &format!("file:{}", request.serial), Outcome::Failed(e.clone()));
request.answer(Err(e.clone()));
app.set_status(e, true);
}
@@ -821,6 +862,7 @@ fn start_staged(app: &mut SolveApp) {
let mut session = if insert { app.session.scratch() } else { Session::default() };
session.set_command_check(app.session.command_check());
session.set_validation(app.preferences.validation);
crate::print_face_ui::sync(&mut session, &app.preferences);
if insert {
session.selection = app.session.selection.clone();
}
@@ -1257,12 +1299,57 @@ pub fn tick(app: &mut SolveApp, ctx: &egui::Context) {
Err(TryRecvError::Disconnected) => Some(Err("the file operation stopped; the current design is unchanged".into())),
};
}
let land = app.file_load.running.as_ref().and_then(|r| r.ready.as_ref().map(|job| landing(app, r, job)));
match land {
None | Some(Land::Wait) => {}
Some(Land::Again) => again(app),
Some(Land::Behind) => behind(app),
Some(Land::Now) => land_now(app),
// A ready result that cannot land yet (its design's tab is not shown, or the person is busy)
// leaves `running` for `waiting`: it stops holding the worker, so the next request starts
// while it waits, and it lands the moment it can.
if let Some(r) = app.file_load.running.as_ref()
&& r.ready.is_some()
{
let land = landing(app, r);
match land {
Land::Wait => {
if let Some(running) = app.file_load.running.take() {
app.file_load.waiting.push(running);
}
}
Land::Now => {
if let Some(running) = app.file_load.running.take() {
land_now(app, running);
}
}
Land::Again => {
if let Some(running) = app.file_load.running.take() {
again(app, running);
}
}
Land::Behind => {
if let Some(running) = app.file_load.running.take() {
behind(app, running);
}
}
Land::Gone => {
if let Some(running) = app.file_load.running.take() {
gone(app, running);
}
}
}
}
// Land each waiting result that can go now, oldest first.
let mut at = 0;
while at < app.file_load.waiting.len() {
let land = landing(app, &app.file_load.waiting[at]);
if land == Land::Wait {
at += 1;
continue;
}
let running = app.file_load.waiting.remove(at);
match land {
Land::Now => land_now(app, running),
Land::Again => again(app, running),
Land::Behind => behind(app, running),
Land::Gone => gone(app, running),
Land::Wait => {}
}
}
#[cfg(not(target_arch = "wasm32"))]
start(app);
@@ -1284,6 +1371,8 @@ enum Land {
Again,
/// A design opened while the person is busy: in a new tab behind the one shown.
Behind,
/// Its design's tab was closed (or replaced): nothing is left to land in.
Gone,
}
/// How many times an insert runs again for a design that keeps changing before it gives up.
@@ -1308,7 +1397,8 @@ fn quiet(app: &SolveApp, r: &Running) -> bool {
&& app.docs.batch.is_none()
}
fn landing(app: &SolveApp, r: &Running, job: &Job) -> Land {
fn landing(app: &SolveApp, r: &Running) -> Land {
let Some(job) = r.ready.as_ref() else { return Land::Wait };
// As it always was: the design waited for it.
if app.preferences.file_operations_wait || r.cancelled() || job.is_err() || r.request.kind == Kind::ExportDxf {
return Land::Now;
@@ -1318,6 +1408,10 @@ fn landing(app: &SolveApp, r: &Running, job: &Job) -> Land {
if app.docs.closing.is_some() || app.docs.quitting.is_some() || app.docs.batch.is_some() {
return Land::Wait;
}
// Its design's tab was closed (or replaced): there is nothing left to land in.
if r.request.tab.is_some_and(|tab| !app.docs.tab_open(tab)) {
return Land::Gone;
}
match r.request.kind {
Kind::Open | Kind::Recover { .. } if quiet(app, r) => Land::Now,
Kind::Open | Kind::Recover { .. } => Land::Behind,
@@ -1329,8 +1423,7 @@ fn landing(app: &SolveApp, r: &Running, job: &Job) -> Land {
/// Run an insert (or Update Links) again for the design as it is now, first in line; its first
/// result is kept meanwhile, so the file it read is still cached.
fn again(app: &mut SolveApp) {
let Some(mut running) = app.file_load.running.take() else { return };
fn again(app: &mut SolveApp, mut running: Running) {
let mut request = std::mem::replace(&mut running.request, Request::new("", Kind::Open));
request.tries += 1;
if let Some(Ok(made)) = running.ready.take() {
@@ -1340,12 +1433,13 @@ fn again(app: &mut SolveApp) {
}
/// A design opened while the person is busy goes in a new tab behind the one shown.
fn behind(app: &mut SolveApp) {
let Some(mut running) = app.file_load.running.take() else { return };
fn behind(app: &mut SolveApp, mut running: Running) {
let Some(Ok(made)) = running.ready.take() else { return };
let request = &running.request;
let key = format!("file:{}", request.serial);
match crate::documents::open_tab_behind(app, made.session) {
Ok(()) => {
crate::operations::ended(app, &key, Outcome::Done);
if request.kind == Kind::Open {
app.home.add_recent(&request.path);
}
@@ -1354,6 +1448,7 @@ fn behind(app: &mut SolveApp) {
request.answer(Ok(made.result));
}
Err(e) => {
crate::operations::ended(app, &key, Outcome::Failed(e.clone()));
app.set_status(e.clone(), true);
request.answer(Err(e));
}
@@ -1367,15 +1462,45 @@ fn behind(app: &mut SolveApp) {
}
}
/// Answer a result whose design's tab was closed: nothing is left to land in.
fn gone(app: &mut SolveApp, running: Running) {
app.file_load.drop_warm(running.request.serial);
let e = "the design it was for was closed";
crate::operations::ended(app, &format!("file:{}", running.request.serial), Outcome::Failed(e.into()));
app.set_status(e, false);
running.request.answer(Err(e.into()));
}
/// Answer a queued request whose design's tab was closed: it would wait for a number that never
/// matches again, holding the queue.
fn orphaned_request(app: &mut SolveApp, request: Request) {
app.file_load.drop_warm(request.serial);
let e = "the design it was for was closed";
crate::operations::ended(app, &format!("file:{}", request.serial), Outcome::Failed(e.into()));
app.set_status(e, false);
request.answer(Err(e.into()));
}
/// Cancel a result that is in and waits to land (its row's X in the operations panel).
pub(crate) fn cancel_waiting(app: &mut SolveApp, serial: u64) {
let Some(at) = app.file_load.waiting.iter().position(|r| r.request.serial == serial) else { return };
let running = app.file_load.waiting.remove(at);
app.file_load.drop_warm(serial);
crate::operations::ended(app, &format!("file:{serial}"), Outcome::Cancelled);
app.set_status("file operation cancelled", false);
running.request.answer(Err("file operation cancelled".into()));
}
/// Take up a finished file operation now.
fn land_now(app: &mut SolveApp) {
let Some(mut running) = app.file_load.running.take() else { return };
fn land_now(app: &mut SolveApp, mut running: Running) {
let Some(job) = running.ready.take() else { return };
if app.file_load.warm.as_ref().is_some_and(|(serial, _)| *serial == running.request.serial) {
app.file_load.warm = None;
}
// Opened while the person kept working: it lands, whatever they changed in the design shown.
let opens = !app.preferences.file_operations_wait && matches!(running.request.kind, Kind::Open | Kind::Recover { .. });
let key = format!("file:{}", running.request.serial);
let mut outcome = Outcome::Done;
{
let request = &running.request;
match job {
@@ -1385,7 +1510,10 @@ fn land_now(app: &mut SolveApp) {
}
request.answer(Ok(made.result));
}
_ if running.cancelled() => request.answer(Err("file operation cancelled".into())),
_ if running.cancelled() => {
outcome = Outcome::Cancelled;
request.answer(Err("file operation cancelled".into()));
}
Ok(made) if request.reads_again() && running.matches(&app.session) => {
let (id, _) = request.command();
app.session.accept_computed(made.session);
@@ -1500,10 +1628,13 @@ fn land_now(app: &mut SolveApp) {
}
Ok(_) => {
let e = "the active design changed while loading; the file operation was discarded";
outcome = Outcome::Failed(e.into());
request.answer(Err(e.into()));
app.set_status(e, true);
}
Err(e) => {
// A real cancel took the `running.cancelled()` arm above; this is a failure.
outcome = Outcome::Failed(e.clone());
request.answer(Err(e.clone()));
if request.reads_again() {
// Said as when the command ran here (the status line, the run's messages).
@@ -1524,6 +1655,7 @@ fn land_now(app: &mut SolveApp) {
solidcraft_engine::recovery::reopen_finished(dir, &request.path);
}
}
crate::operations::ended(app, &key, outcome);
}
/// The light overlay over everything under the title bar while a file loads. The title bar,
@@ -1583,7 +1715,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
if !app.file_load.card_shown {
return;
}
let Some(r) = &app.file_load.running else { return };
let Some(r) = app.file_load.running.as_ref().or_else(|| app.file_load.waiting.first()) else { return };
let cancelled = r.cancelled();
if cancelled && app.file_load.queued.is_empty() {
return;
@@ -1730,6 +1862,9 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
if cancel && !cancelled {
// The worker stops at its next cancellation checkpoint; its scratch design and
// result channels are isolated from any file operation started afterwards.
for op in app.file_load.operations() {
crate::operations::ended(app, &format!("file:{}", op.serial), Outcome::Cancelled);
}
app.file_load.cancel();
app.set_status(if export { solidcraft_ui_text::tr_label("dxf.cancelled") } else { "file operation cancelled" }, false);
}
@@ -1739,6 +1874,19 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
mod tests {
use super::*;
/// Cancelling the queued retry forgets the session it read the file into: nothing runs again
/// for a request that is gone.
#[test]
fn cancelling_the_queued_retry_forgets_its_cached_session() {
let mut load = FileLoad::default();
let mut request = Request::new("part.step", Kind::Insert);
request.serial = 7;
load.queued.push_back(request);
load.warm = Some((7, Box::new(Session::default())));
load.cancel_queued(0);
assert!(load.warm.is_none(), "the cached session goes with its cancelled request");
}
/// A STEP file goes through its three steps in order as the kernel reports them, and stays
/// on the last once the parts are done; a design's steps follow its features.
#[test]
+33
View File
@@ -1204,6 +1204,39 @@ fn an_insert_runs_again_for_a_design_edited_meanwhile_and_lands_after_the_edit()
let _ = std::fs::remove_dir_all(&dir);
}
/// A result waiting to land does not hold the file worker: another open runs meanwhile. Closing
/// the tab the waiting design belongs to answers its request instead of leaving it waiting.
#[test]
fn a_waiting_result_does_not_hold_the_queue_and_a_closed_tab_releases_it() {
let dir = scratch("waiting-queue");
let step = step_box(&dir);
let other = large_design(&dir, 2);
let mut w = Win::new();
w.app.home.open = false;
w.app.run("solid.box", json!({"length": 5, "width": 5, "height": 5})).unwrap();
// An insert for the design in tab 0, with another tab shown when its result comes in.
let (started, release) = hold_next(&mut w);
crate::file_load::begin(&mut w.app, &step, true);
started.recv_timeout(std::time::Duration::from_secs(60)).unwrap();
crate::documents::open_tab(&mut w.app, Session::default()).unwrap();
release.send(()).unwrap();
frames_until(&mut w, "the insert is ready", |app| crate::operations::current(app).iter().any(|r| r.waiting));
// An open runs while that result waits: the waiting one is out of the worker's way.
let (started, release) = hold_next(&mut w);
crate::file_load::begin(&mut w.app, &other, false);
started.recv_timeout(std::time::Duration::from_secs(60)).unwrap();
release.send(()).unwrap();
frames_until(&mut w, "the open runs while the insert waits", |app| {
app.session.path.as_deref().is_some_and(|p| crate::documents::same_path(p, &other))
&& crate::operations::current(app).iter().any(|r| r.waiting)
});
// Closing the waiting design's tab answers its request: nothing waits for it any more.
crate::documents::close(&mut w.app, 0, true);
frames_until(&mut w, "the waiting result is answered", |app| !crate::operations::current(app).iter().any(|r| r.waiting) && app.file_load.idle());
assert_eq!(w.app.docs.count(), 1, "the closed tab released its request");
let _ = std::fs::remove_dir_all(&dir);
}
/// An insert for a design whose tab is no longer shown waits, ready, for it: the design shown
/// gets nothing; showing the tab again lands it there.
#[test]
@@ -1313,9 +1313,66 @@ fn a_computing_batch_is_a_row_of_the_operations_panel_that_stops_it() {
assert_eq!(row.steps.map(|(_, total)| total), Some(3), "{row:?}");
assert!(row.cancellable());
assert!(crate::scenario::rect_of("agent:chip").is_none(), "no chip besides the row");
let steps = h.app.agent_steps().expect("its steps");
crate::operations::cancel(&mut h.app, "agent").unwrap();
assert!(steps.progress.is_cancelled(), "its computation stops, not left to compute unseen");
let reply = reply_to(&mut h, &batch);
assert_eq!((reply["ok"].clone(), reply["stopped"].clone()), (json!(false), json!(true)), "{reply}");
h.frames(2);
assert_eq!(h.app.session.doc.features.len(), features, "nothing applied");
let finished = crate::operations::control(&mut h.app, &json!({})).unwrap()["finished"].clone();
assert_eq!(finished[0]["key"], "agent");
assert_eq!(finished[0]["outcome"], "cancelled", "{finished}");
// One that computes to its end is listed as done, its reply passed on as ever.
let single = ask(&tx, "engine.execute", second_hole());
h.frames(1);
assert_eq!(reply_to(&mut h, &single)["ok"], true);
h.frames(1);
let finished = crate::operations::control(&mut h.app, &json!({})).unwrap()["finished"].clone();
if h.app.operations.rows(crate::now_ms()).any(|(r, _)| r.key == "agent") || finished[0]["key"] == "agent" {
assert_eq!(finished[0]["outcome"], "done", "{finished}");
}
}
/// A batch stopped by a failing step answers `ok` with the step in `failed`: the operation is
/// listed as failed, saying why, not as done.
#[test]
fn a_batch_stopped_by_a_failing_step_ends_failed() {
let (mut h, tx) = agent_harness();
let hole = |x: f64| json!({"command": "solid.cylinder", "params": {"base": [x, 10, -1], "axis": [0, 0, 1], "radius": 3, "height": 20, "operation": "cut"}});
let bad = json!({"command": "solid.fillet", "params": {"edges": ["no-such-edge"], "radius": 2}});
let batch = ask(&tx, "engine.batch", json!({"commands": [hole(50.0), hole(30.0), bad]}));
h.frames(1);
assert!(h.app.agent_computing().is_some(), "the batch computes off the window thread");
let reply = reply_to(&mut h, &batch);
assert!(reply["result"]["failed"].is_object(), "{reply}");
h.frames(1);
let finished = crate::operations::control(&mut h.app, &json!({})).unwrap()["finished"].clone();
assert_eq!(finished[0]["key"], "agent", "{finished}");
assert_eq!(finished[0]["outcome"], "failed", "{finished}");
assert!(finished[0]["error"].as_str().is_some_and(|e| e.contains("Fillet") || e.contains("edge")), "{finished}");
}
/// With the agent cursor shown, a batch it acts out is computed off the window thread, also when
/// a person's edit goes first meanwhile: the run for real finds every feature computed and
/// computes none on the window thread.
#[test]
fn a_batch_the_cursor_acts_out_computes_nothing_on_the_window_thread() {
for edit in [false, true] {
let (mut h, tx) = agent_harness();
h.app.ui.agent_cursor = crate::agent_cursor::Settings { show: true, speed: crate::agent_cursor::Speed::Times(5.0), follow_camera: false };
let features = h.app.session.doc.features.len();
let hole = |x: f64| json!({"command": "solid.cylinder", "params": {"base": [x, 10, -1], "axis": [0, 0, 1], "radius": 2, "height": 20, "operation": "cut"}});
let batch = ask(&tx, "engine.batch", json!({"commands": [hole(50.0), hole(30.0)]}));
h.frames(1);
assert!(h.app.agent.busy(), "the pointer acts the batch out");
if edit {
h.app.run("solid.box", json!({"corner": [40, 5, 10], "length": 5, "width": 5, "height": 5, "operation": "join"})).unwrap();
}
let recomputed = h.app.session.model.recomputed;
let r = reply_to(&mut h, &batch);
assert_eq!(r["ok"], true, "{r}");
assert_eq!(h.app.session.doc.features.len(), features + 2 + usize::from(edit), "edit {edit}");
assert_eq!(h.app.session.model.recomputed, recomputed, "edit {edit}: nothing computed on the window thread");
}
}
+13 -1
View File
@@ -818,6 +818,9 @@ pub struct SolveApp {
control_held: std::collections::VecDeque<ControlRequest>,
/// An agent's design change computing off the window thread ([`control_job`]).
control_job: Option<control_job::ControlJob>,
/// Replies of agents' commands run after computing off the window thread, on their way to
/// the agent ([`control_job`]): each is passed on as it comes, its outcome noted.
agent_replies: Vec<(std::sync::mpsc::Receiver<serde_json::Value>, std::sync::mpsc::Sender<serde_json::Value>)>,
/// Agent exports computing on copies of the design.
control_answering: Vec<control_job::ControlJob>,
/// The agent cursor acting out control requests.
@@ -1022,6 +1025,7 @@ impl SolveApp {
ai_connect: Default::default(),
control_held: Default::default(),
control_job: None,
agent_replies: Vec::new(),
control_answering: Vec::new(),
agent: Default::default(),
shots: solidcraft_control_egui::screenshot::Shots::new("no frame was presented (window hidden?); use ui.render"),
@@ -1356,6 +1360,7 @@ impl SolveApp {
solidcraft_engine::slicers::set_chosen_programs(&self.preferences.custom_slicers);
}
self.session.set_validation(self.preferences.validation);
print_face_ui::sync(&mut self.session, &self.preferences);
if id == "timeline.play" {
let reply = self.session.execute(id, &params).map_err(|e| e.to_string())?;
let action = serde_json::from_value(reply["action"].clone()).map_err(|e| e.to_string())?;
@@ -1506,7 +1511,11 @@ impl SolveApp {
_ => {}
}
if id == "view.render_inspector" {
render_workspace::set_inspector_visible(self, v.get("visible").and_then(Value::as_bool).unwrap_or(true));
if v.get("visible").and_then(Value::as_bool).unwrap_or(true) {
render_workspace::reveal_inspector(self);
} else {
render_workspace::request_inspector_visibility(self, false);
}
}
if id == "view.render_live"
&& let Some(paused) = v.get("paused").and_then(Value::as_bool)
@@ -3708,6 +3717,9 @@ impl SolveApp {
});
}
if render_workspace::active(self) {
if panel_runtime::production_active(self) {
render_workspace::inspector_panel::owner_overlays(self, ui.ctx());
}
render_ui::overlays(self, ui);
}
// Refresh persistent chrome before the host's early confirmation pair.
+1
View File
@@ -219,6 +219,7 @@ pub(crate) fn tick(app: &mut SolveApp, ctx: &egui::Context) {
if let Some(m) = app.open_meshing.as_mut() {
m.complete = true;
}
crate::operations::ended(app, "meshing", crate::operations::Outcome::Done);
if profiling() {
let ms = began.map_or(0.0, |t| (ctx.input(|i| i.time) - t) * 1000.0);
solidcraft_engine::note!("SolidCraft open meshing: all {} bodies shown after {ms:.0} ms", world.bodies.len());
+237 -31
View File
@@ -11,7 +11,9 @@
//! learns it was cancelled, through [`solidcraft_engine::doc::progress::Progress`]: the timeline's
//! evaluation reports each feature it reaches, and long work inside one reports its steps through
//! the kernel's watch ([`solidcraft_engine::kernel::watch::Watch`]); each stops at its next check
//! once cancelled. Cancel from the control channel with `ui.operations` ([`control`]).
//! once cancelled. Each says how it ended ([`ended`]): its row fades out done (a full bar), failed
//! (red, saying why) or cancelled, and `ui.operations` ([`control`]) lists it for a minute; it
//! also cancels operations from the control channel.
use egui::{Align2, Color32, FontId, Rect, Sense, Stroke, pos2, vec2};
use serde_json::{Value, json};
@@ -49,8 +51,8 @@ pub struct Row {
pub waiting: bool,
/// Asked to stop, and finishing its current step.
pub cancelling: bool,
/// Gone because it was cancelled (fading out).
pub cancelled: bool,
/// How it ended, once it is gone (fading out); `None` while it runs, or when it didn't say.
pub ended: Option<Outcome>,
/// How to cancel it; `None`: it can't be (a save being written).
cancel: Option<Cancel>,
/// An agent's command: drawn in the agent cursor's colour.
@@ -61,6 +63,8 @@ pub struct Row {
enum Cancel {
/// The running file operation.
File,
/// A finished file operation whose result waits to land (its serial).
FileWaiting(u64),
/// The `n`th queued file operation (from 0).
FileQueued(usize),
/// The feature dialog's OK, computing.
@@ -79,7 +83,7 @@ enum Cancel {
impl Row {
fn new(key: String, label: String) -> Self {
Row { key, label, steps: None, queued: false, waiting: false, cancelling: false, cancelled: false, cancel: None, agent: false }
Row { key, label, steps: None, queued: false, waiting: false, cancelling: false, ended: None, cancel: None, agent: false }
}
/// Whether its X does anything.
@@ -89,14 +93,21 @@ impl Row {
/// The bar's fill (0..=1), or `None` while the work hasn't said how far it is.
pub fn fraction(&self) -> Option<f32> {
// One that ended done has nothing left to do, even when its work counted no steps: a full
// bar, not the empty one a row without steps fades with.
if self.ended == Some(Outcome::Done) {
return Some(1.0);
}
let (done, total) = self.steps.filter(|(_, total)| *total > 0)?;
Some((done.min(total) as f32 / total as f32).clamp(0.0, 1.0))
}
/// The text over the bar: the step it is on of how many (`3 / 12`), queued or cancelling.
pub fn status(&self) -> Option<String> {
if self.cancelled {
return Some(solidcraft_ui_text::tr("operations.stopped").to_string());
match self.ended {
Some(Outcome::Cancelled) => return Some(solidcraft_ui_text::tr("operations.stopped").to_string()),
Some(Outcome::Failed(_)) => return Some(solidcraft_ui_text::tr("operations.failed").to_string()),
Some(Outcome::Done) | None => {}
}
if self.cancelling {
return Some(solidcraft_ui_text::tr("operations.cancelling").to_string());
@@ -138,7 +149,9 @@ pub fn current(app: &SolveApp) -> Vec<Row> {
for file in app.file_load.operations() {
let key = format!("file:{}", file.serial);
let label = if file.name.is_empty() { command_name(file.command) } else { format!("{} {}", command_name(file.command), file.name) };
let cancel = if file.queued {
let cancel = if file.waiting {
Cancel::FileWaiting(file.serial)
} else if file.queued {
queued += 1;
Cancel::FileQueued(queued - 1)
} else {
@@ -187,7 +200,9 @@ pub fn current(app: &SolveApp) -> Vec<Row> {
if app.agent_computing().is_some() {
let steps = app.agent_steps();
let label = steps.as_ref().map(|s| s.current()).or_else(|| app.agent_computing().map(|(_, label)| label)).unwrap_or_default();
rows.push(Row { steps: steps.map(|s| s.counts()), cancel: Some(Cancel::Agent), agent: true, ..Row::new("agent".into(), label) });
// A batch counts its steps; one command, the features it evaluates.
let steps = steps.and_then(|s| if s.counts().1 > 1 { Some(s.counts()) } else { evaluation_steps(&s.progress) });
rows.push(Row { steps, cancel: Some(Cancel::Agent), agent: true, ..Row::new("agent".into(), label) });
}
rows
}
@@ -212,6 +227,7 @@ pub fn cancel(app: &mut SolveApp, key: &str) -> Result<(), String> {
}
match how {
Cancel::File => app.file_load.cancel_running(),
Cancel::FileWaiting(serial) => crate::file_load::cancel_waiting(app, serial),
Cancel::FileQueued(n) => app.file_load.cancel_queued(n),
// Dropping the job stops its worker; the dialog stays open with its inputs.
Cancel::Apply => app.applying = None,
@@ -236,11 +252,75 @@ pub fn cancel(app: &mut SolveApp, key: &str) -> Result<(), String> {
}
Cancel::Agent => app.stop_agent_command(),
}
app.operations.cancelled.push(row.key.clone());
record(&mut app.operations, &row.key, row.label.clone(), Outcome::Cancelled);
app.set_status(solidcraft_ui_text::format("operations.cancelled", &[("name", &row.label)]), false);
Ok(())
}
/// How an operation ended.
#[derive(Clone, Debug, PartialEq)]
pub enum Outcome {
/// It did what it was asked.
Done,
/// It failed, saying why (as the status line does).
Failed(String),
/// It was cancelled: nothing of it was applied.
Cancelled,
}
impl Outcome {
fn id(&self) -> &'static str {
match self {
Outcome::Done => "done",
Outcome::Failed(_) => "failed",
Outcome::Cancelled => "cancelled",
}
}
/// How long its row stays after it ended, fully shown and then fading (ms): a failure stays
/// long enough to be seen.
fn shown_for(outcome: Option<&Outcome>) -> (f64, f64) {
match outcome {
Some(Outcome::Failed(_)) => (FAILED_MS, FADE_MS),
_ => (0.0, FADE_MS),
}
}
}
/// How long a failed operation's row stays before it fades (ms).
pub const FAILED_MS: f64 = 2500.0;
/// How long `ui.operations` lists an operation that ended (ms), and how many it keeps.
const FINISHED_MS: f64 = 60_000.0;
const FINISHED_KEPT: usize = 16;
/// An operation that ended, as `ui.operations` lists it for a while.
#[derive(Clone, Debug)]
struct Finished {
key: String,
label: String,
outcome: Outcome,
at: f64,
}
/// The operation `key` ended with `outcome`: its row fades out saying so, and `ui.operations`
/// lists it for a minute. The work itself calls this where it ends; an operation that ends
/// without it fades out without saying how. One never seen under way (done within the app's
/// wait, between two frames) had no row, and is not listed either.
pub(crate) fn ended(app: &mut SolveApp, key: &str, outcome: Outcome) {
let Some(label) = app.operations.labels.iter().rev().find(|(k, _)| k == key).map(|(_, label)| label.clone()) else { return };
record(&mut app.operations, key, label, outcome);
}
fn record(panel: &mut Panel, key: &str, label: String, outcome: Outcome) {
let now = crate::now_ms();
panel.finished.retain(|f| now - f.at < FINISHED_MS && f.key != key);
if panel.finished.len() >= FINISHED_KEPT {
panel.finished.remove(0);
}
panel.finished.push(Finished { key: key.to_string(), label, outcome, at: now });
}
/// A row on screen: what it showed last, and since when it is gone (fading out).
#[derive(Clone, Debug)]
struct Shown {
@@ -255,8 +335,10 @@ pub struct Panel {
shown: Vec<Shown>,
/// Where the panel was drawn this frame, for the toasts to keep clear of.
pub(crate) rect: Option<Rect>,
/// Operations cancelled, which fade out as cancelled rather than as done.
cancelled: Vec<String>,
/// The operations that ended lately and how ([`ended`]), oldest first.
finished: Vec<Finished>,
/// The names of the operations seen under way, for those that end ([`ended`]).
labels: Vec<(String, String)>,
/// Whether [`show`] had room to show the panel's rows. The card over the design falls back to
/// a file operation when it has not; it reads the last frame's value, because it decides
/// before the panel is laid out. True until the first frame, which assumes the room is there.
@@ -265,7 +347,7 @@ pub struct Panel {
impl Default for Panel {
fn default() -> Self {
Panel { shown: Vec::new(), rect: None, cancelled: Vec::new(), panel_drew: true }
Panel { shown: Vec::new(), rect: None, finished: Vec::new(), labels: Vec::new(), panel_drew: true }
}
}
@@ -279,23 +361,36 @@ impl Panel {
Some(row) => (shown.row, shown.gone_at) = (row.clone(), None),
None if shown.gone_at.is_none() => {
shown.gone_at = Some(now);
// Nothing left to cancel. It finished: a full bar while it fades; it was
// cancelled: the bar where it stopped, saying so.
if self.cancelled.contains(&shown.row.key) || shown.row.cancelling {
shown.row.cancelled = true;
} else {
// Nothing left to cancel. Done: a full bar while it fades. Failed or
// cancelled: the bar where it stopped, saying so. Not said: as it was.
let outcome = self.finished.iter().rev().find(|f| f.key == shown.row.key).map(|f| f.outcome.clone());
let outcome = outcome.or_else(|| shown.row.cancelling.then_some(Outcome::Cancelled));
if outcome == Some(Outcome::Done) {
shown.row.steps = shown.row.steps.map(|(_, total)| (total, total));
}
shown.row.ended = outcome;
shown.row.cancel = None;
shown.row.cancelling = false;
shown.row.queued = false;
shown.row.waiting = false;
}
None => {}
}
}
self.shown.retain(|s| s.gone_at.is_none_or(|gone| now - gone < FADE_MS));
self.shown.retain(|s| {
let (hold, fade) = Outcome::shown_for(s.row.ended.as_ref());
s.gone_at.is_none_or(|gone| now - gone < hold + fade)
});
for row in &rows {
self.labels.retain(|(key, _)| *key != row.key);
self.labels.push((row.key.clone(), row.label.clone()));
}
// Names are kept for the operations still on screen or under way.
let shown = &self.shown;
self.cancelled.retain(|key| shown.iter().any(|s| s.row.key == *key));
self.labels.retain(|(key, _)| shown.iter().any(|s| s.row.key == *key) || rows.iter().any(|r| r.key == *key));
if self.labels.len() > 64 {
self.labels.drain(..self.labels.len() - 64);
}
for row in rows {
if !self.shown.iter().any(|s| s.row.key == row.key) {
self.shown.push(Shown { row, gone_at: None });
@@ -305,7 +400,10 @@ impl Panel {
/// The rows on screen, bottom first, with how opaque each is (fading out: less).
pub fn rows(&self, now: f64) -> impl Iterator<Item = (&Row, f32)> {
self.shown.iter().map(move |s| (&s.row, s.gone_at.map_or(1.0, |gone| (1.0 - (now - gone) / FADE_MS).clamp(0.0, 1.0) as f32)))
self.shown.iter().map(move |s| {
let (hold, fade) = Outcome::shown_for(s.row.ended.as_ref());
(&s.row, s.gone_at.map_or(1.0, |gone| (1.0 - (now - gone - hold) / fade).clamp(0.0, 1.0) as f32))
})
}
/// Whether anything is on screen.
@@ -318,13 +416,16 @@ impl Panel {
/// readout and its details, left of a pane over that corner. Off in Preferences: nothing.
pub(crate) fn show(app: &mut SolveApp, ui: &egui::Ui, view: Rect) {
app.operations.rect = None;
let now = crate::now_ms();
let mut rows = current(app);
if !app.preferences.operations_panel {
app.operations = Panel::default();
// Not shown, but the names are kept for `ui.operations`' list of those that ended.
app.operations.shown.clear();
app.operations.update(rows, now);
app.operations.shown.clear();
app.operations.panel_drew = false;
return;
}
let now = crate::now_ms();
let mut rows = current(app);
// "Wait for files to open" gives a file operation the card over the design, with its own
// Cancel: its row here would say the same thing twice. The card used as a fallback (no room
// for the panel) keeps the row, so the panel can measure whether it has room again.
@@ -391,7 +492,15 @@ pub(crate) fn show(app: &mut SolveApp, ui: &egui::Ui, view: Rect) {
/// One row in `r`: name, bar with its step, and the X. True when the X was clicked.
fn paint_row(ui: &egui::Ui, painter: &egui::Painter, t: &Tokens, row: &Row, alpha: f32, r: Rect) -> bool {
let fade = |c: Color32| c.gamma_multiply(alpha);
let accent = if row.agent { solidcraft_look::pointer::agent() } else { t.form_primary };
let accent = match row.ended {
Some(Outcome::Failed(_)) => t.error,
_ if row.agent => solidcraft_look::pointer::agent(),
_ => t.form_primary,
};
// A failed row says why when the pointer is over it.
if let Some(Outcome::Failed(why)) = &row.ended {
let _ = ui.interact(r, egui::Id::unique(("sc_operation_failed", &row.key)), Sense::hover()).on_hover_text(why);
}
crate::scenario::publish_rect(&format!("operations:row:{}", row.key), r);
// The X on the right.
let x = Rect::from_center_size(pos2(r.right() - ROW * 0.5, r.center().y), vec2(ROW - 6.0, ROW - 6.0));
@@ -429,7 +538,7 @@ fn paint_row(ui: &egui::Ui, painter: &egui::Painter, t: &Tokens, row: &Row, alph
}
Some(_) => {}
// Not told how far it is: a stripe going to and fro.
None if !row.queued && !row.waiting && !row.cancelling && !row.cancelled => {
None if !row.queued && !row.waiting && !row.cancelling && row.ended.is_none() => {
let phase = (ui.ctx().input(|i| i.time) * 0.8).fract() as f32;
let w = bar.width() * 0.3;
let left = bar.left() + (bar.width() - w) * (1.0 - (phase * 2.0 - 1.0).abs());
@@ -448,7 +557,12 @@ pub fn control(app: &mut SolveApp, p: &Value) -> Result<Value, String> {
if let Some(key) = p.get("cancel").and_then(Value::as_str) {
return cancel(app, key).map(|()| json!({"cancelled": key}));
}
let rows: Vec<Value> = current(app)
let under_way = current(app);
for row in &under_way {
app.operations.labels.retain(|(key, _)| *key != row.key);
app.operations.labels.push((row.key.clone(), row.label.clone()));
}
let rows: Vec<Value> = under_way
.iter()
.rev()
.map(|r| {
@@ -462,7 +576,23 @@ pub fn control(app: &mut SolveApp, p: &Value) -> Result<Value, String> {
})
})
.collect();
Ok(json!({"operations": rows, "shown": app.operations.rect.is_some()}))
// Those that ended in the last minute, newest first, and how.
let now = crate::now_ms();
let finished: Vec<Value> = app
.operations
.finished
.iter()
.rev()
.filter(|f| now - f.at < FINISHED_MS)
.map(|f| {
let mut v = json!({"key": f.key, "label": f.label, "outcome": f.outcome.id(), "ms_ago": (now - f.at).max(0.0).round()});
if let Outcome::Failed(why) = &f.outcome {
v["error"] = json!(why);
}
v
})
.collect();
Ok(json!({"operations": rows, "finished": finished, "shown": app.operations.rect.is_some()}))
}
#[cfg(test)]
@@ -473,6 +603,16 @@ mod tests {
Row { steps, cancel: Some(Cancel::Apply), ..Row::new(key.into(), key.into()) }
}
/// One that ended done has a full bar even when its work counted no steps; one still running
/// without them has none (its stripe says it is working).
#[test]
fn a_done_row_without_steps_has_a_full_bar() {
let mut done = row("done", None);
done.ended = Some(Outcome::Done);
assert_eq!(done.fraction(), Some(1.0));
assert_eq!(row("running", None).fraction(), None);
}
/// A new operation goes on top; one that ends shows a full bar, can't be cancelled and fades
/// out over `FADE_MS`, then goes, and the rows above it take its place.
#[test]
@@ -483,7 +623,8 @@ mod tests {
let keys: Vec<&str> = panel.rows(100.0).map(|(r, _)| r.key.as_str()).collect();
assert_eq!(keys, ["a", "b"], "bottom first: the newer is above");
assert_eq!(panel.rows(100.0).next().unwrap().0.steps, Some((2, 4)), "brought up to date in place");
// `a` ends.
// `a` ends, saying it is done.
panel.finished.push(Finished { key: "a".into(), label: "a".into(), outcome: Outcome::Done, at: 200.0 });
panel.update(vec![row("b", None)], 200.0);
let (a, alpha) = panel.rows(200.0 + FADE_MS / 2.0).next().unwrap();
assert_eq!((a.key.as_str(), a.steps, a.cancellable()), ("a", Some((4, 4)), false));
@@ -491,14 +632,32 @@ mod tests {
panel.update(vec![row("b", None)], 200.0 + FADE_MS);
let keys: Vec<&str> = panel.rows(200.0 + FADE_MS).map(|(r, _)| r.key.as_str()).collect();
assert_eq!(keys, ["b"]);
// One failed stays a while where it stopped, red and saying so, then fades.
panel.update(vec![row("f", Some((2, 5)))], 290.0 + FADE_MS);
panel.finished.push(Finished { key: "f".into(), label: "f".into(), outcome: Outcome::Failed("no room".into()), at: 295.0 });
panel.update(Vec::new(), 295.0 + FADE_MS);
let failed = |panel: &Panel, at: f64| panel.rows(at).find(|(r, _)| r.key == "f").map(|(r, alpha)| (r.steps, r.status(), alpha));
let says = Some(solidcraft_ui_text::tr("operations.failed").to_string());
assert_eq!(failed(&panel, 295.0 + FADE_MS + FAILED_MS - 1.0), Some((Some((2, 5)), says, 1.0)), "shown in full while it holds");
panel.update(Vec::new(), 300.0 + 2.0 * FADE_MS + FAILED_MS);
assert!(failed(&panel, 300.0 + 2.0 * FADE_MS + FAILED_MS).is_none(), "then gone");
// One that ends without saying how fades as it was.
panel.update(vec![row("u", Some((1, 3)))], 300.0 + FADE_MS);
panel.update(Vec::new(), 301.0 + FADE_MS);
let (u, _) = panel.rows(301.0 + FADE_MS).find(|(r, _)| r.key == "u").unwrap();
assert_eq!((u.steps, &u.ended, u.status().as_deref()), (Some((1, 3)), &None, Some("2 / 3")), "as it last was");
panel.update(Vec::new(), 302.0 + 2.0 * FADE_MS);
// One cancelled fades out where it stopped, saying so.
panel.update(vec![row("b", Some((3, 9)))], 300.0 + FADE_MS);
panel.cancelled.push("b".into());
panel.finished.push(Finished { key: "b".into(), label: "b".into(), outcome: Outcome::Cancelled, at: 300.0 });
panel.update(Vec::new(), 310.0 + FADE_MS);
let (b, _) = panel.rows(310.0 + FADE_MS).next().unwrap();
assert_eq!((b.steps, b.cancelled, b.status()), (Some((3, 9)), true, Some(solidcraft_ui_text::tr("operations.stopped").to_string())));
assert_eq!(
(b.steps, &b.ended, b.status()),
(Some((3, 9)), &Some(Outcome::Cancelled), Some(solidcraft_ui_text::tr("operations.stopped").to_string()))
);
panel.update(Vec::new(), 400.0 + 2.0 * FADE_MS);
assert!(panel.is_empty() && panel.cancelled.is_empty());
assert!(panel.is_empty());
// One that comes back while fading is running again.
panel.update(vec![row("b", None), row("c", None)], 1000.0);
panel.update(vec![row("c", None)], 1010.0);
@@ -545,6 +704,53 @@ mod tests {
h.frames(3);
assert_eq!(h.app.session.doc.features.len(), features, "nothing was undone");
assert!(control(&mut h.app, &json!({"cancel": key(1)})).is_err(), "gone");
// Both are listed as ended, cancelled, newest first.
let finished = control(&mut h.app, &json!({})).unwrap()["finished"].clone();
let ended: Vec<(String, String)> =
finished.as_array().unwrap().iter().map(|f| (f["key"].as_str().unwrap().into(), f["outcome"].as_str().unwrap().into())).collect();
assert_eq!(ended, [(key(1), "cancelled".to_string()), (key(0), "cancelled".to_string())], "{finished}");
}
/// An operation seen under way says how it ended: `ui.operations` lists it for a while as done,
/// or as failed with the error the status line gives, and its row fades out saying so.
#[test]
fn ended_operations_are_listed_with_their_outcome() {
let mut h = crate::scenario::Harness::new();
h.app.run("solid.box", json!({"length": 10, "width": 10, "height": 10})).unwrap();
h.frames(2);
let outcome = |h: &mut crate::scenario::Harness, key: &str| {
let listed = control(&mut h.app, &json!({})).unwrap();
listed["finished"].as_array().unwrap().iter().find(|f| f["key"] == key).cloned()
};
// An undo computing (held), seen under way, then done.
let (job, release) = crate::deferred::Deferred::held("edit.undo");
let key = format!("history:{}", job.serial);
h.app.deferred = Some(job);
h.frames(2);
assert!(crate::scenario::rect_of(&format!("operations:row:{key}")).is_some(), "seen under way");
drop(release);
h.frames(2);
assert_eq!(h.app.session.doc.features.len(), 0, "the undo ran");
let done = outcome(&mut h, &key).expect("listed once it ended");
assert_eq!((done["outcome"].as_str(), done["label"].as_str()), (Some("done"), Some(command_name("edit.undo").as_str())), "{done}");
// A redo with nothing to redo fails, saying why.
h.app.run("edit.redo", json!({})).unwrap();
let (job, release) = crate::deferred::Deferred::held("edit.redo");
let key = format!("history:{}", job.serial);
h.app.deferred = Some(job);
h.frames(2);
drop(release);
h.frames(2);
let failed = outcome(&mut h, &key).expect("listed once it ended");
assert_eq!(failed["outcome"], "failed", "{failed}");
assert!(failed["error"].as_str().is_some_and(|e| !e.is_empty()), "{failed}");
let row = h.app.operations.rows(crate::now_ms()).find(|(r, _)| r.key == key).map(|(r, _)| r.clone()).expect("its row stays a while");
assert_eq!(row.status(), Some(solidcraft_ui_text::tr("operations.failed").to_string()));
// Done within the app's wait (never a row): not listed.
h.app.apply_off_thread = Some(std::time::Duration::from_secs(60));
h.app.run_heavy("edit.undo", json!({})).unwrap();
let listed = control(&mut h.app, &json!({})).unwrap();
assert_eq!(listed["finished"].as_array().unwrap().len(), 2, "{listed}");
}
/// The panel shows the rows in the view's corner, above the frame-rate readout, the newer
-25
View File
@@ -1147,31 +1147,6 @@ impl crate::SolveApp {
} else {
self.shell.cards.clear();
}
if self.panel_runtime.as_ref().is_some_and(|integration| integration.production.is_some()) && crate::render_workspace::active(self) {
// These remain legacy content surfaces. Their actual edge reservations
// constrain panel placement without changing the full render domain.
crate::render_ui::shots_strip(self, ui);
crate::render_workspace::panel(self, ui);
if self.ui.show_browser && !crate::render_ui::focused(self) {
// Keep the unmigrated library in its existing lower card slot.
// Reserve its real top edge before admitting shared placements.
let bounds = crate::shell::panel_bounds(self, ui.available_rect_before_wrap());
let upper = crate::ui_kit::Panel::stack_first_bounds(bounds);
crate::render_workspace::appearances_card(self, ui, crate::ui_kit::Panel::stack_next_bounds(bounds, upper));
}
for (id, (bounds, edge)) in &self.shell.workspace_panels {
if *id == "render:appearances" {
placement.max.y = bounds.top().clamp(placement.top(), placement.bottom());
continue;
}
match edge {
Some(crate::ui_kit::Edge::Left) => placement.min.x = bounds.right().clamp(placement.left(), placement.right()),
Some(crate::ui_kit::Edge::Right) => placement.max.x = bounds.left().clamp(placement.left(), placement.right()),
Some(crate::ui_kit::Edge::Bottom) => placement.max.y = bounds.top().clamp(placement.top(), placement.bottom()),
_ => {}
}
}
}
let gap = crate::ui_kit::Container::new(ui.ctx(), crate::ui_kit::Surface::Panel).gap();
placement.min.x += gap.min(placement.width() / 2.0);
placement.max.x -= gap.min(placement.width());
+1 -1
View File
@@ -20,7 +20,7 @@ pub use layout::*;
pub use preferences::*;
pub(crate) use preferences::{PanelLayoutSnapshot, panel_layout_matches, panel_layout_snapshot};
pub(crate) use production::{
production_active, refresh_production, register_content_focus, report_content_composer_height, report_content_extent,
production_active, refresh_production, register_content_focus, report_content_composer_height, report_content_extent, reveal_render_library,
shared_content_composer_height, shared_content_measurement,
};
pub use runtime::*;
+297 -118
View File
@@ -26,11 +26,25 @@ enum Product {
History,
Assistant,
RenderBrowser,
RenderLibrary,
RenderInspector,
RenderShots,
SketchOptions,
Bom,
}
impl Product {
const ALL: [Self; 6] = [Self::Browser, Self::History, Self::Assistant, Self::RenderBrowser, Self::SketchOptions, Self::Bom];
const ALL: [Self; 9] = [
Self::Browser,
Self::History,
Self::Assistant,
Self::RenderBrowser,
Self::RenderLibrary,
Self::RenderInspector,
Self::RenderShots,
Self::SketchOptions,
Self::Bom,
];
const NEW_RENDER: [Self; 3] = [Self::RenderLibrary, Self::RenderInspector, Self::RenderShots];
fn index(self) -> usize {
match self {
@@ -38,8 +52,11 @@ impl Product {
Self::History => 1,
Self::Assistant => 2,
Self::RenderBrowser => 3,
Self::SketchOptions => 4,
Self::Bom => 5,
Self::RenderLibrary => 4,
Self::RenderInspector => 5,
Self::RenderShots => 6,
Self::SketchOptions => 7,
Self::Bom => 8,
}
}
fn visible(self, app: &crate::SolveApp) -> bool {
@@ -47,6 +64,9 @@ impl Product {
Self::Browser | Self::RenderBrowser => app.ui.show_browser,
Self::History => app.ui.show_timeline,
Self::Assistant => app.agent_chat.open,
Self::RenderLibrary => true,
Self::RenderInspector => crate::render_workspace::inspector_visible(app),
Self::RenderShots => app.preferences.render_shots_strip,
Self::SketchOptions => true,
Self::Bom => crate::bom_panel::available(app),
}
@@ -56,9 +76,11 @@ impl Product {
Self::Browser | Self::RenderBrowser => app.ui.show_browser = open,
Self::History => app.ui.show_timeline = open,
Self::Assistant => app.agent_chat.open = open,
Self::SketchOptions => {}
Self::RenderLibrary => {}
Self::RenderInspector => crate::render_workspace::set_inspector_visible(app, open),
Self::RenderShots => app.preferences.render_shots_strip = open,
Self::Bom if !open => app.bom = None,
Self::Bom => {}
Self::Bom | Self::SketchOptions => {}
}
}
@@ -68,6 +90,9 @@ impl Product {
Self::History => "history",
Self::Assistant => "assistant",
Self::RenderBrowser => "render-browser",
Self::RenderLibrary => "render-library",
Self::RenderInspector => "render-inspector",
Self::RenderShots => "render-shots",
Self::SketchOptions => crate::sketch_palette::INSTANCE_ID,
Self::Bom => "bom",
}
@@ -78,6 +103,9 @@ impl Product {
Self::History => "solidcraft.history",
Self::Assistant => "solidcraft.assistant",
Self::RenderBrowser => "solidcraft.render_browser",
Self::RenderLibrary => "solidcraft.render_library",
Self::RenderInspector => "solidcraft.render_inspector",
Self::RenderShots => "solidcraft.render_shots",
Self::SketchOptions => "solidcraft.sketch_options",
Self::Bom => "solidcraft.bom",
}
@@ -88,17 +116,21 @@ impl Product {
Self::History => "ui.history_title",
Self::Assistant => "ui.assistant",
Self::RenderBrowser => "render.page_scene",
Self::RenderLibrary => "render.library_title",
Self::RenderInspector => "render.inspector",
Self::RenderShots => "render.ribbon_shots",
Self::SketchOptions => "ui.palette_sketch_options",
Self::Bom => crate::bom_panel::TITLE_KEY,
}
}
fn icon(self) -> &'static str {
match self {
Self::Bom => "bom",
Self::SketchOptions => "sketch",
Self::Bom => "bom",
_ => "list",
}
}
fn from_instance(instance: &InstanceId) -> PanelResult<Self> {
Self::ALL.into_iter().find(|product| product.id() == instance.0).ok_or_else(|| error(Rule::MissingTarget, "unknown application panel"))
}
@@ -108,11 +140,31 @@ impl Product {
Self::Browser => !render && !crate::render_ui::focused(app),
Self::History => !render && !app.preferences.history_in_browser,
Self::Assistant => crate::agent_chat::shown(app) && !app.preferences.agent_chat_popped,
Self::RenderBrowser => render && !crate::render_ui::focused(app),
Self::RenderBrowser | Self::RenderLibrary | Self::RenderInspector | Self::RenderShots => render && !crate::render_ui::focused(app),
Self::SketchOptions => !render && crate::sketch_palette::editing(app),
Self::Bom => crate::bom_panel::available(app),
}
}
fn default_zone(self, app: &crate::SolveApp) -> Zone {
match self {
Self::History if !app.preferences.timeline_left => Zone::Bottom,
Self::Assistant if app.preferences.agent_chat_side == "right" => Zone::Right,
Self::RenderBrowser | Self::RenderLibrary if app.preferences.render_browser_side == "right" => Zone::Right,
Self::RenderShots => Zone::Bottom,
Self::RenderInspector if app.preferences.render_inspector_side != "left" => Zone::Right,
Self::SketchOptions | Self::Bom => Zone::Right,
_ => Zone::Left,
}
}
fn column_owner(self, stack_history: bool) -> Self {
match self {
Self::History if stack_history => Self::Browser,
Self::RenderLibrary => Self::RenderBrowser,
_ => self,
}
}
fn header_actions(self) -> Vec<HeaderActionSpec> {
let entries: Vec<(&str, &str, &str)> = match self {
Self::History => vec![
@@ -128,7 +180,13 @@ impl Product {
("by-look", "render.tree_by_look", "list"),
("search", "render.tree_search", "search"),
],
Self::Browser | Self::SketchOptions | Self::Bom => Vec::new(),
Self::RenderLibrary => vec![
("grid", "render.library_grid", "grid"),
("list", "render.library_list", "list"),
("references", "render.stage_panels.surfaces_references", "library"),
],
Self::RenderInspector => vec![("remove", "render.inspector_remove", "delete")],
Self::Browser | Self::RenderShots | Self::SketchOptions | Self::Bom => Vec::new(),
};
entries.into_iter().map(|(id, key, icon)| HeaderActionSpec { id: ControlId(id.into()), label_key: key.into(), icon: icon.into() }).collect()
}
@@ -183,7 +241,40 @@ impl Product {
pressed: Some(pressed),
})
.collect(),
Self::Browser | Self::SketchOptions | Self::Bom => Vec::new(),
Self::RenderLibrary => {
presentation.title = crate::render_workspace::library::library_title(app);
crate::render_workspace::library::library_mode(app).map_or_else(Vec::new, |mode| {
self.header_actions()
.into_iter()
.enumerate()
.map(|(index, action)| RuntimeAction {
id: action.id,
label: solidcraft_ui_text::tr(&action.label_key).into(),
enabled: true,
disabled_reason: None,
pressed: Some(mode == index),
})
.collect()
})
}
Self::RenderInspector => {
use crate::render_workspace::inspector_panel as inspector;
let (title, icon) = inspector::shared_title(app);
presentation.title = title;
presentation.icon = icon.into();
presentation.status = inspector::shared_status(app);
inspector::shared_remove_available(app)
.then(|| RuntimeAction {
id: ControlId("remove".into()),
label: solidcraft_ui_text::tr("render.inspector_remove").into(),
enabled: true,
disabled_reason: None,
pressed: None,
})
.into_iter()
.collect()
}
Self::Browser | Self::RenderShots | Self::SketchOptions | Self::Bom => Vec::new(),
};
presentation
}
@@ -216,12 +307,13 @@ struct ContentExtent {
struct ProductionSnapshot {
document: DocumentId,
play_button: bool,
visible: [bool; Product::ALL.len()],
visibility: [Option<VisibilityIntent>; Product::ALL.len()],
panels: [PanelSnapshot; Product::ALL.len()],
sketch_owner: Option<(DocumentId, u64)>,
sketch_placement_attempt: Option<(u64, LogicalRect, u64)>,
bom_focus_attempt: Option<(u64, LogicalRect)>,
render_addition_attempts: [Option<(u64, LogicalRect)>; Product::ALL.len()],
visible: [bool; Product::ALL.len()],
visibility: [Option<VisibilityIntent>; Product::ALL.len()],
panels: [PanelSnapshot; Product::ALL.len()],
actions: VecDeque<QueuedAction>,
}
struct VisibilityIntent {
@@ -243,7 +335,11 @@ impl ProductionState {
Ok(Self(Rc::new(RefCell::new(ProductionSnapshot {
document: DocumentId::new(app.session.document_id.clone())?,
play_button: app.preferences.history_play_button,
render_addition_attempts: Default::default(),
visible: Product::ALL.map(|product| product.visible(app)),
sketch_owner: None,
sketch_placement_attempt: None,
bom_focus_attempt: None,
visibility: Default::default(),
panels: Product::ALL.map(|product| PanelSnapshot {
product,
@@ -251,10 +347,7 @@ impl ProductionState {
presentation: product.presentation(app, app.preferences.history_play_button),
extent: None,
}),
bom_focus_attempt: None,
actions: VecDeque::new(),
sketch_owner: None,
sketch_placement_attempt: None,
}))))
}
}
@@ -284,12 +377,7 @@ pub(crate) fn enable_production(app: &mut crate::SolveApp) -> PanelResult<()> {
PanelKind::new(product.kind())?,
ControllerBinding {
ownership: Ownership::Document,
create: Box::new(move |_instance, owner| {
let ContentOwner::Document(document) = owner else {
return Err(error(Rule::Placement, "application panel requires its document owner"));
};
Ok(Box::new(ProductionController { product, document: document.clone(), state: state.clone() }))
}),
create: Box::new(move |_instance, owner| Ok(Box::new(ProductionController { product, owner: owner.clone(), state: state.clone() }))),
},
)?;
}
@@ -325,7 +413,7 @@ pub(crate) fn enable_production(app: &mut crate::SolveApp) -> PanelResult<()> {
}
pub(crate) fn refresh_production(app: &crate::SolveApp) -> PanelResult<()> {
let Some(state) = app.panel_runtime.as_ref().and_then(|integration| integration.production.as_ref()) else { return Ok(()) };
let Some(state) = app.panel_runtime.as_ref().and_then(|integration| integration.production.clone()) else { return Ok(()) };
let mut snapshot = state.0.try_borrow_mut().map_err(|_| error(Rule::Lifecycle, "application panel snapshot is borrowed"))?;
let document = DocumentId::new(app.session.document_id.clone())?;
if snapshot.document != document {
@@ -339,6 +427,9 @@ pub(crate) fn refresh_production(app: &crate::SolveApp) -> PanelResult<()> {
// Keep an explicit view-toggle edge pending while its content context is
// unavailable. Ordinary workspace changes do not create a new toggle edge.
for product in Product::ALL {
if product == Product::Bom {
continue;
}
let visible = product.visible(app);
if app.panel_runtime.as_ref().is_some_and(|integration| {
integration
@@ -397,7 +488,17 @@ pub(crate) fn drain_production_actions(app: &mut crate::SolveApp) -> PanelResult
};
app.run("view.render_browser", params).map_err(|reason| error(Rule::Availability, reason))?;
}
Product::Browser | Product::SketchOptions | Product::Bom => {}
Product::RenderLibrary => {
let mode = match action.action.0.as_str() {
"grid" => 0,
"list" => 1,
"references" => 2,
_ => continue,
};
crate::render_workspace::library::set_library_mode(app, mode);
}
Product::RenderInspector => crate::render_workspace::inspector_panel::shared_remove(app),
Product::Browser | Product::RenderShots | Product::SketchOptions | Product::Bom => {}
}
}
Ok(())
@@ -407,8 +508,16 @@ pub(crate) fn production_active(app: &crate::SolveApp) -> bool {
app.panel_content_frame.is_some() || app.panel_runtime.as_ref().is_some_and(|integration| integration.production.is_some())
}
pub(crate) fn reveal_render_library(app: &mut crate::SolveApp) -> bool {
if !production_active(app) {
return false;
}
app.render_workspace.view.library_focus_pending = true;
true
}
/// Existing view toggles update remembered placement only when their raw flag
/// changes. BOM opens from its document owner's explicit reveal request.
/// changes. Context changes and hidden bodies never reopen closed panels.
pub(crate) fn sync_production_visibility(app: &mut crate::SolveApp) -> PanelResult<()> {
let Some(integration) = app.panel_runtime.as_ref() else { return Ok(()) };
let Some(state) = integration.production.as_ref() else { return Ok(()) };
@@ -418,32 +527,144 @@ pub(crate) fn sync_production_visibility(app: &mut crate::SolveApp) -> PanelResu
}
let layout = integration.runtime().layout().layout();
let mut mutations = Vec::new();
let reveal_library = app.render_workspace.view.library_focus_pending && Product::RenderLibrary.available(app);
let inspector_visibility = app.render_workspace.view.inspector_visibility_request.filter(|_| Product::RenderInspector.available(app));
let revision = integration.runtime().layout().inspect().revision;
let addition = app.universal_panel_frame().map(|frame| (revision, frame.layout.capacity));
let mut adding = None;
for product in Product::ALL {
if product == Product::Bom {
continue;
}
let visible = product.visible(app);
if snapshot.visible.get(product.index()).copied() != Some(visible) {
let instance = layout.instance(&InstanceId::new(product.id())?)?;
if instance.requested_open != visible && (!visible || product.available(app)) {
let id = InstanceId::new(product.id())?;
let instance = layout.instance(&id).ok();
if instance.is_none()
&& Product::NEW_RENDER.contains(&product)
&& visible
&& product.available(app)
&& adding.is_none()
&& addition.is_some()
&& addition != snapshot.render_addition_attempts[product.index()]
{
// Layouts saved before these registrations gain one panel at a time.
// Reuse a compatible side column when possible.
let zone = product.default_zone(app);
let kind = PanelKind::new(product.kind())?;
let size = integration.runtime().layout().registry().get(&kind)?.default_size;
let section = next_section_id_with_reservations(layout, &integration.preference_identity_reservations()?)?;
let companion = product.column_owner(false);
let anchor = layout
.containing(&InstanceId::new(companion.id())?)
.ok()
.filter(|section| section.location.zone() == zone)
.or_else(|| layout.sections.iter().find(|section| section.location.zone() == zone));
let destination = if let Some(anchor) = anchor.filter(|_| zone.side()) {
Destination::Relative {
section: anchor.id.clone(),
placement: RelativePlacement::Below,
new_section: section,
new_column: None,
size,
}
} else {
Destination::NewSection { section, index: layout.sections.len(), location: SectionLocation::Dock { zone }, size }
};
mutations.push(Mutation::Open { kind, instance: id, destination: Some(destination) });
adding = Some(product);
} else if let Some(instance) = instance {
let reveal =
product == Product::RenderLibrary && reveal_library || product == Product::RenderInspector && inspector_visibility == Some(true);
if reveal {
if !layout.containing(&id).is_ok_and(|section| !section.collapsed && section.active == id) {
mutations.push(Mutation::Focus { instance: id });
}
} else if product == Product::RenderInspector {
if inspector_visibility == Some(false) && instance.requested_open {
mutations.push(Mutation::Close { instance: id });
}
} else if snapshot.visible.get(product.index()).copied() != Some(visible)
&& instance.requested_open != visible
&& (!visible || product.available(app))
{
mutations.push(if visible {
Mutation::Open { kind: instance.kind.clone(), instance: instance.id.clone(), destination: None }
Mutation::Open { kind: instance.kind.clone(), instance: id, destination: None }
} else {
Mutation::Close { instance: instance.id.clone() }
Mutation::Close { instance: id }
});
}
}
}
let revision = integration.runtime().layout().inspect().revision;
let library_present = layout.instance(&InstanceId::new(Product::RenderLibrary.id())?).is_ok();
let inspector_present = layout.instance(&InstanceId::new(Product::RenderInspector.id())?).is_ok();
drop(snapshot);
if let Some(product) = adding {
state.0.try_borrow_mut().map_err(|_| error(Rule::Lifecycle, "application panel visibility is borrowed"))?.render_addition_attempts
[product.index()] = addition;
}
if !mutations.is_empty() {
refresh_production(app)?;
app.dispatch_panel_layout(LayoutAction::Apply { expected_revision: Some(revision), mutations })?;
}
if reveal_library && library_present {
app.render_workspace.view.library_focus_pending = false;
}
if inspector_visibility == Some(false) || (inspector_visibility == Some(true) && inspector_present) {
app.render_workspace.view.inspector_visibility_request = None;
}
sync_sketch_options(app)?;
sync_bom(app)
}
fn sync_bom(app: &mut crate::SolveApp) -> PanelResult<()> {
let Some(integration) = app.panel_runtime.as_ref() else { return Ok(()) };
let Some(state) = integration.production.clone() else { return Ok(()) };
let mut snapshot = state.0.try_borrow_mut().map_err(|_| error(Rule::Lifecycle, "BOM focus is borrowed"))?;
let layout = integration.runtime().layout().layout();
let revision = integration.runtime().layout().inspect().revision;
let mut mutations = Vec::new();
let mut bom_focused = false;
let mut bom_attempt = None;
if crate::bom_panel::focus_pending(app) {
let id = InstanceId::new(Product::Bom.id())?;
if layout.containing(&id).is_ok_and(|section| !section.collapsed && section.active == id) {
bom_focused = true;
} else if let Some(attempt) = app.universal_panel_frame().map(|frame| (revision, frame.layout.capacity))
&& Some(attempt) != snapshot.bom_focus_attempt
{
let mutation = if layout.instance(&id).is_ok() { Mutation::Focus { instance: id } } else { open_bom(integration, layout, id)? };
mutations.push(mutation);
bom_attempt = Some(attempt);
bom_focused = true;
}
}
if let Some(attempt) = bom_attempt {
snapshot.bom_focus_attempt = Some(attempt);
}
drop(snapshot);
if !mutations.is_empty() {
app.dispatch_panel_layout(LayoutAction::Apply { expected_revision: Some(revision), mutations })?;
}
if bom_focused {
crate::bom_panel::set_focus_pending(app, false);
}
Ok(())
}
fn open_bom(integration: &AppPanelRuntime, layout: &Layout, instance: InstanceId) -> PanelResult<Mutation> {
let kind = PanelKind::new(Product::Bom.kind())?;
let spec = integration.runtime().layout().registry().get(&kind)?;
let zone = spec.default_zone;
let size = spec.default_size;
let section = next_section_id_with_reservations(layout, &integration.preference_identity_reservations()?)?;
let destination = if let Some(anchor) = layout.sections.iter().find(|section| section.location.zone() == zone) {
Destination::Relative { section: anchor.id.clone(), placement: RelativePlacement::Below, new_section: section, new_column: None, size }
} else {
Destination::NewSection { section, index: layout.sections.len(), location: SectionLocation::Dock { zone }, size }
};
Ok(Mutation::Open { kind, instance, destination: Some(destination) })
}
fn sync_sketch_options(app: &mut crate::SolveApp) -> PanelResult<()> {
let Some(integration) = app.panel_runtime.as_ref() else { return Ok(()) };
let Some(state) = integration.production.clone() else { return Ok(()) };
@@ -513,55 +734,6 @@ fn sync_sketch_options(app: &mut crate::SolveApp) -> PanelResult<()> {
}
}
fn sync_bom(app: &mut crate::SolveApp) -> PanelResult<()> {
let Some(integration) = app.panel_runtime.as_ref() else { return Ok(()) };
let Some(state) = integration.production.clone() else { return Ok(()) };
let mut snapshot = state.0.try_borrow_mut().map_err(|_| error(Rule::Lifecycle, "BOM focus is borrowed"))?;
let layout = integration.runtime().layout().layout();
let revision = integration.runtime().layout().inspect().revision;
let mut mutations = Vec::new();
let mut bom_focused = false;
let mut bom_attempt = None;
if crate::bom_panel::focus_pending(app) {
let id = InstanceId::new(Product::Bom.id())?;
if layout.containing(&id).is_ok_and(|section| !section.collapsed && section.active == id) {
bom_focused = true;
} else if let Some(attempt) = app.universal_panel_frame().map(|frame| (revision, frame.layout.capacity))
&& Some(attempt) != snapshot.bom_focus_attempt
{
let mutation = if layout.instance(&id).is_ok() { Mutation::Focus { instance: id } } else { open_bom(integration, layout, id)? };
mutations.push(mutation);
bom_attempt = Some(attempt);
bom_focused = true;
}
}
if let Some(attempt) = bom_attempt {
snapshot.bom_focus_attempt = Some(attempt);
}
drop(snapshot);
if !mutations.is_empty() {
app.dispatch_panel_layout(LayoutAction::Apply { expected_revision: Some(revision), mutations })?;
}
if bom_focused {
crate::bom_panel::set_focus_pending(app, false);
}
Ok(())
}
fn open_bom(integration: &AppPanelRuntime, layout: &Layout, instance: InstanceId) -> PanelResult<Mutation> {
let kind = PanelKind::new(Product::Bom.kind())?;
let spec = integration.runtime().layout().registry().get(&kind)?;
let zone = spec.default_zone;
let size = spec.default_size;
let section = next_section_id_with_reservations(layout, &integration.preference_identity_reservations()?)?;
let destination = if let Some(anchor) = layout.sections.iter().find(|section| section.location.zone() == zone) {
Destination::Relative { section: anchor.id.clone(), placement: RelativePlacement::Below, new_section: section, new_column: None, size }
} else {
Destination::NewSection { section, index: layout.sections.len(), location: SectionLocation::Dock { zone }, size }
};
Ok(Mutation::Open { kind, instance, destination: Some(destination) })
}
fn registry(app: &crate::SolveApp) -> PanelResult<PanelRegistry> {
let mut registry = PanelRegistry::default();
let tokens = solidcraft_theme::Tokens::get();
@@ -574,7 +746,8 @@ fn registry(app: &crate::SolveApp) -> PanelResult<PanelRegistry> {
let sketch = product == Product::SketchOptions;
let bom = product == Product::Bom;
let mut allowed_zones = [Zone::Left, Zone::Right, Zone::Floating].into_iter().collect::<std::collections::BTreeSet<_>>();
if history {
let shots = product == Product::RenderShots;
if history || shots {
allowed_zones.insert(Zone::Bottom);
}
let mut header_actions = product.header_actions();
@@ -586,34 +759,32 @@ fn registry(app: &crate::SolveApp) -> PanelResult<PanelRegistry> {
title_key: product.title_key().into(),
icon: product.icon().into(),
allowed_zones,
default_zone: if history {
Zone::Bottom
} else if sketch || bom {
Zone::Right
} else {
Zone::Left
default_zone: match product {
Product::History | Product::RenderShots => Zone::Bottom,
Product::RenderInspector | Product::SketchOptions | Product::Bom => Zone::Right,
_ => Zone::Left,
},
min_size: Size {
width: 240.0,
height: if bom {
200.0
} else if assistant {
120.0
} else if sketch {
150.0
} else {
tokens.control_height
height: match product {
Product::RenderLibrary => 180.0,
Product::Bom => 200.0,
Product::SketchOptions => 150.0,
Product::RenderInspector => 160.0,
Product::Assistant => 120.0,
Product::RenderShots => bottom_height,
_ => tokens.control_height,
},
},
default_size: Size { width: crate::prefs::DEFAULT_BROWSER_WIDTH, height: if history { bottom_height } else { 320.0 } },
default_size: Size { width: crate::prefs::DEFAULT_BROWSER_WIDTH, height: if history || shots { bottom_height } else { 320.0 } },
max_size: Size { width: MAX_LOGICAL, height: MAX_LOGICAL },
edge_rule: if assistant { EdgeRule::PinOuter } else { EdgeRule::None },
surface: PanelSurface::FollowZone,
features: PanelFeatures { popout: false, hot_closable: assistant || bom, closable: !sketch, ..Default::default() },
side_height_policy: if assistant { SideHeightPolicy::Fill } else { SideHeightPolicy::Content },
fixed_bottom_height: history.then_some(bottom_height),
fixed_bottom_height: (history || shots).then_some(bottom_height),
header_actions,
text_heavy: assistant || sketch || bom,
text_heavy: assistant || bom || sketch,
})?;
}
Ok(registry)
@@ -629,21 +800,16 @@ fn default_layout(app: &crate::SolveApp, workspace: WorkspaceId, registry: &Pane
let requested_open = product != Product::Bom && product.visible(app);
let kind = PanelKind::new(product.kind())?;
let spec = registry.get(&kind)?;
let zone = match product {
Product::History if !app.preferences.timeline_left => Zone::Bottom,
Product::Assistant if app.preferences.agent_chat_side == "right" => Zone::Right,
Product::RenderBrowser if app.preferences.render_browser_side == "right" => Zone::Right,
Product::SketchOptions => Zone::Right,
Product::Bom => spec.default_zone,
_ => Zone::Left,
};
let zone = product.default_zone(app);
let width = match product {
Product::Browser => app.preferences.browser_width,
Product::History if stacked => app.preferences.browser_width,
Product::History => app.preferences.timeline_left_width,
Product::Assistant => app.preferences.agent_chat_width,
Product::RenderBrowser => app.preferences.render_browser_width,
Product::SketchOptions | Product::Bom => spec.default_size.width,
Product::RenderBrowser | Product::RenderLibrary => app.preferences.render_browser_width,
Product::RenderInspector => app.preferences.render_inspector_width,
Product::RenderShots => spec.default_size.width,
_ => spec.default_size.width,
};
let width = if width.is_finite() { width.clamp(spec.min_size.width, 1200.0) } else { spec.default_size.width };
let width = if product == Product::History && stacked {
@@ -654,9 +820,7 @@ fn default_layout(app: &crate::SolveApp, workspace: WorkspaceId, registry: &Pane
let size = Size { width, height: spec.default_size.height };
let section = SectionId::new(format!("panel-{}", product.id()))?;
let location = SectionLocation::Dock { zone };
let column = zone
.side()
.then(|| ColumnId(format!("panel-column-{}", if product == Product::History && stacked { Product::Browser.id() } else { product.id() })));
let column = zone.side().then(|| ColumnId(format!("panel-column-{}", product.column_owner(stacked).id())));
if requested_open
&& let Some(column) = &column
&& !layout.columns.iter().any(|existing| existing.id == *column)
@@ -669,7 +833,7 @@ fn default_layout(app: &crate::SolveApp, workspace: WorkspaceId, registry: &Pane
Product::Browser => app.preferences.browser_collapsed,
Product::History => app.preferences.timeline_collapsed,
Product::Assistant => app.preferences.agent_chat_minimized,
Product::RenderBrowser | Product::SketchOptions | Product::Bom => false,
_ => false,
};
let weight = match product {
Product::Browser if stacked => share,
@@ -744,13 +908,14 @@ fn default_layout(app: &crate::SolveApp, workspace: WorkspaceId, registry: &Pane
struct ProductionController {
product: Product,
document: DocumentId,
owner: ContentOwner,
state: ProductionState,
}
impl PanelController for ProductionController {
fn availability(&self, owner: &ContentOwner, _models: &dyn PanelModels) -> Availability {
if self.state.0.try_borrow().is_ok_and(|snapshot| {
*owner == ContentOwner::Document(snapshot.document.clone())
*owner == self.owner
&& *owner == ContentOwner::Document(snapshot.document.clone())
&& snapshot.panels.iter().any(|panel| panel.product == self.product && panel.available)
}) {
Availability::Available
@@ -790,10 +955,10 @@ impl PanelController for ProductionController {
_ => return,
};
if let Ok(mut snapshot) = self.state.0.try_borrow_mut()
&& snapshot.document == self.document
&& let Some(slot) = snapshot.visibility.get_mut(self.product.index())
&& self.owner == ContentOwner::Document(snapshot.document.clone())
{
*slot = Some(VisibilityIntent { document: self.document.clone(), open });
let document = snapshot.document.clone();
snapshot.visibility[self.product.index()] = Some(VisibilityIntent { document, open });
}
}
fn render(&mut self, _slot: ContentSlot, _ui: &mut egui::Ui, _context: &PanelContext, _services: &mut ContentServices<'_>) -> PanelResult<()> {
@@ -1015,13 +1180,24 @@ impl FramePanelContent for ProductionFrame<'_> {
}
context.validate()?;
let product = Product::from_instance(&context.instance)?;
if context.owner != ContentOwner::Document(DocumentId::new(self.app.session.document_id.clone())?) || !product.available(self.app) {
let owner = ContentOwner::Document(DocumentId::new(self.app.session.document_id.clone())?);
if context.owner != owner || !product.available(self.app) {
return Err(error(Rule::Availability, "application panel content owner changed"));
}
match product {
Product::Browser | Product::RenderBrowser => self.app.shell.browser = Some(super::workspace::egui_rect(context.outer)),
Product::History => self.app.shell.history = Some(super::workspace::egui_rect(context.outer)),
Product::Assistant | Product::SketchOptions | Product::Bom => {}
Product::Assistant => {}
Product::RenderLibrary | Product::RenderInspector | Product::RenderShots => {
let rect = super::workspace::egui_rect(context.outer);
let id = match product {
Product::RenderLibrary => "render:appearances",
Product::RenderInspector => "render:inspector",
_ => "render:shots_strip",
};
crate::scenario::publish_rect(id, rect);
}
Product::SketchOptions | Product::Bom => {}
}
let previous = self
.state
@@ -1052,6 +1228,9 @@ impl FramePanelContent for ProductionFrame<'_> {
Product::Assistant => crate::agent_chat::shared_content(self.app, ui, context.flow),
Product::SketchOptions => crate::sketch_palette::shared_body(self.app, ui),
Product::Bom => crate::bom_panel::shared_content(self.app, ui),
Product::RenderLibrary => crate::render_workspace::library::shared_library(self.app, ui),
Product::RenderInspector => crate::render_workspace::inspector_panel::shared_body(self.app, ui),
Product::RenderShots => crate::render_workspace::shots::shared_shots(self.app, ui, context.flow, slot == ContentSlot::Inline),
}
let capture = ui.ctx().data_mut(|data| data.remove_temp::<Option<FocusCapture>>(focus_capture_id()).flatten());
if context.document.as_ref().map(|document| document.0.as_str()) != Some(self.app.session.document_id.as_str()) {
+54 -2
View File
@@ -13401,17 +13401,69 @@ fn production_render_browser_reuses_real_lens_search_and_retains_explicit_layout
assert!(h.app.render_workspace.view.by_look && h.app.render_workspace.view.search_objects);
}
#[test]
fn production_render_panels_reopen_independently_with_existing_controls() {
let mut h = production_content_harness(false, false);
// Restore a layout saved before the three Render panels were registered.
let names = ["render-library", "render-inspector", "render-shots"];
let mut old = h.app.panel_runtime().unwrap().layout().layout().clone();
old.instances.retain(|instance| !names.contains(&instance.id.0.as_str()));
old.sections.retain(|section| !names.contains(&section.active.0.as_str()));
old.columns.retain(|column| old.sections.iter().any(|section| section.column.as_ref() == Some(&column.id)));
h.app.dispatch_panel_layout(LayoutAction::Replace { expected_revision: None, layout: old }).unwrap();
let document = h.app.session.doc.clone();
h.app.ui.tab = "RENDER".into();
h.frames(12);
let presented = |app: &crate::SolveApp, name| {
app.universal_panel_frame()
.unwrap()
.layout
.sections
.iter()
.any(|card| card.instance == id(name) && (card.body.is_some() || card.inline_content.is_some()))
};
for name in ["render-browser", "render-library", "render-inspector", "render-shots"] {
assert!(presented(&h.app, name), "old layout should present {name}");
}
let frame = h.app.universal_panel_frame().unwrap();
let shots = frame.layout.sections.iter().find(|card| card.instance == id("render-shots")).unwrap();
assert_eq!(shots.zone, Zone::Bottom, "older layouts gain Shots in the Bottom dock");
assert!(shots.inline && shots.body.is_none(), "Bottom Shots fits in one shared row");
assert!(crate::scenario::rect_of("render:editor:scene").is_some());
assert!(crate::scenario::rect_of("render:shots:status").is_some());
assert!(!h.app.shell.workspace_panels.contains_key("render:appearances"));
assert!(!h.app.shell.workspace_panels.contains_key("render:inspector"));
assert!(!h.app.shell.workspace_panels.contains_key("render:shots_strip"));
h.app
.dispatch_panel_layout(transaction(vec![
Mutation::Close { instance: id("render-library") },
Mutation::Close { instance: id("render-browser") },
]))
.unwrap();
h.frames(3);
assert!(!presented(&h.app, "render-library"));
assert!(presented(&h.app, "render-inspector"));
assert!(presented(&h.app, "render-shots"));
assert!(super::production::reveal_render_library(&mut h.app));
h.frames(3);
assert!(presented(&h.app, "render-library"));
assert!(!presented(&h.app, "render-browser"));
assert!(std::sync::Arc::ptr_eq(&h.app.session.doc, &document));
}
#[test]
fn production_short_render_assistant_keeps_conversation_above_the_pinned_composer() {
let mut h = production_content_harness(false, true);
h.call("ui.resize", serde_json::json!({"width":1600,"height":720}));
h.call("ui.resize", serde_json::json!({"width":1600,"height":480}));
h.app.ui.tab = "RENDER".into();
for draft in [String::new(), "Retained panel draft\n".repeat(40)] {
h.app.agent_chat.input = draft.clone();
h.frames(8);
let conversation = crate::scenario::rect_of("agent_chat:conversation").unwrap();
let composer = crate::scenario::rect_of("agent_chat:composer_footer").unwrap();
assert!(conversation.height() < 64.0, "exercise the short viewport that formerly expanded over the composer");
assert!(conversation.height() < 64.0, "exercise the short viewport that formerly expanded over the composer: {conversation:?}");
assert!(
!conversation.is_positive() || conversation.bottom() <= composer.top() + 1.0,
"a short transcript viewport must not paint or scroll over the composer: draft bytes={}, conversation={conversation:?}, composer={composer:?}",
@@ -123,22 +123,19 @@ fn marker_moves_a_box_placed_by_its_centre_through_its_centre_fields() {
h.settle();
h.step(&json!({"call": "ui.editFeature", "params": {"feature": name}})).unwrap();
h.settle();
// Edit Feature reads the stored corner back; choosing Center keeps the box where it is.
// Edit Feature reads the stored centre back with Center chosen (#14487).
let at = h.at(&json!({"handle": "primitive:position"})).unwrap();
let expect = h.at(&json!({"world": [20, 15, 10]})).unwrap();
assert!((at[0] - expect[0]).hypot(at[1] - expect[1]) < 2.0, "the marker stands under the centre, on the bottom: {at:?} {expect:?}");
{
let d = h.app.dialog.as_mut().expect("a dialog");
d.extra.remove("corner");
d.extra.insert("center".into(), json!(["20 mm", "15 mm", "14 mm"]));
}
assert!(h.app.dialog.as_ref().is_some_and(|d| d.extra.contains_key("center") && !d.extra.contains_key("corner")));
drag(&mut h, [60.0, 35.0, 10.0]);
let d = h.app.dialog.as_ref().expect("the dialog is open");
assert!(typed(&h, "center").dist(Vec3::new(60.0, 35.0, 14.0)) < 1e-3, "the centre fields took the drop: {:?}", d.extra.get("center"));
ok(&mut h);
let f = h.app.session.doc.find_feature(&name).expect("the Box is still there");
let FeatureKind::Box { corner, .. } = &f.kind else { panic!("a Box: {:?}", f.kind) };
assert!(point(&h, corner).dist(Vec3::new(55.0, 32.0, 10.0)) < 1e-3, "moved: {:?}", point(&h, corner));
let FeatureKind::Box { corner, centred, .. } = &f.kind else { panic!("a Box: {:?}", f.kind) };
assert!(*centred, "still placed by its centre");
assert!(point(&h, corner).dist(Vec3::new(60.0, 35.0, 14.0)) < 1e-3, "moved: {:?}", point(&h, corner));
reopened_at(&mut h, &name, [60.0, 35.0, 10.0]);
}
+7
View File
@@ -1499,6 +1499,10 @@ preference_settings! {
/// How strongly a print face stands out in the viewport (#8429): the share of the section
/// colour in its tint, 0 to 1; its outline follows. 0 draws no highlight.
print_face_highlight: f32 = 0.2, PAGE_GENERAL, accept(0.0..=1.0);
/// Print faces show in the view at all times and are saved in the design, as before #11512.
/// Off (the default): they show only while the Send to Slicer window is open, a save leaves
/// them out, and a design that has them opens without them.
keep_print_faces: bool = false, PAGE_GENERAL;
/// The line about sending again replacing the slicer's file has been shown.
slicer_resend_hint_seen: bool = false;
/// The user has agreed to Open with EasyPrint uploading a model to Printables' servers.
@@ -2296,6 +2300,7 @@ pub fn apply(app: &mut SolveApp, ctx: &egui::Context) {
apply_network(&app.preferences);
// The features computed from now on are checked as the preference says.
app.session.set_validation(app.preferences.validation);
crate::print_face_ui::sync(&mut app.session, &app.preferences);
// The profiler records while the preference is on (switched when it changes; at start-up only
// when on, so SOLIDCRAFT_PROFILE=1 is not undone by the default).
let profile = app.preferences.profile_operations;
@@ -6759,6 +6764,7 @@ mod tests {
("Fold items", PAGE_GRAPHICS),
("Allow network locations in designs", PAGE_GENERAL),
("Print face highlight", PAGE_GENERAL),
("Keep print faces", PAGE_GENERAL),
];
let mut found = vec![Vec::new(); cases.len()];
let mut h = crate::scenario::Harness::new();
@@ -12701,6 +12707,7 @@ mod settings_search_tests {
("allow network locations", "Allow network locations in designs"),
("slicer that is already running", "Open files in a slicer that is already running"),
("print face highlight", "Print face highlight"),
("keep print faces", "Keep print faces"),
("interface language", "Interface language"),
] {
h.app.prefs_window.shell.filter = query.into();
+9
View File
@@ -128,6 +128,15 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
});
ui_kit::help_icon(ui, &tip);
});
ui.horizontal(|ui| {
let (keep, tip) = (app.tr("prefs.keep_print_faces").to_string(), app.tr("prefs.keep_print_faces_tip").to_string());
let width = (ui.available_width() - t.space_16 - ui.spacing().item_spacing.x).max(0.0);
ui.allocate_ui_with_layout(egui::vec2(width, t.control_height), egui::Layout::left_to_right(egui::Align::Center), |ui| {
let response = ui_kit::checkbox(ui, &keep, &mut app.preferences.keep_print_faces).backing_on_hover_text(&tip);
crate::scenario::publish_target("prefs:keep_print_faces", response.rect);
});
ui_kit::help_icon(ui, &tip);
});
ui_kit::settings_form(ui, labels, |ui| {
ui_kit::settings_row_help(ui, app.tr("prefs.print_face_highlight"), app.tr("prefs.print_face_highlight_tip"), |ui| {
ui_kit::settings_control_width(ui, t.space_24 * 10.0, |ui| {
@@ -752,6 +752,28 @@ fn editing_a_box_or_cylinder_shows_its_placement_and_keeps_it() {
assert_eq!(stored(&h, cylinder_id), base);
}
/// A box placed by its centre opens for editing with Center chosen and its centre in the fields,
/// and a new length grows it on both sides (#14487).
#[test]
fn editing_a_box_placed_by_its_centre_keeps_it_centred() {
let mut h = design("mm");
h.app.run("solid.box", json!({"length": 20, "width": 10, "height": 10, "center": [50, 0, 0]})).unwrap();
h.frames(2);
let id = h.app.session.doc.features[0].id;
h.app.edit_feature(id);
h.frames(3);
assert_shown(&h, &[("X", "50".into()), ("Y", "0".into()), ("Z", "0".into())]);
type_into(&mut h, "length", "30");
h.frames(2);
let sent = sent(&h);
let p = sent.get("params").unwrap_or(&sent);
assert!(p.get("center").is_some() && p.get("corner").is_none(), "{sent}");
ok(&mut h);
let made = bounds(&mut h.app.session);
assert_near(made[0].0, [35.0, -5.0, -5.0]);
assert_near(made[0].1, [65.0, 5.0, 5.0]);
}
#[test]
fn the_cylinder_dialog_takes_its_base_point() {
let mut h = design("mm");
+126
View File
@@ -7,6 +7,23 @@ use solidcraft_engine::Sel;
use crate::SolveApp;
/// Whether the view tints print faces now (#11512): while the Send to Slicer window is open, or
/// at all times with the "Keep print faces" preference on.
pub(crate) fn shown(app: &SolveApp) -> bool {
app.preferences.keep_print_faces || send_to_slicer_open(app)
}
/// Whether the Send to Slicer window (or Open with EasyPrint, which shares it) is open.
fn send_to_slicer_open(app: &SolveApp) -> bool {
app.dialog.as_ref().is_some_and(|d| matches!(&d.kind, crate::dialogs::Kind::SaveMesh(k) if k.sends()))
}
/// Hand the "Keep print faces" preference to `session`: whether a save keeps print faces and
/// an opened design keeps the ones it has.
pub(crate) fn sync(session: &mut solidcraft_engine::Session, prefs: &crate::prefs::Prefs) {
session.keep_print_faces = prefs.keep_print_faces;
}
/// Whether face `index` of `body` (as the viewport names it) is that body's print face now.
pub(crate) fn is_print_face(app: &SolveApp, body: &str, index: usize) -> bool {
let world = crate::open_meshing::session_world(&app.session);
@@ -101,4 +118,113 @@ mod tests {
assert!(crate::prefs::set(&mut app, &json!({"print_face_highlight": 1.5})).is_err());
assert!((app.preferences.print_face_highlight - 0.5).abs() < 1e-6);
}
/// What the view's highlight layer draws, in bytes: more when a print face is tinted.
fn drawn(h: &crate::scenario::Harness) -> usize {
let sc = crate::viewport::build_highlight(&h.app);
let blocks: usize = sc.blocks.iter().map(|b| b.bytes.vertices.len() + b.bytes.lines.len() + b.bytes.hidden.len()).sum();
sc.tris.len() + sc.lines.len() + sc.hidden.len() + sc.overlay.len() + blocks
}
/// A harness with Block, nothing selected, and Block's top face marked as its print face; and
/// what the highlight layer drew before the face was marked.
fn marked() -> (crate::scenario::Harness, usize) {
let mut h = crate::scenario::Harness::new();
h.app.home.open = false;
h.app.run("solid.box", json!({"length": 20, "width": 10, "height": 5, "corner": [0, 0, 0], "body_name": "Block"})).unwrap();
h.app.session.selection.clear();
h.settle();
let before = drawn(&h);
let top = Sel::Face { body: "Block".into(), index: 0, point: solidcore_record::Vec3::new(10.0, 5.0, 5.0) };
assert!(super::set(&mut h.app, "Block", Some(&top)));
h.app.session.selection.clear();
h.settle();
assert!(h.app.session.doc.print_faces.contains_key("Block"));
(h, before)
}
#[test]
fn the_print_face_is_tinted_only_while_send_to_slicer_is_open() {
let (mut h, before) = marked();
assert!(!h.app.preferences.keep_print_faces, "off by default");
assert!(!super::shown(&h.app), "no tint with the window closed");
assert_eq!(drawn(&h), before, "the closed window leaves the view as it was before the face was marked");
h.app.services.pick_program = Some(Box::new(|| Box::pin(async { None })));
h.app.start("file.send_to_slicer");
h.settle();
assert!(h.app.dialog.is_some(), "Send to Slicer is open");
assert!(super::shown(&h.app), "the window shows the print face");
assert!(drawn(&h) > before, "and the view tints it");
crate::dialogs::cancel(&mut h.app);
h.settle();
assert!(h.app.dialog.is_none());
assert!(!super::shown(&h.app), "closing the window hides the tint");
assert_eq!(drawn(&h), before);
assert!(h.app.session.doc.print_faces.contains_key("Block"), "the print face itself stays for this session");
}
#[test]
fn keep_print_faces_tints_the_print_face_at_all_times_as_before() {
let (mut h, before) = marked();
crate::prefs::set(&mut h.app, &json!({"keep_print_faces": true})).unwrap();
h.settle();
assert!(super::shown(&h.app));
assert!(drawn(&h) > before, "the window is closed, and the view tints the print face");
}
/// Save the design to a fresh file for `case` and open it again in the app; the file.
fn save_and_reopen(app: &mut crate::SolveApp, case: &str) -> String {
let dir = std::env::temp_dir().join(format!("solidcraft-print-face-ui-{case}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("block.solidcraft").to_string_lossy().into_owned();
app.run("file.save", json!({"path": path})).unwrap();
app.run("file.new", json!({})).unwrap();
app.run("doc.open", json!({"path": path})).unwrap();
path
}
#[test]
fn a_print_face_is_not_saved_unless_kept() {
let mut app = app();
select_face(&mut app, [20.0, 5.0, 2.5]);
app.start("body.print_face");
assert!(app.session.doc.print_faces.contains_key("Block"));
let path = save_and_reopen(&mut app, "default");
assert!(app.session.doc.print_faces.is_empty(), "the design opens without its print face");
assert_eq!(app.session.doc.features.len(), 1, "and with everything else");
// A design saved with a print face (by an older version, or with the preference on)
// opens without it, and the rest of it intact.
let mut older = solidcraft_engine::Session::default();
older.execute("doc.open", &json!({"path": path})).unwrap();
older.execute("body.print_face", &json!({"body": "Block", "point": [20, 5, 2.5]})).unwrap();
older.execute("file.save", &json!({"path": path})).unwrap();
app.run("doc.open", json!({"path": path})).unwrap();
assert!(app.session.doc.print_faces.is_empty());
assert!(!app.session.is_dirty());
assert_eq!(app.session.doc.features.len(), 1);
// With the preference on, the print face is saved and opened as before.
crate::prefs::set(&mut app, &json!({"keep_print_faces": true})).unwrap();
app.run("doc.open", json!({"path": path})).unwrap();
assert!(app.session.doc.print_faces.contains_key("Block"), "kept, an older design opens with its print face");
save_and_reopen(&mut app, "kept");
assert!(app.session.doc.print_faces.contains_key("Block"), "and a save keeps it");
}
#[test]
fn a_design_a_host_imports_comes_without_its_print_face_unless_kept() {
let mut app = app();
select_face(&mut app, [20.0, 5.0, 2.5]);
app.start("body.print_face");
// The design's text with its print face, as an older version or a kept save wrote it.
assert!(app.session.doc.print_faces.contains_key("Block"));
let text = String::from_utf8(solidcraft_engine::io::write_design(&app.session.doc)).unwrap();
crate::documents::import(&mut app, &json!({"text": text})).unwrap();
assert!(app.session.doc.print_faces.is_empty(), "imported without its print face");
assert!(!app.session.is_dirty(), "dropping it is not an unsaved change");
assert_eq!(app.session.doc.features.len(), 1, "and with everything else");
crate::prefs::set(&mut app, &json!({"keep_print_faces": true})).unwrap();
crate::documents::import(&mut app, &json!({"text": text})).unwrap();
assert!(app.session.doc.print_faces.contains_key("Block"), "kept, it comes with its print face");
}
}
+41 -12
View File
@@ -266,21 +266,50 @@ pub(in crate::render_workspace) fn remove(app: &mut SolveApp) {
}
}
pub(in crate::render_workspace) fn footer(app: &mut SolveApp, ui: &mut egui::Ui) {
ui.horizontal(|ui| {
let enabled = !app.session.selection.is_empty();
if ui_kit::icon_label_action(ui, app.tr("render.inspector_save_library"), Some("plus"), enabled, false)
.backing_on_hover_text(app.tr_label("Export design library..."))
.clicked()
{
pub(in crate::render_workspace) fn remove_is_current(app: &SolveApp) -> bool {
let view = &app.render_workspace.view.inspector.selection_appearance;
view.draft.is_some()
&& view.selection == app.session.selection
&& view.revision == Some(app.session.revision)
&& view.document.as_ref().is_some_and(|doc| Weak::ptr_eq(doc, &Arc::downgrade(&app.session.doc)))
}
pub(in crate::render_workspace) fn footer_stacked(app: &SolveApp, ui: &egui::Ui) -> bool {
ui_kit::icon_label_width(ui, app.tr("render.inspector_save_library"), Some("plus"), false)
+ ui.spacing().item_spacing.x
+ ui_kit::icon_label_width(ui, app.tr("render.inspector_reset"), None, false)
> ui.available_width()
}
pub(in crate::render_workspace) fn footer(app: &mut SolveApp, ui: &mut egui::Ui, stacked: Option<bool>) {
let enabled = !app.session.selection.is_empty();
let draw_save = |app: &mut SolveApp, ui: &mut egui::Ui| {
let label = app.tr("render.inspector_save_library");
let response = if stacked.is_some() && ui_kit::icon_label_width(ui, label, Some("plus"), false) > ui.available_width() {
ui_kit::icon_action(ui, label, "plus", enabled, false)
} else {
ui_kit::icon_label_action(ui, label, Some("plus"), enabled, false)
};
if response.backing_on_hover_text(app.tr_label("Export design library...")).clicked() {
app.start("library.export");
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui_kit::text_button(ui, app.tr("render.inspector_reset"), enabled).clicked() {
reset(app);
}
};
let draw_reset = |app: &mut SolveApp, ui: &mut egui::Ui| {
if ui_kit::text_button(ui, app.tr("render.inspector_reset"), enabled).clicked() {
reset(app);
}
};
if stacked == Some(true) {
ui.vertical(|ui| {
draw_save(app, ui);
draw_reset(app, ui);
});
});
} else {
ui.horizontal(|ui| {
draw_save(app, ui);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| draw_reset(app, ui));
});
}
}
/// Returns false only when the explicit-target fallback should be shown.
+125 -61
View File
@@ -5,11 +5,47 @@ use crate::{SolveApp, ui_kit};
use inspector::selection_appearance;
use serde_json::json;
fn owner_reviews(app: &mut SolveApp, ctx: &egui::Context) {
inspector::confirmation(app, ctx);
library::review::dialog(app, ctx);
library::review::confirmation(app, ctx);
}
/// The shared Render workspace owns reviews even when an originating card is hidden.
pub(crate) fn owner_overlays(app: &mut SolveApp, ctx: &egui::Context) {
owner_reviews(app, ctx);
environment::stage_confirmation(app, ctx);
}
pub(crate) fn shared_title(app: &SolveApp) -> (String, &'static str) {
let (mut title, icon) = title(app, &app.render_workspace.view.selection);
title.truncate(title.floor_char_boundary(256));
(title, icon)
}
pub(crate) fn shared_status(app: &SolveApp) -> Option<String> {
matches!(app.render_workspace.view.selection, RenderSelection::Object(_)).then(|| selection_appearance::scope_label(app).to_owned())
}
pub(crate) fn shared_remove_available(app: &SolveApp) -> bool {
matches!(app.render_workspace.view.selection, RenderSelection::Object(_)) && selection_appearance::remove_is_current(app)
}
pub(crate) fn shared_remove(app: &mut SolveApp) {
if shared_remove_available(app) {
selection_appearance::remove(app);
}
}
pub(crate) fn shared_body(app: &mut SolveApp, ui: &mut egui::Ui) {
setups::refresh_dirty(app);
setups::sync_revision(app);
let selection = app.render_workspace.view.selection.clone();
body(app, ui, &selection, true);
}
pub(crate) fn panel(app: &mut SolveApp, ui: &mut egui::Ui) {
// Reviews belong to the workspace, including when their originating editor closes.
inspector::confirmation(app, ui.ctx());
library::review::dialog(app, ui.ctx());
library::review::confirmation(app, ui.ctx());
owner_reviews(app, ui.ctx());
if app.render_workspace.view.inspector.hidden || app.render_workspace.view.selection != RenderSelection::Environment {
environment::stage_confirmation(app, ui.ctx());
}
@@ -20,7 +56,6 @@ pub(crate) fn panel(app: &mut SolveApp, ui: &mut egui::Ui) {
setups::refresh_dirty(app);
setups::sync_revision(app);
let selection = app.render_workspace.view.selection.clone();
let kind = kind(&selection);
let appearance = matches!(selection, RenderSelection::Object(_));
let (title, icon) = title(app, &selection);
let scope = selection_appearance::scope_label(app).to_owned();
@@ -62,50 +97,7 @@ pub(crate) fn panel(app: &mut SolveApp, ui: &mut egui::Ui) {
ui_kit::panel::header(ui, options.icon, options.title, || action.set(Some(ui_kit::InspectorAction::Close)));
}
},
|ui| {
if appearance {
ui_kit::Panel::editor_footer(ui, "render_appearance_footer", |ui| selection_appearance::footer(app, ui));
} else if selection == RenderSelection::Output {
ui_kit::Panel::pinned_footer(ui, "render_output_footer", 1, |ui| {
output_actions(app, ui);
crate::scenario::publish_rect("render:output-footer", ui.min_rect());
});
}
egui::ScrollArea::vertical()
.max_width(ui.available_width())
.id_salt(("render_inspector", kind, app.docs.active))
.auto_shrink([false, false])
.show(ui, |ui| {
let before = setups::draft(app).ok();
let rollback = app.render_workspace.view.clone();
let response = ui.scope(|ui| match &selection {
RenderSelection::Nothing => inspector::scene_controls(app, ui),
RenderSelection::Object(_) => {
selection_appearance::controls(app, ui);
}
RenderSelection::Wear => wear::controls(app, ui),
RenderSelection::Environment => environment::editor(app, ui),
RenderSelection::Lighting => light::lighting(app, ui),
RenderSelection::Light(id) => light::editor(app, ui, id),
RenderSelection::Shot(name) => shot::editor(app, ui, name),
RenderSelection::Output => inspector::output_editor(app, ui),
RenderSelection::Sampling => inspector::sampling_editor(app, ui),
RenderSelection::Effects => super::effects::output_controls(app, ui),
RenderSelection::Check => inspector::check_editor(app, ui),
});
if matches!(selection, RenderSelection::Output | RenderSelection::Sampling | RenderSelection::Effects | RenderSelection::Wear)
&& let Some(before) = before
&& let Err(error) = setups::persist_editor(app, &before)
{
// Refuse the numerical edit without undoing transient editor navigation.
let inspector = std::mem::take(&mut app.render_workspace.view.inspector);
app.render_workspace.view = rollback;
app.render_workspace.view.inspector = inspector;
app.set_status(error, true);
}
crate::scenario::publish_rect(&format!("render:editor:{kind}"), response.response.rect);
});
},
|ui| body(app, ui, &selection, false),
);
match action.get() {
Some(ui_kit::InspectorAction::Close) => {
@@ -130,25 +122,97 @@ pub(crate) fn panel(app: &mut SolveApp, ui: &mut egui::Ui) {
}
}
pub(super) fn output_actions(app: &mut SolveApp, ui: &mut egui::Ui) {
ui.horizontal_wrapped(|ui| {
for (id, label, target) in [
("render.start", app.tr("cmd.render_start").to_owned(), "render:output:render"),
(
"render.batch",
solidcraft_ui_text::format("render.batch_all_shots", &[("count", &app.session.doc.render.setups.len().to_string())]),
"render:output:all-shots",
),
] {
fn body(app: &mut SolveApp, ui: &mut egui::Ui, selection: &RenderSelection, shared: bool) {
let top = ui.cursor().top();
let available_height = ui.available_height();
if matches!(selection, RenderSelection::Object(_)) {
if shared {
let stacked = selection_appearance::footer_stacked(app, ui);
ui_kit::Panel::pinned_footer(ui, "render_appearance_footer", if stacked { 2 } else { 1 }, |ui| {
selection_appearance::footer(app, ui, Some(stacked));
});
} else {
ui_kit::Panel::editor_footer(ui, "render_appearance_footer", |ui| selection_appearance::footer(app, ui, None));
}
} else if *selection == RenderSelection::Output {
let labels = [
app.tr("cmd.render_start").to_owned(),
solidcraft_ui_text::format("render.batch_all_shots", &[("count", &app.session.doc.render.setups.len().to_string())]),
];
let stacked = shared
&& ui_kit::icon_label_width(ui, &labels[0], Some("play"), true)
+ ui.spacing().item_spacing.x
+ ui_kit::icon_label_width(ui, &labels[1], Some("play"), false)
> ui.available_width();
ui_kit::Panel::pinned_footer(ui, "render_output_footer", if stacked { 2 } else { 1 }, |ui| {
output_actions(app, ui, labels, shared, stacked);
crate::scenario::publish_rect("render:output-footer", ui.min_rect());
});
}
let footer = (available_height - ui.available_height()).max(0.0);
let kind = kind(selection);
let scroll = egui::ScrollArea::vertical()
.min_scrolled_height(if shared { 0.0 } else { 64.0 })
.max_width(ui.available_width())
.id_salt(("render_inspector", kind, app.docs.active))
.auto_shrink([false, false])
.show(ui, |ui| {
let before = setups::draft(app).ok();
let rollback = app.render_workspace.view.clone();
let response = ui.scope(|ui| match selection {
RenderSelection::Nothing => inspector::scene_controls(app, ui),
RenderSelection::Object(_) => {
selection_appearance::controls(app, ui);
}
RenderSelection::Wear => wear::controls(app, ui),
RenderSelection::Environment => environment::editor(app, ui),
RenderSelection::Lighting => light::lighting(app, ui),
RenderSelection::Light(id) => light::editor(app, ui, id),
RenderSelection::Shot(name) => shot::editor(app, ui, name),
RenderSelection::Output => inspector::output_editor(app, ui),
RenderSelection::Sampling => inspector::sampling_editor(app, ui),
RenderSelection::Effects => super::effects::output_controls(app, ui),
RenderSelection::Check => inspector::check_editor(app, ui),
});
if matches!(selection, RenderSelection::Output | RenderSelection::Sampling | RenderSelection::Effects | RenderSelection::Wear)
&& let Some(before) = before
&& let Err(error) = setups::persist_editor(app, &before)
{
// Refuse the numerical edit without undoing transient editor navigation.
let inspector = std::mem::take(&mut app.render_workspace.view.inspector);
app.render_workspace.view = rollback;
app.render_workspace.view.inspector = inspector;
app.set_status(error, true);
}
crate::scenario::publish_rect(&format!("render:editor:{kind}"), response.response.rect);
});
if shared {
crate::panel_runtime::report_content_extent(ui, top, scroll.content_size.y + footer, false);
}
}
fn output_actions(app: &mut SolveApp, ui: &mut egui::Ui, labels: [String; 2], shared: bool, stacked: bool) {
let draw = |ui: &mut egui::Ui| {
for ((id, target), label) in [("render.start", "render:output:render"), ("render.batch", "render:output:all-shots")].into_iter().zip(labels) {
let ready = super::ribbon::action_ready(app, id);
let response = ui_kit::icon_label_action(ui, &label, Some("play"), ready.is_ok(), id == "render.start");
let primary = id == "render.start";
let response = if shared && ui_kit::icon_label_width(ui, &label, Some("play"), primary) > ui.available_width() {
ui_kit::icon_action(ui, &label, "play", ready.is_ok(), false)
} else {
ui_kit::icon_label_action(ui, &label, Some("play"), ready.is_ok(), primary)
};
let response = if let Err(reason) = ready { response.backing_on_disabled_hover_text(reason) } else { response };
crate::scenario::publish_target(target, response.rect);
if response.clicked() {
app.start(id);
}
}
});
};
if stacked {
ui.vertical(draw);
} else {
ui.horizontal_wrapped(draw);
}
}
fn kind(selection: &RenderSelection) -> &'static str {
+4 -1
View File
@@ -21,7 +21,10 @@ pub(crate) fn compact(app: &SolveApp, width: f32) -> bool {
}
pub(crate) fn focus_shortcut(app: &mut SolveApp, ui: &mut egui::Ui) {
if crate::render_workspace::active(app) && !ui.ctx().text_edit_focused() && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Tab))
if crate::render_workspace::active(app)
&& !ui.ctx().text_edit_focused()
&& (!crate::panel_runtime::production_active(app) || !ui.ctx().egui_wants_keyboard_input())
&& ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Tab))
{
ui.ctx().memory_mut(|memory| memory.move_focus(egui::FocusDirection::None));
app.start("view.render_focus");
+36 -9
View File
@@ -46,10 +46,10 @@ mod environment;
#[path = "render_workspace_output.rs"]
mod inspector;
#[path = "render_ui/inspector.rs"]
mod inspector_panel;
pub(crate) mod inspector_panel;
#[path = "render_workspace_library.rs"]
mod library;
pub(crate) mod library;
#[path = "render_ui/assign.rs"]
pub(crate) mod assign;
@@ -390,7 +390,7 @@ pub enum RenderSelection {
/// Focus a Render editor without changing the shared modelling selection.
pub(crate) fn selection_editor(app: &mut SolveApp, selection: &RenderSelection) {
app.render_workspace.view.selection = selection.clone();
app.render_workspace.view.inspector.hidden = false;
reveal_inspector(app);
}
pub(crate) fn object_selection(app: &SolveApp) -> RenderSelection {
@@ -443,6 +443,8 @@ pub struct ViewState {
studio: studio::View,
materials: materials::View,
library: library::View,
pub(crate) library_focus_pending: bool,
pub(crate) inspector_visibility_request: Option<bool>,
inspector: inspector::View,
environment_ring: environment::RingView,
@@ -535,6 +537,8 @@ impl Default for ViewState {
studio: studio::View::default(),
materials: materials::View::default(),
library: library::View::default(),
library_focus_pending: false,
inspector_visibility_request: None,
inspector: inspector::View::default(),
environment_ring: environment::RingView::default(),
@@ -968,8 +972,9 @@ pub fn enter(app: &mut SolveApp, render: bool) -> Result<(), String> {
app.render_workspace.displayed.clear();
app.render_workspace.design_camera = Some((app.cam, app.ui.perspective));
app.ui.tab = "RENDER".into();
select_stage(app, app.render_workspace.view.stage.key());
if app.render_workspace.view.selection == RenderSelection::Nothing {
let reveal = !crate::panel_runtime::production_active(app);
apply_stage(app, app.render_workspace.view.stage.key(), reveal);
if reveal && app.render_workspace.view.selection == RenderSelection::Nothing {
selection_editor(app, &RenderSelection::Nothing);
}
}
@@ -1107,6 +1112,10 @@ pub(crate) fn settle_entry_fit(app: &mut SolveApp, source: Camera, bounds: solid
/// Stage navigation is transient per-document state. It never applies a resource or starts a job.
pub(crate) fn select_stage(app: &mut SolveApp, key: &str) {
apply_stage(app, key, true);
}
fn apply_stage(app: &mut SolveApp, key: &str, reveal: bool) {
let stage = match key {
"look" => Stage::Look,
"light" => Stage::Light,
@@ -1115,7 +1124,9 @@ pub(crate) fn select_stage(app: &mut SolveApp, key: &str) {
_ => return,
};
app.render_workspace.view.stage = stage;
app.render_workspace.view.inspector.hidden = false;
if reveal {
reveal_inspector(app);
}
let sections: &[&str] = match stage {
Stage::Look => &["objects"],
Stage::Light => &["lights", "environment"],
@@ -1182,6 +1193,21 @@ pub(crate) fn job_ready(app: &SolveApp, id: &str) -> Result<(), String> {
ribbon::action_ready(app, id)
}
pub(crate) fn reveal_inspector(app: &mut SolveApp) {
request_inspector_visibility(app, true);
}
pub(crate) fn request_inspector_visibility(app: &mut SolveApp, visible: bool) {
set_inspector_visible(app, visible);
if crate::panel_runtime::production_active(app) {
app.render_workspace.view.inspector_visibility_request = Some(visible);
}
}
pub(crate) fn inspector_visible(app: &SolveApp) -> bool {
!app.render_workspace.view.inspector.hidden
}
pub(crate) fn set_inspector_visible(app: &mut SolveApp, visible: bool) {
app.render_workspace.view.inspector.hidden = !visible;
}
@@ -1223,9 +1249,10 @@ pub(crate) fn browser_mode(app: &mut SolveApp, ui: &mut egui::Ui) -> bool {
if app.render_workspace.view.search_objects {
let mut query = app.render_workspace.view.object_query.clone();
crate::ui_kit::scope(ui, |ui| {
if crate::ui_kit::search_field(ui, ui.scope_id().with("render:tree:query"), &mut query, solidcraft_ui_text::tr("render.tree_search"))
.changed()
{
let response =
crate::ui_kit::search_field(ui, ui.scope_id().with("render:tree:query"), &mut query, solidcraft_ui_text::tr("render.tree_search"));
crate::panel_runtime::register_content_focus(ui.ctx(), &response);
if response.changed() {
let _ = app.run("view.render_browser", json!({"query":query}));
}
});
@@ -265,7 +265,9 @@ pub(super) fn editor(app: &mut SolveApp, ui: &mut egui::Ui) {
ground(app, ui);
atmosphere(app, ui);
});
stage_confirmation(app, ui.ctx());
if !crate::panel_runtime::shared_content_measurement(ui.ctx()) {
stage_confirmation(app, ui.ctx());
}
}
pub(super) fn open_sources(app: &mut SolveApp) {
+186 -105
View File
@@ -227,7 +227,7 @@ fn refresh_targets(app: &mut SolveApp, view: &mut View) -> Result<(), String> {
}
fn target_controls(app: &mut SolveApp, ui: &mut egui::Ui, view: &mut View) {
ui.label(app.tr("render.library_target"));
ui_kit::bounded_cell_editor(
let search = ui_kit::bounded_cell_editor(
ui,
ui.scope_id().with("render_library_target_search"),
&mut view.target_query,
@@ -235,6 +235,7 @@ fn target_controls(app: &mut SolveApp, ui: &mut egui::Ui, view: &mut View) {
false,
128,
);
crate::panel_runtime::register_content_focus(ui.ctx(), &search);
if ui_kit::button(ui, app.tr("render.material_refresh"), ui_kit::Button::Normal).clicked()
&& let Err(e) = refresh_targets(app, view)
{
@@ -332,9 +333,11 @@ pub(super) fn open_category(app: &mut SolveApp, kind: &str) {
"scenes" | "selene_shots" | "lighting" => 3,
_ => 0,
};
app.ui.show_browser = true;
let _ = app.run("view.render_browser", json!({"open":true}));
app.preferences.browser_collapsed = false;
if !crate::panel_runtime::reveal_render_library(app) {
app.ui.show_browser = true;
let _ = app.run("view.render_browser", json!({"open":true}));
app.preferences.browser_collapsed = false;
}
}
pub(super) fn choose(app: &mut SolveApp, view: &mut View, kind: &str, id: &str) -> Result<(), String> {
switch_kind(view, kind);
@@ -487,27 +490,9 @@ pub(super) fn card(app: &mut SolveApp, ui: &mut egui::Ui, bounds: egui::Rect) {
let mut side = app.preferences.render_browser_side.clone();
let mut width = app.preferences.render_browser_width;
let stage = app.render_workspace.view.stage;
if stage == super::Stage::Look {
let view = &mut app.render_workspace.view.library;
let compatible = if view.look_tab == 1 {
matches!(view.kind.as_str(), "surfaces" | "physical_profiles")
} else {
LOOK_KINDS.contains(&view.kind.as_str())
};
if !compatible {
let kind = if view.look_tab == 1 { "surfaces" } else { "appearances" };
switch_kind(view, kind);
view.collection = 0;
}
}
let title = app
.tr(if stage == super::Stage::Look && app.render_workspace.view.library.look_tab == 1 {
"render.stage_panels.surfaces_references"
} else {
stage_title(stage)
})
.to_string();
let mut mode = if app.render_workspace.view.library.look_tab == 1 { 2 } else { app.render_workspace.view.library.layout };
prepare_look(app);
let title = library_title(app);
let mut mode = library_mode(app).unwrap_or_default();
let mut actions = |ui: &mut egui::Ui| {
ui_kit::catalog_scope(ui, |ui| {
ui_kit::segmented_icons(
@@ -542,27 +527,10 @@ pub(super) fn card(app: &mut SolveApp, ui: &mut egui::Ui, bounds: egui::Rect) {
},
&mut side,
&mut width,
|ui| match stage {
super::Stage::Look => ui_kit::catalog_scope(ui, |ui| {
if app.render_workspace.view.library.look_tab == 0 {
browse_contents(app, ui, true);
} else {
surface_panel(app, ui);
}
}),
super::Stage::Light => light_panel(app, ui),
super::Stage::Frame => super::shot::presets(app, ui),
super::Stage::Output => super::inspector::size_presets(app, ui),
},
|ui| stage_contents(app, ui, stage),
);
let look_tab = usize::from(mode == 2);
if mode < 2 {
app.render_workspace.view.library.layout = mode;
}
if look_tab != app.render_workspace.view.library.look_tab {
switch_kind(&mut app.render_workspace.view.library, if look_tab == 0 { "appearances" } else { "surfaces" });
app.render_workspace.view.library.collection = 0;
app.render_workspace.view.library.look_tab = look_tab;
if stage == super::Stage::Look {
set_library_mode(app, mode);
}
app.preferences.render_browser_width = width;
let requested =
@@ -578,6 +546,85 @@ pub(super) fn card(app: &mut SolveApp, ui: &mut egui::Ui, bounds: egui::Rect) {
);
}
pub(crate) fn library_title(app: &SolveApp) -> String {
let stage = app.render_workspace.view.stage;
let key = if stage == super::Stage::Look && app.render_workspace.view.library.look_tab == 1 {
"render.stage_panels.surfaces_references"
} else {
stage_title(stage)
};
app.tr(key).to_string()
}
pub(crate) fn library_mode(app: &SolveApp) -> Option<usize> {
let view = &app.render_workspace.view.library;
(app.render_workspace.view.stage == super::Stage::Look).then_some(if view.look_tab == 1 { 2 } else { view.layout })
}
pub(crate) fn set_library_mode(app: &mut SolveApp, mode: usize) {
if mode > 2 || app.render_workspace.view.stage != super::Stage::Look {
return;
}
let view = &mut app.render_workspace.view.library;
let look_tab = usize::from(mode == 2);
if mode < 2 {
view.layout = mode;
}
if look_tab != view.look_tab {
switch_kind(view, if look_tab == 0 { "appearances" } else { "surfaces" });
view.collection = 0;
view.look_tab = look_tab;
}
}
fn prepare_look(app: &mut SolveApp) {
if app.render_workspace.view.stage != super::Stage::Look {
return;
}
let view = &mut app.render_workspace.view.library;
let compatible =
if view.look_tab == 1 { matches!(view.kind.as_str(), "surfaces" | "physical_profiles") } else { LOOK_KINDS.contains(&view.kind.as_str()) };
if !compatible {
let kind = if view.look_tab == 1 { "surfaces" } else { "appearances" };
switch_kind(view, kind);
view.collection = 0;
}
}
fn stage_contents(app: &mut SolveApp, ui: &mut egui::Ui, stage: super::Stage) {
match stage {
super::Stage::Look => ui_kit::catalog_scope(ui, |ui| {
if app.render_workspace.view.library.look_tab == 0 {
browse_contents(app, ui, true);
} else {
surface_panel(app, ui);
}
}),
super::Stage::Light => light_panel(app, ui),
super::Stage::Frame => super::shot::presets(app, ui),
super::Stage::Output => super::inspector::size_presets(app, ui),
}
}
/// The shared host owns placement and the header. The library owns its catalog and drafts.
pub(crate) fn shared_library(app: &mut SolveApp, ui: &mut egui::Ui) {
prepare_look(app);
let stage = app.render_workspace.view.stage;
ui_kit::scope(ui, |ui| {
if matches!(stage, super::Stage::Frame | super::Stage::Output) {
let top = ui.cursor().top();
let scroll = egui::ScrollArea::vertical()
.id_salt(("render_library_presets", stage.key()))
.min_scrolled_height(0.0)
.auto_shrink([false, true])
.show(ui, |ui| stage_contents(app, ui, stage));
crate::panel_runtime::report_content_extent(ui, top, scroll.content_size.y, false);
} else {
stage_contents(app, ui, stage);
}
});
}
fn stage_title(stage: super::Stage) -> &'static str {
match stage {
super::Stage::Look => "render.appearances",
@@ -606,6 +653,7 @@ const LIGHT_SCOPES: &[(&str, &str)] = &[
];
fn light_panel(app: &mut SolveApp, ui: &mut egui::Ui) {
let top = ui.cursor().top();
let mut view = std::mem::take(&mut app.render_workspace.view.library);
let previous = view.light_scope;
let labels: Vec<_> = LIGHT_SCOPES.iter().map(|(_, key)| app.tr(key)).collect();
@@ -632,6 +680,7 @@ fn light_panel(app: &mut SolveApp, ui: &mut egui::Ui) {
ui_kit::button(ui, app.tr("render.stage_panels.apply"), ui_kit::Button::Normal);
});
ui_kit::text(ui, app.tr("render.stage_panels.mine_pending"), ui_kit::Text::Note("render:mine:reason"));
crate::panel_runtime::report_content_extent(ui, top, ui.cursor().top() - top, false);
restore_view(app, view);
return;
}
@@ -639,7 +688,8 @@ fn light_panel(app: &mut SolveApp, ui: &mut egui::Ui) {
let reason =
if view.light_scope == 3 { Some(app.tr("render.library_specification_only").to_string()) } else { selected_light_reason(app, &view) };
let layout = ui_kit::CatalogLayout::new(ui, "render:light:catalog", 0);
layout.body(ui, |ui| {
let body_top = ui.cursor().top();
let body_height = layout.body(ui, |ui| {
let identities: Vec<_> = view.entries.iter().map(|e| format!("render:light:{}", e["id"].as_str().unwrap_or(""))).collect();
let tiles: Vec<_> = view
.entries
@@ -668,8 +718,9 @@ fn light_panel(app: &mut SolveApp, ui: &mut egui::Ui) {
{
ui_kit::text(ui, detail["resource"]["description"].as_str().unwrap_or(""), ui_kit::Text::Note("render:specification"));
}
ui.min_size().y
});
layout.footer(ui, |ui| {
catalog_footer(&layout, ui, body_top, body_height, |ui| {
catalog_pages(app, ui, &mut view);
if let Some(reason) = &reason {
ui_kit::text(ui, reason, ui_kit::Text::Note("render:light:reason"));
@@ -687,8 +738,21 @@ fn light_panel(app: &mut SolveApp, ui: &mut egui::Ui) {
restore_view(app, view);
}
fn catalog_footer(layout: &ui_kit::CatalogLayout, ui: &mut egui::Ui, body_top: f32, body_height: f32, draw: impl FnOnce(&mut egui::Ui)) {
// The catalog allocates a filling scroll body. Use its actual content height
// and the footer's content height for a sole side panel's preferred size.
let mut footer_height = 0.0;
layout.footer(ui, |ui| {
draw(ui);
footer_height = ui.min_size().y;
});
let gap = if footer_height > 0.0 { ui.spacing().item_spacing.y } else { 0.0 };
crate::panel_runtime::report_content_extent(ui, body_top, body_height + gap + footer_height, false);
}
fn catalog_search(app: &mut SolveApp, ui: &mut egui::Ui, view: &mut View, hint: &str) {
let search = ui_kit::search_field(ui, ui.scope_id().with("render_stage_catalog_search"), &mut view.query, app.tr(hint));
crate::panel_runtime::register_content_focus(ui.ctx(), &search);
crate::scenario::publish_target("render:library-search", search.rect);
let end = view.query.char_indices().nth(128).map_or(view.query.len(), |(at, _)| at);
view.query.truncate(end);
@@ -721,6 +785,7 @@ fn catalog_pages(app: &SolveApp, ui: &mut egui::Ui, view: &mut View) {
}
fn surface_panel(app: &mut SolveApp, ui: &mut egui::Ui) {
let mut view = std::mem::take(&mut app.render_workspace.view.library);
let shared = crate::panel_runtime::shared_content_measurement(ui.ctx());
let mut scope = usize::from(view.kind == "physical_profiles");
if ui_kit::segmented(
ui,
@@ -730,12 +795,16 @@ fn surface_panel(app: &mut SolveApp, ui: &mut egui::Ui) {
) {
switch_kind(&mut view, if scope == 0 { "surfaces" } else { "physical_profiles" });
}
if scope == 0 {
if scope == 0 && !shared {
surface_import::controls(app, ui, &mut view);
}
catalog_search(app, ui, &mut view, "render.stage_panels.search_surfaces");
let layout = ui_kit::CatalogLayout::new(ui, "render:surface:catalog", 3);
layout.body(ui, |ui| {
let body_top = ui.cursor().top();
let body_height = layout.body(ui, |ui| {
if scope == 0 && shared {
surface_import::controls(app, ui, &mut view);
}
for entry in view.entries.clone() {
let id = entry["id"].as_str().unwrap_or("");
let label = entry["name"].as_str().or_else(|| entry["resource"]["designation"].as_str()).unwrap_or(id);
@@ -751,8 +820,9 @@ fn surface_panel(app: &mut SolveApp, ui: &mut egui::Ui) {
}
}
}
ui.min_size().y
});
layout.footer(ui, |ui| {
catalog_footer(&layout, ui, body_top, body_height, |ui| {
catalog_pages(app, ui, &mut view);
if let Some(id) = view.selected.clone() {
if scope == 0 {
@@ -1025,6 +1095,7 @@ fn browse_contents(app: &mut SolveApp, ui: &mut egui::Ui, stage_card: bool) {
let hint = solidcraft_ui_text::format("render.search_appearances", &[("count", &count.to_string())]);
let query = if view.collection == 0 { &mut view.query } else { &mut view.collection_query };
let search = ui_kit::search_field(ui, ui.scope_id().with("render_library_search"), query, &hint);
crate::panel_runtime::register_content_focus(ui.ctx(), &search);
query.truncate(query.char_indices().nth(128).map_or(query.len(), |(at, _)| at));
crate::scenario::publish_target("render:library-search", search.rect);
if search.changed() {
@@ -1160,67 +1231,71 @@ fn browse_contents(app: &mut SolveApp, ui: &mut egui::Ui, stage_card: bool) {
let layout = ui_kit::CatalogLayout::new(ui, "render:library:catalog", 0);
let mut selected = None;
let mut dragged = None;
let body_top = ui.cursor().top();
// Keep each catalog/collection's independent scroll position.
ui.push_id((&view.kind, view.collection), |ui| {
layout.body(ui, |ui| {
if entries.is_empty() {
ui_kit::text(ui, app.tr("render.library_empty"), ui_kit::Text::Note("render.library_empty"));
}
if view.layout == 0 {
let tiles: Vec<_> = entries
.iter()
.zip(&identities)
.zip(&tips)
.map(|(((kind, id, entry), identity), tip)| ui_kit::SphereTile {
id: identity,
name: entry["name"].as_str().unwrap_or(id),
sphere: tile_sphere(entry),
selected: view.kind == *kind && view.selected.as_ref() == Some(id),
users: usage_for(entry, &used).map_or_else(|| user_count(entry), user_count),
tip,
badge: Some(tile_badge(app, entry)),
})
.collect();
if applicable_count > 0 {
ui_kit::text(ui, app.tr("render.library_applicable"), ui_kit::Text::Inline);
(selected, dragged) = ui_kit::sphere_grid_drag(ui, tiles.get(..applicable_count).unwrap_or_default());
let body_height = ui
.push_id((&view.kind, view.collection), |ui| {
layout.body(ui, |ui| {
if entries.is_empty() {
ui_kit::text(ui, app.tr("render.library_empty"), ui_kit::Text::Note("render.library_empty"));
}
if applicable_count < tiles.len() {
if view.layout == 0 {
let tiles: Vec<_> = entries
.iter()
.zip(&identities)
.zip(&tips)
.map(|(((kind, id, entry), identity), tip)| ui_kit::SphereTile {
id: identity,
name: entry["name"].as_str().unwrap_or(id),
sphere: tile_sphere(entry),
selected: view.kind == *kind && view.selected.as_ref() == Some(id),
users: usage_for(entry, &used).map_or_else(|| user_count(entry), user_count),
tip,
badge: Some(tile_badge(app, entry)),
})
.collect();
if applicable_count > 0 {
ui_kit::text(ui, app.tr("render.library_other"), ui_kit::Text::Inline);
}
let (other, other_drag) = ui_kit::sphere_grid_drag(ui, tiles.get(applicable_count..).unwrap_or_default());
selected = selected.or(other.map(|i| i + applicable_count));
dragged = dragged.or(other_drag.map(|i| i + applicable_count));
}
} else {
for (index, ((kind, id, entry), tip)) in entries.iter().zip(&tips).enumerate() {
if index == 0 && applicable_count > 0 {
ui_kit::text(ui, app.tr("render.library_applicable"), ui_kit::Text::Inline);
} else if index == applicable_count && applicable_count > 0 {
ui_kit::text(ui, app.tr("render.library_other"), ui_kit::Text::Inline);
(selected, dragged) = ui_kit::sphere_grid_drag(ui, tiles.get(..applicable_count).unwrap_or_default());
}
let mut item = ui_kit::MenuItem::new(id, entry["name"].as_str().unwrap_or(id), "appearance");
item.highlighted = view.kind == *kind && view.selected.as_ref() == Some(id);
let r = item.search_row(
ui,
query,
&format!(
"{} / {}",
if kind == "appearances" { entry["category"].as_str().unwrap_or(app.tr("render.library_in_design")) } else { id },
short_availability(app, entry.pointer("/projection/status").and_then(Value::as_str))
),
None,
);
crate::scenario::publish_target(identities.get(index).map(String::as_str).unwrap_or("render:library:item"), r.rect);
if r.backing_on_hover_text(tip).clicked() {
selected = Some(index);
if applicable_count < tiles.len() {
if applicable_count > 0 {
ui_kit::text(ui, app.tr("render.library_other"), ui_kit::Text::Inline);
}
let (other, other_drag) = ui_kit::sphere_grid_drag(ui, tiles.get(applicable_count..).unwrap_or_default());
selected = selected.or(other.map(|i| i + applicable_count));
dragged = dragged.or(other_drag.map(|i| i + applicable_count));
}
} else {
for (index, ((kind, id, entry), tip)) in entries.iter().zip(&tips).enumerate() {
if index == 0 && applicable_count > 0 {
ui_kit::text(ui, app.tr("render.library_applicable"), ui_kit::Text::Inline);
} else if index == applicable_count && applicable_count > 0 {
ui_kit::text(ui, app.tr("render.library_other"), ui_kit::Text::Inline);
}
let mut item = ui_kit::MenuItem::new(id, entry["name"].as_str().unwrap_or(id), "appearance");
item.highlighted = view.kind == *kind && view.selected.as_ref() == Some(id);
let r = item.search_row(
ui,
query,
&format!(
"{} / {}",
if kind == "appearances" { entry["category"].as_str().unwrap_or(app.tr("render.library_in_design")) } else { id },
short_availability(app, entry.pointer("/projection/status").and_then(Value::as_str))
),
None,
);
crate::scenario::publish_target(identities.get(index).map(String::as_str).unwrap_or("render:library:item"), r.rect);
if r.backing_on_hover_text(tip).clicked() {
selected = Some(index);
}
}
}
}
});
});
layout.footer(ui, |ui| {
ui.min_size().y
})
})
.inner;
catalog_footer(&layout, ui, body_top, body_height, |ui| {
if !stage_card {
let menu = ui_kit::dropdown_menu(ui, "render_library_filters", app.tr("render.library_filters"), |ui| {
if view.collection == 0 {
@@ -1679,7 +1754,8 @@ fn physical_controls(app: &mut SolveApp, ui: &mut egui::Ui, view: &mut View, id:
}
}
ui.label(app.tr("render.physical_material_name"));
ui_kit::bounded_cell_editor(ui, ui.scope_id().with("physical_reference_name"), &mut view.physical_name, "", false, 128);
let name = ui_kit::bounded_cell_editor(ui, ui.scope_id().with("physical_reference_name"), &mut view.physical_name, "", false, 128);
crate::panel_runtime::register_content_focus(ui.ctx(), &name);
if ui_kit::button(ui, app.tr("render.stage_panels.prepare_plan"), ui_kit::Button::Normal).clicked() {
let result =
app.run("render.library.physical.prepare", json!({"id":id,"name":view.physical_name,"acknowledge_reference":true})).and_then(|plan| {
@@ -1726,6 +1802,11 @@ fn availability_label<'a>(app: &'a SolveApp, status: Option<&str>) -> &'a str {
/// Focus the existing library card from the viewport Appearance menu.
pub(super) fn focus_library(app: &mut SolveApp) {
if crate::panel_runtime::reveal_render_library(app) {
let _ = app.run("view.render_stage", json!({"stage":"look"}));
let view = &mut app.render_workspace.view.library;
view.look_tab = 0;
}
let view = &mut app.render_workspace.view.library;
view.collection = 0;
switch_kind(view, "appearances");
+90 -54
View File
@@ -2,7 +2,12 @@
use crate::ui_kit::BackingTooltipExt as _;
use crate::{SolveApp, ui_kit};
use serde_json::json;
use solidcraft_engine::{doc::render_settings::RenderSettings, render::job::Phase};
use solidcraft_engine::{
doc::render_settings::RenderSettings,
panels::{Direction, Flow},
render::job::Phase,
};
use solidcraft_theme::Tokens;
pub(super) const SIZE_PRESETS: [(&str, u32, u32); 3] = [("HD", 1280, 720), ("Full HD", 1920, 1080), ("4K UHD", 3840, 2160)];
@@ -11,6 +16,14 @@ pub(super) fn camera_modified(app: &SolveApp, name: &str) -> bool {
}
pub(crate) fn contents(app: &mut SolveApp, ui: &mut egui::Ui) {
draw_contents(app, ui, None, true);
}
pub(crate) fn shared_shots(app: &mut SolveApp, ui: &mut egui::Ui, flow: Flow, inline: bool) {
draw_contents(app, ui, Some(flow), inline);
}
fn draw_contents(app: &mut SolveApp, ui: &mut egui::Ui, flow: Option<Flow>, inline: bool) {
super::thumbnails::refresh(app, ui.ctx());
let status = app.render_workspace.view.job.and_then(|job| app.session.render_job_handle(job).ok()).and_then(|handle| handle.status().ok());
let progress = status
@@ -44,67 +57,90 @@ pub(crate) fn contents(app: &mut SolveApp, ui: &mut egui::Ui) {
solidcraft_ui_text::format("render.batch_status", &[("running", &queue.running.to_string()), ("waiting", &queue.waiting.to_string())]);
let (mut hide, mut start, mut save, mut chosen, mut size) = (false, false, false, None, None);
let mut visible = Vec::new();
ui_kit::Panel::strip_contents(
ui,
|ui| {
let controls = |ui: &mut egui::Ui| {
if inline {
let response = ui_kit::strip_toggle(ui, true, solidcraft_ui_text::tr_label("Hide Shots strip"));
crate::scenario::publish_target("render:shots:hide", response.rect);
hide = response.clicked();
let response = ui_kit::completion_text(ui, &queue_label, false);
crate::scenario::publish_rect("render:shots:queue", response.rect);
let response = ui_kit::icon_label_action(ui, app.tr("cmd.render_start"), Some("play"), ready.is_ok(), true);
let response = if let Err(reason) = &ready { response.backing_on_disabled_hover_text(reason) } else { response };
crate::scenario::publish_target("render:shots:render", response.rect);
start = response.clicked();
let response = ui_kit::compact_dropdown_menu(ui, "render_shots_size", &size_label, |ui| {
for (label, width, height) in SIZE_PRESETS {
let text = format!("{label} · {width} × {height}");
let mark = super::inspector::size_mark(app, &text, Some((u64::from(width), u64::from(height))));
let mut response =
ui_kit::list_picker(ui, label, mark.as_deref().unwrap_or(&text), draft.width == width && draft.height == height);
if mark.is_some() {
response = response.backing_on_hover_text(super::inspector::size_limit_reason(app, width, height));
}
if response.clicked() {
size = Some((width, height));
ui.close();
}
}
let response = ui_kit::completion_text(ui, &queue_label, false);
crate::scenario::publish_rect("render:shots:queue", response.rect);
let response = ui_kit::icon_label_action(ui, app.tr("cmd.render_start"), Some("play"), ready.is_ok(), true);
let response = if let Err(reason) = &ready { response.backing_on_disabled_hover_text(reason) } else { response };
crate::panel_runtime::register_content_focus(ui.ctx(), &response);
crate::scenario::publish_target("render:shots:render", response.rect);
start = response.clicked();
let response = ui_kit::compact_dropdown_menu(ui, "render_shots_size", &size_label, |ui| {
for (label, width, height) in SIZE_PRESETS {
let text = format!("{label} · {width} × {height}");
let mark = super::inspector::size_mark(app, &text, Some((u64::from(width), u64::from(height))));
let mut response = ui_kit::list_picker(ui, label, mark.as_deref().unwrap_or(&text), draft.width == width && draft.height == height);
if mark.is_some() {
response = response.backing_on_hover_text(super::inspector::size_limit_reason(app, width, height));
}
});
crate::scenario::publish_target("render:shots:size", response.rect);
let response = ui_kit::completion_text(ui, &result_label, completed.is_some());
crate::scenario::publish_rect("render:shots:status", response.rect);
},
|ui| {
if response.clicked() {
size = Some((width, height));
ui.close();
}
}
});
crate::panel_runtime::register_content_focus(ui.ctx(), &response);
crate::scenario::publish_target("render:shots:size", response.rect);
let response = ui_kit::completion_text(ui, &result_label, completed.is_some());
crate::scenario::publish_rect("render:shots:status", response.rect);
};
let choices = |ui: &mut egui::Ui| {
if flow.is_none() {
let heading = ui_kit::StripHeading::new(ui.painter(), solidcraft_ui_text::tr_label("Shots"));
let response = heading.show(ui);
crate::scenario::publish_rect("render:shots:heading", response.rect);
let response = ui.add_enabled_ui(super::setups::snapshot(app).is_ok(), |ui| ui_kit::add_choice(ui, app.tr("render.shots_save"))).inner;
crate::scenario::publish_target("render:shots:save", response.rect);
save = response.clicked();
for name in app.session.doc.render.setups.keys() {
let active = name == &app.render_workspace.view.name;
let response = ui_kit::thumbnail_choice(
ui,
&format!("shot:{name}"),
name,
ui_kit::ThumbnailChoice {
texture: app.render_workspace.thumbnails.texture(name),
selected: active,
modified: active && camera_modified(app, name),
progress: if app.render_workspace.view.job_shot.as_ref() == Some(name) { progress } else { None },
},
);
crate::scenario::publish_target(&format!("render:shots:setup:{name}"), response.rect);
if visible.len() < super::thumbnails::LIMIT && response.rect.intersects(ui.clip_rect()) {
visible.push(name.clone());
}
if response.clicked() {
chosen = Some(name.clone());
}
}
let response = ui.add_enabled_ui(super::setups::snapshot(app).is_ok(), |ui| ui_kit::add_choice(ui, app.tr("render.shots_save"))).inner;
crate::panel_runtime::register_content_focus(ui.ctx(), &response);
crate::scenario::publish_target("render:shots:save", response.rect);
save = response.clicked();
for name in app.session.doc.render.setups.keys() {
let active = name == &app.render_workspace.view.name;
let response = ui_kit::thumbnail_choice(
ui,
&format!("shot:{name}"),
name,
ui_kit::ThumbnailChoice {
texture: app.render_workspace.thumbnails.texture(name),
selected: active,
modified: active && camera_modified(app, name),
progress: if app.render_workspace.view.job_shot.as_ref() == Some(name) { progress } else { None },
},
);
crate::panel_runtime::register_content_focus(ui.ctx(), &response);
crate::scenario::publish_target(&format!("render:shots:setup:{name}"), response.rect);
if visible.len() < super::thumbnails::LIMIT && response.rect.intersects(ui.clip_rect()) {
visible.push(name.clone());
}
},
);
if response.clicked() {
chosen = Some(name.clone());
}
}
};
if let Some(flow) = flow.filter(|_| !inline) {
let top = ui.cursor().top();
let rtl = crate::i18n::panel_direction(&app.preferences.language) == Direction::Rtl;
let horizontal = if rtl { egui::Layout::right_to_left(egui::Align::Center) } else { egui::Layout::left_to_right(egui::Align::Center) };
let horizontal = horizontal.with_main_wrap(true);
let choices_layout =
if flow == Flow::Wide { horizontal } else { egui::Layout::top_down(if rtl { egui::Align::Max } else { egui::Align::Min }) };
let tokens = Tokens::get();
let output =
egui::ScrollArea::vertical().id_salt("render_shots_contents").min_scrolled_height(0.0).auto_shrink([false, true]).show(ui, |ui| {
ui.spacing_mut().item_spacing.x = tokens.space_8;
let row = egui::vec2(ui.available_width(), tokens.control_height);
ui.allocate_ui_with_layout(row, horizontal, controls);
ui.allocate_ui_with_layout(row, choices_layout, choices);
});
crate::panel_runtime::report_content_extent(ui, top, output.content_size.y, false);
} else {
ui_kit::Panel::strip_contents(ui, controls, choices);
}
app.render_workspace.thumbnails.show(visible, ui.ctx());
if hide {
let _ = app.run("view.shots_strip", json!({"visible":false}));
@@ -250,7 +250,8 @@ pub(super) fn controls(app: &mut SolveApp, ui: &mut egui::Ui, view: &mut super::
("render.surface_import.license", &mut view.imported.license, 4096),
] {
ui_kit::row(ui, app.tr(key), |ui| {
ui_kit::bounded_cell_editor(ui, ui.scope_id().with(key), value, "", false, maximum);
let editor = ui_kit::bounded_cell_editor(ui, ui.scope_id().with(key), value, "", false, maximum);
crate::panel_runtime::register_content_focus(ui.ctx(), &editor);
});
}
ui_kit::row(ui, app.tr("render.stage_panels.plane"), |ui| {
+7
View File
@@ -31,6 +31,8 @@ pub fn start(app: &mut SolveApp, command: &'static str, params: Value) -> bool {
app.set_status(solidcraft_ui_text::format("ui.edit_in_place_save_waits", &[("name", &name)]), true);
return false;
}
// A save keeps print faces only when the user keeps them (#11512).
crate::print_face_ui::sync(&mut app.session, &app.preferences);
#[cfg(not(target_arch = "wasm32"))]
if app.saving.is_none() && app.file_picker.is_none() && !app.file_load_holds() {
let mut copy = app.session.scratch();
@@ -73,6 +75,11 @@ pub fn tick(app: &mut SolveApp, wait: bool) {
Some((_, Err(e))) => Err(e),
None => Err(format!("{command}: the save stopped")),
};
let outcome = match &r {
Ok(_) => crate::operations::Outcome::Done,
Err(e) => crate::operations::Outcome::Failed(crate::friendly_error(command, e)),
};
crate::operations::ended(app, &format!("save:{command}"), outcome);
app.finish_command(command, &r);
// Saving a newer SolidCraft's file keeps what this version can't read, unless it was changed
// around: then the save says what went.
@@ -674,6 +674,76 @@ fn a_line_starts_on_projected_geometry_and_in_construction_mode() {
}
}
/// The Line tool's first click starts a line on the edges a sketch projects from the face it
/// sits on: 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. Reported as #14796.
#[test]
fn a_line_starts_on_the_projected_edges_of_a_picked_extruded_face() {
use solidcraft_engine::sketch::{ConstraintKind, CurveKind, Shape};
for edge in 0..4 {
let mut h = Harness::new();
h.app.set_origin_shown(false);
// A rectangle drawn in a sketch and extruded: the extruded body has a cap face on top.
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
h.app.run("sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[40,40]})).unwrap();
h.app.run("sketch.finish", json!({})).unwrap();
h.app.run("solid.extrude", json!({"distance":10})).unwrap();
h.call("ui.view", json!({"view":"top"}));
h.call("ui.view", json!({"view":"fit"}));
h.settle();
// A new sketch picked on that face: the face's edges are projected into it.
h.step(&json!({"start":"sketch.create"})).unwrap();
h.step(&json!({"click":{"world":[20,20,10]}})).unwrap();
h.settle();
let state = h.app.session.world_state().unwrap();
let ss = state.sketch(h.app.session.active_sketch.unwrap()).unwrap();
let sketch = &ss.sketch;
let projected: Vec<usize> = (0..sketch.curves.len()).filter(|&c| sketch.curves[c].link.is_some()).collect();
assert_eq!(projected.len(), 4, "the picked face projects its four edges: {:?}", sketch.curves);
// The middle of every projected edge, in world coordinates, and their middle: the middle
// of the projected rectangle lies on no edge.
let middles: Vec<_> = projected
.iter()
.map(|&c| {
let (a, b) = match sketch.shape(c).unwrap() {
Shape::Line { a, b } => (a, b),
other => panic!("a projected edge is not a line: {other:?}"),
};
ss.plane.to_world((a + b) / 2.0)
})
.collect();
let start = middles[edge];
let n = middles.len() as f64;
let end = [
middles.iter().map(|v| v.x).sum::<f64>() / n,
middles.iter().map(|v| v.y).sum::<f64>() / n,
middles.iter().map(|v| v.z).sum::<f64>() / n,
];
let before = sketch.curves.len();
h.step(&json!({"start":"sketch.line"})).unwrap();
h.step(&json!({"click":{"world":[start.x, start.y, start.z]}})).unwrap();
h.step(&json!({"move":{"world":end}})).unwrap();
h.step(&json!({"click":{"world":end}})).unwrap();
h.settle();
let state = h.app.session.world_state().unwrap();
let ss = state.sketch(h.app.session.active_sketch.unwrap()).unwrap();
let sketch = &ss.sketch;
assert_eq!(sketch.curves.len(), before + 1, "edge {edge}: no line from the first click; {:?}", h.app.status);
let new = sketch.curves.last().unwrap();
let CurveKind::Line { a, .. } = new.kind else { panic!("edge {edge}: the new curve is not a line") };
let at = ss.plane.to_world(sketch.point(a).unwrap());
assert!(
(at.x - start.x).abs() < 1e-3 && (at.y - start.y).abs() < 1e-3 && (at.z - start.z).abs() < 1e-3,
"edge {edge}: the line starts at {at:?}, expected {start:?}"
);
let tied = sketch.constraints.iter().any(|c| match c.kind {
ConstraintKind::PointOnCurve { p, c } | ConstraintKind::Midpoint { p, l: c } => p == a && projected.contains(&c),
_ => false,
});
assert!(tied, "edge {edge}: the line's start is not tied to the projected edge it was started on: {:?}", sketch.constraints);
}
}
#[test]
fn a_line_snap_whose_source_curve_is_undone_ends_without_an_error() {
for start in [[-20.0, 10.0], [-2.5, 10.0]] {
+76 -7
View File
@@ -226,8 +226,36 @@ pub(crate) fn corner_rect(view: egui::Rect, position: crate::prefs::SpaceMouseGh
/// The device in a corner of the 3D view (`rect`, from `corner_rect`), see-through so it doesn't hide the design:
/// cap or trackball as `calibration` says, moving as the device does. A device without a
/// calibration is read as it reports: each axis as a share of the full deflection a reading is
/// scaled to (`FULL_SCALE`).
pub(crate) fn corner(ui: &mut egui::Ui, rect: egui::Rect, calibration: Option<&Calibration>, raw: [i16; 6]) {
/// scaled to (`FULL_SCALE`). The picture shows the device as the view reads it: each of the
/// view's axes shows the motion that drives it (`source` and the driver's `mapping`). The user's
/// own inversions are left to the view: they are a matter of taste, and the picture shows the
/// device, not the view.
pub(crate) fn corner(
ui: &mut egui::Ui,
rect: egui::Rect,
calibration: Option<&Calibration>,
raw: [i16; 6],
source: [usize; 6],
mapping: Option<crate::space_mouse::DriverMapping>,
) {
let (kind, travel, moving) = corner_reading(calibration, raw, source, mapping);
crate::scenario::publish_rect("space_mouse:corner", rect);
let mut corner = ui.new_child(egui::UiBuilder::new().id_salt("sc_space_mouse_corner").max_rect(rect));
corner.set_opacity(0.6);
let (travel, moving) = as_gestures(travel, moving);
draw(&corner, "corner", rect, kind, travel, moving);
}
/// What the corner picture shows for a reading: the kind of device, how far each of the view's
/// axes has moved, and which markers light. The axes are the view's own — pan sideways, zoom, pan
/// up and down, orbit up and down, orbit sideways and roll — so the picture and the view read the
/// device the same way.
fn corner_reading(
calibration: Option<&Calibration>,
raw: [i16; 6],
source: [usize; 6],
mapping: Option<crate::space_mouse::DriverMapping>,
) -> (Kind, [f32; 6], [bool; 6]) {
let (kind, travel, moving) = match calibration {
Some(calibration) => (calibration.kind, calibration.travel(raw), calibration.apply(raw).map(|value| value != 0)),
None => {
@@ -235,11 +263,52 @@ pub(crate) fn corner(ui: &mut egui::Ui, rect: egui::Rect, calibration: Option<&C
(Kind::ReturnToCenter, travel, travel.map(|value| value.abs() > 0.05))
}
};
let travel = still_unless_moving(kind, travel, moving);
crate::scenario::publish_rect("space_mouse:corner", rect);
let mut corner = ui.new_child(egui::UiBuilder::new().id_salt("sc_space_mouse_corner").max_rect(rect));
corner.set_opacity(0.6);
draw(&corner, "corner", rect, kind, travel, moving);
let (travel, moving) = by_view_axis(travel, moving, source, mapping);
(kind, still_unless_moving(kind, travel, moving), moving)
}
/// The reading as the view reads it: the rows are the device's own, each taking the motion it
/// reads (`source`), and the driver's mapping (`mapping`) sends each row to the axis it drives in
/// the view, so the picture shows every motion where the view uses it. The picture shows the
/// device, so neither the user's own inversions nor the driver's reversals are drawn: they are
/// what makes the view follow the cap, and the cap is what the picture shows. An assignment that
/// doesn't drive each motion once reads as the default, as the view does. A device with no mapping
/// yet is read as it reports.
fn by_view_axis(
travel: [f32; 6],
moving: [bool; 6],
source: [usize; 6],
mapping: Option<crate::space_mouse::DriverMapping>,
) -> ([f32; 6], [bool; 6]) {
let source = if crate::space_mouse::drives_each_once(source) { source } else { crate::space_mouse::SAME_AXES };
let mapping = mapping.unwrap_or_default();
let mut values = [0.0; 6];
let mut lights = [false; 6];
for (row, from) in source.iter().enumerate() {
let Some(rule) = mapping.axes.get(row).copied() else { continue };
if !rule.enabled {
continue;
}
let Some(value) = values.get_mut(rule.output) else { continue };
*value = travel.get(*from).copied().unwrap_or(0.0);
if let Some(light) = lights.get_mut(rule.output) {
*light = moving.get(*from).copied().unwrap_or(false);
}
}
(values, lights)
}
/// The view's axes as the cap's gestures: the view pans sideways, zooms and pans up and down
/// with the cap's slide, pull and press, and orbits up and down with the cap's tilt. The view
/// spins sideways with the cap's twist and rolls with the cap's tilt, so the last two swap.
fn as_gestures(travel: [f32; 6], moving: [bool; 6]) -> ([f32; 6], [bool; 6]) {
let mut gestures = [0.0; 6];
let mut lit = [false; 6];
for (axis, gesture) in [0usize, 1, 2, 3, 5, 4].into_iter().enumerate() {
gestures[gesture] = travel[axis];
lit[gesture] = moving[axis];
}
(gestures, lit)
}
/// A trackball's reading is a speed: within the dead zone it is noise, and rolling the picture by

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