Compare commits

...

21 Commits

Author SHA1 Message Date
bherbruck a06bdf5cfc Release 0.20.0
The tree is main at 030187452 exactly; the previous release commit is kept as a parent so release only moves forward.
2026-10-22 18:34:40 -04:00
bherbruck 030187452a Release 0.20.0 (#5118)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 17:40:01 -04:00
ralfkuh-lab 198fbeb814 i18n: every UI label reaches the catalog, on the UI kit (#2483)
On main, 120 label literals (95 distinct) can't be translated in any
locale: the English catalog doesn't reach them. Catalogue them in en,
ja, zh and de, translate indented row labels once for display and
measuring, look up the thread profile by its id, and give each
navigation preset's help, shown by the mouse mapping picker, a catalog
message.

A guard tokenizes the UI sources and checks every label literal of a
kit call, of context_menu::button and of tr_label/tr/SelInput against
the English catalog, resolving kit calls by their imports.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 21:08:05 +00:00
Adam Hickerson 186096cbe6 agent chat: agent detection finishes on the start page (#3265) 2026-10-22 20:52:13 +00:00
Colin Faulkingham 3b982ca371 Prevent deleted item IDs from being reused (#1016) (#3641)
* claim: #1016

* Prevent deleted design item IDs from being reused
2026-10-22 18:31:39 +00:00
Loohi aaa110218b Edit Offset Plane: keep a face base, refuse a plane on itself (#3567) (#3889)
* claim: #3567

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

* Edit Offset Plane: keep a face base, refuse a plane on itself (#3567)

Reopening an Offset Plane built on a face looked the face up at the
plane's origin, which can lie off the face, so the Plane input came up
empty. Look it up at the point picked on the face. A redefine that
builds a feature on itself is refused with a plain message instead of
failing with "plane offsets nested too deeply".

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 18:30:02 +00:00
raykholo 46f80b769f Sketch fillet and chamfer refuse an offset's corners instead of leaving circles (#2874) (#3860)
* claim: #2874

* Sketch fillet and chamfer refuse an offset's corners (#2874)

An analytic offset's output ends are recomputed from its source curves on
every solve and held fixed there. Filleting an output corner moved those
ends to the tangent points, the next refresh pulled them back to the sharp
corner, and the new arcs closed into full circles with the sketch left
unsolved.

Fillet and chamfer now refuse a corner on an offset output before anything
changes (a batch fillet applies all of its corners or none) and say how to
get the rounded result: round the source corners before offsetting, or
break the offset association first, after which the same corners round
normally.
2026-10-22 18:21:13 +00:00
raykholo 26ca82735b Circular Pattern and Revolve: a construction axis can be the axis (#2885, #4506) (#3796)
* claim: #2885

* Circular Pattern and Revolve: a construction axis can be the axis (#2885)

The dialogs send a picked axis by its name, but solid.pattern.circular
only knew X, Y and Z, and solid.revolve / surface.revolve read any other
name as a sketch line id. Picking a construction axis therefore failed.

- solid.pattern.circular takes a construction axis name, saves it as
  `axis_feature` and turns about the axis as evaluated, so the copies
  follow it when it is edited.
- AxisRef::Construction { name } for revolve and surface revolve,
  resolved at evaluation and checked to lie in the sketch plane.
- construct.plane.at_angle accepts a construction axis name (its fixed
  axis line is taken from the axis as it is now).
- Reopening these features shows the construction axis as the pick.
- Design format: pending/construction-axis-refs.rs.

* Construction axis references follow the axis in the feature's component frame (#4506)

- Revolve, Surface Revolve and Circular Pattern resolve a construction axis
  where it is evaluated, mapped into the feature's component frame.
- A line of the revolved sketch with the same id as a construction axis
  still wins, so existing scripts keep their meaning.
- Tests: the revolve keeps the reference through save and reopen and
  follows an edited axis (60π to 80π mm³); perpendicular, offset and
  profile-crossing axes are refused without changing the design.
2026-10-22 18:21:11 +00:00
SS-EN 408d30ea56 Joint drive moves parts without evaluating them (#3391) (#3397)
* Joint drive moves parts without evaluating them

A joint drive (also the Drive dialog and motion steps) changes only occurrence transforms, yet it
re-evaluated nearly the whole timeline: every feature that might read another component's
geometry (a sketch with any link, even to its own axis) hashed every occurrence's transform into
its fingerprint, so any motion invalidated it and everything after it, and no new pose was ever
in the cache.

- Placement digest: a feature now hashes only where the geometry it actually reads lies relative
  to its own frame (link sources, anchors, profiles and paths from other components, mirror
  placements; To Object and face/edge-picked planes read every component, relatively). Moving
  parts that keep those relative placements leaves every result in place: nothing is evaluated
  or re-meshed. In-context features whose referenced part really moved still rebuild.
- Evaluated ahead, applied once: `Ahead::evaluate` returns what it evaluated and
  `Session::prime` lets the window-thread pass take it (history commands and dialog OK), so an
  off-thread command no longer costs twice on designs past the cache's budget.
- Contact meshes are kept per body geometry across drives.

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

* Joint drive: booleans read only their input bodies' placements

A join, cut or intersect with named targets, and a Combine, read every
placement, so a joined part transferred to a component re-ran its boolean
at each new joint pose. Each now hashes only the placements of the bodies
it actually takes as inputs, resolved in its timeline input state (before
any later BodyTransfer), relative to the frame that evaluates it; Combine
reads its tools relative to its target. Unresolved and legacy component or
path reads keep the conservative dependency. 24 new joint_pose_ tests
cover new angles, transfers, targets, save/reopen, undo and real relative
motion (900 -> 800 mm3).

Supplement offered on #3397 by takuto-NA.

Co-Authored-By: takuto-NA <183465875+takuto-NA@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Creating a component recomputes nothing

A new, empty component changes nothing a feature reads, yet it changed
the placement fingerprint of every targeted boolean, Combine and To
Object feature, so `component.create` evaluated the timeline again:
- the first component of a design: `placement_digest` returned 0 while
  the design had no placements and a nonzero value once it had one;
- To / From Object and legacy face- or edge-picked planes hashed every
  component, not just those holding the bodies, sketches and
  construction geometry the feature can reach.

Co-Authored-By: Tyler Chiriboga <18270301+Tyler-Chiriboga@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Test: creating a component after a legacy face sketch recomputes nothing

A face extruded after its construction certificate is gone (a patterned
cut, a pad joined flush with it, then one more cut) is sketched on a
legacy face plane, which reads every placed body. A new, empty component
must not change what it reads either (reported by Tyler Chiriboga).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: takuto-NA <183465875+takuto-NA@users.noreply.github.com>
Co-authored-by: Tyler Chiriboga <18270301+Tyler-Chiriboga@users.noreply.github.com>
2026-10-22 18:21:08 +00:00
Jackson Oursland 75261cd797 Viewport: order-independent transparency for see-through surfaces (#4734) (#4903)
* Viewport: levels of detail for large meshes (#4724)

A large instanced mesh (20 k triangles or more) gets coarser copies, made
once on a worker (`mesh_lod`): a quarter, a sixteenth and a sixty-fourth of
its triangles, by quadric edge collapse (`geom::simplify`, after Garland and
Heckbert 1997; no fold-overs, no sheets joined, open boundaries kept), each
with its measured deviation from the full mesh. Each frame, as placements
are culled, each is drawn from the coarsest copy whose deviation stays
under 0.75 px where it stands (`placement_scale`, `level_for`). Until the
copies are made the mesh is drawn in full; the scene is built again when
they are. Display only: picking, measuring and exports keep the reference
mesh. Outside the kernel.

25 placements of a 2 M-triangle STL, 3600 x 2190 px, 4x MSAA, M2 Max
(`instanced_scene_gpu_benchmark`): 20.2 -> 1.2 ms a frame with all in
view, 21.9 -> 2.9 ms close up. The copies take 10 s to make, on a worker.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Viewport: see-through and faded parts drawn as instances (#4888)

See-through (an appearance's opacity) and faded (context around an active
component) bodies in one colour are drawn as instances like opaque ones,
their alpha in each placement's colour: one copy of the triangles, levels
of detail and culling, no per-placement copies built on the window thread.
Their placements are ordered back to front each frame (not their
triangles one by one) for devices that sort see-through surfaces.

The GPU benchmark gains `SOLIDCRAFT_BENCH_OPACITY` and the CPU share of
each frame, and its pictures each placement's own triangles too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Viewport: order-independent transparency for see-through surfaces (#4734)

The model's see-through surfaces (glass, faded context; instanced or not)
were sorted back to front triangle by triangle on the window thread
whenever the view turned. They are now drawn with weighted blended order-
independent transparency (McGuire and Bavoil 2013): when there are any,
the frame is drawn into the renderer's own textures (as supersampling
does), the see-through surfaces gathered into a weighted colour and a
revealage target against the frame's depth, laid over the frame in one
full-screen pass, then the overlays drawn over them. Nothing is sorted.
A device that cannot blend into float targets keeps the sorted way.

25 placements of a 2 M-triangle STL at opacity 0.5, M2 Max, 3600 x 2190 px:
about 10 s a frame sorted, 21.6 ms (17.2 ms instanced, 2.8 ms with levels
of detail).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 16:17:39 +00:00
Tyler Chiriboga 6f5d0038ab 3D mouse: find devices on Windows without asking every HID device for its strings (#2614) (#3653)
* claim: #2614 (3D mouse: find devices on Windows without asking every HID device for its strings)

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

* 3D mouse: find devices on Windows without asking every HID device for its strings (#2614)

On Windows the 3D mouse reader rescanned every two seconds while no 3D
mouse was plugged in, with hidapi's refresh_devices(). hidapi's Windows
enumeration opens every HID interface and reads its serial number,
manufacturer and product strings: on USB, three control requests to
every keyboard, mouse and touchscreen, forever, on a machine without a
3D mouse. That fits the report of an idle machine whose USB keyboard
drops off and returns and whose cursor jumps.

Windows now asks Plug and Play which present HID interfaces are
multi-axis controllers (hardware ID HID_DEVICE_UP:0001_U:0008), which
sends nothing to any device, and opens only those with open_path().
hidapi's own discovery is turned off before the reader makes its one
context, so making it touches no device either. macOS keeps hidapi's
enumeration (IOKit's registry); Linux is unchanged.

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

* AGENTS.md: list the Plug and Play scan among the files allowed unsafe code (#2614)

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

---------

Co-authored-by: Tyler Chiriboga <18270301+Tyler-Chiriboga@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-22 15:44:27 +00:00
bherbruck 1814adb06a Leaving a sketch keeps the camera where it is (#4909)
Finish Sketch, double Esc (including a blank sketch discarded), and Cancel no longer
restore the pre-sketch view. Entering a sketch still turns the view to face the plane.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 11:04:04 -04:00
Nathan Dane 971b28c982 claim: preferences redesign (#4608) 2026-10-22 13:53:36 +00:00
Nathan Dane f79cbc216c Redesign the home page as the File view (#2027) (#3822)
* claim: #2027

* Redesign the home page as the File view (#2027)

* Match the File view with the accepted component design (#2027)

* Restore the Start subtitle and simplify the recovery icon (#2027)

* Integrate File view with main's shared UI containers
2026-10-22 12:19:09 +00:00
Jackson Oursland 4dc1828138 Imported meshes: share their triangles between document copies (#4673) (#4675)
Every edit clones the document for undo, and `MeshData`'s positions and
triangles (plain `Vec`s) were copied with it: ~165 MB a command for a 2 M-
triangle STL. They are now `Arc<[f64]>` and `Arc<[u32]>`, shared by clones;
the few places that rewrite them build new arrays. Saved designs are
unchanged (an `Arc<[T]>` serializes as the array).

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 12:06:44 +00:00
Jackson Oursland f843a3f83c Picking: remove the GPU pick buffer, the per-body BVHs answer alone (#4665) (#4666)
The pick buffer drew the model's surfaces and edge ids for every still
view to narrow which edges a pick tests. Measured against the BVHs alone
(release, M2 Max, 1400 x 900 view, 600 cursor positions), a pick costs
2-51 us through the BVHs and the tile saved 0-4 us of it, while each pass
cost 0.7-12.5 ms of GPU time per still view, plus gathering its scene on
the window thread, read-backs a frame or two late and repaints to wait for
them (and, before, a copy of every triangle, which made it refuse models
past 6 M triangles). Picks are unchanged: the BVHs give the same answers.

`pick_timing_on_curved_models` gains eight layers of bodies seen from above
(the pick buffer's best case) and `SOLIDCRAFT_BENCH_MESH` placements.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 12:03:37 +00:00
Jackson Oursland f0fd5c10b7 Viewport: keep a face's hover highlight while the pointer moves across it (#4668) (#4669)
The highlight's key hashed the hovered item with its point under the
pointer, so every move across a face built and uploaded its highlight
again: for an imported mesh (one face) all its triangles, every frame.
The key now leaves out a face's and a plane's point, which the highlight
does not draw; an edge's middle stays in (it picks the part lit).

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 12:03:33 +00:00
Jackson Oursland 981d23d1d2 Viewport: weld a large mesh's shared vertices quicker, and ahead of its first frame (#4679) (#4680)
`SharedTris::new` used the standard SipHash hasher and collected each
triangle's corners into a Vec: 451 ms for a 2 M-triangle STL on the window
thread. A quick hasher for the coordinates' bits and a fixed array make it
278 ms, and the background mesher that meshes an opened or inserted
file's bodies now welds their large meshes too (`warm_shared_tris`), so the
first scene that shows them takes the shared vertices ready-made.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 12:03:28 +00:00
Jackson Oursland 01648da72d Ribbon: work out each tab's panel lists once (#4658) (#4659)
`toolbar_custom::panel_lists` is asked for every frame and matched every
registered command against every section by string each time (~337 us a
call for MODEL, several calls a frame). The registry and the ribbon layout
never change while the app runs, so each tab's lists are kept: 0.2 us a call.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 12:03:24 +00:00
Jackson Oursland 3d3dd27439 Viewport: leave out placements of an instanced mesh that are out of view (#4646) (#4647)
* claim: #4616

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Viewport: draw a part placed many times from one copy of its triangles (#4616)

A part placed by many occurrences was drawn, framed and picked from a moved
copy of its mesh per placement: on the CPU (the moved mesh and its scene
bytes) and on the GPU (its vertices uploaded once per placement).

- The model scene draws bodies that share a mesh as GPU instances: the
  shared triangles once, a rigid move and colour per placement
  (`InstancedMesh`, `vs_tri_inst`), in the solid, section and AO passes.
  `GpuScene::merged` expands them, so scene tests see world triangles.
- `ModelBody::mesh_in_place`, `plan_mesh_in_place_at`, `mesh_bounds` and
  `display_identity` give the shared mesh, its placement, the exact box and
  an identity without making the moved copy.
- Display refinement's body ids, Fit/Home bounds, the viewport's body boxes,
  the background mesher and picking use them (picking moves the ray into
  the part's frame and shares one pick index).

25 placements of a 2 M-triangle STL on an M2 Max: orbit 45 -> 37 ms a
frame, footprint 41 -> 16 GB, 24 copies 39 -> 18 s.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Viewport: draw large meshes from shared, indexed vertices (#4637)

An instanced mesh's vertices are shared between its triangles and drawn
indexed (`SharedTris`). A flat-faceted mesh (an STL's facets) shares them by
position alone and lights each fragment with its triangle's own plane
(`vs_tri_inst_flat`, `fs_*_flat`), taken from the fragment's position
relative to the eye so it stays exact at any zoom; its vertex outputs are
kept few, since a tiled GPU stores every vertex's outputs. A body alone is
drawn this way from 50 k triangles. A mesh too large for one buffer falls
back to unshared vertices.

`instanced_scene_gpu_benchmark` (manual, ignored): 25 placements of a 2 M-
triangle STL at 3600 x 2190 px, 4x MSAA, M2 Max: 21.3 ms a frame for the
placements' own triangles, 16.1 ms shared and indexed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Viewport: leave out placements of an instanced mesh that are out of view (#4646)

Each frame `Batches::cull` keeps the placements whose box may be seen
(`placed_box_in_view`: all eight corners past one side of the view in clip
space, so it holds behind the eye too) and writes them again only when
that set changes. The manual benchmark gains `SOLIDCRAFT_BENCH_CLOSE=1`:
close up on the middle of 25 placements of a 2 M-triangle STL (13 in
view), 3600 x 2190 px, 4x MSAA, M2 Max: 28.2 ms a frame for the
placements' own triangles, 17.2 ms instanced and culled.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 04:04:59 +00:00
bherbruck 6c60f1a9ed web-offline-test: hold responses until the test lets them go (#4620)
The slow steps waited on the clock (6 s sleeps, a 1 s timer, a 30 s cap), so on a busy
runner a launch could outlast them and time out at 240 s. The server now holds a response
until the test releases it, and the test waits on events:

- overlapping downloads: build v5's own files are held; files both builds share (index.html,
  names no build changes) are served, since the browser queues a request behind an in-flight
  one for the same URL. The held files are released once v6 has started.
- fail-fast step: the other files wait until the test has seen them asked for.
- update in another tab: the other tab updates once the launch is waiting for build.json, and
  the browser's own sw.js check waits for the same moment.
- hung requests no longer give up after 20 s.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 21:51:01 -04:00
173 changed files with 13883 additions and 2927 deletions
+4 -2
View File
@@ -33,9 +33,11 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
and calls through its function pointers), two functions in
`apps/solidcraft/src/open_documents.rs` (macOS hands Finder files only to an Objective-C
method), the Windows-only `apps/solidcraft/src/native_crash.rs` (the exception filter that
records crashes inside native code such as graphics drivers) and the Windows-only
records crashes inside native code such as graphics drivers), the Windows-only
`apps/solidcraft/src/native_frame.rs` (the window procedure that answers the resize-border hit
test of the window without OS decorations).
test of the window without OS decorations) and the Windows-only
`apps/solidcraft/src/space_mouse/windows_hid.rs` (asks Plug and Play which HID interfaces are 3D
mice, so no other device is sent anything).
- **Errors are `Result<T, E>`** through the crate's error type and `?`. An unfinished feature
returns an error ("not supported yet: …"); it never panics.
- **Input-derived numbers are hostile** (command parameters, design files, control-channel
+6
View File
@@ -196,6 +196,11 @@ Editable drawing code, native projects and shipped exports are collected under `
| assets/icons/ui/design/svg/flat/redo.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/save.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/settings.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/settings_keyboard.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/settings_monitor.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/settings_mouse.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/settings_palette.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/settings_plug.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/spin.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/style.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/undo.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
@@ -336,6 +341,7 @@ Editable drawing code, native projects and shipped exports are collected under `
| assets/icons/ui/design/svg/flat/send.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/stop.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/shield.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/status_recovery.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/placeholder.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/plane.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/plane_angle.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
Generated
+18 -17
View File
@@ -4162,7 +4162,7 @@ dependencies = [
[[package]]
name = "solidcraft"
version = "0.19.0"
version = "0.20.0"
dependencies = [
"block2 0.6.2",
"eframe",
@@ -4193,11 +4193,11 @@ dependencies = [
[[package]]
name = "solidcraft-3dx-client"
version = "0.19.0"
version = "0.20.0"
[[package]]
name = "solidcraft-cli"
version = "0.19.0"
version = "0.20.0"
dependencies = [
"image",
"log",
@@ -4209,7 +4209,7 @@ dependencies = [
[[package]]
name = "solidcraft-derive"
version = "0.19.0"
version = "0.20.0"
dependencies = [
"proc-macro2",
"quote",
@@ -4218,7 +4218,7 @@ dependencies = [
[[package]]
name = "solidcraft-doc"
version = "0.19.0"
version = "0.20.0"
dependencies = [
"log",
"serde",
@@ -4235,7 +4235,7 @@ dependencies = [
[[package]]
name = "solidcraft-engine"
version = "0.19.0"
version = "0.20.0"
dependencies = [
"log",
"schemars",
@@ -4254,7 +4254,7 @@ dependencies = [
[[package]]
name = "solidcraft-geom"
version = "0.19.0"
version = "0.20.0"
dependencies = [
"serde",
"serde_json",
@@ -4263,7 +4263,7 @@ dependencies = [
[[package]]
name = "solidcraft-gpu"
version = "0.19.0"
version = "0.20.0"
dependencies = [
"log",
"naga",
@@ -4280,7 +4280,7 @@ dependencies = [
[[package]]
name = "solidcraft-io"
version = "0.19.0"
version = "0.20.0"
dependencies = [
"cadmpeg-core",
"cadmpeg-ir",
@@ -4303,7 +4303,7 @@ dependencies = [
[[package]]
name = "solidcraft-kernel"
version = "0.19.0"
version = "0.20.0"
dependencies = [
"log",
"proptest",
@@ -4323,7 +4323,7 @@ dependencies = [
[[package]]
name = "solidcraft-mcp"
version = "0.19.0"
version = "0.20.0"
dependencies = [
"serde_json",
"solidcraft-engine",
@@ -4332,7 +4332,7 @@ dependencies = [
[[package]]
name = "solidcraft-render"
version = "0.19.0"
version = "0.20.0"
dependencies = [
"image",
"moxcms",
@@ -4346,7 +4346,7 @@ dependencies = [
[[package]]
name = "solidcraft-sketch"
version = "0.19.0"
version = "0.20.0"
dependencies = [
"serde",
"serde_json",
@@ -4357,14 +4357,14 @@ dependencies = [
[[package]]
name = "solidcraft-space-mouse"
version = "0.19.0"
version = "0.20.0"
dependencies = [
"serde",
]
[[package]]
name = "solidcraft-ui-egui"
version = "0.19.0"
version = "0.20.0"
dependencies = [
"arboard",
"egui",
@@ -4373,6 +4373,7 @@ dependencies = [
"log",
"minisign-verify",
"pollster 0.4.0",
"proc-macro2",
"semver",
"serde",
"serde_json",
@@ -4391,7 +4392,7 @@ dependencies = [
[[package]]
name = "solidcraft-web"
version = "0.19.0"
version = "0.20.0"
dependencies = [
"eframe",
"egui",
@@ -5921,7 +5922,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.19.0"
version = "0.20.0"
dependencies = [
"proc-macro2",
"serde_json",
+1 -1
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
exclude = ["vendor"]
[workspace.package]
version = "0.19.0"
version = "0.20.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
+5
View File
@@ -70,6 +70,11 @@ pointer movement orbits without a mouse button, and vertical scrolling zooms. Ot
retain their saved two-finger scroll preference; camera motion is available through
`view.navigate` and modifier-aware `ui.move` automation.
UI redesign (#2016): the File/start view (#2027) uses shared light/dark navigation,
action tiles, recent rows and sample cards, with recovery review and Save As/Export
entry points. Document loading, sample caches and recovery safety retain their
existing command paths.
M11 precursor: File → Export Current View as DXF exports shown placed bodies and sketches from the current
camera as a 2D millimetre drawing, with hidden edges omitted or dashed. Vector occlusion splits
partly hidden edges and includes curved silhouettes. Repeated part meshes and triangle
+4
View File
@@ -50,7 +50,11 @@ hidapi = { version = "2.6.7", default-features = false, features = ["windows-nat
raw-window-handle = "0.6"
# Native crash capture (src/native_crash.rs): the exception filter, the faulting module, a minidump.
# The window's resize border and DPI size (src/native_frame.rs): the window procedure, hit test, DPI.
# Finding 3D mice through Plug and Play without asking other devices anything (src/space_mouse/windows_hid.rs).
windows-sys = { version = "0.61", features = [
"Win32_Devices_DeviceAndDriverInstallation",
"Win32_Devices_HumanInterfaceDevice",
"Win32_Devices_Properties",
"Win32_Foundation",
"Win32_Graphics_Gdi",
"Win32_Security",
+48 -9
View File
@@ -7,7 +7,11 @@
mod hidraw;
#[cfg(target_os = "linux")]
mod spacenavd;
#[cfg(target_os = "windows")]
mod windows_hid;
#[cfg(any(target_os = "windows", target_os = "macos"))]
use std::ffi::CString;
#[cfg(target_os = "linux")]
use std::sync::atomic::AtomicU8;
use std::sync::atomic::{AtomicBool, Ordering};
@@ -15,8 +19,10 @@ use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use egui::Context;
#[cfg(target_os = "macos")]
use hidapi::DeviceInfo;
#[cfg(any(target_os = "windows", target_os = "macos"))]
use hidapi::{DeviceInfo, HidApi};
use hidapi::HidApi;
use quick_xml::Reader;
use quick_xml::events::Event;
use solidcraft_space_mouse::Layout;
@@ -256,8 +262,9 @@ fn at_rest(calibration: Option<Calibration>) -> Axes {
}
/// The Generic Desktop "Multi-Axis Controller" usage names a 3D mouse's motion interface, whoever
/// made it, and excludes ordinary mice and the keyboard interfaces of 3D mice.
#[cfg(any(target_os = "windows", target_os = "macos"))]
/// made it, and excludes ordinary mice and the keyboard interfaces of 3D mice. (Windows asks Plug
/// and Play for the same usage: `windows_hid`.)
#[cfg(target_os = "macos")]
fn is_3d_mouse(info: &DeviceInfo) -> bool {
info.usage_page() == 0x01 && info.usage() == 0x08
}
@@ -317,18 +324,48 @@ fn reset(ctx: &Context, shared: &Mutex<Reading>) {
ctx.request_repaint();
}
/// The HID interfaces that are 3D mice, by path. Windows asks Plug and Play, which sends nothing to
/// the devices: hidapi's enumeration would ask every keyboard and mouse for its strings on each scan
/// (#2614, `windows_hid`).
#[cfg(target_os = "windows")]
fn candidates(_api: &mut HidApi) -> Vec<CString> {
windows_hid::multi_axis_controllers()
}
/// The HID interfaces that are 3D mice, by path, from hidapi's enumeration (IOKit's registry).
#[cfg(target_os = "macos")]
fn candidates(api: &mut HidApi) -> Vec<CString> {
if let Err(error) = api.refresh_devices() {
log::warn!("3D mouse: device scan failed: {error}");
return Vec::new();
}
api.device_list().filter(|info| is_3d_mouse(info)).map(|info| info.path().to_owned()).collect()
}
/// The HID context. On Windows hidapi's own device discovery is off, so making it asks no device
/// anything either (`candidates` finds the 3D mice). Turning discovery off panics only once a
/// context with discovery exists; the reader is the one place the app makes a context, once.
#[cfg(target_os = "windows")]
fn hid_api() -> hidapi::HidResult<HidApi> {
HidApi::disable_device_discovery();
HidApi::new()
}
#[cfg(target_os = "macos")]
fn hid_api() -> hidapi::HidResult<HidApi> {
HidApi::new()
}
#[cfg(any(target_os = "windows", target_os = "macos"))]
fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
let Ok(mut api) = HidApi::new() else {
let Ok(mut api) = hid_api() else {
log::warn!("3D mouse: HID initialization failed");
return;
};
loop {
if let Err(error) = api.refresh_devices() {
log::warn!("3D mouse: device scan failed: {error}");
} else if let Some((device, layout, ids)) = api.device_list().filter(|info| is_3d_mouse(info)).find_map(|info| {
let device = info
.open_device(&api)
if let Some((device, layout, ids)) = candidates(&mut api).into_iter().find_map(|path| {
let device = api
.open_path(&path)
.inspect_err(|error| {
// On macOS: missing Input Monitoring permission, or the 3DxWare helper holding the device.
// The driver having the device is the reason it cannot be opened, not a problem.
@@ -337,6 +374,8 @@ fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
}
})
.ok()?;
// Read when the device was opened, from this device alone.
let info = device.get_device_info().ok()?;
let layout = device_layout(&device, info.vendor_id())?;
Some((device, layout, (info.vendor_id(), info.product_id())))
}) {
@@ -0,0 +1,172 @@
//! Windows: finding 3D mice without asking any other device anything (#2614).
//!
//! hidapi's enumeration opens every HID interface and reads its serial number, manufacturer and
//! product strings: on USB, three control requests to every keyboard, mouse and touchscreen. The
//! reader repeated it every two seconds while no 3D mouse was plugged in, and on some machines the
//! keyboard then kept dropping off the bus and the cursor jumped. Plug and Play already names each
//! interface's top-level usage in its hardware IDs (`HID_DEVICE_UP:0001_U:0008`: Generic Desktop,
//! Multi-Axis Controller), so the reader asks it, which sends nothing to any device, and opens only
//! the interfaces that are 3D mice.
//!
//! The configuration manager has no safe API, hence the `unsafe_code` allowance on this module
//! (the app crate denies it; see `native_crash.rs`).
#![allow(unsafe_code)]
use std::ffi::CString;
use std::ptr::{null, null_mut};
use windows_sys::Win32::Devices::DeviceAndDriverInstallation::{
CM_GET_DEVICE_INTERFACE_LIST_PRESENT, CM_Get_DevNode_PropertyW, CM_Get_Device_Interface_List_SizeW, CM_Get_Device_Interface_ListW,
CM_Get_Device_Interface_PropertyW, CM_LOCATE_DEVNODE_NORMAL, CM_Locate_DevNodeW, CONFIGRET, CR_BUFFER_SMALL, CR_SUCCESS,
};
use windows_sys::Win32::Devices::HumanInterfaceDevice::GUID_DEVINTERFACE_HID;
use windows_sys::Win32::Devices::Properties::{
DEVPKEY_Device_HardwareIds, DEVPKEY_Device_InstanceId, DEVPROP_TYPE_STRING, DEVPROP_TYPE_STRING_LIST, DEVPROPTYPE,
};
use windows_sys::Win32::Foundation::DEVPROPKEY;
/// The hardware ID Windows gives a top-level collection with the Generic Desktop page's
/// Multi-Axis Controller usage.
const MULTI_AXIS_CONTROLLER: &str = "HID_DEVICE_UP:0001_U:0008";
/// Largest property read, in bytes: hardware IDs are a few hundred.
const MAX_PROPERTY_BYTES: u32 = 64 * 1024;
/// Tries at reading the interface list, which can grow between asking its size and reading it.
const LIST_TRIES: usize = 4;
/// The paths of the present HID interfaces whose top-level collection is a multi-axis controller.
pub fn multi_axis_controllers() -> Vec<CString> {
interfaces()
.into_iter()
.filter(|path| {
let Some(instance) = interface_strings(path, &DEVPKEY_Device_InstanceId).and_then(|ids| ids.into_iter().next()) else {
return false;
};
devnode_strings(&instance, &DEVPKEY_Device_HardwareIds).is_some_and(|ids| is_multi_axis(&ids))
})
.filter_map(|path| CString::new(path).ok())
.collect()
}
/// Whether a device with these hardware IDs is a multi-axis controller.
fn is_multi_axis(hardware_ids: &[String]) -> bool {
hardware_ids.iter().any(|id| id.eq_ignore_ascii_case(MULTI_AXIS_CONTROLLER))
}
/// The strings of a nul-separated list (`REG_MULTI_SZ`, or one string), without empty ones.
fn split_multi_sz(buffer: &[u16]) -> Vec<String> {
buffer.split(|c| *c == 0).filter(|s| !s.is_empty()).map(String::from_utf16_lossy).collect()
}
/// `s` as a nul-terminated wide string.
fn wide(s: &str) -> Vec<u16> {
s.encode_utf16().chain(std::iter::once(0)).collect()
}
/// The paths of the present HID interfaces.
fn interfaces() -> Vec<String> {
for _ in 0..LIST_TRIES {
let mut len = 0u32;
// SAFETY: `len` is a valid place for the size; the class GUID is a static; no device filter.
let sized = unsafe { CM_Get_Device_Interface_List_SizeW(&mut len, &GUID_DEVINTERFACE_HID, null(), CM_GET_DEVICE_INTERFACE_LIST_PRESENT) };
if sized != CR_SUCCESS || len == 0 {
return Vec::new();
}
let mut buffer = vec![0u16; len as usize];
// SAFETY: `buffer` holds `len` wide characters, the length the call is given.
let listed =
unsafe { CM_Get_Device_Interface_ListW(&GUID_DEVINTERFACE_HID, null(), buffer.as_mut_ptr(), len, CM_GET_DEVICE_INTERFACE_LIST_PRESENT) };
match listed {
CR_SUCCESS => return split_multi_sz(&buffer),
CR_BUFFER_SMALL => continue,
_ => return Vec::new(),
}
}
Vec::new()
}
/// A string or string-list property, read by `get` (property type, buffer, its size in bytes) in
/// two calls: the size, then the value.
fn property_strings(get: impl Fn(*mut DEVPROPTYPE, *mut u8, *mut u32) -> CONFIGRET) -> Option<Vec<String>> {
let mut kind: DEVPROPTYPE = 0;
let mut size = 0u32;
if get(&mut kind, null_mut(), &mut size) != CR_BUFFER_SMALL || size == 0 || size > MAX_PROPERTY_BYTES {
return None;
}
let mut buffer = vec![0u16; (size as usize).div_ceil(2)];
let mut size = (buffer.len() * 2) as u32;
if get(&mut kind, buffer.as_mut_ptr().cast(), &mut size) != CR_SUCCESS || (kind != DEVPROP_TYPE_STRING && kind != DEVPROP_TYPE_STRING_LIST) {
return None;
}
Some(split_multi_sz(&buffer))
}
/// A string property of the device interface at `path`.
fn interface_strings(path: &str, key: &DEVPROPKEY) -> Option<Vec<String>> {
let path = wide(path);
property_strings(|kind, buffer, size| {
// SAFETY: `path` is nul-terminated and outlives the call; `buffer` is null (asking the size)
// or holds `*size` bytes, as `property_strings` sets up.
unsafe { CM_Get_Device_Interface_PropertyW(path.as_ptr(), key, kind, buffer, size, 0) }
})
}
/// A string property of the device with instance ID `instance`.
fn devnode_strings(instance: &str, key: &DEVPROPKEY) -> Option<Vec<String>> {
let instance = wide(instance);
let mut devnode = 0u32;
// SAFETY: `instance` is nul-terminated and outlives the call; `devnode` is a valid place for the result.
if unsafe { CM_Locate_DevNodeW(&mut devnode, instance.as_ptr(), CM_LOCATE_DEVNODE_NORMAL) } != CR_SUCCESS {
return None;
}
property_strings(|kind, buffer, size| {
// SAFETY: `buffer` is null (asking the size) or holds `*size` bytes, as `property_strings` sets up.
unsafe { CM_Get_DevNode_PropertyW(devnode, key, kind, buffer, size, 0) }
})
}
#[cfg(test)]
mod tests {
use super::*;
/// The hardware IDs Windows gives a wired 3Dconnexion device's motion interface, and a
/// keyboard's: only the first is a multi-axis controller.
#[test]
fn only_the_multi_axis_usage_is_a_3d_mouse() {
let ids = |list: &[&str]| list.iter().map(|s| s.to_string()).collect::<Vec<_>>();
let space_mouse = ids(&["HID\\VID_256F&PID_C635&REV_0437&MI_00", "HID\\VID_256F&UP:0001_U:0008", "HID_DEVICE_UP:0001_U:0008", "HID_DEVICE"]);
let keyboard = ids(&[
"HID\\VID_046D&PID_C31C&REV_6400&MI_00",
"HID\\VID_046D&UP:0001_U:0006",
"HID_DEVICE_SYSTEM_KEYBOARD",
"HID_DEVICE_UP:0001_U:0006",
"HID_DEVICE",
]);
let vendor = ids(&["HID\\VID_256F&UP:FF00_U:0001", "HID_DEVICE_UP:FF00_U:0001", "HID_DEVICE_UPR:FF00-FFFF", "HID_DEVICE"]);
assert!(is_multi_axis(&space_mouse));
assert!(is_multi_axis(&ids(&["hid_device_up:0001_u:0008"])), "IDs compare without regard to case");
assert!(!is_multi_axis(&keyboard) && !is_multi_axis(&vendor) && !is_multi_axis(&[]));
}
#[test]
fn nul_separated_lists_split_into_their_strings() {
let buffer: Vec<u16> = "a\\b\0HID_DEVICE\0\0".encode_utf16().collect();
assert_eq!(split_multi_sz(&buffer), ["a\\b", "HID_DEVICE"]);
assert_eq!(split_multi_sz(&wide("one")), ["one"]);
assert!(split_multi_sz(&[0, 0]).is_empty() && split_multi_sz(&[]).is_empty());
}
/// The scan asks Plug and Play only: on any machine it returns, and every path it returns is a
/// present HID interface whose hardware IDs name the multi-axis usage.
#[test]
fn the_scan_returns_only_multi_axis_interfaces() {
let found = multi_axis_controllers();
let all = interfaces();
for path in &found {
let path = path.to_str().unwrap_or_default();
assert!(all.iter().any(|p| p == path), "{path}");
let instance = interface_strings(path, &DEVPKEY_Device_InstanceId).and_then(|ids| ids.into_iter().next()).unwrap_or_default();
assert!(devnode_strings(&instance, &DEVPKEY_Device_HardwareIds).is_some_and(|ids| is_multi_axis(&ids)), "{path}");
}
eprintln!("{} HID interfaces present, {} multi-axis controllers", all.len(), found.len());
}
}
@@ -239,6 +239,7 @@ split_face false
spun false
star false
status_error false
status_recovery false
style false
suppress false
surface_blend false
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Keyboard shortcuts</title>
<path d="M13 16L51 16A5 5 0 0 1 56 21L56 43A5 5 0 0 1 51 48L13 48A5 5 0 0 1 8 43L8 21A5 5 0 0 1 13 16Z" fill="none" stroke="currentColor" stroke-width="5.6" stroke-linejoin="round" stroke-linecap="round"/>
<circle cx="20" cy="27" r="2.8" fill="currentColor"/>
<circle cx="32" cy="27" r="2.8" fill="currentColor"/>
<circle cx="44" cy="27" r="2.8" fill="currentColor"/>
<path d="M20 39L44 39" fill="none" stroke="currentColor" stroke-width="5.6" stroke-linejoin="round" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 607 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Graphics display</title>
<path d="M13 10L51 10A5 5 0 0 1 56 15L56 37A5 5 0 0 1 51 42L13 42A5 5 0 0 1 8 37L8 15A5 5 0 0 1 13 10ZM32 42L32 54M24 54L40 54" fill="none" stroke="currentColor" stroke-width="5.6" stroke-linejoin="round" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 334 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Mouse navigation</title>
<path d="M32 8A13 13 0 0 1 45 21L45 43A13 13 0 0 1 19 43L19 21A13 13 0 0 1 32 8ZM32 8L32 24" fill="none" stroke="currentColor" stroke-width="5.6" stroke-linejoin="round" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 299 B

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Interface palette</title>
<path d="M32 8C18.75 8 8 18.75 8 32C8 45.25 18.75 56 32 56C37 56 39 52.5 36.5 49C34 45.5 36 42 40 42L45 42C51.1 42 56 37.1 56 31C56 19 45 8 32 8Z" fill="none" stroke="currentColor" stroke-width="5.6" stroke-linejoin="round" stroke-linecap="round"/>
<circle cx="21" cy="29" r="2.8" fill="currentColor"/>
<circle cx="29" cy="20" r="2.8" fill="currentColor"/>
<circle cx="41" cy="23" r="2.8" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 522 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Plugins</title>
<path d="M24 8L24 21M40 8L40 21M16 21L48 21L48 29A16 16 0 0 1 16 29L16 21ZM32 45L32 56" fill="none" stroke="currentColor" stroke-width="5.6" stroke-linejoin="round" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 285 B

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Recover unsaved design</title>
<circle cx="32" cy="33" r="22" fill="#b9ddf8"/>
<path d="M10 33A22 22 0 1 0 16.44 17.44L10 24" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M10 12L10 24L22 24" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M32 21L32 33L41 39" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 563 B

+14 -2
View File
@@ -99,14 +99,23 @@ pub(crate) fn breadcrumb(p: &egui::Painter, c: egui::Pos2, col: Color32) {
}
pub(crate) fn brand_mark(p: &egui::Painter, c: Pos2) {
let s = 20.0 / 412.217;
brand_mark_styled(p, c, 20.0, crate::theme::Tokens::get().logo, true);
}
/// The same original mark as a single quiet silhouette, without face seams or outlines.
pub(crate) fn brand_mark_silhouette(p: &egui::Painter, c: Pos2, height: f32, ink: Color32) {
brand_mark_styled(p, c, height, [ink; 4], false);
}
fn brand_mark_styled(p: &egui::Painter, c: Pos2, height: f32, colours: [Color32; 4], edges: bool) {
let s = height / 412.217;
let m = |pts: &[(f32, f32)]| -> Vec<egui::Pos2> { pts.iter().map(|&(x, y)| pos2(c.x + (x - 178.5) * s, c.y + (y - 206.1) * s)).collect() };
// egui only fills convex shapes, so each face is a few overlapping convex pieces.
let face = |col: Color32, pts: &[(f32, f32)]| {
p.add(egui::Shape::convex_polygon(m(pts), col, Stroke::NONE));
};
// The faces are separated by gaps that show the application bar, not by white lines.
let [light, mid, dark, _] = crate::theme::Tokens::get().logo;
let [light, mid, dark, _] = colours;
let edge_col = crate::theme::Tokens::get().app_bar;
face(light, &[(3.5, 105.9), (177.0, 5.8), (263.4, 55.6), (263.8, 156.0), (177.0, 406.4)]);
face(light, &[(177.0, 206.1), (263.8, 156.0), (350.5, 206.2), (350.5, 306.3), (177.0, 406.4)]);
@@ -117,6 +126,9 @@ pub(crate) fn brand_mark(p: &egui::Painter, c: Pos2) {
face(dark, &[(263.8, 256.2), (350.5, 206.2), (350.5, 306.3), (177.0, 406.4)]);
face(dark, &[(177.0, 206.1), (177.0, 406.4), (350.5, 306.3)]);
face(dark, &[(263.8, 55.9), (263.8, 156.0), (177.1, 105.9)]);
if !edges {
return;
}
// White edge lines.
let edge = Stroke::new(10.0 * s, edge_col);
let line = |pts: &[(f32, f32)]| {
+42 -4
View File
@@ -857,19 +857,57 @@
"help.mit": "Licence MIT",
"help.notice": "NOTICE",
"help.third_party": "Licence třetích stran",
"home.back": "Zpět na {name}",
"home.crashes": "Hlášení o pádech",
"home.design_workspace": "Návrh",
"home.documentation": "Dokumentace",
"home.export": "Exportovat",
"home.export_hint": "Vyberte formát pro aktuální návrh.",
"home.export_json_hint": "Upravitelný návrh a historie příkazů",
"home.export_mesh_hint": "Síť pro tisk nebo sdílení",
"home.export_step_hint": "Přesná geometrie tělesa",
"home.import": "Importovat…",
"home.import_hint": "STEP, IGES nebo síť do nového návrhu",
"home.in_this_browser": "v tomto prohlížeči",
"home.missing": "chybí",
"home.month_01": "led",
"home.month_02": "úno",
"home.month_03": "bře",
"home.month_04": "dub",
"home.month_05": "kvě",
"home.month_06": "čer",
"home.month_07": "čvc",
"home.month_08": "srp",
"home.month_09": "zář",
"home.month_10": "říj",
"home.month_11": "lis",
"home.month_12": "pro",
"home.new_inches": "Nový návrh v palcích",
"home.new_inches_hint": "Palce · otevře se v pracovním prostoru Návrh",
"home.new_mm": "Nový návrh",
"home.new_mm_hint": "Milimetry · otevře se v pracovním prostoru Návrh",
"home.open": "Otevřít…",
"home.opened_days_ago": "Otevřeno před {count} dny",
"home.opened_hours_ago": "Otevřeno před {count} h",
"home.opened_just_now": "Otevřeno právě teď",
"home.opened_minutes_ago": "Otevřeno před {count} min",
"home.open_hint": "Návrh ve formátu .solidcraft ({shortcut})",
"home.open_now": "Otevřít nyní",
"home.opened_days_ago": "Před {count} dny",
"home.opened_hours_ago": "Před {count} h",
"home.opened_just_now": "Právě teď",
"home.opened_minutes_ago": "Před {count} min",
"home.opening": "otevírání…",
"home.properties": "Vlastnosti návrhu",
"home.recent": "Nedávné",
"home.recent_empty": "Zde se zobrazí návrhy, které otevřete nebo uložíte.",
"home.recovery_count": "Návrhy k obnovení: {count}",
"home.recovery_empty": "Žádné návrhy k obnovení.",
"home.recovery_hint": "SolidCraft se zavřel před uložením těchto návrhů.",
"home.report_issue": "Nahlásit problém",
"home.samples": "Ukázky",
"home.settings": "Nastavení",
"home.short_date": "{day}. {month}",
"home.shortcuts": "Klávesové zkratky",
"home.start_hint": "Vytvořte návrh, otevřete nedávný soubor nebo prozkoumejte ukázku.",
"home.whats_new": "Co je nového",
"home.yesterday": "Včera",
"install.button_tip": "Nainstalovat SolidCraft jako aplikaci: otevře se ve vlastním okně a funguje i offline",
"install.dock_1": "Zvolte Soubor › Přidat do Docku.",
"install.dock_2": "Klikněte na Přidat.",
+42 -4
View File
@@ -857,19 +857,57 @@
"help.mit": "MIT-licens",
"help.notice": "NOTICE",
"help.third_party": "Tredjepartslicenser",
"home.back": "Tilbage til {name}",
"home.crashes": "Nedbrudsrapporter",
"home.design_workspace": "Design",
"home.documentation": "Dokumentation",
"home.export": "Eksportér",
"home.export_hint": "Vælg et format til det aktuelle design.",
"home.export_json_hint": "Redigerbart design og kommandohistorik",
"home.export_mesh_hint": "Mesh til udskrivning eller deling",
"home.export_step_hint": "Præcis geometri for massive legemer",
"home.import": "Importér…",
"home.import_hint": "STEP, IGES eller mesh til et nyt design",
"home.in_this_browser": "i denne webbrowser",
"home.missing": "mangler",
"home.month_01": "jan.",
"home.month_02": "feb.",
"home.month_03": "mar.",
"home.month_04": "apr.",
"home.month_05": "maj",
"home.month_06": "jun.",
"home.month_07": "jul.",
"home.month_08": "aug.",
"home.month_09": "sep.",
"home.month_10": "okt.",
"home.month_11": "nov.",
"home.month_12": "dec.",
"home.new_inches": "Nyt design i tommer",
"home.new_inches_hint": "Tommer · åbner i Design",
"home.new_mm": "Nyt design",
"home.new_mm_hint": "Millimeter · åbner i Design",
"home.open": "Åbn…",
"home.opened_days_ago": "Åbnet for {count} dage siden",
"home.opened_hours_ago": "Åbnet for {count} t siden",
"home.opened_just_now": "Netop åbnet",
"home.opened_minutes_ago": "Åbnet for {count} min siden",
"home.open_hint": "Et .solidcraft-design ({shortcut})",
"home.open_now": "Åbn nu",
"home.opened_days_ago": "For {count} dage siden",
"home.opened_hours_ago": "For {count} t siden",
"home.opened_just_now": "Lige nu",
"home.opened_minutes_ago": "For {count} min siden",
"home.opening": "åbner…",
"home.properties": "Designegenskaber",
"home.recent": "Seneste",
"home.recent_empty": "Design, du åbner eller gemmer, vises her.",
"home.recovery_count": "Design til gendannelse: {count}",
"home.recovery_empty": "Ingen design kan gendannes.",
"home.recovery_hint": "SolidCraft lukkede, før disse design blev gemt.",
"home.report_issue": "Rapportér et problem",
"home.samples": "Eksempler",
"home.settings": "Indstillinger",
"home.short_date": "{day}. {month}",
"home.shortcuts": "Tastaturgenveje",
"home.start_hint": "Opret et design, åbn en nylig fil, eller udforsk et eksempel.",
"home.whats_new": "Nyheder",
"home.yesterday": "I går",
"install.button_tip": "Installér SolidCraft som en app: Den åbner i sit eget vindue og virker offline",
"install.dock_1": "Vælg Arkiv › Føj til Dock.",
"install.dock_2": "Klik på Tilføj.",
+156 -4
View File
@@ -14,11 +14,15 @@
"Active": "Aktiv",
"Active rule": "Aktive Regel",
"Add": "Hinzufügen",
"Add Column": "Spalte hinzufügen",
"Add Key": "Schlüssel hinzufügen",
"Add Mid Radius": "Mittleren Radius hinzufügen",
"Add Row": "Zeile hinzufügen",
"Add a tool": "Werkzeug hinzufügen",
"Add condition": "Bedingung hinzufügen",
"Add or edit rule": "Regel hinzufügen oder bearbeiten",
"Add to New Folder": "Zu neuem Ordner hinzufügen",
"Advanced": "Erweitert",
"Against": "Gegen",
"Alignment": "Ausrichtung",
"All Sketch Points": "Alle Skizzenpunkte",
@@ -31,6 +35,7 @@
"Appearance name": "Name des Erscheinungsbilds",
"Applied Radius": "Angewendeter Radius",
"Apply To": "Anwenden auf",
"Apply library appearance": "Erscheinungsbild aus der Bibliothek anwenden",
"Apply to selection": "Auf Auswahl anwenden",
"Apply your appearance first. Exports assigned appearances and materials created in this design.": "Wenden Sie zuerst Ihr Erscheinungsbild an. Exportiert zugewiesene Erscheinungsbilder und in diesem Design erstellte Materialien.",
"Arc length": "Bogenlänge",
@@ -41,13 +46,18 @@
"BLEND SURFACE": "ÜBERGANGSFLÄCHE",
"BOUNDARY SURFACE": "BERANDUNGSFLÄCHE",
"BROWSER": "BROWSER",
"Bend Edges": "Biegekanten",
"Bend Line Position": "Position der Biegelinie",
"Bend Position": "Biegeposition",
"Bend Radius": "Biegeradius",
"Bend Table": "Biegetabelle",
"Bends": "Biegungen",
"Bodies": "Körper",
"Bodies (optional)": "Körper (optional)",
"Body": "Körper",
"Body (last if none)": "Körper (letzter, wenn keiner)",
"Body to split": "Zu teilender Körper",
"Body, or faces to take out": "Körper oder zu entfernende Flächen",
"Boundary": "Begrenzung",
"Bounding box": "Hüllquader",
"CHECK": "PRÜFEN",
@@ -59,7 +69,9 @@
"CSV or JSON: updates existing parameters, adds new ones": "CSV or JSON: updates existing parameters, adds new ones",
"Capture All Positions": "Alle Positionen erfassen",
"Capture Pos.": "Pos. erfassen",
"Catch Depth": "Fangtiefe",
"Catch Direction": "Fangrichtung",
"Catch Length": "Fanglänge",
"Cells": "Zellen",
"Center X": "Mittelpunkt X",
"Center Y": "Mittelpunkt Y",
@@ -68,15 +80,21 @@
"Chamfer Type": "Abschrägungstyp",
"Check collisions": "Kollisionen prüfen",
"Checked": "Geprüft",
"Choose Variable radius or Spline in Type.": "Unter „Typ“ „Variable radius“ oder „Spline“ wählen.",
"Choose a new plane. Sketch geometry and dimensions stay unchanged in its two-dimensional coordinates.": "Wählen Sie eine neue Ebene. Skizzengeometrie und Bemaßungen bleiben in ihren zweidimensionalen Koordinaten unverändert.",
"Choose…": "Auswählen…",
"Circular": "Kreisförmig",
"Circumference": "Umfang",
"Close draft": "Entwurf schließen",
"Collapse": "Einklappen",
"Collisions": "Kollisionen",
"Colour": "Farbe",
"Command": "Befehl",
"Comment": "Kommentar",
"Component 1": "Komponente 1",
"Component 2": "Komponente 2",
"Component1": "Komponente1",
"Components": "Komponenten",
"Config Table": "Konfigurationstabelle",
"Configuration": "Konfiguration",
"Configuration rules": "Configuration rules",
@@ -84,6 +102,7 @@
"Contact Sets": "Kontaktsätze",
"Contact sets": "Kontaktsätze",
"Continuity": "Stetigkeit",
"Control Radius": "Kontrollradius",
"Copy Error Message": "Fehlermeldung kopieren",
"Copy Warning Message": "Warnmeldung kopieren",
"Copy all licences": "Alle Lizenzen kopieren",
@@ -93,8 +112,11 @@
"Corner Relief": "Eckenentlastung",
"Corner Relief Shape": "Form der Eckenentlastung",
"Counterbore depth": "Tiefe der Senkung",
"Counterbore Ø": "Tiefenbohrung Ø",
"Countersink angle": "Winkel der Kegelsenkung",
"Countersink Ø": "Senkung Ø",
"Create Issue — review draft": "Issue erstellen — Entwurf prüfen",
"Create Issue…": "Issue erstellen…",
"Cross curves": "Querkurven",
"Cube": "Würfel",
"Current orthographic view (1:1 mm)": "Aktuelle orthogonale Ansicht (1:1 mm)",
@@ -103,7 +125,11 @@
"Curvature (G2)": "Krümmung (G2)",
"Curvature change (G3)": "Krümmungsänderung (G3)",
"Customize Toolbar": "Symbolleiste anpassen",
"Customize…": "Anpassen…",
"Cylindrical face": "Zylindrische Fläche",
"Delete": "Löschen",
"Delete Selected Features…": "Ausgewählte Merkmale löschen…",
"Delete Selected Folders": "Ausgewählte Ordner löschen",
"Delete all": "Alle löschen",
"Delete rule": "Regel löschen",
"Delete the row": "Zeile löschen",
@@ -112,8 +138,11 @@
"Dependents": "Abhängige Elemente",
"Designs you open or save appear here.": "Designs, die Sie öffnen oder speichern, erscheinen hier.",
"Determined by To Object. Choose Angle to edit the angular extent.": "Bestimmt durch Bis Objekt. Wählen Sie Winkel, um den Winkelbereich zu bearbeiten.",
"Diameter": "Durchmesser",
"Direction": "Richtung",
"Discard": "Verwerfen",
"Distance": "Abstand",
"Distance 1": "Abstand 1",
"Do not export hidden edges": "Verdeckte Kanten nicht exportieren",
"Don't ask again": "Nicht mehr fragen",
"Double-click to rename": "Double-click to rename",
@@ -125,17 +154,21 @@
"Edit Profile Sketch": "Profile-Skizze bearbeiten",
"Enable rule": "Regel aktivieren",
"Enabled": "Aktiviert",
"End Radius": "Endradius",
"End bulge": "Endausbauchung",
"End continuity": "Endstetigkeit",
"Expand": "Ausklappen",
"Explode": "Explodieren",
"Explode Text": "Text auflösen",
"Export Animation…": "Animation exportieren…",
"Export Bend Table…": "Biegetabelle exportieren…",
"Export CSV…": "CSV exportieren…",
"Export Current View as DXF": "Aktuelle Ansicht als DXF exportieren",
"Export Current View as DXF…": "Aktuelle Ansicht als DXF exportieren…",
"Export DXF": "DXF exportieren",
"Export DXF…": "DXF exportieren…",
"Export JSON…": "JSON exportieren…",
"Export design library...": "Designbibliothek exportieren...",
"Export hidden edges as dashed lines": "Verdeckte Kanten gestrichelt exportieren",
"Expression": "Ausdruck",
"Extend to meet": "Verlängern, bis sie sich treffen",
@@ -143,31 +176,40 @@
"FILES": "DATEIEN",
"FLAT": "ABWICKLUNG",
"Face": "Fläche",
"Face Appearance": "Flächenerscheinungsbild",
"Face attachment lost": "Flächenanbindung verloren",
"Faces": "Flächen",
"Faces / edges / vertices": "Flächen / Kanten / Ecken",
"Faces to split": "Zu teilende Flächen",
"Fastener": "Verbindungselement",
"Favorites": "Favoriten",
"Favourite": "Favourite",
"Feature": "Funktion",
"Files from many CAD programs have Y up, so they open lying on their back here (Z up).": "Dateien aus vielen CAD-Programmen haben Y nach oben und öffnen sich hier daher auf dem Rücken liegend (Z nach oben).",
"Filter": "Filter",
"Filter…": "Filtern…",
"Find in Browser": "Im Browser suchen",
"Finish": "Oberfläche",
"First plane": "Erste Ebene",
"Fit and G2 corners require Constant radius.": "Passung und G2-Ecken erfordern Constant radius.",
"Fixed Face": "Feste Fläche",
"Flat Pattern": "Flachmuster",
"Flat size": "Abwicklungsgröße",
"Follow camera (Look At sketches)": "Kamera folgen (Skizzen ansehen)",
"Follow the camera (Look At sketches)": "Der Kamera folgen (Skizzen ansehen)",
"Font": "Schriftart",
"From": "Von",
"From Bodies": "Aus Körpern",
"From edges": "Ausgangskanten",
"Full Length": "Volle Länge",
"G2 corners require a constant radius and Exact fit. G2 corner construction is not supported yet.": "G2-Ecken erfordern einen konstanten Radius und Exact fit. Die Konstruktion von G2-Ecken wird noch nicht unterstützt.",
"Gap": "Spalt",
"Grid": "Raster",
"HISTORY": "VERLAUF",
"Height": "Höhe",
"Hidden edges": "Verdeckte Kanten",
"History layout": "Verlaufslayout",
"Hole (optional)": "Bohrung (optional)",
"Holes": "Löcher",
"Holes (edges on them)": "Löcher (ihre Kanten)",
"Home view": "Startansicht",
@@ -178,14 +220,20 @@
"In place (surfaces)": "An Ort und Stelle (Flächen)",
"In plane": "In der Ebene",
"Inside": "Innen",
"Instant": "Sofort",
"Interface font": "Schriftart der Oberfläche",
"Interferes": "Überschneidet sich",
"Invert": "Umkehren",
"Isocurve": "Isokurve",
"Issue title": "Issue-Titel",
"Items": "Elemente",
"JOINTS": "GELENKE",
"Joint Origin1": "Gelenkursprung1",
"Keep (points on it)": "Behalten (Punkte darauf)",
"Keep Tools": "Werkzeuge behalten",
"Keep of the other surface": "Vom anderen Flächenkörper behalten",
"Key a joint's value at this step": "Gelenkwert in diesem Schritt als Schlüsselbild setzen",
"Keys": "Schlüsselbilder",
"LIBRARY": "BIBLIOTHEK",
"LINKS": "VERKNÜPFUNGEN",
"Later": "Später",
@@ -196,9 +244,14 @@
"Letter Spacing": "Zeichenabstand",
"Library": "Bibliothek",
"Library file": "Bibliotheksdatei",
"Limits": "Grenzen",
"Line Spacing": "Zeilenabstand",
"Line Type": "Linientyp",
"Listing the installed fonts…": "Installierte Schriftarten werden aufgelistet…",
"Load library": "Bibliothek laden",
"Local library": "Lokale Bibliothek",
"Loop": "Schleife",
"M5 or M5x0.5": "M5 oder M5x0.5",
"MANAGE": "VERWALTEN",
"MATERIALS": "MATERIALIEN",
"MODEL": "MODELL",
@@ -209,14 +262,20 @@
"Matched restrictions check the final values.": "Zutreffende Einschränkungen prüfen die endgültigen Werte.",
"Material": "Material",
"Max": "Max",
"Maximum": "Maximum",
"Mesh body": "Netzkörper",
"Mid Radius": "Mittlerer Radius",
"Middle double-click": "Doppelklick mit mittlerer Taste",
"Min": "Min",
"Minimum": "Minimum",
"Mirror Plane": "Spiegelebene",
"Mitre": "Gehrung",
"Model Parameters": "Modellparameter",
"Modeled": "Modelliert",
"More toolbar panels": "Weitere Symbolleistenbereiche",
"Motion": "Bewegung",
"Mouse gestures": "Mausgesten",
"Mouse gestures only work with the SolidWorks mouse mode (the wheel orbits, Ctrl + wheel pans).": "Mausgesten funktionieren nur im SolidWorks-Mausmodus (das Rad dreht, Strg + Rad verschiebt).",
"Move / Copy": "Verschieben / Kopieren",
"Move Type": "Verschiebungstyp",
"Move down": "Nach unten",
@@ -238,8 +297,11 @@
"Not available yet": "Noch nicht verfügbar",
"Notch": "Ausklinkung",
"OUTPUT": "AUSGABE",
"Of": "Freiheitsgrad",
"On your computer": "Auf diesem Computer",
"Opacity": "Deckkraft",
"Open GitHub draft": "GitHub-Entwurf öffnen",
"Open library...": "Bibliothek öffnen...",
"Open local libraries in the desktop app. You can export this design's library here.": "Öffnen Sie lokale Bibliotheken in der Desktop-App. Die Bibliothek dieses Designs können Sie hier exportieren.",
"Open-source parametric 3D CAD in Rust. Licensed MIT OR Apache-2.0.": "Quelloffenes parametrisches 3D-CAD in Rust. Lizenziert unter MIT OR Apache-2.0.",
"Operator": "Operator",
@@ -258,8 +320,10 @@
"Pick the tool bodies": "Werkzeugkörper wählen",
"Picked": "Ausgewählte",
"Picked point": "Gewählter Punkt",
"Pivot": "Drehpunkt",
"Place at the origin instead": "Stattdessen im Ursprung platzieren",
"Plastic Rules": "Kunststoffregeln",
"Play the joint's motion in the view": "Die Bewegung des Gelenks in der Ansicht abspielen",
"Playback": "Wiedergabe",
"Plot": "Diagramm",
"Position": "Position",
@@ -267,14 +331,18 @@
"Position (G0)": "Position (G0)",
"Position along the edge, 0 at the start end, 1 at the other": "Position entlang der Kante, 0 am Anfang, 1 am anderen Ende",
"Preferences › Design turns the offer back on": "Unter Einstellungen › Design lässt sich das Angebot wieder einschalten",
"Preview": "Vorschau",
"Preview Row": "Vorschauzeile",
"Preview leaves the current design unchanged.": "Die Vorschau lässt das aktuelle Design unverändert.",
"Profile / Edges": "Profil / Kanten",
"Profiles or curves": "Profile oder Kurven",
"Profiles/Curves": "Profile/Kurven",
"Quantity": "Größe",
"RECENT": "ZULETZT",
"REFERENCE": "REFERENZ",
"RULED SURFACE": "REGELFLÄCHE",
"RULES": "REGELN",
"Radius": "Radius",
"Radius Fit": "Radiusanpassung",
"Ratio": "Verhältnis",
"Reattach selected face": "Ausgewählte Fläche neu anbinden",
@@ -283,6 +351,8 @@
"Rectangular": "Rechteckig",
"Reference face": "Referenzfläche",
"Remove Tools": "Werkzeuge entfernen",
"Remove condition": "Bedingung entfernen",
"Remove from Folder": "Aus Ordner entfernen",
"Remove from list": "Aus Liste entfernen",
"Remove override": "Überschreibung entfernen",
"Remove private design details or paths before sharing.": "Entfernen Sie vor dem Teilen private Designdetails oder Pfade.",
@@ -290,12 +360,16 @@
"Remove the column": "Spalte entfernen",
"Remove the track": "Spur entfernen",
"Remove this point": "Diesen Punkt entfernen",
"Rename Feature": "Merkmal umbenennen",
"Rename Folder": "Ordner umbenennen",
"Replace existing library file": "Vorhandene Bibliotheksdatei ersetzen",
"Report a bug — review a screenshot and open a GitHub draft": "Fehler melden — Screenshot prüfen und einen GitHub-Entwurf öffnen",
"Reset Toolbar": "Symbolleiste zurücksetzen",
"Resize Part": "Teil skalieren",
"Restore View": "Ansicht wiederherstellen",
"Restore outer boundary": "Äußere Begrenzung wiederherstellen",
"Restore the inherited component or physical material appearance": "Das geerbte Erscheinungsbild der Komponente oder des physikalischen Materials wiederherstellen",
"Restore this studio's tools": "Werkzeuge dieses Studios wiederherstellen",
"Result": "Ergebnis",
"Retake screenshot": "Screenshot erneut aufnehmen",
"Reverse": "Umkehren",
@@ -305,7 +379,13 @@
"Revert Pos.": "Pos. zurücksetzen",
"Revert Position": "Position zurücksetzen",
"Review the report and screenshot before opening GitHub.": "Prüfen Sie Bericht und Screenshot, bevor Sie GitHub öffnen.",
"Rib Length": "Rippenlänge",
"Rib Offset": "Rippenversatz",
"Rib Thickness": "Rippendicke",
"Ribs": "Rippen",
"Rim Edge": "Randkante",
"Ring of tools on the right button": "Werkzeugring auf der rechten Maustaste",
"Roll Back Here": "Hierher zurücksetzen",
"Roll History Marker Here": "Verlaufsmarkierung hierher verschieben",
"Round": "Rund",
"Rule": "Rule",
@@ -322,13 +402,18 @@
"Search": "Suchen",
"Search commands": "Befehle suchen",
"Search this library": "Diese Bibliothek durchsuchen",
"Second plane": "Zweite Ebene",
"Section": "Schnitt",
"Sections (one sketch each)": "Querschnitte (je eine Skizze)",
"Select Other": "Andere auswählen",
"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).": "Anderes auswählen: Halten Sie die linke Taste ruhig auf dem Modell gedrückt (oder Rechtsklick › Anderes auswählen…), um alles unter dem Cursor aufzulisten; klicken Sie erneut an dieselbe Stelle, um das nächste Element dort auszuwählen. Doppelklick mit der mittleren Taste wechselt zur Startansicht (Einstellungen › Navigation).",
"Select bodies (a face picks its body)": "Körper auswählen (eine Fläche wählt ihren Körper)",
"Select bodies or faces instead": "Stattdessen Körper oder Flächen auswählen",
"Select edges": "Kanten auswählen",
"Selection filter": "Auswahlfilter",
"Semantic faces share their appearance across instances.": "Semantische Flächen teilen ihr Erscheinungsbild über alle Instanzen hinweg.",
"Send your report on GitHub": "Bericht auf GitHub senden",
"Shaded": "Schattiert",
"Shape": "Form",
"Sheet Rules": "Blechregeln",
"Show Agent Cursor": "Agenten-Cursor anzeigen",
@@ -346,6 +431,7 @@
"SolidCraft closed before these designs were saved. Their last autosave can be opened.": "SolidCraft wurde beendet, bevor diese Designs gespeichert wurden. Ihr letzter Autosave kann geöffnet werden.",
"Specialise": "Spezialisieren",
"Speed": "Geschwindigkeit",
"Splitting tool": "Trennwerkzeug",
"Stand it up": "Aufrichten",
"Stand it up: a quarter turn about X, as one Move feature you can undo.": "Aufrichten: eine Vierteldrehung um X, als ein Verschieben-Merkmal, das sich rückgängig machen lässt.",
"Stand this part up?": "Dieses Teil aufrichten?",
@@ -355,14 +441,20 @@
"Steps": "Schritte",
"Stitch the kept pieces": "Behaltene Teile vernähen",
"Stitch to both bodies": "Mit beiden Körpern vernähen",
"Studio": "Studio",
"Study": "Untersuchung",
"Style": "Stil",
"Suppress": "Unterdrücken",
"Suppressed": "Unterdrückt",
"Surface": "Flächenkörper",
"Surface, or faces to turn over": "Fläche oder umzudrehende Flächen",
"Swap Ends": "Enden vertauschen",
"TRIM": "TRIMMEN",
"Tangent (G1)": "Tangente (G1)",
"Tangent Chain": "Tangentenkette",
"Tangent chain": "Tangentialkette",
"Target": "Ziel",
"Target Body": "Zielkörper",
"Tear": "Reißen",
"The dashed sketch is a saved plane guide. Select a new face to repair the attachment.": "Die gestrichelte Skizze ist eine gespeicherte Ebenenführung. Wählen Sie eine neue Fläche, um die Anbindung zu reparieren.",
"The saved author occurrence is missing. Select a valid author placement before rebinding.": "Das gespeicherte Autorvorkommen fehlt. Wählen Sie vor dem erneuten Anbinden eine gültige Autorplatzierung.",
@@ -375,24 +467,31 @@
"This child is shared. Deleting it removes it from every copy of its parent.": "Dieses untergeordnete Element ist geteilt. Löschen entfernt es aus jeder Kopie des übergeordneten Elements.",
"This child is shared. Position changes apply to every copy of its parent.": "Dieses untergeordnete Element ist geteilt. Positionsänderungen gelten für jede Kopie des übergeordneten Elements.",
"This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "Diese Fläche kann keine dauerhafte Anbindung bieten. Wählen Sie eine andere Fläche oder verwenden Sie ausdrücklich eine feste Ebene.",
"To": "Bis",
"To Offset": "Bis Versatz",
"To edges": "Zielkanten",
"Tolerance": "Toleranz",
"Too close": "Zu nah",
"Tool Bodies": "Werkzeugkörper",
"Tools": "Werkzeuge",
"Tools, clockwise from the top": "Werkzeuge, im Uhrzeigersinn von oben",
"Trimming tool (plane, face or curves)": "Trimmwerkzeug (Ebene, Fläche oder Kurven)",
"Turn off section analysis before exporting the view.": "Deaktivieren Sie die Schnittanalyse, bevor Sie die Ansicht exportieren.",
"Type": "Typ",
"UNTRIM": "TRIMMEN AUFHEBEN",
"Unit": "Einheit",
"Unsuppress": "Unterdrückung aufheben",
"Updating the preview": "Vorschau wird aktualisiert",
"Use active author placement": "Aktive Autorplatzierung verwenden",
"Use fixed plane": "Feste Ebene verwenden",
"Use saved fixed plane": "Gespeicherte feste Ebene verwenden",
"Use selected face as fixed plane": "Ausgewählte Fläche als feste Ebene verwenden",
"Use the SolidWorks mouse mode": "SolidWorks-Mausmodus verwenden",
"User Parameters": "Benutzerparameter",
"VIEW": "ANSICHT",
"VIEWS": "ANSICHTEN",
"Value": "Value",
"Variable Radius": "Variabler Radius",
"Velocity": "Geschwindigkeit",
"Vertices": "Scheitelpunkte",
"View navigator": "Ansichtsnavigator",
@@ -403,10 +502,18 @@
"When": "When",
"Width": "Breite",
"Will fail": "Schlägt fehl",
"With gestures on, the right button no longer orbits the view: hold it and move to open the ring, then touch a tool's zone. A plain right-click still opens the menu.": "Mit eingeschalteten Mausgesten dreht die rechte Maustaste die Ansicht nicht mehr: Taste gedrückt halten und bewegen öffnet den Werkzeugring, dann die Zone eines Werkzeugs berühren. Ein einfacher Rechtsklick öffnet weiterhin das Menü.",
"X": "X",
"X Angle": "X-Winkel",
"X angle": "X-Winkel",
"X distance": "X-Abstand",
"Y": "Y",
"Y Angle": "Y-Winkel",
"Y angle": "Y-Winkel",
"Y distance": "Y-Abstand",
"Z": "Z",
"Z Angle": "Z-Winkel",
"Z Distance": "Z-Abstand",
"Z angle": "Z-Winkel",
"Z distance": "Z-Abstand",
"Zebra": "Zebra",
@@ -859,19 +966,57 @@
"help.mit": "MIT-Lizenz",
"help.notice": "NOTICE",
"help.third_party": "Drittanbieterlizenzen",
"home.back": "Zurück zu {name}",
"home.crashes": "Absturzberichte",
"home.design_workspace": "Entwurf",
"home.documentation": "Dokumentation",
"home.export": "Exportieren",
"home.export_hint": "Wählen Sie ein Format für den aktuellen Entwurf.",
"home.export_json_hint": "Bearbeitbarer Entwurf und Befehlsverlauf",
"home.export_mesh_hint": "Netz zum Drucken oder Teilen",
"home.export_step_hint": "Präzise Volumengeometrie",
"home.import": "Importieren…",
"home.import_hint": "STEP, IGES oder Netz in einen neuen Entwurf",
"home.in_this_browser": "in diesem Browser",
"home.missing": "fehlt",
"home.month_01": "Jan.",
"home.month_02": "Feb.",
"home.month_03": "März",
"home.month_04": "Apr.",
"home.month_05": "Mai",
"home.month_06": "Juni",
"home.month_07": "Juli",
"home.month_08": "Aug.",
"home.month_09": "Sept.",
"home.month_10": "Okt.",
"home.month_11": "Nov.",
"home.month_12": "Dez.",
"home.new_inches": "Neuer Entwurf in Zoll",
"home.new_inches_hint": "Zoll · öffnet sich im Arbeitsbereich Entwurf",
"home.new_mm": "Neuer Entwurf",
"home.new_mm_hint": "Millimeter · öffnet sich im Arbeitsbereich Entwurf",
"home.open": "Öffnen…",
"home.opened_days_ago": "Vor {count} Tag(en) geöffnet",
"home.opened_hours_ago": "Vor {count} Std. geöffnet",
"home.opened_just_now": "Gerade eben geöffnet",
"home.opened_minutes_ago": "Vor {count} Min. geöffnet",
"home.open_hint": "Ein .solidcraft-Entwurf ({shortcut})",
"home.open_now": "Jetzt öffnen",
"home.opened_days_ago": "Vor {count} Tagen",
"home.opened_hours_ago": "Vor {count} Std.",
"home.opened_just_now": "Gerade eben",
"home.opened_minutes_ago": "Vor {count} Min.",
"home.opening": "wird geöffnet…",
"home.properties": "Entwurfseigenschaften",
"home.recent": "Zuletzt verwendet",
"home.recent_empty": "Entwürfe, die Sie öffnen oder speichern, erscheinen hier.",
"home.recovery_count": "Wiederherstellbare Entwürfe: {count}",
"home.recovery_empty": "Keine wiederherstellbaren Entwürfe.",
"home.recovery_hint": "SolidCraft wurde geschlossen, bevor diese Entwürfe gespeichert wurden.",
"home.report_issue": "Problem melden",
"home.samples": "Beispiele",
"home.settings": "Einstellungen",
"home.short_date": "{day}. {month}",
"home.shortcuts": "Tastenkombinationen",
"home.start_hint": "Erstellen Sie einen Entwurf, öffnen Sie eine zuletzt verwendete Datei oder erkunden Sie ein Beispiel.",
"home.whats_new": "Neuigkeiten",
"home.yesterday": "Gestern",
"install.button_tip": "SolidCraft als App installieren: eigenes Fenster, funktioniert auch offline",
"install.dock_1": "Wählen Sie Ablage › Zum Dock hinzufügen.",
"install.dock_2": "Klicken Sie auf „Hinzufügen“.",
@@ -929,8 +1074,15 @@
"motion.to_end": "Zum Ende",
"motion.to_start": "Zum Anfang",
"motion.track_name": "Spur {number}",
"nav.alias.help": "Umschalt+Alt+Linksziehen dreht, Umschalt+Alt+Mitte verschiebt, Umschalt+Alt+Rechtsziehen zoomt; mittleres Ziehen verschiebt ebenfalls und rechtes Ziehen dreht",
"nav.blender.help": "Mittlere Maustaste dreht, Umschalt+Mitte verschiebt, Strg+Mitte zoomt; rechtes Ziehen lässt die Kamera unverändert",
"nav.free_orbit.help": "Maus und 3D-Maus drehen über beide Pole hinweg und behalten die Rollneigung bei. Das Einrasten mit Umschalt ist deaktiviert.",
"nav.free_orbit.label": "Freie Drehung",
"nav.fusion.help": "Mittlere Maustaste verschiebt, Umschalt+Mitte dreht, rechte Maustaste dreht",
"nav.inventor.help": "Mittlere Maustaste verschiebt, Umschalt+Mitte dreht, Strg+Mitte zoomt, rechte Maustaste dreht",
"nav.openscad.help": "Linksziehen auf leerem Hintergrund dreht, Umschalt+Linksziehen zieht ein Auswahlrechteck auf, Skizzengeometrie wird durch Ziehen bearbeitet; rechtes oder mittleres Ziehen verschiebt, Umschalt+rechtes oder Umschalt+mittleres Ziehen zoomt",
"nav.solidworks.help": "Mittlere Maustaste dreht, Strg+Mitte verschiebt, Umschalt+Mitte zoomt, rechte Maustaste dreht",
"nav.tinkercad.help": "Rechte Maustaste dreht, mittlere oder Umschalt+rechte Maustaste verschiebt",
"nav.touchpad.help": "Umschalt gedrückt halten und den Zeiger zum Verschieben bewegen; mit Alt/Option drehen. Keine Maustaste nötig. Vertikales Scrollen zoomt.",
"nav.touchpad.label": "Touchpad",
"nav.touchpad.orbit": "Drehen (Alt/Option halten und bewegen; ohne Maustaste)",
+302 -5
View File
@@ -14,11 +14,15 @@
"Active": "Active",
"Active rule": "Active rule",
"Add": "Add",
"Add Column": "Add Column",
"Add Key": "Add Key",
"Add Mid Radius": "Add Mid Radius",
"Add Row": "Add Row",
"Add a tool": "Add a tool",
"Add condition": "Add condition",
"Add or edit rule": "Add or edit rule",
"Add to New Folder": "Add to New Folder",
"Advanced": "Advanced",
"Against": "Against",
"Alignment": "Alignment",
"All Sketch Points": "All Sketch Points",
@@ -32,6 +36,7 @@
"Appearance name": "Appearance name",
"Applied Radius": "Applied Radius",
"Apply To": "Apply To",
"Apply library appearance": "Apply library appearance",
"Apply to selection": "Apply to selection",
"Apply your appearance first. Exports assigned appearances and materials created in this design.": "Apply your appearance first. Exports assigned appearances and materials created in this design.",
"Arc length": "Arc length",
@@ -42,13 +47,18 @@
"BLEND SURFACE": "BLEND SURFACE",
"BOUNDARY SURFACE": "BOUNDARY SURFACE",
"BROWSER": "BROWSER",
"Bend Edges": "Bend Edges",
"Bend Line Position": "Bend Line Position",
"Bend Position": "Bend Position",
"Bend Radius": "Bend Radius",
"Bend Table": "Bend Table",
"Bends": "Bends",
"Bodies": "Bodies",
"Bodies (optional)": "Bodies (optional)",
"Body": "Body",
"Body (last if none)": "Body (last if none)",
"Body to split": "Body to split",
"Body, or faces to take out": "Body, or faces to take out",
"Boundary": "Boundary",
"Bounding box": "Bounding box",
"CHECK": "CHECK",
@@ -60,7 +70,9 @@
"CSV or JSON: updates existing parameters, adds new ones": "CSV or JSON: updates existing parameters, adds new ones",
"Capture All Positions": "Capture All Positions",
"Capture Pos.": "Capture Pos.",
"Catch Depth": "Catch Depth",
"Catch Direction": "Catch Direction",
"Catch Length": "Catch Length",
"Cells": "Cells",
"Center X": "Center X",
"Center Y": "Center Y",
@@ -69,15 +81,21 @@
"Chamfer Type": "Chamfer Type",
"Check collisions": "Check collisions",
"Checked": "Checked",
"Choose Variable radius or Spline in Type.": "Choose Variable radius or Spline in Type.",
"Choose a new plane. Sketch geometry and dimensions stay unchanged in its two-dimensional coordinates.": "Choose a new plane. Sketch geometry and dimensions stay unchanged in its two-dimensional coordinates.",
"Choose…": "Choose…",
"Circular": "Circular",
"Circumference": "Circumference",
"Close draft": "Close draft",
"Collapse": "Collapse",
"Collisions": "Collisions",
"Colour": "Colour",
"Command": "Command",
"Comment": "Comment",
"Component 1": "Component 1",
"Component 2": "Component 2",
"Component1": "Component1",
"Components": "Components",
"Config Table": "Config Table",
"Configuration": "Configuration",
"Configuration rules": "Configuration rules",
@@ -86,6 +104,7 @@
"Contact sets": "Contact sets",
"Continue on GitHub": "Continue on GitHub",
"Continuity": "Continuity",
"Control Radius": "Control Radius",
"Copy Error Message": "Copy Error Message",
"Copy Warning Message": "Copy Warning Message",
"Copy all licences": "Copy all licences",
@@ -95,8 +114,11 @@
"Corner Relief": "Corner Relief",
"Corner Relief Shape": "Corner Relief Shape",
"Counterbore depth": "Counterbore depth",
"Counterbore Ø": "Counterbore Ø",
"Countersink angle": "Countersink angle",
"Countersink Ø": "Countersink Ø",
"Create Issue — review draft": "Create Issue — review draft",
"Create Issue…": "Create Issue…",
"Cross curves": "Cross curves",
"Cube": "Cube",
"Current orthographic view (1:1 mm)": "Current orthographic view (1:1 mm)",
@@ -106,7 +128,11 @@
"Curvature change (G3)": "Curvature change (G3)",
"Custom": "Custom",
"Customize Toolbar": "Customize Toolbar",
"Customize…": "Customize…",
"Cylindrical face": "Cylindrical face",
"Delete": "Delete",
"Delete Selected Features…": "Delete Selected Features…",
"Delete Selected Folders": "Delete Selected Folders",
"Delete all": "Delete all",
"Delete rule": "Delete rule",
"Delete the row": "Delete the row",
@@ -117,8 +143,11 @@
"Describe what happened before continuing.": "Describe what happened before continuing.",
"Designs you open or save appear here.": "Designs you open or save appear here.",
"Determined by To Object. Choose Angle to edit the angular extent.": "Determined by To Object. Choose Angle to edit the angular extent.",
"Diameter": "Diameter",
"Direction": "Direction",
"Discard": "Discard",
"Distance": "Distance",
"Distance 1": "Distance 1",
"Do not export hidden edges": "Do not export hidden edges",
"Don't ask again": "Don't ask again",
"Double-click to rename": "Double-click to rename",
@@ -130,18 +159,22 @@
"Edit Profile Sketch": "Edit Profile Sketch",
"Enable rule": "Enable rule",
"Enabled": "Enabled",
"End Radius": "End Radius",
"End bulge": "End bulge",
"End continuity": "End continuity",
"Expand": "Expand",
"Expected result (optional)": "Expected result (optional)",
"Explode": "Explode",
"Explode Text": "Explode Text",
"Export Animation…": "Export Animation…",
"Export Bend Table…": "Export Bend Table…",
"Export CSV…": "Export CSV…",
"Export Current View as DXF": "Export Current View as DXF",
"Export Current View as DXF…": "Export Current View as DXF…",
"Export DXF": "Export DXF",
"Export DXF…": "Export DXF…",
"Export JSON…": "Export JSON…",
"Export design library...": "Export design library...",
"Export hidden edges as dashed lines": "Export hidden edges as dashed lines",
"Expression": "Expression",
"Extend to meet": "Extend to meet",
@@ -149,25 +182,33 @@
"FILES": "FILES",
"FLAT": "FLAT",
"Face": "Face",
"Face Appearance": "Face Appearance",
"Face attachment lost": "Face attachment lost",
"Faces": "Faces",
"Faces / edges / vertices": "Faces / edges / vertices",
"Faces to split": "Faces to split",
"Fastener": "Fastener",
"Favorites": "Favorites",
"Favourite": "Favourite",
"Feature": "Feature",
"Files from many CAD programs have Y up, so they open lying on their back here (Z up).": "Files from many CAD programs have Y up, so they open lying on their back here (Z up).",
"Filter": "Filter",
"Filter…": "Filter…",
"Find in Browser": "Find in Browser",
"Finish": "Finish",
"First plane": "First plane",
"Fit and G2 corners require Constant radius.": "Fit and G2 corners require Constant radius.",
"Fixed Face": "Fixed Face",
"Flat Pattern": "Flat Pattern",
"Flat size": "Flat size",
"Follow camera (Look At sketches)": "Follow camera (Look At sketches)",
"Follow the camera (Look At sketches)": "Follow the camera (Look At sketches)",
"Font": "Font",
"From": "From",
"From Bodies": "From Bodies",
"From edges": "From edges",
"Full Length": "Full Length",
"G2 corners require a constant radius and Exact fit. G2 corner construction is not supported yet.": "G2 corners require a constant radius and Exact fit. G2 corner construction is not supported yet.",
"Gap": "Gap",
"Grid": "Grid",
"HISTORY": "HISTORY",
@@ -175,6 +216,7 @@
"Hidden edges": "Hidden edges",
"History layout": "History layout",
"History names": "History names",
"Hole (optional)": "Hole (optional)",
"Holes": "Holes",
"Holes (edges on them)": "Holes (edges on them)",
"Home view": "Home view",
@@ -185,14 +227,20 @@
"In place (surfaces)": "In place (surfaces)",
"In plane": "In plane",
"Inside": "Inside",
"Instant": "Instant",
"Interface font": "Interface font",
"Interferes": "Interferes",
"Invert": "Invert",
"Isocurve": "Isocurve",
"Issue title": "Issue title",
"Items": "Items",
"JOINTS": "JOINTS",
"Joint Origin1": "Joint Origin1",
"Keep (points on it)": "Keep (points on it)",
"Keep Tools": "Keep Tools",
"Keep of the other surface": "Keep of the other surface",
"Key a joint's value at this step": "Key a joint's value at this step",
"Keys": "Keys",
"LIBRARY": "LIBRARY",
"LINKS": "LINKS",
"Later": "Later",
@@ -203,9 +251,14 @@
"Letter Spacing": "Letter Spacing",
"Library": "Library",
"Library file": "Library file",
"Limits": "Limits",
"Line Spacing": "Line Spacing",
"Line Type": "Line Type",
"Listing the installed fonts…": "Listing the installed fonts…",
"Load library": "Load library",
"Local library": "Local library",
"Loop": "Loop",
"M5 or M5x0.5": "M5 or M5x0.5",
"MANAGE": "MANAGE",
"MATERIALS": "MATERIALS",
"MODEL": "MODEL",
@@ -216,14 +269,20 @@
"Matched restrictions check the final values.": "Matched restrictions check the final values.",
"Material": "Material",
"Max": "Max",
"Maximum": "Maximum",
"Mesh body": "Mesh body",
"Mid Radius": "Mid Radius",
"Middle double-click": "Middle double-click",
"Min": "Min",
"Minimum": "Minimum",
"Mirror Plane": "Mirror Plane",
"Mitre": "Mitre",
"Model Parameters": "Model Parameters",
"Modeled": "Modeled",
"More toolbar panels": "More toolbar panels",
"Motion": "Motion",
"Mouse gestures": "Mouse gestures",
"Mouse gestures only work with the SolidWorks mouse mode (the wheel orbits, Ctrl + wheel pans).": "Mouse gestures only work with the SolidWorks mouse mode (the wheel orbits, Ctrl + wheel pans).",
"Move / Copy": "Move / Copy",
"Move Type": "Move Type",
"Move down": "Move down",
@@ -246,8 +305,11 @@
"Not available yet": "Not available yet",
"Notch": "Notch",
"OUTPUT": "OUTPUT",
"Of": "Of",
"On your computer": "On your computer",
"Opacity": "Opacity",
"Open GitHub draft": "Open GitHub draft",
"Open library...": "Open library...",
"Open local libraries in the desktop app. You can export this design's library here.": "Open local libraries in the desktop app. You can export this design's library here.",
"Open-source parametric 3D CAD in Rust. Licensed MIT OR Apache-2.0.": "Open-source parametric 3D CAD in Rust. Licensed MIT OR Apache-2.0.",
"Opens your browser and copies the report text for you to paste into GitHub.": "Opens your browser and copies the report text for you to paste into GitHub.",
@@ -269,8 +331,10 @@
"Picked": "Picked",
"Picked point": "Picked point",
"Pitch": "Pitch",
"Pivot": "Pivot",
"Place at the origin instead": "Place at the origin instead",
"Plastic Rules": "Plastic Rules",
"Play the joint's motion in the view": "Play the joint's motion in the view",
"Playback": "Playback",
"Plot": "Plot",
"Position": "Position",
@@ -278,14 +342,18 @@
"Position (G0)": "Position (G0)",
"Position along the edge, 0 at the start end, 1 at the other": "Position along the edge, 0 at the start end, 1 at the other",
"Preferences › Design turns the offer back on": "Preferences › Design turns the offer back on",
"Preview": "Preview",
"Preview Row": "Preview Row",
"Preview leaves the current design unchanged.": "Preview leaves the current design unchanged.",
"Profile / Edges": "Profile / Edges",
"Profiles or curves": "Profiles or curves",
"Profiles/Curves": "Profiles/Curves",
"Quantity": "Quantity",
"RECENT": "RECENT",
"REFERENCE": "REFERENCE",
"RULED SURFACE": "RULED SURFACE",
"RULES": "RULES",
"Radius": "Radius",
"Radius Fit": "Radius Fit",
"Ratio": "Ratio",
"Reattach selected face": "Reattach selected face",
@@ -294,6 +362,8 @@
"Rectangular": "Rectangular",
"Reference face": "Reference face",
"Remove Tools": "Remove Tools",
"Remove condition": "Remove condition",
"Remove from Folder": "Remove from Folder",
"Remove from list": "Remove from list",
"Remove override": "Remove override",
"Remove private design details or paths before sharing.": "Remove private design details or paths before sharing.",
@@ -301,6 +371,9 @@
"Remove the column": "Remove the column",
"Remove the track": "Remove the track",
"Remove this point": "Remove this point",
"Rename Feature": "Rename Feature",
"Rename Folder": "Rename Folder",
"Replace existing library file": "Replace existing library file",
"Report a bug — review a screenshot and open a GitHub draft": "Report a bug — review a screenshot and open a GitHub draft",
"Report text copied. Paste it into GitHub and submit your report there.": "Report text copied. Paste it into GitHub and submit your report there.",
"Reports are public. A GitHub account is required.": "Reports are public. A GitHub account is required.",
@@ -309,6 +382,7 @@
"Restore View": "Restore View",
"Restore outer boundary": "Restore outer boundary",
"Restore the inherited component or physical material appearance": "Restore the inherited component or physical material appearance",
"Restore this studio's tools": "Restore this studio's tools",
"Result": "Result",
"Retake screenshot": "Retake screenshot",
"Reverse": "Reverse",
@@ -319,7 +393,13 @@
"Revert Position": "Revert Position",
"Review the report and screenshot before opening GitHub.": "Review the report and screenshot before opening GitHub.",
"Review your report on GitHub and click Submit new issue to publish it.": "Review your report on GitHub and click Submit new issue to publish it.",
"Rib Length": "Rib Length",
"Rib Offset": "Rib Offset",
"Rib Thickness": "Rib Thickness",
"Ribs": "Ribs",
"Rim Edge": "Rim Edge",
"Ring of tools on the right button": "Ring of tools on the right button",
"Roll Back Here": "Roll Back Here",
"Roll History Marker Here": "Roll History Marker Here",
"Round": "Round",
"Rule": "Rule",
@@ -338,13 +418,18 @@
"Search": "Search",
"Search commands": "Search commands",
"Search this library": "Search this library",
"Second plane": "Second plane",
"Section": "Section",
"Sections (one sketch each)": "Sections (one sketch each)",
"Select Other": "Select Other",
"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).": "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).",
"Select bodies (a face picks its body)": "Select bodies (a face picks its body)",
"Select bodies or faces instead": "Select bodies or faces instead",
"Select edges": "Select edges",
"Selection filter": "Selection filter",
"Semantic faces share their appearance across instances.": "Semantic faces share their appearance across instances.",
"Send your report on GitHub": "Send your report on GitHub",
"Shaded": "Shaded",
"Shape": "Shape",
"Sheet Rules": "Sheet Rules",
"Show Agent Cursor": "Show Agent Cursor",
@@ -362,6 +447,7 @@
"SolidCraft closed before these designs were saved. Their last autosave can be opened.": "SolidCraft closed before these designs were saved. Their last autosave can be opened.",
"Specialise": "Specialise",
"Speed": "Speed",
"Splitting tool": "Splitting tool",
"Stand it up": "Stand it up",
"Stand it up: a quarter turn about X, as one Move feature you can undo.": "Stand it up: a quarter turn about X, as one Move feature you can undo.",
"Stand this part up?": "Stand this part up?",
@@ -372,16 +458,22 @@
"Steps to reproduce (optional)": "Steps to reproduce (optional)",
"Stitch the kept pieces": "Stitch the kept pieces",
"Stitch to both bodies": "Stitch to both bodies",
"Studio": "Studio",
"Study": "Study",
"Style": "Style",
"Summary": "Summary",
"Suppress": "Suppress",
"Suppressed": "Suppressed",
"Surface": "Surface",
"Surface, or faces to turn over": "Surface, or faces to turn over",
"Swap Ends": "Swap Ends",
"TRIM": "TRIM",
"Tangent (G1)": "Tangent (G1)",
"Tangent Chain": "Tangent Chain",
"Tangent chain": "Tangent chain",
"Tap drill diameter": "Tap drill diameter",
"Target": "Target",
"Target Body": "Target Body",
"Tear": "Tear",
"Technical details": "Technical details",
"The dashed sketch is a saved plane guide. Select a new face to repair the attachment.": "The dashed sketch is a saved plane guide. Select a new face to repair the attachment.",
@@ -396,24 +488,31 @@
"This child is shared. Position changes apply to every copy of its parent.": "This child is shared. Position changes apply to every copy of its parent.",
"This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.",
"This image is not attached to GitHub yet. Copy or save it, then attach it on GitHub.": "This image is not attached to GitHub yet. Copy or save it, then attach it on GitHub.",
"To": "To",
"To Offset": "To Offset",
"To edges": "To edges",
"Tolerance": "Tolerance",
"Too close": "Too close",
"Tool Bodies": "Tool Bodies",
"Tools": "Tools",
"Tools, clockwise from the top": "Tools, clockwise from the top",
"Trimming tool (plane, face or curves)": "Trimming tool (plane, face or curves)",
"Turn off section analysis before exporting the view.": "Turn off section analysis before exporting the view.",
"Type": "Type",
"UNTRIM": "UNTRIM",
"Unit": "Unit",
"Unsuppress": "Unsuppress",
"Updating the preview": "Updating the preview",
"Use active author placement": "Use active author placement",
"Use fixed plane": "Use fixed plane",
"Use saved fixed plane": "Use saved fixed plane",
"Use selected face as fixed plane": "Use selected face as fixed plane",
"Use the SolidWorks mouse mode": "Use the SolidWorks mouse mode",
"User Parameters": "User Parameters",
"VIEW": "VIEW",
"VIEWS": "VIEWS",
"Value": "Value",
"Variable Radius": "Variable Radius",
"Velocity": "Velocity",
"Vertices": "Vertices",
"View navigator": "View navigator",
@@ -427,13 +526,44 @@
"When": "When",
"Width": "Width",
"Will fail": "Will fail",
"With gestures on, the right button no longer orbits the view: hold it and move to open the ring, then touch a tool's zone. A plain right-click still opens the menu.": "With gestures on, the right button no longer orbits the view: hold it and move to open the ring, then touch a tool's zone. A plain right-click still opens the menu.",
"X": "X",
"X Angle": "X Angle",
"X angle": "X angle",
"X distance": "X distance",
"Y": "Y",
"Y Angle": "Y Angle",
"Y angle": "Y angle",
"Y distance": "Y distance",
"Z": "Z",
"Z Angle": "Z Angle",
"Z Distance": "Z Distance",
"Z angle": "Z angle",
"Z distance": "Z distance",
"Zebra": "Zebra",
"ai.prefs.availability": "Assistant controls",
"ai.prefs.background_commands": "Run agent commands in the background",
"ai.prefs.follow_camera": "Turn the camera to sketches",
"ai.prefs.follow_camera_off": "Automatic camera following is off.",
"ai.prefs.follow_camera_on": "The camera turns to face sketches the agent creates.",
"ai.prefs.intro": "Agents, permissions and how their work appears.",
"ai.prefs.make_default": "Use by default",
"ai.prefs.no_agents": "No agents yet. Add an agent or refresh to find installed agents.",
"ai.prefs.no_saved_approvals": "No saved approvals.",
"ai.prefs.override_all_warning": "This agent may save, overwrite, export or replace the design without asking.",
"ai.prefs.permission_all": "Allow everything",
"ai.prefs.permission_all_summary": "Available SolidCraft tools run without asking.",
"ai.prefs.permission_ask": "Ask before changes",
"ai.prefs.permission_ask_summary": "Approve every design change and file action.",
"ai.prefs.permission_edits": "Allow model edits",
"ai.prefs.permission_edits_summary": "Model edits run; file and destructive actions still ask.",
"ai.prefs.permission_scope": "For SolidCraft tools only. The agent’s shell and file edits use its own permissions.",
"ai.prefs.pointer_preview_hidden": "Pointer hidden",
"ai.prefs.pointer_preview_speed": "Agent pointer · {speed}",
"ai.prefs.pointer_speed": "Motion speed",
"ai.prefs.privacy_note": "Only your chosen agent or endpoint receives the conversation. Endpoint keys are stored in your system keychain.",
"ai.prefs.show_pointer": "Show agent pointer",
"ai.prefs.turn_limit_hint": "Maximum design-changing tool calls the Assistant may make for one message.",
"angle, height": "angle, height",
"appearance.dielectric_ior": "Dielectric IOR",
"appearance.metallic": "Metallic",
@@ -889,19 +1019,57 @@
"help.solid.revolve": "Spins a sketch profile around an axis to make a round solid. Use it for shafts, knobs, bowls and any part that is round about a centre line.",
"help.solid.shell": "Hollows out a solid, leaving walls of the thickness you set, and can open the faces you pick. Use it for boxes, covers and other thin-walled parts.",
"help.third_party": "Third-party licences",
"home.back": "Back to {name}",
"home.crashes": "Crash reports",
"home.design_workspace": "Design",
"home.documentation": "Documentation",
"home.export": "Export",
"home.export_hint": "Choose a format for the current design.",
"home.export_json_hint": "Editable design and command history",
"home.export_mesh_hint": "Mesh for printing or sharing",
"home.export_step_hint": "Precise solid geometry",
"home.import": "Import…",
"home.import_hint": "STEP, IGES or mesh into a new design",
"home.in_this_browser": "in this browser",
"home.missing": "missing",
"home.month_01": "Jan",
"home.month_02": "Feb",
"home.month_03": "Mar",
"home.month_04": "Apr",
"home.month_05": "May",
"home.month_06": "Jun",
"home.month_07": "Jul",
"home.month_08": "Aug",
"home.month_09": "Sep",
"home.month_10": "Oct",
"home.month_11": "Nov",
"home.month_12": "Dec",
"home.new_inches": "New design, inches",
"home.new_inches_hint": "Inches · opens in Design",
"home.new_mm": "New design",
"home.new_mm_hint": "Millimetres · opens in Design",
"home.open": "Open…",
"home.opened_days_ago": "Opened {count} days ago",
"home.opened_hours_ago": "Opened {count} h ago",
"home.opened_just_now": "Opened just now",
"home.opened_minutes_ago": "Opened {count} min ago",
"home.open_hint": "A .solidcraft design ({shortcut})",
"home.open_now": "Open now",
"home.opened_days_ago": "{count} days ago",
"home.opened_hours_ago": "{count} h ago",
"home.opened_just_now": "Just now",
"home.opened_minutes_ago": "{count} min ago",
"home.opening": "opening…",
"home.properties": "Design properties",
"home.recent": "Recent",
"home.recent_empty": "Designs you open or save appear here.",
"home.recovery_count": "Recoverable designs: {count}",
"home.recovery_empty": "No recoverable designs.",
"home.recovery_hint": "SolidCraft closed before these designs were saved.",
"home.report_issue": "Report an Issue",
"home.samples": "Samples",
"home.settings": "Settings",
"home.short_date": "{day} {month}",
"home.shortcuts": "Keyboard shortcuts",
"home.start_hint": "Create a design, open a recent file, or explore a sample.",
"home.whats_new": "What's new",
"home.yesterday": "Yesterday",
"install.button_tip": "Install SolidCraft as an app: it opens in its own window and works offline",
"install.dock_1": "Choose File › Add to Dock.",
"install.dock_2": "Click Add.",
@@ -959,8 +1127,15 @@
"motion.to_end": "To the end",
"motion.to_start": "To the start",
"motion.track_name": "Track {number}",
"nav.alias.help": "Shift+Alt+left drag orbits, Shift+Alt+middle pans, Shift+Alt+right zooms; middle drag also pans and right drag orbits",
"nav.blender.help": "Middle drag orbits, Shift+middle pans, Ctrl+middle zooms; right drag leaves the camera alone",
"nav.free_orbit.help": "Mouse and 3D mouse rotate through both poles and preserve roll. Shift axis snapping is disabled.",
"nav.free_orbit.label": "Free orbit",
"nav.fusion.help": "Middle drag pans, Shift+middle orbits, right drag orbits",
"nav.inventor.help": "Middle drag pans, Shift+middle orbits, Ctrl+middle zooms, right drag orbits",
"nav.openscad.help": "Left drag on empty space orbits, Shift+left drag box-selects, sketch geometry drags edit; right or middle drag pans, Shift+right or Shift+middle drag zooms",
"nav.solidworks.help": "Middle drag orbits, Ctrl+middle pans, Shift+middle zooms, right drag orbits",
"nav.tinkercad.help": "Right drag orbits, middle or Shift+right drag pans",
"nav.touchpad.help": "Hold Shift and move the pointer to pan; hold Alt/Option and move it to orbit. No mouse button is needed. Vertical scroll zooms.",
"nav.touchpad.label": "Touchpad",
"nav.touchpad.orbit": "Orbit (hold Alt/Option and move; no mouse button)",
@@ -983,8 +1158,130 @@
"pattern.flip_direction": "Flip direction",
"pick a hole to seat it, or OK places it at the origin": "pick a hole to seat it, or OK places it at the origin",
"pick one or two vertices, edges or faces": "pick one or two vertices, edges or faces",
"prefs.add_item": "Add the item",
"prefs.alt_frees_manipulators": "Alt / Option also frees manipulator arrows",
"prefs.anti_aliasing_note": "Smooths jagged edges.",
"prefs.applies_on_restart": "Applies on restart",
"prefs.assistant": "Assistant",
"prefs.axes": "Axes",
"prefs.axis": "Axis",
"prefs.bottom": "Bottom",
"prefs.browser": "Browser",
"prefs.built_in": "Built in",
"prefs.button_left": "Left drag",
"prefs.button_middle": "Middle drag",
"prefs.button_right": "Right drag",
"prefs.choose_font": "Choose font file…",
"prefs.click": "Click",
"prefs.contact_shading": "Soft contact shading",
"prefs.contact_shading_note": "Soft shading where parts come close.",
"prefs.context_menu": "Context menu",
"prefs.context_menu_ring": "Context menu · hold for the radial menu",
"prefs.cpu": "CPU (software)",
"prefs.crash_reports": "Open crash reports…",
"prefs.crash_reports_note": "Reports are kept on this computer, with the design and commands that reproduce the problem.",
"prefs.ctrl_cmd": "Ctrl / Cmd",
"prefs.curved_surfaces": "Curved surfaces",
"prefs.curves": "Curved surfaces",
"prefs.dead_zone_note": "The hatched band is the dead zone: motion inside it is ignored.",
"prefs.default": "default",
"prefs.design": "Design defaults",
"prefs.design_note": "What new designs start with. Open designs keep their own.",
"prefs.devices": "3D mouse",
"prefs.display": "Display",
"prefs.edges": "Edges and shading",
"prefs.extend": "Extend",
"prefs.extra_navigation": "Ctrl+left drag on empty space orbits; hold Space to pan.",
"prefs.follows_os": "Follows the OS",
"prefs.font": "Font",
"prefs.font_fallback": "Glyphs the chosen font lacks fall back to the built-in font, so no label shows an empty box.",
"prefs.font_sample": "The quick brown fox · Ø12.50 mm · 45.0° · 0123456789",
"prefs.font_unreadable": "The selected font could not be read.",
"prefs.general": "General",
"prefs.general_note": "Language, saving and updates.",
"prefs.gesture_orbit": "Orbit",
"prefs.gesture_pan": "Pan",
"prefs.gesture_zoom": "Zoom",
"prefs.graphics": "Graphics",
"prefs.graphics_note": "Trade speed for smoothness. Each choice shows its effect on a sample.",
"prefs.graphics_presets_note": "Presets set the choices below together.",
"prefs.history": "History",
"prefs.hold_stop_snapping": "Hold to stop snapping",
"prefs.icons_only": "Icons only",
"prefs.imported_file": "Imported file",
"prefs.input": "Input",
"prefs.interface": "Interface",
"prefs.interface_language": "Interface language",
"prefs.interface_note": "How SolidCraft looks and where its parts sit.",
"prefs.interval_minutes": "{n} min",
"prefs.labels": "Labels",
"prefs.layout": "Layout",
"prefs.layout_note": "Compact docks panels to the window edges. Browser translucency is available in Floating. Change the Assistant side on the Assistant page.",
"prefs.left": "Left",
"prefs.left_click": "Left click",
"prefs.live_input": "Live input",
"prefs.live_preview": "Live preview",
"prefs.minutes": "minutes",
"prefs.minutes_suffix": " min",
"prefs.mouse": "Mouse and trackpad",
"prefs.mouse_heading": "Mouse",
"prefs.mouse_mapping": "Mouse mapping",
"prefs.navigation": "Navigation",
"prefs.navigation_note": "How the mouse, trackpad and 3D mouse move the view.",
"prefs.not_on_gpu": "not on this GPU",
"prefs.on": "On",
"prefs.opaque": "Opaque",
"prefs.orbit_snap": "Hold Shift during orbit to snap to an axis.",
"prefs.orientation": "Orientation",
"prefs.overall_speed": "Overall speed",
"prefs.preview_choices": "Preview of your choices",
"prefs.quality_default": "default",
"prefs.quality_fastest": "fastest",
"prefs.quality_slowest": "slowest",
"prefs.quality_smoother": "smoother",
"prefs.reports_saved": "{n} saved",
"prefs.right_click": "Right click",
"prefs.saving": "Autosave and recovery",
"prefs.scale_note": "Also Ctrl + / Ctrl − in the window. Buttons are 28 px at 100%.",
"prefs.scale_text": "Scale and text",
"prefs.select": "Select",
"prefs.select_one_item": "Select one item",
"prefs.selecting": "Selecting",
"prefs.shift": "Shift",
"prefs.shift_add_ctrl_toggle": "Shift adds only; Ctrl / Cmd toggles",
"prefs.shift_add_note": "On: Shift adds, Ctrl / Cmd toggles. Off (default): both toggle.",
"prefs.shortcuts": "Shortcuts",
"prefs.sketch_selection": "Sketch & selection",
"prefs.sketch_selection_heading": "Sketch and selection",
"prefs.sketch_selection_note": "How sketches snap and how clicks select.",
"prefs.sketching": "Sketching",
"prefs.snap_drawing_note": "While drawing or dragging a sketch point.",
"prefs.source": "Source",
"prefs.speed": "Speed",
"prefs.stand_up_note": "After a STEP or IGES file opens with Z up, offer a quarter turn about X.",
"prefs.status_done": "Done",
"prefs.status_error": "Error",
"prefs.status_selected": "Selected",
"prefs.status_warning": "Warning",
"prefs.supersampling_brief": "Draws the view larger and filters it down: very crisp, and heavier.",
"prefs.tab_accent_note": "The accent follows the active tab",
"prefs.theme_file": "theme file",
"prefs.timeline_half_hour": "−30 min",
"prefs.timeline_now": "now",
"prefs.timeline_quarter_hour": "−15",
"prefs.toggle_item": "Add the item, or take it out again",
"prefs.toolbar": "Toolbar",
"prefs.translucent": "Translucent",
"prefs.under_browser": "Under browser",
"prefs.under_cursor": "Under cursor",
"prefs.units_precision": "Units and precision",
"prefs.updates": "Updates",
"prefs.view": "View behavior",
"prefs.viewport": "Viewport",
"prefs.wheel": "Wheel",
"prefs.wheel_trackpad": "Wheel and trackpad",
"prefs.workspace": "Workspace",
"prefs.zoom_toward_cursor": "Zoom, toward the cursor",
"properties.title": "Properties: {name}",
"recovery.days_ago": "{count} days ago",
"recovery.detail": "{file} · {features} features · autosaved {when}",
@@ -1055,7 +1352,7 @@
"search.suggest": "Did you mean {label}?",
"search.try": "Type a command name, a feature in this design, or a parameter. To run a command with values, type its id and a JSON object: {example}",
"search.valid": "Valid",
"settings.display_quality_note": "Finer levels draw curved faces and edges smoother and take longer to mesh. Only the viewport changes, never the design or exports.",
"settings.display_quality_note": "Only the display. Designs and exports are not affected.",
"settings.frame_rate_note": "Shows frames per second in the view's bottom-right corner. While it is on, the view redraws continuously.",
"settings.measure_axis_distances": "Show X/Y/Z distances",
"settings.measure_axis_distances_help": "Show world-axis component dimensions alongside the total distance while Measure is open.",
+42 -4
View File
@@ -857,19 +857,57 @@
"help.mit": "Licencia MIT",
"help.notice": "NOTICE",
"help.third_party": "Licencias de terceros",
"home.back": "Volver a {name}",
"home.crashes": "Informes de fallos",
"home.design_workspace": "Diseño",
"home.documentation": "Documentación",
"home.export": "Exportar",
"home.export_hint": "Elige un formato para el diseño actual.",
"home.export_json_hint": "Diseño editable e historial de comandos",
"home.export_mesh_hint": "Malla para imprimir o compartir",
"home.export_step_hint": "Geometría sólida precisa",
"home.import": "Importar…",
"home.import_hint": "STEP, IGES o malla en un nuevo diseño",
"home.in_this_browser": "en este navegador",
"home.missing": "no disponible",
"home.month_01": "ene",
"home.month_02": "feb",
"home.month_03": "mar",
"home.month_04": "abr",
"home.month_05": "may",
"home.month_06": "jun",
"home.month_07": "jul",
"home.month_08": "ago",
"home.month_09": "sept",
"home.month_10": "oct",
"home.month_11": "nov",
"home.month_12": "dic",
"home.new_inches": "Nuevo diseño en pulgadas",
"home.new_inches_hint": "Pulgadas · se abre en Diseño",
"home.new_mm": "Nuevo diseño",
"home.new_mm_hint": "Milímetros · se abre en Diseño",
"home.open": "Abrir…",
"home.opened_days_ago": "Abierto hace {count} días",
"home.opened_hours_ago": "Abierto hace {count} h",
"home.opened_just_now": "Acaba de abrirse",
"home.opened_minutes_ago": "Abierto hace {count} min",
"home.open_hint": "Un diseño .solidcraft ({shortcut})",
"home.open_now": "Abrir ahora",
"home.opened_days_ago": "Hace {count} días",
"home.opened_hours_ago": "Hace {count} h",
"home.opened_just_now": "Ahora mismo",
"home.opened_minutes_ago": "Hace {count} min",
"home.opening": "abriendo…",
"home.properties": "Propiedades del diseño",
"home.recent": "Recientes",
"home.recent_empty": "Los diseños que abras o guardes aparecerán aquí.",
"home.recovery_count": "Diseños recuperables: {count}",
"home.recovery_empty": "No hay diseños recuperables.",
"home.recovery_hint": "SolidCraft se cerró antes de guardar estos diseños.",
"home.report_issue": "Notificar un problema",
"home.samples": "Ejemplos",
"home.settings": "Configuración",
"home.short_date": "{day} {month}",
"home.shortcuts": "Atajos de teclado",
"home.start_hint": "Crea un diseño, abre un archivo reciente o explora un ejemplo.",
"home.whats_new": "Novedades",
"home.yesterday": "Ayer",
"install.button_tip": "Instalar SolidCraft como aplicación: se abre en una ventana propia y funciona sin conexión",
"install.dock_1": "Elige Archivo › Añadir al Dock.",
"install.dock_2": "Haz clic en Añadir.",
+42 -4
View File
@@ -863,19 +863,57 @@
"help.solid.revolve": "Fait tourner le profil d'une esquisse autour d'un axe pour obtenir un solide de révolution. À utiliser pour les arbres, boutons, bols et toute pièce ronde autour d'une ligne centrale.",
"help.solid.shell": "Évide un solide en laissant des parois de l'épaisseur indiquée, et peut ouvrir les faces choisies. À utiliser pour les boîtes, couvercles et autres pièces à parois minces.",
"help.third_party": "Licences tierces",
"home.back": "Revenir à {name}",
"home.crashes": "Rapports de plantage",
"home.design_workspace": "Conception",
"home.documentation": "Documentation",
"home.export": "Exporter",
"home.export_hint": "Choisissez un format pour la conception actuelle.",
"home.export_json_hint": "Conception modifiable et historique des commandes",
"home.export_mesh_hint": "Maillage pour l’impression ou le partage",
"home.export_step_hint": "Géométrie solide précise",
"home.import": "Importer…",
"home.import_hint": "STEP, IGES ou maillage dans une nouvelle conception",
"home.in_this_browser": "dans ce navigateur",
"home.missing": "introuvable",
"home.month_01": "janv.",
"home.month_02": "févr.",
"home.month_03": "mars",
"home.month_04": "avr.",
"home.month_05": "mai",
"home.month_06": "juin",
"home.month_07": "juil.",
"home.month_08": "août",
"home.month_09": "sept.",
"home.month_10": "oct.",
"home.month_11": "nov.",
"home.month_12": "déc.",
"home.new_inches": "Nouvelle conception en pouces",
"home.new_inches_hint": "Pouces · ouvre l’espace Conception",
"home.new_mm": "Nouvelle conception",
"home.new_mm_hint": "Millimètres · ouvre l’espace Conception",
"home.open": "Ouvrir…",
"home.opened_days_ago": "Ouvert il y a {count} j",
"home.opened_hours_ago": "Ouvert il y a {count} h",
"home.opened_just_now": "Ouvert à l'instant",
"home.opened_minutes_ago": "Ouvert il y a {count} min",
"home.open_hint": "Une conception .solidcraft ({shortcut})",
"home.open_now": "Ouvrir maintenant",
"home.opened_days_ago": "Il y a {count} j",
"home.opened_hours_ago": "Il y a {count} h",
"home.opened_just_now": "À l’instant",
"home.opened_minutes_ago": "Il y a {count} min",
"home.opening": "ouverture…",
"home.properties": "Propriétés de la conception",
"home.recent": "Récents",
"home.recent_empty": "Les conceptions que vous ouvrez ou enregistrez apparaissent ici.",
"home.recovery_count": "Conceptions récupérables : {count}",
"home.recovery_empty": "Aucune conception récupérable.",
"home.recovery_hint": "SolidCraft s’est fermé avant l’enregistrement de ces conceptions.",
"home.report_issue": "Signaler un problème",
"home.samples": "Exemples",
"home.settings": "Paramètres",
"home.short_date": "{day} {month}",
"home.shortcuts": "Raccourcis clavier",
"home.start_hint": "Créez une conception, ouvrez un fichier récent ou explorez un exemple.",
"home.whats_new": "Nouveautés",
"home.yesterday": "Hier",
"install.button_tip": "Installer SolidCraft comme une app : il s'ouvre dans sa propre fenêtre et fonctionne hors ligne",
"install.dock_1": "Choisissez Fichier › Ajouter au Dock.",
"install.dock_2": "Cliquez sur Ajouter.",
+42 -4
View File
@@ -857,19 +857,57 @@
"help.mit": "Licenza MIT",
"help.notice": "NOTICE",
"help.third_party": "Licenzas de terceiros",
"home.back": "Volver a {name}",
"home.crashes": "Informes de fallos",
"home.design_workspace": "Deseño",
"home.documentation": "Documentación",
"home.export": "Exportar",
"home.export_hint": "Escolle un formato para o deseño actual.",
"home.export_json_hint": "Deseño editable e historial de comandos",
"home.export_mesh_hint": "Malla para imprimir ou compartir",
"home.export_step_hint": "Xeometría sólida precisa",
"home.import": "Importar…",
"home.import_hint": "STEP, IGES ou malla nun novo deseño",
"home.in_this_browser": "neste navegador",
"home.missing": "non atopado",
"home.month_01": "xan.",
"home.month_02": "feb.",
"home.month_03": "mar.",
"home.month_04": "abr.",
"home.month_05": "maio",
"home.month_06": "xuño",
"home.month_07": "xul.",
"home.month_08": "ago.",
"home.month_09": "set.",
"home.month_10": "out.",
"home.month_11": "nov.",
"home.month_12": "dec.",
"home.new_inches": "Novo deseño en polgadas",
"home.new_inches_hint": "Polgadas · ábrese en Deseño",
"home.new_mm": "Novo deseño",
"home.new_mm_hint": "Milímetros · ábrese en Deseño",
"home.open": "Abrir…",
"home.opened_days_ago": "Aberto hai {count} días",
"home.opened_hours_ago": "Aberto hai {count} h",
"home.opened_just_now": "Aberto agora mesmo",
"home.opened_minutes_ago": "Aberto hai {count} min",
"home.open_hint": "Un deseño .solidcraft ({shortcut})",
"home.open_now": "Abrir agora",
"home.opened_days_ago": "Hai {count} días",
"home.opened_hours_ago": "Hai {count} h",
"home.opened_just_now": "Agora mesmo",
"home.opened_minutes_ago": "Hai {count} min",
"home.opening": "abrindo…",
"home.properties": "Propiedades do deseño",
"home.recent": "Recentes",
"home.recent_empty": "Os deseños que abras ou gardes aparecerán aquí.",
"home.recovery_count": "Deseños recuperables: {count}",
"home.recovery_empty": "Non hai deseños recuperables.",
"home.recovery_hint": "SolidCraft pechouse antes de gardar estes deseños.",
"home.report_issue": "Informar dunha incidencia",
"home.samples": "Exemplos",
"home.settings": "Configuración",
"home.short_date": "{day} {month}",
"home.shortcuts": "Atallos de teclado",
"home.start_hint": "Crea un deseño, abre un ficheiro recente ou explora un exemplo.",
"home.whats_new": "Novidades",
"home.yesterday": "Onte",
"install.button_tip": "Instala SolidCraft como aplicación: ábrese na súa propia xanela e funciona sen conexión",
"install.dock_1": "Escolle Ficheiro › Engadir ao Dock.",
"install.dock_2": "Fai clic en Engadir.",
+42 -4
View File
@@ -856,19 +856,57 @@
"help.mit": "MIT licenc",
"help.notice": "NOTICE",
"help.third_party": "Harmadik felek licencei",
"home.back": "Vissza ide: {name}",
"home.crashes": "Összeomlási jelentések",
"home.design_workspace": "Tervezés",
"home.documentation": "Dokumentáció",
"home.export": "Exportálás",
"home.export_hint": "Válasszon formátumot az aktuális tervhez.",
"home.export_json_hint": "Szerkeszthető terv és parancselőzmények",
"home.export_mesh_hint": "Háló nyomtatáshoz vagy megosztáshoz",
"home.export_step_hint": "Pontos testgeometria",
"home.import": "Importálás…",
"home.import_hint": "STEP, IGES vagy háló importálása új tervbe",
"home.in_this_browser": "ebben a böngészőben",
"home.missing": "hiányzik",
"home.month_01": "jan.",
"home.month_02": "febr.",
"home.month_03": "márc.",
"home.month_04": "ápr.",
"home.month_05": "máj.",
"home.month_06": "jún.",
"home.month_07": "júl.",
"home.month_08": "aug.",
"home.month_09": "szept.",
"home.month_10": "okt.",
"home.month_11": "nov.",
"home.month_12": "dec.",
"home.new_inches": "Új terv hüvelykben",
"home.new_inches_hint": "Hüvelyk · a Tervezés munkaterületen nyílik meg",
"home.new_mm": "Új terv",
"home.new_mm_hint": "Milliméter · a Tervezés munkaterületen nyílik meg",
"home.open": "Megnyitás…",
"home.opened_days_ago": "Megnyitva {count} napja",
"home.opened_hours_ago": "Megnyitva {count} órája",
"home.opened_just_now": "Épp most megnyitva",
"home.opened_minutes_ago": "Megnyitva {count} perce",
"home.open_hint": "Egy .solidcraft terv ({shortcut})",
"home.open_now": "Jelenleg megnyitva",
"home.opened_days_ago": "{count} napja",
"home.opened_hours_ago": "{count} órája",
"home.opened_just_now": "Épp most",
"home.opened_minutes_ago": "{count} perce",
"home.opening": "megnyitás…",
"home.properties": "Terv tulajdonságai",
"home.recent": "Legutóbbi",
"home.recent_empty": "A megnyitott vagy mentett tervek itt jelennek meg.",
"home.recovery_count": "Helyreállítható tervek: {count}",
"home.recovery_empty": "Nincs helyreállítható terv.",
"home.recovery_hint": "A SolidCraft a tervek mentése előtt bezárult.",
"home.report_issue": "Hiba jelentése",
"home.samples": "Minták",
"home.settings": "Beállítások",
"home.short_date": "{month} {day}.",
"home.shortcuts": "Billentyűparancsok",
"home.start_hint": "Hozzon létre egy tervet, nyisson meg egy legutóbbi fájlt, vagy fedezzen fel egy mintát.",
"home.whats_new": "Újdonságok",
"home.yesterday": "Tegnap",
"install.button_tip": "A SolidCraft telepítése alkalmazásként: saját ablakban nyílik meg, és offline is működik",
"install.dock_1": "Válassza a Fájl › Hozzáadás a Dockhoz lehetőséget.",
"install.dock_2": "Kattintson a Hozzáadás gombra.",
+42 -4
View File
@@ -856,19 +856,57 @@
"help.mit": "Lisensi MIT",
"help.notice": "NOTICE",
"help.third_party": "Lisensi pihak ketiga",
"home.back": "Kembali ke {name}",
"home.crashes": "Laporan kerusakan",
"home.design_workspace": "Desain",
"home.documentation": "Dokumentasi",
"home.export": "Ekspor",
"home.export_hint": "Pilih format untuk desain saat ini.",
"home.export_json_hint": "Desain yang dapat diedit dan riwayat perintah",
"home.export_mesh_hint": "Mesh untuk pencetakan atau berbagi",
"home.export_step_hint": "Geometri benda padat yang presisi",
"home.import": "Impor…",
"home.import_hint": "STEP, IGES, atau mesh ke dalam desain baru",
"home.in_this_browser": "di peramban ini",
"home.missing": "hilang",
"home.month_01": "Jan",
"home.month_02": "Feb",
"home.month_03": "Mar",
"home.month_04": "Apr",
"home.month_05": "Mei",
"home.month_06": "Jun",
"home.month_07": "Jul",
"home.month_08": "Agu",
"home.month_09": "Sep",
"home.month_10": "Okt",
"home.month_11": "Nov",
"home.month_12": "Des",
"home.new_inches": "Desain baru dalam inci",
"home.new_inches_hint": "Inci · terbuka di Desain",
"home.new_mm": "Desain baru",
"home.new_mm_hint": "Milimeter · terbuka di Desain",
"home.open": "Buka…",
"home.opened_days_ago": "Dibuka {count} hari lalu",
"home.opened_hours_ago": "Dibuka {count} jam lalu",
"home.opened_just_now": "Baru saja dibuka",
"home.opened_minutes_ago": "Dibuka {count} mnt lalu",
"home.open_hint": "Desain .solidcraft ({shortcut})",
"home.open_now": "Sedang terbuka",
"home.opened_days_ago": "{count} hari lalu",
"home.opened_hours_ago": "{count} jam lalu",
"home.opened_just_now": "Baru saja",
"home.opened_minutes_ago": "{count} mnt lalu",
"home.opening": "membuka…",
"home.properties": "Properti desain",
"home.recent": "Terbaru",
"home.recent_empty": "Desain yang Anda buka atau simpan akan muncul di sini.",
"home.recovery_count": "Desain yang dapat dipulihkan: {count}",
"home.recovery_empty": "Tidak ada desain yang dapat dipulihkan.",
"home.recovery_hint": "SolidCraft ditutup sebelum desain ini disimpan.",
"home.report_issue": "Laporkan Masalah",
"home.samples": "Contoh",
"home.settings": "Pengaturan",
"home.short_date": "{day} {month}",
"home.shortcuts": "Pintasan papan ketik",
"home.start_hint": "Buat desain, buka berkas terbaru, atau jelajahi contoh.",
"home.whats_new": "Yang baru",
"home.yesterday": "Kemarin",
"install.button_tip": "Pasang SolidCraft sebagai aplikasi: terbuka di jendelanya sendiri dan berfungsi offline",
"install.dock_1": "Pilih File › Add to Dock.",
"install.dock_2": "Klik Add.",
+42 -4
View File
@@ -860,19 +860,57 @@
"help.mit": "Licenza MIT",
"help.notice": "NOTICE",
"help.third_party": "Licenze di terze parti",
"home.back": "Torna a {name}",
"home.crashes": "Rapporti di arresto anomalo",
"home.design_workspace": "Progettazione",
"home.documentation": "Documentazione",
"home.export": "Esporta",
"home.export_hint": "Scegli un formato per il progetto attuale.",
"home.export_json_hint": "Progetto modificabile e cronologia dei comandi",
"home.export_mesh_hint": "Mesh per la stampa o la condivisione",
"home.export_step_hint": "Geometria solida precisa",
"home.import": "Importa…",
"home.import_hint": "STEP, IGES o mesh in un nuovo progetto",
"home.in_this_browser": "in questo browser",
"home.missing": "non presente",
"home.month_01": "gen",
"home.month_02": "feb",
"home.month_03": "mar",
"home.month_04": "apr",
"home.month_05": "mag",
"home.month_06": "giu",
"home.month_07": "lug",
"home.month_08": "ago",
"home.month_09": "set",
"home.month_10": "ott",
"home.month_11": "nov",
"home.month_12": "dic",
"home.new_inches": "Nuovo progetto in pollici",
"home.new_inches_hint": "Pollici · si apre in Progettazione",
"home.new_mm": "Nuovo progetto",
"home.new_mm_hint": "Millimetri · si apre in Progettazione",
"home.open": "Apri…",
"home.opened_days_ago": "Aperto {count} giorni fa",
"home.opened_hours_ago": "Aperto {count} h fa",
"home.opened_just_now": "Appena aperto",
"home.opened_minutes_ago": "Aperto {count} min fa",
"home.open_hint": "Un progetto .solidcraft ({shortcut})",
"home.open_now": "Apri ora",
"home.opened_days_ago": "{count} giorni fa",
"home.opened_hours_ago": "{count} h fa",
"home.opened_just_now": "Adesso",
"home.opened_minutes_ago": "{count} min fa",
"home.opening": "apertura…",
"home.properties": "Proprietà del progetto",
"home.recent": "Recenti",
"home.recent_empty": "I progetti che apri o salvi vengono visualizzati qui.",
"home.recovery_count": "Progetti recuperabili: {count}",
"home.recovery_empty": "Nessun progetto recuperabile.",
"home.recovery_hint": "SolidCraft si è chiuso prima del salvataggio di questi progetti.",
"home.report_issue": "Segnala un problema",
"home.samples": "Esempi",
"home.settings": "Impostazioni",
"home.short_date": "{day} {month}",
"home.shortcuts": "Scorciatoie da tastiera",
"home.start_hint": "Crea un progetto, apri un file recente o esplora un esempio.",
"home.whats_new": "Novità",
"home.yesterday": "Ieri",
"install.button_tip": "Installa SolidCraft come applicazione: si apre in una finestra dedicata e funziona offline",
"install.dock_1": "Scegli File › Aggiungi al Dock.",
"install.dock_2": "Fai clic su Aggiungi.",
+302 -5
View File
@@ -14,11 +14,15 @@
"Active": "アクティブ",
"Active rule": "有効なルール",
"Add": "追加",
"Add Column": "列を追加",
"Add Key": "キーを追加",
"Add Mid Radius": "中間半径を追加",
"Add Row": "行を追加",
"Add a tool": "ツールを追加",
"Add condition": "条件を追加",
"Add or edit rule": "ルールを追加または編集",
"Add to New Folder": "新しいフォルダーに追加",
"Advanced": "詳細",
"Against": "相手",
"Alignment": "配置",
"All Sketch Points": "すべてのスケッチ点",
@@ -32,6 +36,7 @@
"Appearance name": "外観名",
"Applied Radius": "適用半径",
"Apply To": "適用先",
"Apply library appearance": "ライブラリの外観を適用",
"Apply to selection": "選択項目に適用",
"Apply your appearance first. Exports assigned appearances and materials created in this design.": "先に外観を適用してください。割り当てた外観と、このデザインで作成したマテリアルを書き出します。",
"Arc length": "円弧の長さ",
@@ -42,13 +47,18 @@
"BLEND SURFACE": "ブレンドサーフェス",
"BOUNDARY SURFACE": "境界サーフェス",
"BROWSER": "ブラウザ",
"Bend Edges": "曲げエッジ",
"Bend Line Position": "曲げ線位置",
"Bend Position": "曲げ位置",
"Bend Radius": "曲げ半径",
"Bend Table": "曲げテーブル",
"Bends": "曲げ",
"Bodies": "ボディ",
"Bodies (optional)": "ボディ (オプション)",
"Body": "ボディ",
"Body (last if none)": "ボディ (未指定時は最後)",
"Body to split": "分割するボディ",
"Body, or faces to take out": "ボディ、または取り除く面",
"Boundary": "境界",
"Bounding box": "境界ボックス",
"CHECK": "チェック",
@@ -60,7 +70,9 @@
"CSV or JSON: updates existing parameters, adds new ones": "CSVまたはJSON:既存のパラメータを更新し、新しいパラメータを追加します",
"Capture All Positions": "すべての位置をキャプチャ",
"Capture Pos.": "位置を記録",
"Catch Depth": "キャッチ深さ",
"Catch Direction": "キャッチ方向",
"Catch Length": "キャッチ長さ",
"Cells": "セル",
"Center X": "中心 X",
"Center Y": "中心 Y",
@@ -69,15 +81,21 @@
"Chamfer Type": "面取りタイプ",
"Check collisions": "衝突チェック",
"Checked": "チェック済み",
"Choose Variable radius or Spline in Type.": "タイプで Variable radius またはスプラインを選択してください。",
"Choose a new plane. Sketch geometry and dimensions stay unchanged in its two-dimensional coordinates.": "新しい平面を選択してください。スケッチジオメトリと寸法は2次元座標系のまま維持されます。",
"Choose…": "選択…",
"Circular": "円形",
"Circumference": "円周",
"Close draft": "下書きを閉じる",
"Collapse": "折りたたむ",
"Collisions": "衝突",
"Colour": "色",
"Command": "コマンド",
"Comment": "コメント",
"Component 1": "コンポーネント 1",
"Component 2": "コンポーネント 2",
"Component1": "コンポーネント1",
"Components": "コンポーネント",
"Config Table": "構成テーブル",
"Configuration": "構成",
"Configuration rules": "構成ルール",
@@ -86,6 +104,7 @@
"Contact sets": "接触セット",
"Continue on GitHub": "GitHubで続ける",
"Continuity": "連続性",
"Control Radius": "制御半径",
"Copy Error Message": "エラーメッセージをコピー",
"Copy Warning Message": "警告メッセージをコピー",
"Copy all licences": "すべてのライセンスをコピー",
@@ -95,8 +114,11 @@
"Corner Relief": "コーナーリリーフ",
"Corner Relief Shape": "コーナーリリーフ形状",
"Counterbore depth": "座ぐり深さ",
"Counterbore Ø": "座ぐり Ø",
"Countersink angle": "皿穴角度",
"Countersink Ø": "皿穴 Ø",
"Create Issue — review draft": "Issueを作成 — 下書きの確認",
"Create Issue…": "Issueを作成…",
"Cross curves": "交差する曲線",
"Cube": "キューブ",
"Current orthographic view (1:1 mm)": "現在の平行投影(実寸・mm)",
@@ -106,7 +128,11 @@
"Curvature change (G3)": "曲率変化 (G3)",
"Custom": "カスタム",
"Customize Toolbar": "ツールバーのカスタマイズ",
"Customize…": "カスタマイズ…",
"Cylindrical face": "円筒面",
"Delete": "削除",
"Delete Selected Features…": "選択したフィーチャーを削除…",
"Delete Selected Folders": "選択したフォルダーを削除",
"Delete all": "すべて削除",
"Delete rule": "ルールを削除",
"Delete the row": "行を削除",
@@ -117,8 +143,11 @@
"Describe what happened before continuing.": "続ける前に、何が起きたか記入してください。",
"Designs you open or save appear here.": "開いたデザインや保存したデザインがここに表示されます。",
"Determined by To Object. Choose Angle to edit the angular extent.": "「対象オブジェクトまで」により決定されます。角度範囲を編集するには「角度」を選択してください。",
"Diameter": "直径",
"Direction": "方向",
"Discard": "破棄",
"Distance": "距離",
"Distance 1": "距離 1",
"Do not export hidden edges": "出力しない",
"Don't ask again": "今後確認しない",
"Double-click to rename": "ダブルクリックで名前変更",
@@ -130,18 +159,22 @@
"Edit Profile Sketch": "プロファイルスケッチを編集",
"Enable rule": "ルールを有効化",
"Enabled": "有効",
"End Radius": "終了半径",
"End bulge": "終了側の膨らみ",
"End continuity": "終了側の連続性",
"Expand": "展開",
"Expected result (optional)": "期待した結果(任意)",
"Explode": "分解",
"Explode Text": "テキストを分解",
"Export Animation…": "アニメーションをエクスポート…",
"Export Bend Table…": "曲げテーブルを書き出す…",
"Export CSV…": "CSVを書き出し…",
"Export Current View as DXF": "現在の表示をDXFでエクスポート",
"Export Current View as DXF…": "現在の表示をDXFでエクスポート…",
"Export DXF": "DXF出力",
"Export DXF…": "DXFをエクスポート…",
"Export JSON…": "JSONを書き出し…",
"Export design library...": "デザインライブラリを書き出す...",
"Export hidden edges as dashed lines": "点線で出力",
"Expression": "式",
"Extend to meet": "交わるまで延長",
@@ -149,25 +182,33 @@
"FILES": "ファイル",
"FLAT": "展開",
"Face": "面",
"Face Appearance": "面の外観",
"Face attachment lost": "面アタッチメントが失われました",
"Faces": "面",
"Faces / edges / vertices": "面 / エッジ / 頂点",
"Faces to split": "分割する面",
"Fastener": "締結部品",
"Favorites": "お気に入り",
"Favourite": "お気に入り",
"Feature": "フィーチャー",
"Files from many CAD programs have Y up, so they open lying on their back here (Z up).": "多くのCADではY軸を上向きとしているため、Z軸を上向きとするSolidCraftでは、読み込んだモデルが横倒しの向きで表示されることがあります。",
"Filter": "フィルター",
"Filter…": "フィルター…",
"Find in Browser": "ブラウザで検索",
"Finish": "終了",
"First plane": "第1平面",
"Fit and G2 corners require Constant radius.": "フィットと G2 コーナーには Constant radius が必要です。",
"Fixed Face": "固定面",
"Flat Pattern": "フラットパターン",
"Flat size": "展開サイズ",
"Follow camera (Look At sketches)": "カメラ追従(スケッチを正面視)",
"Follow the camera (Look At sketches)": "カメラ追従(スケッチを正面視)",
"Font": "フォント",
"From": "開始",
"From Bodies": "ボディから",
"From edges": "開始側エッジ",
"Full Length": "全長",
"G2 corners require a constant radius and Exact fit. G2 corner construction is not supported yet.": "G2 コーナーには一定半径と Exact fit が必要です。G2 コーナーの構築はまだサポートされていません。",
"Gap": "間隔",
"Grid": "グリッド",
"HISTORY": "履歴",
@@ -175,6 +216,7 @@
"Hidden edges": "隠線",
"History layout": "履歴のレイアウト",
"History names": "履歴名",
"Hole (optional)": "穴 (オプション)",
"Holes": "穴",
"Holes (edges on them)": "穴(そのエッジ)",
"Home view": "ホームビュー",
@@ -185,14 +227,20 @@
"In place (surfaces)": "その場で(サーフェス)",
"In plane": "平面内",
"Inside": "内側",
"Instant": "即時",
"Interface font": "インターフェースフォント",
"Interferes": "干渉あり",
"Invert": "反転",
"Isocurve": "等パラメータ線",
"Issue title": "Issueタイトル",
"Items": "項目",
"JOINTS": "ジョイント",
"Joint Origin1": "ジョイント原点1",
"Keep (points on it)": "残す部分(その上の点)",
"Keep Tools": "ツールボディを保持",
"Keep of the other surface": "相手のサーフェスで残す部分",
"Key a joint's value at this step": "このステップでジョイントの値をキー設定",
"Keys": "キー",
"LIBRARY": "ライブラリ",
"LINKS": "リンク",
"Later": "後で",
@@ -203,9 +251,14 @@
"Letter Spacing": "文字間隔",
"Library": "ライブラリ",
"Library file": "ライブラリファイル",
"Limits": "制限",
"Line Spacing": "行間",
"Line Type": "線種",
"Listing the installed fonts…": "インストール済みフォントを一覧表示中…",
"Load library": "ライブラリを読み込む",
"Local library": "ローカルライブラリ",
"Loop": "ループ",
"M5 or M5x0.5": "M5 または M5x0.5",
"MANAGE": "管理",
"MATERIALS": "材料",
"MODEL": "モデル",
@@ -216,14 +269,20 @@
"Matched restrictions check the final values.": "条件に一致する制限ルールが最終値を確認します。",
"Material": "材料",
"Max": "最大",
"Maximum": "最大",
"Mesh body": "メッシュボディ",
"Mid Radius": "中間半径",
"Middle double-click": "中ボタンのダブルクリック",
"Min": "最小",
"Minimum": "最小",
"Mirror Plane": "ミラー平面",
"Mitre": "留め継ぎ",
"Model Parameters": "モデルパラメータ",
"Modeled": "モデル化済み",
"More toolbar panels": "その他のツールバーパネル",
"Motion": "モーション",
"Mouse gestures": "マウス ジェスチャ",
"Mouse gestures only work with the SolidWorks mouse mode (the wheel orbits, Ctrl + wheel pans).": "マウス ジェスチャは SolidWorks マウス モードでのみ機能します(ホイールで回転、Ctrl + ホイールでパン)。",
"Move / Copy": "移動 / コピー",
"Move Type": "移動タイプ",
"Move down": "下へ移動",
@@ -246,8 +305,11 @@
"Not available yet": "まだ利用できません",
"Notch": "ノッチ",
"OUTPUT": "出力",
"Of": "の値",
"On your computer": "このコンピューター上",
"Opacity": "不透明度",
"Open GitHub draft": "GitHubの下書きを開く",
"Open library...": "ライブラリを開く...",
"Open local libraries in the desktop app. You can export this design's library here.": "ローカルライブラリはデスクトップアプリで開いてください。このデザインのライブラリはここで書き出せます。",
"Open-source parametric 3D CAD in Rust. Licensed MIT OR Apache-2.0.": "Rust製オープンソース・パラメトリック3D CAD。ライセンス: MIT または Apache-2.0。",
"Opens your browser and copies the report text for you to paste into GitHub.": "ブラウザーを開き、GitHubに貼り付ける報告本文をコピーします。",
@@ -269,8 +331,10 @@
"Picked": "選択したもの",
"Picked point": "選択した点",
"Pitch": "ピッチ",
"Pivot": "ピボット",
"Place at the origin instead": "代わりに原点に配置",
"Plastic Rules": "樹脂ルール",
"Play the joint's motion in the view": "ビューでジョイントの動きを再生",
"Playback": "再生",
"Plot": "グラフ",
"Position": "位置",
@@ -278,14 +342,18 @@
"Position (G0)": "位置 (G0)",
"Position along the edge, 0 at the start end, 1 at the other": "エッジ上の位置(始点が0、終点が1)",
"Preferences › Design turns the offer back on": "環境設定 › デザイン で再びオンにできます",
"Preview": "プレビュー",
"Preview Row": "行をプレビュー",
"Preview leaves the current design unchanged.": "プレビューは現在のデザインを変更しません。",
"Profile / Edges": "プロファイル / エッジ",
"Profiles or curves": "プロファイルまたは曲線",
"Profiles/Curves": "プロファイル/曲線",
"Quantity": "量",
"RECENT": "最近使用した項目",
"REFERENCE": "参照",
"RULED SURFACE": "ルールドサーフェス",
"RULES": "ルール",
"Radius": "半径",
"Radius Fit": "半径フィット",
"Ratio": "比率",
"Reattach selected face": "選択した面を再アタッチ",
@@ -294,6 +362,8 @@
"Rectangular": "長方形",
"Reference face": "参照面",
"Remove Tools": "ツールボディを削除",
"Remove condition": "条件を削除",
"Remove from Folder": "フォルダーから削除",
"Remove from list": "リストから削除",
"Remove override": "オーバーライドを削除",
"Remove private design details or paths before sharing.": "共有前に、非公開の設計情報やファイルパスを削除してください。",
@@ -301,6 +371,9 @@
"Remove the column": "列を削除",
"Remove the track": "トラックを削除",
"Remove this point": "この点を削除",
"Rename Feature": "フィーチャーの名前を変更",
"Rename Folder": "フォルダーの名前を変更",
"Replace existing library file": "既存のライブラリファイルを置き換える",
"Report a bug — review a screenshot and open a GitHub draft": "バグを報告 — スクリーンショットを確認してGitHub下書きを作成",
"Report text copied. Paste it into GitHub and submit your report there.": "報告本文をコピーしました。GitHubに貼り付けて投稿してください。",
"Reports are public. A GitHub account is required.": "報告は公開されます。GitHubアカウントが必要です。",
@@ -309,6 +382,7 @@
"Restore View": "ビューを復元",
"Restore outer boundary": "外側の境界を復元",
"Restore the inherited component or physical material appearance": "コンポーネントまたは物理マテリアルから継承した外観に戻す",
"Restore this studio's tools": "このスタジオのツールを復元",
"Result": "結果",
"Retake screenshot": "スクリーンショットを再取得",
"Reverse": "反転",
@@ -319,7 +393,13 @@
"Revert Position": "位置を元に戻す",
"Review the report and screenshot before opening GitHub.": "GitHubを開く前にレポートとスクリーンショットを確認してください。",
"Review your report on GitHub and click Submit new issue to publish it.": "GitHubで内容を確認し、Submit new issueをクリックすると公開されます。",
"Rib Length": "リブの長さ",
"Rib Offset": "リブのオフセット",
"Rib Thickness": "リブの厚さ",
"Ribs": "リブ",
"Rim Edge": "リムエッジ",
"Ring of tools on the right button": "右ボタンのツールリング",
"Roll Back Here": "ここまでロールバック",
"Roll History Marker Here": "履歴マーカーをここに移動",
"Round": "丸め",
"Rule": "ルール",
@@ -338,13 +418,18 @@
"Search": "検索",
"Search commands": "コマンドを検索",
"Search this library": "このライブラリを検索",
"Second plane": "第2平面",
"Section": "断面",
"Sections (one sketch each)": "セクション (各1スケッチ)",
"Select Other": "その他を選択",
"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).": "その他を選択: モデル上で左ボタンを長押し(または右クリック › その他を選択…)するとカーソル下の要素が一覧表示されます。同じ場所を再度クリックすると次の要素が選択されます。中ボタンをダブルクリックするとホームビューに戻ります(環境設定 › ナビゲーション)。",
"Select bodies (a face picks its body)": "ボディを選択(面をクリックするとそのボディが選択されます)",
"Select bodies or faces instead": "代わりにボディまたは面を選択",
"Select edges": "エッジを選択",
"Selection filter": "選択フィルター",
"Semantic faces share their appearance across instances.": "セマンティック面はインスタンス間で外観を共有します。",
"Send your report on GitHub": "GitHubでレポートを送信",
"Shaded": "シェーディング",
"Shape": "形状",
"Sheet Rules": "板金ルール",
"Show Agent Cursor": "エージェントカーソルを表示",
@@ -362,6 +447,7 @@
"SolidCraft closed before these designs were saved. Their last autosave can be opened.": "これらのデザインが保存される前にSolidCraftが終了しました。最後の自動保存ファイルを開くことができます。",
"Specialise": "特殊操作",
"Speed": "速度",
"Splitting tool": "分割ツール",
"Stand it up": "正立させる",
"Stand it up: a quarter turn about X, as one Move feature you can undo.": "正立させる: 取り消し可能な1つの移動フィーチャーとして、X軸周りに90度回転します。",
"Stand this part up?": "このパーツを正立させますか?",
@@ -372,16 +458,22 @@
"Steps to reproduce (optional)": "再現手順(任意)",
"Stitch the kept pieces": "残した部分をステッチ",
"Stitch to both bodies": "両方のボディにステッチ",
"Studio": "スタジオ",
"Study": "スタディ",
"Style": "スタイル",
"Summary": "概要",
"Suppress": "抑制",
"Suppressed": "抑制",
"Surface": "サーフェス",
"Surface, or faces to turn over": "サーフェス、または裏返す面",
"Swap Ends": "端点を入れ替え",
"TRIM": "トリム",
"Tangent (G1)": "接線 (G1)",
"Tangent Chain": "正接チェーン",
"Tangent chain": "接線チェーン",
"Tap drill diameter": "タップ下穴径",
"Target": "ターゲット",
"Target Body": "ターゲットボディ",
"Tear": "引き裂き",
"Technical details": "技術情報",
"The dashed sketch is a saved plane guide. Select a new face to repair the attachment.": "破線のスケッチは保存された平面ガイドです。新しい面を選択してアタッチメントを修復してください。",
@@ -396,24 +488,31 @@
"This child is shared. Position changes apply to every copy of its parent.": "この子要素は共有されています。位置の変更は親のすべてのコピーに適用されます。",
"This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "この面は安定したアタッチメントを提供できません。別の面を選択するか、固定平面を明示的に使用してください。",
"This image is not attached to GitHub yet. Copy or save it, then attach it on GitHub.": "この画像はまだGitHubに添付されていません。コピーまたは保存し、GitHubで添付してください。",
"To": "終了",
"To Offset": "オフセットまで",
"To edges": "終了側エッジ",
"Tolerance": "公差",
"Too close": "近すぎる",
"Tool Bodies": "ツールボディ",
"Tools": "ツール",
"Tools, clockwise from the top": "上から時計回りにツール",
"Trimming tool (plane, face or curves)": "トリムツール(平面、面、または曲線)",
"Turn off section analysis before exporting the view.": "断面解析をオフにしてからエクスポートしてください。",
"Type": "タイプ",
"UNTRIM": "トリム解除",
"Unit": "単位",
"Unsuppress": "抑制を解除",
"Updating the preview": "プレビューを更新中",
"Use active author placement": "アクティブな作成元配置を使用",
"Use fixed plane": "固定平面を使用",
"Use saved fixed plane": "保存された固定平面を使用",
"Use selected face as fixed plane": "選択した面を固定平面として使用",
"Use the SolidWorks mouse mode": "SolidWorks マウス モードを使用",
"User Parameters": "ユーザーパラメータ",
"VIEW": "表示",
"VIEWS": "ビュー",
"Value": "値",
"Variable Radius": "可変半径",
"Velocity": "速度",
"Vertices": "頂点",
"View navigator": "ビューナビゲーター",
@@ -427,13 +526,44 @@
"When": "条件 (When)",
"Width": "幅",
"Will fail": "失敗します",
"With gestures on, the right button no longer orbits the view: hold it and move to open the ring, then touch a tool's zone. A plain right-click still opens the menu.": "マウス ジェスチャがオンのとき、右ボタンではビューを回転しません。押したまま動かすとツールリングが開くので、ツールのゾーンに触れてください。通常の右クリックでは引き続きメニューが開きます。",
"X": "X",
"X Angle": "X 角度",
"X angle": "X角度",
"X distance": "X距離",
"Y": "Y",
"Y Angle": "Y 角度",
"Y angle": "Y角度",
"Y distance": "Y距離",
"Z": "Z",
"Z Angle": "Z 角度",
"Z Distance": "Z 距離",
"Z angle": "Z角度",
"Z distance": "Z距離",
"Zebra": "ゼブラ",
"ai.prefs.availability": "アシスタントの操作",
"ai.prefs.background_commands": "エージェントのコマンドをバックグラウンドで実行",
"ai.prefs.follow_camera": "カメラをスケッチに向ける",
"ai.prefs.follow_camera_off": "カメラの自動追従はオフです。",
"ai.prefs.follow_camera_on": "エージェントが作成するスケッチにカメラを向けます。",
"ai.prefs.intro": "エージェント、権限、作業の表示方法を設定します。",
"ai.prefs.make_default": "既定にする",
"ai.prefs.no_agents": "エージェントはまだありません。追加するか、更新してインストール済みのエージェントを検索してください。",
"ai.prefs.no_saved_approvals": "保存された許可はありません。",
"ai.prefs.override_all_warning": "このエージェントは確認せずに設計を保存、上書き、書き出し、置換できます。",
"ai.prefs.permission_all": "すべて許可",
"ai.prefs.permission_all_summary": "利用可能なSolidCraftツールを確認せず実行します。",
"ai.prefs.permission_ask": "変更前に確認",
"ai.prefs.permission_ask_summary": "設計の変更とファイル操作を毎回確認します。",
"ai.prefs.permission_edits": "モデル編集を許可",
"ai.prefs.permission_edits_summary": "モデル編集は自動実行。ファイル操作や破壊的な操作は確認します。",
"ai.prefs.permission_scope": "SolidCraftツールのみに適用されます。エージェントのシェルやファイル編集には、そのエージェント自身の権限設定が適用されます。",
"ai.prefs.pointer_preview_hidden": "ポインターは非表示",
"ai.prefs.pointer_preview_speed": "エージェントのポインター · {speed}",
"ai.prefs.pointer_speed": "移動速度",
"ai.prefs.privacy_note": "会話は選択したエージェントまたはエンドポイントにのみ送信されます。エンドポイントのキーはシステムのキーチェーンに保存されます。",
"ai.prefs.show_pointer": "エージェントのポインターを表示",
"ai.prefs.turn_limit_hint": "アシスタントが1件のメッセージで行える、設計を変更するツール呼び出しの上限です。",
"angle, height": "角度, 高さ",
"appearance.dielectric_ior": "誘電体の屈折率",
"appearance.metallic": "金属度",
@@ -889,19 +1019,57 @@
"help.solid.revolve": "スケッチの輪郭を軸のまわりに回転させ、丸い立体を作ります。軸、つまみ、ボウルなど、中心線まわりに丸い部品に使います。",
"help.solid.shell": "立体の中をくり抜き、指定した厚さの壁を残します。選んだ面を開口にすることもできます。箱、カバーなどの薄肉の部品に使います。",
"help.third_party": "サードパーティライセンス",
"home.back": "{name} に戻る",
"home.crashes": "クラッシュレポート",
"home.design_workspace": "デザイン",
"home.documentation": "ドキュメント",
"home.export": "エクスポート",
"home.export_hint": "現在のデザインの出力形式を選択してください。",
"home.export_json_hint": "編集可能なデザインとコマンド履歴",
"home.export_mesh_hint": "印刷や共有用のメッシュ",
"home.export_step_hint": "精密なソリッド形状",
"home.import": "インポート…",
"home.import_hint": "STEP、IGES、またはメッシュを新しいデザインに読み込みます",
"home.in_this_browser": "このブラウザ内",
"home.missing": "見つかりません",
"home.month_01": "1月",
"home.month_02": "2月",
"home.month_03": "3月",
"home.month_04": "4月",
"home.month_05": "5月",
"home.month_06": "6月",
"home.month_07": "7月",
"home.month_08": "8月",
"home.month_09": "9月",
"home.month_10": "10月",
"home.month_11": "11月",
"home.month_12": "12月",
"home.new_inches": "新しいデザイン(インチ)",
"home.new_inches_hint": "インチ · デザインで開きます",
"home.new_mm": "新しいデザイン",
"home.new_mm_hint": "ミリメートル · デザインで開きます",
"home.open": "開く…",
"home.opened_days_ago": "{count} 日前に開きました",
"home.opened_hours_ago": "{count} 時間前に開きました",
"home.opened_just_now": "たった今開きました",
"home.opened_minutes_ago": "{count} 分前に開きました",
"home.open_hint": ".solidcraft 形式のデザイン ({shortcut})",
"home.open_now": "今すぐ開く",
"home.opened_days_ago": "{count} 日前",
"home.opened_hours_ago": "{count} 時間前",
"home.opened_just_now": "たった今",
"home.opened_minutes_ago": "{count} 分前",
"home.opening": "開いています…",
"home.properties": "デザインのプロパティ",
"home.recent": "最近使用した項目",
"home.recent_empty": "開いたり保存したデザインがここに表示されます。",
"home.recovery_count": "復元可能なデザイン: {count} 件",
"home.recovery_empty": "復元可能なデザインはありません。",
"home.recovery_hint": "これらのデザインが保存される前に SolidCraft が終了しました。",
"home.report_issue": "問題を報告",
"home.samples": "サンプル",
"home.settings": "設定",
"home.short_date": "{month}{day}日",
"home.shortcuts": "キーボードショートカット",
"home.start_hint": "デザインを作成するか、最近使ったファイルを開くか、サンプルをご覧ください。",
"home.whats_new": "新機能",
"home.yesterday": "昨日",
"install.button_tip": "SolidCraft をアプリとしてインストールします。専用のウィンドウで開き、オフラインでも使えます",
"install.dock_1": "「ファイル」›「Dockに追加」を選びます。",
"install.dock_2": "「追加」をクリックします。",
@@ -959,8 +1127,15 @@
"motion.to_end": "最後へ",
"motion.to_start": "最初へ",
"motion.track_name": "トラック {number}",
"nav.alias.help": "Shift+Alt+左ドラッグで回転、Shift+Alt+中ドラッグでパン、Shift+Alt+右ドラッグでズーム。中ドラッグでもパン、右ドラッグで回転。",
"nav.blender.help": "中ドラッグで回転、Shift+中ドラッグでパン、Ctrl+中ドラッグでズーム。右ドラッグではカメラは動きません。",
"nav.free_orbit.help": "マウスと3Dマウスで、極点を越えて回転し、ロールを保ちます。\nShiftによる軸スナップは無効になります。",
"nav.free_orbit.label": "自由回転",
"nav.fusion.help": "中ドラッグでパン、Shift+中ドラッグで回転、右ドラッグで回転。",
"nav.inventor.help": "中ドラッグでパン、Shift+中ドラッグで回転、Ctrl+中ドラッグでズーム、右ドラッグで回転。",
"nav.openscad.help": "空きスペースで左ドラッグすると回転、Shift+左ドラッグで矩形選択、スケッチ ジオメトリはドラッグで編集。右または中ドラッグでパン、Shift+右または Shift+中ドラッグでズーム。",
"nav.solidworks.help": "中ドラッグで回転、Ctrl+中ドラッグでパン、Shift+中ドラッグでズーム、右ドラッグで回転。",
"nav.tinkercad.help": "右ドラッグで回転、中ドラッグまたは Shift+右ドラッグでパン。",
"nav.touchpad.help": "Shift を押しながらポインターを動かすとパン、Alt/Option を押しながら動かすと回転します。マウスボタンは不要です。縦スクロールでズームします。",
"nav.touchpad.label": "タッチパッド",
"nav.touchpad.orbit": "回転(Alt/Option を押しながら移動、マウスボタン不要)",
@@ -983,8 +1158,130 @@
"pattern.flip_direction": "方向を反転",
"pick a hole to seat it, or OK places it at the origin": "配置先の穴を選択するか、[OK]を押すと原点に配置します。",
"pick one or two vertices, edges or faces": "1つまたは2つの頂点、エッジ、面を選択",
"prefs.add_item": "項目を追加",
"prefs.alt_frees_manipulators": "Alt / Option でマニピュレーターの矢印も自由に動かす",
"prefs.anti_aliasing_note": "輪郭のぎざぎざを滑らかにします。",
"prefs.applies_on_restart": "再起動後に適用",
"prefs.assistant": "アシスタント",
"prefs.axes": "軸",
"prefs.axis": "軸",
"prefs.bottom": "下",
"prefs.browser": "ブラウザー",
"prefs.built_in": "組み込み",
"prefs.button_left": "左ドラッグ",
"prefs.button_middle": "中央ドラッグ",
"prefs.button_right": "右ドラッグ",
"prefs.choose_font": "フォントファイルを選択…",
"prefs.click": "クリック",
"prefs.contact_shading": "柔らかな接触陰影",
"prefs.contact_shading_note": "部品が近接する部分に柔らかな陰影を付けます。",
"prefs.context_menu": "コンテキストメニュー",
"prefs.context_menu_ring": "コンテキストメニュー · 長押しで放射状メニュー",
"prefs.cpu": "CPU(ソフトウェア)",
"prefs.crash_reports": "クラッシュレポートを開く…",
"prefs.crash_reports_note": "問題を再現する設計とコマンドを含むレポートをこのコンピューターに保存します。",
"prefs.ctrl_cmd": "Ctrl / Cmd",
"prefs.curved_surfaces": "曲面",
"prefs.curves": "曲面",
"prefs.dead_zone_note": "斜線の帯はデッドゾーンです。この範囲内の動きは無視されます。",
"prefs.default": "既定",
"prefs.design": "設計の既定値",
"prefs.design_note": "新しいデザインの初期設定です。開いているデザインはそれぞれの設定を保持します。",
"prefs.devices": "3D マウス",
"prefs.display": "表示",
"prefs.edges": "エッジと陰影",
"prefs.extend": "拡張",
"prefs.extra_navigation": "何もない場所でCtrl+左ドラッグすると回転し、Spaceを押しながらドラッグすると移動します。",
"prefs.follows_os": "OS の設定に従う",
"prefs.font": "フォント",
"prefs.font_fallback": "選択したフォントにない文字は組み込みフォントで表示し、ラベルが空の四角になるのを防ぎます。",
"prefs.font_sample": "いろはにほへと · Ø12.50 mm · 45.0° · 0123456789",
"prefs.font_unreadable": "選択したフォントを読み込めませんでした。",
"prefs.general": "一般",
"prefs.general_note": "言語、保存、更新。",
"prefs.gesture_orbit": "回転",
"prefs.gesture_pan": "移動",
"prefs.gesture_zoom": "ズーム",
"prefs.graphics": "グラフィックス",
"prefs.graphics_note": "速度と滑らかさのバランスを調整します。各選択肢の効果をサンプルで確認できます。",
"prefs.graphics_presets_note": "プリセットは以下の設定をまとめて変更します。",
"prefs.history": "履歴",
"prefs.hold_stop_snapping": "押している間スナップを解除",
"prefs.icons_only": "アイコンのみ",
"prefs.imported_file": "読み込んだファイル",
"prefs.input": "入力",
"prefs.interface": "インターフェース",
"prefs.interface_language": "表示言語",
"prefs.interface_note": "SolidCraft の外観と各パネルの配置。",
"prefs.interval_minutes": "{n} 分",
"prefs.labels": "ラベル",
"prefs.layout": "レイアウト",
"prefs.layout_note": "コンパクトではパネルをウィンドウの端に固定します。ブラウザーの半透明表示はフローティングで使用できます。アシスタントの配置はアシスタントページで変更します。",
"prefs.left": "左",
"prefs.left_click": "左クリック",
"prefs.live_input": "現在の入力",
"prefs.live_preview": "ライブプレビュー",
"prefs.minutes": "分",
"prefs.minutes_suffix": " min",
"prefs.mouse": "マウスとトラックパッド",
"prefs.mouse_heading": "マウス",
"prefs.mouse_mapping": "マウスの割り当て",
"prefs.navigation": "ナビゲーション",
"prefs.navigation_note": "マウス、トラックパッド、3Dマウスによるビューの操作。",
"prefs.not_on_gpu": "この GPU は非対応",
"prefs.on": "オン",
"prefs.opaque": "不透明",
"prefs.orbit_snap": "回転中にShiftを押すと軸にスナップします。",
"prefs.orientation": "方向",
"prefs.overall_speed": "全体の速度",
"prefs.preview_choices": "選択した設定のプレビュー",
"prefs.quality_default": "既定",
"prefs.quality_fastest": "最速",
"prefs.quality_slowest": "最も低速",
"prefs.quality_smoother": "より滑らか",
"prefs.reports_saved": "{n} 件保存済み",
"prefs.right_click": "右クリック",
"prefs.saving": "自動保存と復元",
"prefs.scale_note": "ウィンドウ内では Ctrl + / Ctrl − でも変更できます。100% のボタンは 28 px です。",
"prefs.scale_text": "拡大率と文字",
"prefs.select": "選択",
"prefs.select_one_item": "項目を 1 つ選択",
"prefs.selecting": "選択",
"prefs.shift": "Shift",
"prefs.shift_add_ctrl_toggle": "Shift は追加のみ、Ctrl / Cmd は選択を切り替え",
"prefs.shift_add_note": "オン:Shift は追加、Ctrl / Cmd は選択を切り替えます。オフ(既定):どちらも選択を切り替えます。",
"prefs.shortcuts": "ショートカット",
"prefs.sketch_selection": "スケッチと選択",
"prefs.sketch_selection_heading": "スケッチと選択",
"prefs.sketch_selection_note": "スケッチのスナップとクリックによる選択。",
"prefs.sketching": "スケッチ",
"prefs.snap_drawing_note": "スケッチの描画中または点のドラッグ中に使用します。",
"prefs.source": "入力元",
"prefs.speed": "速度",
"prefs.stand_up_note": "STEP または IGES ファイルを Z 上向きで開いた後、X 軸周りに 90° 回転するか確認します。",
"prefs.status_done": "完了",
"prefs.status_error": "エラー",
"prefs.status_selected": "選択中",
"prefs.status_warning": "警告",
"prefs.supersampling_brief": "大きく描画して縮小するため、非常に鮮明になりますが負荷も増えます。",
"prefs.tab_accent_note": "アクセント色はアクティブなタブに従います",
"prefs.theme_file": "テーマファイル",
"prefs.timeline_half_hour": "−30 分",
"prefs.timeline_now": "現在",
"prefs.timeline_quarter_hour": "−15",
"prefs.toggle_item": "項目を追加、または選択から外す",
"prefs.toolbar": "ツールバー",
"prefs.translucent": "半透明",
"prefs.under_browser": "ブラウザー下",
"prefs.under_cursor": "カーソル下",
"prefs.units_precision": "単位と精度",
"prefs.updates": "更新",
"prefs.view": "ビューの動作",
"prefs.viewport": "ビューポート",
"prefs.wheel": "ホイール",
"prefs.wheel_trackpad": "ホイールとトラックパッド",
"prefs.workspace": "ワークスペース",
"prefs.zoom_toward_cursor": "カーソルに向かってズーム",
"properties.title": "プロパティ: {name}",
"recovery.days_ago": "{count}日前",
"recovery.detail": "{file} · {features}フィーチャー · 自動保存: {when}",
@@ -1055,7 +1352,7 @@
"search.suggest": "{label} ですか?",
"search.try": "コマンド名、このデザインのフィーチャー、またはパラメーターを入力します。値を指定して実行するには、IDとJSONオブジェクトを入力します:{example}",
"search.valid": "有効",
"settings.display_quality_note": "細かいほど曲面とエッジが滑らかに表示されますが、メッシュの生成に時間がかかります。変わるのはビューポートだけで、設計やエクスポートは変わりません。",
"settings.display_quality_note": "表示だけに影響します。設計や書き出しは変わりません。",
"settings.frame_rate_note": "ビューの右下に 1 秒あたりのフレーム数を表示します。オンの間、ビューは常に再描画されます。",
"settings.measure_axis_distances": "計測のX・Y・Z距離を表示",
"settings.measure_axis_distances_help": "計測ダイアログを開いている間、2点間距離に加えてワールドX・Y・Z軸方向の寸法線と距離を表示します。",
+42 -4
View File
@@ -859,19 +859,57 @@
"help.mit": "MIT 라이선스",
"help.notice": "NOTICE",
"help.third_party": "타사 라이선스",
"home.back": "{name}(으)로 돌아가기",
"home.crashes": "충돌 보고서",
"home.design_workspace": "설계",
"home.documentation": "문서",
"home.export": "내보내기",
"home.export_hint": "현재 설계의 형식을 선택하세요.",
"home.export_json_hint": "편집 가능한 설계 및 명령 기록",
"home.export_mesh_hint": "인쇄 또는 공유용 메시",
"home.export_step_hint": "정밀한 솔리드 형상",
"home.import": "가져오기…",
"home.import_hint": "STEP, IGES 또는 메시를 새 설계로 가져오기",
"home.in_this_browser": "이 브라우저에 저장됨",
"home.missing": "찾을 수 없음",
"home.month_01": "1월",
"home.month_02": "2월",
"home.month_03": "3월",
"home.month_04": "4월",
"home.month_05": "5월",
"home.month_06": "6월",
"home.month_07": "7월",
"home.month_08": "8월",
"home.month_09": "9월",
"home.month_10": "10월",
"home.month_11": "11월",
"home.month_12": "12월",
"home.new_inches": "새 설계(인치)",
"home.new_inches_hint": "인치 · 설계 작업 공간에서 열기",
"home.new_mm": "새 설계",
"home.new_mm_hint": "밀리미터 · 설계 작업 공간에서 열기",
"home.open": "열기…",
"home.opened_days_ago": "{count}일 전에 열림",
"home.opened_hours_ago": "{count}시간 전에 열림",
"home.opened_just_now": "방금 열림",
"home.opened_minutes_ago": "{count}분 전에 열림",
"home.open_hint": ".solidcraft 설계 ({shortcut})",
"home.open_now": "지금 열기",
"home.opened_days_ago": "{count}일 전",
"home.opened_hours_ago": "{count}시간 전",
"home.opened_just_now": "방금",
"home.opened_minutes_ago": "{count}분 전",
"home.opening": "여는 중…",
"home.properties": "설계 속성",
"home.recent": "최근 항목",
"home.recent_empty": "열거나 저장한 설계가 여기에 표시됩니다.",
"home.recovery_count": "복구 가능한 설계: {count}개",
"home.recovery_empty": "복구 가능한 설계가 없습니다.",
"home.recovery_hint": "이 설계가 저장되기 전에 SolidCraft가 종료되었습니다.",
"home.report_issue": "문제 신고",
"home.samples": "샘플",
"home.settings": "설정",
"home.short_date": "{month} {day}일",
"home.shortcuts": "키보드 단축키",
"home.start_hint": "설계를 만들거나, 최근 파일을 열거나, 예제를 살펴보세요.",
"home.whats_new": "새로운 기능",
"home.yesterday": "어제",
"install.button_tip": "SolidCraft를 앱으로 설치합니다. 별도 창에서 열리며 오프라인에서도 작동합니다",
"install.dock_1": "파일 › Dock에 추가를 선택합니다.",
"install.dock_2": "추가를 클릭합니다.",
+42 -4
View File
@@ -857,19 +857,57 @@
"help.mit": "Lesen MIT",
"help.notice": "NOTIS",
"help.third_party": "Lesen pihak ketiga",
"home.back": "Kembali ke {name}",
"home.crashes": "Laporan ranap",
"home.design_workspace": "Reka Bentuk",
"home.documentation": "Dokumentasi",
"home.export": "Eksport",
"home.export_hint": "Pilih format untuk reka bentuk semasa.",
"home.export_json_hint": "Reka bentuk boleh sunting dan sejarah perintah",
"home.export_mesh_hint": "Jejaring untuk pencetakan atau perkongsian",
"home.export_step_hint": "Geometri pepejal yang tepat",
"home.import": "Import…",
"home.import_hint": "STEP, IGES atau jejaring ke dalam reka bentuk baharu",
"home.in_this_browser": "dalam pelayar ini",
"home.missing": "tiada",
"home.month_01": "Jan",
"home.month_02": "Feb",
"home.month_03": "Mac",
"home.month_04": "Apr",
"home.month_05": "Mei",
"home.month_06": "Jun",
"home.month_07": "Jul",
"home.month_08": "Ogo",
"home.month_09": "Sep",
"home.month_10": "Okt",
"home.month_11": "Nov",
"home.month_12": "Dis",
"home.new_inches": "Reka bentuk baharu dalam inci",
"home.new_inches_hint": "Inci · dibuka dalam Reka Bentuk",
"home.new_mm": "Reka bentuk baharu",
"home.new_mm_hint": "Milimeter · dibuka dalam Reka Bentuk",
"home.open": "Buka…",
"home.opened_days_ago": "Dibuka {count} hari lalu",
"home.opened_hours_ago": "Dibuka {count} jam lalu",
"home.opened_just_now": "Baru dibuka",
"home.opened_minutes_ago": "Dibuka {count} minit lalu",
"home.open_hint": "Reka bentuk .solidcraft ({shortcut})",
"home.open_now": "Buka sekarang",
"home.opened_days_ago": "{count} hari lalu",
"home.opened_hours_ago": "{count} jam lalu",
"home.opened_just_now": "Baru sahaja",
"home.opened_minutes_ago": "{count} minit lalu",
"home.opening": "membuka…",
"home.properties": "Sifat reka bentuk",
"home.recent": "Terkini",
"home.recent_empty": "Reka bentuk yang anda buka atau simpan dipaparkan di sini.",
"home.recovery_count": "Reka bentuk boleh dipulihkan: {count}",
"home.recovery_empty": "Tiada reka bentuk yang boleh dipulihkan.",
"home.recovery_hint": "SolidCraft ditutup sebelum reka bentuk ini disimpan.",
"home.report_issue": "Laporkan Isu",
"home.samples": "Contoh",
"home.settings": "Tetapan",
"home.short_date": "{day} {month}",
"home.shortcuts": "Pintasan papan kekunci",
"home.start_hint": "Cipta reka bentuk, buka fail terkini atau terokai contoh.",
"home.whats_new": "Apa yang baharu",
"home.yesterday": "Semalam",
"install.button_tip": "Pasang SolidCraft: tetingkap sendiri dan boleh guna di luar talian",
"install.dock_1": "Pilih File › Add to Dock.",
"install.dock_2": "Klik Add.",
+42 -4
View File
@@ -856,19 +856,57 @@
"help.mit": "MIT-licentie",
"help.notice": "NOTICE",
"help.third_party": "Licenties van derden",
"home.back": "Terug naar {name}",
"home.crashes": "Crashrapporten",
"home.design_workspace": "Ontwerp",
"home.documentation": "Documentatie",
"home.export": "Exporteren",
"home.export_hint": "Kies een indeling voor het huidige ontwerp.",
"home.export_json_hint": "Bewerkbaar ontwerp en opdrachtgeschiedenis",
"home.export_mesh_hint": "Mesh om af te drukken of te delen",
"home.export_step_hint": "Nauwkeurige volumegeometrie",
"home.import": "Importeren…",
"home.import_hint": "STEP, IGES of een mesh in een nieuw ontwerp",
"home.in_this_browser": "in deze browser",
"home.missing": "ontbreekt",
"home.month_01": "jan.",
"home.month_02": "feb.",
"home.month_03": "mrt.",
"home.month_04": "apr.",
"home.month_05": "mei",
"home.month_06": "jun.",
"home.month_07": "jul.",
"home.month_08": "aug.",
"home.month_09": "sep.",
"home.month_10": "okt.",
"home.month_11": "nov.",
"home.month_12": "dec.",
"home.new_inches": "Nieuw ontwerp in inches",
"home.new_inches_hint": "Inches · opent in Ontwerp",
"home.new_mm": "Nieuw ontwerp",
"home.new_mm_hint": "Millimeters · opent in Ontwerp",
"home.open": "Openen…",
"home.opened_days_ago": "{count} dagen geleden geopend",
"home.opened_hours_ago": "{count} u geleden geopend",
"home.opened_just_now": "Zojuist geopend",
"home.opened_minutes_ago": "{count} min geleden geopend",
"home.open_hint": "Een .solidcraft-ontwerp ({shortcut})",
"home.open_now": "Momenteel geopend",
"home.opened_days_ago": "{count} dagen geleden",
"home.opened_hours_ago": "{count} u geleden",
"home.opened_just_now": "Zojuist",
"home.opened_minutes_ago": "{count} min geleden",
"home.opening": "openen…",
"home.properties": "Ontwerpeigenschappen",
"home.recent": "Recent",
"home.recent_empty": "Ontwerpen die u opent of opslaat, verschijnen hier.",
"home.recovery_count": "Herstelbare ontwerpen: {count}",
"home.recovery_empty": "Geen herstelbare ontwerpen.",
"home.recovery_hint": "SolidCraft is gesloten voordat deze ontwerpen werden opgeslagen.",
"home.report_issue": "Probleem melden",
"home.samples": "Voorbeelden",
"home.settings": "Instellingen",
"home.short_date": "{day} {month}",
"home.shortcuts": "Sneltoetsen",
"home.start_hint": "Maak een ontwerp, open een recent bestand of bekijk een voorbeeld.",
"home.whats_new": "Wat is er nieuw",
"home.yesterday": "Gisteren",
"install.button_tip": "Installeer SolidCraft als app: deze opent in een eigen venster en werkt offline",
"install.dock_1": "Kies Bestand › Toevoegen aan Dock.",
"install.dock_2": "Klik op Voeg toe.",
+42 -4
View File
@@ -857,19 +857,57 @@
"help.mit": "Licencja MIT",
"help.notice": "NOTICE",
"help.third_party": "Licencje stron trzecich",
"home.back": "Wróć do {name}",
"home.crashes": "Raporty awarii",
"home.design_workspace": "Projektowanie",
"home.documentation": "Dokumentacja",
"home.export": "Eksportuj",
"home.export_hint": "Wybierz format dla bieżącego projektu.",
"home.export_json_hint": "Edytowalny projekt i historia poleceń",
"home.export_mesh_hint": "Siatka do druku lub udostępniania",
"home.export_step_hint": "Precyzyjna geometria bryłowa",
"home.import": "Importuj…",
"home.import_hint": "STEP, IGES lub siatka do nowego projektu",
"home.in_this_browser": "w tej przeglądarce",
"home.missing": "brak",
"home.month_01": "sty",
"home.month_02": "lut",
"home.month_03": "mar",
"home.month_04": "kwi",
"home.month_05": "maj",
"home.month_06": "cze",
"home.month_07": "lip",
"home.month_08": "sie",
"home.month_09": "wrz",
"home.month_10": "paź",
"home.month_11": "lis",
"home.month_12": "gru",
"home.new_inches": "Nowy projekt w calach",
"home.new_inches_hint": "Cale · otwiera w obszarze Projektowanie",
"home.new_mm": "Nowy projekt",
"home.new_mm_hint": "Milimetry · otwiera w obszarze Projektowanie",
"home.open": "Otwórz…",
"home.opened_days_ago": "Otwarto {count} dni temu",
"home.opened_hours_ago": "Otwarto {count} godz. temu",
"home.opened_just_now": "Otwarto przed chwilą",
"home.opened_minutes_ago": "Otwarto {count} min temu",
"home.open_hint": "Projekt .solidcraft ({shortcut})",
"home.open_now": "Otwórz teraz",
"home.opened_days_ago": "{count} dni temu",
"home.opened_hours_ago": "{count} godz. temu",
"home.opened_just_now": "Przed chwilą",
"home.opened_minutes_ago": "{count} min temu",
"home.opening": "otwieranie…",
"home.properties": "Właściwości projektu",
"home.recent": "Ostatnie",
"home.recent_empty": "Tutaj pojawią się projekty, które otworzysz lub zapiszesz.",
"home.recovery_count": "Projekty do odzyskania: {count}",
"home.recovery_empty": "Brak projektów do odzyskania.",
"home.recovery_hint": "SolidCraft zamknął się przed zapisaniem tych projektów.",
"home.report_issue": "Zgłoś problem",
"home.samples": "Przykłady",
"home.settings": "Ustawienia",
"home.short_date": "{day} {month}",
"home.shortcuts": "Skróty klawiszowe",
"home.start_hint": "Utwórz projekt, otwórz ostatni plik lub obejrzyj przykład.",
"home.whats_new": "Co nowego",
"home.yesterday": "Wczoraj",
"install.button_tip": "Zainstaluj SolidCraft jako aplikację: otwiera się we własnym oknie i działa offline",
"install.dock_1": "Wybierz Plik › Dodaj do Docka.",
"install.dock_2": "Kliknij Dodaj.",
+42 -4
View File
@@ -857,19 +857,57 @@
"help.mit": "Licença MIT",
"help.notice": "NOTICE",
"help.third_party": "Licenças de terceiros",
"home.back": "Voltar para {name}",
"home.crashes": "Relatórios de falhas",
"home.design_workspace": "Projeto",
"home.documentation": "Documentação",
"home.export": "Exportar",
"home.export_hint": "Escolha um formato para o projeto atual.",
"home.export_json_hint": "Projeto editável e histórico de comandos",
"home.export_mesh_hint": "Malha para impressão ou compartilhamento",
"home.export_step_hint": "Geometria sólida precisa",
"home.import": "Importar…",
"home.import_hint": "STEP, IGES ou malha em um novo projeto",
"home.in_this_browser": "neste navegador",
"home.missing": "ausente",
"home.month_01": "jan.",
"home.month_02": "fev.",
"home.month_03": "mar.",
"home.month_04": "abr.",
"home.month_05": "mai.",
"home.month_06": "jun.",
"home.month_07": "jul.",
"home.month_08": "ago.",
"home.month_09": "set.",
"home.month_10": "out.",
"home.month_11": "nov.",
"home.month_12": "dez.",
"home.new_inches": "Novo projeto em polegadas",
"home.new_inches_hint": "Polegadas · abre em Projeto",
"home.new_mm": "Novo projeto",
"home.new_mm_hint": "Milímetros · abre em Projeto",
"home.open": "Abrir…",
"home.opened_days_ago": "Aberto há {count} dias",
"home.opened_hours_ago": "Aberto há {count} h",
"home.opened_just_now": "Aberto agora mesmo",
"home.opened_minutes_ago": "Aberto há {count} min",
"home.open_hint": "Um projeto .solidcraft ({shortcut})",
"home.open_now": "Abrir agora",
"home.opened_days_ago": "Há {count} dias",
"home.opened_hours_ago": "Há {count} h",
"home.opened_just_now": "Agora mesmo",
"home.opened_minutes_ago": "Há {count} min",
"home.opening": "abrindo…",
"home.properties": "Propriedades do projeto",
"home.recent": "Recentes",
"home.recent_empty": "Os projetos que você abrir ou salvar aparecerão aqui.",
"home.recovery_count": "Projetos recuperáveis: {count}",
"home.recovery_empty": "Nenhum projeto recuperável.",
"home.recovery_hint": "O SolidCraft foi fechado antes que estes projetos fossem salvos.",
"home.report_issue": "Relatar um problema",
"home.samples": "Exemplos",
"home.settings": "Configurações",
"home.short_date": "{day} {month}",
"home.shortcuts": "Atalhos de teclado",
"home.start_hint": "Crie um projeto, abra um arquivo recente ou explore um exemplo.",
"home.whats_new": "Novidades",
"home.yesterday": "Ontem",
"install.button_tip": "Instale o SolidCraft como aplicativo: ele abre em sua própria janela e funciona offline",
"install.dock_1": "Escolha Arquivo › Adicionar ao Dock.",
"install.dock_2": "Clique em Adicionar.",
+42 -4
View File
@@ -856,19 +856,57 @@
"help.mit": "Licența MIT",
"help.notice": "NOTICE",
"help.third_party": "Licențe terțe",
"home.back": "Înapoi la {name}",
"home.crashes": "Rapoarte de blocare",
"home.design_workspace": "Proiectare",
"home.documentation": "Documentație",
"home.export": "Exportă",
"home.export_hint": "Alege un format pentru proiectul curent.",
"home.export_json_hint": "Proiect editabil și istoricul comenzilor",
"home.export_mesh_hint": "Plasă pentru imprimare sau partajare",
"home.export_step_hint": "Geometrie solidă precisă",
"home.import": "Importă…",
"home.import_hint": "STEP, IGES sau plasă într-un proiect nou",
"home.in_this_browser": "în acest browser",
"home.missing": "lipsă",
"home.month_01": "ian.",
"home.month_02": "feb.",
"home.month_03": "mar.",
"home.month_04": "apr.",
"home.month_05": "mai",
"home.month_06": "iun.",
"home.month_07": "iul.",
"home.month_08": "aug.",
"home.month_09": "sept.",
"home.month_10": "oct.",
"home.month_11": "nov.",
"home.month_12": "dec.",
"home.new_inches": "Proiect nou în inci",
"home.new_inches_hint": "Inci · se deschide în Proiectare",
"home.new_mm": "Proiect nou",
"home.new_mm_hint": "Milimetri · se deschide în Proiectare",
"home.open": "Deschide…",
"home.opened_days_ago": "Deschis acum {count} zile",
"home.opened_hours_ago": "Deschis acum {count} h",
"home.opened_just_now": "Deschis chiar acum",
"home.opened_minutes_ago": "Deschis acum {count} min",
"home.open_hint": "Un proiect .solidcraft ({shortcut})",
"home.open_now": "Deschis acum",
"home.opened_days_ago": "Acum {count} zile",
"home.opened_hours_ago": "Acum {count} h",
"home.opened_just_now": "Chiar acum",
"home.opened_minutes_ago": "Acum {count} min",
"home.opening": "se deschide…",
"home.properties": "Proprietățile proiectului",
"home.recent": "Recente",
"home.recent_empty": "Proiectele pe care le deschizi sau le salvezi apar aici.",
"home.recovery_count": "Proiecte recuperabile: {count}",
"home.recovery_empty": "Nu există proiecte recuperabile.",
"home.recovery_hint": "SolidCraft s-a închis înainte ca aceste proiecte să fie salvate.",
"home.report_issue": "Raportează o problemă",
"home.samples": "Exemple",
"home.settings": "Setări",
"home.short_date": "{day} {month}",
"home.shortcuts": "Scurtături de la tastatură",
"home.start_hint": "Creează un proiect, deschide un fișier recent sau explorează un exemplu.",
"home.whats_new": "Noutăți",
"home.yesterday": "Ieri",
"install.button_tip": "Instalați SolidCraft ca aplicație: se deschide în propria fereastră și funcționează offline",
"install.dock_1": "Alegeți Fișier › Adaugă în Dock.",
"install.dock_2": "Faceți clic pe Adaugă.",
+42 -4
View File
@@ -856,19 +856,57 @@
"help.mit": "Лицензия MIT",
"help.notice": "NOTICE",
"help.third_party": "Лицензии сторонних компонентов",
"home.back": "Назад к {name}",
"home.crashes": "Отчёты о сбоях",
"home.design_workspace": "Проектирование",
"home.documentation": "Документация",
"home.export": "Экспорт",
"home.export_hint": "Выберите формат для текущего проекта.",
"home.export_json_hint": "Редактируемый проект и история команд",
"home.export_mesh_hint": "Сетка для печати или обмена",
"home.export_step_hint": "Точная твердотельная геометрия",
"home.import": "Импорт…",
"home.import_hint": "STEP, IGES или сетка в новый проект",
"home.in_this_browser": "в этом браузере",
"home.missing": "отсутствует",
"home.month_01": "янв.",
"home.month_02": "февр.",
"home.month_03": "мар.",
"home.month_04": "апр.",
"home.month_05": "мая",
"home.month_06": "июн.",
"home.month_07": "июл.",
"home.month_08": "авг.",
"home.month_09": "сент.",
"home.month_10": "окт.",
"home.month_11": "нояб.",
"home.month_12": "дек.",
"home.new_inches": "Новый проект в дюймах",
"home.new_inches_hint": "Дюймы · откроется в рабочей области «Проектирование»",
"home.new_mm": "Новый проект",
"home.new_mm_hint": "Миллиметры · откроется в рабочей области «Проектирование»",
"home.open": "Открыть…",
"home.opened_days_ago": "Открыт {count} дн. назад",
"home.opened_hours_ago": "Открыт {count} ч назад",
"home.opened_just_now": "Открыт только что",
"home.opened_minutes_ago": "Открыт {count} мин назад",
"home.open_hint": "Проект .solidcraft ({shortcut})",
"home.open_now": "Открыть сейчас",
"home.opened_days_ago": "{count} дн. назад",
"home.opened_hours_ago": "{count} ч назад",
"home.opened_just_now": "Только что",
"home.opened_minutes_ago": "{count} мин назад",
"home.opening": "открывается…",
"home.properties": "Свойства проекта",
"home.recent": "Недавние",
"home.recent_empty": "Здесь появятся проекты, которые вы откроете или сохраните.",
"home.recovery_count": "Проектов для восстановления: {count}",
"home.recovery_empty": "Нет проектов для восстановления.",
"home.recovery_hint": "SolidCraft закрылся до сохранения этих проектов.",
"home.report_issue": "Сообщить о проблеме",
"home.samples": "Примеры",
"home.settings": "Настройки",
"home.short_date": "{day} {month}",
"home.shortcuts": "Сочетания клавиш",
"home.start_hint": "Создайте проект, откройте недавний файл или изучите пример.",
"home.whats_new": "Что нового",
"home.yesterday": "Вчера",
"install.button_tip": "Установить SolidCraft как приложение: отдельное окно и работа без сети",
"install.dock_1": "Выберите «Файл» › «Добавить в Dock».",
"install.dock_2": "Нажмите «Добавить».",
+42 -4
View File
@@ -856,19 +856,57 @@
"help.mit": "MIT-licens",
"help.notice": "NOTICE",
"help.third_party": "Licenser från tredje part",
"home.back": "Tillbaka till {name}",
"home.crashes": "Kraschrapporter",
"home.design_workspace": "Konstruktion",
"home.documentation": "Dokumentation",
"home.export": "Exportera",
"home.export_hint": "Välj ett format för den aktuella konstruktionen.",
"home.export_json_hint": "Redigerbar konstruktion och kommandohistorik",
"home.export_mesh_hint": "Mesh för utskrift eller delning",
"home.export_step_hint": "Exakt solidgeometri",
"home.import": "Importera…",
"home.import_hint": "STEP, IGES eller mesh till en ny konstruktion",
"home.in_this_browser": "i den här webbläsaren",
"home.missing": "saknas",
"home.month_01": "jan.",
"home.month_02": "feb.",
"home.month_03": "mars",
"home.month_04": "apr.",
"home.month_05": "maj",
"home.month_06": "juni",
"home.month_07": "juli",
"home.month_08": "aug.",
"home.month_09": "sep.",
"home.month_10": "okt.",
"home.month_11": "nov.",
"home.month_12": "dec.",
"home.new_inches": "Ny konstruktion i tum",
"home.new_inches_hint": "Tum · öppnas i Konstruktion",
"home.new_mm": "Ny konstruktion",
"home.new_mm_hint": "Millimeter · öppnas i Konstruktion",
"home.open": "Öppna…",
"home.opened_days_ago": "Öppnades för {count} dagar sedan",
"home.opened_hours_ago": "Öppnades för {count} h sedan",
"home.opened_just_now": "Öppnades nyss",
"home.opened_minutes_ago": "Öppnades för {count} min sedan",
"home.open_hint": "En .solidcraft-konstruktion ({shortcut})",
"home.open_now": "Öppen nu",
"home.opened_days_ago": "För {count} dagar sedan",
"home.opened_hours_ago": "För {count} h sedan",
"home.opened_just_now": "Nyss",
"home.opened_minutes_ago": "För {count} min sedan",
"home.opening": "öppnar…",
"home.properties": "Konstruktionsegenskaper",
"home.recent": "Senaste",
"home.recent_empty": "Konstruktioner som du öppnar eller sparar visas här.",
"home.recovery_count": "Återställningsbara konstruktioner: {count}",
"home.recovery_empty": "Inga återställningsbara konstruktioner.",
"home.recovery_hint": "SolidCraft stängdes innan dessa konstruktioner sparades.",
"home.report_issue": "Rapportera ett problem",
"home.samples": "Exempel",
"home.settings": "Inställningar",
"home.short_date": "{day} {month}",
"home.shortcuts": "Kortkommandon",
"home.start_hint": "Skapa en konstruktion, öppna en nyligen använd fil eller utforska ett exempel.",
"home.whats_new": "Nyheter",
"home.yesterday": "I går",
"install.button_tip": "Installera SolidCraft som en app: den öppnas i ett eget fönster och fungerar offline",
"install.dock_1": "Välj Arkiv › Lägg till i Dock.",
"install.dock_2": "Klicka på Lägg till.",
+42 -4
View File
@@ -856,19 +856,57 @@
"help.mit": "MIT lisansı",
"help.notice": "NOTICE",
"help.third_party": "Üçüncü taraf lisansları",
"home.back": "{name} tasarımına dön",
"home.crashes": "Çökme raporları",
"home.design_workspace": "Tasarım",
"home.documentation": "Belgeler",
"home.export": "Dışa aktar",
"home.export_hint": "Geçerli tasarım için bir biçim seçin.",
"home.export_json_hint": "Düzenlenebilir tasarım ve komut geçmişi",
"home.export_mesh_hint": "Yazdırma veya paylaşma için ağ",
"home.export_step_hint": "Hassas katı geometrisi",
"home.import": "İçe aktar…",
"home.import_hint": "STEP, IGES veya ağı yeni bir tasarıma aktar",
"home.in_this_browser": "bu tarayıcıda",
"home.missing": "eksik",
"home.month_01": "Oca",
"home.month_02": "Şub",
"home.month_03": "Mar",
"home.month_04": "Nis",
"home.month_05": "May",
"home.month_06": "Haz",
"home.month_07": "Tem",
"home.month_08": "Ağu",
"home.month_09": "Eyl",
"home.month_10": "Eki",
"home.month_11": "Kas",
"home.month_12": "Ara",
"home.new_inches": "Yeni tasarım, inç",
"home.new_inches_hint": "İnç · Tasarım alanında açılır",
"home.new_mm": "Yeni tasarım",
"home.new_mm_hint": "Milimetre · Tasarım alanında açılır",
"home.open": "Aç…",
"home.opened_days_ago": "{count} gün önce açıldı",
"home.opened_hours_ago": "{count} sa. önce açıldı",
"home.opened_just_now": "Az önce açıldı",
"home.opened_minutes_ago": "{count} dk. önce açıldı",
"home.open_hint": "Bir .solidcraft tasarımı ({shortcut})",
"home.open_now": "Şimdi aç",
"home.opened_days_ago": "{count} gün önce",
"home.opened_hours_ago": "{count} sa. önce",
"home.opened_just_now": "Az önce",
"home.opened_minutes_ago": "{count} dk. önce",
"home.opening": "açılıyor…",
"home.properties": "Tasarım özellikleri",
"home.recent": "Son kullanılanlar",
"home.recent_empty": "Açtığınız veya kaydettiğiniz tasarımlar burada görünür.",
"home.recovery_count": "Kurtarılabilir tasarımlar: {count}",
"home.recovery_empty": "Kurtarılabilir tasarım yok.",
"home.recovery_hint": "Bu tasarımlar kaydedilmeden SolidCraft kapandı.",
"home.report_issue": "Sorun bildir",
"home.samples": "Örnekler",
"home.settings": "Ayarlar",
"home.short_date": "{day} {month}",
"home.shortcuts": "Klavye kısayolları",
"home.start_hint": "Bir tasarım oluşturun, son kullanılan bir dosyayı açın veya bir örneği inceleyin.",
"home.whats_new": "Yenilikler",
"home.yesterday": "Dün",
"install.button_tip": "SolidCraft'ı uygulama olarak yükleyin: kendi penceresinde açılır ve çevrimdışı da çalışır",
"install.dock_1": "Dosya › Dock'a Ekle'yi seçin.",
"install.dock_2": "“Ekle” düğmesine tıklayın.",
+42 -4
View File
@@ -857,19 +857,57 @@
"help.mit": "Ліцензія MIT",
"help.notice": "NOTICE",
"help.third_party": "Ліцензії сторонніх компонентів",
"home.back": "Назад до {name}",
"home.crashes": "Звіти про збої",
"home.design_workspace": "Проєктування",
"home.documentation": "Документація",
"home.export": "Експортувати",
"home.export_hint": "Виберіть формат для поточного проєкту.",
"home.export_json_hint": "Редагований проєкт та історія команд",
"home.export_mesh_hint": "Сітка для друку або поширення",
"home.export_step_hint": "Точна твердотільна геометрія",
"home.import": "Імпортувати…",
"home.import_hint": "STEP, IGES або сітку в новий проєкт",
"home.in_this_browser": "у цьому браузері",
"home.missing": "відсутній",
"home.month_01": "січ.",
"home.month_02": "лют.",
"home.month_03": "бер.",
"home.month_04": "квіт.",
"home.month_05": "трав.",
"home.month_06": "черв.",
"home.month_07": "лип.",
"home.month_08": "серп.",
"home.month_09": "вер.",
"home.month_10": "жовт.",
"home.month_11": "лист.",
"home.month_12": "груд.",
"home.new_inches": "Новий проєкт у дюймах",
"home.new_inches_hint": "Дюйми · відкриється в робочому просторі «Проєктування»",
"home.new_mm": "Новий проєкт",
"home.new_mm_hint": "Міліметри · відкриється в робочому просторі «Проєктування»",
"home.open": "Відкрити…",
"home.opened_days_ago": "Відкрито {count} дн. тому",
"home.opened_hours_ago": "Відкрито {count} год тому",
"home.opened_just_now": "Щойно відкрито",
"home.opened_minutes_ago": "Відкрито {count} хв тому",
"home.open_hint": "Проєкт .solidcraft ({shortcut})",
"home.open_now": "Відкрити зараз",
"home.opened_days_ago": "{count} дн. тому",
"home.opened_hours_ago": "{count} год тому",
"home.opened_just_now": "Щойно",
"home.opened_minutes_ago": "{count} хв тому",
"home.opening": "відкриття…",
"home.properties": "Властивості проєкту",
"home.recent": "Недавні",
"home.recent_empty": "Тут з’являться проєкти, які ви відкриєте або збережете.",
"home.recovery_count": "Проєктів для відновлення: {count}",
"home.recovery_empty": "Немає проєктів для відновлення.",
"home.recovery_hint": "SolidCraft закрився до збереження цих проєктів.",
"home.report_issue": "Повідомити про проблему",
"home.samples": "Зразки",
"home.settings": "Налаштування",
"home.short_date": "{day} {month}",
"home.shortcuts": "Сполучення клавіш",
"home.start_hint": "Створіть проєкт, відкрийте нещодавній файл або перегляньте приклад.",
"home.whats_new": "Що нового",
"home.yesterday": "Учора",
"install.button_tip": "Встановити SolidCraft як застосунок: він відкривається в окремому вікні й працює без мережі",
"install.dock_1": "Виберіть «Файл» › «Додати в Dock».",
"install.dock_2": "Натисніть «Додати».",
+42 -4
View File
@@ -857,19 +857,57 @@
"help.mit": "Giấy phép MIT",
"help.notice": "NOTICE",
"help.third_party": "Giấy phép của bên thứ ba",
"home.back": "Quay lại {name}",
"home.crashes": "Báo cáo sự cố",
"home.design_workspace": "Thiết kế",
"home.documentation": "Tài liệu hướng dẫn",
"home.export": "Xuất",
"home.export_hint": "Chọn định dạng cho thiết kế hiện tại.",
"home.export_json_hint": "Thiết kế có thể chỉnh sửa và lịch sử lệnh",
"home.export_mesh_hint": "Lưới để in hoặc chia sẻ",
"home.export_step_hint": "Hình học khối chính xác",
"home.import": "Nhập…",
"home.import_hint": "Nhập STEP, IGES hoặc lưới vào thiết kế mới",
"home.in_this_browser": "trong trình duyệt này",
"home.missing": "thiếu",
"home.month_01": "thg 1",
"home.month_02": "thg 2",
"home.month_03": "thg 3",
"home.month_04": "thg 4",
"home.month_05": "thg 5",
"home.month_06": "thg 6",
"home.month_07": "thg 7",
"home.month_08": "thg 8",
"home.month_09": "thg 9",
"home.month_10": "thg 10",
"home.month_11": "thg 11",
"home.month_12": "thg 12",
"home.new_inches": "Thiết kế mới theo inch",
"home.new_inches_hint": "Inch · mở trong Thiết kế",
"home.new_mm": "Thiết kế mới",
"home.new_mm_hint": "Milimét · mở trong Thiết kế",
"home.open": "Mở…",
"home.opened_days_ago": "Đã mở {count} ngày trước",
"home.opened_hours_ago": "Đã mở {count} giờ trước",
"home.opened_just_now": "Vừa mới mở",
"home.opened_minutes_ago": "Đã mở {count} phút trước",
"home.open_hint": "Một thiết kế .solidcraft ({shortcut})",
"home.open_now": "Mở ngay",
"home.opened_days_ago": "{count} ngày trước",
"home.opened_hours_ago": "{count} giờ trước",
"home.opened_just_now": "Vừa xong",
"home.opened_minutes_ago": "{count} phút trước",
"home.opening": "đang mở…",
"home.properties": "Thuộc tính thiết kế",
"home.recent": "Gần đây",
"home.recent_empty": "Các thiết kế bạn mở hoặc lưu sẽ xuất hiện ở đây.",
"home.recovery_count": "Thiết kế có thể khôi phục: {count}",
"home.recovery_empty": "Không có thiết kế nào có thể khôi phục.",
"home.recovery_hint": "SolidCraft đã đóng trước khi các thiết kế này được lưu.",
"home.report_issue": "Báo cáo sự cố",
"home.samples": "Mẫu thiết kế",
"home.settings": "Cài đặt",
"home.short_date": "{day} {month}",
"home.shortcuts": "Phím tắt",
"home.start_hint": "Tạo thiết kế, mở tệp gần đây hoặc khám phá mẫu.",
"home.whats_new": "Có gì mới",
"home.yesterday": "Hôm qua",
"install.button_tip": "Cài đặt SolidCraft dưới dạng ứng dụng: mở trong cửa sổ riêng và hoạt động ngoại tuyến",
"install.dock_1": "Chọn Tệp › Thêm vào Dock.",
"install.dock_2": "Nhấp vào Thêm.",
+42 -4
View File
@@ -856,19 +856,57 @@
"help.mit": "MIT 授權",
"help.notice": "NOTICE",
"help.third_party": "第三方授權",
"home.back": "返回 {name}",
"home.crashes": "當機報告",
"home.design_workspace": "設計",
"home.documentation": "文件",
"home.export": "匯出",
"home.export_hint": "選擇目前設計的格式。",
"home.export_json_hint": "可編輯的設計與命令歷程",
"home.export_mesh_hint": "用於列印或分享的網格",
"home.export_step_hint": "精確的實體幾何",
"home.import": "匯入…",
"home.import_hint": "將 STEP、IGES 或網格匯入新設計",
"home.in_this_browser": "在此瀏覽器中",
"home.missing": "找不到檔案",
"home.month_01": "1月",
"home.month_02": "2月",
"home.month_03": "3月",
"home.month_04": "4月",
"home.month_05": "5月",
"home.month_06": "6月",
"home.month_07": "7月",
"home.month_08": "8月",
"home.month_09": "9月",
"home.month_10": "10月",
"home.month_11": "11月",
"home.month_12": "12月",
"home.new_inches": "新增設計(英吋)",
"home.new_inches_hint": "英吋 · 在設計工作區中開啟",
"home.new_mm": "新增設計",
"home.new_mm_hint": "公釐 · 在設計工作區中開啟",
"home.open": "開啟…",
"home.opened_days_ago": "{count} 天前開啟",
"home.opened_hours_ago": "{count} 小時前開啟",
"home.opened_just_now": "剛剛開啟",
"home.opened_minutes_ago": "{count} 分鐘前開啟",
"home.open_hint": ".solidcraft 設計檔案 ({shortcut})",
"home.open_now": "目前開啟中",
"home.opened_days_ago": "{count} 天前",
"home.opened_hours_ago": "{count} 小時前",
"home.opened_just_now": "剛剛",
"home.opened_minutes_ago": "{count} 分鐘前",
"home.opening": "正在開啟…",
"home.properties": "設計屬性",
"home.recent": "最近使用",
"home.recent_empty": "您開啟或儲存的設計會顯示於此。",
"home.recovery_count": "可復原的設計:{count} 個",
"home.recovery_empty": "沒有可復原的設計。",
"home.recovery_hint": "SolidCraft 在這些設計儲存前已關閉。",
"home.report_issue": "回報問題",
"home.samples": "範例",
"home.settings": "設定",
"home.short_date": "{month}{day}日",
"home.shortcuts": "鍵盤快速鍵",
"home.start_hint": "建立設計、開啟最近使用的檔案,或瀏覽範例。",
"home.whats_new": "新功能",
"home.yesterday": "昨天",
"install.button_tip": "將 SolidCraft 安裝為應用程式:在獨立視窗中開啟,離線也能使用",
"install.dock_1": "選擇「檔案」›「加入 Dock」。",
"install.dock_2": "按一下「加入」。",
+302 -5
View File
@@ -14,11 +14,15 @@
"Active": "活动",
"Active rule": "活动规则",
"Add": "添加",
"Add Column": "添加列",
"Add Key": "添加按键",
"Add Mid Radius": "添加中间半径",
"Add Row": "添加行",
"Add a tool": "添加工具",
"Add condition": "添加条件",
"Add or edit rule": "添加或编辑规则",
"Add to New Folder": "添加到新文件夹",
"Advanced": "高级",
"Against": "对照",
"Alignment": "对齐",
"All Sketch Points": "所有草图点",
@@ -32,6 +36,7 @@
"Appearance name": "外观名称",
"Applied Radius": "应用半径",
"Apply To": "应用于",
"Apply library appearance": "应用库外观",
"Apply to selection": "应用到选择",
"Apply your appearance first. Exports assigned appearances and materials created in this design.": "请先应用外观。导出已指定的外观和在此设计中创建的材质。",
"Arc length": "弧长",
@@ -42,13 +47,18 @@
"BLEND SURFACE": "混合曲面",
"BOUNDARY SURFACE": "边界曲面",
"BROWSER": "浏览器",
"Bend Edges": "折弯边",
"Bend Line Position": "弯折线位置",
"Bend Position": "折弯位置",
"Bend Radius": "折弯半径",
"Bend Table": "折弯表",
"Bends": "折弯",
"Bodies": "实体",
"Bodies (optional)": "实体(可选)",
"Body": "实体",
"Body (last if none)": "实体(未指定时为最后一个)",
"Body to split": "要分割的实体",
"Body, or faces to take out": "实体,或要移除的面",
"Boundary": "边界",
"Bounding box": "边界框",
"CHECK": "检查",
@@ -60,7 +70,9 @@
"CSV or JSON: updates existing parameters, adds new ones": "CSV 或 JSON:更新现有参数、添加新参数",
"Capture All Positions": "捕获所有位置",
"Capture Pos.": "捕获位置",
"Catch Depth": "卡扣深度",
"Catch Direction": "卡扣方向",
"Catch Length": "卡扣长度",
"Cells": "区域",
"Center X": "中心 X",
"Center Y": "中心 Y",
@@ -69,15 +81,21 @@
"Chamfer Type": "倒角类型",
"Check collisions": "检查碰撞",
"Checked": "已检查",
"Choose Variable radius or Spline in Type.": "请在“类型”中选择 Variable radius 或样条曲线。",
"Choose a new plane. Sketch geometry and dimensions stay unchanged in its two-dimensional coordinates.": "选择一个新平面。草图几何和尺寸在其二维坐标中保持不变。",
"Choose…": "选择…",
"Circular": "圆周",
"Circumference": "周长",
"Close draft": "关闭草稿",
"Collapse": "折叠",
"Collisions": "碰撞",
"Colour": "颜色",
"Command": "命令",
"Comment": "注释",
"Component 1": "组件 1",
"Component 2": "组件 2",
"Component1": "组件1",
"Components": "组件",
"Config Table": "配置表",
"Configuration": "配置",
"Configuration rules": "配置规则",
@@ -86,6 +104,7 @@
"Contact sets": "接触集",
"Continue on GitHub": "在GitHub中继续",
"Continuity": "连续性",
"Control Radius": "控制半径",
"Copy Error Message": "复制错误消息",
"Copy Warning Message": "复制警告消息",
"Copy all licences": "复制所有许可证",
@@ -95,8 +114,11 @@
"Corner Relief": "拐角释放",
"Corner Relief Shape": "拐角释放形状",
"Counterbore depth": "沉孔深度",
"Counterbore Ø": "柱形沉孔 Ø",
"Countersink angle": "沉头角度",
"Countersink Ø": "锥形沉孔 Ø",
"Create Issue — review draft": "创建 Issue——审阅草稿",
"Create Issue…": "创建 Issue…",
"Cross curves": "交叉曲线",
"Cube": "立方体",
"Current orthographic view (1:1 mm)": "当前正交视图(1:1毫米)",
@@ -106,7 +128,11 @@
"Curvature change (G3)": "曲率变化 (G3)",
"Custom": "自定义",
"Customize Toolbar": "自定义工具栏",
"Customize…": "自定义…",
"Cylindrical face": "圆柱面",
"Delete": "删除",
"Delete Selected Features…": "删除所选特征…",
"Delete Selected Folders": "删除所选文件夹",
"Delete all": "全部删除",
"Delete rule": "删除规则",
"Delete the row": "删除此行",
@@ -117,8 +143,11 @@
"Describe what happened before continuing.": "继续之前,请描述发生了什么。",
"Designs you open or save appear here.": "你打开或保存的设计会显示在此处。",
"Determined by To Object. Choose Angle to edit the angular extent.": "由“到对象”确定。选择“角度”可编辑角度范围。",
"Diameter": "直径",
"Direction": "方向",
"Discard": "放弃",
"Distance": "距离",
"Distance 1": "距离 1",
"Do not export hidden edges": "不导出隐藏线",
"Don't ask again": "不再询问",
"Double-click to rename": "双击重命名",
@@ -130,18 +159,22 @@
"Edit Profile Sketch": "编辑轮廓草图",
"Enable rule": "启用规则",
"Enabled": "已启用",
"End Radius": "结束半径",
"End bulge": "终止鼓度",
"End continuity": "终止连续性",
"Expand": "展开",
"Expected result (optional)": "预期结果(可选)",
"Explode": "爆炸",
"Explode Text": "分解文字",
"Export Animation…": "导出动画…",
"Export Bend Table…": "导出折弯表…",
"Export CSV…": "导出 CSV…",
"Export Current View as DXF": "将当前视图导出为DXF",
"Export Current View as DXF…": "将当前视图导出为DXF…",
"Export DXF": "导出DXF",
"Export DXF…": "导出DXF…",
"Export JSON…": "导出 JSON…",
"Export design library...": "导出设计库...",
"Export hidden edges as dashed lines": "以虚线导出隐藏线",
"Expression": "表达式",
"Extend to meet": "延伸至相交",
@@ -149,25 +182,33 @@
"FILES": "文件",
"FLAT": "展开",
"Face": "面",
"Face Appearance": "面外观",
"Face attachment lost": "面附着丢失",
"Faces": "面",
"Faces / edges / vertices": "面 / 边 / 顶点",
"Faces to split": "要分割的面",
"Fastener": "紧固件",
"Favorites": "收藏",
"Favourite": "收藏",
"Feature": "特征",
"Files from many CAD programs have Y up, so they open lying on their back here (Z up).": "许多 CAD 程序的文件以 Y 为上轴,因此在此处打开时会平躺(Z 朝上)。",
"Filter": "筛选",
"Filter…": "筛选…",
"Find in Browser": "在浏览器中查找",
"Finish": "完成",
"First plane": "第一个平面",
"Fit and G2 corners require Constant radius.": "拟合和 G2 拐角需要 Constant radius。",
"Fixed Face": "固定面",
"Flat Pattern": "展开图",
"Flat size": "平板尺寸",
"Follow camera (Look At sketches)": "跟随相机(注视草图)",
"Follow the camera (Look At sketches)": "跟随相机(注视草图)",
"Font": "字体",
"From": "起点",
"From Bodies": "从实体创建",
"From edges": "起始边",
"Full Length": "全长",
"G2 corners require a constant radius and Exact fit. G2 corner construction is not supported yet.": "G2 拐角需要恒定半径和 Exact fit。尚不支持构建 G2 拐角。",
"Gap": "间隙",
"Grid": "网格",
"HISTORY": "历史",
@@ -175,6 +216,7 @@
"Hidden edges": "隐藏线",
"History layout": "历史布局",
"History names": "历史名称",
"Hole (optional)": "孔(可选)",
"Holes": "孔",
"Holes (edges on them)": "孔(孔上的边)",
"Home view": "主视图",
@@ -185,14 +227,20 @@
"In place (surfaces)": "原位(曲面)",
"In plane": "在平面内",
"Inside": "内侧",
"Instant": "即时",
"Interface font": "界面字体",
"Interferes": "发生干涉",
"Invert": "反转",
"Isocurve": "等参线",
"Issue title": "Issue 标题",
"Items": "项目",
"JOINTS": "关节",
"Joint Origin1": "接头原点1",
"Keep (points on it)": "保留(其上的点)",
"Keep Tools": "保留工具",
"Keep of the other surface": "另一曲面保留的部分",
"Key a joint's value at this step": "在此步骤为接头值设置关键帧",
"Keys": "关键帧",
"LIBRARY": "库",
"LINKS": "链接",
"Later": "稍后",
@@ -203,9 +251,14 @@
"Letter Spacing": "字间距",
"Library": "零件库",
"Library file": "库文件",
"Limits": "限制",
"Line Spacing": "行间距",
"Line Type": "线型",
"Listing the installed fonts…": "正在列出已安装字体…",
"Load library": "加载库",
"Local library": "本地库",
"Loop": "循环",
"M5 or M5x0.5": "M5 或 M5x0.5",
"MANAGE": "管理",
"MATERIALS": "材料",
"MODEL": "模型",
@@ -216,14 +269,20 @@
"Matched restrictions check the final values.": "满足条件的限制规则检查最终值。",
"Material": "材质",
"Max": "最大",
"Maximum": "最大",
"Mesh body": "网格实体",
"Mid Radius": "中间半径",
"Middle double-click": "中键双击",
"Min": "最小",
"Minimum": "最小",
"Mirror Plane": "镜像平面",
"Mitre": "斜接",
"Model Parameters": "模型参数",
"Modeled": "已建模",
"More toolbar panels": "更多工具栏面板",
"Motion": "运动",
"Mouse gestures": "鼠标手势",
"Mouse gestures only work with the SolidWorks mouse mode (the wheel orbits, Ctrl + wheel pans).": "鼠标手势仅在 SolidWorks 鼠标模式下有效(滚轮旋转,Ctrl + 滚轮平移)。",
"Move / Copy": "移动 / 复制",
"Move Type": "移动类型",
"Move down": "下移",
@@ -246,8 +305,11 @@
"Not available yet": "暂不可用",
"Notch": "缺口",
"OUTPUT": "输出",
"Of": "的值",
"On your computer": "在此计算机上",
"Opacity": "不透明度",
"Open GitHub draft": "打开 GitHub 草稿",
"Open library...": "打开库...",
"Open local libraries in the desktop app. You can export this design's library here.": "请在桌面应用中打开本地库。您可以在此导出当前设计的库。",
"Open-source parametric 3D CAD in Rust. Licensed MIT OR Apache-2.0.": "使用 Rust 编写的开源参数化 3D CAD。基于 MIT OR Apache-2.0 许可。",
"Opens your browser and copies the report text for you to paste into GitHub.": "打开浏览器,并复制报告文本以便粘贴到GitHub。",
@@ -269,8 +331,10 @@
"Picked": "所选",
"Picked point": "选取的点",
"Pitch": "螺距",
"Pivot": "枢轴",
"Place at the origin instead": "改为放置在原点",
"Plastic Rules": "塑料规则",
"Play the joint's motion in the view": "在视图中播放接头运动",
"Playback": "播放",
"Plot": "图表",
"Position": "位置",
@@ -278,14 +342,18 @@
"Position (G0)": "位置 (G0)",
"Position along the edge, 0 at the start end, 1 at the other": "沿边的位置,起点端为 0,另一端为 1",
"Preferences › Design turns the offer back on": "可在 首选项 › 设计 中重新开启此提示",
"Preview": "预览",
"Preview Row": "预览行",
"Preview leaves the current design unchanged.": "预览不会更改当前设计。",
"Profile / Edges": "轮廓 / 边",
"Profiles or curves": "轮廓或曲线",
"Profiles/Curves": "轮廓/曲线",
"Quantity": "物理量",
"RECENT": "最近使用",
"REFERENCE": "参考",
"RULED SURFACE": "直纹曲面",
"RULES": "规则",
"Radius": "半径",
"Radius Fit": "半径拟合",
"Ratio": "比率",
"Reattach selected face": "重新附着所选面",
@@ -294,6 +362,8 @@
"Rectangular": "矩形",
"Reference face": "参考面",
"Remove Tools": "移除工具",
"Remove condition": "删除条件",
"Remove from Folder": "从文件夹中移除",
"Remove from list": "从列表移除",
"Remove override": "移除覆盖",
"Remove private design details or paths before sharing.": "共享前请移除私密设计细节或路径。",
@@ -301,6 +371,9 @@
"Remove the column": "移除此列",
"Remove the track": "移除轨迹",
"Remove this point": "移除此点",
"Rename Feature": "重命名特征",
"Rename Folder": "重命名文件夹",
"Replace existing library file": "替换现有库文件",
"Report a bug — review a screenshot and open a GitHub draft": "报告缺陷 — 复核截图并开启 GitHub 草稿",
"Report text copied. Paste it into GitHub and submit your report there.": "报告文本已复制。请在GitHub中粘贴并提交。",
"Reports are public. A GitHub account is required.": "报告将公开发布。您需要GitHub账户。",
@@ -309,6 +382,7 @@
"Restore View": "恢复视图",
"Restore outer boundary": "恢复外边界",
"Restore the inherited component or physical material appearance": "还原继承的组件或物理材质外观",
"Restore this studio's tools": "恢复此工作室的工具",
"Result": "结果",
"Retake screenshot": "重新截图",
"Reverse": "反向",
@@ -319,7 +393,13 @@
"Revert Position": "恢复位置",
"Review the report and screenshot before opening GitHub.": "打开 GitHub 前请审阅报告和截图。",
"Review your report on GitHub and click Submit new issue to publish it.": "请在GitHub中检查报告,然后点击Submit new issue发布。",
"Rib Length": "筋板长度",
"Rib Offset": "筋板偏移",
"Rib Thickness": "筋板厚度",
"Ribs": "筋板",
"Rim Edge": "轮缘边",
"Ring of tools on the right button": "右键工具环",
"Roll Back Here": "回滚到此处",
"Roll History Marker Here": "将历史标记滚动到此处",
"Round": "圆角",
"Rule": "规则",
@@ -338,13 +418,18 @@
"Search": "搜索",
"Search commands": "搜索命令",
"Search this library": "搜索此库",
"Second plane": "第二个平面",
"Section": "剖面",
"Sections (one sketch each)": "截面(每个一个草图)",
"Select Other": "选择其他",
"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).": "“选择其他”:在模型上按住左键(或右键单击 › 选择其他…)列出光标下的所有对象;在同一位置再次单击可选择该处的下一个对象。双击中键转到主视图(首选项 › 导航)。",
"Select bodies (a face picks its body)": "选择实体(单击面即选择其所属实体)",
"Select bodies or faces instead": "改为选择实体或面",
"Select edges": "选择边",
"Selection filter": "选择筛选",
"Semantic faces share their appearance across instances.": "语义面在各实例间共享外观。",
"Send your report on GitHub": "在 GitHub 上发送报告",
"Shaded": "着色",
"Shape": "形状",
"Sheet Rules": "钣金规则",
"Show Agent Cursor": "显示代理光标",
@@ -362,6 +447,7 @@
"SolidCraft closed before these designs were saved. Their last autosave can be opened.": "SolidCraft 在这些设计保存前已关闭。可以打开它们的上次自动保存。",
"Specialise": "专用操作",
"Speed": "速度",
"Splitting tool": "分割工具",
"Stand it up": "将其立起",
"Stand it up: a quarter turn about X, as one Move feature you can undo.": "将其立起:绕 X 轴旋转四分之一周,作为一个可撤销的移动特征。",
"Stand this part up?": "立起此零件?",
@@ -372,16 +458,22 @@
"Steps to reproduce (optional)": "重现步骤(可选)",
"Stitch the kept pieces": "缝合保留的部分",
"Stitch to both bodies": "缝合到两个实体",
"Studio": "工作室",
"Study": "研究",
"Style": "样式",
"Summary": "摘要",
"Suppress": "抑制",
"Suppressed": "已抑制",
"Surface": "曲面",
"Surface, or faces to turn over": "曲面,或要翻转的面",
"Swap Ends": "交换端点",
"TRIM": "修剪",
"Tangent (G1)": "相切 (G1)",
"Tangent Chain": "相切链",
"Tangent chain": "相切链",
"Tap drill diameter": "攻丝底孔直径",
"Target": "目标",
"Target Body": "目标实体",
"Tear": "撕裂",
"Technical details": "技术详情",
"The dashed sketch is a saved plane guide. Select a new face to repair the attachment.": "虚线草图是已保存的平面引导。选择一个新面以修复附着。",
@@ -396,24 +488,31 @@
"This child is shared. Position changes apply to every copy of its parent.": "此子项是共享的。位置更改会应用到其父项的每个副本。",
"This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "此面无法提供持久附着。请选择其他面或显式使用固定平面。",
"This image is not attached to GitHub yet. Copy or save it, then attach it on GitHub.": "此图片尚未附加到GitHub。请复制或保存图片,然后在GitHub中附加。",
"To": "终点",
"To Offset": "偏移",
"To edges": "终止边",
"Tolerance": "公差",
"Too close": "过近",
"Tool Bodies": "工具实体",
"Tools": "工具",
"Tools, clockwise from the top": "工具,从顶部顺时针排列",
"Trimming tool (plane, face or curves)": "修剪工具(平面、面或曲线)",
"Turn off section analysis before exporting the view.": "请关闭截面分析后再导出视图。",
"Type": "类型",
"UNTRIM": "取消修剪",
"Unit": "单位",
"Unsuppress": "取消抑制",
"Updating the preview": "正在更新预览",
"Use active author placement": "使用活动作者放置",
"Use fixed plane": "使用固定平面",
"Use saved fixed plane": "使用已保存的固定平面",
"Use selected face as fixed plane": "使用所选面作为固定平面",
"Use the SolidWorks mouse mode": "使用 SolidWorks 鼠标模式",
"User Parameters": "用户参数",
"VIEW": "视图",
"VIEWS": "视图",
"Value": "值",
"Variable Radius": "可变半径",
"Velocity": "速度",
"Vertices": "顶点",
"View navigator": "视图导航器",
@@ -427,13 +526,44 @@
"When": "条件",
"Width": "宽度",
"Will fail": "将失败",
"With gestures on, the right button no longer orbits the view: hold it and move to open the ring, then touch a tool's zone. A plain right-click still opens the menu.": "开启鼠标手势后,右键不再旋转视图:按住右键并移动以打开工具环,然后触碰某个工具的区域。普通右键单击仍会打开菜单。",
"X": "X",
"X Angle": "X 角度",
"X angle": "X 角度",
"X distance": "X 距离",
"Y": "Y",
"Y Angle": "Y 角度",
"Y angle": "Y 角度",
"Y distance": "Y 距离",
"Z": "Z",
"Z Angle": "Z 角度",
"Z Distance": "Z 距离",
"Z angle": "Z 角度",
"Z distance": "Z 距离",
"Zebra": "斑马纹",
"ai.prefs.availability": "助手控制",
"ai.prefs.background_commands": "在后台执行代理命令",
"ai.prefs.follow_camera": "将相机转向草图",
"ai.prefs.follow_camera_off": "相机自动跟随已关闭。",
"ai.prefs.follow_camera_on": "相机将转向代理创建的草图。",
"ai.prefs.intro": "设置代理、权限及其工作方式。",
"ai.prefs.make_default": "设为默认",
"ai.prefs.no_agents": "尚无代理。添加代理或刷新以查找已安装的代理。",
"ai.prefs.no_saved_approvals": "没有已保存的批准。",
"ai.prefs.override_all_warning": "此代理可在不询问的情况下保存、覆盖、导出或替换设计。",
"ai.prefs.permission_all": "全部允许",
"ai.prefs.permission_all_summary": "可用的 SolidCraft 工具无需询问即可运行。",
"ai.prefs.permission_ask": "更改前询问",
"ai.prefs.permission_ask_summary": "每次设计更改和文件操作均需批准。",
"ai.prefs.permission_edits": "允许模型编辑",
"ai.prefs.permission_edits_summary": "模型编辑自动执行;文件及破坏性操作仍需询问。",
"ai.prefs.permission_scope": "仅适用于 SolidCraft 工具。代理的终端和文件编辑使用其自身权限。",
"ai.prefs.pointer_preview_hidden": "指针已隐藏",
"ai.prefs.pointer_preview_speed": "代理指针 · {speed}",
"ai.prefs.pointer_speed": "移动速度",
"ai.prefs.privacy_note": "对话仅发送给所选代理或端点。端点密钥保存在系统钥匙串中。",
"ai.prefs.show_pointer": "显示代理指针",
"ai.prefs.turn_limit_hint": "助手针对一条消息可调用的设计更改工具次数上限。",
"angle, height": "角度、高度",
"appearance.dielectric_ior": "电介质折射率",
"appearance.metallic": "金属度",
@@ -889,19 +1019,57 @@
"help.solid.revolve": "将草图轮廓绕一条轴旋转,生成圆形的实体。用于轴、旋钮、碗等围绕中心线呈圆形的零件。",
"help.solid.shell": "将实体掏空,留下指定厚度的壁,还可以打开所选的面。用于盒子、盖子等薄壁零件。",
"help.third_party": "第三方许可证",
"home.back": "返回 {name}",
"home.crashes": "崩溃报告",
"home.design_workspace": "设计",
"home.documentation": "文档",
"home.export": "导出",
"home.export_hint": "为当前设计选择一种格式。",
"home.export_json_hint": "可编辑的设计和命令历史记录",
"home.export_mesh_hint": "用于打印或共享的网格",
"home.export_step_hint": "精确的实体几何",
"home.import": "导入…",
"home.import_hint": "将 STEP、IGES 或网格导入新设计",
"home.in_this_browser": "在此浏览器中",
"home.missing": "文件不存在",
"home.month_01": "1月",
"home.month_02": "2月",
"home.month_03": "3月",
"home.month_04": "4月",
"home.month_05": "5月",
"home.month_06": "6月",
"home.month_07": "7月",
"home.month_08": "8月",
"home.month_09": "9月",
"home.month_10": "10月",
"home.month_11": "11月",
"home.month_12": "12月",
"home.new_inches": "新建设计(英寸)",
"home.new_inches_hint": "英寸 · 在设计工作区中打开",
"home.new_mm": "新建设计",
"home.new_mm_hint": "毫米 · 在设计工作区中打开",
"home.open": "打开…",
"home.opened_days_ago": "{count} 天前打开",
"home.opened_hours_ago": "{count} 小时前打开",
"home.opened_just_now": "刚刚打开",
"home.opened_minutes_ago": "{count} 分钟前打开",
"home.open_hint": ".solidcraft 设计文件 ({shortcut})",
"home.open_now": "立即打开",
"home.opened_days_ago": "{count} 天前",
"home.opened_hours_ago": "{count} 小时前",
"home.opened_just_now": "刚刚",
"home.opened_minutes_ago": "{count} 分钟前",
"home.opening": "正在打开…",
"home.properties": "设计属性",
"home.recent": "最近使用",
"home.recent_empty": "您打开或保存的设计将显示在这里。",
"home.recovery_count": "可恢复的设计: {count} 个",
"home.recovery_empty": "没有可恢复的设计。",
"home.recovery_hint": "SolidCraft 在这些设计保存前已关闭。",
"home.report_issue": "报告问题",
"home.samples": "示例",
"home.settings": "设置",
"home.short_date": "{month}{day}日",
"home.shortcuts": "键盘快捷键",
"home.start_hint": "创建设计、打开最近的文件或浏览示例。",
"home.whats_new": "新功能",
"home.yesterday": "昨天",
"install.button_tip": "将 SolidCraft 安装为应用:在独立窗口中打开,离线也能使用",
"install.dock_1": "选择“文件”›“添加到程序坞”。",
"install.dock_2": "点按“添加”。",
@@ -959,8 +1127,15 @@
"motion.to_end": "到结尾",
"motion.to_start": "到开头",
"motion.track_name": "轨道 {number}",
"nav.alias.help": "Shift+Alt+左键拖动旋转,Shift+Alt+中键拖动平移,Shift+Alt+右键拖动缩放;中键拖动也可平移,右键拖动旋转",
"nav.blender.help": "中键拖动旋转,Shift+中键拖动平移,Ctrl+中键拖动缩放;右键拖动不会移动相机",
"nav.free_orbit.help": "鼠标和3D鼠标可保持滚转并越过两个极点旋转。Shift轴吸附将被禁用。",
"nav.free_orbit.label": "自由旋转",
"nav.fusion.help": "中键拖动平移,Shift+中键拖动旋转,右键拖动旋转",
"nav.inventor.help": "中键拖动平移,Shift+中键拖动旋转,Ctrl+中键拖动缩放,右键拖动旋转",
"nav.openscad.help": "在空白处左键拖动旋转,Shift+左键拖动框选,拖动草图几何可编辑;右键或中键拖动平移,Shift+右键或 Shift+中键拖动缩放",
"nav.solidworks.help": "中键拖动旋转,Ctrl+中键拖动平移,Shift+中键拖动缩放,右键拖动旋转",
"nav.tinkercad.help": "右键拖动旋转,中键或 Shift+右键拖动平移",
"nav.touchpad.help": "按住 Shift 并移动指针可平移;按住 Alt/Option 并移动可旋转。无需按鼠标按钮。垂直滚动可缩放。",
"nav.touchpad.label": "触控板",
"nav.touchpad.orbit": "旋转(按住 Alt/Option 并移动,无需鼠标按钮)",
@@ -983,8 +1158,130 @@
"pattern.flip_direction": "反转方向",
"pick a hole to seat it, or OK places it at the origin": "选择一个孔放置,或单击确定放置在原点",
"pick one or two vertices, edges or faces": "选择一个或两个顶点、边或面",
"prefs.add_item": "添加项目",
"prefs.alt_frees_manipulators": "Alt / Option 也可自由拖动操纵器箭头",
"prefs.anti_aliasing_note": "平滑锯齿边缘。",
"prefs.applies_on_restart": "重启后生效",
"prefs.assistant": "助手",
"prefs.axes": "坐标轴",
"prefs.axis": "轴",
"prefs.bottom": "底部",
"prefs.browser": "浏览器",
"prefs.built_in": "内置",
"prefs.button_left": "左键拖动",
"prefs.button_middle": "中键拖动",
"prefs.button_right": "右键拖动",
"prefs.choose_font": "选择字体文件…",
"prefs.click": "单击",
"prefs.contact_shading": "柔和接触阴影",
"prefs.contact_shading_note": "在零件相近处添加柔和阴影。",
"prefs.context_menu": "上下文菜单",
"prefs.context_menu_ring": "上下文菜单 · 按住显示环形菜单",
"prefs.cpu": "CPU(软件)",
"prefs.crash_reports": "打开崩溃报告…",
"prefs.crash_reports_note": "报告与重现问题的设计和命令一起保存在此计算机上。",
"prefs.ctrl_cmd": "Ctrl / Cmd",
"prefs.curved_surfaces": "曲面",
"prefs.curves": "曲面",
"prefs.dead_zone_note": "斜线区域为死区:此范围内的移动将被忽略。",
"prefs.default": "默认",
"prefs.design": "设计默认值",
"prefs.design_note": "新建设计的初始设置。已打开的设计保留各自的设置。",
"prefs.devices": "3D 鼠标",
"prefs.display": "显示",
"prefs.edges": "边缘与着色",
"prefs.extend": "扩展",
"prefs.extra_navigation": "在空白处按住Ctrl并用鼠标左键拖动可旋转;按住空格键可平移。",
"prefs.follows_os": "跟随操作系统",
"prefs.font": "字体",
"prefs.font_fallback": "所选字体缺少的字符会使用内置字体,避免标签显示为空方框。",
"prefs.font_sample": "字体示例 · Ø12.50 mm · 45.0° · 0123456789",
"prefs.font_unreadable": "无法读取所选字体。",
"prefs.general": "常规",
"prefs.general_note": "语言、保存和更新。",
"prefs.gesture_orbit": "环绕",
"prefs.gesture_pan": "平移",
"prefs.gesture_zoom": "缩放",
"prefs.graphics": "图形",
"prefs.graphics_note": "在速度与平滑度之间取舍。每个选项均以示例展示效果。",
"prefs.graphics_presets_note": "预设会同时设置以下选项。",
"prefs.history": "历史",
"prefs.hold_stop_snapping": "按住以停用吸附",
"prefs.icons_only": "仅图标",
"prefs.imported_file": "已导入文件",
"prefs.input": "输入",
"prefs.interface": "界面",
"prefs.interface_language": "界面语言",
"prefs.interface_note": "SolidCraft 的外观和面板布局。",
"prefs.interval_minutes": "{n} 分钟",
"prefs.labels": "标签",
"prefs.layout": "布局",
"prefs.layout_note": "紧凑模式将面板停靠在窗口边缘。浏览器半透明仅适用于浮动模式。助手面板的位置在助手页面中设置。",
"prefs.left": "左侧",
"prefs.left_click": "左键单击",
"prefs.live_input": "实时输入",
"prefs.live_preview": "实时预览",
"prefs.minutes": "分钟",
"prefs.minutes_suffix": " min",
"prefs.mouse": "鼠标与触控板",
"prefs.mouse_heading": "鼠标",
"prefs.mouse_mapping": "鼠标映射",
"prefs.navigation": "导航",
"prefs.navigation_note": "鼠标、触控板和3D鼠标如何移动视图。",
"prefs.not_on_gpu": "此 GPU 不支持",
"prefs.on": "开启",
"prefs.opaque": "不透明",
"prefs.orbit_snap": "旋转时按住Shift可捕捉到坐标轴。",
"prefs.orientation": "方向",
"prefs.overall_speed": "整体速度",
"prefs.preview_choices": "所选设置预览",
"prefs.quality_default": "默认",
"prefs.quality_fastest": "最快",
"prefs.quality_slowest": "最慢",
"prefs.quality_smoother": "更平滑",
"prefs.reports_saved": "已保存 {n} 份",
"prefs.right_click": "右键单击",
"prefs.saving": "自动保存和恢复",
"prefs.scale_note": "也可在窗口中使用 Ctrl + / Ctrl −。100% 时按钮高度为 28 像素。",
"prefs.scale_text": "缩放与文字",
"prefs.select": "选择",
"prefs.select_one_item": "选择一个项目",
"prefs.selecting": "选择",
"prefs.shift": "Shift",
"prefs.shift_add_ctrl_toggle": "Shift 仅添加;Ctrl / Cmd 切换选择",
"prefs.shift_add_note": "开启:Shift 添加,Ctrl / Cmd 切换选择。关闭(默认):两者均切换选择。",
"prefs.shortcuts": "快捷键",
"prefs.sketch_selection": "草图与选择",
"prefs.sketch_selection_heading": "草图和选择",
"prefs.sketch_selection_note": "草图如何捕捉,以及单击如何选择。",
"prefs.sketching": "草图",
"prefs.snap_drawing_note": "在绘制或拖动草图点时使用。",
"prefs.source": "来源",
"prefs.speed": "速度",
"prefs.stand_up_note": "以 Z 轴向上打开 STEP 或 IGES 文件后,询问是否绕 X 轴旋转 90°。",
"prefs.status_done": "完成",
"prefs.status_error": "错误",
"prefs.status_selected": "已选择",
"prefs.status_warning": "警告",
"prefs.supersampling_brief": "以更高分辨率绘制后滤波缩小:更清晰,但负载更高。",
"prefs.tab_accent_note": "强调色跟随当前选项卡",
"prefs.theme_file": "主题文件",
"prefs.timeline_half_hour": "−30 分钟",
"prefs.timeline_now": "现在",
"prefs.timeline_quarter_hour": "−15",
"prefs.toggle_item": "添加项目,或取消选择",
"prefs.toolbar": "工具栏",
"prefs.translucent": "半透明",
"prefs.under_browser": "浏览器下方",
"prefs.under_cursor": "光标下方",
"prefs.units_precision": "单位与精度",
"prefs.updates": "更新",
"prefs.view": "视图行为",
"prefs.viewport": "视口",
"prefs.wheel": "滚轮",
"prefs.wheel_trackpad": "滚轮与触控板",
"prefs.workspace": "工作区",
"prefs.zoom_toward_cursor": "朝光标缩放",
"properties.title": "属性:{name}",
"recovery.days_ago": "{count} 天前",
"recovery.detail": "{file} · {features} 个特征 · 自动保存于 {when}",
@@ -1055,7 +1352,7 @@
"search.suggest": "您是否想找 {label}?",
"search.try": "输入命令名称、此设计中的特征或参数。要带值运行命令,请输入其ID和JSON对象:{example}",
"search.valid": "有效",
"settings.display_quality_note": "级别越精细,曲面和边显示越平滑,但网格生成耗时更长。只影响视口,不影响设计或导出。",
"settings.display_quality_note": "仅影响显示,不影响设计和导出。",
"settings.frame_rate_note": "在视图右下角显示每秒帧数。开启时视图会持续重绘。",
"settings.measure_axis_distances": "显示 X/Y/Z 距离",
"settings.measure_axis_distances_help": "测量对话框打开时,在总距离之外显示沿世界坐标轴方向的尺寸线和距离。",
+137 -1
View File
@@ -64,7 +64,138 @@ fn vs_tri(@location(0) p: vec3<f32>, @location(1) n: vec3<f32>, @location(2) c:
return o;
}
/// Opaque model surfaces: cut by the section plane, the inside shows as a flat cap colour.
/// Shared triangles (in their part's own frame) drawn at one placement: the rows of its rigid
/// move (`r0`..`r2`, a point's coordinates are their dot products with (p, 1)) and its colour.
/// The vertex colour (location 2) is not read.
@vertex
fn vs_tri_inst(
@location(0) p: vec3<f32>,
@location(1) n: vec3<f32>,
@location(3) r0: vec4<f32>,
@location(4) r1: vec4<f32>,
@location(5) r2: vec4<f32>,
@location(6) c: vec4<f32>,
) -> TOut {
let q = vec4<f32>(p, 1.0);
let wp = vec3<f32>(dot(r0, q), dot(r1, q), dot(r2, q));
var o: TOut;
o.pos = u.vp * vec4<f32>(wp, 1.0);
o.n = vec3<f32>(dot(r0.xyz, n), dot(r1.xyz, n), dot(r2.xyz, n));
o.c = c;
o.wp = wp;
return o;
}
/// A flat-shaded placement (`vs_tri_inst_flat`): its vertices are shared between triangles, so
/// the face's normal is the triangle's own plane, worked out per fragment. The fragment gets its
/// position from the eye (`rp`): small near what is looked at, so the plane comes out exact at
/// any zoom however far from the origin the part stands, and few values per vertex (a tiled GPU
/// stores every vertex's outputs: more of them cost more than the triangles' shared vertices
/// save).
struct FOut {
@builtin(position) pos: vec4<f32>,
@location(0) rp: vec3<f32>,
@location(1) c: vec4<f32>,
};
@vertex
fn vs_tri_inst_flat(
@location(0) p: vec3<f32>,
@location(3) r0: vec4<f32>,
@location(4) r1: vec4<f32>,
@location(5) r2: vec4<f32>,
@location(6) c: vec4<f32>,
) -> FOut {
let q = vec4<f32>(p, 1.0);
let wp = vec3<f32>(dot(r0, q), dot(r1, q), dot(r2, q));
var o: FOut;
o.pos = u.vp * vec4<f32>(wp, 1.0);
o.rp = wp - u.eye.xyz;
o.c = c;
return o;
}
/// The fragment of a flat-shaded placement as the other triangles' fragments are: its normal
/// the outward normal of its triangle (toward the eye on a front face, away on a back face, as
/// the triangles are wound outward).
fn flat_fragment(i: FOut, front: bool) -> TOut {
var n = cross(dpdx(i.rp), dpdy(i.rp));
let toward = select(u.back.xyz, -i.rp, u.eye.w > 0.5);
n = n * inverseSqrt(max(dot(n, n), 1e-30));
if ((dot(n, toward) < 0.0) == front) { n = -n; }
var o: TOut;
o.pos = i.pos;
o.n = n;
o.c = i.c;
o.wp = i.rp + u.eye.xyz;
return o;
}
@fragment
fn fs_solid_flat(i: FOut, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
let t = flat_fragment(i, front);
let c = solid(t, front);
return c;
}
@fragment
fn fs_solid_cut_flat(i: FOut, @builtin(front_facing) front: bool) -> CutOut {
let t = flat_fragment(i, front);
var o: CutOut;
o.depth = pushed(cap_depth(t.wp, t.pos.z));
o.c = solid(t, front);
return o;
}
/// Weighted blended order-independent transparency (`view/oit.rs`): a see-through fragment's
/// colour, premultiplied and weighted by its alpha and nearness, into the accumulation target,
/// and its alpha into the revealage target (which keeps what is behind it by 1 - alpha).
struct Oit {
@location(0) accum: vec4<f32>,
@location(1) reveal: vec4<f32>,
};
fn oit(c: vec4<f32>, z: f32) -> Oit {
let a = clamp(c.a, 0.0, 1.0);
// Depth 0 near and 1 far, whichever way the buffer runs.
let d = select(z, 1.0 - z, reversed());
let w = clamp(a * max(1e-2, 3e3 * pow(1.0 - d, 3.0)), 1e-2, 3e3);
var o: Oit;
o.accum = vec4<f32>(c.rgb * a, a) * w;
o.reveal = vec4<f32>(a);
return o;
}
@fragment
fn fs_oit(i: TOut) -> Oit {
if (clipped(i.wp)) { discard; }
return oit(shade(i), i.pos.z);
}
@fragment
fn fs_oit_flat(i: FOut, @builtin(front_facing) front: bool) -> Oit {
let t = flat_fragment(i, front);
let o = oit(shade(t), t.pos.z);
if (clipped(t.wp)) { discard; }
return o;
}
/// A see-through flat-shaded placement (faded or glass), shaded like `fs_tri`.
@fragment
fn fs_tri_flat(i: FOut, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
let t = flat_fragment(i, front);
let c = shade(t);
if (clipped(t.wp)) { discard; }
return c;
}
@fragment
fn fs_ao_depth_flat(i: FOut, @builtin(front_facing) front: bool) -> AoDepth {
let t = flat_fragment(i, front);
return opaque_depth(t, front);
}
/// Opaque model surfaces: cut by the section plane/// Opaque model surfaces: cut by the section plane, the inside shows as a flat cap colour.
@fragment
fn fs_solid(i: TOut, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
let c = solid(i, front);
@@ -150,6 +281,11 @@ struct AoDepth {
@fragment
fn fs_ao_depth(i: TOut, @builtin(front_facing) front: bool) -> AoDepth {
return opaque_depth(i, front);
}
/// The AO prepass's depth of an opaque surface's fragment.
fn opaque_depth(i: TOut, front: bool) -> AoDepth {
if (clipped(i.wp) || length(i.n) < 0.5) { discard; }
var depth = i.pos.z;
if (!front && length(u.clip.xyz) > 0.5) { depth = cap_depth(i.wp, i.pos.z); }
+48 -6
View File
@@ -8,6 +8,35 @@ const fn icon_primary(rgb: [u8; 3]) -> Color32 {
impl Tokens {
pub const fn light() -> Tokens {
Tokens {
file_dim: Color32::from_rgb(58, 70, 86),
file_recovery_well: Color32::from_rgb(247, 226, 184),
file_action_height: 32.0,
file_back_height: 40.0,
file_recovery_icon: 32.0,
file_grid_gap: 14.0,
file_background: Color32::from_rgb(247, 249, 251),
file_surface: Color32::WHITE,
file_border: Color32::from_rgb(224, 229, 236),
file_image_tint: Color32::WHITE,
file_rail: Color32::from_rgb(35, 110, 173),
file_rail_ink: Color32::WHITE,
file_rail_hover: Color32::from_rgb(43, 118, 180),
file_primary: Color32::from_rgb(227, 240, 251),
file_primary_ink: Color32::from_rgb(26, 80, 127),
file_primary_border: Color32::from_rgb(50, 139, 211),
file_recovery: Color32::from_rgb(255, 246, 229),
file_recovery_ink: Color32::from_rgb(122, 74, 5),
file_recovery_border: Color32::from_rgb(240, 210, 154),
file_nav_width: 248.0,
file_nav_height: 38.0,
file_tile_height: 84.0,
file_tile_icon: 44.0,
file_recent_height: 52.0,
file_sample_image_height: 84.0,
file_content_inset_x: 40.0,
file_content_inset_y: 32.0,
file_section_gap: 28.0,
font_start_title: 28.0,
space_2: 2.0,
space_3: 3.0,
space_4: 4.0,
@@ -236,6 +265,19 @@ impl Tokens {
t.sketch_mode_tint = 0.12;
t.sketch_footer_icon_size = 15.0;
t.font_section_tracking = 0.04;
t.file_dim = Color32::from_rgb(180, 187, 200);
t.file_recovery_well = Color32::from_rgb(92, 68, 32);
t.file_background = Color32::from_rgb(31, 35, 43);
t.file_surface = Color32::from_rgb(39, 43, 52);
t.file_border = Color32::from_rgb(63, 69, 82);
t.file_rail = Color32::from_rgb(30, 80, 128);
t.file_rail_hover = Color32::from_rgb(46, 99, 149);
t.file_primary = Color32::from_rgb(27, 48, 72);
t.file_primary_ink = Color32::from_rgb(156, 199, 245);
t.file_primary_border = Color32::from_rgb(58, 136, 238);
t.file_recovery = Color32::from_rgb(67, 51, 28);
t.file_recovery_ink = Color32::from_rgb(245, 204, 137);
t.file_recovery_border = Color32::from_rgb(115, 88, 43);
t.app_bar = Color32::from_rgb(28, 31, 38);
t.toolbar = Color32::from_rgb(39, 43, 52);
t.panel = Color32::from_rgb(39, 43, 52);
@@ -268,12 +310,12 @@ impl Tokens {
t.render_ink = Color32::from_rgb(224, 116, 174);
t.render_soft = Color32::from_rgb(64, 35, 54);
t.tab_on_fill_dark = Color32::BLACK;
t.dialog_body = Color32::from_rgb(59, 68, 83);
t.dialog_body = Color32::from_rgb(39, 43, 52);
t.dialog_footer = Color32::from_rgb(47, 52, 62);
t.dialog_rule = Color32::from_rgb(53, 59, 71);
t.control_fill = Color32::from_rgb(39, 43, 52);
t.control_fill = Color32::from_rgb(28, 31, 38);
t.control_border = Color32::from_rgb(74, 82, 98);
t.dialog_label = Color32::from_rgb(180, 187, 200);
t.dialog_label = t.text;
t.selected_ink = Color32::from_rgb(156, 199, 245);
t.selected_bg = Color32::from_rgb(42, 70, 110);
t.construction_geom = icon_primary(crate::icon_svg::CONSTRUCTION_PRIMARY[1]);
@@ -298,8 +340,8 @@ impl Tokens {
t.dimension_driven = Color32::from_rgb(142, 150, 164);
t.sketch_point_free = Color32::from_rgb(92, 150, 255);
t.profile_fill = Color32::from_rgba_unmultiplied_const(120, 150, 200, 40);
t.dialog_bg = Color32::from_rgb(59, 68, 83);
t.dialog_border = Color32::from_rgb(72, 83, 99);
t.dialog_bg = Color32::from_rgb(39, 43, 52);
t.dialog_border = Color32::from_rgb(63, 69, 82);
t.chip = Color32::from_rgb(59, 101, 168);
t.glyph_bg = Color32::from_rgb(44, 50, 60);
t.glyph_edge = Color32::from_rgb(200, 130, 120);
@@ -315,7 +357,7 @@ impl Tokens {
t.bend_up = Color32::from_rgb(110, 200, 120);
t.bend_down = Color32::from_rgb(240, 150, 80);
t.form_ink = t.text;
t.form_dim = t.text_dim;
t.form_dim = Color32::from_rgb(180, 187, 200);
t.form_primary = t.accent;
t.form_error = t.error;
t.form_error_fill = t.timeline_item_error;
+38 -3
View File
@@ -368,6 +368,8 @@ pub enum AxisRef {
World { axis: String },
/// Any line in the sketch plane, in world coordinates.
Line { origin: Position, dir: Dir },
/// A construction axis feature, by name, where it is when the revolve is evaluated.
Construction { name: FeatureName },
}
/// Persistent cylindrical Emboss selection, authored in the feature component frame.
@@ -1854,14 +1856,16 @@ fn fair_keep_edges() -> bool {
true
}
/// Triangles of one imported mesh body (millimetres).
/// Triangles of one imported mesh body (millimetres). The arrays are shared by clones: every
/// edit keeps the design as it was for undo, and a scanned part's triangles (hundreds of
/// megabytes) are not copied for that.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct MeshData {
pub name: String,
/// x, y, z per vertex.
pub positions: Vec<f64>,
pub positions: std::sync::Arc<[f64]>,
/// Three vertex indices per triangle.
pub triangles: Vec<u32>,
pub triangles: std::sync::Arc<[u32]>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<[f32; 3]>,
}
@@ -2026,6 +2030,10 @@ pub enum PatternKind {
alignment: solidcraft_sketch::PatternAlignment,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
reversed: bool,
/// A construction axis the copies turn about, by name, followed when it moves;
/// `origin` and `axis` are where it was when the pattern was made.
#[serde(default, skip_serializing_if = "Option::is_none")]
axis_feature: Option<FeatureName>,
},
/// Copies along a chain of sketch curves: instance k moves by path(k·spacing) − path(0),
/// measured along the path from its start.
@@ -2618,6 +2626,33 @@ impl Default for Document {
}
impl Document {
/// The next shared identity, including auxiliary lists written by older versions or
/// populated programmatically. Reading it does not reserve an id (used by joint previews).
pub fn next_item_id(&self) -> Result<u64> {
let a = &self.assembly;
let largest = self
.browser_groups
.iter()
.map(|g| g.id)
.chain(self.timeline_groups.iter().map(|g| g.id))
.chain(self.canvases.iter().map(|c| c.id))
.chain(a.joints.iter().map(|j| j.id))
.chain(a.contact_sets.iter().map(|c| c.id))
.chain(a.studies.iter().map(|s| s.id))
.chain(a.exploded.iter().map(|v| v.id))
.max()
.unwrap_or(0);
let floor = largest.checked_add(1).ok_or_else(|| DocError::Invalid("too many items in the design".into()))?;
Ok(self.next_id.max(floor).max(1))
}
/// Reserve an identity in the saved counter. Removing an item never rewinds this counter.
pub fn allocate_id(&mut self) -> Result<u64> {
let id = self.next_item_id()?;
self.next_id = id.checked_add(1).ok_or_else(|| DocError::Invalid("too many items in the design".into()))?;
Ok(id)
}
pub fn new(name: &str) -> Self {
Document {
render: Default::default(),
+388 -91
View File
@@ -163,6 +163,50 @@ impl ModelBody {
.clone()
}
/// Identity of this body's display geometry, without meshing it: the cell its mesh is kept
/// in, shared by every clone of the body (timeline snapshots, world states that place it
/// unchanged) and, while it is held, by no other body.
pub fn display_identity(&self) -> Arc<dyn std::any::Any + Send + Sync> {
self.mesh.clone()
}
/// [`ModelBody::mesh`] as a shared mesh and the move that places it: for a rigid placement
/// of a definition, the definition's own mesh (the same one for every placement of it) and
/// the placement; for any other body its own mesh, unmoved. The mesh moved by the matrix is
/// `mesh()`, so a viewport can draw every placement of a part from one copy of its
/// triangles, without making the moved copies.
pub fn mesh_in_place(&self) -> (Arc<Mesh>, crate::Mat) {
match self.mesh_placement.as_deref() {
Some(placed) => placed.source_mesh(),
None => (self.mesh(), crate::IDENTITY),
}
}
/// The box of [`ModelBody::mesh`] without making a placement's moved copy: the shared mesh's
/// points, moved.
pub fn mesh_bounds(&self) -> solidcraft_geom::Aabb3 {
if let Some(mesh) = self.cached_mesh() {
return mesh.bounds();
}
let (mesh, m) = self.mesh_in_place();
if crate::is_identity(&m) {
return mesh.bounds();
}
mesh.positions.iter().fold(solidcraft_geom::Aabb3::EMPTY, |mut b, p| {
b.add(crate::apply_point(&m, *p));
b
})
}
/// [`ModelBody::plan_mesh_at`] as a shared mesh and the move that places it, as
/// [`ModelBody::mesh_in_place`] does for the reference mesh.
pub fn plan_mesh_in_place_at(&self, q: crate::display_quality::DisplayQuality) -> Option<(Arc<Mesh>, crate::Mat)> {
match self.mesh_placement.as_deref() {
Some(placed) => placed.source_plan_mesh(q, &self.name),
None => Some((self.plan_mesh_at(q)?, crate::IDENTITY)),
}
}
/// [`ModelBody::plan_mesh`] if it is made already; otherwise it is made on a worker thread
/// (once, however often this is asked) and `None` comes back meanwhile, so a caller drawing a
/// frame (a face highlighted under the pointer) is not held up by a large body's plan
@@ -977,6 +1021,11 @@ pub struct Model {
kept: KeptSlot,
/// The imported texts' digests the last `evaluate` fingerprinted the features with.
step_digests: StepDigests,
/// The document context the last `evaluate` ran in ([`context_digest`]).
context: u64,
/// Results another copy of this model evaluated (ahead, off the window thread) that
/// `evaluate` takes for features they match ([`Model::prime`]), with that copy's context.
primed: Option<(u64, Arc<std::collections::HashMap<u64, FeatureResult>>)>,
}
/// Parameters a feature uses directly (inputs, sketch dimensions and the expressions of a
@@ -1143,8 +1192,17 @@ fn evaluation_values(doc: &Document) -> (BTreeMap<String, Value>, BTreeMap<Strin
/// A feature's fingerprint in the chain after `prev`: its own inputs, where it is placed, and
/// the sheet metal or plastic rules it takes defaults from.
fn chain_fingerprint(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, prev: u64, rolled_back: bool, steps: &StepDigests) -> u64 {
let mut fp = fingerprint(prev, f, vals, rolled_back, steps) ^ placement_digest(doc, f);
#[allow(clippy::too_many_arguments)]
fn chain_fingerprint(
doc: &Document,
vals: &BTreeMap<String, Value>,
f: &Feature,
prev: u64,
rolled_back: bool,
steps: &StepDigests,
st: Option<&ModelState>,
) -> u64 {
let mut fp = fingerprint(prev, f, vals, rolled_back, steps) ^ placement_digest(doc, f, st);
// Sheet metal features also depend on the rules (and the parameters they use).
if f.kind.is_sheet() {
let mut h = std::collections::hash_map::DefaultHasher::new();
@@ -1229,9 +1287,157 @@ fn cache_key(f: &Feature, chain_fp: u64, local: u64, ctx: u64, state: &ModelStat
h.finish()
}
/// Where components are placed, for a sketch that sees other components' geometry (on a face,
/// or with projections): it is resolved in its own component's view of them.
fn placement_digest(doc: &Document, f: &Feature) -> u64 {
/// What a feature reads of placements: components (their first placement), occurrence paths
/// and bodies in its timeline input, whose geometry it finds in its evaluating frame, or all.
#[derive(Default)]
struct PlacementReads {
all: bool,
components: std::collections::BTreeSet<u64>,
paths: Vec<Vec<u64>>,
bodies: std::collections::BTreeSet<String>,
}
impl PlacementReads {
fn is_empty(&self) -> bool {
!self.all && self.components.is_empty() && self.paths.is_empty() && self.bodies.is_empty()
}
/// A body's complete placement in the feature's input state, including its offset. Later
/// transfers do not change where an earlier feature read it.
fn placed_body(&mut self, st: Option<&ModelState>, name: &str) {
if st.is_some_and(|st| st.body(name).is_some()) {
self.bodies.insert(name.to_owned());
} else {
self.all = true;
}
}
/// The same target selection as `apply_traced`, expanded only over the timeline input.
fn drawn_targets(&mut self, st: Option<&ModelState>, f: &Feature) {
let Some(st) = st else {
self.all = true;
return;
};
let (_, targets) = feature_op(f);
let names = if targets.is_empty() { &f.participants } else { &targets };
let all = names.len() == 1 && names.first().is_some_and(|n| n == "*");
if all || names.is_empty() {
self.bodies.extend(st.bodies.iter().filter(|b| all || b.component == f.component || b.feature == f.id).map(|b| b.name.clone()));
} else {
for name in names {
self.placed_body(Some(st), name);
}
}
}
/// The component of body `name` in `st`; every component when the body is not there (a
/// projection then takes the body nearest its point, wherever that is).
fn body(&mut self, doc: &Document, st: Option<&ModelState>, name: &str) {
match st.and_then(|st| st.body(name)) {
Some(b) => {
self.components.insert(doc.model_body_component(b));
}
None => self.all = true,
}
}
/// The component of sketch `id`, and the root when it holds wires fixed in the world (they
/// are read through the reader's whole placement).
fn sketch(&mut self, doc: &Document, id: u64) {
self.components.insert(doc.feature(id).map(|f| f.component).unwrap_or(0));
if doc.sketch(id).is_ok_and(|sk| sk.wires.iter().any(|w| w.space == solidcraft_sketch::WireSpace::World)) {
self.components.insert(0);
}
}
fn link(&mut self, doc: &Document, st: Option<&ModelState>, source: &solidcraft_sketch::LinkSource) {
use solidcraft_sketch::LinkSource as L;
match source {
L::Edge { body, .. } | L::Face { body, .. } | L::Body { body } | L::Vertex { body, .. } | L::Iso { body, .. } | L::Spun { body, .. } => {
self.body(doc, st, body)
}
L::SketchCurve { sketch, .. } | L::SketchPoint { sketch, .. } => self.sketch(doc, *sketch),
L::OnSurface { sketch, body, .. } => {
self.sketch(doc, *sketch);
self.body(doc, st, body);
}
L::Intersection { a, b } => {
self.link(doc, st, a);
self.link(doc, st, b);
}
// In the sketch's own frame: the component's origin, axes and planes, its own axes, text.
L::Origin | L::Axis { .. } | L::SketchAxis { .. } | L::Plane { .. } | L::Text { .. } => {}
}
}
}
/// The frame of an occurrence path from the root (its occurrences' transforms in turn); `None`
/// when the path no longer leads anywhere.
fn path_frame(doc: &Document, path: &[u64]) -> Option<Mat> {
if path.len() > crate::MAX_PLACEMENT_DEPTH + 1 {
return None;
}
let (mut m, mut c) = (crate::IDENTITY, 0);
for id in path {
let o = doc.occurrences.iter().find(|o| o.id == *id && o.parent == c)?;
m = crate::mat_mul(&m, &o.transform);
c = o.component;
}
Some(m)
}
/// Hash a placement on a grid far finer than anything a design shows ([`QUANTUM`] mm, a
/// thousandth of that for the rotation), so the same relative placement reached through
/// different whole-assembly motions (each rounding a little differently) reads the same.
fn hash_placement(m: &Mat, h: &mut impl Hasher) {
for (col, column) in m.iter().enumerate() {
let q = if col == 3 { QUANTUM } else { QUANTUM * 1e-3 };
for v in column {
let r = (v / q).round();
// -0 and 0 are the same place.
(if r == 0.0 { 0.0f64 } else { r }).to_bits().hash(h);
}
}
}
/// Where the geometry a feature reads from other components is placed, as its own component
/// sees it: the placement of each component (or occurrence path) it reads relative to its own
/// frame. A feature that reads nothing outside its own frame gives 0, so moving parts around
/// (a joint driven, an occurrence moved) leaves it as it was; one that reads a part moved
/// relative to it (a sketch projecting a face of another part, To Object, a mirror of a placed
/// body) is evaluated again. `st` is the model the feature starts from, which says what
/// component a body it names belongs to (without it, every component is taken as read).
/// [`placement_digest`] of a mirror of placed bodies: its own component's frame, each placement it
/// was picked at (the copy's component, frame and the frame it was picked in) and the offsets of
/// the bodies it mirrors (#1938).
fn mirror_placements_digest(doc: &Document, f: &Feature, placements: &[Vec<u64>], bodies: &[crate::BodyName]) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
doc.component_transform(f.component).iter().flatten().for_each(|x| x.to_bits().hash(&mut h));
for placed in placements.iter().map(|path| mirror_copy_frame(doc, path)) {
placed.is_some().hash(&mut h);
if let Some((component, frame, picked)) = placed {
component.hash(&mut h);
frame.iter().chain(&picked).flatten().for_each(|x| x.to_bits().hash(&mut h));
}
}
for b in bodies {
serde_json::to_string(&doc.body_offsets.get(&b.0)).unwrap_or_default().hash(&mut h);
}
h.finish()
}
fn placement_digest(doc: &Document, f: &Feature, st: Option<&ModelState>) -> u64 {
// No shortcut for a design without placements: its first component (placed where the
// design is) must not change what its features read, or creating it evaluates them all.
let mut reads = PlacementReads::default();
let mut frame = None;
let mut h = std::collections::hash_map::DefaultHasher::new();
// A sketch, profile or path of another component is moved through both placements.
for (sketch, _) in f.kind.profile_sels() {
if doc.feature(sketch).is_some_and(|s| s.component != f.component) {
reads.sketch(doc, sketch);
}
}
let path = match &f.kind {
FeatureKind::Sweep { path_sketch, .. }
| FeatureKind::SurfaceSweep { path_sketch, .. }
@@ -1248,10 +1454,8 @@ fn placement_digest(doc: &Document, f: &Feature) -> u64 {
_ => &[],
};
if path.as_ref().is_some_and(wired) || rails.iter().any(|r| wired(&r.sketch)) {
let mut hash = std::collections::hash_map::DefaultHasher::new();
let owner = path.and_then(|path| doc.feature(path.0)).map(|feature| feature.component).unwrap_or(0);
for frame in [doc.component_transform(f.component), doc.component_transform(owner)] {
frame.iter().flatten().for_each(|value| value.to_bits().hash(&mut hash));
if let Some(path) = path {
reads.sketch(doc, path.0);
}
// A sweep's profile and the rails' components place them too.
let profile = match &f.kind {
@@ -1259,69 +1463,113 @@ fn placement_digest(doc: &Document, f: &Feature) -> u64 {
_ => None,
};
for sketch in profile.into_iter().chain(rails.iter().map(|r| &r.sketch)) {
let owner = doc.feature(sketch.0).map(|feature| feature.component).unwrap_or(0);
doc.component_transform(owner).iter().flatten().for_each(|value| value.to_bits().hash(&mut hash));
reads.sketch(doc, sketch.0);
}
return hash.finish();
}
// A network through 3D sketch curves reads their points through the components the
// feature and those sketches are placed in, as a sweep's wired path does.
if let FeatureKind::SurfaceNetwork { u, v, .. } = &f.kind {
let sketches: Vec<crate::SketchId> = u.iter().chain(v).filter_map(|c| c.sketch).collect();
if sketches.iter().any(wired) {
let mut hash = std::collections::hash_map::DefaultHasher::new();
doc.component_transform(f.component).iter().flatten().for_each(|value| value.to_bits().hash(&mut hash));
for sketch in &sketches {
let owner = doc.feature(sketch.0).map(|feature| feature.component).unwrap_or(0);
doc.component_transform(owner).iter().flatten().for_each(|value| value.to_bits().hash(&mut hash));
}
return hash.finish();
}
}
// To / From Object finds its target where that target's component is placed (#928).
let to_object = match &f.kind {
FeatureKind::Extrude { extent, .. } => extent.to.is_some() || extent.from.is_some(),
FeatureKind::Revolve { to, .. } => to.is_some(),
_ => false,
};
// A mirror of placed bodies follows the placements it was picked at and its own component's
// frame, not every occurrence: moving an unrelated one leaves it (and its cache) alone.
if let FeatureKind::Mirror { placements, bodies, .. } = &f.kind
&& !placements.is_empty()
{
let mut h = std::collections::hash_map::DefaultHasher::new();
doc.component_transform(f.component).iter().flatten().for_each(|x| x.to_bits().hash(&mut h));
for placed in placements.iter().map(|path| mirror_copy_frame(doc, path)) {
placed.is_some().hash(&mut h);
if let Some((component, frame, picked)) = placed {
component.hash(&mut h);
frame.iter().chain(&picked).flatten().for_each(|x| x.to_bits().hash(&mut h));
reads.sketch(doc, sketch.0);
}
}
for b in bodies {
serde_json::to_string(&doc.body_offsets.get(&b.0)).unwrap_or_default().hash(&mut h);
}
match &f.kind {
// To / From Object finds its target where that target's component is placed (#928),
// over every body.
FeatureKind::Extrude { extent, .. } if extent.to.is_some() || extent.from.is_some() => reads.all = true,
FeatureKind::Revolve { to: Some(_), .. } => reads.all = true,
// A mirror of placed bodies follows the placements it was picked at and its own component's
// frame, not every occurrence: moving an unrelated one leaves it (and its cache) alone.
FeatureKind::Mirror { placements, bodies, .. } if !placements.is_empty() => return mirror_placements_digest(doc, f, placements, bodies),
// A combine moves tools placed differently from its target into the target's frame (#3383).
FeatureKind::Combine { target, tools, .. } => {
frame = st.and_then(|st| st.body(target)).map(|b| body_world(doc, b));
reads.placed_body(st, target);
for tool in tools {
reads.placed_body(st, tool);
}
}
return h.finish();
// Joins, cuts and intersects meet their targets where those are placed (#3383).
kind if drawn_tools_change_bodies(kind) => reads.drawn_targets(st, f),
FeatureKind::Sketch { plane, sketch } => {
match crate::face_anchor::plane_face_anchor(plane) {
// Read in the frame of the placement it was drawn in, its face through the
// placement it was picked at.
Some(anchor) => {
match anchor.author_frame(doc, f.component) {
Ok(m) => frame = Some(m),
Err(e) => e.to_string().hash(&mut h),
}
if anchor.path.is_empty() {
reads.components.insert(anchor.component);
} else {
reads.paths.push(anchor.path.clone());
}
}
// A face or edges picked on whatever body lies there.
None if plane_reads_geometry(plane) => reads.all = true,
None => {}
}
for link in &sketch.links {
reads.link(doc, st, &link.source);
}
}
_ => {}
}
// A combine moves tools placed differently from its target into the target's frame (#3383).
let combine = matches!(&f.kind, FeatureKind::Combine { .. });
if !to_object && !combine && drawn_tools_change_bodies(&f.kind) {
return drawn_tools_digest(doc, f);
}
if to_object || combine {
return if doc.occurrences.is_empty() && doc.body_offsets.is_empty() { 0 } else { placements_hash(doc) };
}
let FeatureKind::Sketch { plane, sketch } = &f.kind else { return 0 };
let anchor = crate::face_anchor::plane_face_anchor(plane);
if (doc.occurrences.is_empty() && (anchor.is_none() || doc.body_offsets.is_empty()))
|| (!matches!(plane, PlaneRef::Face { .. }) && anchor.is_none() && sketch.links.is_empty())
{
if reads.is_empty() {
return 0;
}
placements_hash(doc)
let frame = frame.unwrap_or_else(|| doc.component_transform(f.component));
let Some(inverse) = crate::mat_inverse(&frame) else { return placements_hash(doc) };
if reads.all {
reads.components.insert(0);
match st {
// The components holding what the feature can reach: a component made later, or one
// that holds nothing yet (just created), changes nothing it reads.
Some(st) => {
reads.components.extend(doc.body_owners(st));
let owner = |id: u64| doc.feature(id).map(|g| g.component);
reads.components.extend(st.sketches.iter().filter_map(|s| owner(s.feature)));
reads.components.extend(st.construct.iter().filter_map(|c| owner(c.feature)));
}
None => reads.components.extend(doc.components.iter().map(|c| c.id)),
}
}
for c in &reads.components {
c.hash(&mut h);
// Its own component is read in its own frame.
if *c != f.component {
hash_placement(&crate::mat_mul(&inverse, &doc.component_transform(*c)), &mut h);
}
}
for path in &reads.paths {
path.hash(&mut h);
match path_frame(doc, path) {
Some(m) => hash_placement(&crate::mat_mul(&inverse, &m), &mut h),
None => u8::MAX.hash(&mut h),
}
}
for name in &reads.bodies {
name.hash(&mut h);
match st.and_then(|st| st.body(name)) {
Some(b) => hash_placement(&crate::mat_mul(&inverse, &body_world(doc, b)), &mut h),
None => u8::MAX.hash(&mut h),
}
}
// Legacy and component-wide reads still need the conservative offset dependency. Body
// reads above already include just their own offsets, in the frame that evaluates them.
if reads.all || !reads.components.is_empty() || !reads.paths.is_empty() {
serde_json::to_string(&doc.body_offsets).unwrap_or_default().hash(&mut h);
}
// Never 0: the feature reads placements.
h.finish() | 1
}
/// Every occurrence's placement and every body offset.
/// Every occurrence's placement and every body offset (a feature read from a frame that cannot
/// be inverted).
fn placements_hash(doc: &Document) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
for o in &doc.occurrences {
@@ -1419,7 +1667,8 @@ impl Model {
let marker = doc.marker.unwrap_or(usize::MAX);
let mut prev_fp = first.checked_sub(1).and_then(|i| self.results.get(i)).map_or(0, |r| r.fingerprint);
for (i, f) in doc.features.iter().enumerate().skip(first) {
prev_fp = chain_fingerprint(doc, &vals, f, prev_fp, i >= marker, &steps);
let before = self.state_before(f.id);
prev_fp = chain_fingerprint(doc, &vals, f, prev_fp, i >= marker, &steps, Some(&before));
if let Some(r) = self.results.get_mut(i).filter(|r| r.id == f.id) {
r.fingerprint = prev_fp;
}
@@ -1460,12 +1709,31 @@ impl Model {
let steps = StepDigests::of(doc, &self.step_digests);
let marker = doc.marker.unwrap_or(usize::MAX);
let mut prev = 0;
// The model each feature starts from (which components the bodies it names are in).
let mut state = self.empty.clone();
doc.features.iter().zip(&self.results).enumerate().all(|(i, (f, r))| {
prev = chain_fingerprint(doc, &vals, f, prev, i >= marker, &steps);
prev = chain_fingerprint(doc, &vals, f, prev, i >= marker, &steps, Some(&state));
if !r.skipped {
state = r.state.clone();
}
r.id == f.id && r.fingerprint == prev
})
}
/// Take the results `ahead` (a copy of this model, evaluated off this thread for the same
/// commands) computed: until [`Model::unprime`], `evaluate` reuses each one whose feature
/// has the same inputs here, instead of evaluating it again. Results that don't match are
/// ignored, so a design changed meanwhile is evaluated as usual.
pub fn prime(&mut self, ahead: &Model) {
let results = ahead.results.iter().filter(|r| !r.skipped && !r.stale).map(|r| (r.id, r.clone())).collect();
self.primed = Some((ahead.context, Arc::new(results)));
}
/// Stop offering the results [`Model::prime`] took.
pub fn unprime(&mut self) {
self.primed = None;
}
/// Re-evaluate the document, reusing results up to the first changed feature.
pub fn evaluate(&mut self, doc: &Document) {
let (vals, perr) = evaluation_values(doc);
@@ -1478,7 +1746,9 @@ impl Model {
let mut restored = 0;
let marker = doc.marker.unwrap_or(usize::MAX);
let ctx = context_digest(doc, &vals);
self.context = ctx;
self.step_digests = StepDigests::of(doc, &self.step_digests);
let primed = self.primed.as_ref().filter(|(context, _)| !self.no_cache && *context == ctx).map(|(_, results)| results.clone());
// Each feature's component, for `Document::legacy_body_component` on every body after
// every feature without searching the timeline for each (the first feature with an id).
let mut feature_component: std::collections::HashMap<u64, u64> = std::collections::HashMap::with_capacity(doc.features.len());
@@ -1490,7 +1760,7 @@ impl Model {
let rolled_back = i >= marker;
// Opening was cancelled: the rest is skipped and the result discarded.
let cancelled = watched.step(i);
let fp = chain_fingerprint(doc, &vals, f, prev_fp, rolled_back, &self.step_digests);
let fp = chain_fingerprint(doc, &vals, f, prev_fp, rolled_back, &self.step_digests, Some(&state));
prev_fp = fp;
if reuse
&& let Some(old) = self.results.get(i)
@@ -1521,7 +1791,7 @@ impl Model {
continue;
}
// The same feature on the same inputs: its earlier result.
let key = cache_key(f, fp, fingerprint(0, f, &vals, false, &self.step_digests) ^ placement_digest(doc, f), ctx, &state);
let key = cache_key(f, fp, fingerprint(0, f, &vals, false, &self.step_digests) ^ placement_digest(doc, f, Some(&state)), ctx, &state);
let kept = self.kept.0.as_ref().filter(|_| !self.no_cache);
if !self.no_cache
&& let Some(c) = self.cache.lock().ok().and_then(|mut m| m.get(key)).or_else(|| kept.and_then(|k| k.get(key)))
@@ -1547,6 +1817,15 @@ impl Model {
});
continue;
}
// Evaluated ahead on these same inputs (the same chain of definitions from the start).
if let Some(r) = primed.as_ref().and_then(|p| p.get(&f.id)).filter(|r| r.fingerprint == fp && !r.stale && !r.skipped) {
restored += 1;
if r.error.is_none() {
state = r.state.clone();
}
out.push(FeatureResult { state: state.clone(), ..r.clone() });
continue;
}
recomputed += 1;
let t0 = now();
let mut next = (*state).clone();
@@ -1787,9 +2066,12 @@ impl Model {
let marker = doc.marker.unwrap_or(usize::MAX);
self.step_digests = StepDigests::of(doc, &self.step_digests);
let mut prev = 0;
// Each feature's fingerprint reads the model it starts from, as evaluating does.
let mut before = Arc::new(ModelState::default());
for (i, (r, f)) in self.results.iter_mut().zip(&doc.features).enumerate() {
prev = chain_fingerprint(doc, &vals, f, prev, i >= marker, &self.step_digests);
prev = chain_fingerprint(doc, &vals, f, prev, i >= marker, &self.step_digests, Some(&before));
r.fingerprint = prev;
before = r.state.clone();
}
}
}
@@ -3038,7 +3320,17 @@ fn blend_target(state: &ModelState, body: &Option<crate::BodyName>, edges: &[Vec
})
}
pub(crate) fn revolve_axis(ss: &SolvedSketch, axis: &AxisRef) -> Result<(Vec2, Vec2)> {
/// The revolve axis in the sketch's 2D coordinates (a point on it, its direction). `construct`
/// gives a construction axis, by name, in the sketch's 3D frame.
pub(crate) fn revolve_axis(ss: &SolvedSketch, axis: &AxisRef, construct: &dyn Fn(&str) -> Result<(Vec3, Vec3)>) -> Result<(Vec2, Vec2)> {
let in_plane = |origin: Vec3, dir: Vec3| {
let pl = &ss.plane;
let d = dir.normalized().ok_or_else(|| DocError::Invalid("revolve axis has no direction".into()))?;
if d.dot(pl.normal()).abs() > 1e-9 || pl.height(origin).abs() > 1e-6 {
return Err(DocError::Invalid("the revolve axis does not lie in the sketch plane".into()));
}
Ok((pl.to_local(origin), Vec2::new(d.dot(pl.x), d.dot(pl.y))))
};
match axis {
AxisRef::SketchLine { curve } => {
let ci = ss.sketch.curve_index(curve).ok_or_else(|| DocError::Unknown(format!("sketch line `{curve}`")))?;
@@ -3068,17 +3360,37 @@ pub(crate) fn revolve_axis(ss: &SolvedSketch, axis: &AxisRef) -> Result<(Vec2, V
}
Ok((pl.to_local(Vec3::ZERO), Vec2::new(d.dot(pl.x), d.dot(pl.y))))
}
AxisRef::Line { origin, dir } => {
let pl = &ss.plane;
let d = dir.normalized().ok_or_else(|| DocError::Invalid("revolve axis has no direction".into()))?;
if d.dot(pl.normal()).abs() > 1e-9 || pl.height(origin.0).abs() > 1e-6 {
return Err(DocError::Invalid("the revolve axis does not lie in the sketch plane".into()));
}
Ok((pl.to_local(origin.0), Vec2::new(d.dot(pl.x), d.dot(pl.y))))
AxisRef::Line { origin, dir } => in_plane(origin.0, dir.0),
AxisRef::Construction { name } => {
let (origin, dir) = construct(name)?;
in_plane(origin, dir)
}
}
}
/// A construction axis `name` of the timeline so far, as evaluated, in the frame of component
/// `comp`: a point on it and its direction. Without a document (`doc` None) it is taken in its
/// own component's frame.
pub(crate) fn construction_axis_in(doc: Option<&Document>, st: &ModelState, name: &str, comp: u64) -> Result<(Vec3, Vec3)> {
let c = st
.construct
.iter()
.find(|c| c.name == name)
.ok_or_else(|| DocError::Unknown(format!("construction axis `{name}` (it must come earlier in the timeline)")))?;
let crate::construct::ConstructGeom::Axis { origin, dir } = &c.geom else {
return Err(DocError::Invalid(format!("`{name}` is not a construction axis")));
};
let Some(doc) = doc else { return Ok((*origin, *dir)) };
let owner = doc.feature(c.feature).map_or(0, |f| f.component);
if owner == comp || doc.occurrences.is_empty() {
return Ok((*origin, *dir));
}
let inverse =
crate::mat_inverse(&doc.component_transform(comp)).ok_or_else(|| DocError::Invalid("the feature's component frame is invalid".into()))?;
let relative = crate::mat_mul(&inverse, &doc.component_transform(owner));
Ok((crate::apply_point(&relative, *origin), crate::apply_vector(&relative, *dir)))
}
/// Whether a drill from `p` along `d` reaches material: it starts into a body (`touches`), or,
/// when `ahead` is set, a body lies further along `d`. A spot exactly on a surface counts only
/// when the drill enters there, not when it leaves. Only the bodies in `scope` count.
@@ -4113,7 +4425,7 @@ fn feature_tools_with_constructor(
f.component,
)?;
let regions = solidcraft_sketch::merge_regions(&select_profiles(&ss, profiles)?);
let (o, d) = revolve_axis(&ss, axis)?;
let (o, d) = revolve_axis(&ss, axis, &|name| construction_axis_in(Some(doc), st, name, f.component))?;
// To Object is in this feature's component frame: the model as seen from there.
let tst = if to.is_some() { state_in_frame(doc, st, f.component) } else { std::borrow::Cow::Borrowed(st) };
// Where the tool meets its targets is measured where they are shown (#3383).
@@ -4897,7 +5209,11 @@ fn pattern_transforms(doc: &Document, vals: &BTreeMap<String, Value>, st: &Model
out.push(m);
}
}
crate::PatternKind::Circular { origin, axis, count: c, angle, alignment, reversed } => {
crate::PatternKind::Circular { origin, axis, count: c, angle, alignment, reversed, axis_feature } => {
let (origin, axis) = match axis_feature {
Some(name) => construction_axis_in(Some(doc), st, name, component)?,
None => (origin.0, axis.0),
};
let n = count(c)?;
let total = val(vals, angle, Kind::Angle)?;
let full = (total.abs() - std::f64::consts::TAU).abs() < 1e-9;
@@ -4909,7 +5225,7 @@ fn pattern_transforms(doc: &Document, vals: &BTreeMap<String, Value>, st: &Model
0.0
};
for i in 1..n {
out.push(rotation(origin.0, axis.0, step * alignment.multiplier(i, *reversed)));
out.push(rotation(origin, axis, step * alignment.multiplier(i, *reversed)));
}
}
}
@@ -6419,25 +6735,6 @@ impl Drop for ToolFrames {
}
}
/// [`placement_digest`] of a feature with drawn tools: the placements, when it names the bodies
/// it changes (they may lie in another component, where its tools meet them, #3383); nothing
/// otherwise, as it then changes only bodies of its own component.
fn drawn_tools_digest(doc: &Document, f: &Feature) -> u64 {
let targets = match &f.kind {
FeatureKind::Extrude { targets, .. }
| FeatureKind::Revolve { targets, .. }
| FeatureKind::Loft { targets, .. }
| FeatureKind::Sweep { targets, .. }
| FeatureKind::Pipe { targets, .. }
| FeatureKind::Coil { targets, .. } => !targets.is_empty(),
_ => false,
};
if (!targets && f.participants.is_empty()) || (doc.occurrences.is_empty() && doc.body_offsets.is_empty()) {
return 0;
}
placements_hash(doc)
}
/// The matrix taking the evaluated feature's drawn tools into body `name`'s frame (`None`: the
/// same frame).
fn tool_frame(name: &str) -> Option<Mat> {
+1 -1
View File
@@ -304,7 +304,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
st.sketch(sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?,
f.component,
)?;
let (o, d) = revolve_axis(&ss, axis)?;
let (o, d) = revolve_axis(&ss, axis, &|name| construction_axis_in(Some(doc), st, name, f.component))?;
let a = val(vals, angle, Kind::Angle)?;
let (start, sweep) = match (angle2, symmetric) {
(Some(a2), _) => {
+12
View File
@@ -28,6 +28,18 @@ impl FeatureKind {
v
}
/// Whether the feature names the feature `name` (the construction plane a plane is built on,
/// a patterned or mirrored feature).
pub fn names_feature(&self, name: &str) -> bool {
let mut found = false;
self.walk_refs(&mut |r| {
if let Ref::Feature(n) = r {
found |= n.as_ref() == name;
}
});
found
}
/// Point references to sketches (by id) and features (by name) at their copies, or at a
/// feature's new name (`timeline.rename`).
pub fn remap_refs(&mut self, sketches: &BTreeMap<u64, u64>, names: &BTreeMap<String, String>) {
+43
View File
@@ -674,6 +674,9 @@ pub fn read(s: &str) -> Result<Document> {
d.features.iter().map(|f| f.id).chain(d.components.iter().map(|c| c.id)).chain(d.occurrences.iter().map(|o| o.id)).max().unwrap_or(0);
let floor = largest.checked_add(1).ok_or_else(|| DocError::Invalid("damaged design: the id space is exhausted".into()))?;
d.next_id = d.next_id.max(floor).max(1);
// These lists used to allocate independently. Keep their existing identities, but never
// let a later deletion make them available again after loading an older design.
d.next_id = d.next_item_id()?;
d.format = format_string(FORMAT);
d.format_pending = pending_names();
Ok(d)
@@ -2038,6 +2041,46 @@ mod tests {
}
}
#[test]
fn older_auxiliary_ids_advance_the_saved_counter_before_deletion() {
use serde_json::json;
let origin = json!({"occurrence": 0, "snap": {"type": "point", "point": [0, 0, 0]}});
let cases = [
("browser_groups", json!({"id": 100, "name": "Group", "component": 0, "folder": "bodies", "items": []})),
("timeline_groups", json!({"id": 100, "name": "Folder", "features": []})),
(
"canvases",
json!({"id": 100, "name": "Canvas", "format": "png", "data": "", "pixels": [1, 1],
"plane": {"type": "origin", "name": "XY"}, "center": [0, 0], "width": 10,
"angle": 0, "opacity": 0.5, "flip": false, "visible": true}),
),
("joints", json!({"id": 100, "name": "Joint", "kind": "rigid", "a": origin, "b": origin})),
("contact_sets", json!({"id": 100, "name": "Contact", "occurrences": []})),
("studies", json!({"id": 100, "name": "Study", "steps": 10, "keys": []})),
("exploded", json!({"id": 100, "name": "View", "moves": []})),
];
for (field, item) in cases {
let json = edited(&Document::new("Legacy IDs"), |v| {
v.as_object_mut().unwrap().remove("next_id");
if matches!(field, "browser_groups" | "timeline_groups" | "canvases") {
v[field] = json!([item]);
} else {
v["assembly"] = json!({field: [item]});
}
});
let mut back = read(&json).unwrap_or_else(|e| panic!("{field}: {e}"));
assert_eq!(back.next_id, 101, "{field}");
back.browser_groups.clear();
back.timeline_groups.clear();
back.canvases.clear();
back.assembly = Default::default();
let mut reopened = read(&back.to_json()).unwrap();
assert_eq!(reopened.allocate_id().unwrap(), 101, "{field}: deleted legacy id was forgotten");
assert_eq!(reopened.allocate_id().unwrap(), 102, "{field}");
}
}
#[test]
fn duplicate_component_or_occurrence_ids_are_a_damaged_design() {
let d = two_components_one_occurrence();
@@ -0,0 +1,4 @@
//! A circular pattern can turn about a construction axis (`axis_feature`, its name) and a revolve
//! or surface revolve can take one as its axis (`"type": "construction"`, with `name`); both follow
//! the axis when it is edited. Older designs need no transformation: they name no construction
//! axis. The last release refuses a design saved with this (the newer format number).
+2 -2
View File
@@ -380,7 +380,7 @@ impl Model {
let mut prev = 0;
let mut results = Vec::with_capacity(features.len());
for (i, (f, r)) in doc.features.iter().zip(features).enumerate() {
prev = chain_fingerprint(doc, &vals, f, prev, i >= marker, &step_digests);
prev = chain_fingerprint(doc, &vals, f, prev, i >= marker, &step_digests, Some(&before));
if r.get("id").and_then(Json::as_u64) != Some(f.id) || unhex(r.get("fingerprint").unwrap_or(&Json::Null))? != prev {
return Err(bad("saved model: the document changed"));
}
@@ -403,7 +403,7 @@ impl Model {
// Each result where an evaluation would have put it, so an edit undone (or set back)
// finds it again rather than evaluating the feature anew.
if !result.skipped {
let local = fingerprint(0, f, &vals, false, &step_digests) ^ placement_digest(doc, f);
let local = fingerprint(0, f, &vals, false, &step_digests) ^ placement_digest(doc, f, Some(&before));
cache.put(
cache_key(f, prev, local, ctx, &before),
CachedEval {
+3 -1
View File
@@ -583,7 +583,9 @@ fn role_name(f: &Feature, before: &ModelState, ps: &[Vec3], ns: &[Vec3], fi: usi
}
FeatureKind::Revolve { sketch, axis, .. } => {
let Some(ss) = before.sketch(sketch.0) else { return format!("F{fid}:face:{fi}") };
let Ok((o2, d2)) = crate::eval::revolve_axis(ss, axis) else { return format!("F{fid}:face:{fi}") };
let Ok((o2, d2)) = crate::eval::revolve_axis(ss, axis, &|name| crate::eval::construction_axis_in(None, before, name, 0)) else {
return format!("F{fid}:face:{fi}");
};
let (o, d) = (ss.plane.to_world(o2), (ss.plane.to_world(o2 + d2) - ss.plane.to_world(o2)).normalized().unwrap_or(Vec3::Z));
// A flat face holding the axis is an angle cap: on the sketch plane, or the far one.
let flat = !ns.is_empty() && ns.windows(2).all(|w| w[0].cross(w[1]).len() < 1e-6) && ns.iter().all(|m| m.dot(d).abs() < 1e-6);
+51 -1
View File
@@ -59,6 +59,18 @@ impl PlacedMesh {
/// The definition's display plan mesh at quality `q`, moved here.
pub(super) fn plan_mesh(&self, q: crate::display_quality::DisplayQuality, name: &str) -> Option<Arc<Mesh>> {
let (source, _) = self.source_plan_mesh(q, name)?;
Some(Arc::new(self.moved(&source)))
}
/// The definition's reference mesh (shared by every placement of it) and this placement.
pub(super) fn source_mesh(&self) -> (Arc<Mesh>, crate::Mat) {
(reference_mesh(&self.body, &self.mesh, self.tolerance), self.transform)
}
/// The definition's display plan mesh at quality `q` (shared by every placement of it) and
/// this placement.
pub(super) fn source_plan_mesh(&self, q: crate::display_quality::DisplayQuality, name: &str) -> Option<(Arc<Mesh>, crate::Mat)> {
let cell = self.plan_meshes.get(q.index())?;
let source = cell
.get_or_init(|| {
@@ -66,7 +78,7 @@ impl PlacedMesh {
self.body.display_mesh_of(&plan).ok().map(Arc::new)
})
.clone()?;
Some(Arc::new(self.moved(&source)))
Some((source, self.transform))
}
/// `source` (in the definition's place) moved by this placement.
@@ -150,6 +162,44 @@ mod tests {
assert!(Arc::ptr_eq(&mesh, &nested.mesh()));
}
/// A placement hands out its definition's own meshes and its move without making the moved
/// copy: every placement of a part gives the same mesh, and that mesh moved is the
/// placement's own.
#[test]
fn placements_share_their_definitions_meshes_in_place() {
use crate::display_quality::DisplayQuality;
let source = ModelBody::new("Part".into(), kernel::cylinder(Vec3::ZERO, Vec3::Z, 2., 8.).unwrap(), 1);
let placed: Vec<ModelBody> =
[crate::rigid(Vec3::new(10., 20., 30.), Vec3::ZERO, Vec3::Z, 0.7), crate::rigid(Vec3::new(-4., 5., 1.), Vec3::ZERO, Vec3::X, 1.1)]
.iter()
.map(|m| {
let mut p = ModelBody::new("Copy".into(), kernel::transform_matrix(&source.body, *m).unwrap(), 1);
p.mesh_placement = Some(Arc::new(PlacedMesh::new(&source, m).unwrap()));
p
})
.collect();
let (own, identity) = source.mesh_in_place();
assert_eq!(identity, crate::IDENTITY);
let q = DisplayQuality::parse("normal").unwrap();
for p in &placed {
let (mesh, m) = p.mesh_in_place();
assert!(Arc::ptr_eq(&mesh, &own), "the definition's mesh, not a copy");
let bounds = p.mesh_bounds();
assert!(p.cached_mesh().is_none(), "no moved copy is made");
let (plan_mesh, pm) = p.plan_mesh_in_place_at(q).unwrap();
assert!(Arc::ptr_eq(&plan_mesh, &source.plan_mesh_at(q).unwrap()));
assert_eq!(pm, m);
let moved = p.mesh();
let exact = moved.bounds();
assert!(bounds.min.dist(exact.min) < 1e-9 && bounds.max.dist(exact.max) < 1e-9, "{bounds:?} vs {exact:?}");
for (a, b) in mesh.positions.iter().zip(&moved.positions) {
assert!(crate::apply_point(&m, *a).dist(*b) < 1e-9);
}
assert!(!Arc::ptr_eq(&p.display_identity(), &source.display_identity()), "each placement is its own body");
assert!(Arc::ptr_eq(&p.display_identity(), &p.clone().display_identity()), "clones are the same body");
}
}
/// Placed parts share their definition's display plan (#3044): the definition is planned
/// once, every placement moves that plan, and a moved plan evaluates to the definition's
/// points moved, on every kind of surface (planes, revolved and spline surfaces).
+21 -7
View File
@@ -89,7 +89,7 @@ fn group(s: &mut Session, p: &Value) -> Result<Value> {
let component = component_of(s, p, cmd)?;
let items = keys(p, "items", cmd)?;
let d = s.doc_mut();
let id = super::next_free_id(cmd, d.browser_groups.iter().map(|g| g.id))?;
let id = d.allocate_id()?;
let name = match str_(p, "name").map(str::trim).filter(|n| !n.is_empty()) {
Some(n) if n.len() <= 128 => n.to_string(),
Some(_) => return Err(bad(cmd, "`name` is too long")),
@@ -1097,12 +1097,26 @@ mod tests {
}
#[test]
fn the_next_free_id_is_one_past_the_largest_and_never_wraps() {
use super::super::next_free_id;
assert_eq!(next_free_id("t", std::iter::empty()).unwrap(), 1);
assert_eq!(next_free_id("t", [3, 9, 4].into_iter()).unwrap(), 10);
assert_eq!(next_free_id("t", [u64::MAX - 1].into_iter()).unwrap(), u64::MAX);
assert!(next_free_id("t", [1, u64::MAX].into_iter()).is_err());
fn exhausted_shared_ids_refuse_a_group_without_changing_the_design() {
let mut s = Session::default();
s.doc_mut().next_id = u64::MAX;
let before = s.doc.to_json();
let result = s.execute("browser.group", &json!({"folder": "bodies"}));
assert!(result.is_err() && !matches!(result, Err(crate::EngineError::Internal(..))), "{result:?}");
assert_eq!(s.doc.to_json(), before);
}
#[test]
fn deleted_group_ids_are_not_reused_after_reopening() {
let mut s = Session::default();
let params = json!({"folder": "bodies"});
let first = run(&mut s, "browser.group", params.clone())["group"].as_u64().unwrap();
run(&mut s, "browser.ungroup", json!({"group": first}));
let doc = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
*s.doc_mut() = doc;
let second = run(&mut s, "browser.group", params)["group"].as_u64().unwrap();
assert!(second > first, "deleted group {first} was reused as {second}");
assert!(s.execute("browser.rename_group", &json!({"group": first, "name": "Stale reference"})).is_err());
}
#[test]
@@ -269,12 +269,14 @@ pub(super) fn clone_assembly(
return Err(invalid("copy occurrence map points to a missing occurrence"));
}
unique_ids(source.assembly.joints.iter().map(|j| j.id))?;
let mut joint_id = unique_ids(doc.assembly.joints.iter().map(|j| j.id))?;
unique_ids(doc.assembly.joints.iter().map(|j| j.id))?;
let aux = |a: &Assembly| {
a.contact_sets.iter().map(|x| x.id).chain(a.studies.iter().map(|x| x.id)).chain(a.exploded.iter().map(|x| x.id)).collect::<Vec<_>>()
};
unique_ids(aux(&source.assembly).into_iter())?;
let mut auxiliary_id = unique_ids(aux(&doc.assembly).into_iter())?;
unique_ids(aux(&doc.assembly).into_iter())?;
// Reserve from one persistent counter, committing it only when the entire copy succeeds.
let mut item_id = doc.next_item_id()?.saturating_sub(1);
let mut a = doc.assembly.clone();
let mut messages = Vec::new();
let mut origin_names = a.origins.iter().map(|o| o.name.clone()).collect();
@@ -302,7 +304,7 @@ pub(super) fn clone_assembly(
match (origin(&old.a, occurrences, features, bodies), origin(&old.b, occurrences, features, bodies)) {
(Some(aa), Some(bb)) => {
let mut item = old.clone();
item.id = next_id(&mut joint_id)?;
item.id = next_id(&mut item_id)?;
item.name = unique_name(&old.name, &mut joint_names)?;
item.a = aa;
item.b = bb;
@@ -354,7 +356,7 @@ pub(super) fn clone_assembly(
}
if let Some(ids) = old.occurrences.iter().map(|o| occurrences.get(o).copied()).collect::<Option<Vec<_>>>() {
let mut item = old.clone();
item.id = next_id(&mut auxiliary_id)?;
item.id = next_id(&mut item_id)?;
item.name = unique_name(&old.name, &mut contact_names)?;
item.occurrences = ids;
a.contact_sets.push(item);
@@ -369,7 +371,7 @@ pub(super) fn clone_assembly(
}
if old.keys.iter().all(|k| joints.contains_key(&k.joint)) {
let mut item = old.clone();
item.id = next_id(&mut auxiliary_id)?;
item.id = next_id(&mut item_id)?;
item.name = unique_name(&old.name, &mut study_names)?;
for key in &mut item.keys {
key.joint = *joints.get(&key.joint).ok_or_else(|| invalid("missing copied study joint"))?;
@@ -386,7 +388,7 @@ pub(super) fn clone_assembly(
}
if old.moves.iter().all(|(o, _)| occurrences.contains_key(o)) {
let mut item = old.clone();
item.id = next_id(&mut auxiliary_id)?;
item.id = next_id(&mut item_id)?;
item.name = unique_name(&old.name, &mut view_names)?;
for (o, _) in &mut item.moves {
*o = *occurrences.get(o).ok_or_else(|| invalid("missing copied exploded occurrence"))?;
@@ -397,6 +399,7 @@ pub(super) fn clone_assembly(
}
}
budget(&a)?;
doc.next_id = item_id.checked_add(1).ok_or_else(|| invalid("assembly identifier space is exhausted"))?;
doc.assembly = a;
Ok(messages)
}
@@ -76,12 +76,13 @@ fn documents() -> (Document, Document) {
#[test]
fn internal_relationships_receive_independent_ids_and_remapped_typed_references() {
let (source, mut destination) = documents();
destination.next_id = 1000;
let report =
clone_assembly(&mut destination, &source, &BTreeMap::from([(1, 101), (2, 102)]), &BTreeMap::from([(1, 11), (10, 110)]), &BTreeMap::new())
.unwrap();
let a = &destination.assembly;
assert_eq!(a.joints.len(), 6);
assert_eq!((a.joints[4].id, a.joints[5].id), (5, 6));
assert_eq!((a.joints[4].id, a.joints[5].id), (1000, 1001));
assert_eq!((a.joints[4].a.occurrence, a.joints[4].b.occurrence), (101, 102));
assert_eq!(a.joints[4].values, source.assembly.joints[0].values);
assert_eq!(a.joints[4].limits, source.assembly.joints[0].limits);
@@ -90,13 +91,14 @@ fn internal_relationships_receive_independent_ids_and_remapped_typed_references(
// Its x reference's face is the copy's too.
assert!(matches!(&a.origins[3].origin.snap, Snap::FaceCenter { face: Some(face), x: Some(XRef::Face { face: x, .. }), .. }
if face == "F11:start@F110.2" && x == "F110:side"));
assert_eq!((a.links[2].a, a.links[2].b, a.links[2].ratio), (5, 6, -2.0));
assert_eq!(a.contact_sets[2].id, 7);
assert_eq!((a.links[2].a, a.links[2].b, a.links[2].ratio), (1000, 1001, -2.0));
assert_eq!(a.contact_sets[2].id, 1002);
assert_eq!(a.contact_sets[2].occurrences, vec![101, 102]);
assert!(a.contact_sets[2].suppressed);
assert_eq!(a.studies[2].id, 8);
assert_eq!(a.studies[2].keys.iter().map(|k| k.joint).collect::<Vec<_>>(), vec![5, 6]);
assert_eq!(a.exploded[2].id, 9);
assert_eq!(a.studies[2].id, 1003);
assert_eq!(a.studies[2].keys.iter().map(|k| k.joint).collect::<Vec<_>>(), vec![1000, 1001]);
assert_eq!(a.exploded[2].id, 1004);
assert_eq!(destination.next_id, 1005);
assert_eq!(a.exploded[2].moves, vec![(101, Vec3::X * 10.0), (102, Vec3::Y * 20.0)]);
assert_eq!(report.len(), 6);
for kind in ["joint origin", "joint `Joint3`", "motion link", "contact set", "motion study", "exploded view"] {
@@ -171,9 +173,9 @@ fn malformed_maps_identifier_exhaustion_and_aggregate_budgets_are_atomic() {
invalid.assembly.links[0].ratio = f64::NAN;
cases.push((invalid, destination, BTreeMap::from([(1, 101), (2, 102)])));
for (source, mut destination, occurrences) in cases {
let before = destination.assembly.clone();
let before = destination.clone();
assert!(clone_assembly(&mut destination, &source, &occurrences, &BTreeMap::from([(1, 11), (10, 110)]), &BTreeMap::new()).is_err());
assert_eq!(destination.assembly, before);
assert_eq!(destination, before);
}
}
+76 -40
View File
@@ -32,6 +32,65 @@ struct CachedBody {
chord: f64,
}
/// Bodies [`ContactMeshes`] keeps; it starts over when full.
const MAX_CACHED_MESHES: usize = 4 * MAX_BODIES;
/// A body's contact mesh and what is measured on it, made once per body content.
#[derive(Clone)]
pub(crate) struct ContactMesh {
mesh: Arc<Mesh>,
chord: f64,
/// Its thinnest width across its facet directions (the sweep's step is a quarter of it).
thickness: f64,
}
/// Contact meshes by the body's geometry ([`solidcraft_doc::ModelBody::geometry_key`]), shared
/// by a session and its scratch copies: driving a joint again meshes no body that did not change.
#[derive(Default)]
pub(crate) struct ContactMeshes(HashMap<u64, ContactMesh>);
/// Tessellate `body` for contact checks and measure how thin it is.
fn contact_mesh(body: &solidcraft_doc::ModelBody) -> Result<ContactMesh> {
let tolerance = (body.body.size() * 1e-3).max(1e-3);
let mesh = body.body.tessellate(tolerance)?;
if mesh.triangles.len() > MAX_TRIANGLES || mesh.positions.len().saturating_add(mesh.triangles.len().saturating_mul(4)) > 200_000 {
return Err(limit("body exceeds contact sweep tessellation budget"));
}
let extent = mesh.bounds().size();
// Whole-body widths along bounded distinct facet normals catch tilted
// plates without interpreting skinny mesh triangles as thin material.
// Local thin walls of a wider body remain a sampling limitation.
let mut thickness = extent.x.min(extent.y).min(extent.z);
let mut directions: Vec<Vec3> = Vec::new();
for triangle in &mesh.triangles {
let Some([a, b, c]) = mesh.tri(triangle) else {
return Err(limit("invalid contact tessellation"));
};
let Some(direction) = (b - a).cross(c - a).normalized() else { continue };
if directions.len() < 32 && !directions.iter().any(|d| d.dot(direction).abs() > 1.0 - 1e-6) {
directions.push(direction);
}
}
for direction in directions {
let (mut low, mut high) = (f64::INFINITY, f64::NEG_INFINITY);
for point in &mesh.positions {
let projected = point.dot(direction);
low = low.min(projected);
high = high.max(projected);
}
thickness = thickness.min(high - low);
}
if !thickness.is_finite() || thickness <= 1e-9 {
return Err(limit("body is too thin for a bounded contact sweep"));
}
// Flat faces tessellate exactly; only planes known from how the face was
// made count (an unknown face is taken as curved).
let faces = body.body.face_count();
let flat = faces <= 1024 && (0..faces).all(|i| body.body.face_plane(i).is_some());
let chord = if flat { 0.0 } else { tolerance };
Ok(ContactMesh { mesh: Arc::new(mesh), chord, thickness })
}
/// A member of a contact set: (occurrence id, name).
pub(super) type Member = (u64, String);
@@ -90,47 +149,24 @@ impl ContactGeometry {
let (mesh, chord) = if let Some(cached) = meshes.get(&body.name) {
cached.clone()
} else {
let tolerance = (body.body.size() * 1e-3).max(1e-3);
let mesh = body.body.tessellate(tolerance)?;
if mesh.triangles.len() > MAX_TRIANGLES || mesh.positions.len().saturating_add(mesh.triangles.len().saturating_mul(4)) > 200_000 {
return Err(limit("body exceeds contact sweep tessellation budget"));
}
let extent = mesh.bounds().size();
// Whole-body widths along bounded distinct facet normals catch tilted
// plates without interpreting skinny mesh triangles as thin material.
// Local thin walls of a wider body remain a sampling limitation.
let mut thickness = extent.x.min(extent.y).min(extent.z);
let mut directions: Vec<Vec3> = Vec::new();
for triangle in &mesh.triangles {
let Some([a, b, c]) = mesh.tri(triangle) else {
return Err(limit("invalid contact tessellation"));
};
let Some(direction) = (b - a).cross(c - a).normalized() else { continue };
if directions.len() < 32 && !directions.iter().any(|d| d.dot(direction).abs() > 1.0 - 1e-6) {
directions.push(direction);
let key = body.geometry_key();
let known = key.and_then(|k| s.contact_meshes.lock().ok().and_then(|c| c.0.get(&k).cloned()));
let made = match known {
Some(made) => made,
None => {
let made = contact_mesh(body)?;
if let (Some(k), Ok(mut cache)) = (key, s.contact_meshes.lock()) {
if cache.0.len() >= MAX_CACHED_MESHES {
cache.0.clear();
}
cache.0.insert(k, made.clone());
}
made
}
}
for direction in directions {
let (mut low, mut high) = (f64::INFINITY, f64::NEG_INFINITY);
for point in &mesh.positions {
let projected = point.dot(direction);
low = low.min(projected);
high = high.max(projected);
}
thickness = thickness.min(high - low);
}
if !thickness.is_finite() || thickness <= 1e-9 {
return Err(limit("body is too thin for a bounded contact sweep"));
}
out.step = out.step.min(thickness * 0.25);
let mesh = Arc::new(mesh);
// Flat faces tessellate exactly; only planes known from how the face was
// made count (an unknown face is taken as curved).
let faces = body.body.face_count();
let flat = faces <= 1024 && (0..faces).all(|i| body.body.face_plane(i).is_some());
let chord = if flat { 0.0 } else { tolerance };
meshes.insert(body.name.clone(), (mesh.clone(), chord));
(mesh, chord)
};
out.step = out.step.min(made.thickness * 0.25);
meshes.insert(body.name.clone(), (made.mesh.clone(), made.chord));
(made.mesh, made.chord)
};
out.bodies.push(CachedBody {
path: path.clone(),
@@ -26,6 +26,24 @@ fn rail_value(s: &Session) -> f64 {
s.doc.assembly.joints.iter().find(|j| j.name == "Rail").unwrap().values[0]
}
#[test]
fn joint_pose_contact_reuses_reference_geometry_across_new_placements() {
let mut s = slider();
// Measure the swept-contact geometry factory, separately from other joint work.
super::ContactGeometry::new(&s).unwrap();
for value in [11., 12., 13., 14.] {
let result = run(&mut s, "joint.drive", json!({"joint":"Rail","value":value}));
let (_, work) = crate::kernel::profile::capture(|| super::ContactGeometry::new(&s).unwrap());
assert!(result["stopped_by_contact"].is_null(), "{result}");
assert!((rail_value(&s) - value).abs() < 1e-8);
assert_eq!(work.tessellate_ms, 0., "unchanged contact bodies were tessellated for slider value {value}");
}
// Reusing geometry must still stop a genuine collision, including an endpoint beyond it.
let result = run(&mut s, "joint.drive", json!({"joint":"Rail","value":-30}));
assert!(result["stopped_by_contact"].is_object(), "{result}");
assert!(rail_value(&s).abs() < 2e-3);
}
#[test]
fn slider_cannot_tunnel_to_a_collision_free_endpoint() {
let mut s = slider();
@@ -39,6 +57,28 @@ fn slider_cannot_tunnel_to_a_collision_free_endpoint() {
assert!((rail_value(&s) + 100000.0).abs() < 1e-6, "disabled contact skips sweep budget");
}
/// Each drive with contact on meshed every body in a contact set again: a body that did not
/// change keeps its contact mesh across drives (and scratch copies), a changed one is meshed anew.
#[test]
fn contact_meshes_are_made_once_per_body() {
let mut s = slider();
let meshes = |s: &Session| super::ContactGeometry::new(s).unwrap().bodies.iter().map(|b| b.mesh.clone()).collect::<Vec<_>>();
let first = meshes(&s);
assert_eq!(first.len(), 2);
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 5}));
let scratch = s.scratch();
for again in [meshes(&s), meshes(&scratch)] {
assert!(first.iter().zip(&again).all(|(a, b)| std::sync::Arc::ptr_eq(a, b)), "the same meshes, not tessellated again");
}
// A body that changed is meshed for what it is now.
run(&mut s, "component.activate", json!({"component": "B"}));
run(&mut s, "solid.box", json!({"length": 2, "width": 2, "height": 2, "corner": [24, 4, 10], "operation": "join"}));
run(&mut s, "component.activate", json!({"component": 0}));
let changed = meshes(&s);
assert!(changed.iter().zip(&first).any(|(a, b)| !std::sync::Arc::ptr_eq(a, b)));
assert!(changed.iter().map(|m| m.bounds().max.z).fold(0.0, f64::max) > 11.0, "the joined box is in the contact mesh");
}
#[test]
fn revolute_cannot_cross_a_blocker_between_clear_endpoints() {
let mut s = Session::default();
+6 -9
View File
@@ -330,15 +330,7 @@ fn group(s: &mut Session, p: &Value) -> Result<Value> {
Some(_) => return Err(bad(cmd, "`name` must be 1…128 characters")),
None => format!("Folder{}", s.doc.timeline_groups.len() + 1),
};
let id = s
.doc
.timeline_groups
.iter()
.map(|group| group.id)
.max()
.unwrap_or(0)
.checked_add(1)
.ok_or_else(|| bad(cmd, "no more timeline folder ids are available"))?;
let id = s.doc_mut().allocate_id()?;
let parent = match p.get("parent") {
Some(Value::Null) | None => None,
Some(_) => Some(timeline_group_id_field(s, p, "parent", cmd)?),
@@ -1253,6 +1245,11 @@ fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
if std::mem::discriminant(&new.kind) != std::mem::discriminant(&old.kind) && !(boundary(&new.kind) && boundary(&old.kind)) {
return Err(bad(cmd, format!("{} is not a {} feature", old.name, cid)));
}
// The trial still holds the old feature, so a plane offset from its own name was made there,
// and once swapped in it would be built on itself (#3567).
if new.kind.names_feature(&old.name) {
return Err(bad(cmd, format!("{} can't be built on itself: pick another plane or face", old.name)));
}
// Optional inputs can move other inputs' positions (e.g. Distance 2 goes before Taper).
// Keep each existing model parameter attached to its semantic input, rather than its
// old index. Constructor-assigned names for new inputs cannot reuse the old dimensions.
+14
View File
@@ -503,6 +503,20 @@ fn timeline_folders_refuse_unknown_non_contiguous_and_split_contents() {
assert!(error.contains("not contiguous"), "{error}");
}
#[test]
fn deleted_timeline_group_ids_are_not_reused_after_reopening() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "name": "Block"}));
let params = json!({"features": ["Block"]});
let first = run(&mut s, "timeline.group", params.clone())["group"].as_u64().unwrap();
run(&mut s, "timeline.ungroup", json!({"group": first}));
let doc = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
*s.doc_mut() = doc;
let second = run(&mut s, "timeline.group", params)["group"].as_u64().unwrap();
assert!(second > first, "deleted folder {first} was reused as {second}");
assert!(s.execute("timeline.rename_group", &json!({"group": first, "name": "Stale reference"})).is_err());
}
#[test]
fn timeline_move_many_is_atomic_and_one_undo_step() {
let mut s = Session::default();
+39 -16
View File
@@ -29,7 +29,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("solid.revolve", "Revolve", revolve)
.at("SOLID", "CREATE")
.icon("revolve")
.params("axis: sketch line id | x | y (sketch axes) | X|Y|Z (world); angle?: expr (default 360 deg); angle2?: expr (Two Sides: the other side's angle); direction?: symmetric (angle split either side, independent of axis direction); convention?: sketch_normal|legacy_axis_start (default sketch_normal; legacy retains older angular starts); to?: [x,y,z] (To Object: turn until that face, vertex or point); sketch?, profiles? | face: [x,y,z] (a planar body face as the profile) and face_body?: name (whose face, where faces of several bodies meet), operation?, targets?, name?, body_name?"),
.params("axis: sketch line id | x | y (sketch axes) | X|Y|Z (world) | construction axis name (followed when edited) | {origin, dir}; angle?: expr (default 360 deg); angle2?: expr (Two Sides: the other side's angle); direction?: symmetric (angle split either side, independent of axis direction); convention?: sketch_normal|legacy_axis_start (default sketch_normal; legacy retains older angular starts); to?: [x,y,z] (To Object: turn until that face, vertex or point); sketch?, profiles? | face: [x,y,z] (a planar body face as the profile) and face_body?: name (whose face, where faces of several bodies meet), operation?, targets?, name?, body_name?"),
CommandSpec::new("solid.sweep", "Sweep", sweep)
.at("SOLID", "CREATE")
.icon("sweep")
@@ -58,7 +58,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("solid.pattern.circular", "Circular Pattern", pattern_circ)
.at("SOLID", "CREATE")
.icon("pattern_circ")
.params("features: [names] | bodies: [names] | components: [names]; axis: X|Y|Z | {origin, dir}; count; angle? (default 360 deg; partial arcs include both ends); alignment?: left|center|right (default right; original stays fixed); reversed?: bool (Flip; selects the extra side for even centered counts)"),
.params("features: [names] | bodies: [names] | components: [names]; axis: X|Y|Z | construction axis name (followed when edited) | {origin, dir}; count; angle? (default 360 deg; partial arcs include both ends); alignment?: left|center|right (default right; original stays fixed); reversed?: bool (Flip; selects the extra side for even centered counts)"),
CommandSpec::new("solid.pattern.path", "Pattern on Path", pattern_path)
.at("SOLID", "CREATE")
.icon("pattern_path")
@@ -108,7 +108,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("construct.plane.at_angle", "Plane at Angle", plane_angle)
.at("SOLID", "CONSTRUCT")
.icon("plane_angle")
.params("base: XY|XZ|YZ|plane name; axis: X|Y|Z|{origin, dir}; angle: expr; name?"),
.params("base: XY|XZ|YZ|plane name; axis: X|Y|Z|construction axis name (where it is now)|{origin, dir}; angle: expr; name?"),
CommandSpec::new("solid.split_face", "Split Face", split_face)
.at("SOLID", "MODIFY")
.icon("split_face")
@@ -816,7 +816,7 @@ fn revolve(s: &mut Session, p: &Value) -> Result<Value> {
}
None => (feature_sketch(s, p, cmd)?, None),
};
let axis = axis_param(p, cmd)?;
let axis = axis_param(s, sketch, p, cmd)?;
let angle = expr(p, "angle").unwrap_or_else(|| "360 deg".into());
check_expr(s, &angle, Kind::Angle, cmd, "angle")?;
let profiles = match face_profile {
@@ -852,9 +852,10 @@ fn revolve(s: &mut Session, p: &Value) -> Result<Value> {
add_feature(s, p, kind)
}
/// A revolve axis from its parameter: a sketch line id, the sketch's x or y axis, a world axis,
/// or {origin, dir}.
pub(super) fn axis_param(p: &Value, cmd: &str) -> Result<AxisRef> {
/// A revolve axis of `sketch` from its parameter: a sketch line id, the sketch's x or y axis, a
/// world axis, a construction axis by name (followed when it is edited) or {origin, dir}. A line
/// of the sketch wins over a construction axis of the same name.
pub(super) fn axis_param(s: &Session, sketch: u64, p: &Value, cmd: &str) -> Result<AxisRef> {
if let Some(o) = p.get("axis").filter(|v| v.is_object()) {
let origin = o.get("origin").and_then(vec3).unwrap_or(Vec3::ZERO);
let dir = o.get("dir").and_then(vec3).ok_or_else(|| bad(cmd, "axis needs `dir`"))?;
@@ -863,11 +864,22 @@ pub(super) fn axis_param(p: &Value, cmd: &str) -> Result<AxisRef> {
match str_(p, "axis") {
Some(a @ ("X" | "Y" | "Z")) => Ok(AxisRef::World { axis: a.into() }),
Some(a @ ("x" | "y")) => Ok(AxisRef::SketchAxis { axis: a.into() }),
Some(name) if !is_sketch_line(s, sketch, name) && is_construction_axis(s, name) => Ok(AxisRef::Construction { name: name.into() }),
Some(id) => Ok(AxisRef::SketchLine { curve: id.into() }),
None => Err(bad(cmd, "`axis` must be a sketch line id, x, y (sketch axes), X, Y, Z or {origin, dir}")),
None => Err(bad(cmd, "`axis` must be a sketch line id, x, y (sketch axes), X, Y, Z, a construction axis or {origin, dir}")),
}
}
/// Whether `curve` is a curve of the sketch feature `sketch`.
fn is_sketch_line(s: &Session, sketch: u64, curve: &str) -> bool {
matches!(s.doc.feature(sketch).map(|f| &f.kind), Some(FeatureKind::Sketch { sketch, .. }) if sketch.curve_index(curve).is_some())
}
/// Whether `name` is a construction axis feature of the design.
fn is_construction_axis(s: &Session, name: &str) -> bool {
s.doc.find_feature(name).is_some_and(|f| matches!(f.kind, FeatureKind::ConstructionAxis { .. }))
}
fn radius_expr(s: &Session, p: &Value, cmd: &str) -> Result<Expr> {
match (expr(p, "radius"), expr(p, "diameter")) {
(Some(r), _) => Ok(r),
@@ -1325,13 +1337,22 @@ fn source_features(s: &Session, p: &Value, cmd: &str) -> Result<Vec<String>> {
Ok(names)
}
fn axis_line(p: &Value, cmd: &str) -> Result<(Vec3, Vec3)> {
/// An axis line from `axis`: X, Y, Z, a construction axis by name or {origin, dir}. A construction
/// axis comes back where it is now, with its name.
fn axis_line(s: &Session, p: &Value, cmd: &str) -> Result<(Vec3, Vec3, Option<FeatureName>)> {
match p.get("axis") {
Some(Value::String(a)) => match a.to_ascii_uppercase().as_str() {
"X" => Ok((Vec3::ZERO, Vec3::X)),
"Y" => Ok((Vec3::ZERO, Vec3::Y)),
"Z" => Ok((Vec3::ZERO, Vec3::Z)),
_ => Err(bad(cmd, "axis must be X, Y, Z or {origin, dir}")),
"X" => Ok((Vec3::ZERO, Vec3::X, None)),
"Y" => Ok((Vec3::ZERO, Vec3::Y, None)),
"Z" => Ok((Vec3::ZERO, Vec3::Z, None)),
_ if is_construction_axis(s, a) => {
let (_, _, origin, dir) = crate::view::construction_axes(s)
.into_iter()
.find(|(_, name, _, _)| name == a)
.ok_or_else(|| bad(cmd, format!("construction axis `{a}` has no geometry: fix its definition first")))?;
Ok((origin, dir, Some(a.as_str().into())))
}
_ => Err(bad(cmd, "axis must be X, Y, Z, a construction axis or {origin, dir}")),
},
Some(o @ Value::Object(_)) => {
let origin = match o.get("origin") {
@@ -1341,7 +1362,7 @@ fn axis_line(p: &Value, cmd: &str) -> Result<(Vec3, Vec3)> {
})?,
};
let dir = o.get("dir").and_then(vec3).and_then(|d| d.normalized()).ok_or_else(|| bad(cmd, "axis needs a non-zero `dir`"))?;
Ok((origin, dir))
Ok((origin, dir, None))
}
_ => Err(bad(cmd, "`axis` is required")),
}
@@ -1425,7 +1446,7 @@ fn pattern_rect(s: &mut Session, p: &Value) -> Result<Value> {
fn pattern_circ(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.pattern.circular";
let (features, bodies) = pattern_sources(s, p, cmd)?;
let (origin, axis) = axis_line(p, cmd)?;
let (origin, axis, axis_feature) = axis_line(s, p, cmd)?;
let count = req_expr(cmd, p, "count")?;
check_expr(s, &count, Kind::Unitless, cmd, "count")?;
let angle = expr(p, "angle").unwrap_or_else(|| "360 deg".into());
@@ -1437,6 +1458,7 @@ fn pattern_circ(s: &mut Session, p: &Value) -> Result<Value> {
angle,
alignment: super::alignment_param(p, "alignment", cmd)?,
reversed: super::reversed_param(p, "reversed", cmd)?,
axis_feature,
};
finish_pattern(s, p, features, pattern, bodies)
}
@@ -1613,7 +1635,8 @@ fn plane_offset(s: &mut Session, p: &Value) -> Result<Value> {
fn plane_angle(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "construct.plane.at_angle";
let base = plane_param(s, p.get("base"), cmd)?;
let (axis_origin, axis_dir) = axis_line(p, cmd)?;
// The plane keeps the axis line as it is now.
let (axis_origin, axis_dir, _) = axis_line(s, p, cmd)?;
let angle = req_expr(cmd, p, "angle")?;
check_expr(s, &angle, Kind::Angle, cmd, "angle")?;
add_feature(
+1 -3
View File
@@ -752,9 +752,7 @@ mod tests {
let mut preview = saved_preview(&s).unwrap();
// A checksum-valid but forged cached shape must not become editable geometry.
for mesh in &mut preview.meshes {
for coordinate in &mut mesh.positions {
*coordinate *= 10.0;
}
mesh.positions = mesh.positions.iter().map(|c| c * 10.0).collect();
}
let bytes = solidcraft_io::write_design_container_with_preview(&s.doc, solidcraft_io::DesignCompression::Fast, Some(&preview)).unwrap();
let path = std::env::temp_dir().join(format!("solidcraft-preview-{}.solidcraft", std::process::id()));
+4 -5
View File
@@ -344,17 +344,16 @@ fn add_rigid_joints(s: &mut Session, pairs: &[(u64, u64, Mat)]) -> Result<Vec<u6
// Too many to hold together: the parts are placed, not locked.
return Ok(Vec::new());
}
let mut id = s.doc.assembly.joints.iter().map(|j| j.id).max().unwrap_or(0);
let mut ids = Vec::with_capacity(pairs.len());
let point = |m: &Mat, p: Vec3| solidcraft_doc::apply_point(m, p);
let vector = |m: &Mat, v: Vec3| solidcraft_doc::apply_vector(m, v);
let made: Vec<Joint> = pairs
.iter()
.map(|(a, b, m)| {
id += 1;
let id = s.doc_mut().allocate_id()?;
ids.push(id);
// As built: B's origin, seen from A (B's placement in A) and from B itself.
Joint {
Ok(Joint {
id,
name: format!("Rigid Group {a}:{b}"),
kind: JointKind::Rigid,
@@ -370,9 +369,9 @@ fn add_rigid_joints(s: &mut Session, pairs: &[(u64, u64, Mat)]) -> Result<Vec<u6
angle: 0.0,
suppressed: false,
exprs: Default::default(),
}
})
})
.collect();
.collect::<Result<_>>()?;
s.doc_mut().assembly.joints.extend(made);
// Dozens of coinciding glyphs say nothing: the group shows in the browser instead.
s.visibility.hidden_joints.retain(|h| !ids.contains(h));
+13 -9
View File
@@ -576,10 +576,6 @@ fn limits_param(s: &Session, p: &Value, kind: JointKind, cmd: &str) -> Result<Ve
Ok(out)
}
fn next_joint_id(s: &Session, cmd: &str) -> Result<u64> {
super::next_free_id(cmd, s.doc.assembly.joints.iter().map(|j| j.id))
}
fn add_joint(s: &mut Session, mut j: Joint, p: &Value) -> Result<Value> {
if s.doc.assembly.joints.len() >= 10_000 {
return Err(EngineError::Other("too many joints".into()));
@@ -619,7 +615,7 @@ fn add_joint(s: &mut Session, mut j: Joint, p: &Value) -> Result<Value> {
j.values = q;
let id = j.id;
let name = j.name.clone();
// A new joint can take a deleted one's id; it starts shown.
// A new joint starts shown, even if a stale visibility entry came from an older design.
s.visibility.hidden_joints.retain(|h| *h != id);
s.doc_mut().assembly.joints.push(j);
let sol = resolve(s);
@@ -652,13 +648,21 @@ fn kind_param(p: &Value, cmd: &str) -> Result<JointKind> {
.ok_or_else(|| bad(cmd, "`type` must be rigid, revolute, slider, cylindrical, pin_slot, planar, ball, parallel, perpendicular, angle, point_on_plane, point_on_line or line_on_plane"))
}
fn new_joint_name(s: &Session, p: &Value) -> String {
str_(p, "name").map(str::to_string).unwrap_or_else(|| {
let names: std::collections::BTreeSet<_> = s.doc.assembly.joints.iter().map(|j| j.name.as_str()).collect();
let number = (1..=s.doc.assembly.joints.len().saturating_add(1)).find(|n| !names.contains(format!("Joint{n}").as_str())).unwrap_or(1);
format!("Joint{number}")
})
}
fn joint(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "joint.create";
let kind = kind_param(p, cmd)?;
let a = origin_param(s, p.get("a"), cmd)?;
let b = origin_param(s, p.get("b"), cmd)?;
let id = next_joint_id(s, cmd)?;
let name = str_(p, "name").map(str::to_string).unwrap_or_else(|| format!("Joint{id}"));
let id = s.doc_mut().allocate_id()?;
let name = new_joint_name(s, p);
let offset = match p.get("offset") {
Some(v) => value(s, v, false, cmd)?,
None => 0.0,
@@ -716,8 +720,8 @@ fn as_built(s: &mut Session, p: &Value) -> Result<Value> {
};
let fa = side(oa)?;
let fb = side(ob)?;
let id = next_joint_id(s, cmd)?;
let name = str_(p, "name").map(str::to_string).unwrap_or_else(|| format!("Joint{id}"));
let id = s.doc_mut().allocate_id()?;
let name = new_joint_name(s, p);
let n = kind.dofs().len();
let j = Joint {
id,
+20
View File
@@ -25,6 +25,26 @@ fn near(a: Vec3, b: Vec3) -> bool {
a.dist(b) < 1e-6
}
#[test]
fn deleted_joint_ids_are_not_reused_after_reopening() {
for command in ["joint.create", "joint.as_built"] {
let mut s = Session::default();
run(&mut s, "component.create", json!({"name": "Part"}));
let params = if command == "joint.create" {
json!({"type": "revolute", "a": {"occurrence": 0, "point": [0, 0, 0]}, "b": {"occurrence": "Part", "point": [0, 0, 0]}})
} else {
json!({"type": "revolute", "a": 0, "b": "Part"})
};
let first = run(&mut s, command, params.clone())["joint"].as_u64().unwrap();
run(&mut s, "joint.delete", json!({"joint": first}));
let doc = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
*s.doc_mut() = doc;
let second = run(&mut s, command, params)["joint"].as_u64().unwrap();
assert!(second > first, "{command}: deleted joint {first} was reused as {second}");
assert!(s.execute("joint.drive", &json!({"joint": first, "value": 45})).is_err());
}
}
/// A hinge leaf (oracle 63's knuckle geometry): a 40 x 30 x 3 plate and an r4 knuckle along X
/// at y = 33, z = 4.
fn leaf(s: &mut Session, name: &str) {
-7
View File
@@ -313,13 +313,6 @@ pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
all.iter().find(|c| c.id == id).or_else(|| all.iter().find(|c| c.id.eq_ignore_ascii_case(id))).copied()
}
/// The id for a new item of a list: one past the largest id in it. Ids come from the design file,
/// so the largest may be the largest a `u64` holds, and one past that is an error, not an overflow
/// (a panic where overflow is checked, an id that is already taken where it is not).
pub(crate) fn next_free_id(cmd: &str, ids: impl Iterator<Item = u64>) -> Result<u64> {
ids.max().unwrap_or(0).checked_add(1).ok_or_else(|| crate::params::bad(cmd, "this design has no ids left to give"))
}
/// The end of an unknown command's error: up to three ids like `id`, nearest first. Ids a few
/// letters away (`solid.extrud` → `solid.extrude`) come before ids that end in the same word
/// (`file.open` → `doc.open`). Empty when nothing is close.
+10 -12
View File
@@ -19,7 +19,7 @@ mod analysis;
#[path = "motion_animation.rs"]
mod animation;
#[path = "contact_sweep.rs"]
mod contact_sweep;
pub(crate) mod contact_sweep;
#[path = "motion_sweep.rs"]
mod sweep;
@@ -98,12 +98,6 @@ pub static COMMANDS: &[CommandSpec] = &[
const MAX_ITEMS: usize = 10_000;
fn next_id(s: &Session, cmd: &str) -> Result<u64> {
let a = &s.doc.assembly;
let ids = a.contact_sets.iter().map(|c| c.id).chain(a.studies.iter().map(|m| m.id)).chain(a.exploded.iter().map(|e| e.id));
super::next_free_id(cmd, ids)
}
fn occurrences(s: &Session, p: &Value, cmd: &str) -> Result<Vec<u64>> {
let list = p.get("occurrences").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`occurrences` must list occurrences"))?;
if list.len() > MAX_ITEMS {
@@ -477,7 +471,7 @@ fn new_set(s: &mut Session, p: &Value) -> Result<Value> {
if s.doc.assembly.contact_sets.len() >= MAX_ITEMS {
return Err(bad(cmd, "too many contact sets"));
}
let id = next_id(s, cmd)?;
let id = s.doc_mut().allocate_id()?;
let name = str_(p, "name").map(str::to_string).unwrap_or_else(|| format!("Contact Set{}", s.doc.assembly.contact_sets.len() + 1));
let a = &mut s.doc_mut().assembly;
a.contact_sets.push(ContactSet { id, name: name.clone(), occurrences: occ, suppressed: false });
@@ -585,9 +579,13 @@ fn study(s: &mut Session, p: &Value) -> Result<Value> {
keys.push(StudyKey { joint, index, points });
}
}
let id = next_id(s, cmd)?;
let count = s.doc.assembly.studies.len();
let a = &mut s.doc_mut().assembly;
let doc = s.doc_mut();
let id = match existing {
Some(i) => doc.assembly.studies.get(i).ok_or_else(|| bad(cmd, "no such motion study"))?.id,
None => doc.allocate_id()?,
};
let count = doc.assembly.studies.len();
let a = &mut doc.assembly;
let out = match existing.and_then(|i| a.studies.get_mut(i)) {
Some(m) => {
if let Some(n) = steps {
@@ -1003,7 +1001,7 @@ fn explode_create(s: &mut Session, p: &Value) -> Result<Value> {
if s.doc.assembly.exploded.len() >= MAX_ITEMS {
return Err(bad(cmd, "too many exploded views"));
}
let id = next_id(s, cmd)?;
let id = s.doc_mut().allocate_id()?;
let name = str_(p, "name").map(str::to_string).unwrap_or_else(|| format!("Exploded View{}", s.doc.assembly.exploded.len() + 1));
s.doc_mut().assembly.exploded.push(ExplodedView { id, name: name.clone(), moves: moves.clone() });
Ok(json!({"view": id, "name": name, "moves": moves.iter().map(|(o, t)| json!({"occurrence": o, "translate": t})).collect::<Vec<_>>()}))
+542
View File
@@ -15,11 +15,444 @@ fn occ(s: &Session, component: &str) -> u64 {
s.doc.occurrence_of(c).map(|o| o.id).unwrap_or(0)
}
#[test]
fn deleted_motion_item_ids_are_not_reused_after_reopening() {
for (create, delete, key, params) in [
("contact.create", "contact.edit", "set", json!({"occurrences": ["A", "B"]})),
("motion.study", "motion.delete", "study", json!({"name": "Travel", "steps": 10})),
("explode.create", "explode.delete", "view", json!({"moves": [{"occurrence": "B", "translate": [20, 0, 0]}]})),
] {
let mut s = Session::default();
for name in ["A", "B"] {
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "component.create", json!({"name": name}));
}
let first = run(&mut s, create, params.clone())[key].as_u64().unwrap();
let deletion = json!({key: first, "delete": true});
run(&mut s, delete, deletion.clone());
let doc = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
*s.doc_mut() = doc;
let second = run(&mut s, create, params)[key].as_u64().unwrap();
assert!(second > first, "{create}: deleted item {first} was reused as {second}");
assert!(s.execute(delete, &deletion).is_err());
}
}
/// Where B's corner (20, 0, 0) is now.
fn b_x(s: &Session) -> f64 {
super::world_point(s, occ(s, "B"), Vec3::new(20.0, 0.0, 0.0)).x
}
#[derive(Clone, Copy)]
enum PartAuthoring {
RootThenTransfer,
RootThenNestedTransfer,
InComponent,
}
#[derive(Clone, Copy)]
enum JoinSelection {
Named,
Implicit,
All,
}
/// A boolean authored before a later transfer still reads the bodies in its timeline input,
/// not their final owners. Explicit targets must not turn every occurrence into an input.
fn joined_motion_part(authoring: PartAuthoring, selection: JoinSelection) -> Session {
let mut s = Session::default();
if matches!(authoring, PartAuthoring::InComponent) {
run(&mut s, "component.create", json!({"name":"Rotor"}));
}
run(&mut s, "solid.box", json!({"length":10,"width":8,"height":6,"body_name":"Part","name":"Base"}));
run(&mut s, "select.set", json!({"items":[]}));
let mut p = json!({"length":6,"width":4,"height":4,"corner":[8,2,1],"operation":"join","name":"Boss"});
match selection {
JoinSelection::Named => p["targets"] = json!(["Part"]),
JoinSelection::All => p["targets"] = json!(["*"]),
JoinSelection::Implicit => {}
}
run(&mut s, "solid.box", p);
if !matches!(authoring, PartAuthoring::InComponent) {
let parent = if matches!(authoring, PartAuthoring::RootThenNestedTransfer) {
run(&mut s, "component.create", json!({"name":"Group","activate":false}));
"Group"
} else {
"root"
};
run(&mut s, "component.create", json!({"name":"Rotor","parent":parent,"activate":false}));
run(&mut s, "component.move_bodies", json!({"bodies":["Part"],"component":"Rotor"}));
}
let driven = if matches!(authoring, PartAuthoring::RootThenNestedTransfer) { "Group" } else { "Rotor" };
attach_motion_rotor(&mut s, driven, "Turn");
s
}
fn attach_motion_rotor(s: &mut Session, component: &str, joint: &str) {
run(s, "component.activate", json!({"component":"root"}));
run(s, "joint.as_built", json!({"type":"revolute","name":joint,"a":0,"b":component,"at":{"occurrence":0,"point":[0,0,0],"z":[0,0,1]}}));
}
fn unrelated_motion_rotor(s: &mut Session) {
run(s, "component.activate", json!({"component":"root"}));
run(s, "component.create", json!({"name":"Unrelated"}));
run(s, "solid.box", json!({"length":2,"width":2,"height":2,"corner":[100,100,100],"body_name":"Remote"}));
attach_motion_rotor(s, "Unrelated", "Other turn");
}
/// Test work and identity, not elapsed time. Every first visit to an angle must preserve the
/// definitions and their warm meshes, even across full turns; returning to a cached pose alone
/// would miss the regression. The world mesh is still in the new physical position.
fn assert_joint_only_moves(s: &mut Session, joint: &str) {
use std::sync::Arc;
let definitions = s.model.state();
let meshes: Vec<_> = definitions.bodies.iter().map(|b| b.mesh()).collect();
for body in &s.world_state().bodies {
std::hint::black_box(body.mesh());
std::hint::black_box(body.display_plan());
}
let joints = run(s, "joint.list", json!({"quiet":true}));
let base = joints["joints"].as_array().unwrap().iter().find(|j| j["name"] == joint).unwrap()["values"][0]["value"].as_f64().unwrap();
for offset in [5., 10., 15., 37., 91., 365., 721., 12418., 12423., 37., 0.] {
let angle = base + offset;
let r = run(s, "joint.drive", json!({"joint":joint,"value":angle,"quiet":true}));
assert_eq!(r["moved"], true, "{r}");
assert!(r["stopped_by_loop"].is_null() && r["stopped_by_contact"].is_null(), "{r}");
let joints = run(s, "joint.list", json!({"quiet":true}));
let actual = joints["joints"].as_array().unwrap().iter().find(|j| j["name"] == joint).unwrap()["values"][0]["value"].as_f64().unwrap();
assert!((actual - angle).abs() < 1e-8, "{joint} did not reach {angle}: {actual}");
assert_eq!(s.model.last_recomputed, 0, "{joint} at {angle}: unchanged geometry was evaluated again");
let current = s.model.state();
assert!(Arc::ptr_eq(&definitions, &current), "{joint} at {angle}: definition state replaced");
for (b, mesh) in current.bodies.iter().zip(&meshes) {
assert!(Arc::ptr_eq(&b.mesh(), mesh), "{}: canonical mesh replaced", b.name);
}
let world = s.world_state();
let (_, work) = crate::kernel::profile::capture(|| {
for b in &world.bodies {
let local = definitions.body(b.definition_name()).unwrap().mesh();
let placed = b.mesh();
assert_eq!(placed.triangles, local.triangles);
assert_eq!(placed.tri_face, local.tri_face);
assert_eq!(placed.positions.len(), local.positions.len());
assert_eq!(placed.normals.len(), local.normals.len());
for (p, q) in local.positions.iter().zip(&placed.positions) {
assert!(crate::doc::apply_point(&b.source_transform, *p).dist(*q) < 1e-8);
}
for (n, q) in local.normals.iter().zip(&placed.normals) {
assert!(crate::doc::apply_vector(&b.source_transform, *n).dist(*q) < 1e-8);
}
}
});
assert_eq!(work.tessellate_ms, 0., "{joint} at {angle}: reference tessellation ran");
}
}
fn assert_motion_matches_full_rebuild(s: &Session) {
let mut full = crate::doc::Model::default();
full.no_cache = true;
full.evaluate(&s.doc);
assert!(full.results.iter().all(|r| r.error.is_none()), "{:?}", full.results.iter().filter_map(|r| r.error.as_ref()).collect::<Vec<_>>());
let (cached, fresh) = (s.model.state(), full.state());
assert_eq!(cached.bodies.len(), fresh.bodies.len());
for (a, b) in cached.bodies.iter().zip(&fresh.bodies) {
assert_eq!(a.name, b.name);
assert_eq!(a.body.face_count(), b.body.face_count(), "{}", a.name);
let (a_bounds, b_bounds) = (a.mesh().bounds(), b.mesh().bounds());
assert!(a_bounds.min.dist(b_bounds.min) < 1e-8 && a_bounds.max.dist(b_bounds.max) < 1e-8, "{}", a.name);
let (a_mass, b_mass) = (crate::kernel::measure(&a.body).unwrap(), crate::kernel::measure(&b.body).unwrap());
assert!((a_mass.volume - b_mass.volume).abs() < 1e-6, "{}: {} vs {}", a.name, a_mass.volume, b_mass.volume);
}
}
#[test]
fn joint_pose_named_union_before_transfer_retains_definitions() {
assert_joint_only_moves(&mut joined_motion_part(PartAuthoring::RootThenTransfer, JoinSelection::Named), "Turn");
}
#[test]
fn joint_pose_joined_cylinders_keep_mesh_cells_at_unseen_angles() {
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"radius":8.5,"height":8,"base":[0,32,0],"axis":[0,1,0],"operation":"new","body_name":"Crank"}));
run(
&mut s,
"solid.cylinder",
json!({"radius":2.8,"height":30,"base":[0,36,0],"axis":[0.981627183447664,0,0.1908089953765448],"operation":"join","targets":["Crank"]}),
);
run(&mut s, "component.create", json!({"name":"Crank","activate":false}));
run(&mut s, "component.move_bodies", json!({"bodies":["Crank"],"component":"Crank"}));
run(&mut s, "joint.as_built", json!({"type":"revolute","name":"Crank","a":0,"b":"Crank","at":{"occurrence":0,"point":[0,0,0],"z":[0,1,0]}}));
assert_joint_only_moves(&mut s, "Crank");
}
#[test]
fn joint_pose_named_union_in_its_component_retains_definitions() {
assert_joint_only_moves(&mut joined_motion_part(PartAuthoring::InComponent, JoinSelection::Named), "Turn");
}
#[test]
fn joint_pose_nested_body_transfer_ignores_ancestor_motion() {
assert_joint_only_moves(&mut joined_motion_part(PartAuthoring::RootThenNestedTransfer, JoinSelection::Named), "Turn");
}
#[test]
fn joint_pose_second_occurrence_shares_the_definition() {
let mut s = joined_motion_part(PartAuthoring::InComponent, JoinSelection::Named);
let second = run(&mut s, "occurrence.copy", json!({"component":"Rotor","translate":[40,0,0]}))["occurrence"].as_u64().unwrap();
run(&mut s, "joint.as_built", json!({"type":"revolute","name":"Copy turn","a":0,"b":second,"at":{"occurrence":0,"point":[40,0,0],"z":[0,0,1]}}));
assert_eq!(s.world_state().bodies.len(), 2);
assert_joint_only_moves(&mut s, "Copy turn");
assert_motion_matches_full_rebuild(&s);
}
#[test]
fn joint_pose_implicit_union_in_its_component_retains_definitions() {
assert_joint_only_moves(&mut joined_motion_part(PartAuthoring::InComponent, JoinSelection::Implicit), "Turn");
}
#[test]
fn joint_pose_unrelated_part_does_not_invalidate_named_union() {
let mut s = joined_motion_part(PartAuthoring::RootThenTransfer, JoinSelection::Named);
unrelated_motion_rotor(&mut s);
assert_joint_only_moves(&mut s, "Other turn");
}
#[test]
fn joint_pose_future_body_is_not_an_input_of_an_earlier_wildcard_union() {
let mut s = joined_motion_part(PartAuthoring::RootThenTransfer, JoinSelection::All);
unrelated_motion_rotor(&mut s);
assert_joint_only_moves(&mut s, "Other turn");
}
#[test]
fn joint_pose_empty_cache_keeps_unchanged_definition_results() {
let mut s = joined_motion_part(PartAuthoring::RootThenTransfer, JoinSelection::Named);
*s.model.cache.lock().unwrap() = Default::default();
assert_joint_only_moves(&mut s, "Turn");
}
#[test]
fn joint_pose_explicit_extrude_targets_ignore_unrelated_motion() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":3,"body_name":"Base"}));
run(&mut s, "sketch.create", json!({"plane":"XY","name":"BossOutline"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[8,2],"p1":[14,6]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch":"BossOutline","distance":3,"operation":"join","targets":["Base"],"name":"Boss"}));
unrelated_motion_rotor(&mut s);
assert_joint_only_moves(&mut s, "Other turn");
}
#[test]
fn joint_pose_combine_ignores_unrelated_motion() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Base"}));
run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"corner":[4,4,9],"body_name":"Boss","operation":"new"}));
run(&mut s, "solid.combine", json!({"target":"Base","tools":["Boss"],"operation":"join"}));
unrelated_motion_rotor(&mut s);
assert_joint_only_moves(&mut s, "Other turn");
}
/// A fixed tool removes the top 5 mm of the target where x=8..12. Sliding the target
/// relative to it changes the removed volume from 100 to 200 mm³. Moving the tool together
/// with the target preserves that volume and must preserve the computed definition too.
fn cross_component_motion_cut(move_tool_with_target: bool, kind: &str) -> Session {
let mut s = Session::default();
run(&mut s, "component.create", json!({"name":"MovingTarget"}));
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Target"}));
run(&mut s, "component.activate", json!({"component":"root"}));
if move_tool_with_target {
run(&mut s, "component.create", json!({"name":"Tool"}));
}
run(&mut s, "solid.box", json!({"length":4,"width":12,"height":6,"corner":[8,-1,5],"operation":"cut","targets":["Target"],"name":"Notch"}));
run(&mut s, "component.activate", json!({"component":"root"}));
let axis = if kind == "slider" { json!([1, 0, 0]) } else { json!([0, 0, 1]) };
run(&mut s, "joint.as_built", json!({"type":kind,"name":"Motion","a":0,"b":"MovingTarget","at":{"occurrence":0,"point":[0,0,0],"z":axis}}));
if move_tool_with_target {
run(&mut s, "joint.rigid_group", json!({"occurrences":["MovingTarget","Tool"]}));
}
s
}
fn motion_target_volume(s: &Session) -> f64 {
crate::kernel::measure(&s.model.state().body("Target").unwrap().body).unwrap().volume
}
fn motion_combine_cut(kind: &str, together: bool) -> Session {
let mut s = Session::default();
run(&mut s, "component.create", json!({"name":"MovingTarget"}));
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Target"}));
run(&mut s, "component.activate", json!({"component":"root"}));
run(&mut s, "component.create", json!({"name":"Tool"}));
run(&mut s, "solid.box", json!({"length":4,"width":12,"height":6,"corner":[8,-1,5],"body_name":"Cutter","operation":"new"}));
run(&mut s, "component.activate", json!({"component":"root"}));
run(&mut s, "solid.combine", json!({"target":"Target","tools":["Cutter"],"operation":"cut"}));
let axis = if kind == "slider" { json!([1, 0, 0]) } else { json!([0, 0, 1]) };
run(&mut s, "joint.as_built", json!({"type":kind,"name":"Motion","a":0,"b":"MovingTarget","at":{"occurrence":0,"point":[0,0,0],"z":axis}}));
if together {
run(&mut s, "joint.rigid_group", json!({"occurrences":["MovingTarget","Tool"]}));
}
s
}
#[test]
fn joint_pose_combine_common_motion_keeps_the_target_frame() {
let mut s = motion_combine_cut("revolute", true);
assert_joint_only_moves(&mut s, "Motion");
assert!((motion_target_volume(&s) - 900.).abs() < 1e-6);
assert_motion_matches_full_rebuild(&s);
}
#[test]
fn joint_pose_combine_relative_motion_rebuilds_the_cut() {
let mut s = motion_combine_cut("slider", false);
assert!((motion_target_volume(&s) - 900.).abs() < 1e-6);
run(&mut s, "joint.drive", json!({"joint":"Motion","value":5}));
let volume = motion_target_volume(&s);
assert!((volume - 800.).abs() < 1e-6, "Combine must follow relative motion: {volume}");
assert_motion_matches_full_rebuild(&s);
}
#[test]
fn joint_pose_target_body_offset_rebuilds_the_cut() {
let mut s = cross_component_motion_cut(false, "slider");
assert!((motion_target_volume(&s) - 900.).abs() < 1e-6);
let mut offset = crate::doc::IDENTITY;
offset[3][0] = 5.;
s.doc_mut().body_offsets.insert("Target".into(), offset);
s.refresh();
let volume = motion_target_volume(&s);
assert!((volume - 800.).abs() < 1e-6, "the input body's offset changes the tool frame: {volume}");
assert_motion_matches_full_rebuild(&s);
}
#[test]
fn joint_pose_unrelated_body_offset_keeps_named_union() {
let mut s = joined_motion_part(PartAuthoring::RootThenTransfer, JoinSelection::Named);
unrelated_motion_rotor(&mut s);
let before = s.model.state();
let mut offset = crate::doc::IDENTITY;
offset[3][0] = 5.;
s.doc_mut().body_offsets.insert("Remote".into(), offset);
s.refresh();
assert_eq!(s.model.last_recomputed, 0);
assert!(std::sync::Arc::ptr_eq(&before, &s.model.state()));
assert_motion_matches_full_rebuild(&s);
}
#[test]
fn joint_pose_implicit_union_reads_its_target_body_offset() {
let mut s = joined_motion_part(PartAuthoring::InComponent, JoinSelection::Implicit);
let volume = |s: &Session| crate::kernel::measure(&s.model.state().body("Part").unwrap().body).unwrap().volume;
assert!((volume(&s) - 544.).abs() < 1e-6);
let mut offset = crate::doc::IDENTITY;
offset[3][0] = 5.;
s.doc_mut().body_offsets.insert("Part".into(), offset);
s.refresh();
assert!((volume(&s) - 480.).abs() < 1e-6, "the join's tool is now entirely inside its target: {}", volume(&s));
assert_motion_matches_full_rebuild(&s);
}
#[test]
fn joint_pose_true_relative_target_motion_rebuilds_the_cut() {
let mut s = cross_component_motion_cut(false, "slider");
assert!((motion_target_volume(&s) - 900.).abs() < 1e-6);
run(&mut s, "joint.drive", json!({"joint":"Motion","value":5}));
let volume = motion_target_volume(&s);
assert!((volume - 800.).abs() < 1e-6, "relative motion should leave 800 mm³, got {volume}");
assert!(s.model.last_recomputed > 0, "a target moved relative to its tool");
assert_motion_matches_full_rebuild(&s);
}
#[test]
fn joint_pose_shared_rigid_motion_keeps_the_cut_definition() {
let mut s = cross_component_motion_cut(true, "slider");
assert!((motion_target_volume(&s) - 900.).abs() < 1e-6);
let before = s.model.state();
run(&mut s, "joint.drive", json!({"joint":"Motion","value":5}));
assert_eq!(s.model.last_recomputed, 0, "relative target/tool placement did not change");
assert!(std::sync::Arc::ptr_eq(&before, &s.model.state()));
assert!((motion_target_volume(&s) - 900.).abs() < 1e-6);
assert_motion_matches_full_rebuild(&s);
}
#[test]
fn joint_pose_shared_rotation_preserves_relative_cut_geometry() {
let mut s = cross_component_motion_cut(true, "revolute");
assert_joint_only_moves(&mut s, "Motion");
assert!((motion_target_volume(&s) - 900.).abs() < 1e-6);
assert_motion_matches_full_rebuild(&s);
}
#[test]
fn joint_pose_primed_ahead_result_does_not_replace_unchanged_geometry() {
let mut s = joined_motion_part(PartAuthoring::RootThenTransfer, JoinSelection::Named);
let definitions = s.model.state();
let mesh = definitions.body("Part").unwrap().mesh();
let commands = [("joint.drive".into(), json!({"joint":"Turn","value":5,"quiet":true}))];
let evaluated = s.ahead().evaluate(&commands).unwrap();
*s.model.cache.lock().unwrap() = Default::default();
s.prime(&evaluated);
run(&mut s, &commands[0].0, commands[0].1.clone());
s.unprime();
assert!(std::sync::Arc::ptr_eq(&definitions, &s.model.state()), "computing ahead must not replace an unchanged definition");
assert!(std::sync::Arc::ptr_eq(&mesh, &s.model.state().body("Part").unwrap().mesh()));
assert_motion_matches_full_rebuild(&s);
}
#[test]
fn joint_pose_save_and_reopen_keep_definitions_at_new_angles() {
let mut s = joined_motion_part(PartAuthoring::RootThenTransfer, JoinSelection::Named);
run(&mut s, "joint.drive", json!({"joint":"Turn","value":5}));
let directory = std::env::temp_dir().join(format!(
"solidcraft-joint-pose-{}-{}",
std::process::id(),
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
));
std::fs::create_dir(&directory).unwrap();
let path = directory.join("part.solidcraft");
run(&mut s, "file.save", json!({"path":path,"cache":true,"preview":true}));
let mut opened = Session::default();
run(&mut opened, "doc.open", json!({"path":path}));
std::fs::remove_file(&path).unwrap();
std::fs::remove_dir(&directory).unwrap();
assert_motion_matches_full_rebuild(&opened);
assert_joint_only_moves(&mut opened, "Turn");
}
#[test]
fn joint_pose_undo_and_redo_match_fresh_geometry() {
let mut s = joined_motion_part(PartAuthoring::RootThenTransfer, JoinSelection::Named);
for angle in [5, 10] {
run(&mut s, "joint.drive", json!({"joint":"Turn","value":angle}));
}
run(&mut s, "edit.undo", json!({}));
let joints = run(&mut s, "joint.list", json!({"quiet":true}));
assert!((joints["joints"][0]["values"][0]["value"].as_f64().unwrap() - 5.).abs() < 1e-8);
assert_motion_matches_full_rebuild(&s);
run(&mut s, "edit.redo", json!({}));
let joints = run(&mut s, "joint.list", json!({"quiet":true}));
assert!((joints["joints"][0]["values"][0]["value"].as_f64().unwrap() - 10.).abs() < 1e-8);
assert_motion_matches_full_rebuild(&s);
}
#[test]
fn joint_pose_geometry_edit_invalidates_the_warm_definition_mesh() {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name":"span","expression":"10 mm"}));
run(&mut s, "component.create", json!({"name":"Rotor"}));
run(&mut s, "solid.box", json!({"length":"span","width":8,"height":6,"body_name":"Part"}));
attach_motion_rotor(&mut s, "Rotor", "Turn");
run(&mut s, "joint.drive", json!({"joint":"Turn","value":5}));
let before = s.model.state().body("Part").unwrap().mesh();
run(&mut s, "parameters.change", json!({"name":"span","expression":"12 mm"}));
let model = s.model.state();
let body = model.body("Part").unwrap();
assert!(!std::sync::Arc::ptr_eq(&before, &body.mesh()));
assert!((crate::kernel::measure(&body.body).unwrap().volume - 576.).abs() < 1e-6);
assert_motion_matches_full_rebuild(&s);
}
/// Cubes A (grounded, x 0…10) and B (x 20…30) on a slider along x: B's −x face on A's +x face
/// at value 0, B 10 mm away to start.
fn rail() -> Session {
@@ -456,3 +889,112 @@ fn hostile_animation_params_are_refused() {
assert!(root.join("in.gif").exists() && !dir.join("out").exists());
let _ = std::fs::remove_dir_all(&dir);
}
/// A grounded Base plate, an Arm turning on it about Z (joint `Pivot`) and a Head fixed to the
/// arm. The Arm's slot sketch projects the arm's own edge and its axis, the Head's bore sketch
/// an edge of the arm (they move together). With `in_context`, the slot sketch also projects an
/// edge of the Base, across the joint.
fn pivot(in_context: bool) -> Session {
let mut s = Session::default();
run(&mut s, "component.create", json!({"name": "Base"}));
run(&mut s, "solid.box", json!({"length": 40, "width": 40, "height": 10, "corner": [-20, -20, 0], "body_name": "Plate"}));
run(&mut s, "solid.chamfer", json!({"edges": [[0, -20, 10]], "distance": 1}));
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "component.create", json!({"name": "Arm"}));
run(&mut s, "solid.box", json!({"length": 50, "width": 8, "height": 5, "corner": [-4, -4, 10], "body_name": "Bar"}));
run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 0, 15], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}, "name": "Slot"}));
run(&mut s, "sketch.project", json!({"refs": [{"edge": [20, 4, 15], "body": "Bar"}, "origin"]}));
if in_context {
run(&mut s, "sketch.project", json!({"refs": [{"edge": [0, 20, 10], "body": "Plate"}]}));
}
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [20, -2], "p1": [30, 2]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "Slot", "distance": -2, "operation": "cut", "name": "SlotCut"}));
run(&mut s, "solid.chamfer", json!({"edges": [[10, -4, 15]], "distance": 0.5}));
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "component.create", json!({"name": "Head"}));
run(&mut s, "solid.box", json!({"length": 10, "width": 12, "height": 6, "corner": [36, -6, 15], "body_name": "Cap"}));
run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 0, 21], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}, "name": "Bore"}));
run(&mut s, "sketch.project", json!({"refs": [{"edge": [40, 4, 15], "body": "Bar"}]}));
run(&mut s, "sketch.circle.center", json!({"center": [41, 0], "radius": 2}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "Bore", "distance": -4, "operation": "cut", "name": "BoreCut"}));
run(&mut s, "component.activate", json!({"component": "root"}));
let (base, arm, head) = (occ(&s, "Base"), occ(&s, "Arm"), occ(&s, "Head"));
run(&mut s, "occurrence.ground", json!({"occurrence": base, "grounded": true}));
run(
&mut s,
"joint.create",
json!({"type": "revolute", "a": {"occurrence": base, "point": [0, 0, 10]}, "b": {"occurrence": arm, "point": [0, 0, 10]}, "flip": true, "name": "Pivot"}),
);
run(&mut s, "joint.rigid_group", json!({"occurrences": [arm, head]}));
s
}
/// Every body of `model` and `full` the same: names, faces and where their meshes lie.
fn same_bodies(model: &solidcraft_doc::ModelState, full: &solidcraft_doc::ModelState) {
assert_eq!(model.bodies.len(), full.bodies.len());
for (a, b) in model.bodies.iter().zip(&full.bodies) {
assert_eq!(a.name, b.name);
assert_eq!(a.body.face_count(), b.body.face_count(), "{}", a.name);
let (m, n) = (a.mesh().bounds(), b.mesh().bounds());
assert!(m.min.dist(n.min) < 1e-9 && m.max.dist(n.max) < 1e-9, "{}: {m:?} vs {n:?}", a.name);
}
}
/// The model `s` shows, evaluated again from nothing.
fn rebuilt(s: &Session) -> std::sync::Arc<solidcraft_doc::ModelState> {
let mut full = solidcraft_doc::Model::default();
full.no_cache = true;
full.evaluate(&s.doc);
full.state()
}
/// Driving a joint only moves parts: a part that projects its own geometry, or that of a part
/// moving with it, is not evaluated again (#1989's motion on a finished assembly evaluated the
/// whole timeline on every drive, and every new pose missed the cache).
#[test]
fn driving_a_joint_moves_parts_without_evaluating_them() {
let mut s = pivot(false);
let model = s.model.state();
let head = occ(&s, "Head");
let tip = |s: &Session| super::world_point(s, head, Vec3::new(46.0, 0.0, 15.0));
let before = tip(&s);
for (step, angle) in ["30 deg", "75 deg", "-40 deg", "75 deg"].into_iter().enumerate() {
let r = run(&mut s, "joint.drive", json!({"joint": "Pivot", "value": angle}));
assert_eq!(r["moved"], true, "{r}");
assert_eq!((s.model.last_recomputed, s.model.last_restored), (0, 0), "step {step}: nothing evaluated, nothing taken from the cache");
assert!(std::sync::Arc::ptr_eq(&model, &s.model.state()), "the parts' bodies are the ones they were");
}
// The head went round with the arm: 75° about Z through the origin.
let (sin, cos) = 75f64.to_radians().sin_cos();
let want = Vec3::new(before.x * cos - before.y * sin, before.x * sin + before.y * cos, before.z);
assert!(tip(&s).dist(want) < 1e-6, "{:?} vs {want:?}", tip(&s));
same_bodies(&s.model.state(), &rebuilt(&s));
}
/// A sketch that projects another part's edge is in-context design: it is evaluated again when
/// that part moves relative to it (and gives what a full rebuild gives), not when the two move
/// together.
#[test]
fn an_in_context_sketch_follows_only_the_parts_it_reads() {
let mut s = pivot(true);
let slot = s.doc.find_feature("Slot").map(|f| f.id).unwrap();
let projected = |s: &Session| s.model.state().sketch(slot).map(|k| k.sketch.clone()).unwrap();
let first = projected(&s);
run(&mut s, "joint.drive", json!({"joint": "Pivot", "value": "30 deg"}));
assert!(s.model.last_recomputed > 0, "the slot sketch reads the base, which turned relative to it");
assert_ne!(projected(&s).links, first.links, "the base's edge is where the arm now sees it");
same_bodies(&s.model.state(), &rebuilt(&s));
// The whole assembly moved as one (both occurrences by the same turn and shift): nothing the
// sketch reads moved relative to it.
let model = s.model.state();
let turn = solidcraft_doc::rigid(Vec3::new(5.0, -3.0, 2.0), Vec3::new(1.0, 2.0, 0.0), Vec3::new(0.3, 0.2, 1.0), 0.7);
for o in &mut s.doc_mut().occurrences {
o.transform = solidcraft_doc::mat_mul(&turn, &o.transform);
}
s.refresh();
assert_eq!(s.model.last_recomputed, 0);
assert!(std::sync::Arc::ptr_eq(&model, &s.model.state()));
same_bodies(&s.model.state(), &rebuilt(&s));
}
@@ -80,3 +80,22 @@ fn offset_plane_rejects_bad_face_picks_without_panicking() {
assert_eq!(s.doc.features.len(), features, "{base}");
}
}
/// #3567: an Offset Plane redefined with itself as its base was accepted while the old plane
/// stood beside the new one, and then failed with "plane offsets nested too deeply". It is
/// refused with a plain message, and the plane is left as it was.
#[test]
fn offset_plane_redefined_on_itself_is_refused() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20, "corner": [10, 0, 0]}));
run(&mut s, "construct.plane.offset", json!({"base": {"face": [30, 0, 10]}, "offset": 10, "name": "Side"}));
assert!((plane(&s, "Side").origin.y + 10.0).abs() < 1e-9, "{:?}", plane(&s, "Side"));
let saved = s.doc.to_json();
let redefine = |base: Value| json!({"feature": "Side", "command": "construct.plane.offset", "params": {"base": base, "offset": "7"}});
let e = s.execute("timeline.redefine", &redefine(json!("Side"))).unwrap_err().to_string();
assert!(e.contains("can't be built on itself"), "{e}");
assert_eq!(s.doc.to_json(), saved);
// The same edit on the face it was built on works.
run(&mut s, "timeline.redefine", redefine(json!({"face": [30, 0, 10]})));
assert!((plane(&s, "Side").origin.y + 7.0).abs() < 1e-9, "{:?}", plane(&s, "Side"));
}
+29
View File
@@ -230,6 +230,35 @@ fn patterns_of_bodies() {
assert!(s.execute("solid.pattern.rectangular", &json!({"bodies": ["Nope"], "dir1": [1, 0, 0], "count1": 2, "spacing1": 5})).is_err());
}
/// A construction axis picked in the dialog comes as its name (#2885): the copies turn about it,
/// and follow it when it is edited.
#[test]
fn circular_pattern_about_a_construction_axis_follows_it() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 2, "width": 2, "height": 2, "corner": [40, 0, 0], "body_name": "Cube"}));
let axis = |x: f64| json!({"a": {"point": [x, 0, 0]}, "b": {"point": [x, 0, 10]}, "name": "Axis1"});
run(&mut s, "construct.axis.two_points", axis(30.0));
run(&mut s, "solid.pattern.circular", json!({"bodies": ["Cube"], "axis": "Axis1", "count": 4}));
// The x and y extent of the four cubes.
let span = |s: &Session| {
let st = s.model.state();
assert_eq!(st.bodies.len(), 4);
let mut out = [f64::INFINITY, f64::NEG_INFINITY, f64::INFINITY, f64::NEG_INFINITY];
for b in st.bodies.iter().filter_map(|mb| solidcraft_kernel::measure(&mb.body).ok()).map(|m| m.bbox) {
out = [out[0].min(b.min.x), out[1].max(b.max.x), out[2].min(b.min.y), out[3].max(b.max.y)];
}
out
};
// About x = 30: the cube at x 40…42 also lands at 18…20, and at y ±(10…12).
for (got, want) in span(&s).into_iter().zip([18.0, 42.0, -12.0, 12.0]) {
assert!((got - want).abs() < 1e-6, "{got} vs {want}");
}
run(&mut s, "timeline.redefine", json!({"feature": "Axis1", "command": "construct.axis.two_points", "params": axis(35.0)}));
for (got, want) in span(&s).into_iter().zip([28.0, 42.0, -7.0, 7.0]) {
assert!((got - want).abs() < 1e-6, "{got} vs {want}");
}
}
#[test]
fn patterns_of_components_place_occurrences() {
let mut s = Session::default();
@@ -307,3 +307,56 @@ fn curved_targets_keep_only_the_material_before_the_post_for_both_axis_signs() {
}
}
}
/// The construction-axis points of a feature's JSON moved along x by `dx`.
fn shift_points(v: &mut Value, dx: f64) {
match v {
Value::Array(a) if a.len() == 3 && a.iter().all(Value::is_number) => a[0] = json!(a[0].as_f64().unwrap() + dx),
Value::Array(a) => a.iter_mut().for_each(|x| shift_points(x, dx)),
Value::Object(o) => o.values_mut().for_each(|x| shift_points(x, dx)),
_ => {}
}
}
/// A construction axis picked as the revolve axis (#2885, #4506) is saved by name, survives
/// saving and opening, and the revolve follows the axis when it is edited.
#[test]
fn construction_axis_revolve_follows_the_axis() {
let mut s = Session::default();
run(&mut s, "construct.axis.two_points", json!({"a":{"point":[0,0,0]},"b":{"point":[0,30,0]},"name":"Axis1"}));
profile(&mut s, false);
run(&mut s, "solid.revolve", json!({"sketch":"Profile","axis":"Axis1","angle":"180 deg","operation":"new","profiles":[0],"name":"Turn"}));
let turn = s.doc.find_feature("Turn").unwrap();
assert!(matches!(&turn.kind, FeatureKind::Revolve { axis: solidcraft_doc::AxisRef::Construction { name }, .. } if name == "Axis1"));
let reopened = Document::from_json(&s.doc.to_json()).unwrap();
assert_eq!(reopened.find_feature("Turn").unwrap().kind, turn.kind);
let volume = |s: &Session| solidcraft_kernel::measure(&s.model.state().bodies[0].body).unwrap().volume;
assert!((volume(&s) - 60.0 * PI).abs() < 0.05);
assert!((volume(&Session::new(reopened)) - 60.0 * PI).abs() < 0.05);
// Moving the axis 1 mm away from the profile widens the ring: radii 3 to 5 instead of 2 to 4.
let mut axis = serde_json::to_value(s.doc.find_feature("Axis1").unwrap()).unwrap();
let def = if axis.get("def").is_some() { axis.get_mut("def") } else { axis.pointer_mut("/kind/def") }.unwrap();
shift_points(def, -1.0);
let set = if axis.get("def").is_some() { json!({"def": axis["def"]}) } else { json!({"kind": {"def": axis["kind"]["def"]}}) };
run(&mut s, "timeline.edit", json!({"feature":"Axis1","set":set}));
assert!((volume(&s) - 80.0 * PI).abs() < 0.05, "{}", volume(&s));
}
#[test]
fn construction_axis_revolve_rejects_invalid_axes_atomically() {
for (a, b, message) in [
([0, 0, 0], [0, 0, 30], "does not lie in the sketch plane"),
([0, 0, 1], [0, 30, 1], "does not lie in the sketch plane"),
([3, 0, 0], [3, 30, 0], "profile crosses the revolve axis"),
] {
let mut s = Session::default();
run(&mut s, "construct.axis.two_points", json!({"a":{"point":a},"b":{"point":b},"name":"Axis1"}));
profile(&mut s, false);
let before = s.doc.to_json();
let error =
s.execute("solid.revolve", &json!({"sketch":"Profile","axis":"Axis1","angle":"180 deg","operation":"new"})).unwrap_err().to_string();
assert!(error.contains(message), "{error}");
assert_eq!(s.doc.to_json(), before);
assert!(s.model.state().bodies.is_empty());
}
}
+19 -2
View File
@@ -56,9 +56,13 @@ fn make(s: &mut Session, p: &Value, cmd: &str, decal: bool) -> Result<Value> {
// It belongs to the active component (its plane is in that frame) and moves with it.
let component = s.active_component;
let doc = s.doc_mut();
let id = super::next_free_id(cmd, doc.canvases.iter().map(|c| c.id))?;
let id = doc.allocate_id()?;
let base = if decal { "Decal" } else { "Canvas" };
let name = str_(p, "name").map(str::to_string).unwrap_or_else(|| format!("{base}{id}"));
let name = str_(p, "name").map(str::to_string).unwrap_or_else(|| {
let names: std::collections::BTreeSet<_> = doc.canvases.iter().map(|c| c.name.as_str()).collect();
let number = (1..=doc.canvases.len().saturating_add(1)).find(|n| !names.contains(format!("{base}{n}").as_str())).unwrap_or(1);
format!("{base}{number}")
});
doc.canvases.push(Canvas {
id,
name: name.clone(),
@@ -170,6 +174,19 @@ mod tests {
base64_encode(&b)
}
#[test]
fn deleted_canvas_ids_are_not_reused_after_reopening() {
let mut s = Session::default();
let params = json!({"data": png(10, 10)});
let first = s.execute("canvas.insert", &params).unwrap()["canvas"].as_u64().unwrap();
s.execute("canvas.delete", &json!({"canvas": first})).unwrap();
let doc = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
*s.doc_mut() = doc;
let second = s.execute("canvas.insert", &params).unwrap()["canvas"].as_u64().unwrap();
assert!(second > first, "deleted canvas {first} was reused as {second}");
assert!(s.execute("canvas.edit", &json!({"canvas": first, "width": 500})).is_err());
}
#[test]
fn canvas_insert_edit_calibrate_save() {
let mut s = Session::default();
+22
View File
@@ -758,6 +758,26 @@ fn refuse_infinite_corner(sk: &Sketch, p: &Value, cmd: &str) -> Result<()> {
if at_corner { Err(bad(cmd, "an infinite line has no corner to round or cut: make it a normal line first")) } else { Ok(()) }
}
/// Fillets and chamfers refuse a corner of an offset's output (#2874). The output's ends are
/// recomputed from its source curves on every solve, so a corner cut there would be pulled back
/// to the sharp corner, leaving full circles and an unsolved sketch.
fn refuse_offset_corner(sk: &Sketch, p: &Value, cmd: &str) -> Result<()> {
let output = |c: usize| sk.curves.get(c).filter(|c| sk.offset_distance(&c.id).is_some()).map(|c| c.id.clone());
let found = match str_(p, "point").and_then(|r| sk.resolve_point(r)) {
Some(q) => (0..sk.curves.len()).filter(|c| sk.curves.get(*c).is_some_and(|x| x.kind.uses(q))).find_map(output),
None => ["a", "b"].iter().find_map(|k| str_(p, k).and_then(|i| sk.curve_index(i)).and_then(output)),
};
match found {
Some(id) => Err(bad(
cmd,
format!(
"`{id}` is an offset, and its corners follow its source curves: round or cut the source corners before offsetting, or break the offset association first"
),
)),
None => Ok(()),
}
}
/// A corner of two lines: (line a, line b, corner point, far end of a, far end of b).
fn corner(sk: &Sketch, p: &Value, cmd: &str) -> Result<(usize, usize, usize, usize, usize)> {
let ends = |l: usize| match sk.curves.get(l).map(|c| &c.kind) {
@@ -976,6 +996,7 @@ fn kept_json(kept: Vec<CornerKept>) -> (Vec<String>, Vec<String>) {
/// the arc, the tangent constraints and what became of the corner point.
fn fillet_corner(sk: &mut Sketch, p: &Value, r: f64, cmd: &str) -> Result<(usize, Vec<String>, CornerKept)> {
refuse_infinite_corner(sk, p, cmd)?;
refuse_offset_corner(sk, p, cmd)?;
weld_corner(sk, p);
let (la, lb, c, fa, fb) = match corner(sk, p, cmd) {
Ok(x) => x,
@@ -1086,6 +1107,7 @@ fn round_corner(sk: &mut Sketch, p: &Value, r: f64, cmd: &str) -> Result<Option<
fn chamfer(s: &mut Session, p: &Value, cmd: &str, dists: impl FnOnce(f64) -> Result<(f64, f64)>) -> Result<Value> {
let (out, info) = edit(s, p, cmd, false, |sk, _| {
refuse_infinite_corner(sk, p, cmd)?;
refuse_offset_corner(sk, p, cmd)?;
weld_corner(sk, p);
let (la, lb, c, fa, fb) = corner(sk, p, cmd)?;
let (pc, pa, pb) = (sk.point(c).unwrap_or_default(), sk.point(fa).unwrap_or_default(), sk.point(fb).unwrap_or_default());
@@ -1841,3 +1841,33 @@ fn trim_and_extend_refuse_an_offsets_ends_until_its_association_is_broken() {
"{sh:?}"
);
}
/// #2874: an offset's corners follow its source curves, so filleting them is refused before
/// anything changes (the whole batch), instead of leaving full circles and an unsolved sketch.
/// Once the offset association is broken the same corners round normally.
#[test]
fn fillet_refuses_an_offsets_corners_until_its_association_is_broken() {
let mut s = new_sketch();
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]}));
let out = ids(&run(&mut s, "sketch.offset", json!({"curves": ["l1", "l2", "l3", "l4"], "distance": "2 mm", "side": [-5, -5]}))["curves"]);
assert_eq!(out, ["l5", "l6", "l7", "l8"]);
let before = serde_json::to_value(sketch(&s)).unwrap();
let corners = json!({"points": ["l5.start", "l5.end", "l6.end", "l7.end"], "radius": "2 mm"});
let e = s.execute("sketch.fillet", &corners).unwrap_err().to_string();
assert!(e.contains("offset") && e.contains("break the offset association"), "{e}");
assert_eq!(serde_json::to_value(sketch(&s)).unwrap(), before, "a refused fillet changes nothing");
let e = s.execute("sketch.chamfer.equal_distance", &json!({"point": "l6.end", "distance": 1})).unwrap_err().to_string();
assert!(e.contains("offset"), "{e}");
run(&mut s, "sketch.offset.break", json!({"curve": "l5"}));
let f = run(&mut s, "sketch.fillet", corners);
assert_eq!(f["sketch"]["solved"], json!(true), "{f}");
let sk = sketch(&s);
let arcs = ids(&f["curves"]);
assert_eq!(arcs.len(), 4);
for arc in &arcs {
let ai = sk.curve_index(arc).unwrap();
assert!(matches!(sk.curves[ai].kind, CurveKind::Arc { .. }), "{arc} is a trimmed arc");
assert!((sk.radius(ai).unwrap() - 2.0).abs() < 1e-7);
}
}
+2 -2
View File
@@ -43,7 +43,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("surface.revolve", "Surface Revolve", surface_revolve)
.at("SURFACE", "CREATE")
.icon("surface_revolve")
.params("sketch?: id|name; curves?: [curve ids] and/or profiles? (as surface.extrude); axis: sketch line id|x|y|X|Y|Z|{origin, dir}; angle?: expr (default 360 deg); angle2?: expr (the other side); symmetric?: bool; name?, body_name? (a surface body, no caps; a solid where a full turn closes it)"),
.params("sketch?: id|name; curves?: [curve ids] and/or profiles? (as surface.extrude); axis: sketch line id|x|y|X|Y|Z|construction axis name|{origin, dir}; angle?: expr (default 360 deg); angle2?: expr (the other side); symmetric?: bool; name?, body_name? (a surface body, no caps; a solid where a full turn closes it)"),
CommandSpec::new("surface.sweep", "Surface Sweep", surface_sweep)
.at("SURFACE", "CREATE")
.icon("surface_sweep")
@@ -333,7 +333,7 @@ fn surface_revolve(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "surface.revolve";
let sketch = feature_sketch(s, p, cmd)?;
let (profiles, curves) = sheet_inputs(p, cmd)?;
let axis = axis_param(p, cmd)?;
let axis = axis_param(s, sketch, p, cmd)?;
let angle = expr(p, "angle").unwrap_or_else(|| "360 deg".into());
check_expr(s, &angle, Kind::Angle, cmd, "angle")?;
let angle2 = expr(p, "angle2");
+30 -6
View File
@@ -177,12 +177,14 @@ impl Ahead {
}
/// Run `commands` (their results stay in the shared evaluation cache, and in [`Ahead::kept`]),
/// then make the display meshes and plans of the bodies they end with: the bodies the run
/// here finds share them, so the window draws, fits and picks without tessellating anything.
pub fn evaluate(self, commands: &[(String, Value)]) -> Result<()> {
let (s, _) = self.0.preview_in_place(commands)?;
s.world_state().warm_display();
Ok(())
/// then make the display meshes and plans of the bodies they end with, so the window draws,
/// fits and picks without tessellating anything. What they evaluated comes back for
/// [`Session::prime`], so running them for real evaluates nothing again: the shared cache
/// drops results past its budget, and a large design's states are past it.
pub fn evaluate(self, commands: &[(String, Value)]) -> Result<Evaluated> {
let (after, _) = self.0.preview_in_place(commands)?;
after.world_state().warm_display();
Ok(Evaluated(after.model))
}
/// Run `commands` one after another, each reply or error in turn (what they evaluate stays
@@ -193,6 +195,10 @@ impl Ahead {
}
}
/// The model a session copy evaluated ahead ([`Ahead::evaluate`]).
#[derive(Clone, Debug)]
pub struct Evaluated(solidcraft_doc::Model);
/// What some commands would make, evaluated on a scratch copy of a session
/// ([`Session::preview`]). The session itself is not changed.
#[derive(Clone, Debug)]
@@ -267,6 +273,9 @@ pub struct Session {
/// Recent body renames: the design before and after each, and the names, so undo and redo
/// follow them without evaluating either.
body_renames: Vec<BodyRename>,
/// Bodies' contact meshes (shared with scratch copies): a joint driven again meshes no body
/// that did not change.
contact_meshes: std::sync::Arc<std::sync::Mutex<cmd::motion::contact_sweep::ContactMeshes>>,
/// Features copied with `timeline.copy` (pasted by `timeline.paste`).
pub clipboard: Vec<solidcraft_doc::Feature>,
/// Sketch geometry copied with `sketch.copy` (pasted by `sketch.paste`).
@@ -421,6 +430,7 @@ impl Session {
removed_later_cache: Default::default(),
clearance_cache: Default::default(),
body_renames: Vec::new(),
contact_meshes: Default::default(),
clipboard: Vec::new(),
sketch_clipboard: None,
section: None,
@@ -953,6 +963,7 @@ impl Session {
removed_later_cache: Default::default(),
clearance_cache: self.clearance_cache.clone(),
body_renames: Vec::new(),
contact_meshes: self.contact_meshes.clone(),
clipboard: self.clipboard.clone(),
sketch_clipboard: self.sketch_clipboard.clone(),
section: None,
@@ -978,6 +989,19 @@ impl Session {
self.revision = old.max(previous_revision) + 1;
}
/// Offer what a copy of this session evaluated ahead for the commands about to run
/// ([`Ahead::evaluate`]): until [`Session::unprime`], evaluating takes each feature result
/// whose inputs match instead of computing it again (a design changed meanwhile matches
/// less, or nothing, and is evaluated as usual).
pub fn prime(&mut self, evaluated: &Evaluated) {
self.model.prime(&evaluated.0);
}
/// Stop offering what [`Session::prime`] offered.
pub fn unprime(&mut self) {
self.model.unprime();
}
/// Commit commands evaluated on a scratch copy without losing this session's file,
/// saved state, view state, checkpoints or earlier undo steps.
pub fn accept_computed(&mut self, mut computed: Session) {
+80
View File
@@ -3242,6 +3242,39 @@ fn computed_import_preserves_file_and_earlier_undo_history() {
assert!(s.model.state().bodies.is_empty());
}
/// Commands evaluated ahead (off the window thread) and then run for real take what was
/// evaluated: on a design whose states are past the shared cache's budget the second run
/// evaluated everything again, so an off-thread command cost twice its time.
#[test]
fn commands_evaluated_ahead_are_not_evaluated_again() {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "w", "expression": "40 mm"}));
run(&mut s, "solid.box", json!({"length": "w", "width": 30, "height": 10, "body_name": "Block"}));
run(&mut s, "solid.chamfer", json!({"edges": [[10, 0, 10]], "distance": 1}));
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 20, "corner": [2, 2, 0], "operation": "cut"}));
let commands = vec![("parameters.change".to_string(), json!({"name": "w", "expression": "50 mm"}))];
let evaluated = s.ahead().evaluate(&commands).unwrap();
// The shared cache dropped those results (as it does past its budget).
s.model.cache = Default::default();
let mut unprimed = s.scratch();
s.prime(&evaluated);
let r = run(&mut s, "parameters.change", json!({"name": "w", "expression": "50 mm"}));
s.unprime();
assert_eq!((&r["recomputed"], &r["restored"]), (&json!(0), &json!(3)), "every feature taken from the evaluation ahead: {r}");
let r = run(&mut unprimed, "parameters.change", json!({"name": "w", "expression": "50 mm"}));
assert_eq!(r["recomputed"], 3, "without it, all evaluated again: {r}");
for (a, b) in s.model.state().bodies.iter().zip(&unprimed.model.state().bodies) {
assert_eq!(a.mesh().bounds(), b.mesh().bounds());
}
// Inputs that differ from those evaluated ahead are evaluated, as usual.
s.model.cache = Default::default();
s.prime(&evaluated);
let r = run(&mut s, "parameters.change", json!({"name": "w", "expression": "60 mm"}));
s.unprime();
assert_eq!(r["recomputed"], 3, "{r}");
assert!((s.model.state().body("Block").unwrap().mesh().bounds().max.x - 60.0).abs() < 1e-9);
}
#[test]
fn adopting_a_computed_component_keeps_its_warm_world_geometry() {
let mut s = Session::default();
@@ -3954,3 +3987,50 @@ fn cheap_commands_on_thousands_of_bodies_evaluate_nothing() {
}
assert_eq!(meshes(&s), shown, "every body keeps its mesh");
}
/// #3397: a new, empty component changes nothing a feature reads, so creating one recomputes
/// nothing, the first component of a design included: not a targeted cut, a Combine, a To
/// Object extrude or a sketch on a face without a construction certificate (a legacy face
/// plane, which reads every placed body), all of which read placements.
#[test]
fn creating_a_component_recomputes_nothing() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 30, "width": 30, "height": 10, "operation": "new", "body_name": "Block"}));
run(&mut s, "solid.cylinder", json!({"diameter": 8, "height": 12, "base": [15, 15, -1], "operation": "cut", "targets": ["Block"]}));
run(&mut s, "solid.box", json!({"length": 10, "width": 30, "height": 20, "corner": [0, 0, 10], "operation": "new", "body_name": "Tab"}));
run(&mut s, "solid.combine", json!({"target": "Block", "tools": ["Tab"], "operation": "join"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Post sketch"}));
run(&mut s, "sketch.circle.center", json!({"center": [25, 25], "radius": 2}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "Post sketch", "to": [25, 25, 10], "operation": "new", "body_name": "Post"}));
// A plate whose top face loses its certificate: a patterned cut, a pad joined flush with
// the top, then one more cut. Extruding that face makes a sketch on a legacy face plane.
run(&mut s, "solid.box", json!({"length": 100, "width": 100, "height": 10, "corner": [200, 0, 0], "operation": "new", "body_name": "Plate"}));
run(
&mut s,
"solid.cylinder",
json!({"diameter": 4, "height": 12, "base": [210, 10, -1], "operation": "cut", "targets": ["Plate"], "name": "Pilot"}),
);
run(
&mut s,
"solid.pattern.rectangular",
json!({"features": ["Pilot"], "dir1": [1, 0, 0], "count1": 4, "spacing1": 20, "dir2": [0, 1, 0], "count2": 4, "spacing2": 20}),
);
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": "5 mm", "name": "Pad sketch"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [260, 60], "p1": [295, 95]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "Pad sketch", "distance": 5, "operation": "join", "targets": ["Plate"]}));
run(&mut s, "solid.box", json!({"length": 5, "width": 100, "height": 12, "corner": [199, 0, -1], "operation": "cut", "targets": ["Plate"]}));
run(&mut s, "solid.extrude", json!({"face": [240, 40, 10], "distance": 1, "operation": "join", "name": "Face layer"}));
let legacy_face = s
.doc
.features
.iter()
.any(|f| matches!(&f.kind, solidcraft_doc::FeatureKind::Sketch { plane: solidcraft_doc::PlaneRef::Face { anchor: None, .. }, .. }));
assert!(legacy_face, "the face extrude's sketch is on a legacy face plane");
for name in ["First", "Second"] {
run(&mut s, "component.create", json!({"name": name}));
assert_eq!(s.model.last_recomputed, 0, "creating {name} recomputed a feature");
run(&mut s, "component.activate", json!({"component": "root"}));
}
}
+25
View File
@@ -22,6 +22,31 @@ fn imported_box() -> Session {
Session::new(doc)
}
/// An edit keeps the design as it was for undo, and an imported mesh's triangles are shared
/// with that copy, not copied (a scan's are hundreds of megabytes): after an edit and its undo
/// the document holds the same arrays. They still save as plain arrays.
#[test]
fn edits_share_an_imported_meshs_triangles_with_undo() {
let mut s = imported_box();
let arrays = |s: &Session| match &s.doc.features[0].kind {
FeatureKind::MeshImport { meshes, .. } => (meshes[0].positions.clone(), meshes[0].triangles.clone()),
other => panic!("{other:?}"),
};
let (positions, triangles) = arrays(&s);
run(&mut s, "parameters.add", json!({"name":"size","expression":"2","unit":""}));
let (p, t) = arrays(&s);
assert!(std::sync::Arc::ptr_eq(&p, &positions) && std::sync::Arc::ptr_eq(&t, &triangles), "shared after an edit");
run(&mut s, "edit.undo", json!({}));
let (p, t) = arrays(&s);
assert!(std::sync::Arc::ptr_eq(&p, &positions) && std::sync::Arc::ptr_eq(&t, &triangles), "shared after undo");
let saved =
serde_json::to_value(MeshData { name: "M".into(), positions: vec![1.5, 2.0, 3.0].into(), triangles: vec![0, 0, 0].into(), color: None })
.unwrap();
assert_eq!(saved, json!({"name":"M","positions":[1.5,2.0,3.0],"triangles":[0,0,0]}));
let back: MeshData = serde_json::from_value(saved).unwrap();
assert_eq!(&back.positions[..], &[1.5, 2.0, 3.0]);
}
#[test]
fn mesh_conversion_is_parametric_undoable_and_editable_after_cold_reopen() {
let mut s = imported_box();
@@ -64,6 +64,25 @@ fn total_volume(s: &mut Session) -> f64 {
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap()
}
#[test]
fn imported_joints_do_not_reuse_deleted_ids() {
let path = frame_file("joint-ids");
let mut s = Session::default();
run(&mut s, "file.insert_step", json!({"path": path}));
let first: Vec<_> = s.doc.assembly.joints.iter().map(|j| j.id).collect();
assert!(!first.is_empty());
for id in &first {
run(&mut s, "joint.delete", json!({"joint": id}));
}
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "file.insert_step", json!({"path": path}));
assert!(!s.doc.assembly.joints.is_empty());
for joint in &s.doc.assembly.joints {
assert!(!first.contains(&joint.id), "import reused deleted joint {}", joint.id);
assert!(joint.id < s.doc.next_id, "import did not reserve joint {}", joint.id);
}
}
/// `up: "y"` turns the file's bodies a quarter turn about X, Y becoming Z: the frame's height
/// lies along Z, and what was its depth now runs towards -Y.
#[test]
+1 -1
View File
@@ -110,7 +110,7 @@ pub fn bounds(s: &Session) -> Aabb3 {
/// [`bounds`], with the browser's Sketches folder shown (`sketches`) or hidden.
pub fn visible_bounds(s: &Session, sketches: bool) -> Aabb3 {
bounds_by(s, sketches, |body| body.mesh().bounds())
bounds_by(s, sketches, |body| body.mesh_bounds())
}
/// [`visible_bounds`] without meshing anything: a body not meshed yet counts with its outline
+1
View File
@@ -27,6 +27,7 @@ pub mod network;
mod plane;
mod profile;
pub mod section;
pub mod simplify;
pub mod spline_space;
pub mod tess;
pub mod tspline;
+551
View File
@@ -0,0 +1,551 @@
//! Triangle mesh simplification, for drawing a large mesh coarser where it is small on screen
//! (display levels of detail). Display only: nothing measured, exported or picked reads a
//! simplified mesh.
//!
//! Edge collapse ordered by quadric error (Garland and Heckbert, "Surface Simplification Using
//! Quadric Error Metrics", SIGGRAPH 1997): each vertex carries the sum of the squared-distance
//! quadrics of the planes of its faces; the edge whose two ends merge at the point of least
//! summed squared distance goes first. A collapse that would fold a face over (its normal turning
//! by more than about 80°) or join two sheets of the surface (the ends sharing more neighbours
//! than the faces between them) is skipped. Open boundaries carry extra planes across them, so
//! they keep their place.
//!
//! [`levels`] simplifies once, keeping a copy of the mesh each time it reaches the next target
//! triangle count, with its error measured: how far its vertices and the centres of its triangles
//! lie from the original surface, at most.
use std::cmp::Ordering;
use std::collections::BinaryHeap;
use crate::Vec3;
/// Most triangles [`levels`] simplifies (more: no levels).
pub const MAX_TRIANGLES: usize = 50_000_000;
/// How much more an open boundary's planes count than a face's.
const BOUNDARY_WEIGHT: f64 = 10.0;
/// A collapse may turn a face's normal by at most this (cosine): about 80°.
const MIN_TURN_COS: f64 = 0.2;
/// A simplified copy of a mesh.
#[derive(Clone, Debug, Default)]
pub struct Level {
pub positions: Vec<Vec3>,
pub triangles: Vec<[u32; 3]>,
/// How far (mm) its vertices and triangle centres lie from the original surface, at most.
pub error: f64,
}
/// The mesh (`positions`, and `triangles` indexing them; corners at the same position are one
/// vertex) simplified to each of `targets` triangles in turn (largest first), one [`Level`] per
/// target reached. Simplification stops early when no edge can collapse any more; the levels made
/// by then are returned, but none that is not at least a fifth smaller than the one before it.
/// Triangles with an index out of range or a point not finite are left out; an empty or oversized
/// mesh ([`MAX_TRIANGLES`]) has no levels.
pub fn levels(positions: &[Vec3], triangles: &[[u32; 3]], targets: &[usize]) -> Vec<Level> {
if triangles.is_empty() || triangles.len() > MAX_TRIANGLES {
return Vec::new();
}
let Some(mut m) = Collapser::new(positions, triangles) else { return Vec::new() };
let original = Surface::new(&m.pos, &m.tris);
let mut out: Vec<Level> = Vec::new();
let mut previous = m.live_triangles;
for &target in targets {
if target >= previous {
continue;
}
m.collapse_to(target);
if m.live_triangles * 5 > previous * 4 {
break;
}
previous = m.live_triangles;
let (mut level, seeds) = m.level();
// Never less than a coarser level's own error.
level.error = original.deviation(&level, &seeds).max(out.last().map_or(0.0, |l| l.error));
out.push(level);
}
out
}
/// The original surface, for measuring how far a level strays from it.
struct Surface {
tris: Vec<[Vec3; 3]>,
bvh: crate::Bvh,
}
impl Surface {
fn new(pos: &[[f64; 3]], tris: &[[u32; 3]]) -> Surface {
let tris: Vec<[Vec3; 3]> = tris.iter().map(|t| t.map(|i| pos.get(i as usize).map_or(Vec3::ZERO, |p| Vec3::new(p[0], p[1], p[2])))).collect();
let boxes: Vec<crate::Aabb3> = tris
.iter()
.map(|t| {
let mut b = crate::Aabb3::EMPTY;
t.iter().for_each(|p| b.add(*p));
b
})
.collect();
Surface { bvh: crate::Bvh::new(&boxes), tris }
}
fn to_triangle(&self, p: Vec3, t: u32) -> f64 {
self.tris.get(t as usize).map_or(f64::INFINITY, |[a, b, c]| crate::compare::closest_on_triangle(p, *a, *b, *c).dist(p))
}
/// How far `p` lies from the surface, when that is more than `beyond`; otherwise
/// `beyond` or less (the search stops at the first triangle within `beyond`). `near`: an
/// original triangle `p` is likely near (where it came from), so the search starts close.
fn distance_beyond(&self, p: Vec3, beyond: f64, near: u32) -> f64 {
let best = std::cell::Cell::new(self.to_triangle(p, near));
let gap = |b: &crate::Aabb3| {
let d = |v: f64, lo: f64, hi: f64| (lo - v).max(v - hi).max(0.0);
Vec3::new(d(p.x, b.min.x, b.max.x), d(p.y, b.min.y, b.max.y), d(p.z, b.min.z, b.max.z)).len()
};
self.bvh.visit(|b| best.get() > beyond && gap(b) < best.get(), |t| best.set(best.get().min(self.to_triangle(p, t))));
best.get()
}
/// How far `level`'s vertices and triangle centres lie from the surface, at most (`seeds`:
/// an original triangle each vertex came from).
fn deviation(&self, level: &Level, seeds: &[u32]) -> f64 {
let at = |i: u32| level.positions.get(i as usize).copied().unwrap_or(Vec3::ZERO);
let seed = |i: u32| seeds.get(i as usize).copied().unwrap_or(0);
let vertices = level.positions.iter().copied().zip(seeds.iter().copied());
let centres = level.triangles.iter().map(|t| {
let [a, b, c] = t.map(at);
((a + b + c) * (1.0 / 3.0), seed(t[0]))
});
let worst = vertices.chain(centres).fold(0.0, |worst: f64, (p, near)| worst.max(self.distance_beyond(p, worst, near)));
if worst.is_finite() { worst } else { 0.0 }
}
}
/// A symmetric 4 × 4 quadric: a a, a b, a c, a d, b b, b c, b d, c c, c d, d d.
#[derive(Clone, Copy, Default)]
struct Quadric([f64; 10]);
impl Quadric {
/// The squared distance to the plane n · p + d = 0 (`n` unit), times `w`.
fn plane(n: [f64; 3], d: f64, w: f64) -> Quadric {
let [a, b, c] = n;
Quadric([a * a * w, a * b * w, a * c * w, a * d * w, b * b * w, b * c * w, b * d * w, c * c * w, c * d * w, d * d * w])
}
fn add(&mut self, o: &Quadric) {
for (x, y) in self.0.iter_mut().zip(o.0) {
*x += y;
}
}
fn sum(&self, o: &Quadric) -> Quadric {
let mut q = *self;
q.add(o);
q
}
/// The summed squared distance at `p`.
fn at(&self, p: [f64; 3]) -> f64 {
let [aa, ab, ac, ad, bb, bc, bd, cc, cd, dd] = self.0;
let [x, y, z] = p;
(aa * x * x
+ 2.0 * ab * x * y
+ 2.0 * ac * x * z
+ 2.0 * ad * x
+ bb * y * y
+ 2.0 * bc * y * z
+ 2.0 * bd * y
+ cc * z * z
+ 2.0 * cd * z
+ dd)
.max(0.0)
}
/// The point of least summed squared distance, when it is well defined.
fn best(&self) -> Option<[f64; 3]> {
let [aa, ab, ac, ad, bb, bc, bd, cc, cd, _] = self.0;
let det = aa * (bb * cc - bc * bc) - ab * (ab * cc - bc * ac) + ac * (ab * bc - bb * ac);
let scale = (aa.abs() + bb.abs() + cc.abs()).powi(3);
if !(det.abs() > 1e-12 * scale) {
return None;
}
let (r0, r1, r2) = (-ad, -bd, -cd);
let x = (r0 * (bb * cc - bc * bc) - ab * (r1 * cc - bc * r2) + ac * (r1 * bc - bb * r2)) / det;
let y = (aa * (r1 * cc - bc * r2) - r0 * (ab * cc - bc * ac) + ac * (ab * r2 - r1 * ac)) / det;
let z = (aa * (bb * r2 - r1 * bc) - ab * (ab * r2 - r1 * ac) + r0 * (ab * bc - bb * ac)) / det;
[x, y, z].iter().all(|v| v.is_finite()).then_some([x, y, z])
}
}
fn sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
}
fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
fn unit(a: [f64; 3]) -> Option<[f64; 3]> {
let l = dot(a, a).sqrt();
(l > 0.0 && l.is_finite()).then(|| [a[0] / l, a[1] / l, a[2] / l])
}
/// A candidate collapse: its cost, its ends, and their versions when it was costed (a later
/// change to either end makes it stale).
#[derive(Clone, Copy)]
struct Candidate {
cost: f64,
u: u32,
v: u32,
stamps: (u32, u32),
}
impl PartialEq for Candidate {
fn eq(&self, o: &Self) -> bool {
self.cmp(o) == Ordering::Equal
}
}
impl Eq for Candidate {}
impl PartialOrd for Candidate {
fn partial_cmp(&self, o: &Self) -> Option<Ordering> {
Some(self.cmp(o))
}
}
impl Ord for Candidate {
/// The cheapest first (the heap pops the greatest), ties by the ends, so the result never
/// depends on the heap's order.
fn cmp(&self, o: &Self) -> Ordering {
o.cost.total_cmp(&self.cost).then_with(|| (o.u, o.v).cmp(&(self.u, self.v)))
}
}
/// A quick hasher for welding by position (the keys are coordinates' bits).
#[derive(Default)]
struct BitsHasher(u64);
impl std::hash::Hasher for BitsHasher {
fn finish(&self) -> u64 {
self.0
}
fn write(&mut self, bytes: &[u8]) {
for b in bytes {
self.write_u64(u64::from(*b));
}
}
fn write_u64(&mut self, x: u64) {
self.0 = (self.0.rotate_left(5) ^ x).wrapping_mul(0x51_7c_c1_b7_27_22_0a_95);
}
}
/// The lists a collapse works with, kept between collapses.
#[derive(Default)]
struct Scratch {
at_u: Vec<u32>,
at_v: Vec<u32>,
shared: Vec<u32>,
nu: Vec<u32>,
nv: Vec<u32>,
}
impl Scratch {
fn clear(&mut self) {
for l in [&mut self.at_u, &mut self.at_v, &mut self.shared, &mut self.nu, &mut self.nv] {
l.clear();
}
}
}
struct Collapser {
pos: Vec<[f64; 3]>,
quadric: Vec<Quadric>,
alive: Vec<bool>,
stamp: Vec<u32>,
tris: Vec<[u32; 3]>,
tri_alive: Vec<bool>,
/// The triangles at each vertex (dead ones are dropped lazily).
around: Vec<Vec<u32>>,
/// An original triangle at each vertex (where it came from), to start a search near it.
seed: Vec<u32>,
/// Lists a collapse reuses.
scratch: Scratch,
heap: BinaryHeap<Candidate>,
live_triangles: usize,
}
impl Collapser {
fn new(positions: &[Vec3], triangles: &[[u32; 3]]) -> Option<Collapser> {
// Weld corners at the same position.
let mut index: std::collections::HashMap<[u64; 3], u32, std::hash::BuildHasherDefault<BitsHasher>> =
std::collections::HashMap::with_capacity_and_hasher(positions.len() / 2, Default::default());
let mut remap: Vec<Option<u32>> = Vec::with_capacity(positions.len());
let mut pos: Vec<[f64; 3]> = Vec::new();
for p in positions {
if !p.is_finite() {
remap.push(None);
continue;
}
let next = u32::try_from(pos.len()).ok()?;
let key = [(p.x + 0.0).to_bits(), (p.y + 0.0).to_bits(), (p.z + 0.0).to_bits()];
remap.push(Some(*index.entry(key).or_insert_with(|| {
pos.push([p.x, p.y, p.z]);
next
})));
}
drop(index);
let mut tris: Vec<[u32; 3]> = Vec::with_capacity(triangles.len());
for t in triangles {
let r = |i: u32| remap.get(i as usize).copied().flatten();
let (Some(a), Some(b), Some(c)) = (r(t[0]), r(t[1]), r(t[2])) else { continue };
if a != b && b != c && c != a {
tris.push([a, b, c]);
}
}
if tris.is_empty() {
return None;
}
let n = pos.len();
let mut quadric = vec![Quadric::default(); n];
let mut around: Vec<Vec<u32>> = vec![Vec::new(); n];
let mut sides: Vec<(u32, u32, u32)> = Vec::with_capacity(tris.len() * 3);
for (ti, t) in tris.iter().enumerate() {
let ti = u32::try_from(ti).ok()?;
let [a, b, c] = t.map(|i| pos.get(i as usize).copied().unwrap_or_default());
if let Some(nrm) = unit(cross(sub(b, a), sub(c, a))) {
let q = Quadric::plane(nrm, -dot(nrm, a), 1.0);
for &i in t {
if let Some(x) = quadric.get_mut(i as usize) {
x.add(&q);
}
}
}
for (k, &i) in t.iter().enumerate() {
if let Some(list) = around.get_mut(i as usize) {
list.push(ti);
}
let j = t[(k + 1) % 3];
sides.push((i.min(j), i.max(j), ti));
}
}
// Open boundaries (sides of one triangle only): a plane across each, weighted, so the
// boundary keeps its place.
sides.sort_unstable();
let mut k = 0;
while k < sides.len() {
let (a, b, t) = sides[k];
let mut e = k + 1;
while e < sides.len() && sides[e].0 == a && sides[e].1 == b {
e += 1;
}
if e - k == 1
&& let (Some(pa), Some(pb), Some(tri)) = (pos.get(a as usize), pos.get(b as usize), tris.get(t as usize))
{
let [p0, p1, p2] = tri.map(|i| pos.get(i as usize).copied().unwrap_or_default());
let face = cross(sub(p1, p0), sub(p2, p0));
if let Some(across) = unit(cross(sub(*pb, *pa), face)) {
let q = Quadric::plane(across, -dot(across, *pa), BOUNDARY_WEIGHT);
for i in [a, b] {
if let Some(x) = quadric.get_mut(i as usize) {
x.add(&q);
}
}
}
}
k = e;
}
let live_triangles = tris.len();
let seed: Vec<u32> = around.iter().map(|list| list.first().copied().unwrap_or(0)).collect();
let mut m = Collapser {
alive: vec![true; n],
stamp: vec![0; n],
tri_alive: vec![true; tris.len()],
heap: BinaryHeap::with_capacity(sides.len() / 2 + 1),
pos,
quadric,
tris,
around,
seed,
scratch: Scratch::default(),
live_triangles,
};
let mut last = None;
for &(a, b, _) in &sides {
if last != Some((a, b)) {
m.offer(a, b);
last = Some((a, b));
}
}
Some(m)
}
/// Where `u` and `v` would merge, and its cost.
fn target(&self, u: u32, v: u32) -> Option<([f64; 3], f64)> {
let (qu, qv) = (self.quadric.get(u as usize)?, self.quadric.get(v as usize)?);
let q = qu.sum(qv);
let (pu, pv) = (*self.pos.get(u as usize)?, *self.pos.get(v as usize)?);
let mid = [(pu[0] + pv[0]) * 0.5, (pu[1] + pv[1]) * 0.5, (pu[2] + pv[2]) * 0.5];
// The best point when it lies near the edge; else the best of its ends and middle.
let reach = dot(sub(pu, pv), sub(pu, pv)) * 4.0;
let best = q.best().filter(|p| dot(sub(*p, mid), sub(*p, mid)) <= reach);
best.into_iter().chain([pu, pv, mid]).map(|p| (p, q.at(p))).min_by(|a, b| a.1.total_cmp(&b.1))
}
fn offer(&mut self, u: u32, v: u32) {
let Some((_, cost)) = self.target(u, v) else { return };
let stamps = (self.stamp.get(u as usize).copied().unwrap_or(0), self.stamp.get(v as usize).copied().unwrap_or(0));
self.heap.push(Candidate { cost, u, v, stamps });
}
/// The live vertices sharing a live triangle with `v`.
fn neighbours(&self, v: u32, out: &mut Vec<u32>) {
out.clear();
out.extend(
self.around
.get(v as usize)
.into_iter()
.flatten()
.filter(|t| self.tri_alive.get(**t as usize).copied().unwrap_or(false))
.filter_map(|t| self.tris.get(*t as usize))
.flat_map(|t| t.iter().copied())
.filter(|i| *i != v),
);
out.sort_unstable();
out.dedup();
}
fn collapse_to(&mut self, target: usize) {
while self.live_triangles > target {
let Some(c) = self.heap.pop() else { return };
let current = |i: u32| self.alive.get(i as usize).copied().unwrap_or(false);
let stamp = |i: u32| self.stamp.get(i as usize).copied().unwrap_or(u32::MAX);
if !current(c.u) || !current(c.v) || (stamp(c.u), stamp(c.v)) != c.stamps {
continue;
}
self.collapse(c.u, c.v);
}
}
/// Merge `v` into `u` at their best point, unless that would fold a face over or join two
/// sheets of the surface.
fn collapse(&mut self, u: u32, v: u32) {
let Some((p, _)) = self.target(u, v) else { return };
let live = |t: &u32| self.tri_alive.get(*t as usize).copied().unwrap_or(false);
let mut s = std::mem::take(&mut self.scratch);
s.clear();
s.at_v.extend(self.around.get(v as usize).into_iter().flatten().copied().filter(live));
s.at_u.extend(self.around.get(u as usize).into_iter().flatten().copied().filter(live));
s.shared.extend(s.at_v.iter().copied().filter(|t| self.tris.get(*t as usize).is_some_and(|tri| tri.contains(&u))));
let done = self.collapse_with(u, v, p, &mut s);
self.scratch = s;
if done {
self.reoffer(u);
}
}
/// [`Collapser::collapse`] with its lists in `s`: whether it collapsed.
fn collapse_with(&mut self, u: u32, v: u32, p: [f64; 3], s: &mut Scratch) -> bool {
if s.shared.is_empty() {
return false;
}
// Two sheets meeting only at these ends would be joined.
self.neighbours(u, &mut s.nu);
self.neighbours(v, &mut s.nv);
let common = s.nu.iter().filter(|x| s.nv.binary_search(x).is_ok()).count();
if common > s.shared.len() {
return false;
}
// No face folds over.
for &t in s.at_u.iter().chain(&s.at_v).filter(|t| !s.shared.contains(t)) {
let Some(tri) = self.tris.get(t as usize) else { continue };
let corners = tri.map(|i| self.pos.get(i as usize).copied().unwrap_or_default());
let moved = tri.map(|i| if i == u || i == v { p } else { self.pos.get(i as usize).copied().unwrap_or_default() });
let before = cross(sub(corners[1], corners[0]), sub(corners[2], corners[0]));
let after = cross(sub(moved[1], moved[0]), sub(moved[2], moved[0]));
let (lb, la) = (dot(before, before).sqrt(), dot(after, after).sqrt());
if !(la > 0.0) || (lb > 0.0 && dot(before, after) < MIN_TURN_COS * lb * la) {
return false;
}
}
for t in &s.shared {
if let Some(a) = self.tri_alive.get_mut(*t as usize) {
*a = false;
}
self.live_triangles = self.live_triangles.saturating_sub(1);
}
let mut list = self.around.get_mut(u as usize).map(std::mem::take).unwrap_or_default();
let tri_alive = &self.tri_alive;
list.retain(|t| tri_alive.get(*t as usize).copied().unwrap_or(false));
for &t in s.at_v.iter().filter(|t| !s.shared.contains(t)) {
if let Some(tri) = self.tris.get_mut(t as usize) {
for i in tri.iter_mut() {
if *i == v {
*i = u;
}
}
list.push(t);
}
}
if let Some(slot) = self.around.get_mut(u as usize) {
*slot = list;
}
if let Some(slot) = self.around.get_mut(v as usize) {
*slot = Vec::new();
}
let qv = self.quadric.get(v as usize).copied().unwrap_or_default();
if let Some(q) = self.quadric.get_mut(u as usize) {
q.add(&qv);
}
if let Some(x) = self.pos.get_mut(u as usize) {
*x = p;
}
if let Some(a) = self.alive.get_mut(v as usize) {
*a = false;
}
for i in [u, v] {
if let Some(stamp) = self.stamp.get_mut(i as usize) {
*stamp = stamp.wrapping_add(1);
}
}
true
}
/// Cost again every edge at `u` (it moved).
fn reoffer(&mut self, u: u32) {
let mut around = std::mem::take(&mut self.scratch.nu);
self.neighbours(u, &mut around);
for &w in &around {
self.offer(u.min(w), u.max(w));
}
self.scratch.nu = around;
}
/// The mesh as it is now, and an original triangle each of its vertices came from.
fn level(&self) -> (Level, Vec<u32>) {
let mut index: Vec<Option<u32>> = vec![None; self.pos.len()];
let mut out = Level::default();
let mut seeds = Vec::new();
out.triangles.reserve(self.live_triangles);
for (t, alive) in self.tris.iter().zip(&self.tri_alive) {
if !alive {
continue;
}
let mut tri = [0u32; 3];
for (k, &i) in t.iter().enumerate() {
let Some(slot) = index.get_mut(i as usize) else { continue };
tri[k] = *slot.get_or_insert_with(|| {
let p = self.pos.get(i as usize).copied().unwrap_or_default();
out.positions.push(Vec3::new(p[0], p[1], p[2]));
seeds.push(self.seed.get(i as usize).copied().unwrap_or(0));
u32::try_from(out.positions.len() - 1).unwrap_or(u32::MAX)
});
}
out.triangles.push(tri);
}
(out, seeds)
}
}
#[cfg(test)]
#[path = "simplify_tests.rs"]
mod tests;
+158
View File
@@ -0,0 +1,158 @@
use super::*;
/// A sphere of radius `r` as a scan reads it: `n` × 2`n` quads, each triangle's corners its own
/// points (the same position repeated where triangles meet).
fn sphere(r: f64, n: u32) -> (Vec<Vec3>, Vec<[u32; 3]>) {
let at = |i: u32, j: u32| {
let (t, p) = (std::f64::consts::PI * f64::from(i) / f64::from(n), std::f64::consts::TAU * f64::from(j) / f64::from(2 * n));
let (st, ct, sp, cp) = (t.sin(), t.cos(), p.sin(), p.cos());
// The poles exactly, and the seam the same as the start, so corners weld.
let (st, sp, cp) = (if i == 0 || i == n { 0.0 } else { st }, if j == 2 * n { 0.0 } else { sp }, if j == 2 * n { 1.0 } else { cp });
Vec3::new(r * st * cp, r * st * sp, r * ct)
};
let (mut pos, mut tris) = (Vec::new(), Vec::new());
let mut tri = |a: Vec3, b: Vec3, c: Vec3| {
let k = pos.len() as u32;
pos.extend([a, b, c]);
tris.push([k, k + 1, k + 2]);
};
for i in 0..n {
for j in 0..2 * n {
let (a, b, c, d) = (at(i, j), at(i + 1, j), at(i + 1, j + 1), at(i, j + 1));
if i > 0 {
tri(a, c, d);
}
if i + 1 < n {
tri(a, b, c);
}
}
}
(pos, tris)
}
fn volume(l: &Level) -> f64 {
l.triangles
.iter()
.map(|t| {
let [a, b, c] = t.map(|i| l.positions[i as usize]);
a.dot(b.cross(c)) / 6.0
})
.sum()
}
/// Each side of a closed surface is shared by exactly two triangles, once each way.
fn closed(l: &Level) -> bool {
let mut sides: Vec<(u32, u32)> = l.triangles.iter().flat_map(|t| [(t[0], t[1]), (t[1], t[2]), (t[2], t[0])]).collect();
sides.sort_unstable();
let n = sides.len();
sides.dedup();
sides.len() == n && sides.iter().all(|(a, b)| sides.binary_search(&(*b, *a)).is_ok())
}
/// A finely meshed sphere comes down to each target, closed, with its volume and shape kept
/// within the error each level reports, and the errors grow as the levels coarsen.
#[test]
fn a_sphere_comes_down_to_each_target_within_its_error() {
let (pos, tris) = sphere(50.0, 100);
let full = tris.len();
let made = levels(&pos, &tris, &[full / 4, full / 16, full / 64]);
assert_eq!(made.len(), 3);
let exact = 4.0 / 3.0 * std::f64::consts::PI * 50.0f64.powi(3);
let mut last = 0.0;
for (l, target) in made.iter().zip([full / 4, full / 16, full / 64]) {
assert!(l.triangles.len() <= target, "{} > {target}", l.triangles.len());
assert!(closed(l), "closed and consistently wound");
assert!(l.error >= last, "the error grows: {} after {last}", l.error);
last = l.error;
// Each point within the error of the sphere (its faces' planes lie within their sag of it).
let sag = 50.0 * (1.0 - (std::f64::consts::PI / 100.0).cos());
for p in &l.positions {
assert!((p.len() - 50.0).abs() <= l.error + sag + 1e-9, "{} from the centre, error {}", p.len(), l.error);
}
assert!((volume(l) - exact).abs() < exact * 0.02, "volume {} vs {exact}", volume(l));
// A 600-triangle sphere of radius 50 bows in by about 0.4 at its triangles' centres at
// best; finer levels a fraction of that.
assert!(l.error < 1.0, "error {}", l.error);
}
// The same mesh simplifies the same way.
let again = levels(&pos, &tris, &[full / 4, full / 16, full / 64]);
assert!(made.iter().zip(&again).all(|(a, b)| a.triangles == b.triangles && a.positions == b.positions));
}
/// A flat sheet comes down to a few triangles with no error, and its open outline stays put.
#[test]
fn a_flat_sheet_keeps_its_outline() {
let n = 40u32;
let at = |i: u32, j: u32| Vec3::new(f64::from(i), f64::from(j) * 0.5, 0.0);
let (mut pos, mut tris) = (Vec::new(), Vec::new());
for i in 0..n {
for j in 0..n {
for t in [[at(i, j), at(i + 1, j), at(i + 1, j + 1)], [at(i, j), at(i + 1, j + 1), at(i, j + 1)]] {
let k = pos.len() as u32;
pos.extend(t);
tris.push([k, k + 1, k + 2]);
}
}
}
let made = levels(&pos, &tris, &[tris.len() / 16]);
let l = made.first().unwrap();
assert!(l.triangles.len() <= tris.len() / 16);
assert!(l.error < 1e-6, "flat: {}", l.error);
assert!(l.positions.iter().all(|p| p.z.abs() < 1e-9));
let b = l.positions.iter().fold(crate::Aabb3::EMPTY, |mut b, p| {
b.add(*p);
b
});
assert!(b.min.dist(Vec3::ZERO) < 1e-9 && b.max.dist(Vec3::new(40.0, 20.0, 0.0)) < 1e-9, "{b:?}");
let area: f64 = l
.triangles
.iter()
.map(|t| {
let [a, b, c] = t.map(|i| l.positions[i as usize]);
(b - a).cross(c - a).len() / 2.0
})
.sum();
assert!((area - 800.0).abs() < 1e-6, "the sheet is still whole: {area}");
}
/// Input from a file is hostile: points that are not finite, indices out of range, triangles
/// with a repeated corner and empty meshes are left out, never a panic.
#[test]
fn hostile_meshes_never_panic() {
let p = |x: f64, y: f64| Vec3::new(x, y, 0.0);
assert!(levels(&[], &[], &[1]).is_empty());
assert!(levels(&[p(0.0, 0.0)], &[[0, 0, 0]], &[1]).is_empty());
assert!(levels(&[p(f64::NAN, 0.0), p(1.0, 0.0), p(0.0, 1.0)], &[[0, 1, 2]], &[0]).is_empty());
assert!(levels(&[p(0.0, 0.0), p(1.0, 0.0), p(0.0, 1.0)], &[[0, 1, 99]], &[0]).is_empty());
let (pos, mut tris) = sphere(5.0, 12);
tris.push([0, 1, u32::MAX]);
tris.push([2, 2, 3]);
let made = levels(&pos, &tris, &[usize::MAX, 100, 0]);
assert!(made.iter().all(|l| l.triangles.iter().flatten().all(|i| (*i as usize) < l.positions.len())));
}
/// Timing, not a check (`SOLIDCRAFT_BENCH_STL=part.stl cargo test --release -p solidcraft-geom
/// --lib simplify_timing -- --ignored --nocapture`): the levels of a binary STL.
#[test]
#[ignore]
fn simplify_timing() {
let Some(path) = std::env::var_os("SOLIDCRAFT_BENCH_STL") else { return };
let bytes = std::fs::read(path).unwrap();
let n = u32::from_le_bytes(bytes[80..84].try_into().unwrap()) as usize;
let f = |o: usize| f64::from(f32::from_le_bytes(bytes[o..o + 4].try_into().unwrap()));
let (mut pos, mut tris) = (Vec::with_capacity(n * 3), Vec::with_capacity(n));
for t in 0..n {
let o = 84 + t * 50 + 12;
let k = pos.len() as u32;
for v in 0..3 {
pos.push(Vec3::new(f(o + v * 12), f(o + v * 12 + 4), f(o + v * 12 + 8)));
}
tris.push([k, k + 1, k + 2]);
}
let started = std::time::Instant::now();
let made = levels(&pos, &tris, &[n / 4, n / 16, n / 64]);
eprintln!("{n} triangles: levels in {:.2} s", started.elapsed().as_secs_f64());
for l in &made {
eprintln!(" {} triangles, error {:.3} mm", l.triangles.len(), l.error);
}
}
-1
View File
@@ -21,7 +21,6 @@ use wgpu::util::DeviceExt;
pub mod capture;
mod pbr;
pub mod pick;
#[cfg(not(target_arch = "wasm32"))]
pub mod select;
pub mod view;
-503
View File
@@ -1,503 +0,0 @@
//! The pick buffer: which model edges are visible at each pixel of the viewport, for picking on a
//! large model without testing every edge. Surfaces are drawn into the depth buffer only; then
//! every edge segment is drawn as a line carrying its id, depth tested against them (lifted
//! toward the eye by the picker's slack, the depth by which an edge may lie behind a face and
//! still count as visible) and written into an `R32Uint` id texture (0: no edge).
//!
//! The pass is drawn once per view (camera, viewport size, model), and only small tiles around
//! the cursor are read back, so a pointer moving over a still view reads a few kilobytes and
//! draws nothing. Read-backs arrive a frame or two later ([`PickResults`]); until a tile is in,
//! the picker answers on the CPU. The ids are hints: the picker still runs its exact tests on
//! the edges a tile names.
use std::sync::atomic::{AtomicU8, Ordering};
use std::sync::{Arc, Mutex};
use wgpu::util::DeviceExt;
/// The side of a read-back tile, in pixels (a multiple of 64, so rows need no padding).
pub const TILE: u32 = 128;
/// Most tiles kept from one view.
const KEEP: usize = 6;
/// Most read-backs in flight.
const IN_FLIGHT: usize = 3;
/// Bytes per surface vertex (position) and per edge vertex (position, id).
const FACE_VERTEX: u64 = 12;
const EDGE_VERTEX: u64 = 16;
const UNIFORM_SIZE: u64 = 96;
const SHADER: &str = r#"
struct U {
vp: mat4x4<f32>,
eye: vec4<f32>, // eye position, w = 1 for perspective
back: vec4<f32>, // toward the eye (orthographic), w = how far edges are lifted toward it
};
@group(0) @binding(0) var<uniform> u: U;
@vertex
fn vs_face(@location(0) p: vec3<f32>) -> @builtin(position) vec4<f32> {
return u.vp * vec4<f32>(p, 1.0);
}
@fragment
fn fs_face() -> @location(0) u32 {
return 0u;
}
struct EOut {
@builtin(position) pos: vec4<f32>,
@location(0) @interpolate(flat) id: u32,
};
@vertex
fn vs_edge(@location(0) p: vec3<f32>, @location(1) id: u32) -> EOut {
var toward = u.back.xyz;
if (u.eye.w > 0.5) {
let v = u.eye.xyz - p;
let l = length(v);
if (l > 0.0) {
toward = v / l;
}
}
var o: EOut;
o.pos = u.vp * vec4<f32>(p + toward * u.back.w, 1.0);
o.id = id;
return o;
}
@fragment
fn fs_edge(i: EOut) -> @location(0) u32 {
return i.id;
}
"#;
/// What the pass draws: surfaces (for depth) and edge segments with their ids (from 1).
#[derive(Default, Clone)]
pub struct PickScene {
faces: Vec<u8>,
edges: Vec<u8>,
}
impl PickScene {
pub fn triangle(&mut self, t: [[f32; 3]; 3]) {
for p in t {
for c in p {
self.faces.extend_from_slice(&c.to_le_bytes());
}
}
}
/// Segment `a`–`b` of the edge `id` (0 is no edge: not drawn).
pub fn segment(&mut self, a: [f32; 3], b: [f32; 3], id: u32) {
if id == 0 {
return;
}
for p in [a, b] {
for c in p {
self.edges.extend_from_slice(&c.to_le_bytes());
}
self.edges.extend_from_slice(&id.to_le_bytes());
}
}
pub fn is_empty(&self) -> bool {
self.edges.is_empty()
}
}
/// The view the pass is drawn for.
#[derive(Clone, Copy, Debug)]
pub struct PickView {
/// Changes whenever anything below or the scene changes.
pub key: u64,
/// Column-major view-projection (as [`crate::view::Frame::view_proj`]).
pub view_proj: [[f32; 4]; 4],
/// Eye position, and 1 for a perspective camera (0: orthographic).
pub eye: [f32; 4],
/// Direction toward the eye (orthographic).
pub back: [f32; 3],
/// How far edges are lifted toward the eye (model units).
pub lift: f32,
/// The viewport in physical pixels.
pub size: [u32; 2],
}
/// Edge ids of a block of pixels of one view.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PickTile {
pub key: u64,
pub origin: [u32; 2],
pub size: [u32; 2],
/// Row-major, `size[0] * size[1]`.
pub ids: Vec<u32>,
}
impl PickTile {
/// Whether the tile is of view `key` and holds every pixel of `[x0, y0, x1, y1]` (inclusive)
/// that lies inside a viewport of `view` pixels.
pub fn covers(&self, key: u64, view: [u32; 2], [x0, y0, x1, y1]: [i64; 4]) -> bool {
let clamp = |v: i64, n: u32| v.clamp(0, i64::from(n.max(1)) - 1);
let (x0, x1, y0, y1) = (clamp(x0, view[0]), clamp(x1, view[0]), clamp(y0, view[1]), clamp(y1, view[1]));
let [ox, oy] = self.origin.map(i64::from);
let [w, h] = self.size.map(i64::from);
self.key == key && x0 >= ox && y0 >= oy && x1 < ox + w && y1 < oy + h
}
/// The ids within `[x0, y0, x1, y1]` (inclusive; outside the tile: none), each once.
pub fn ids_in(&self, [x0, y0, x1, y1]: [i64; 4]) -> Vec<u32> {
let [ox, oy] = self.origin.map(i64::from);
let [w, h] = self.size.map(i64::from);
let mut out = Vec::new();
for y in y0.max(oy)..=y1.min(oy + h - 1) {
for x in x0.max(ox)..=x1.min(ox + w - 1) {
if let Some(&id) = self.ids.get(((y - oy) * w + (x - ox)) as usize)
&& id != 0
{
out.push(id);
}
}
}
out.sort_unstable();
out.dedup();
out
}
}
/// The tiles read back so far (of the latest view only), newest last.
#[derive(Default, Debug)]
pub struct PickResults {
pub tiles: Vec<PickTile>,
}
impl PickResults {
/// The newest tile of view `key` covering `window`.
pub fn tile(&self, key: u64, view: [u32; 2], window: [i64; 4]) -> Option<&PickTile> {
self.tiles.iter().rev().find(|t| t.covers(key, view, window))
}
}
pub type PickOut = Arc<Mutex<PickResults>>;
/// The tile (origin and size) around pixel `centre` of a viewport of `view` pixels.
pub fn tile_around(centre: [u32; 2], view: [u32; 2]) -> ([u32; 2], [u32; 2]) {
let size = [TILE.min(view[0]), TILE.min(view[1])];
let origin = [0, 1].map(|k| centre[k].saturating_sub(size[k] / 2).min(view[k].saturating_sub(size[k])));
(origin, size)
}
const NEW: u8 = 0;
const MAPPING: u8 = 1;
const READY: u8 = 2;
const FAILED: u8 = 3;
/// A tile being copied out of the id texture.
struct Pending {
key: u64,
origin: [u32; 2],
size: [u32; 2],
bytes_per_row: u32,
buffer: wgpu::Buffer,
/// The frame whose commands copy it (it is mapped once they are submitted).
frame: u64,
state: Arc<AtomicU8>,
}
struct Target {
size: [u32; 2],
ids: wgpu::Texture,
ids_view: wgpu::TextureView,
depth_view: wgpu::TextureView,
}
struct Uploaded {
key: u64,
faces: Option<(wgpu::Buffer, u32)>,
edges: Option<(wgpu::Buffer, u32)>,
}
/// The pass's pipelines, its textures and the read-backs in flight, on one device.
pub struct PickBuffer {
face: wgpu::RenderPipeline,
edge: wgpu::RenderPipeline,
uniform: wgpu::Buffer,
bind: wgpu::BindGroup,
scene: Option<Uploaded>,
target: Option<Target>,
/// The view drawn into the textures.
drawn: Option<u64>,
pending: Vec<Pending>,
frame: u64,
}
const DEPTH: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float;
const IDS: wgpu::TextureFormat = wgpu::TextureFormat::R32Uint;
impl PickBuffer {
pub fn new(device: &wgpu::Device) -> PickBuffer {
let module =
device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("sc_pick"), source: wgpu::ShaderSource::Wgsl(SHADER.into()) });
let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("sc_pick"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None },
count: None,
}],
});
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("sc_pick"),
bind_group_layouts: &[Some(&bgl)],
immediate_size: 0,
});
let uniform = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("sc_pick_u"),
size: UNIFORM_SIZE,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("sc_pick"),
layout: &bgl,
entries: &[wgpu::BindGroupEntry { binding: 0, resource: uniform.as_entire_binding() }],
});
const FA: [wgpu::VertexAttribute; 1] = wgpu::vertex_attr_array![0 => Float32x3];
const EA: [wgpu::VertexAttribute; 2] = wgpu::vertex_attr_array![0 => Float32x3, 1 => Uint32];
let pipeline = |label: &str,
vs: &str,
fs: &str,
stride: u64,
attributes: &'static [wgpu::VertexAttribute],
topology: wgpu::PrimitiveTopology,
write: bool,
mask: wgpu::ColorWrites| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(&layout),
vertex: wgpu::VertexState {
module: &module,
entry_point: Some(vs),
buffers: &[Some(wgpu::VertexBufferLayout { array_stride: stride, step_mode: wgpu::VertexStepMode::Vertex, attributes })],
compilation_options: Default::default(),
},
primitive: wgpu::PrimitiveState { topology, ..Default::default() },
depth_stencil: Some(wgpu::DepthStencilState {
format: DEPTH,
depth_write_enabled: Some(write),
depth_compare: Some(wgpu::CompareFunction::LessEqual),
stencil: Default::default(),
bias: Default::default(),
}),
multisample: wgpu::MultisampleState::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some(fs),
targets: &[Some(wgpu::ColorTargetState { format: IDS, blend: None, write_mask: mask })],
compilation_options: Default::default(),
}),
multiview_mask: None,
cache: None,
})
};
let face =
pipeline("sc_pick_face", "vs_face", "fs_face", FACE_VERTEX, &FA, wgpu::PrimitiveTopology::TriangleList, true, wgpu::ColorWrites::empty());
let edge = pipeline("sc_pick_edge", "vs_edge", "fs_edge", EDGE_VERTEX, &EA, wgpu::PrimitiveTopology::LineList, false, wgpu::ColorWrites::ALL);
PickBuffer { face, edge, uniform, bind, scene: None, target: None, drawn: None, pending: Vec::new(), frame: 0 }
}
/// Before the frame is submitted: collect the tiles read back, then draw `view` of `scene`
/// (`scene_key` names it; it is uploaded when that changes) unless it is drawn already, and
/// start reading back the tile around pixel `centre` unless it is in or on its way. The tiles
/// land in `out` a frame or two later; keep frames coming until then.
#[allow(clippy::too_many_arguments)]
pub fn prepare(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
scene_key: u64,
scene: &PickScene,
view: &PickView,
centre: Option<[u32; 2]>,
out: &PickOut,
) {
self.frame += 1;
self.collect(device, out);
let [w, h] = view.size;
let max = device.limits().max_texture_dimension_2d;
if w == 0 || h == 0 || w > max || h > max {
return;
}
if self.scene.as_ref().is_none_or(|s| s.key != scene_key) {
let limit = device.limits().max_buffer_size;
let upload = |label: &str, bytes: &[u8], stride: u64| {
let n = bytes.len() as u64 / stride;
(n > 0 && (bytes.len() as u64) <= limit && n <= u64::from(u32::MAX)).then(|| {
(
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some(label),
contents: bytes,
usage: wgpu::BufferUsages::VERTEX,
}),
n as u32,
)
})
};
self.scene = Some(Uploaded {
key: scene_key,
faces: upload("sc_pick_faces", &scene.faces, FACE_VERTEX),
edges: upload("sc_pick_edges", &scene.edges, EDGE_VERTEX),
});
self.drawn = None;
}
if self.target.as_ref().is_none_or(|t| t.size != view.size) {
let texture = |label: &str, format, usage| {
device.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage,
view_formats: &[],
})
};
let ids = texture("sc_pick_ids", IDS, wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC);
let depth = texture("sc_pick_depth", DEPTH, wgpu::TextureUsages::RENDER_ATTACHMENT);
let (ids_view, depth_view) = (ids.create_view(&Default::default()), depth.create_view(&Default::default()));
self.target = Some(Target { size: view.size, ids, ids_view, depth_view });
self.drawn = None;
}
let (Some(target), Some(scene)) = (&self.target, &self.scene) else { return };
if self.drawn != Some(view.key) {
queue.write_buffer(&self.uniform, 0, &uniform_bytes(view));
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("sc_pick"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &target.ids_view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), store: wgpu::StoreOp::Store },
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &target.depth_view,
depth_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Clear(1.0), store: wgpu::StoreOp::Discard }),
stencil_ops: None,
}),
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
});
pass.set_bind_group(0, &self.bind, &[]);
for (pipeline, part) in [(&self.face, &scene.faces), (&self.edge, &scene.edges)] {
if let Some((buffer, n)) = part {
pass.set_pipeline(pipeline);
pass.set_vertex_buffer(0, buffer.slice(..));
pass.draw(0..*n, 0..1);
}
}
drop(pass);
self.drawn = Some(view.key);
// Tiles of other views no longer answer.
self.pending.retain(|p| p.key == view.key);
if let Ok(mut o) = out.lock() {
o.tiles.retain(|t| t.key == view.key);
}
}
let Some(centre) = centre else { return };
let (origin, size) = tile_around(centre, view.size);
let known = out.lock().is_ok_and(|o| o.tiles.iter().any(|t| t.key == view.key && t.origin == origin && t.size == size))
|| self.pending.iter().any(|p| p.key == view.key && p.origin == origin && p.size == size);
if known || self.pending.len() >= IN_FLIGHT {
return;
}
let bytes_per_row = (size[0] * 4).div_ceil(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT) * wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("sc_pick_tile"),
size: u64::from(bytes_per_row) * u64::from(size[1]),
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
encoder.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: &target.ids,
mip_level: 0,
origin: wgpu::Origin3d { x: origin[0], y: origin[1], z: 0 },
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &buffer,
layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(bytes_per_row), rows_per_image: Some(size[1]) },
},
wgpu::Extent3d { width: size[0], height: size[1], depth_or_array_layers: 1 },
);
self.pending.push(Pending { key: view.key, origin, size, bytes_per_row, buffer, frame: self.frame, state: Arc::new(AtomicU8::new(NEW)) });
}
/// Whether read-backs are still on their way (keep frames coming).
pub fn waiting(&self) -> bool {
!self.pending.is_empty()
}
/// Maps the copies earlier frames submitted and moves the mapped ones into `out`.
fn collect(&mut self, device: &wgpu::Device, out: &PickOut) {
let frame = self.frame;
for p in &self.pending {
if p.frame < frame && p.state.load(Ordering::Acquire) == NEW {
p.state.store(MAPPING, Ordering::Release);
let state = p.state.clone();
p.buffer.slice(..).map_async(wgpu::MapMode::Read, move |r| state.store(if r.is_ok() { READY } else { FAILED }, Ordering::Release));
}
}
if self.pending.iter().any(|p| p.state.load(Ordering::Acquire) == MAPPING) {
let _ = device.poll(wgpu::PollType::Poll);
}
let mut done = Vec::new();
self.pending.retain(|p| match p.state.load(Ordering::Acquire) {
READY => {
if let Ok(bytes) = p.buffer.slice(..).get_mapped_range() {
let [w, h] = p.size;
let mut ids = Vec::with_capacity(w as usize * h as usize);
for row in bytes.chunks(p.bytes_per_row as usize).take(h as usize) {
ids.extend(row.as_chunks::<4>().0.iter().take(w as usize).map(|c| u32::from_le_bytes(*c)));
}
done.push(PickTile { key: p.key, origin: p.origin, size: p.size, ids });
}
p.buffer.unmap();
false
}
FAILED => false,
_ => true,
});
if !done.is_empty()
&& let Ok(mut o) = out.lock()
{
for t in done {
if self.drawn == Some(t.key) && t.ids.len() == t.size[0] as usize * t.size[1] as usize {
o.tiles.retain(|x| x.key == t.key);
o.tiles.push(t);
}
}
let excess = o.tiles.len().saturating_sub(KEEP);
o.tiles.drain(..excess);
}
}
}
fn uniform_bytes(v: &PickView) -> Vec<u8> {
let mut b = Vec::with_capacity(UNIFORM_SIZE as usize);
for col in v.view_proj {
for x in col {
b.extend_from_slice(&x.to_le_bytes());
}
}
for x in v.eye.into_iter().chain(v.back).chain([v.lift]) {
b.extend_from_slice(&x.to_le_bytes());
}
b
}
#[cfg(test)]
#[path = "pick_tests.rs"]
mod tests;
-79
View File
@@ -1,79 +0,0 @@
use super::*;
/// Looking down −z at x, y in −10..10 (orthographic): depth 0 at z = 50, 1 at z = −50.
const VIEW: [[f32; 4]; 4] = [[0.1, 0.0, 0.0, 0.0], [0.0, 0.1, 0.0, 0.0], [0.0, 0.0, -0.01, 0.0], [0.0, 0.0, 0.5, 1.0]];
const SIZE: u32 = 64;
/// The pixel of model point (x, y).
fn px(x: f32, y: f32) -> [i64; 2] {
[((x / 10.0 + 1.0) * 0.5 * SIZE as f32) as i64, ((1.0 - y / 10.0) * 0.5 * SIZE as f32) as i64]
}
/// A square face at z = 0 (x, y in −5..5), and three edges: 1 on it, 2 well behind it, 3 behind
/// it by less than the lift.
fn scene() -> PickScene {
let mut s = PickScene::default();
let (a, b, c, d) = ([-5.0, -5.0, 0.0], [5.0, -5.0, 0.0], [5.0, 5.0, 0.0], [-5.0, 5.0, 0.0]);
s.triangle([a, b, c]);
s.triangle([a, c, d]);
s.segment([-8.0, 0.0, 0.0], [8.0, 0.0, 0.0], 1);
s.segment([2.0, -8.0, -3.0], [2.0, 8.0, -3.0], 2);
s.segment([-2.0, -8.0, -0.2], [-2.0, 8.0, -0.2], 3);
s
}
#[test]
fn the_pick_buffer_names_the_edges_seen_at_each_pixel() {
let (_, device, queue) = match crate::open_headless() {
Ok(d) => d,
Err(e) => {
assert!(std::env::var_os("SOLIDCRAFT_REQUIRE_GPU").is_none(), "a GPU is required here: {e}");
eprintln!("skipping: {e}");
return;
}
};
let mut pb = PickBuffer::new(&device);
let out = PickOut::default();
let view = PickView { key: 7, view_proj: VIEW, eye: [0.0, 0.0, 100.0, 0.0], back: [0.0, 0.0, 1.0], lift: 0.5, size: [SIZE, SIZE] };
let scene = scene();
let all = [0, 0, SIZE as i64 - 1, SIZE as i64 - 1];
for _ in 0..8 {
let mut encoder = device.create_command_encoder(&Default::default());
pb.prepare(&device, &queue, &mut encoder, 1, &scene, &view, Some([SIZE / 2, SIZE / 2]), &out);
queue.submit([encoder.finish()]);
let _ = device.poll(wgpu::PollType::wait_indefinitely());
if out.lock().unwrap().tile(7, [SIZE, SIZE], all).is_some() {
break;
}
}
let results = out.lock().unwrap();
let tile = results.tile(7, [SIZE, SIZE], all).expect("the tile is read back within a few frames");
let at = |x: f32, y: f32| {
let [px, py] = px(x, y);
tile.ids_in([px - 1, py - 1, px + 1, py + 1])
};
assert_eq!(at(0.0, 0.0), vec![1], "the edge on the face");
assert_eq!(at(2.0, 3.0), Vec::<u32>::new(), "the edge behind the face is hidden");
assert_eq!(at(2.0, 7.5), vec![2], "and seen where the face does not cover it");
assert_eq!(at(-2.0, 3.0), vec![3], "an edge behind the face by less than the lift counts as seen");
assert_eq!(at(3.5, 3.5), Vec::<u32>::new(), "the face itself names no edge");
assert!(results.tile(8, [SIZE, SIZE], all).is_none(), "another view's tile does not answer");
drop(results);
assert!(!pb.waiting());
}
#[test]
fn tiles_cover_the_window_around_the_cursor_inside_the_view() {
assert_eq!(tile_around([10, 10], [1000, 800]), ([0, 0], [TILE, TILE]));
assert_eq!(tile_around([500, 400], [1000, 800]), ([500 - TILE / 2, 400 - TILE / 2], [TILE, TILE]));
assert_eq!(tile_around([999, 799], [1000, 800]), ([1000 - TILE, 800 - TILE], [TILE, TILE]));
assert_eq!(tile_around([30, 20], [50, 40]), ([0, 0], [50, 40]));
let t = PickTile { key: 1, origin: [100, 100], size: [TILE, TILE], ids: vec![0; (TILE * TILE) as usize] };
assert!(t.covers(1, [1000, 800], [110, 110, 120, 120]));
assert!(!t.covers(1, [1000, 800], [90, 110, 120, 120]));
assert!(!t.covers(2, [1000, 800], [110, 110, 120, 120]));
// A window past the view's border needs only the pixels inside it.
let corner = PickTile { key: 1, origin: [0, 0], size: [TILE, TILE], ids: vec![5; (TILE * TILE) as usize] };
assert!(corner.covers(1, [1000, 800], [-8, -8, 8, 8]));
assert_eq!(corner.ids_in([-8, -8, 8, 8]), vec![5]);
}
+1049 -65
View File
File diff suppressed because it is too large Load Diff
+20 -8
View File
@@ -6,7 +6,7 @@
//! bits for portable integer texel reads. GLSL cannot load from a depth texture, and float
//! colour attachments are not available on every GL adapter.
use super::{Batches, color_layout, depth_compare, far_depth, planned_layout, tri_layout};
use super::{Batches, TriSource, color_layout, depth_compare, far_depth, instance_layout, planned_layout, shared_layout, tri_layout};
pub(super) struct Resources {
pub layout: wgpu::BindGroupLayout,
@@ -16,6 +16,8 @@ pub(super) struct Resources {
size: [u32; 2],
pipeline: wgpu::RenderPipeline,
planned_pipeline: wgpu::RenderPipeline,
instanced_pipeline: wgpu::RenderPipeline,
instanced_flat_pipeline: wgpu::RenderPipeline,
}
fn texture(device: &wgpu::Device, layout: &wgpu::BindGroupLayout, size: [u32; 2]) -> (wgpu::TextureView, wgpu::TextureView, wgpu::BindGroup) {
@@ -64,14 +66,19 @@ impl Resources {
});
let built = [Some(tri_layout())];
let evaluated = [Some(planned_layout()), Some(color_layout())];
let pipeline = |planned: bool| {
let placed = [Some(shared_layout()), Some(instance_layout())];
let pipeline = |source: TriSource| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("sc_ao_prepass"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module,
entry_point: Some("vs_tri"),
buffers: if planned { &evaluated } else { &built },
entry_point: Some(source.entry()),
buffers: match source {
TriSource::Built => &built[..],
TriSource::Planned => &evaluated[..],
TriSource::Instanced | TriSource::InstancedFlat => &placed[..],
},
compilation_options: Default::default(),
},
primitive: wgpu::PrimitiveState::default(),
@@ -85,7 +92,7 @@ impl Resources {
multisample: wgpu::MultisampleState::default(),
fragment: Some(wgpu::FragmentState {
module,
entry_point: Some("fs_ao_depth"),
entry_point: Some(&source.fragment("fs_ao_depth")),
targets: &[Some(wgpu::ColorTargetState {
format: wgpu::TextureFormat::R32Uint,
blend: None,
@@ -97,10 +104,12 @@ impl Resources {
cache: None,
})
};
let planned_pipeline = pipeline(true);
let pipeline = pipeline(false);
let planned_pipeline = pipeline(TriSource::Planned);
let instanced_pipeline = pipeline(TriSource::Instanced);
let instanced_flat_pipeline = pipeline(TriSource::InstancedFlat);
let pipeline = pipeline(TriSource::Built);
let (depth, values, bind) = texture(device, &layout, [1, 1]);
Self { layout, bind, depth, values, size: [1, 1], pipeline, planned_pipeline }
Self { layout, bind, depth, values, size: [1, 1], pipeline, planned_pipeline, instanced_pipeline, instanced_flat_pipeline }
}
pub fn prepare(
@@ -159,6 +168,9 @@ impl Resources {
pass.set_index_buffer(draw.mesh.indices.slice(..), wgpu::IndexFormat::Uint32);
pass.draw_indexed(0..draw.mesh.index_count, 0, 0..1);
}
for draw in batch.instanced.iter().filter(|d| d.look == super::InstanceLook::Opaque) {
draw.draw(&mut pass, &self.instanced_pipeline, &self.instanced_flat_pipeline);
}
}
}
}
+284
View File
@@ -0,0 +1,284 @@
//! Weighted blended order-independent transparency (McGuire and Bavoil, "Weighted Blended
//! Order-Independent Transparency", JCGT 2013) for the model's see-through surfaces: glass (an
//! appearance's opacity) and faded context, instanced or not.
//!
//! Each see-through fragment adds its colour, weighted by its alpha and its depth, into an
//! accumulation target, and multiplies its transparency into a revealage target; a full-screen
//! pass then lays the weighted average over what is behind it, by how much is covered. Nothing is
//! sorted, on the CPU or the GPU, however many see-through triangles there are. The targets share
//! the frame's depth (tested, not written), so the opaque model hides what lies behind it.
use super::{TriSource, depth_compare, instance_layout, shared_layout, tri_layout};
/// The accumulated colour (premultiplied, weighted) and alpha.
const ACCUM: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
/// How much of what is behind still shows (1: all of it).
const REVEAL: wgpu::TextureFormat = wgpu::TextureFormat::R8Unorm;
const COMPOSITE: &str = r#"
@group(0) @binding(0) var accum_t: texture_2d<f32>;
@group(0) @binding(1) var reveal_t: texture_2d<f32>;
@vertex
fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
let x = f32((i << 1u) & 2u);
let y = f32(i & 2u);
return vec4<f32>(x * 2.0 - 1.0, 1.0 - y * 2.0, 0.0, 1.0);
}
@fragment
fn fs(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
let p = vec2<i32>(pos.xy);
let reveal = textureLoad(reveal_t, p, 0).r;
let accum = textureLoad(accum_t, p, 0);
if (reveal >= 1.0) { discard; }
return vec4<f32>(accum.rgb / max(accum.a, 1e-5), 1.0 - reveal);
}
"#;
/// The pipelines, made once per target kind.
pub(super) struct Pipelines {
/// Built triangles ([`tri_layout`]), and instanced meshes smooth and flat.
pub built: wgpu::RenderPipeline,
pub instanced: wgpu::RenderPipeline,
pub instanced_flat: wgpu::RenderPipeline,
composite: wgpu::RenderPipeline,
bgl: wgpu::BindGroupLayout,
}
/// The targets, kept while the frame's size stays.
pub(super) struct Targets {
pub size: [u32; 2],
accum: wgpu::TextureView,
accum_resolve: Option<wgpu::TextureView>,
reveal: wgpu::TextureView,
reveal_resolve: Option<wgpu::TextureView>,
bind: wgpu::BindGroup,
}
impl Pipelines {
/// The pipelines for frames of `format` with `depth` and `samples`, using the viewport's
/// shader `module` and uniform `layout`; `None` where the device cannot blend into a float
/// target (the model's see-through surfaces are then sorted as before).
pub fn new(
device: &wgpu::Device,
module: &wgpu::ShaderModule,
layout: &wgpu::PipelineLayout,
format: wgpu::TextureFormat,
depth: Option<wgpu::TextureFormat>,
samples: u32,
) -> Option<Pipelines> {
let scopes = [wgpu::ErrorFilter::OutOfMemory, wgpu::ErrorFilter::Internal, wgpu::ErrorFilter::Validation].map(|f| device.push_error_scope(f));
let reversed = super::reversed_depth(depth);
let samples = samples.max(1);
let multisample = wgpu::MultisampleState { count: samples, mask: !0, alpha_to_coverage_enabled: false };
let add =
wgpu::BlendComponent { src_factor: wgpu::BlendFactor::One, dst_factor: wgpu::BlendFactor::One, operation: wgpu::BlendOperation::Add };
let keep = wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::Zero,
dst_factor: wgpu::BlendFactor::OneMinusSrc,
operation: wgpu::BlendOperation::Add,
};
let targets = [
Some(wgpu::ColorTargetState {
format: ACCUM,
blend: Some(wgpu::BlendState { color: add, alpha: add }),
write_mask: wgpu::ColorWrites::ALL,
}),
Some(wgpu::ColorTargetState {
format: REVEAL,
blend: Some(wgpu::BlendState { color: keep, alpha: keep }),
write_mask: wgpu::ColorWrites::RED,
}),
];
let built_buffers = [Some(tri_layout())];
let placed = [Some(shared_layout()), Some(instance_layout())];
let pipeline = |label: &str, source: TriSource| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(layout),
vertex: wgpu::VertexState {
module,
entry_point: Some(source.entry()),
buffers: if source == TriSource::Built { &built_buffers[..] } else { &placed[..] },
compilation_options: Default::default(),
},
primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, ..Default::default() },
depth_stencil: depth.map(|format| wgpu::DepthStencilState {
format,
depth_write_enabled: Some(false),
depth_compare: Some(depth_compare(reversed, wgpu::CompareFunction::LessEqual)),
stencil: Default::default(),
bias: Default::default(),
}),
multisample,
fragment: Some(wgpu::FragmentState {
module,
entry_point: Some(&source.fragment("fs_oit")),
targets: &targets,
compilation_options: Default::default(),
}),
multiview_mask: None,
cache: None,
})
};
let built = pipeline("sc_oit", TriSource::Built);
let instanced = pipeline("sc_oit_instanced", TriSource::Instanced);
let instanced_flat = pipeline("sc_oit_instanced_flat", TriSource::InstancedFlat);
let texture = |binding| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: false },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
};
let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("sc_oit"), entries: &[texture(0), texture(1)] });
let composite_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("sc_oit_composite"),
bind_group_layouts: &[Some(&bgl)],
immediate_size: 0,
});
let composite_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("sc_oit_composite"),
source: wgpu::ShaderSource::Wgsl(COMPOSITE.into()),
});
// Over the frame (premultiplied over transparent black): the average colour by how much
// is covered.
let over = wgpu::BlendState {
color: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::SrcAlpha,
dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
operation: wgpu::BlendOperation::Add,
},
alpha: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::One,
dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
operation: wgpu::BlendOperation::Add,
},
};
let composite = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("sc_oit_composite"),
layout: Some(&composite_layout),
vertex: wgpu::VertexState { module: &composite_module, entry_point: Some("vs"), buffers: &[], compilation_options: Default::default() },
primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, ..Default::default() },
depth_stencil: None,
multisample,
fragment: Some(wgpu::FragmentState {
module: &composite_module,
entry_point: Some("fs"),
targets: &[Some(wgpu::ColorTargetState { format, blend: Some(over), write_mask: wgpu::ColorWrites::ALL })],
compilation_options: Default::default(),
}),
multiview_mask: None,
cache: None,
});
let mut failed = None;
for scope in scopes.into_iter().rev() {
if let Some(e) = crate::popped(scope.pop()) {
failed.get_or_insert(e.to_string());
}
}
if let Some(e) = failed {
log::info!("see-through surfaces are sorted (no order-independent transparency here: {e})");
return None;
}
Some(Pipelines { built, instanced, instanced_flat, composite, bgl })
}
/// The targets for a frame of `size` pixels at `samples` per pixel.
pub fn targets(&self, device: &wgpu::Device, size: [u32; 2], samples: u32) -> Targets {
let samples = samples.max(1);
let texture = |label: &str, format, samples, usage| {
device
.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: wgpu::Extent3d { width: size[0].max(1), height: size[1].max(1), depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: samples,
dimension: wgpu::TextureDimension::D2,
format,
usage,
view_formats: &[],
})
.create_view(&Default::default())
};
let read = wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING;
let pair = |label: &str, format| {
if samples > 1 {
(texture(label, format, samples, wgpu::TextureUsages::RENDER_ATTACHMENT), Some(texture(label, format, 1, read)))
} else {
(texture(label, format, 1, read), None)
}
};
let (accum, accum_resolve) = pair("sc_oit_accum", ACCUM);
let (reveal, reveal_resolve) = pair("sc_oit_reveal", REVEAL);
let bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("sc_oit"),
layout: &self.bgl,
entries: &[
wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(accum_resolve.as_ref().unwrap_or(&accum)) },
wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(reveal_resolve.as_ref().unwrap_or(&reveal)) },
],
});
Targets { size, accum, accum_resolve, reveal, reveal_resolve, bind }
}
/// The pass that gathers the see-through surfaces (`draw` records them), against `depth`.
pub fn accumulate(
&self,
encoder: &mut wgpu::CommandEncoder,
t: &Targets,
depth: Option<&wgpu::TextureView>,
stencil: bool,
draw: impl FnOnce(&mut wgpu::RenderPass<'static>),
) {
let attach = |view, resolve_target, clear| {
Some(wgpu::RenderPassColorAttachment {
view,
depth_slice: None,
resolve_target,
ops: wgpu::Operations { load: wgpu::LoadOp::Clear(clear), store: wgpu::StoreOp::Store },
})
};
let mut pass = encoder
.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("sc_oit"),
color_attachments: &[
attach(&t.accum, t.accum_resolve.as_ref(), wgpu::Color::TRANSPARENT),
attach(&t.reveal, t.reveal_resolve.as_ref(), wgpu::Color::WHITE),
],
depth_stencil_attachment: depth.map(|view| wgpu::RenderPassDepthStencilAttachment {
view,
depth_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store }),
stencil_ops: stencil.then_some(wgpu::Operations { load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store }),
}),
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
})
.forget_lifetime();
pass.set_viewport(0.0, 0.0, t.size[0] as f32, t.size[1] as f32, 0.0, 1.0);
draw(&mut pass);
}
/// Lay what was gathered over the frame's `color` (resolved into `resolve`).
pub fn composite(&self, encoder: &mut wgpu::CommandEncoder, t: &Targets, color: &wgpu::TextureView, resolve: Option<&wgpu::TextureView>) {
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("sc_oit_composite"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: color,
depth_slice: None,
resolve_target: resolve,
ops: wgpu::Operations { load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store },
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
});
pass.set_pipeline(&self.composite);
pass.set_bind_group(0, &t.bind, &[]);
pass.draw(0..3, 0..1);
}
}
+8 -7
View File
@@ -326,10 +326,10 @@ fn encode_mesh(mesh: &MeshData) -> Result<Vec<u8>> {
bytes.extend_from_slice(MESH_MAGIC);
bytes.extend_from_slice(&(mesh.positions.len() as u64).to_le_bytes());
bytes.extend_from_slice(&(mesh.triangles.len() as u64).to_le_bytes());
for value in &mesh.positions {
for value in mesh.positions.iter() {
bytes.extend_from_slice(&value.to_le_bytes());
}
for value in &mesh.triangles {
for value in mesh.triangles.iter() {
bytes.extend_from_slice(&value.to_le_bytes());
}
Ok(bytes)
@@ -536,7 +536,7 @@ fn read_preview<R: Read + Seek>(reader: &mut PackageReader<R>) -> Result<DesignP
return Err(invalid("overlapping preview bodies"));
}
end = end.checked_add(length).ok_or_else(|| invalid("preview overflow"))?;
let mut mesh = MeshData { name: String::new(), positions: Vec::new(), triangles: Vec::new(), color: None };
let mut mesh = MeshData { name: String::new(), positions: Vec::new().into(), triangles: Vec::new().into(), color: None };
decode_mesh(data.get(offset..end).ok_or_else(|| invalid("truncated preview"))?, &mut mesh)?;
triangles = triangles.saturating_add(mesh.triangles.len() / 3);
if triangles > MAX_PREVIEW_TRIANGLES {
@@ -971,8 +971,8 @@ mod tests {
fn mesh() -> MeshData {
MeshData {
name: "Precise mesh".into(),
positions: vec![0.123456789012345, -0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0],
triangles: vec![0, 1, 2],
positions: vec![0.123456789012345, -0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0].into(),
triangles: vec![0, 1, 2].into(),
color: Some([0.25, 0.5, 0.75]),
}
}
@@ -995,8 +995,9 @@ mod tests {
other.name = "Same triangles, separate appearance".into();
other.color = None;
doc.add_feature(FeatureKind::MeshImport { file: "example.obj".into(), meshes: vec![mesh(), other] }, None).unwrap();
let id = doc.allocate_id().unwrap();
doc.canvases.push(solidcraft_doc::canvas::Canvas {
id: 99,
id,
name: "Reference".into(),
format: "png".into(),
data: solidcraft_doc::canvas::base64_encode(b"original image bytes"),
@@ -1128,7 +1129,7 @@ mod tests {
assert!(read_design_preview(&package(&entries)).is_none());
}
let mut too_large = mesh();
too_large.triangles = vec![0; (MAX_PREVIEW_TRIANGLES + 1) * 3];
too_large.triangles = vec![0; (MAX_PREVIEW_TRIANGLES + 1) * 3].into();
let bytes = write_design_container_with_preview(&doc, DesignCompression::Fast, Some(&DesignPreview { meshes: vec![too_large] })).unwrap();
assert!(read_design_preview(&bytes).is_none());
assert_eq!(crate::read_design(&bytes).unwrap(), doc);
+10 -4
View File
@@ -101,10 +101,16 @@ impl GltfImport {
if let solidcraft_doc::FeatureKind::MeshImport { meshes, .. } = &mut self.feature.kind {
for (m, slot) in meshes.iter_mut().zip(&self.plan.bodies) {
let Some(frame) = slot.and_then(|s| world.get(s).copied().flatten()) else { continue };
for p in m.positions.as_chunks_mut::<3>().0 {
let q = solidcraft_doc::apply_point(&frame, Vec3::new(p[0], p[1], p[2]));
*p = [q.x, q.y, q.z];
}
m.positions = m
.positions
.as_chunks::<3>()
.0
.iter()
.flat_map(|p| {
let q = solidcraft_doc::apply_point(&frame, Vec3::new(p[0], p[1], p[2]));
[q.x, q.y, q.z]
})
.collect();
}
}
self.plan = MeshPlan::default();
+2 -2
View File
@@ -508,8 +508,8 @@ outer
doc.set_param("width", "40", Some("mm"), None).unwrap();
let mesh = solidcraft_doc::MeshData {
name: "Preview".into(),
positions: vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0],
triangles: vec![0, 1, 2],
positions: vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0].into(),
triangles: vec![0, 1, 2].into(),
color: None,
};
let preview = crate::DesignPreview { meshes: vec![mesh] };
+3
View File
@@ -39,6 +39,9 @@ winreg = "0.56"
[dev-dependencies]
# Parse the source guard structurally, including code after inline test modules.
syn = { workspace = true, features = ["visit"] }
# The catalog guard reads every UI source file as Rust tokens; already in the tree through
# crates/derive and the workspace lockfile.
proc-macro2 = { workspace = true }
# Pictures pasted into the agent chat; already in the tree, with the same features, through
# egui-winit's clipboard.
-14
View File
@@ -883,20 +883,6 @@ fn desktop_body(app: &mut SolveApp, ui: &mut egui::Ui) {
}
}
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn login_chip(ui: &mut egui::Ui, login: &str) {
let t = Tokens::get();
let (text, color) = match login {
"signed_in" => (tr("ui.agent_chat_signed_in"), t.accent),
"key" => (tr("ui.agent_chat_key_in_env"), t.accent),
"signed_out" => (tr("ui.agent_chat_not_signed_in"), t.warning),
_ => return,
};
egui::Frame::NONE.stroke(Stroke::new(1.0, color)).corner_radius(8.0).inner_margin(egui::Margin::symmetric(6, 1)).show(ui, |ui| {
ui.label(RichText::new(text).color(color).size(crate::theme::Tokens::get().font_small));
});
}
/// The header's second row: the agent this conversation uses (a compact switcher), the agent's
/// own mode when it has modes, and the state of its login.
#[cfg(not(target_arch = "wasm32"))]
+192 -112
View File
@@ -101,86 +101,143 @@ fn test_line(ui: &mut egui::Ui, test: &Option<&Test>) {
}
}
/// The AI page of Settings.
/// The Assistant page of Settings. Copy stays close to its control; full security details
/// remain accessible on hover, and high-risk permissions always retain a visible warning.
pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) {
let r = ui_kit::checkbox(ui, tr("ui.ai_show_features"), &mut app.preferences.ai_features);
crate::scenario::publish_handle("prefs:ai_features", r.rect.center());
ui_kit::text(ui, tr("ui.ai_show_features_note"), ui_kit::Text::Note("features"));
if cfg!(target_arch = "wasm32") {
ui_kit::text(ui, tr("ui.agent_chat_desktop_only"), ui_kit::Text::Note("desktop"));
return;
}
// Any agent's control requests, the chat's or an outside MCP client's (#1989).
let r = ui_kit::checkbox(ui, tr("ui.agent_off_thread"), &mut app.preferences.agent_off_thread);
crate::scenario::publish_handle("prefs:agent_off_thread", r.rect.center());
ui_kit::text(ui, tr("ui.agent_off_thread_note"), ui_kit::Text::Note("off_thread"));
if !app.preferences.ai_features {
return;
}
let r = ui_kit::checkbox(ui, tr("ui.ai_toolbar_button"), &mut app.preferences.ai_toolbar_button);
crate::scenario::publish_handle("prefs:ai_toolbar_button", r.rect.center());
ui_kit::text(ui, tr("ui.ai_toolbar_button_note"), ui_kit::Text::Note("toolbar"));
#[cfg(not(target_arch = "wasm32"))]
{
if app.agent_chat.detection.is_none() && app.agent_chat.detecting.is_none() {
refresh(app, &ui.ctx().clone());
}
agents_section(app, ui);
}
ui_kit::section(ui, "permissions", tr("ui.ai_permissions"), true, |ui| {
for mode in Mode::ALL {
let r = ui_kit::list_picker(ui, mode.id(), mode.label(), app.preferences.ai_permissions == mode);
crate::scenario::publish_handle(&format!("prefs:ai_mode:{}", mode.id()), r.rect.center());
if r.clicked() {
app.preferences.ai_permissions = mode;
ui_kit::settings_heading(ui, tr("prefs.assistant"), tr("ai.prefs.intro"));
ui_kit::settings_section(ui, "assistant_availability", tr("ai.prefs.availability"), |ui| {
let response =
ui_kit::checkbox(ui, tr("ui.ai_show_features"), &mut app.preferences.ai_features).on_hover_text(tr("ui.ai_show_features_note"));
crate::scenario::publish_handle("prefs:ai_features", response.rect.center());
if !cfg!(target_arch = "wasm32") {
if app.preferences.ai_features {
let response = ui_kit::checkbox(ui, tr("ui.ai_toolbar_button"), &mut app.preferences.ai_toolbar_button)
.on_hover_text(tr("ui.ai_toolbar_button_note"));
crate::scenario::publish_handle("prefs:ai_toolbar_button", response.rect.center());
}
ui_kit::text(
ui,
tr(match mode {
Mode::Ask => "ui.ai_mode_ask_note",
Mode::Edits => "ui.ai_mode_edits_note",
Mode::All => "ui.ai_mode_all_note",
}),
ui_kit::Text::Note(mode.id()),
);
let response = ui_kit::checkbox(ui, tr("ai.prefs.background_commands"), &mut app.preferences.agent_off_thread)
.on_hover_text(tr("ui.agent_off_thread_note"));
crate::scenario::publish_handle("prefs:agent_off_thread", response.rect.center());
}
if cfg!(target_arch = "wasm32") {
ui_kit::text(ui, tr("ui.agent_chat_desktop_only"), ui_kit::Text::Hint("desktop"));
}
});
if !cfg!(target_arch = "wasm32") && app.preferences.ai_features {
#[cfg(not(target_arch = "wasm32"))]
{
if app.agent_chat.detection.is_none() && app.agent_chat.detecting.is_none() {
refresh(app, &ui.ctx().clone());
}
agents_section(app, ui);
}
permissions_section(app, ui);
panel_section(app, ui);
}
pointer_section(app, ui);
}
fn permissions_section(app: &mut SolveApp, ui: &mut egui::Ui) {
ui_kit::settings_section(ui, "permissions", tr("ui.ai_permissions"), |ui| {
let mut selected = Mode::ALL.iter().position(|mode| *mode == app.preferences.ai_permissions).unwrap_or(0);
let choices = [
ui_kit::SettingsChoice {
key: "prefs:ai_mode:ask",
title: tr("ai.prefs.permission_ask"),
summary: tr("ai.prefs.permission_ask_summary"),
detail: tr("ui.ai_mode_ask_note"),
},
ui_kit::SettingsChoice {
key: "prefs:ai_mode:edits",
title: tr("ai.prefs.permission_edits"),
summary: tr("ai.prefs.permission_edits_summary"),
detail: tr("ui.ai_mode_edits_note"),
},
ui_kit::SettingsChoice {
key: "prefs:ai_mode:all",
title: tr("ai.prefs.permission_all"),
summary: tr("ai.prefs.permission_all_summary"),
detail: tr("ui.ai_mode_all_note"),
},
];
if ui_kit::settings_choices(ui, &choices, &mut selected)
&& let Some(mode) = Mode::ALL.get(selected)
{
app.preferences.ai_permissions = *mode;
}
if app.preferences.ai_permissions == Mode::All {
ui_kit::text(ui, tr("ui.ai_mode_all_warning"), ui_kit::Text::Report("permissions_warning", true));
}
ui_kit::text(ui, tr("ui.ai_modes_scope"), ui_kit::Text::Note("scope"));
ui_kit::section(ui, "always_allowed", tr("ui.ai_always_allowed"), true, |ui| {
let remembered: Vec<_> =
app.preferences.agent_always_allow.iter().filter(|(_, v)| !v.is_empty()).map(|(k, v)| (k.clone(), v.clone())).collect();
ui_kit::text(ui, tr("ai.prefs.permission_scope"), ui_kit::Text::Hint("scope")).on_hover_text(tr("ui.ai_modes_scope"));
let remembered: Vec<_> = app
.preferences
.agent_always_allow
.iter()
.filter(|(_, tools)| !tools.is_empty())
.map(|(agent, tools)| (agent.clone(), tools.clone()))
.collect();
ui_kit::section_count(ui, "always_allowed", tr("ui.ai_always_allowed"), false, Some(remembered.len()), |ui| {
if remembered.is_empty() {
ui_kit::text(ui, tr("ui.ai_always_allowed_none"), ui_kit::Text::Note("none"));
ui_kit::text(ui, tr("ai.prefs.no_saved_approvals"), ui_kit::Text::Hint("none")).on_hover_text(tr("ui.ai_always_allowed_none"));
}
let names: std::collections::HashMap<_, _> = agents::entries(app).into_iter().map(|e| (e.id, e.name)).collect();
let names: std::collections::HashMap<_, _> = agents::entries(app).into_iter().map(|entry| (entry.id, entry.name)).collect();
for (agent, tools) in remembered {
ui_kit::label_actions(ui, names.get(&agent).unwrap_or(&agent), |ui| {
if ui_kit::button(ui, tr("ui.ai_reset"), ui_kit::Button::Small).clicked() {
app.preferences.agent_always_allow.remove(&agent);
}
let t = crate::theme::Tokens::get();
ui_kit::settings_block(ui, names.get(&agent).unwrap_or(&agent), t.space_24 * 6.0 + t.space_6, |ui| {
ui.horizontal(|ui| {
if ui_kit::button(ui, tr("ui.ai_reset"), ui_kit::Button::Small).clicked() {
app.preferences.agent_always_allow.remove(&agent);
}
ui_kit::settings_short_note(ui, &tools.join(", "));
});
});
ui_kit::text(ui, &tools.join(", "), ui_kit::Text::Report(&agent, false));
}
});
});
ui_kit::section(ui, "panel", tr("ui.ai_panel"), true, |ui| {
ui_kit::row(ui, tr("ui.agent_chat_side"), |ui| {
let p = &mut app.preferences;
let mut selected = if p.agent_chat_popped { 2 } else { usize::from(p.agent_chat_side != "left") };
let options = [tr("ui.agent_chat_left"), tr("ui.agent_chat_right"), tr("ui.agent_chat_own_window")];
if ui_kit::segmented(ui, &mut selected, &options, Default::default()) {
p.agent_chat_popped = selected == 2;
if selected < 2 {
p.agent_chat_side = if selected == 0 { "left" } else { "right" }.into();
}
fn panel_section(app: &mut SolveApp, ui: &mut egui::Ui) {
let t = crate::theme::Tokens::get();
ui_kit::settings_section(ui, "panel", tr("ui.ai_panel"), |ui| {
ui_kit::settings_form(ui, t.space_24 * 6.0 + t.space_6, |ui| {
ui_kit::row(ui, tr("ui.agent_chat_side"), |ui| {
let p = &mut app.preferences;
let mut selected = if p.agent_chat_popped { 2 } else { usize::from(p.agent_chat_side != "left") };
let options = [tr("ui.agent_chat_left"), tr("ui.agent_chat_right"), tr("ui.agent_chat_own_window")];
if ui_kit::segmented(ui, &mut selected, &options, ui_kit::Segments { intrinsic: true, ..Default::default() }) {
p.agent_chat_popped = selected == 2;
if selected < 2 {
p.agent_chat_side = if selected == 0 { "left" } else { "right" }.into();
}
}
}
});
ui_kit::row(ui, tr("ui.agent_chat_turn_limit"), |ui| {
ui_kit::settings_number(ui, &mut app.preferences.agent_chat_turn_limit, 1..=200, 1.0).on_hover_text(tr("ai.prefs.turn_limit_hint"));
});
});
ui_kit::row(ui, tr("ui.agent_chat_turn_limit"), |ui| {
ui_kit::number_field(ui, &mut app.preferences.agent_chat_turn_limit, 1..=200, 1.0, "");
});
ui_kit::text(ui, tr("ui.agent_chat_privacy_note"), ui_kit::Text::Note("privacy"));
ui_kit::text(ui, tr("ai.prefs.privacy_note"), ui_kit::Text::Hint("privacy"));
});
}
fn pointer_section(app: &mut SolveApp, ui: &mut egui::Ui) {
ui_kit::settings_section(ui, "agent_pointer", tr("ui.agent_cursor"), |ui| {
ui_kit::settings_pair(
ui,
|ui| {
ui_kit::checkbox(ui, tr("ai.prefs.show_pointer"), &mut app.ui.agent_cursor.show)
.on_hover_text(crate::i18n::tr_label("Show the pointer an AI agent moves (#35)"));
ui.add_enabled_ui(app.ui.agent_cursor.show, |ui| {
let speed_label_width = ui_kit::ContentWidth::label(ui, tr("ai.prefs.pointer_speed")) + crate::theme::Tokens::get().space_4;
ui_kit::settings_block(ui, tr("ai.prefs.pointer_speed"), speed_label_width, |ui| {
crate::agent_cursor::speed_slider(ui, &mut app.ui.agent_cursor.speed);
});
ui_kit::checkbox(ui, tr("ai.prefs.follow_camera"), &mut app.ui.agent_cursor.follow_camera)
.on_hover_text(crate::i18n::tr_label("Follow the camera (Look At sketches)"));
});
(app.ui.agent_cursor.show, app.ui.agent_cursor.speed.label(), app.ui.agent_cursor.follow_camera)
},
|ui, (visible, speed, follow)| ui_kit::settings_pointer_preview(ui, visible, crate::i18n::tr_label(&speed), follow),
);
});
}
@@ -195,65 +252,88 @@ fn agents_section(app: &mut SolveApp, ui: &mut egui::Ui) {
let default = if all.iter().any(|e| &e.id == d) { Some(d.clone()) } else { all.first().map(|e| e.id.clone()) };
let mut remove: Option<String> = None;
let mut test: Option<agents::Entry> = None;
ui_kit::section(ui, "agents", tr("ui.ai_agents"), true, |ui| {
let t = crate::theme::Tokens::get();
ui_kit::settings_section(ui, "agents", tr("ui.ai_agents"), |ui| {
if app.agent_chat.detecting.is_some() {
ui_kit::text(ui, tr("ui.agent_chat_detecting"), ui_kit::Text::Note("detecting"));
ui_kit::text(ui, tr("ui.agent_chat_detecting"), ui_kit::Text::Hint("detecting"));
} else if all.is_empty() {
ui_kit::text(ui, tr("ui.agent_chat_none_found"), ui_kit::Text::Note("none"));
ui_kit::empty_box(ui, "assistant_agents", tr("ai.prefs.no_agents"));
}
for e in &all {
ui_kit::section(ui, &e.id, &e.name, true, |ui| {
ui_kit::text(ui, &e.detail(), ui_kit::Text::Report("detail", false));
super::panel::login_chip(ui, e.login);
if !e.found {
ui_kit::text(ui, tr("ui.ai_added"), ui_kit::Text::Note("added"));
for (index, entry) in all.iter().enumerate() {
ui.push_id(&entry.id, |ui| {
if index > 0 {
ui_kit::divider(ui, "agent_divider");
}
ui.horizontal_wrapped(|ui| {
if ui_kit::list_picker(ui, &e.id, tr("ui.ai_default"), default.as_deref() == Some(e.id.as_str())).clicked() {
app.preferences.agent_default = e.id.clone();
}
});
ui_kit::row(ui, tr("ui.ai_permissions"), |ui| {
let cur = app.preferences.agent_permissions.get(&e.id).copied();
let shown = cur.map_or_else(|| tr("ui.ai_mode_global").to_string(), |m| m.label().to_string());
let mut pick: Option<Option<Mode>> = None;
ui_kit::dropdown_menu(ui, ("sc_ai_mode", &e.id), &shown, |ui| {
if ui_kit::list_picker(ui, "global", tr("ui.ai_mode_global"), cur.is_none()).clicked() {
pick = Some(None);
ui_kit::settings_block(ui, &entry.name, t.space_24 * 6.0 + t.space_6, |ui| {
ui.horizontal_wrapped(|ui| {
let is_default = default.as_deref() == Some(entry.id.as_str());
let choice = ui_kit::button(
ui,
tr(if is_default { "ui.ai_default" } else { "ai.prefs.make_default" }),
if is_default { ui_kit::Button::SmallSelected } else { ui_kit::Button::Small },
);
crate::scenario::publish_target(&format!("dialog:list:{}", entry.id), choice.rect);
if choice.clicked() {
app.preferences.agent_default = entry.id.clone();
}
for m in Mode::ALL {
if ui_kit::list_picker(ui, m.id(), m.label(), cur == Some(m)).clicked() {
pick = Some(Some(m));
}
let test_button = ui_kit::button(ui, tr("ui.ai_test"), ui_kit::Button::Small);
crate::scenario::publish_handle(&format!("prefs:ai_test:{}", entry.id), test_button.rect.center());
if test_button.on_hover_text(tr(if entry.kind == Kind::Acp { "ui.ai_test_acp_tip" } else { "ui.ai_test_api_tip" })).clicked()
{
test = Some(entry.clone());
}
if !entry.found && ui_kit::button(ui, tr("ui.ai_remove"), ui_kit::Button::Ghost).clicked() {
remove = Some(entry.id.clone());
}
match entry.login {
"signed_in" => ui_kit::settings_badge(ui, tr("ui.agent_chat_signed_in"), false),
"key" => ui_kit::settings_badge(ui, tr("ui.agent_chat_key_in_env"), false),
"signed_out" => ui_kit::settings_badge(ui, tr("ui.agent_chat_not_signed_in"), true),
_ => {}
}
if !entry.found {
ui_kit::settings_badge(ui, tr("ui.ai_added"), false);
}
});
match pick {
Some(Some(m)) => {
app.preferences.agent_permissions.insert(e.id.clone(), m);
}
Some(None) => {
app.preferences.agent_permissions.remove(&e.id);
}
None => {}
ui_kit::settings_short_note(ui, &entry.detail());
ui_kit::settings_form(ui, t.form_label_width + t.space_16, |ui| {
ui_kit::row(ui, tr("ui.ai_permissions"), |ui| {
let current = app.preferences.agent_permissions.get(&entry.id).copied();
let shown = current.map_or_else(|| tr("ui.ai_mode_global").to_owned(), |mode| mode.label().to_owned());
let mut pick: Option<Option<Mode>> = None;
ui_kit::dropdown_menu(ui, ("sc_ai_mode", &entry.id), &shown, |ui| {
if ui_kit::list_picker(ui, "global", tr("ui.ai_mode_global"), current.is_none()).clicked() {
pick = Some(None);
}
for mode in Mode::ALL {
if ui_kit::list_picker(ui, mode.id(), mode.label(), current == Some(mode)).clicked() {
pick = Some(Some(mode));
}
}
});
match pick {
Some(Some(mode)) => {
app.preferences.agent_permissions.insert(entry.id.clone(), mode);
}
Some(None) => {
app.preferences.agent_permissions.remove(&entry.id);
}
None => {}
}
});
});
if app.preferences.agent_permissions.get(&entry.id) == Some(&Mode::All) {
ui_kit::text(ui, tr("ai.prefs.override_all_warning"), ui_kit::Text::Report("agent_permissions_warning", true))
.on_hover_text(tr("ui.ai_mode_all_warning"));
}
test_line(ui, &app.agent_chat.settings.tests.get(&entry.id));
});
ui.horizontal_wrapped(|ui| {
let b = ui_kit::button(ui, tr("ui.ai_test"), ui_kit::Button::Normal);
crate::scenario::publish_handle(&format!("prefs:ai_test:{}", e.id), b.rect.center());
if b.on_hover_text(tr(if e.kind == Kind::Acp { "ui.ai_test_acp_tip" } else { "ui.ai_test_api_tip" })).clicked() {
test = Some(e.clone());
}
if !e.found && ui_kit::button(ui, tr("ui.ai_remove"), ui_kit::Button::Normal).clicked() {
remove = Some(e.id.clone());
}
});
test_line(ui, &app.agent_chat.settings.tests.get(&e.id));
});
}
ui.horizontal_wrapped(|ui| {
let b = ui_kit::button(ui, tr("ui.ai_add_agent"), ui_kit::Button::Normal);
crate::scenario::publish_handle("prefs:ai_add", b.rect.center());
if b.clicked() {
let button = ui_kit::icon_label_button(ui, tr("ui.ai_add_agent"), "plus", true, false);
crate::scenario::publish_handle("prefs:ai_add", button.rect.center());
if button.clicked() {
open_add(app);
}
if ui_kit::button(ui, tr("ui.agent_chat_refresh"), ui_kit::Button::Enabled(app.agent_chat.detecting.is_none())).clicked() {
+4 -4
View File
@@ -108,12 +108,12 @@ fn the_dialog_holds_its_inputs_while_ok_computes() {
// OK computing, until the test lets it finish.
let (finish, done) = std::sync::mpsc::channel();
let cmds = h.app.dialog.as_ref().and_then(|d| crate::dialogs::apply_commands(&h.app, d).ok()).unwrap();
h.app.applying = Some(crate::dialogs::Applying { done, started: crate::now_ms(), cmds, seen: true });
h.app.applying = Some(crate::dialogs::Applying::computing(done, cmds));
// A click on the square picked would take it out of the profiles.
h.step(&json!({"click": {"world": [10, 10, 10]}})).unwrap();
h.frames(2);
assert_eq!(h.app.dialog.as_ref().map(|d| d.inputs[0].items.clone()), Some(picked), "no pick while computing");
finish.send(()).unwrap();
finish.send(None).unwrap();
until_applied(&mut h);
assert!(h.app.dialog.is_none());
h.step(&json!({"expect": {"bodies": 1, "volume": 13000}})).unwrap();
@@ -129,8 +129,8 @@ fn inputs_changed_while_ok_computes_are_computed_off_the_thread_too() {
let features = h.app.session.doc.features.len();
// OK computing other commands than the dialog's, until the test lets it finish.
let (finish, done) = std::sync::mpsc::channel();
h.app.applying = Some(crate::dialogs::Applying { done, started: crate::now_ms(), cmds: Vec::new(), seen: true });
finish.send(()).unwrap();
h.app.applying = Some(crate::dialogs::Applying::computing(done, Vec::new()));
finish.send(None).unwrap();
h.frames(1);
assert!(h.app.applying.is_some(), "the dialog's own commands are computing");
assert_eq!(h.app.session.doc.features.len(), features, "nothing applied on the window thread");
@@ -75,7 +75,6 @@ const ALLOWED_LITERALS: &[(&str, usize, &str)] = &[
("dialogs_motion.rs", 1, "a white outline"),
("gizmo.rs", 2, "the gizmo's neutral grey ball"),
("icons.rs", 1, "the white tint that carries an icon image's opacity"),
("home.rs", 2, "thumbnail tints"),
("ref_images.rs", 1, "a reference image's opacity tint"),
("theme_files.rs", 1, "a colour read from a theme file, which sets tokens"),
("titlebar.rs", 2, "the window close button's red hover (the system's convention) and white ink"),
+34 -5
View File
@@ -379,6 +379,35 @@ const SHADED: [&str; 2] = ["analysis.draft", "analysis.access"];
/// Tokens no colour vision mode needs to keep apart, and why. A new token fails
/// `every_token_is_measured_or_carries_no_meaning` until it is in a group above or here.
const NO_MEANING: &[(&str, &str)] = &[
("file_dim", "File view secondary text, WCAG guarded in theme tests"),
("file_recovery_well", "decorative recovery icon ground"),
("file_action_height", "layout dimension"),
("file_back_height", "layout dimension"),
("file_recovery_icon", "layout dimension"),
("file_grid_gap", "layout dimension"),
("file_nav_width", "layout dimension"),
("file_nav_height", "layout dimension"),
("file_tile_height", "layout dimension"),
("file_tile_icon", "layout dimension"),
("file_recent_height", "layout dimension"),
("file_sample_image_height", "layout dimension"),
("file_content_inset_x", "layout dimension"),
("file_content_inset_y", "layout dimension"),
("file_section_gap", "layout dimension"),
("font_start_title", "layout dimension"),
("file_background", "File view chrome; paired text contrast is guarded in theme tests"),
("file_surface", "File view chrome; paired text contrast is guarded in theme tests"),
("file_image_tint", "neutral image multiplication; model pixels retain their original colours"),
("file_border", "File view chrome; paired text contrast is guarded in theme tests"),
("file_rail", "File view chrome; paired text contrast is guarded in theme tests"),
("file_rail_ink", "File view chrome; paired text contrast is guarded in theme tests"),
("file_rail_hover", "File view chrome; paired text contrast is guarded in theme tests"),
("file_primary", "File view chrome; paired text contrast is guarded in theme tests"),
("file_primary_ink", "File view chrome; paired text contrast is guarded in theme tests"),
("file_primary_border", "File view chrome; paired text contrast is guarded in theme tests"),
("file_recovery", "File view chrome; paired text contrast is guarded in theme tests"),
("file_recovery_ink", "File view chrome; paired text contrast is guarded in theme tests"),
("file_recovery_border", "File view chrome; paired text contrast is guarded in theme tests"),
("space_2", "layout dimension"),
("space_3", "layout dimension"),
("space_4", "layout dimension"),
@@ -846,7 +875,7 @@ fn the_sections_messages_are_in_every_catalog() {
let ids: Vec<&str> = ColorVision::ALL
.into_iter()
.flat_map(|m| [m.label_id(), m.help_id()])
.chain(crate::prefs::COLOUR_VISION_SAMPLE)
.chain(crate::ui_kit::COLOUR_VISION_SAMPLE)
.chain(["ui.colour_vision", "ui.colour_vision_mode", "ui.colour_vision_sample", "ui.colour_vision_note"])
.collect();
for (lang, text) in [
@@ -984,22 +1013,22 @@ fn random_values_never_panic_and_only_the_four_ids_are_taken() {
}
#[test]
fn cancel_puts_the_mode_back_ok_keeps_it_and_restore_defaults_resets_graphics_only() {
fn cancel_puts_the_mode_back_ok_keeps_it_and_restore_defaults_resets_interface_only() {
let mut app = app();
let control = |app: &mut crate::SolveApp, p: serde_json::Value| crate::prefs::control(app, &p).unwrap();
control(&mut app, json!({"open": "Graphics"}));
control(&mut app, json!({"open": "Interface"}));
control(&mut app, json!({"color_vision": "protan"}));
assert_eq!(app.preferences.color_vision, ColorVision::Protan);
control(&mut app, json!({"button": "cancel"}));
assert_eq!(app.preferences.color_vision, ColorVision::Off);
control(&mut app, json!({"open": "Graphics"}));
control(&mut app, json!({"open": "Interface"}));
control(&mut app, json!({"color_vision": "tritan", "button": "ok"}));
assert_eq!(app.preferences.color_vision, ColorVision::Tritan);
// Restore Defaults on another page leaves it.
control(&mut app, json!({"open": "General"}));
control(&mut app, json!({"button": "defaults"}));
assert_eq!(app.preferences.color_vision, ColorVision::Tritan);
control(&mut app, json!({"open": "Graphics"}));
control(&mut app, json!({"open": "Interface"}));
control(&mut app, json!({"button": "defaults"}));
assert_eq!(app.preferences.color_vision, ColorVision::Off);
control(&mut app, json!({"button": "ok"}));
+5
View File
@@ -17,6 +17,11 @@ pub struct CrashUi {
}
impl CrashUi {
/// Cached report count; rendering preferences never reads the filesystem.
pub(crate) fn saved_count(&self) -> usize {
self.reports.len()
}
/// Read the reports again (when the window opens or changes; never every frame).
pub fn refresh(&mut self) {
let dir = self.dir_path();
+55 -25
View File
@@ -7,8 +7,9 @@
//! The command is evaluated on a copy of the session ([`solidcraft_engine::Session::ahead`]),
//! which leaves what it computes in the evaluation cache the session shares. Done within the
//! app's wait ([`crate::dialogs::APPLY_AT_ONCE`]), the command just runs; otherwise the window
//! goes on, saying it computes, and the command runs for real once the evaluation is in, finding
//! its features in the cache. Commands asked for meanwhile wait their turn; a marker moved again
//! goes on, saying it computes, and the command runs for real once the evaluation is in, taking
//! the features it evaluated ([`solidcraft_engine::Session::prime`]) rather than computing them
//! again. Commands asked for meanwhile wait their turn; a marker moved again
//! replaces the move still waiting, so a drag computes where it ends rather than every slot it
//! crossed.
//!
@@ -24,7 +25,9 @@ use crate::SolveApp;
/// A command computing off the window thread.
pub struct Deferred {
done: std::sync::mpsc::Receiver<()>,
done: std::sync::mpsc::Receiver<Option<solidcraft_engine::Evaluated>>,
/// What it evaluated, once in (`Some(None)`: it failed, or its thread is gone).
evaluated: Option<Option<solidcraft_engine::Evaluated>>,
/// When it started (ms).
pub(crate) started: f64,
command: (String, Value),
@@ -33,11 +36,26 @@ pub struct Deferred {
}
impl Deferred {
fn finished(&self) -> bool {
!matches!(self.done.try_recv(), Err(std::sync::mpsc::TryRecvError::Empty))
pub(crate) fn finished(&mut self) -> bool {
if self.evaluated.is_none() {
match self.done.try_recv() {
Ok(evaluated) => self.evaluated = Some(evaluated),
Err(std::sync::mpsc::TryRecvError::Disconnected) => self.evaluated = Some(None),
Err(std::sync::mpsc::TryRecvError::Empty) => {}
}
}
self.evaluated.is_some()
}
}
/// How a command asked to run off the window thread goes.
enum Start {
/// It computes off the window thread.
Later(Deferred),
/// It runs now, with what was evaluated for it ahead (if anything).
Now(Option<solidcraft_engine::Evaluated>),
}
/// A delete's check computing off the window thread: each check command's reply, in order.
pub struct DeleteCheck {
done: std::sync::mpsc::Receiver<Vec<Result<Value, String>>>,
@@ -105,14 +123,24 @@ impl SolveApp {
return Ok(Value::Null);
}
match self.begin_deferred(id, &params) {
Some(job) => {
Start::Later(job) => {
self.deferred = Some(job);
Ok(Value::Null)
}
None => self.run(id, params),
Start::Now(evaluated) => self.run_evaluated(id, params, evaluated.as_ref()),
}
}
/// Run a command for real, taking the features `evaluated` computed for it ahead.
fn run_evaluated(&mut self, id: &str, params: Value, evaluated: Option<&solidcraft_engine::Evaluated>) -> Result<Value, String> {
if let Some(evaluated) = evaluated {
self.session.prime(evaluated);
}
let r = self.run(id, params);
self.session.unprime();
r
}
/// Since when a command, or a delete's check, has been computing off the window thread (ms),
/// if one is.
pub fn computing_since(&self) -> Option<f64> {
@@ -175,7 +203,7 @@ impl SolveApp {
/// Each frame: a command whose evaluation is in runs, then the next one waiting starts.
pub(crate) fn poll_deferred(&mut self, ctx: &egui::Context) {
if let Some(job) = &self.deferred {
if let Some(job) = &mut self.deferred {
if !job.finished() {
ctx.request_repaint_after(std::time::Duration::from_millis(100));
return;
@@ -187,7 +215,7 @@ impl SolveApp {
// The design changed meanwhile: computed again for the design as it is now.
let _ = self.run_heavy(&id, params);
} else if !superseded {
let _ = self.run(&id, params);
let _ = self.run_evaluated(&id, params, job.evaluated.flatten().as_ref());
}
}
}
@@ -201,33 +229,35 @@ impl SolveApp {
/// ([`SolveApp::apply_off_thread`]), a file operation in progress, no threads (the web), or
/// done within the wait.
#[cfg(not(target_arch = "wasm32"))]
fn begin_deferred(&self, id: &str, params: &Value) -> Option<Deferred> {
let wait = self.apply_off_thread?;
fn begin_deferred(&self, id: &str, params: &Value) -> Start {
let Some(wait) = self.apply_off_thread else { return Start::Now(None) };
if self.file_load.busy() || self.file_picker.is_some() {
return None;
return Start::Now(None);
}
let ahead = self.session.ahead();
let command = (id.to_string(), params.clone());
let commands = vec![command.clone()];
let (tx, rx) = std::sync::mpsc::channel();
std::thread::Builder::new()
.name("history".into())
.spawn(move || {
let _ = ahead.evaluate(&commands);
let _ = tx.send(());
})
.ok()?;
let spawned = std::thread::Builder::new().name("history".into()).spawn(move || {
let _ = tx.send(ahead.evaluate(&commands).ok());
});
if spawned.is_err() {
return Start::Now(None);
}
// Zero explicitly requests off-thread application, even if a fast worker
// finishes before this thread is scheduled again. Apply on the next frame.
if wait.is_zero() || matches!(rx.recv_timeout(wait), Err(std::sync::mpsc::RecvTimeoutError::Timeout)) {
Some(Deferred { done: rx, started: crate::now_ms(), command, revision: self.session.revision })
} else {
None
if !wait.is_zero() {
match rx.recv_timeout(wait) {
Ok(evaluated) => return Start::Now(evaluated),
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => return Start::Now(None),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
}
}
Start::Later(Deferred { done: rx, evaluated: None, started: crate::now_ms(), command, revision: self.session.revision })
}
#[cfg(target_arch = "wasm32")]
fn begin_deferred(&self, _id: &str, _params: &Value) -> Option<Deferred> {
None
fn begin_deferred(&self, _id: &str, _params: &Value) -> Start {
Start::Now(None)
}
}
+82 -44
View File
@@ -437,12 +437,14 @@ struct ShellChain {
faces: Vec<Sel>,
}
/// OK computing off the window thread: the dialog's commands evaluated on a scratch copy of the
/// session, which leaves the feature in the evaluation cache the session shares. Once that is
/// in, OK runs them for real and finds the feature there; meanwhile the window stays live and
/// the dialog says it is computing.
/// OK computing off the window thread: the dialog's commands evaluated on a copy of the
/// session. Once that is in, OK runs them for real, taking what the copy evaluated
/// ([`solidcraft_engine::Session::prime`]) rather than computing it again; meanwhile the window
/// stays live and the dialog says it is computing.
pub struct Applying {
pub(crate) done: std::sync::mpsc::Receiver<()>,
pub(crate) done: std::sync::mpsc::Receiver<Option<solidcraft_engine::Evaluated>>,
/// What it evaluated, once in (`Some(None)`: it failed, or its worker is gone).
evaluated: Option<Option<solidcraft_engine::Evaluated>>,
/// When OK was pressed (ms).
pub(crate) started: f64,
/// The commands being computed.
@@ -453,17 +455,48 @@ pub struct Applying {
}
impl Applying {
/// The evaluation is in (or its worker ended).
fn finished(&self) -> bool {
!matches!(self.done.try_recv(), Err(std::sync::mpsc::TryRecvError::Empty))
/// OK computing `cmds`, in once `done` says what was evaluated (tests stand in for the worker).
#[cfg(test)]
pub(crate) fn computing(done: std::sync::mpsc::Receiver<Option<solidcraft_engine::Evaluated>>, cmds: Vec<(String, Value)>) -> Self {
Applying { done, evaluated: None, started: crate::now_ms(), cmds, seen: true }
}
/// The evaluation is in and the job has been in a frame. A job that has just started waits
/// one frame: the window thread may be held up between starting it and the check, and the
/// feature must not appear where the dialog says it is computing.
fn ready(&self) -> bool {
fn ready(&mut self) -> bool {
self.seen && self.finished()
}
/// The evaluation is in (or its worker ended).
fn finished(&mut self) -> bool {
if self.evaluated.is_none() {
match self.done.try_recv() {
Ok(evaluated) => self.evaluated = Some(evaluated),
Err(std::sync::mpsc::TryRecvError::Disconnected) => self.evaluated = Some(None),
Err(std::sync::mpsc::TryRecvError::Empty) => {}
}
}
self.evaluated.is_some()
}
}
/// How OK goes.
enum Begun {
/// It computes off the window thread.
Later(Applying),
/// It runs now, with what was evaluated for it (if anything).
Now(Option<solidcraft_engine::Evaluated>),
}
/// Run the dialog's commands, taking the features `evaluated` computed for them ahead.
fn run_evaluated(app: &mut SolveApp, d: &Dialog, evaluated: Option<&solidcraft_engine::Evaluated>) -> Result<(), String> {
if let Some(evaluated) = evaluated {
app.session.prime(evaluated);
}
let r = run_dialog(app, d);
app.session.unprime();
r
}
/// How long the desktop app's OK waits for its evaluation before it lets the window go on and
@@ -471,41 +504,44 @@ impl Applying {
/// applies at once, as it always did.
pub const APPLY_AT_ONCE: std::time::Duration = std::time::Duration::from_millis(150);
/// Start OK's evaluation off the window thread, for a feature dialog. `None` when OK should
/// Start OK's evaluation off the window thread, for a feature dialog. `Now` when OK should
/// just run: not a feature, not asked for ([`SolveApp::apply_off_thread`]), no threads (the
/// web), or done within the wait.
#[cfg(not(target_arch = "wasm32"))]
fn begin_applying(app: &SolveApp, d: &Dialog) -> Option<Applying> {
let wait = app.apply_off_thread?;
fn begin_applying(app: &SolveApp, d: &Dialog) -> Begun {
let Some(wait) = app.apply_off_thread else { return Begun::Now(None) };
if !d.previews() {
return None;
return Begun::Now(None);
}
let cmds = apply_commands(app, d).ok()?;
let scratch = app.session.scratch();
let Ok(cmds) = apply_commands(app, d) else { return Begun::Now(None) };
let ahead = app.session.ahead();
let (tx, rx) = std::sync::mpsc::channel();
let computed = cmds.clone();
#[cfg(test)]
let gate = app.apply_gate.clone();
std::thread::Builder::new()
.name("apply".into())
.spawn(move || {
let _ = scratch.preview(&computed);
#[cfg(test)]
if let Some(gate) = gate {
let _ = gate.lock().unwrap().recv();
}
let _ = tx.send(());
})
.ok()?;
let spawned = std::thread::Builder::new().name("apply".into()).spawn(move || {
let evaluated = ahead.evaluate(&computed).ok();
#[cfg(test)]
if let Some(gate) = gate {
let _ = gate.lock().unwrap().recv();
}
let _ = tx.send(evaluated);
});
if spawned.is_err() {
return Begun::Now(None);
}
match rx.recv_timeout(wait) {
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => Some(Applying { done: rx, started: crate::now_ms(), cmds, seen: false }),
_ => None,
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
Begun::Later(Applying { done: rx, evaluated: None, started: crate::now_ms(), cmds, seen: false })
}
Ok(evaluated) => Begun::Now(evaluated),
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => Begun::Now(None),
}
}
#[cfg(target_arch = "wasm32")]
fn begin_applying(_app: &SolveApp, _d: &Dialog) -> Option<Applying> {
None
fn begin_applying(_app: &SolveApp, _d: &Dialog) -> Begun {
Begun::Now(None)
}
/// The commands a dialog's inputs make (or why they make none), its picks, and the design's
@@ -2443,7 +2479,6 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
plane["fixed"] = json!(true);
match app.run("sketch.create", json!({"plane":plane})) {
Ok(_) => {
app.pre_sketch_cam = Some(app.cam);
look_at_sketch(app);
cancel = true;
then_start = d.then_start;
@@ -2471,11 +2506,11 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
Kind::Text(k) => Some(crate::sketch_text::apply(app, k)),
_ => match begin_applying(app, &d) {
// Computing: the dialog stays, saying so, until it is in.
Some(job) => {
Begun::Later(job) => {
app.applying = Some(job);
None
}
None => Some(run_dialog(app, &d)),
Begun::Now(evaluated) => Some(run_evaluated(app, &d, evaluated.as_ref())),
},
};
match run {
@@ -2487,22 +2522,22 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
None => {}
}
}
// OK's evaluation is in: run it, the feature taken from the cache. Inputs changed meanwhile
// OK's evaluation is in: run it, taking what was evaluated. Inputs changed meanwhile
// (nothing should change them) are computed in turn, off the window thread too: run here,
// they would hold the window for the whole boolean.
// A job that has just started is not in yet: it waits a frame, so the dialog says it is
// computing before the feature appears.
let ready = app.applying.as_ref().is_some_and(Applying::ready);
let ready = app.applying.as_mut().is_some_and(Applying::ready);
if let Some(job) = &mut app.applying {
job.seen = true;
}
if ready && !cancel {
let computed = app.applying.take().map(|job| job.cmds);
let (computed, evaluated) = app.applying.take().map(|job| (Some(job.cmds), job.evaluated.flatten())).unwrap_or_default();
let current = apply_commands(app, &d).ok();
let again = if current.is_some() && current != computed { begin_applying(app, &d) } else { None };
let again = if current.is_some() && current != computed { begin_applying(app, &d) } else { Begun::Now(evaluated) };
match again {
Some(job) => app.applying = Some(job),
None => match run_dialog(app, &d) {
Begun::Later(job) => app.applying = Some(job),
Begun::Now(evaluated) => match run_evaluated(app, &d, evaluated.as_ref()) {
Ok(()) => {
cancel = !apply_again;
applied = true;
@@ -2778,9 +2813,7 @@ pub fn start_sketch(app: &mut SolveApp, sel: &Sel) -> Result<(), String> {
{
params["flip"] = json!(flip);
}
let before = app.cam;
app.run("sketch.create", params).map_err(|error| crate::friendly_error("sketch.create", &error))?;
app.pre_sketch_cam = Some(before);
look_at_sketch(app);
// On a face: look at the picked point, not the plane's origin (a corner of the face),
// which left the face filling the view off-centre like a big square.
@@ -4081,7 +4114,9 @@ pub(crate) fn plane_sel(s: &Session, pl: &PlaneRef) -> Option<Sel> {
match pl {
PlaneRef::Origin { name } => Some(Sel::Plane { name: name.clone() }),
PlaneRef::Construction { name } => Some(Sel::Plane { name: name.to_string() }),
PlaneRef::Custom { plane } | PlaneRef::Face { plane, .. } => face_sel(s, plane.origin),
PlaneRef::Custom { plane } => face_sel(s, plane.origin),
// The point picked on the face: the plane's origin can lie off the face (#3567).
PlaneRef::Face { at, .. } => face_sel(s, *at),
PlaneRef::Flipped { base, .. } => plane_sel(s, base),
_ => None,
}
@@ -4207,6 +4242,7 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
AxisRef::World { axis } => Some(Sel::Axis { name: axis.to_ascii_uppercase() }),
AxisRef::SketchAxis { axis } => Some(Sel::Axis { name: axis.to_ascii_lowercase() }),
AxisRef::SketchLine { curve } => Some(Sel::SketchCurve { id: curve.clone() }),
AxisRef::Construction { name } => Some(Sel::Axis { name: name.to_string() }),
_ => None,
};
if let Some(inp) = d.inputs.get_mut(1) {
@@ -4538,12 +4574,14 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
}
d
}
PatternKind::Circular { origin, axis, count, angle, alignment, reversed } => {
PatternKind::Circular { origin, axis, count, angle, alignment, reversed, axis_feature } => {
let mut d = start("solid.pattern.circular")?;
d.extra.insert("alignment".into(), json!(alignment));
d.extra.insert("reversed".into(), json!(reversed));
d.kind = Kind::PatternCirc { count: count.to_string(), angle: angle.to_string() };
match world_axis(axis.0).filter(|_| origin.len() < 1e-9) {
// The construction axis it turns about, else an origin axis or its line.
let named = axis_feature.as_ref().map(|n| n.to_string());
match named.or_else(|| world_axis(axis.0).filter(|_| origin.len() < 1e-9)) {
Some(name) => {
if let Some(inp) = d.inputs.get_mut(1) {
inp.items = vec![Sel::Axis { name }];
+1 -5
View File
@@ -1007,7 +1007,7 @@ pub fn animate(app: &SolveApp, k: &Asm, cmds: &mut Vec<(String, Value)>) {
None
}
// As-built joints start at zero: drive the new one after it is made.
None => Some(next_joint_id(&app.session)),
None => app.session.doc.next_item_id().ok(),
},
};
if let Some(id) = drive {
@@ -1015,10 +1015,6 @@ pub fn animate(app: &SolveApp, k: &Asm, cmds: &mut Vec<(String, Value)>) {
}
}
fn next_joint_id(s: &Session) -> u64 {
s.doc.assembly.joints.iter().map(|j| j.id).max().unwrap_or(0) + 1
}
/// Joint and drive previews: the commands run on a scratch copy and the placed model before and
/// after is compared, so the components that move show where they go (`None`: not such
/// commands).

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