Compare commits

..

52 Commits

Author SHA1 Message Date
lillith a077906234 feat(render): add bounded CPU Interactive request lifecycle 2026-10-29 20:18:50 +01:00
lillith b2733d607a claim: #8936 2026-10-29 18:16:13 +01:00
github-actions[bot] 4aa2d6248e Regenerate generated files and fold pending files [skip ci] 2026-10-29 16:45:50 +00:00
github-actions[bot] bd4a234461 Regenerate generated files and fold pending files [skip ci] 2026-10-29 16:36:59 +00:00
Frank 162b3cd4ed Sketch Intersect: add Faces/Bodies and Projection Link options (#1110) (#7989)
* Sketch Intersect: add Faces/Bodies and Projection Link options (#1110)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Sketch Project: add Geometry/Bodies and Projection Link options (#1110)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Sketch Project/Intersect: keep exact lines and arcs of whole bodies, no noisy rebuild warning (#1110)

Whole-body silhouettes and sections fit lines and arcs tightly and snap arcs
tangent to their neighbours. A rebuilt projection warns only when it removed
the user's own constraints or curves. Pending locales cover ar, he and vi, and
the preselection tests use the direct-pick workflow.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Select Other: label curves from Intersect as intersected (#1110)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-29 16:09:18 +00:00
PaddleStroke 420dff36c8 Browser: remember collapsed state, optional reveal on hover (#8692) (#8762)
* claim: #8692

* Browser: remember the folded card across runs, optional reveal on hover (#8692)

The browser card's fold (the chevron in its header) is now a preference,
saved with the other layout state, so the next run starts the way the user
left it. Preferences > Interface > Layout adds Shown / Reveal on hover: the
browser then stays out of sight until the pointer comes over its place or
the thin tab at its left edge, fading in like the navigation bar's Hide
until hovered, and stays while the pointer is inside or a menu, drag or text
edit started from it is open. It never folds in that mode.

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

* Browser: move the reveal-on-hover messages to a pending locale file (#8692)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 16:03:05 +00:00
raykholo 3cdfec798f Timeline: joints in feature folders (#8306) (#8316)
* claim: #8306

* Timeline: joints in feature folders (#8306)

timeline.group and timeline.move_to_group take joints as well as features, so
hundreds of joints fold into one folder tile. A closed folder shows one tile
whatever it holds; opening it shows its features and joints in both the bottom
and left History. Suppress, delete and ungroup on a folder reach its joints too
(one undo step), deleting a joint removes it from its folder, and an unknown
joint id or name is an error. Saved with the design; designs without joint
folders read and write as before.
2026-10-29 15:59:18 +00:00
austin62jake c32f6b60eb Expressions: Enter after pi mm keeps the unit, and the list's state follows the text (#8862) (#8871)
* claim: #8862

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

* Expressions: Enter after `pi mm` keeps the unit, not a longer name (#8862)

A unit after a name or constant multiplies it, as after a number (`pi mm`
is pi millimetres), so it is a complete word there: the longer names are
listed unarmed and Enter submits what was typed. A unit at the start, or
after an operator or bracket, stays a prefix as #3664 chose.

Whether Enter completes is now worked out again every frame, unless a key
armed the list. It was kept from when the list first appeared for a word
and caret position, so it went stale when the text before the word was
replaced, or when the box took the focus before the names were gathered.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-29 15:56:31 +00:00
github-actions[bot] 5fe8d5a757 Regenerate generated files and fold pending files [skip ci] 2026-10-29 15:57:43 +00:00
Colin Faulkingham 4e78acc7a2 Sketch: the same constraint twice is one constraint (#5224) (#8850)
* claim: #5224

* Sketch: the same constraint twice is one constraint (#5224)
2026-10-29 15:31:18 +00:00
phanoob 4cd90e6806 Status bar: switch the mouse mapping from a menu (#7023) (#8856)
* claim: #7023

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

* Status bar: switch the mouse mapping from a menu (#7023)

The status bar gets a mouse-mapping button beside the units. It shows the
current mapping, and a click opens a menu of the mappings above the bar.
Picking one sets what Preferences > Navigation sets for it (bindings, wheel
direction, middle double-click, right drag orbits) through
choose_nav_preset. Nothing changes while the status bar is off (the
default).

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

---------

Co-authored-by: Phanoob <phanoob@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 15:28:34 +00:00
raykholo 8a888ff0f3 Preferences: mouse-wheel zoom speed (#4176) (#4179)
* claim: #4176

* Preferences: mouse-wheel zoom speed (#4176)

A 0.25x to 4x multiplier for each wheel zoom step, applied in
navigation::handle so every canvas uses it. The 3D mouse keeps its own
speed. Values outside the range are refused when set and reset to 1x when
loaded.

* Control protocol: wheel_zoom_speed in the sorted ui.prefs list (#4176)

* Wheel zoom speed: its label in the complete catalogs (#4176)
2026-10-29 15:26:14 +00:00
ABDULLAH 5530c3c68e Arabic: review of strings added today (Resize Part hint, navigation bar position, geometry budget help) (#8826)
* Arabic: fix the Resize Part hint (tanween style, part list, command name)

* Arabic: navigation bar Edge reads as position, tanween spelling in the geometry budget help
2026-10-29 15:24:59 +00:00
Dahak Builds 35601da3f0 3D sketch: 3D Line, Spline and Point buttons start tools (#8040) (#8041)
* claim: #8040

* wip: 3D line/spline/point tools (#8040)

* Take sketch.move3d off the toolbar help backlog now that it is agent-only (#8040)

---------

Co-authored-by: Dahak Builds <dahakbuilds@users.noreply.github.com>
2026-10-29 15:10:41 +00:00
github-actions[bot] 1d566398d8 Regenerate generated files and fold pending files [skip ci] 2026-10-29 15:28:09 +00:00
austin62jake 3e01081ff9 Control token: a closing window removes only its own token and discovery files (#8794) (#8837)
* claim: #8794

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

* Control token: a closing window removes only its own token and discovery files (#8794)

Under Flatpak each instance has its own loopback, so a second window
binds the same port number as the first and writes the same
<port>.token, <port>.pid and current.port in the shared data folder.
At exit the first window deleted them all, unconditionally, leaving the
second window running with no token file a client could read.

remove_token_file now takes this launch's token and removes the token
and process files only while the file still holds it; the process id
can't tell instances apart, as every Flatpak instance has the same one.
remove_current_port also takes the token and keeps the discovery file
while another launch's token file holds that port, and removes
current.pid with current.port (it was left behind).

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-29 15:01:45 +00:00
austin62jake 420785b514 Sketch: a selected text moves when dragged from anywhere inside its frame (#8835) (#8851)
* claim: #8835

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

* Sketch: a selected text moves when dragged from anywhere inside its frame (#8835)

A drag had to start on one of the text's letters to move it. From the
space between letters, or inside a letter's outline, it found no geometry
and drew a selection box instead.

Off the sketch's own geometry, a drag from inside the selected text's
frame now grabs the text, as one from a letter does. Handles, dimensions
and geometry under the pointer still come first, and outside the frame a
drag from empty space still draws a selection box.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-29 14:58:03 +00:00
raykholo dcae86e456 Trackpad: zoom with two-finger scroll while holding a key you choose (#8322) (#8584)
* Trackpad: zoom with two-finger scroll while holding a key, chosen in Preferences (#8322)

Holding the zoom key turns a vertical two-finger scroll (or a wheel) into zoom at
the pointer, with the wheel zoom's speed, direction and anchor, so a laptop user
zooms without pinching. Preferences > Navigation > "Zoom with two-finger scroll
while holding": Cmd on macOS and Ctrl elsewhere (default), Option/Alt, Ctrl, or
Off. That key with the scroll did nothing before, so no default changes; Off
keeps it that way. Shift keeps its orbit.

* Trackpad zoom modifier: off by default (#8322)

Scroll keeps its usual meaning until a key is chosen in Navigation.

* Trackpad zoom modifier tests: Off is the default; tests that zoom choose Cmd (#8322)

* Trackpad zoom modifier: zoom once alongside the Ctrl+wheel pinch from #8539 (#8322)

A wheel the chosen key claims is no longer also read as a pinch, and a key that is not
chosen keeps main's pinch handling unchanged.
2026-10-29 14:49:19 +00:00
Colin Faulkingham 443e5cd59d Feature editor: allow visibility changes while editing (#8810) (#8827)
* claim: #8810

* fix(engine): preserve feature editors across visibility changes (#8810)
2026-10-29 14:26:24 +00:00
github-actions[bot] b4799575fd Regenerate generated files and fold pending files [skip ci] 2026-10-29 14:58:12 +00:00
Charlie 5a0abb8710 feat(align): add Components mode to Align for occurrence placement (#7224)
Align gets an Object Type row (Bodies | Components). In Components mode it runs the new
occurrence.align command, which moves only the chosen occurrence placements (their
transforms) instead of adding an Align feature to the shared component, so other instances
stay where they are.

- occurrence.align: from/to points, snaps, planar faces and planes in world coordinates,
  flip, capture/pending, grounded and rigid-group refusals as occurrence.move.
- A viewport or browser-body pick takes the placement directly under the active component.
- A placement inside a subassembly placed more than once still moves (as occurrence.move
  does) but the dialog warns, and the reply lists it under shared.
2026-10-29 14:19:05 +00:00
Andrzej Błasik cb05e1e0f7 GPU path tracer: run on Metal on macOS (#7183) (#7185)
* claim: #7183

* GPU path tracer runs on Metal on macOS, not only Vulkan (#3217)
2026-10-29 13:58:49 +00:00
jeromehzl 6fdd58dec6 Toasts: the corner stack keeps its full width just left of a pane at the right edge (#8804) (#8840)
corner_free looked only in a fixed column at the view's right edge and took
the largest free rect there by area. Next to a tall dialog docked at the
right edge, or the Assistant in the Floating layout, the narrow strip beside
the pane won: the toasts were squeezed into it, their text broke inside
words, and with a wider pane they were not shown at all.

It now tries that column first and then columns ending at each obstacle's
left edge, nearest the corner first, and takes the first free rect that is
TOAST_WIDTH wide and tall enough for a toast, as the frame-rate readout
moves beside a right pane (#6755); failing that, a rect tall enough beats a
wider one too short to show any toast. Without a pane the stack stays
exactly where it was.
2026-10-29 13:58:16 +00:00
phanoob 5b0a86a5b2 Graphics: a background colour of your choice for the 3D view (#7688) (#8815)
* claim: #7688

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

* Graphics: a background colour of your choice for the 3D view (#7688)

Preferences › Interface › Theme gets a Background row: the theme's
gradient (the default, unchanged) or one colour for the whole application,
filling the view in either theme. The window keeps its own colours, a
picture of the view (`ui.render`) takes the colour too, and `ui.prefs`
sets it as `viewport_background` ([r, g, b] or null).

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

---------

Co-authored-by: Phanoob <phanoob@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 13:58:13 +00:00
cronos 28e26209f3 Assembly Move: drag allowed joint freedoms (#6679) (#6904)
* feat: add joint-aware assembly Move handles and live motion readouts

* test: improve render job failure diagnostics

* fix: preserve assembly Move compatibility and viewport theme colors

* ci: skip default label sync for fork PRs

* fix: keep Windows CI checks portable after main rebase

* test: cover RTL assembly strings and stable refraction job

* i18n: cover assembly Move in Vietnamese

* i18n: cover assembly Move in French

* fix: complete CGA palette for assembly motion handles

* fix: complete Assembly Move palettes and Turkish strings

* fix: retain assembly move cache answers and toolbar help

* style: format merged assembly Move navigation guard

* fix: align Assembly Move preferences and tests with main

* Fix offline update fixture synchronization and clipped prism intersections

Wait for all v5 manifest requests before deploying v6. Intersect hole-free prism sections over their common height, retaining the established B-rep path for newly clipped perforated sections. Preserve the failing property seed and add closed-form and cap-boundary regressions.
2026-10-29 13:57:31 +00:00
github-actions[bot] 986de7ec9a Regenerate generated files and fold pending files [skip ci] 2026-10-29 14:25:59 +00:00
github-actions[bot] 7c7c0a57bd Regenerate generated files and fold pending files [skip ci] 2026-10-29 14:16:56 +00:00
tr1pp4 54b07d3e0b History in the browser: mark features that failed or built with a warning (#8592) (#8593)
* claim: #8592

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

* History: error and warning marks in the browser and on folded folders (#8592)

In the History "In browser" layout, a feature whose build failed gets the
error icon at the end of its row, one that built with a warning the
warning icon, with the message in the tooltip: the marks and text of the
History on the left. A folded History or Features folder carries the
worst mark of what it holds, and so does a folded folder in the History
on the left. Suppressed and rolled-back features are not built and get
none.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 13:51:20 +00:00
P'tite Tête 7dc341ae55 fix: Remove two per-frame quadratic lookups on large designs (#8843)
- active_body: take every body's owner in one pass (model_body_components) instead of searching the timeline body by body
- Timeline strip: find each chip's result in a table built once per frame (result_index) instead of a linear search per chip
- 4,880-body design: UI frame 31-32 ms -> 7 ms of CPU; tests pin the active body and the result lookup

Co-authored-by: P'tite Tête <asso@ptitetete.com>
2026-10-29 13:49:59 +00:00
Ryan 5e0e168ae9 Properties: moment of inertia, principal axes and first moments of a part or assembly (#8095) (#8103)
* claim: #8095

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

* Properties: moment of inertia, principal axes and first moments of a part or assembly (#8095)

component.properties and occurrence.properties report each closed body's
inertia tensor about its centre of mass, and in total the tensor about the
total centre of mass (world axes, g·mm²), its principal moments and axes,
the first moments, and with `about: [x, y, z]` the tensor about any point.
The tensor comes from each body's welded export mesh, tessellated once per
body, scaled to the measured mass and turned by each placement; bodies add
by parallel axes. The maths is a new crates/geom inertia module.

The Properties window shows the first moments, the tensor, the principal
moments and axes, and the view marks the centre of mass with the principal
axes while it is open. Inspect → Measure → Properties now opens that window
(it ran the command and showed nothing).

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

* Properties: Copy matrix, and the whole design's card titled just Properties (#8095)

Copy matrix, under the tensor, puts it on the clipboard as three lines of
tab-separated values at full precision, in the units shown. The card for
the whole design is titled Properties; one opened on a component,
placement or bodies keeps its name.

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

* Properties: measure beside the window, so a large assembly doesn't freeze it (#8095)

Properties ran on the window thread: 18 s on a 144-body design, with the
window answering nothing meanwhile (#5203). In the desktop app the card now
measures on a copy of the session on a worker, the way #4614 made agents'
measures work, says "Measuring…" until the reply is in, and Close drops it.
A quick measure still shows at once; headless, tests and the web measure in
place.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 13:47:44 +00:00
bherbruck be8bd43a18 CI: wasm compile in the merge queue, dependency advisories on dependency changes (#8652)
The wasm compile ran only by hand, so #8227 (a native-only DroppedFile::bytes() call in
crates/ui-egui) passed PR CI and the merge queue and broke the web build on main (#8534).
A merge group that touches the crates below the apps, the web app, the vendored crates, the
manifests, the toolchain or this workflow now runs `cargo xtask wasm`, and `ci` requires it.

Dependency advisories ran only nightly. A pull request or merge group that changes Cargo.lock
or a Cargo.toml now runs `cargo deny --locked check --warn unmaintained advisories`, and `ci`
requires it then. The nightly keeps the full advisories, bans and sources scan.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 13:39:48 +00:00
raykholo 884c47496b Render preview: geometry budget from the device, readable refusals (#4127) (#4154)
* claim: #4127

* Render preview: geometry budget from the device, readable refusals (#4127)

The Render preview refused every scene over a fixed 128 MiB of geometry. On
desktop the budget now follows the device's buffer limit (eight of its
largest buffers, at most 2 GiB; 2 GiB with the usual 256 MiB buffers). The
web keeps 128 MiB.

The product upload runs under its own GPU error scopes: an allocation the
device refuses is reported in the preview instead of reaching the device's
uncaptured-error handler, and the half-uploaded batches are dropped. Buffer
uploads map the buffer themselves so a refused buffer fails the upload
rather than panicking in create_buffer_init.

Refusals name the geometry size and the limit and say how to get under it,
on a theme panel card that stays readable on any preview background.

* Render preview: keep the previous geometry budget behind a preference (#4127)

* Render preview: adapt the device budget to main's GPU upload errors (#4127)
2026-10-29 13:33:39 +00:00
Karl Von Richter b2d727f4b2 Toolbar: choose visible tabs while preserving all defaults (#5717) (#8594)
* claim: #5717

* ui(feat): let users choose visible toolbar tabs

Keep Sheet Metal and Weldments visible by default while adding persisted tab visibility controls and restoring specialty tabs for designs that use them.

Signed-off-by: Karl Von Richter<karl@embeint.com>

* ui(fix): respect hidden toolbar tabs in every design

Remove feature-based visibility overrides for Sheet Metal and Weldments. Keep all tabs visible by default and preserve the user choice across feature creation, opening designs, selection and undo.

Signed-off-by: Karl Von Richter<karl@embeint.com>

---------

Signed-off-by: Karl Von Richter<karl@embeint.com>
2026-10-29 13:32:21 +00:00
bherbruck 4f5ed7bfa6 CI: open an issue when the nightly fails, close it when it passes (#8651)
The nightly failed every day from 2026-10-16 to 2026-10-28 without anyone noticing. A new
nightly-alert job (scheduled runs only, the only job with issues: write) opens or comments on
one issue, "Nightly CI is failing" (label ci), when a nightly job fails, and closes it when a
nightly passes.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 13:31:29 +00:00
raykholo f03d0a20bd Update now installs the newest release, checked when pressed (#7724) (#7725)
* claim: #7724

* Update now installs the newest release, checked when pressed (#7724)

* Update: close the unmanaged-install test after the rebase (#7724)

* Update: the refresh messages in Arabic, Hebrew and Vietnamese too (#7724)
2026-10-29 13:29:43 +00:00
SilentWolf75 c6c91e6f43 Parameters: show full names in the table and formula popup (#8533) (#8551)
* claim: #8533

Co-authored-by: Cursor <cursoragent@cursor.com>

* Parameters: show full names in the table and formula popup.

Hover tips carry the full identifier when Name cells or completion rows clip, and the completion list widens for long suggestions (#8533).

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-29 13:25:57 +00:00
github-actions[bot] 2f56d77e90 Regenerate generated files and fold pending files [skip ci] 2026-10-29 13:45:31 +00:00
Dylan M. Taylor 68b53a4c77 Release: Arch Linux packages for x86_64 and aarch64 (#8576)
A new arch job repackages each release tarball as
solidcraft-<version>-linux-<arch>.pkg.tar.zst with makepkg, through the Craft apps' shared
action (storytold/craft-common-actions/actions/arch-package, pinned by commit). The package
is io.tangencylabs.solidcraft, named after the tarball's .desktop file, provides solidcraft
and conflicts with solidcraft-bin/-git. Each package also gets a version-less copy, and
docs/downloads.md links them.
2026-10-29 13:17:34 +00:00
phanoob 2235389344 Navigation bar: can sit at the top of the view (#8183) (#8793)
* claim: #8183

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

* Navigation bar: can sit at the top of the view (#8183)

Preferences > Navigation bar gets an Edge choice, Bottom (default) or Top.
On top the bar sits under the ribbon, its menus open downwards and its
tooltips show below it; the OK/Cancel pair and the axis view move down
below it when they would overlap.

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

* Navigation bar: the edge's labels in the complete catalogs (#8183)

ar, he, ja, vi and zh carry every English message; the Edge row's three
labels were missing from them, so `xtask i18n --check` and
`every_complete_catalog_carries_every_english_message` failed.

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

---------

Co-authored-by: Phanoob <phanoob@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 13:16:51 +00:00
jeromehzl 9509348320 Viewport: editing a material's colour redraws the bodies that use it (#8764) (#8814)
body_appearance_key hashed only the name of a body's assigned material, so
after material.edit changed that material's colour (or an undo or redo
changed it back) the key stayed the same and the viewport kept the cached
old look. The key now also hashes the material's resolved look; with an
unchanged material the cache is reused as before.
2026-10-29 13:14:15 +00:00
Colin Faulkingham fd8ddc23b7 Pattern on Path: reopen saved patterns in the editor (#8784) (#8805)
* claim: #8784

* fix(ui): restore saved path pattern editor
2026-10-29 12:49:17 +00:00
Joseph 07d692ee0d Keyboard Shortcuts: several keys for one command (#6273) (#6288)
* Keyboard shortcuts: a command can have several keys

Click a key on the Keyboard Shortcuts page to replace it, or the + after it
to add another; every key runs the command and menus show the first. One
key is still saved as a string, several as a list. Taking a key over from
another command leaves that command its other keys. ui.shortcut takes a
slot and reports every key.

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

* Keyboard shortcuts: follow main's search layout; fix prefs test for key lists

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-29 12:32:19 +00:00
github-actions[bot] 00f9243c0d Regenerate generated files and fold pending files [skip ci] 2026-10-29 12:29:52 +00:00
ABDULLAH 1e1be6934f Arabic: use the Mouse mapping term in the Right drag orbits help (#8803)
nav.right_drag_orbits.help now names the setting as prefs.mouse_mapping does (تعيين أزرار الفأرة).
2026-10-29 11:58:59 +00:00
semmelrinde 6a183cce02 Sketch corners: a corner cut is refused where two lines lie in line, as rounding is (#8802)
Chamfer at a corner of two lines lying on each other cut both back and
added a line of no length between their ends; lines in line with each
other have no corner either. chamfer now refuses a corner angle of 0 or
180 degrees with Fillet's message, "the lines are parallel" (found by a
local trim fuzzer).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 11:49:31 +00:00
jeromehzl dd6a91533d Standard parts: Resize Part says what it works on, Parts opens Insert Part (#8735) (#8801)
* Resize Part: say what it works on instead of a parameter error; Parts opens the library (#8735)

Resize Part (parts.resize) started with no standard part selected, as the
ribbon starts it, ran the command with no parameters and showed
"`component` must be an id or a name". It now says to select an inserted
standard part, and that Scale resizes a body. Parts (parts.library) only
listed the library, so its button did nothing: it opens the Insert Part
dialog. The German and Dutch button labels said "scale" ("Teil skalieren",
"Onderdeel schalen"), like the Scale command: they now say change size.

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

* Resize Part: the German label matches the command, and the test checks that nothing ran (#8735)

The German ribbon label reads "Bauteilgröße ändern", the name the command,
its dialog and its messages already use. The hint for a missing standard
part is now one function shared by the ribbon start and the Browser's
Change Part Size. The regression test also checks that the design and its
history stay as they were and no message carries the command's own error,
that a scripted parts.library still only lists the library, and that with a
standard part picked Change Part Size opens on it.

---------

Co-authored-by: semmelrinde <124386289+semmelrinde@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 11:49:25 +00:00
Christian Hammacher 93d0298d25 Browser: Turn Up Axis on a placement row asks that placement whether it can move (#8786) (#8788)
A placement row's menu starts from its component's menu, which takes
Turn Up Axis's enabled state from the component's first placement.
Move/Copy was already set again for the placement right-clicked; Turn Up
Axis and its two turns now are too, so a free second placement can turn
while the first is grounded, and a grounded one can't.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 11:28:19 +00:00
semmelrinde cf86f18c11 Sketch: an end held on a removed curve where another curve ends stays tied (#8779)
A trimmed circle ended on a line exactly where an arc started on that
line, held there by a point-on-curve tie. Trimming the line away dropped
the tie and left the two ends on one spot, untied. remove_curves now ties
each point held on a removed curve at one of its ends to that end before
the curve goes (found by a local trim fuzzer, 15 -> 12 cases in 2000
seeds).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 11:07:28 +00:00
github-actions[bot] 63f053e4fe Regenerate generated files and fold pending files [skip ci] 2026-10-29 11:06:21 +00:00
Colin Faulkingham 81039a0e10 Pinch zoom: route Ctrl+wheel input to the camera (#8480) (#8539)
* claim: #8480

* Pinch to zoom on touchpad: native Ctrl+wheel pinch zooms the view (#8480)

The viewport's pinch path read only `egui::Event::Zoom`; `immediate_wheel` leaves
wheel events carrying the zoom modifier to egui. Native Linux and Windows
touchpads report a pinch as Ctrl+wheel in points, so the gesture reached neither
path and the view did not move. `pinch_factor` now reads those wheels too, in
every wheel unit and scaled as egui scales them, and `ui.scroll` can send the
same gesture (`ctrl: true`) so scripts and scenarios can replay a pinch.

* fix: finish Ctrl+wheel pinch handling and replay validation (#8480)
2026-10-29 10:40:10 +00:00
Lars Jansen 375dc01602 Navigation: Right drag orbits option; picking the SolidWorks preset turns it off (#6492) (#6497)
* claim: #6492

* feat(navigation): Right drag orbits option, off when picking the SolidWorks preset, which also fits on middle double-click (#6492)
2026-10-29 10:24:37 +00:00
PaddleStroke fe3f913d76 Dialogs: disable Apply until something changed (#8561) (#8728)
* claim: #8561

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

* Dialogs: disable Apply until something changed (#8561)

The dialog footer gets a disabled state for Apply (Footer::nothing_to_apply,
Window::apply_enabled): drawn with the theme's dim ink, not clickable, not
reached by Tab and reported disabled to accessibility. Preferences enables
Apply only while something differs from its snapshot (what Cancel would put
back), so it is disabled when the dialog opens and again after Apply.
ui.prefs replies with apply_enabled.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 10:14:45 +00:00
198 changed files with 10264 additions and 639 deletions
+110 -6
View File
@@ -15,7 +15,9 @@
# nightly. The reference tests and the doc tests run in the merge queue, on main and nightly, not
# on pull requests (the fast lane, in Select checks); a break there is fixed forward on main. The
# Windows jobs (D3D12 shaders and app startup) run nightly, and in the merge queue only for a group
# that changes what they exercise. Only main saves the build caches; pull requests restore them.
# that changes what they exercise. The wasm compile check runs in the merge queue for a group that
# touches the crates below the apps, the web app or the manifests. Dependency advisories run nightly
# and on any pull request or merge group that changes Cargo.lock or a Cargo.toml. Only main saves the build caches; pull requests restore them.
# sccache is on a Blacksmith sticky disk that only main commits.
# A push to main runs only the repository checks when a passing merge-queue run tested that very
# commit, or when nothing a build reads changed since the last push to main whose run passed.
@@ -78,8 +80,13 @@ env:
jobs:
dependency-advisories:
# Nightly and by hand: advisories, bans and sources for everything. A pull request or merge
# group that changes Cargo.lock or a Cargo.toml (Select checks' `deps`) checks the advisories
# only, so a dependency with a known vulnerability is caught before it lands, not the next
# morning; other pull requests skip it. `ci` requires it whenever `deps` is true.
name: Dependency advisories
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
needs: changes
if: ${{ !cancelled() && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || needs.changes.outputs.deps == 'true') }}
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 20
steps:
@@ -88,7 +95,12 @@ jobs:
- name: Install advisory scanner
run: cargo install cargo-deny --version 0.20.2 --locked
- name: Check locked dependencies
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
run: cargo deny --locked check --warn unmaintained advisories bans sources
# deny.toml denies unmaintained crates; like the nightly, they are reported, not fatal.
- name: Check advisories for the changed dependencies
if: github.event_name != 'schedule' && github.event_name != 'workflow_dispatch'
run: cargo deny --locked check --warn unmaintained advisories
changes:
name: Select checks
@@ -115,6 +127,10 @@ jobs:
packages: ${{ steps.paths.outputs.packages }}
oracle: ${{ steps.paths.outputs.oracle }}
doctests: ${{ steps.paths.outputs.doctests }}
# Cargo.lock or a Cargo.toml changed (pull requests and merge groups): Dependency advisories.
deps: ${{ steps.paths.outputs.deps }}
# A merge group reaches the wasm build: Compile WebAssembly.
wasm: ${{ steps.paths.outputs.wasm }}
# The passing run a merge of main carries forward (its URL), when this push is only that.
carried: ${{ steps.paths.outputs.carried }}
steps:
@@ -143,6 +159,8 @@ jobs:
goldens=false
windows=false
gpu_paths=false
deps=false
wasm=false
full=true
packages=
# Whether the NUL-separated paths in $1 reach any code: scripts/ci/affected.py selects
@@ -183,6 +201,21 @@ jobs:
esac
done < "$1"
}
# Whether the NUL-separated paths in $1 change the dependencies (Cargo.lock or any
# Cargo.toml: deps), and whether they reach what `cargo xtask wasm` compiles (wasm): the
# crates below the apps, the web app, the vendored crates, the manifests and lockfile, the
# toolchain and cargo config, and this workflow. #8227 put a native-only call in
# crates/ui-egui and the merge queue passed it, because nothing built for wasm there.
deps_wasm_paths() {
while IFS= read -r -d '' path; do
case "$path" in
Cargo.lock|Cargo.toml|*/Cargo.toml) deps=true ;;
esac
case "$path" in
crates/*|apps/solidcraft-web/*|vendor/*|Cargo.lock|Cargo.toml|rust-toolchain*|.cargo/*|.github/workflows/ci.yml) wasm=true ;;
esac
done < "$1"
}
# Opt-in for a big render change: a pull request labelled render-tests, or a merge group
# holding one (its pull requests are the (#N) of its squash commits), runs them whatever
# the paths say.
@@ -228,14 +261,17 @@ jobs:
if git diff --no-renames --name-only -z "$GROUP_BASE_SHA...$GROUP_HEAD_SHA" > "$RUNNER_TEMP/ci-paths"; then
windows_paths "$RUNNER_TEMP/ci-paths" "$GROUP_BASE_SHA...$GROUP_HEAD_SHA"
gpu_paths "$RUNNER_TEMP/ci-paths"
deps_wasm_paths "$RUNNER_TEMP/ci-paths"
if [ "$gpu_paths" = false ] && group_has_render_label "$GROUP_BASE_SHA..$GROUP_HEAD_SHA"; then
gpu_paths=true
fi
else
windows=true
gpu_paths=true
deps=true
wasm=true
fi
echo "windows=$windows gpu_paths=$gpu_paths for $GROUP_BASE_SHA...$GROUP_HEAD_SHA"
echo "windows=$windows gpu_paths=$gpu_paths deps=$deps wasm=$wasm for $GROUP_BASE_SHA...$GROUP_HEAD_SHA"
elif [ "$EVENT_NAME" != pull_request ]; then
# The nightly run and Run workflow: everything, Windows included.
native=true
@@ -256,6 +292,10 @@ jobs:
done < "$RUNNER_TEMP/ci-paths"
# Computed as for a merge group, then turned off below (the fast lane).
windows_paths "$RUNNER_TEMP/ci-paths" "$BASE_SHA...$HEAD_SHA"
# The advisories run on the pull request itself (it builds nothing of ours: about two minutes, most of it installing cargo-deny);
# the wasm compile waits for the merge queue.
deps_wasm_paths "$RUNNER_TEMP/ci-paths"
wasm=false
# Pull requests build and test the touched packages and their dependents; nothing
# selected means no code changed.
select_packages "$RUNNER_TEMP/ci-paths"
@@ -327,6 +367,8 @@ jobs:
echo "windows=$windows"
echo "oracle=$oracle"
echo "doctests=$doctests"
echo "deps=$deps"
echo "wasm=$wasm"
echo "carried=$carried"
echo "full=$full"
echo "quick=$quick"
@@ -946,7 +988,7 @@ jobs:
# commit's real run instead: the merge queue reads `ci` from the newest run of a commit, so a
# run without a passing `ci` would block a pull request that passed (#1850, #3182, #3252).
name: ci
needs: [changes, checks, quick-compile, clippy, build-tests, tests, gpu-path-tests, doc-tests, oracle, windows-fxc, windows-startup]
needs: [changes, checks, quick-compile, clippy, build-tests, tests, gpu-path-tests, doc-tests, oracle, windows-fxc, windows-startup, dependency-advisories, wasm]
if: always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft)
runs-on: blacksmith-2vcpu-ubuntu-2404
permissions:
@@ -1000,6 +1042,10 @@ jobs:
ORACLE: ${{ needs.oracle.result }}
WINDOWS_FXC: ${{ needs.windows-fxc.result }}
WINDOWS_STARTUP: ${{ needs.windows-startup.result }}
DEPS_RUN: ${{ needs.changes.outputs.deps }}
ADVISORIES: ${{ needs.dependency-advisories.result }}
WASM_RUN: ${{ needs.changes.outputs.wasm }}
WASM: ${{ needs.wasm.result }}
run: |
test "$CHANGES" = success
test "$CHECKS" = success
@@ -1029,6 +1075,14 @@ jobs:
test "$WINDOWS_FXC" = success
test "$WINDOWS_STARTUP" = success
fi
# Cargo.lock or a Cargo.toml changed on a pull request or in a merge group.
if [ "$DEPS_RUN" = true ]; then
test "$ADVISORIES" = success
fi
# A merge group that reaches the wasm build.
if [ "$WASM_RUN" = true ]; then
test "$WASM" = success
fi
- name: Note a carried result
if: needs.changes.outputs.carried != ''
env:
@@ -1036,9 +1090,13 @@ jobs:
run: |
echo "This push only merges main into a change that already passed; the heavy checks were not repeated. Passing run: $CARRIED" >> "$GITHUB_STEP_SUMMARY"
# Every crate below the apps and the web app must build for the browser (`cargo xtask wasm`, a
# cargo check, no tests). In the merge queue for a group that reaches the wasm build (Select
# checks' `wasm`), where `ci` requires it; by hand with cross_compile.
wasm:
name: Compile WebAssembly (manual)
if: github.event_name == 'workflow_dispatch' && inputs.cross_compile
name: Compile WebAssembly
needs: changes
if: ${{ !cancelled() && ((github.event_name == 'workflow_dispatch' && inputs.cross_compile) || needs.changes.outputs.wasm == 'true') }}
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 30
steps:
@@ -1099,3 +1157,49 @@ jobs:
. "$HOME/.cargo/env"
export CRAFT_FONTS_DIR="$PWD/craft-fonts"
cargo check --all-targets --locked --target riscv64gc-unknown-linux-gnu
# Nobody watches the nightly run, and it failed every day from 2026-10-16 to 2026-10-28 before
# anyone noticed. When a nightly fails, open one issue, "Nightly CI is failing" (label `ci`), or
# comment on it if it is already open; when a nightly passes, close it. A nightly that neither
# failed nor fully passed (cancelled) leaves the issue as it is. The only job that may write issues.
nightly-alert:
name: Nightly alert
needs: [dependency-advisories, changes, checks, ci, build-tests, action-matrix, action-matrix-determinism, property-tests]
if: always() && github.event_name == 'schedule'
runs-on: blacksmith-2vcpu-ubuntu-2404
timeout-minutes: 5
permissions:
issues: write
steps:
- name: Open, update or close the nightly issue
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
RESULTS: ${{ toJSON(needs) }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
SHA: ${{ github.sha }}
run: |
title="Nightly CI is failing"
failed=$(jq -r 'to_entries[] | select(.value.result == "failure") | .key' <<<"$RESULTS")
unfinished=$(jq -r 'to_entries[] | select(.value.result != "success" and .value.result != "failure") | "\(.key) (\(.value.result))"' <<<"$RESULTS")
issue=$(gh issue list --state open --label ci --limit 100 --json number,title \
--jq "[.[] | select(.title == \"$title\")] | first | .number // empty")
if [ -n "$failed" ]; then
body="The nightly run on ${SHA:0:9} failed: $RUN_URL
Failed jobs: $(paste -sd , - <<<"$failed" | sed 's/,/, /g')."
if [ -n "$issue" ]; then
gh issue comment "$issue" --body "$body"
else
gh issue create --title "$title" --label ci --body "$body
This issue is opened and updated by the nightly run (the nightly-alert job in .github/workflows/ci.yml) and closed by the first nightly that passes."
fi
elif [ -z "$unfinished" ]; then
if [ -n "$issue" ]; then
gh issue close "$issue" --comment "The nightly run on ${SHA:0:9} passed: $RUN_URL"
fi
echo "The nightly passed."
else
echo "Nothing failed, but not every job finished: $unfinished. The issue is left as it is."
fi
+1
View File
@@ -52,6 +52,7 @@ jobs:
# effort: a fork PR just doesn't get the `changes-default` label from here.
label:
name: Label changes-default
if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-24.04
timeout-minutes: 5
continue-on-error: true
+42 -2
View File
@@ -10,6 +10,7 @@
# (see the windows job). Nothing else is signed yet.
#
# Linux x86_64, aarch64 AppImage (+ .zsync), .deb, .rpm, .flatpak, .snap, .tar.gz packaging/linux
# Arch Linux x86_64, aarch64 .pkg.tar.zst, repackaged from the .tar.gz the arch job
# FreeBSD x86_64 .tar.gz (built in a FreeBSD VM) packaging/freebsd
# macOS universal .dmg, and the CLI as a .zip (both archs built in parallel) packaging/macos
# Windows x64, x86, arm64 .msi and a portable .zip; x64 also the plain .exe packaging/windows
@@ -206,6 +207,45 @@ jobs:
path: dist/release/*.flatpak
if-no-files-found: error
arch:
name: Arch Linux ${{ matrix.arch }}
needs: [version, linux]
if: needs.version.outputs.build == 'true'
# makepkg only repackages the tarball's prebuilt binaries, so both architectures build here.
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
arch: [x86_64, aarch64]
env:
VERSION: ${{ needs.version.outputs.version }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: release-linux-${{ matrix.arch }}
path: dist/linux
# The Craft apps' shared Arch packaging (storytold/craft-common-actions, MIT OR Apache-2.0):
# makepkg in an archlinux:base-devel container. The package is named after the tarball's
# .desktop file (io.tangencylabs.solidcraft) and written as
# dist/release/solidcraft-<version>-linux-<arch>.pkg.tar.zst.
- uses: storytold/craft-common-actions/actions/arch-package@fa5ef18780cf5e7d5d3b8de068de56cffc83cac5 # main
with:
tarball: dist/linux/solidcraft-${{ env.VERSION }}-linux-${{ matrix.arch }}.tar.gz
arch: ${{ matrix.arch }}
- name: Check the package
env:
PKG: dist/release/solidcraft-${{ env.VERSION }}-linux-${{ matrix.arch }}.pkg.tar.zst
run: |
tar --zstd -tf "$PKG" | grep -E '^usr/bin/solidcraft$|^usr/bin/solidcraft-cli$|^usr/share/applications/io\.tangencylabs\.solidcraft\.desktop$'
tar --zstd -xOf "$PKG" .PKGINFO | grep -E '^(pkgname|pkgver|arch) = '
tar --zstd -xOf "$PKG" .PKGINFO | grep -qx 'pkgname = io.tangencylabs.solidcraft'
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-arch-${{ matrix.arch }}
path: dist/release/*.pkg.tar.zst
if-no-files-found: error
snap:
@@ -571,7 +611,7 @@ jobs:
release:
name: GitHub Release or dry-run artifacts
needs: [version, linux, flatpak, snap, freebsd, macos, windows, web]
needs: [version, linux, flatpak, arch, snap, freebsd, macos, windows, web]
if: needs.version.outputs.build == 'true'
runs-on: ubuntu-latest
permissions:
@@ -597,7 +637,7 @@ jobs:
shopt -s nullglob
for f in "solidcraft-$VERSION-"*; do
case "$f" in
*.msi | *-portable.zip | *.dmg | *.AppImage | *.deb | *.rpm | *.flatpak | *.snap)
*.msi | *-portable.zip | *.dmg | *.AppImage | *.deb | *.rpm | *.flatpak | *.snap | *.pkg.tar.zst)
cp -- "$f" "solidcraft-${f#"solidcraft-$VERSION-"}" ;;
esac
done
+2 -1
View File
@@ -117,7 +117,8 @@ The [architecture page](docs/architecture.md) has the layer table. The short ver
not.) Permissive
dependencies are fine. Note which one you're adding and why in the PR. THIRD-PARTY-LICENSES.txt
lists it once the change lands: the Generated files workflow rewrites it on `main`.
Nightly/manual CI also scans advisories, bans and sources with cargo-deny; see
Nightly/manual CI also scans advisories, bans and sources with cargo-deny, and a PR that
changes Cargo.lock or a Cargo.toml checks the advisories; see
[the local command and coverage limits](docs/book/src/dev/testing.md#dependency-advisories).
3. **Every asset is original or openly licensed and has a row in
[ATTRIBUTION.md](ATTRIBUTION.md).** Icons are drawn in code (`crates/ui-egui/src/icons.rs`).
@@ -87,7 +87,7 @@ fn run() -> Result<(), String> {
let gpu_host_start = Instant::now();
let policy = solidcraft_gpu::select::Policy::from_env().map_err(|e| e.to_string())?;
let (adapter, report) =
solidcraft_gpu::select::pick_vulkan(policy, |_| true).ok_or("no Vulkan adapter satisfies the requested GPU policy")?;
solidcraft_gpu::select::pick_path(policy, |_| true).ok_or("no Vulkan or Metal adapter satisfies the requested GPU policy")?;
session.register_render_job_host(solidcraft_gpu::path::host_on_adapter(&adapter).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
(Some(report), Some(gpu_host_start.elapsed().as_secs_f64()))
} else {
+14 -13
View File
@@ -24,9 +24,9 @@ const MAX_BAD_LINES: usize = 8;
const EVENT_GAP: Duration = Duration::from_millis(100);
static OPEN: AtomicUsize = AtomicUsize::new(0);
/// The token files this launch wrote (its control port, the agent chat's endpoint), removed
/// when the app ends.
static TOKEN_FILES: std::sync::Mutex<Vec<std::path::PathBuf>> = std::sync::Mutex::new(Vec::new());
/// The token files this launch wrote (its control port, the agent chat's endpoint) and the
/// token in each, removed when the app ends unless another launch has written the file since.
static TOKEN_FILES: std::sync::Mutex<Vec<(std::path::PathBuf, String)>> = std::sync::Mutex::new(Vec::new());
/// A new random token: 32 bytes from the operating system, as hex.
fn new_token() -> Option<String> {
@@ -35,20 +35,21 @@ fn new_token() -> Option<String> {
Some(bytes.iter().map(|b| format!("{b:02x}")).collect())
}
/// Remove this launch's token file (at exit).
/// Remove this launch's token files and discovery file (at exit), leaving any that another
/// launch has written since (#8794).
pub fn remove_token_file() {
if let Ok(files) = TOKEN_FILES.lock() {
for p in files.iter() {
control_auth::remove_token_file(p);
for (path, token) in files.iter() {
control_auth::remove_token_file(path, token);
}
}
if let Some(port) = APP_PORT.lock().ok().and_then(|p| *p) {
control_auth::remove_current_port(port);
if let Some((port, token)) = APP_PORT.lock().ok().and_then(|p| p.clone()) {
control_auth::remove_current_port(port, &token);
}
}
/// The port the app's own channel listens on, for removing the discovery file at exit.
static APP_PORT: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
/// The port the app's own channel listens on and its token, for removing the discovery file at exit.
static APP_PORT: std::sync::Mutex<Option<(u16, String)>> = std::sync::Mutex::new(None);
pub fn start(listen_on: control_auth::Listen, ctx: egui::Context) -> Receiver<ControlRequest> {
let (tx, rx) = channel::<ControlRequest>();
@@ -61,7 +62,7 @@ pub fn start(listen_on: control_auth::Listen, ctx: egui::Context) -> Receiver<Co
};
match listener {
Ok(l) => {
if let Some(port) = listen_on_listener(l, token, tx, ctx, false) {
if let Some(port) = listen_on_listener(l, token.clone(), tx, ctx, false) {
if matches!(listen_on, control_auth::Listen::Default) {
eprintln!("solidcraft: control channel on the default port 127.0.0.1:{port} (Preferences > Assistant turns it off)");
}
@@ -69,7 +70,7 @@ pub fn start(listen_on: control_auth::Listen, ctx: egui::Context) -> Receiver<Co
match control_auth::write_current_port(port) {
Ok(_) => {
if let Ok(mut p) = APP_PORT.lock() {
*p = Some(port);
*p = Some((port, token));
}
}
Err(e) => eprintln!("solidcraft: could not write the control discovery file ({e})"),
@@ -112,7 +113,7 @@ fn listen_on_listener(listener: TcpListener, token: String, tx: Sender<ControlRe
Ok(()) => {
eprintln!("solidcraft: control server listening on 127.0.0.1:{port} (token in {})", path.display());
if let Ok(mut files) = TOKEN_FILES.lock() {
files.push(path);
files.push((path, token.clone()));
}
}
Err(e) => eprintln!(
@@ -224,6 +224,7 @@ settings_monitor false
settings_mouse false
settings_palette false
settings_plug false
sheet false
sheet_rules false
shell false
shield false
+70
View File
@@ -652,6 +652,7 @@
"Third point": "النقطة الثالثة",
"This child is shared. Deleting it removes it from every copy of its parent.": "هذا العنصر الفرعي مشترك. حذفه يزيله من كل نسخة من العنصر الأب.",
"This child is shared. Position changes apply to every copy of its parent.": "هذا العنصر الفرعي مشترك. تغييرات الموضع تنطبق على كل نسخة من العنصر الأب.",
"This component is inside a subassembly placed more than once: aligning it moves it in every placement.": "هذا المكوّن داخل تجميع فرعي موضوع أكثر من مرة: محاذاته تحرّكه في كل موضع.",
"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.",
"Thread depth": "عمق السن اللولبي",
@@ -984,6 +985,7 @@
"cmd.motion_trace": "أثر الحركة",
"cmd.motion_trace_edit": "تحرير أثر الحركة",
"cmd.motion_trace_keep": "الإبقاء على أثر الحركة",
"cmd.occurrence_align": "محاذاة النسخة",
"cmd.occurrence_copy": "لصق (نسخة مرتبطة)",
"cmd.occurrence_drag": "سحب النسخة",
"cmd.occurrence_ground": "تثبيت بالأرض",
@@ -1369,6 +1371,7 @@
"help.material.assign": "يحدد المادة التي صُنعت منها الأجسام المحددة، مثل الفولاذ أو الألومنيوم أو ABS. استخدمه لتطابق الكتلة والوزن القطعة الحقيقية.",
"help.mit": "رخصة MIT",
"help.notice": "NOTICE",
"help.occurrence.move": "يحرّك جزءًا في التجميع فقط ضمن درجات الحرية التي تسمح بها مفاصله. حدد وجهًا للجزء، ثم اسحب سهمًا أو قوسًا متاحًا.",
"help.parameters.change": "يفتح جدول القيم المسماة والتعبيرات التي تتحكم في أبعاد التصميم. استخدمه لتغيير مقاس في مكان واحد فتتبعه كل الميزات التي تستخدمه.",
"help.pointcloud.insert": "أدرج مسحًا بصيغة PLY أو XYZ أو PTS كسحابة نقاط بجانب النموذج. استخدمه كمرجع عند النمذجة انطلاقًا من بيانات القياس.",
"help.solid.align": "يحرّك الأجسام المحددة بحيث تلتقي نقطة أو وجه أو مستوى منها بنقطة أو وجه أو مستوى في مكان آخر. استخدمه لوضع قطعة ملاصقة لأخرى دون قياس المسافة.",
@@ -1499,6 +1502,21 @@
"joint.one_component": "هذه القطع أجسام لمكوّن واحد: {bodies}. المفصل يحرّك مكوّنًا مقابل آخر، لذا تحتاج كل قطعة إلى مكوّن خاص بها.",
"joint.rigid_group_one_component": "هذه الأجسام وعددها {count} كلها في مكوّن واحد (أو في التصميم نفسه): استخدم {command} أولًا.",
"joint.rigid_group_select_components": "اختر مكوّنين أو أكثر.",
"joint_move.blocked_hint": "تحقق من نوع المفصل (مثل المفصل الثابت)، وتثبيت الجزء، وحدود المفصل، والقيم التي تتحكم بها المعلمات.",
"joint_move.blocked_title": "لا يمكن تحريك هذا الجزء",
"joint_move.coarse": "خطوة الدوران قرب المحور (°)",
"joint_move.distance": "المسافة من نقطة البداية",
"joint_move.done": "تم",
"joint_move.fine": "خطوة الدوران بعيدًا عن المحور (°)",
"joint_move.fixed": "لا يملك هذا الجزء محور مفصل قابلًا للحركة.",
"joint_move.help": "تحريك — اسحب سهمًا أو قوسًا. يلغى السحب الحالي بمفتاح Esc.",
"joint_move.legacy_hint": "أوقف هذا الخيار لاستخدام لوحة التحريك السابقة.",
"joint_move.precision": "اسحب خارج الدليل المتقطع لضبط الدوران بدقة.",
"joint_move.rotation": "الدوران من نقطة البداية",
"joint_move.select": "اختر وجهًا أو جزءًا واحدًا من التجميع، ثم اختر تحريك.",
"joint_move.settings": "تحريك التجميع",
"joint_move.step": "الخطوة",
"joint_move.use_handles": "استخدام مقابض حركة المفاصل لتحريك التجميع",
"keymap.command_id": "معرّف الأمر",
"keymap.empty": "لا توجد أوامر مطابقة",
"keymap.use_anyway": "استخدام {key} هنا على أي حال",
@@ -1593,6 +1611,8 @@
"nav.maya.help": "Alt مع السحب بالزر الأيسر يدوّر العرض، وAlt مع الأوسط يُزيحه، وAlt مع الأيمن يكبّره؛ والسحب بالزر الأوسط يُزيح أيضًا",
"nav.openscad.help": "السحب بالزر الأيسر على مساحة فارغة يدوّر العرض، وShift مع السحب بالأيسر يحدد بمربع، وسحب هندسة الرسم يعدّلها؛ والسحب بالأيمن أو الأوسط يُزيح، وShift مع السحب بالأيمن أو الأوسط يكبّر",
"nav.perspective.sketch": "تحرير الرسم يستخدم العرض المتعامد. أنهِ الرسم للتبديل إلى المنظور.",
"nav.right_drag_orbits.help": "عند الإيقاف: لا يحرّك السحب بالزر الأيمن الكاميرا، ويفتح الزر الأيمن قائمة السياق فقط. اختيار تعيين أزرار الفأرة SolidWorks يوقفه.",
"nav.right_drag_orbits.label": "السحب بالزر الأيمن يدوّر العرض",
"nav.solidworks.help": "السحب بالزر الأوسط يدوّر العرض، وCtrl مع الأوسط يُزيحه، وShift مع الأوسط يكبّره، والسحب بالأيمن يدوّره",
"nav.tinkercad.help": "السحب بالزر الأيمن يدوّر العرض، والسحب بالأوسط أو Shift مع الأيمن يُزيحه",
"nav.touchpad.help": "اضغط Shift مع تحريك المؤشر للإزاحة؛ واضغط Alt/Option مع تحريكه للتدوير. لا حاجة لأي زر فأرة. التمرير العمودي يكبّر ويصغّر.",
@@ -1614,6 +1634,9 @@
"navbar.custom.customise": "تخصيص شريط التنقل…",
"navbar.custom.design": "التصميم",
"navbar.custom.duplicate": "هذه الخانة موجودة بالفعل على الشريط.",
"navbar.custom.edge": "الموضع",
"navbar.custom.edge_bottom": "أسفل",
"navbar.custom.edge_top": "أعلى",
"navbar.custom.fade": "تلاشٍ",
"navbar.custom.full": "أزل خانة لإضافة أخرى.",
"navbar.custom.hidden_design": "مخفي في التصميم",
@@ -1778,6 +1801,9 @@
"prefs.bottom_centre": "أسفل الوسط",
"prefs.bottom_right": "أسفل اليمين",
"prefs.browser": "المستعرض",
"prefs.browser_reveal_on_hover": "إظهار عند التمرير",
"prefs.browser_show_note": "«ظاهر» يُبقي المستعرض على الشاشة. «إظهار عند التمرير» يُخفيه حتى يمر المؤشر فوق مكانه أو فوق الشريط الرفيع عند حافته اليسرى، ويبقى مفتوحًا ما دام المؤشر داخله أو كانت قائمة منه مفتوحة.",
"prefs.browser_shown": "ظاهر",
"prefs.built_in": "مدمج",
"prefs.button_left": "سحب بالزر الأيسر",
"prefs.button_middle": "سحب بالزر الأوسط",
@@ -1883,10 +1909,14 @@
"prefs.placement": "الموضع",
"prefs.prefer_free_sketch_points": "تفضيل نقاط الرسم القابلة للتحريك عند تداخل النقاط",
"prefs.preview_choices": "معاينة اختياراتك",
"prefs.project_options_dialog": "مربع حوار خيارات Project",
"prefs.project_options_dialog_note": "تشغيل: اختر الهندسة أو الأجسام ورابط الإسقاط قبل الإسقاط. إيقاف: الإبقاء على سير العمل السابق بالاختيار المباشر.",
"prefs.quality_default": "افتراضي",
"prefs.quality_fastest": "الأسرع",
"prefs.quality_slowest": "الأبطأ",
"prefs.quality_smoother": "أنعم",
"prefs.render_previous_geometry_budget": "استخدام ميزانية الهندسة السابقة لمعاينة التصيير",
"prefs.render_previous_geometry_budget_help": "استعادة حد الهندسة الثابت السابق (512 ميبيبايت على سطح المكتب 64 بت، و128 ميبيبايت على المنصات الأخرى) بدلًا من الميزانية المعتمدة على الجهاز.",
"prefs.reports_saved": "{n} محفوظة",
"prefs.right_click": "نقر أيمن",
"prefs.saving": "الحفظ التلقائي والاستعادة",
@@ -1952,7 +1982,14 @@
"prefs.toast_place": "الإشعارات",
"prefs.toggle_item": "إضافة العنصر، أو إزالته مرة أخرى",
"prefs.toolbar": "شريط الأدوات",
"prefs.toolbar_tabs": "علامات تبويب شريط الأدوات",
"prefs.top": "أعلى",
"prefs.trackpad_zoom_modifier": "التكبير بالتمرير بإصبعين أثناء الضغط على",
"prefs.trackpad_zoom_modifier.alt": "⌥ / Alt",
"prefs.trackpad_zoom_modifier.cmd": "⌘",
"prefs.trackpad_zoom_modifier.ctrl": "Ctrl",
"prefs.trackpad_zoom_modifier.help": "اضغط على هذا المفتاح فقط للتكبير عند المؤشر بالتمرير العمودي. يحافظ الإيقاف على سلوك التمرير الحالي.",
"prefs.trackpad_zoom_modifier.off": "إيقاف",
"prefs.translucent": "شفاف",
"prefs.under_browser": "تحت المستعرض",
"prefs.under_cursor": "تحت المؤشر",
@@ -1962,6 +1999,9 @@
"prefs.view_confirm_corner": "موافق وإلغاء في زاوية العرض",
"prefs.view_confirm_corner_note": "أثناء فتح مربع حوار أو رسم، تؤدي علامة الصح وعلامة الإكس أعلى يمين العرض ما يؤديه زرّا موافق وإلغاء في مربع الحوار، أو تنهي الرسم وتلغيه. وينتقل متصفح العرض إلى أسفلهما.",
"prefs.viewport": "منطقة العرض",
"prefs.viewport_background": "الخلفية",
"prefs.viewport_background_colour": "لون",
"prefs.viewport_background_theme": "تدرج السمة",
"prefs.wheel": "العجلة",
"prefs.wheel_trackpad": "العجلة ولوحة اللمس",
"prefs.workspace": "مساحة العمل",
@@ -3296,6 +3336,7 @@
"select an axis": "حدد محورًا",
"select an axis, a straight or circular edge, or a cylinder, cone or torus face first": "حدد أولًا محورًا أو حافة مستقيمة أو دائرية أو وجه أسطوانة أو مخروط أو طارة",
"select bodies first": "حدد الأجسام أولًا",
"select components first": "حدد المكوّنات أولًا",
"select corner points, or the two lines of one corner": "حدد نقاط الزوايا، أو الخطين اللذين يكوّنان زاوية واحدة",
"select one corner point, or the two lines of one corner": "حدد نقطة زاوية واحدة، أو الخطين اللذين يكوّنان زاوية واحدة",
"select sheet edges first": "حدد حواف الصفيحة أولًا",
@@ -3372,6 +3413,8 @@
"sketch_text.use_font_file": "استخدام ملف الخط {path}",
"solid": "مصمت",
"status.editing": "تحرير {name}",
"status.navigation": "تعيين أزرار الفأرة: {mapping}",
"status.navigation_tip": "كيف تحرّك الفأرة العرض. انقر لاختيار تعيين آخر",
"status.under_constrained": "قيود ناقصة ({count} درجات حرية)",
"status.under_constrained_one": "قيود ناقصة ({count} درجة حرية)",
"status.units_tip": "وحدات التصميم. انقر لتغييرها",
@@ -4220,6 +4263,7 @@
"ui.pan_zoom_orbit": "إزاحة، تكبير، تدوير",
"ui.parallel": "متوازي",
"ui.parameters": "المعاملات",
"ui.part_size_needs_a_part": "حدد قطعة قياسية مُدرجة (برغي أو صامولة أو حلقة مسطحة أو مسمار) لتغيير مقاسها. لتكبير جسم أو تصغيره، استخدم «تحجيم».",
"ui.part_size_shared": "يعيد بناء القطعة مستبدلًا أي ميزات مضافة. تتحدث كل نسخ هذا المكوّن.",
"ui.paste": "لصق",
"ui.path": "المسار",
@@ -4269,6 +4313,18 @@
"ui.project": "إسقاط",
"ui.project_face_edges_sketch": "إسقاط حواف الوجه في رسومات جديدة",
"ui.properties": "الخصائص",
"ui.properties.copy_matrix": "نسخ المصفوفة",
"ui.properties.first_moments": "العزوم الأولى",
"ui.properties.inertia": "عزم القصور الذاتي",
"ui.properties.inertia_note": "حول مركز الكتلة، بمحاور العالم (g·{unit}²)",
"ui.properties.inertia_unavailable": "غير متاح: شبكة أحد الأجسام غير مغلقة.",
"ui.properties.inertia_x": "Ixx, Ixy, Ixz",
"ui.properties.inertia_y": "Iyx, Iyy, Iyz",
"ui.properties.inertia_z": "Izx, Izy, Izz",
"ui.properties.measuring": "جارٍ القياس…",
"ui.properties.measuring_failed": "توقف القياس قبل أن يكتمل.",
"ui.properties.principal_axis": "المحور الرئيسي {number}",
"ui.properties.principal_moments": "العزوم الرئيسية",
"ui.pull_direction": "اتجاه السحب",
"ui.quality": "الجودة",
"ui.quantity": "الكمية",
@@ -4367,7 +4423,11 @@
"ui.sketch": "رسم",
"ui.sketch_dimension": "بُعد الرسم",
"ui.sketch_grid": "شبكة الرسم",
"ui.sketch_intersect_geometry": "الهندسة",
"ui.sketch_intersect_projection_link": "رابط الإسقاط",
"ui.sketch_intersect_selection_filter": "مرشح التحديد",
"ui.sketch_modifiers": "مُعدِّلات الرسم",
"ui.sketch_project_geometry": "الهندسة",
"ui.sketch_scale": "تحجيم الرسم",
"ui.sketches": "الرسومات",
"ui.slice": "تقطيع",
@@ -4414,6 +4474,11 @@
"ui.symmetric": "متماثل",
"ui.symmetry": "التماثل",
"ui.system": "النظام",
"ui.tab_assemble": "التجميع",
"ui.tab_manage": "إدارة",
"ui.tab_sheet_metal": "الصفائح المعدنية",
"ui.tab_view": "العرض",
"ui.tab_weldments": "اللحامات",
"ui.tangent": "مماس",
"ui.taper_angle": "زاوية الاستدقاق",
"ui.tapped": "ملولب",
@@ -4429,6 +4494,7 @@
"ui.tip_angle": "زاوية الرأس",
"ui.to_object": "إلى كائن",
"ui.tool_direction": "اتجاه الأداة",
"ui.toolbar_tabs_hidden": "جميع علامات تبويب شريط الأدوات مخفية. انقر على + أو انقر هنا بزر الفأرة الأيمن لإظهار إحداها.",
"ui.top": "أعلى",
"ui.top_diameter": "القطر العلوي",
"ui.top_radius": "نصف القطر العلوي",
@@ -4475,6 +4541,7 @@
"ui.visual_shaded_with_edges": "مظلل مع الحواف",
"ui.visual_style": "نمط العرض",
"ui.visual_wireframe": "إطار سلكي",
"ui.wheel_zoom_speed": "سرعة التكبير بعجلة الفأرة",
"ui.width": "العرض",
"ui.window_system": "نظام النوافذ",
"ui.window_system_auto": "تلقائي",
@@ -4528,6 +4595,9 @@
"update.prefs_managed": "هذا هو SolidCraft {version}، ويديره {manager}. يسأل الفحص GitHub عن أحدث إصدار ولا يرسل شيئًا آخر؛ حدّث SolidCraft من داخل {manager}.",
"update.prefs_note": "هذا SolidCraft {version} ({kind}). التحقق يسأل GitHub عن أحدث إصدار ولا يرسل أي شيء آخر.",
"update.prefs_web": "أعد تحميل الصفحة للحصول على أحدث إصدار من تطبيق الويب. إذا لم تتوفر الشبكة أو لم يكتمل تنزيل التحديث في الوقت المناسب، يُفتح الإصدار المحفوظ بدلًا منه؛ ويستمر التنزيل الجاري حتى إعادة التحميل التالية.",
"update.refresh_checking": "جارٍ البحث عن أحدث إصدار…",
"update.refresh_fallback": "تعذّر البحث عن إصدار أحدث؛ سيتم التحديث إلى {version} الذي عُثر عليه سابقًا.",
"update.refresh_selected": "جارٍ التحديث إلى {version}.",
"update.release_page": "فتح صفحة الإصدار",
"update.restart": "إعادة التشغيل الآن",
"update.restart_later": "لاحقًا",
+1
View File
@@ -3975,6 +3975,7 @@
"ui.pan_zoom_orbit": "Pomjeranje, zumiranje, orbitiranje",
"ui.parallel": "Paralelnost",
"ui.parameters": "Parametri",
"ui.part_size_needs_a_part": "Odaberite umetnuti standardni dio (vijak, maticu, podlošku ili zatik) da biste mu promijenili veličinu. Da tijelo povećate ili smanjite, koristite Skaliraj.",
"ui.part_size_shared": "Ponovo gradi dio, zamjenjujući sve dodane operacije. Svi primjerci ove komponente se ažuriraju.",
"ui.paste": "Zalijepi",
"ui.path": "Putanja",
+5
View File
@@ -3950,6 +3950,7 @@
"ui.pan_zoom_orbit": "Posun, přiblížení, otáčení",
"ui.parallel": "Rovnoběžnost",
"ui.parameters": "Parametry",
"ui.part_size_needs_a_part": "Vyberte vložený normalizovaný díl (šroub, matici, podložku nebo kolík), jehož velikost chcete změnit. Těleso zvětšíte nebo zmenšíte příkazem Měřítko.",
"ui.part_size_shared": "Přestaví díl a nahradí všechny přidané prvky. Aktualizují se všechny výskyty této komponenty.",
"ui.paste": "Vložit",
"ui.path": "Cesta",
@@ -4144,6 +4145,10 @@
"ui.symmetric": "Symetricky",
"ui.symmetry": "Symetrie",
"ui.system": "Systém",
"ui.tab_assemble": "Sestava",
"ui.tab_manage": "Správa",
"ui.tab_sheet_metal": "Plech",
"ui.tab_view": "Pohled",
"ui.tangent": "Tečnost",
"ui.taper_angle": "Úhel zúžení",
"ui.tapped": "Se závitem",
+5
View File
@@ -2202,6 +2202,7 @@
"ui.pan_zoom_orbit": "Panorér, zoom, rotér visning",
"ui.parallel": "Parallel",
"ui.parameters": "Parametre",
"ui.part_size_needs_a_part": "Vælg en indsat standarddel (en skrue, møtrik, skive eller stift) for at ændre dens størrelse. Brug Skalering til at gøre et emne større eller mindre.",
"ui.part_size_shared": "Genopbygger delen og erstatter alle tilføjede features. Alle forekomster af komponenten opdateres.",
"ui.paste": "Indsæt",
"ui.path": "Bane",
@@ -2354,6 +2355,10 @@
"ui.symmetric": "Symmetrisk",
"ui.symmetry": "Symmetri",
"ui.system": "System",
"ui.tab_assemble": "Samling",
"ui.tab_manage": "Administrér",
"ui.tab_sheet_metal": "Plade",
"ui.tab_view": "Visning",
"ui.tangent": "Tangent",
"ui.taper_angle": "Konusvinkel",
"ui.tapped": "Med gevind",
+50 -1
View File
@@ -490,7 +490,7 @@
"Report text copied. Paste it into GitHub and submit your report there.": "Berichtstext kopiert. Fügen Sie ihn auf GitHub ein und reichen Sie den Bericht dort ein.",
"Reports are public. A GitHub account is required.": "Berichte sind öffentlich. Ein GitHub-Konto ist erforderlich.",
"Reset Toolbar": "Symbolleiste zurücksetzen",
"Resize Part": "Teil skalieren",
"Resize Part": "Bauteilgröße ändern",
"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",
@@ -1565,6 +1565,8 @@
"nav.maya.help": "Alt+Linksziehen dreht, Alt+Mitte verschiebt, Alt+Rechtsziehen zoomt; mittleres Ziehen verschiebt ebenfalls",
"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.perspective.sketch": "Beim Bearbeiten einer Skizze wird die orthografische Ansicht verwendet. Beenden Sie die Skizze, um die Perspektive umzuschalten.",
"nav.right_drag_orbits.help": "Aus: rechtes Ziehen lässt die Kamera unverändert, die rechte Maustaste öffnet nur das Kontextmenü. Die Mausbelegung SolidWorks schaltet es beim Auswählen aus.",
"nav.right_drag_orbits.label": "Rechtes Ziehen dreht",
"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.",
@@ -1586,6 +1588,9 @@
"navbar.custom.customise": "Navigationsleiste anpassen…",
"navbar.custom.design": "Entwurf",
"navbar.custom.duplicate": "Dieses Feld ist bereits in der Leiste.",
"navbar.custom.edge": "Rand",
"navbar.custom.edge_bottom": "Unten",
"navbar.custom.edge_top": "Oben",
"navbar.custom.fade": "Abblenden",
"navbar.custom.full": "Entfernen Sie ein Feld, um ein weiteres hinzuzufügen.",
"navbar.custom.hidden_design": "Im Entwurf ausgeblendet",
@@ -1748,6 +1753,9 @@
"prefs.bottom_centre": "Unten mittig",
"prefs.bottom_right": "Unten rechts",
"prefs.browser": "Browser",
"prefs.browser_reveal_on_hover": "Beim Überfahren einblenden",
"prefs.browser_show_note": "Angezeigt lässt den Browser auf dem Bildschirm. Beim Überfahren einblenden blendet ihn aus, bis der Zeiger über seinen Platz oder die schmale Lasche an seinem linken Rand kommt; er bleibt offen, solange der Zeiger darin ist oder ein Menü daraus offen ist.",
"prefs.browser_shown": "Angezeigt",
"prefs.built_in": "Integriert",
"prefs.button_left": "Ziehen mit der linken Maustaste",
"prefs.button_middle": "Ziehen mit der mittleren Maustaste",
@@ -1829,6 +1837,8 @@
"prefs.minutes_suffix": " min",
"prefs.origin_plane_names": "Namen der Ursprungsebenen",
"prefs.origin_plane_names_note": "Namen in Browser, Dialogen und Listen. {top} ist XY, {front} ist XZ und {right} ist YZ, mit Z nach oben. Befehle, Skripte und gespeicherte Konstruktionen behalten XY, XZ und YZ.",
"prefs.project_options_dialog": "Project-Optionendialog",
"prefs.project_options_dialog_note": "Ein: Vor dem Projizieren Geometrie oder Körper und die Projektionsverknüpfung auswählen. Aus: Den bisherigen Direktwahl-Ablauf beibehalten.",
"prefs.search": "Einstellungen suchen",
"prefs.search_empty": "Keine passenden Einstellungen",
"prefs.sketch_conflict_colour": "Widersprüchliche Geometrie in der Konfliktfarbe",
@@ -1857,6 +1867,16 @@
"prefs.theme_paste": "Design einfügen",
"prefs.theme_paste_none": "Die Zwischenablage enthält kein SolidCraft-Design",
"prefs.toast_place": "Hinweise",
"prefs.toolbar_tabs": "Werkzeugleisten-Registerkarten",
"prefs.trackpad_zoom_modifier": "Mit zwei Fingern zoomen bei gedrückter Taste",
"prefs.trackpad_zoom_modifier.alt": "⌥ / Alt",
"prefs.trackpad_zoom_modifier.cmd": "⌘",
"prefs.trackpad_zoom_modifier.ctrl": "Strg",
"prefs.trackpad_zoom_modifier.help": "Nur diese Taste gedrückt halten, um durch vertikales Scrollen am Zeiger zu zoomen. Aus behält das bisherige Scrollverhalten bei.",
"prefs.trackpad_zoom_modifier.off": "Aus",
"prefs.viewport_background": "Hintergrund",
"prefs.viewport_background_colour": "Farbe",
"prefs.viewport_background_theme": "Verlauf des Designs",
"properties.title": "Eigenschaften: {name}",
"recovery.days_ago": "vor {count} Tagen",
"recovery.detail": "{file} · {features} Merkmale · automatisch gespeichert {when}",
@@ -2801,6 +2821,8 @@
"sketch_text.use_font_file": "Schriftdatei {path} verwenden",
"solid": "Volumenkörper",
"status.editing": "{name} wird bearbeitet",
"status.navigation": "Mausbelegung: {mapping}",
"status.navigation_tip": "Wie die Maus die Ansicht bewegt. Zum Wählen einer anderen Belegung klicken",
"status.under_constrained": "Unterbestimmt ({count} Freiheitsgrade)",
"status.under_constrained_one": "Unterbestimmt ({count} Freiheitsgrad)",
"status.units_tip": "Die Einheiten der Konstruktion. Zum Ändern klicken",
@@ -3569,6 +3591,7 @@
"ui.pan_zoom_orbit": "Verschieben, zoomen, umkreisen",
"ui.parallel": "Parallel",
"ui.parameters": "Parameter",
"ui.part_size_needs_a_part": "Wählen Sie ein eingefügtes Standardbauteil (Schraube, Mutter, Scheibe oder Stift) aus, um seine Größe zu ändern. Einen Körper vergrößern oder verkleinern Sie mit Skalieren.",
"ui.part_size_shared": "Baut das Teil neu auf und ersetzt alle hinzugefügten Merkmale. Alle Vorkommen dieser Komponente werden aktualisiert.",
"ui.paste": "Einfügen",
"ui.path": "Pfad",
@@ -3609,6 +3632,18 @@
"ui.project": "Projizieren",
"ui.project_face_edges_sketch": "Flächenkanten in neue Skizzen projizieren",
"ui.properties": "Eigenschaften",
"ui.properties.copy_matrix": "Matrix kopieren",
"ui.properties.first_moments": "Statische Momente",
"ui.properties.inertia": "Trägheitsmoment",
"ui.properties.inertia_note": "Um den Massenschwerpunkt, in Weltachsen (g·{unit}²)",
"ui.properties.inertia_unavailable": "Nicht verfügbar: Das Netz eines Körpers ist nicht geschlossen.",
"ui.properties.inertia_x": "Ixx, Ixy, Ixz",
"ui.properties.inertia_y": "Iyx, Iyy, Iyz",
"ui.properties.inertia_z": "Izx, Izy, Izz",
"ui.properties.measuring": "Wird gemessen…",
"ui.properties.measuring_failed": "Die Messung wurde vor dem Ende abgebrochen.",
"ui.properties.principal_axis": "Hauptachse {number}",
"ui.properties.principal_moments": "Hauptträgheitsmomente",
"ui.pull_direction": "Entformungsrichtung",
"ui.quality": "Qualität",
"ui.quantity": "Anzahl",
@@ -3699,7 +3734,11 @@
"ui.sketch": "Skizze",
"ui.sketch_dimension": "Skizzenmaß",
"ui.sketch_grid": "Skizzenraster",
"ui.sketch_intersect_geometry": "Geometrie",
"ui.sketch_intersect_projection_link": "Projektionsverknüpfung",
"ui.sketch_intersect_selection_filter": "Auswahlfilter",
"ui.sketch_modifiers": "Skizzenmodifikatoren",
"ui.sketch_project_geometry": "Geometrie",
"ui.sketch_scale": "Skizzenskalierung",
"ui.sketches": "Skizzen",
"ui.slice": "Scheibe",
@@ -3735,6 +3774,11 @@
"ui.symmetric": "Symmetrisch",
"ui.symmetry": "Symmetrie",
"ui.system": "System",
"ui.tab_assemble": "Zusammenbau",
"ui.tab_manage": "Verwalten",
"ui.tab_sheet_metal": "Blech",
"ui.tab_view": "Ansicht",
"ui.tab_weldments": "Schweissteile",
"ui.tangent": "Tangential",
"ui.taper_angle": "Kegelwinkel",
"ui.tapped": "Gewindebohrung",
@@ -3750,6 +3794,7 @@
"ui.tip_angle": "Spitzenwinkel",
"ui.to_object": "Bis Objekt",
"ui.tool_direction": "Werkzeugrichtung",
"ui.toolbar_tabs_hidden": "Alle Werkzeugleisten-Registerkarten sind ausgeblendet. Klicken Sie auf + oder hier mit der rechten Maustaste, um eine einzublenden.",
"ui.top": "Oben",
"ui.top_diameter": "Oberer Durchmesser",
"ui.top_radius": "Oberer Radius",
@@ -3794,6 +3839,7 @@
"ui.visual_shaded_with_edges": "Schattiert mit Kanten",
"ui.visual_style": "Ansichtsstil",
"ui.visual_wireframe": "Drahtmodell",
"ui.wheel_zoom_speed": "Zoomgeschwindigkeit des Mausrads",
"ui.width": "Breite",
"ui.workspace": "Arbeitsbereich",
"ui.workspace_animation_description": "Explosionsansichten, Bewegungsstudien und Storyboards",
@@ -3841,6 +3887,9 @@
"update.prefs_label": "Updates",
"update.prefs_note": "Dies ist SolidCraft {version} ({kind}). Die Suche fragt GitHub nach dem neuesten Release und sendet sonst nichts.",
"update.prefs_web": "Laden Sie die Seite neu, um die neueste Web-App zu erhalten. Ist das Netzwerk nicht verfügbar oder kann das Update nicht rechtzeitig vollständig heruntergeladen werden, wird die gespeicherte Version geöffnet. Ein laufender Download wird für das nächste Neuladen fortgesetzt.",
"update.refresh_checking": "Neueste Version wird gesucht…",
"update.refresh_fallback": "Die Suche nach einer neueren Version ist fehlgeschlagen; die zuvor gefundene Version {version} wird installiert.",
"update.refresh_selected": "Version {version} wird installiert.",
"update.release_page": "Release-Seite öffnen",
"update.restart": "Jetzt neu starten",
"update.restart_later": "Später",
+2
View File
@@ -51,6 +51,7 @@
"prefs.theme_follow_off": "Off",
"prefs.theme_follow_stop": "Stop following",
"prefs.view": "View behaviour",
"prefs.viewport_background_colour": "Colour",
"render.aging_add": "Add aging layer",
"render.aging_airborne": "Airborne dust / participating medium",
"render.aging_airborne_help": "Airborne dust is a separate finite homogeneous scattering volume. It changes transport and raw EXR, not the surface-aging stack. Existing volume/glass/denoising restrictions remain explicit; no atmosphere is added automatically.",
@@ -168,6 +169,7 @@
"ui.point_cloud_file_colors": "File Colours",
"ui.point_cloud_no_file_colors": "The file has no colours",
"ui.point_cloud_single_color": "Single Colour",
"ui.properties.inertia_note": "About the centre of mass, in world axes (g·{unit}²)",
"ui.ribbon_labels": "Show text under ribbon icons",
"ui.workspace_layout": "Workspace layout",
"ui.zoom_to_cursor_tip": "The wheel zooms towards the point under the cursor (off: towards the view's centre)",
+70
View File
@@ -652,6 +652,7 @@
"Third point": "Third point",
"This child is shared. Deleting it removes it from every copy of its parent.": "This child is shared. Deleting it removes it from every copy of its parent.",
"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 component is inside a subassembly placed more than once: aligning it moves it in every placement.": "This component is inside a subassembly placed more than once: aligning it moves it in every placement.",
"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.",
"Thread depth": "Thread depth",
@@ -984,6 +985,7 @@
"cmd.motion_trace": "Motion Trace",
"cmd.motion_trace_edit": "Edit Motion Trace",
"cmd.motion_trace_keep": "Keep Motion Trace",
"cmd.occurrence_align": "Align Occurrence",
"cmd.occurrence_copy": "Paste (Instance)",
"cmd.occurrence_drag": "Drag Occurrence",
"cmd.occurrence_ground": "Ground",
@@ -1369,6 +1371,7 @@
"help.material.assign": "Sets what the selected bodies are made of, such as steel, aluminium or ABS. Use it so that the mass and weight match the real part.",
"help.mit": "MIT licence",
"help.notice": "NOTICE",
"help.occurrence.move": "Moves an assembly part only along freedoms allowed by its joints. Select a part face, then drag an available arrow or arc.",
"help.parameters.change": "Opens the table of named values and expressions that drive the dimensions of the design. Use it to change a size in one place and let every feature that uses it follow.",
"help.pointcloud.insert": "Insert a PLY, XYZ or PTS scan as a point cloud alongside the model. Use it as a reference when building a model from measured data.",
"help.solid.align": "Moves the selected bodies so that a point, face or plane on them meets a point, face or plane elsewhere. Use it to place a part against another one without measuring the offset.",
@@ -1499,6 +1502,21 @@
"joint.one_component": "These parts are bodies of one component: {bodies}. A joint moves one component against another, so each part needs a component of its own.",
"joint.rigid_group_one_component": "These {count} bodies are all in one component (or in the design itself): use {command} first.",
"joint.rigid_group_select_components": "Select two or more components.",
"joint_move.blocked_hint": "Check the joint type (such as Rigid), grounding, joint limits, and parameter-driven values.",
"joint_move.blocked_title": "Cannot move this part",
"joint_move.coarse": "Near-axis rotation step (°)",
"joint_move.distance": "Distance from start",
"joint_move.done": "Done",
"joint_move.fine": "Far-axis rotation step (°)",
"joint_move.fixed": "This part has no movable joint axis.",
"joint_move.help": "Move — drag an arrow or ring. Esc cancels the current drag.",
"joint_move.legacy_hint": "Turn off to use the previous Move panel.",
"joint_move.precision": "Pull outside the dashed guide for fine rotation.",
"joint_move.rotation": "Rotation from start",
"joint_move.select": "Select a face or one assembly part, then choose Move.",
"joint_move.settings": "Assembly Move",
"joint_move.step": "Step",
"joint_move.use_handles": "Use joint motion handles for assembly Move",
"keymap.command_id": "Command ID",
"keymap.empty": "No matching commands",
"keymap.use_anyway": "Use {key} here anyway",
@@ -1593,6 +1611,8 @@
"nav.maya.help": "Alt+left drag orbits, Alt+middle pans, Alt+right zooms; middle drag also pans",
"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.perspective.sketch": "Sketch editing uses orthographic view. Finish the sketch to switch perspective.",
"nav.right_drag_orbits.help": "Off: a right drag leaves the camera alone and the right button only opens the context menu. Picking the SolidWorks mouse mapping turns it off.",
"nav.right_drag_orbits.label": "Right drag orbits",
"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.",
@@ -1614,6 +1634,9 @@
"navbar.custom.customise": "Customise navigation bar…",
"navbar.custom.design": "Design",
"navbar.custom.duplicate": "This slot is already on the bar.",
"navbar.custom.edge": "Edge",
"navbar.custom.edge_bottom": "Bottom",
"navbar.custom.edge_top": "Top",
"navbar.custom.fade": "Fade",
"navbar.custom.full": "Remove a slot to add another.",
"navbar.custom.hidden_design": "Hidden in Design",
@@ -1778,6 +1801,9 @@
"prefs.bottom_centre": "Bottom centre",
"prefs.bottom_right": "Bottom right",
"prefs.browser": "Browser",
"prefs.browser_reveal_on_hover": "Reveal on hover",
"prefs.browser_show_note": "Shown keeps the browser on screen. Reveal on hover keeps it out of sight until the pointer comes over its place or the thin tab at its left edge, and it stays open while the pointer is inside it or a menu from it is open.",
"prefs.browser_shown": "Shown",
"prefs.built_in": "Built in",
"prefs.button_left": "Left drag",
"prefs.button_middle": "Middle drag",
@@ -1883,10 +1909,14 @@
"prefs.placement": "Placement",
"prefs.prefer_free_sketch_points": "Prefer movable sketch points when points overlap",
"prefs.preview_choices": "Preview of your choices",
"prefs.project_options_dialog": "Project options dialog",
"prefs.project_options_dialog_note": "On: choose Geometry or Bodies and Projection Link before projecting. Off: keep the previous direct-pick workflow.",
"prefs.quality_default": "default",
"prefs.quality_fastest": "fastest",
"prefs.quality_slowest": "slowest",
"prefs.quality_smoother": "smoother",
"prefs.render_previous_geometry_budget": "Use previous Render preview geometry budget",
"prefs.render_previous_geometry_budget_help": "Restore the previous fixed geometry limit (512 MiB on 64-bit desktop, 128 MiB elsewhere) instead of the device-based budget.",
"prefs.reports_saved": "{n} saved",
"prefs.right_click": "Right click",
"prefs.saving": "Autosave and recovery",
@@ -1952,7 +1982,14 @@
"prefs.toast_place": "Notices",
"prefs.toggle_item": "Add the item, or take it out again",
"prefs.toolbar": "Toolbar",
"prefs.toolbar_tabs": "Toolbar tabs",
"prefs.top": "Top",
"prefs.trackpad_zoom_modifier": "Zoom with two-finger scroll while holding",
"prefs.trackpad_zoom_modifier.alt": "⌥ / Alt",
"prefs.trackpad_zoom_modifier.cmd": "⌘",
"prefs.trackpad_zoom_modifier.ctrl": "Ctrl",
"prefs.trackpad_zoom_modifier.help": "Hold only this key to zoom at the pointer with vertical scroll. Off keeps the existing scroll behaviour.",
"prefs.trackpad_zoom_modifier.off": "Off",
"prefs.translucent": "Translucent",
"prefs.under_browser": "Under browser",
"prefs.under_cursor": "Under cursor",
@@ -1962,6 +1999,9 @@
"prefs.view_confirm_corner": "OK and Cancel in the view's corner",
"prefs.view_confirm_corner_note": "While a dialog or a sketch is open, a check and a cross at the top right of the view do what the dialog's OK and Cancel do, or finish and cancel the sketch. The view navigator moves down below them.",
"prefs.viewport": "Viewport",
"prefs.viewport_background": "Background",
"prefs.viewport_background_colour": "Color",
"prefs.viewport_background_theme": "Theme's gradient",
"prefs.wheel": "Wheel",
"prefs.wheel_trackpad": "Wheel and trackpad",
"prefs.workspace": "Workspace",
@@ -3296,6 +3336,7 @@
"select an axis": "select an axis",
"select an axis, a straight or circular edge, or a cylinder, cone or torus face first": "select an axis, a straight or circular edge, or a cylinder, cone or torus face first",
"select bodies first": "select bodies first",
"select components first": "select components first",
"select corner points, or the two lines of one corner": "select corner points, or the two lines of one corner",
"select one corner point, or the two lines of one corner": "select one corner point, or the two lines of one corner",
"select sheet edges first": "select sheet edges first",
@@ -3372,6 +3413,8 @@
"sketch_text.use_font_file": "Use the font file {path}",
"solid": "solid",
"status.editing": "Editing {name}",
"status.navigation": "Mouse mapping: {mapping}",
"status.navigation_tip": "How the mouse moves the view. Click to choose another mapping",
"status.under_constrained": "Under constrained ({count} degrees of freedom)",
"status.under_constrained_one": "Under constrained ({count} degree of freedom)",
"status.units_tip": "The design's units. Click to change them",
@@ -4220,6 +4263,7 @@
"ui.pan_zoom_orbit": "Pan, zoom, orbit",
"ui.parallel": "Parallel",
"ui.parameters": "Parameters",
"ui.part_size_needs_a_part": "Select an inserted standard part (a screw, nut, washer or pin) to change its size. To make a body larger or smaller, use Scale.",
"ui.part_size_shared": "Rebuilds the part, replacing any added features. All occurrences of this component update.",
"ui.paste": "Paste",
"ui.path": "Path",
@@ -4269,6 +4313,18 @@
"ui.project": "Project",
"ui.project_face_edges_sketch": "Project face edges into new sketches",
"ui.properties": "Properties",
"ui.properties.copy_matrix": "Copy matrix",
"ui.properties.first_moments": "First moments",
"ui.properties.inertia": "Moment of inertia",
"ui.properties.inertia_note": "About the center of mass, in world axes (g·{unit}²)",
"ui.properties.inertia_unavailable": "Not available: a body's mesh does not close.",
"ui.properties.inertia_x": "Ixx, Ixy, Ixz",
"ui.properties.inertia_y": "Iyx, Iyy, Iyz",
"ui.properties.inertia_z": "Izx, Izy, Izz",
"ui.properties.measuring": "Measuring…",
"ui.properties.measuring_failed": "Measuring stopped before it finished.",
"ui.properties.principal_axis": "Principal axis {number}",
"ui.properties.principal_moments": "Principal moments",
"ui.pull_direction": "Pull direction",
"ui.quality": "Quality",
"ui.quantity": "Quantity",
@@ -4367,7 +4423,11 @@
"ui.sketch": "Sketch",
"ui.sketch_dimension": "Sketch Dimension",
"ui.sketch_grid": "Sketch Grid",
"ui.sketch_intersect_geometry": "Geometry",
"ui.sketch_intersect_projection_link": "Projection Link",
"ui.sketch_intersect_selection_filter": "Selection Filter",
"ui.sketch_modifiers": "Sketch modifiers",
"ui.sketch_project_geometry": "Geometry",
"ui.sketch_scale": "Sketch Scale",
"ui.sketches": "Sketches",
"ui.slice": "Slice",
@@ -4414,6 +4474,11 @@
"ui.symmetric": "Symmetric",
"ui.symmetry": "Symmetry",
"ui.system": "System",
"ui.tab_assemble": "Assemble",
"ui.tab_manage": "Manage",
"ui.tab_sheet_metal": "Sheet Metal",
"ui.tab_view": "View",
"ui.tab_weldments": "Weldments",
"ui.tangent": "Tangent",
"ui.taper_angle": "Taper Angle",
"ui.tapped": "Tapped",
@@ -4429,6 +4494,7 @@
"ui.tip_angle": "Tip Angle",
"ui.to_object": "To Object",
"ui.tool_direction": "Tool direction",
"ui.toolbar_tabs_hidden": "Every toolbar tab is hidden. Click + or right-click here to show one.",
"ui.top": "Top",
"ui.top_diameter": "Top diameter",
"ui.top_radius": "Top radius",
@@ -4475,6 +4541,7 @@
"ui.visual_shaded_with_edges": "Shaded with edges",
"ui.visual_style": "Visual style",
"ui.visual_wireframe": "Wireframe",
"ui.wheel_zoom_speed": "Mouse wheel zoom speed",
"ui.width": "Width",
"ui.window_system": "Window system",
"ui.window_system_auto": "Automatic",
@@ -4528,6 +4595,9 @@
"update.prefs_managed": "This is SolidCraft {version}, managed by {manager}. A check asks GitHub for the latest release and sends nothing else; update SolidCraft in {manager}.",
"update.prefs_note": "This is SolidCraft {version} ({kind}). A check asks GitHub for the latest release and sends nothing else.",
"update.prefs_web": "Reload the page to get the latest web app. If the network is unavailable or the update cannot finish downloading in time, the saved version opens instead; a download in progress continues for the next reload.",
"update.refresh_checking": "Checking for the newest release…",
"update.refresh_fallback": "Could not check for a newer release; updating to {version}, found earlier.",
"update.refresh_selected": "Updating to {version}.",
"update.release_page": "Open release page",
"update.restart": "Restart now",
"update.restart_later": "Later",
+16
View File
@@ -1541,6 +1541,9 @@
"nav.touchpad.pan": "Desplazar (mantén Mayús y mueve el puntero; sin pulsar botones)",
"nav.touchpad.scroll": "El panel táctil usa el desplazamiento vertical para hacer zoom. La opción de desplazamiento con dos dedos se conserva para otros ajustes predefinidos.",
"nav.touchpad.zoom": "Zoom (desplazamiento vertical o gesto de pellizco)",
"navbar.custom.edge": "Borde",
"navbar.custom.edge_bottom": "Abajo",
"navbar.custom.edge_top": "Arriba",
"needs two joints": "necesita dos uniones",
"network.allow_once": "Permitir una vez",
"network.always_allow": "Permitir siempre este servidor",
@@ -1621,6 +1624,9 @@
"prefs.axis": "Eje",
"prefs.bottom": "Abajo",
"prefs.browser": "Explorador",
"prefs.browser_reveal_on_hover": "Mostrar al pasar el puntero",
"prefs.browser_show_note": "Visible mantiene el navegador en pantalla. Mostrar al pasar el puntero lo oculta hasta que el puntero llega a su lugar o a la pestaña fina de su borde izquierdo, y sigue abierto mientras el puntero está dentro o hay un menú suyo abierto.",
"prefs.browser_shown": "Visible",
"prefs.built_in": "Integrada",
"prefs.button_left": "Arrastrar con el botón izquierdo",
"prefs.button_middle": "Arrastrar con el botón central",
@@ -1776,6 +1782,9 @@
"prefs.view_confirm_corner": "Aceptar y Cancelar en la esquina de la vista",
"prefs.view_confirm_corner_note": "Mientras un diálogo o un boceto está abierto, una marca y una equis en la esquina superior derecha de la vista hacen lo que el Aceptar y el Cancelar del diálogo, o terminan y cancelan el boceto. El navegador de vistas se mueve debajo.",
"prefs.viewport": "Visor",
"prefs.viewport_background": "Fondo",
"prefs.viewport_background_colour": "Color",
"prefs.viewport_background_theme": "Degradado del tema",
"prefs.wheel": "Rueda",
"prefs.wheel_trackpad": "Rueda y panel táctil",
"prefs.workspace": "Espacio de trabajo",
@@ -2525,6 +2534,8 @@
"sketch_text.path_hint": "Curva del boceto que seguirá el texto: haz clic aquí y después en la curva",
"sketch_text.use_font_file": "Usar el archivo de fuente {path}",
"solid": "sólido",
"status.navigation": "Asignación del ratón: {mapping}",
"status.navigation_tip": "Cómo mueve el ratón la vista. Haz clic para elegir otra asignación",
"the angle must be a number": "el ángulo debe ser un número",
"the curves must be in one sketch": "las curvas deben estar en un solo boceto",
"the direction must be an axis, a sketch line or a straight edge": "la dirección debe ser un eje, una línea de boceto o una arista recta",
@@ -3340,6 +3351,7 @@
"ui.pan_zoom_orbit": "Desplazar, zoom y orbitar",
"ui.parallel": "Paralelo",
"ui.parameters": "Parámetros",
"ui.part_size_needs_a_part": "Seleccione una pieza normalizada insertada (un tornillo, una tuerca, una arandela o un pasador) para cambiar su tamaño. Para agrandar o reducir un cuerpo, use Escala.",
"ui.part_size_shared": "Reconstruye la pieza y sustituye las operaciones añadidas. Se actualizan todas las apariciones de este componente.",
"ui.paste": "Pegar",
"ui.path": "Trayectoria",
@@ -3520,6 +3532,10 @@
"ui.symmetric": "Simétrico",
"ui.symmetry": "Simetría",
"ui.system": "Sistema",
"ui.tab_assemble": "Ensamblar",
"ui.tab_manage": "Gestionar",
"ui.tab_sheet_metal": "Chapa",
"ui.tab_view": "Vista",
"ui.tangent": "Tangente",
"ui.taper_angle": "Ángulo de conicidad",
"ui.tapped": "Roscado",
+32
View File
@@ -1346,6 +1346,7 @@
"help.material.assign": "Applique le matériau choisi aux corps sélectionnés, par exemple acier, aluminium ou ABS. À utiliser pour que la masse et le poids correspondent à la pièce réelle.",
"help.mit": "Licence MIT",
"help.notice": "NOTICE",
"help.occurrence.move": "Déplace une pièce d’assemblage selon les seules libertés permises par ses liaisons. Sélectionnez une face de la pièce, puis faites glisser une flèche ou un arc disponible.",
"help.parameters.change": "Ouvre le tableau des valeurs nommées et des expressions qui pilotent les cotes de la conception. À utiliser pour changer une dimension à un seul endroit et faire suivre toutes les fonctions qui l'utilisent.",
"help.pointcloud.insert": "Insère un scan PLY, XYZ ou PTS sous forme de nuage de points à côté du modèle. Utilisez-le comme référence pour construire un modèle à partir de données mesurées.",
"help.solid.align": "Déplace les corps sélectionnés pour qu'un de leurs points, faces ou plans rejoigne un point, une face ou un plan ailleurs. À utiliser pour poser une pièce contre une autre sans mesurer le décalage.",
@@ -1474,6 +1475,21 @@
"joint.one_component": "Ces pièces sont des corps d'un même composant : {bodies}. Une liaison déplace un composant par rapport à un autre, chaque pièce a donc besoin de son propre composant.",
"joint.rigid_group_one_component": "Ces {count} corps sont tous dans un même composant (ou dans la conception elle-même) : utilisez d'abord {command}.",
"joint.rigid_group_select_components": "Sélectionnez au moins deux composants.",
"joint_move.blocked_hint": "Vérifiez le type de liaison (par exemple rigide), la fixation de la pièce, les limites de la liaison et les valeurs pilotées par des paramètres.",
"joint_move.blocked_title": "Impossible de déplacer cette pièce",
"joint_move.coarse": "Pas de rotation près de l’axe (°)",
"joint_move.distance": "Distance depuis le départ",
"joint_move.done": "Terminé",
"joint_move.fine": "Pas de rotation loin de l’axe (°)",
"joint_move.fixed": "Cette pièce n’a aucun axe de liaison mobile.",
"joint_move.help": "Déplacer — faites glisser une flèche ou un arc. Esc annule le glissement en cours.",
"joint_move.legacy_hint": "Désactivez cette option pour utiliser l’ancien panneau Déplacer.",
"joint_move.precision": "Tirez au-delà du repère en pointillés pour ajuster finement la rotation.",
"joint_move.rotation": "Rotation depuis le départ",
"joint_move.select": "Sélectionnez une face ou une pièce de l’assemblage, puis choisissez Déplacer.",
"joint_move.settings": "Déplacement dans l’assemblage",
"joint_move.step": "Pas",
"joint_move.use_handles": "Utiliser les poignées de mouvement des liaisons pour déplacer dans l’assemblage",
"keymap.command_id": "ID de commande",
"keymap.empty": "Aucune commande correspondante",
"keymap.use_anyway": "Utiliser quand même {key} ici",
@@ -1589,6 +1605,9 @@
"navbar.custom.customise": "Personnaliser la barre de navigation…",
"navbar.custom.design": "Conception",
"navbar.custom.duplicate": "Cet emplacement est déjà sur la barre.",
"navbar.custom.edge": "Bord",
"navbar.custom.edge_bottom": "Bas",
"navbar.custom.edge_top": "Haut",
"navbar.custom.fade": "Atténuer",
"navbar.custom.full": "Retirer un emplacement pour en ajouter un autre.",
"navbar.custom.hidden_design": "Masqué en Conception",
@@ -1751,6 +1770,9 @@
"prefs.bottom_centre": "Bas centre",
"prefs.bottom_right": "Bas droite",
"prefs.browser": "Navigateur",
"prefs.browser_reveal_on_hover": "Révéler au survol",
"prefs.browser_show_note": "Affiché garde le navigateur à l'écran. Révéler au survol le cache jusqu'à ce que le pointeur passe sur sa place ou sur le fin onglet à son bord gauche, et il reste ouvert tant que le pointeur est dedans ou qu'un de ses menus est ouvert.",
"prefs.browser_shown": "Affiché",
"prefs.built_in": "Intégrée",
"prefs.button_left": "Glisser gauche",
"prefs.button_middle": "Glisser milieu",
@@ -1928,6 +1950,9 @@
"prefs.view_confirm_corner": "OK et Annuler dans le coin de la vue",
"prefs.view_confirm_corner_note": "Lorsqu'une boîte de dialogue ou une esquisse est ouverte, une coche et une croix en haut à droite de la vue font la même chose que OK et Annuler, ou terminent et annulent l'esquisse. Le repère de navigation se place en dessous.",
"prefs.viewport": "Vue 3D",
"prefs.viewport_background": "Fond",
"prefs.viewport_background_colour": "Couleur",
"prefs.viewport_background_theme": "Dégradé du thème",
"prefs.wheel": "Molette",
"prefs.wheel_trackpad": "Molette et pavé tactile",
"prefs.workspace": "Espace de travail",
@@ -3106,6 +3131,8 @@
"sketch_text.use_font_file": "Utiliser le fichier de police {path}",
"solid": "plein (sans paroi)",
"status.editing": "Modification de {name}",
"status.navigation": "Affectation des boutons de la souris : {mapping}",
"status.navigation_tip": "La façon dont la souris déplace la vue. Cliquez pour en choisir une autre",
"status.under_constrained": "Sous-contraint ({count} degrés de liberté)",
"status.under_constrained_one": "Sous-contraint ({count} degré de liberté)",
"status.units_tip": "Les unités de la conception. Cliquez pour les modifier",
@@ -3950,6 +3977,7 @@
"ui.pan_zoom_orbit": "Panoramique, zoom, orbite",
"ui.parallel": "Parallélisme",
"ui.parameters": "Paramètres",
"ui.part_size_needs_a_part": "Sélectionnez une pièce standard insérée (vis, écrou, rondelle ou goupille) pour changer sa taille. Pour agrandir ou réduire un corps, utilisez Échelle.",
"ui.part_size_shared": "Reconstruit la pièce en remplaçant les entités ajoutées. Toutes les occurrences de ce composant sont mises à jour.",
"ui.paste": "Coller",
"ui.path": "Trajectoire",
@@ -4144,6 +4172,10 @@
"ui.symmetric": "Symétrique",
"ui.symmetry": "Symétrie",
"ui.system": "Système",
"ui.tab_assemble": "Assembler",
"ui.tab_manage": "Gérer",
"ui.tab_sheet_metal": "Tôlerie",
"ui.tab_view": "Vue",
"ui.tangent": "Tangence",
"ui.taper_angle": "Angle de dépouille",
"ui.tapped": "Taraudé",
+5
View File
@@ -3950,6 +3950,7 @@
"ui.pan_zoom_orbit": "Desprazar, zoom, orbitar",
"ui.parallel": "Paralela",
"ui.parameters": "Parámetros",
"ui.part_size_needs_a_part": "Selecciona unha peza normalizada inserida (un parafuso, unha porca, unha arandela ou un pasador) para cambiar o seu tamaño. Para agrandar ou reducir un corpo, usa Escala.",
"ui.part_size_shared": "Reconstrúe a peza, substituíndo as operacións engadidas. Actualízanse todas as ocorrencias deste compoñente.",
"ui.paste": "Pegar",
"ui.path": "Traxectoria",
@@ -4144,6 +4145,10 @@
"ui.symmetric": "Simétrico",
"ui.symmetry": "Simetría",
"ui.system": "Sistema",
"ui.tab_assemble": "Ensamblar",
"ui.tab_manage": "Xestionar",
"ui.tab_sheet_metal": "Chapa",
"ui.tab_view": "Vista",
"ui.tangent": "Tanxente",
"ui.taper_angle": "Ángulo de conicidade",
"ui.tapped": "Roscado",
+70
View File
@@ -652,6 +652,7 @@
"Third point": "נקודה שלישית",
"This child is shared. Deleting it removes it from every copy of its parent.": "צאצא זה משותף. מחיקתו מסירה אותו מכל עותק של רכיב האב.",
"This child is shared. Position changes apply to every copy of its parent.": "צאצא זה משותף. שינויי מיקום חלים על כל עותק של רכיב האב.",
"This component is inside a subassembly placed more than once: aligning it moves it in every placement.": "רכיב זה נמצא בתוך תת-הרכבה שהוצבה יותר מפעם אחת: יישורו יזיז אותו בכל הצבה.",
"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.",
"Thread depth": "עומק הברגה",
@@ -984,6 +985,7 @@
"cmd.motion_trace": "עקבת תנועה",
"cmd.motion_trace_edit": "עריכת עקבת תנועה",
"cmd.motion_trace_keep": "שמירת עקבת תנועה",
"cmd.occurrence_align": "יישור מופע",
"cmd.occurrence_copy": "הדבקה (מופע)",
"cmd.occurrence_drag": "גרירת מופע",
"cmd.occurrence_ground": "עיגון",
@@ -1369,6 +1371,7 @@
"help.material.assign": "קביעת החומר שממנו עשויים הגופים שנבחרו, למשל פלדה, אלומיניום או ABS. כך המסה והמשקל תואמים את החלק האמיתי.",
"help.mit": "רישיון MIT",
"help.notice": "NOTICE",
"help.occurrence.move": "מזיז חלק בהרכבה רק בכיוונים שהמפרקים שלו מאפשרים. בחרו פאה של החלק, ואז גררו חץ או קשת זמינים.",
"help.parameters.change": "פתיחת הטבלה של ערכים בעלי שם וביטויים שקובעים את מידות התכנון. כך משנים מידה במקום אחד וכל הפיצ'רים שמשתמשים בה מתעדכנים.",
"help.pointcloud.insert": "הוספת סריקה בפורמט PLY, XYZ או PTS כענן נקודות לצד המודל. משמש כעזר למידול על סמך נתוני מדידה.",
"help.solid.align": "הזזת הגופים שנבחרו כך שנקודה, פאה או מישור שלהם יפגשו נקודה, פאה או מישור במקום אחר. מתאים להצמדת חלק לחלק אחר בלי למדוד את המרחק.",
@@ -1499,6 +1502,21 @@
"joint.one_component": "חלקים אלה הם גופים של רכיב אחד: {bodies}. מפרק מזיז רכיב אחד ביחס לרכיב אחר, ולכן כל חלק צריך רכיב משלו.",
"joint.rigid_group_one_component": "כל {count} הגופים האלה נמצאים ברכיב אחד (או בתכן עצמו): יש להשתמש קודם ב־{command}.",
"joint.rigid_group_select_components": "יש לבחור שני רכיבים או יותר.",
"joint_move.blocked_hint": "בדקו את סוג המפרק (למשל מפרק קשיח), את קיבוע החלק, את מגבלות המפרק ואת הערכים הנשלטים על ידי פרמטרים.",
"joint_move.blocked_title": "אי אפשר להזיז את החלק הזה",
"joint_move.coarse": "צעד סיבוב קרוב לציר (°)",
"joint_move.distance": "מרחק מנקודת ההתחלה",
"joint_move.done": "סיום",
"joint_move.fine": "צעד סיבוב רחוק מהציר (°)",
"joint_move.fixed": "לחלק הזה אין ציר מפרק שניתן להזיז.",
"joint_move.help": "הזזה — גררו חץ או קשת. Esc מבטל את הגרירה הנוכחית.",
"joint_move.legacy_hint": "כבו כדי להשתמש בחלונית ההזזה הקודמת.",
"joint_move.precision": "משכו אל מחוץ למנחה המקווקו כדי לכוון את הסיבוב בעדינות.",
"joint_move.rotation": "סיבוב מנקודת ההתחלה",
"joint_move.select": "בחרו פאה או חלק אחד בהרכבה, ואז בחרו הזזה.",
"joint_move.settings": "הזזה בהרכבה",
"joint_move.step": "צעד",
"joint_move.use_handles": "שימוש בידיות תנועת מפרקים להזזה בהרכבה",
"keymap.command_id": "מזהה פקודה",
"keymap.empty": "אין פקודות תואמות",
"keymap.use_anyway": "שימוש ב־{key} כאן בכל זאת",
@@ -1593,6 +1611,8 @@
"nav.maya.help": "Alt וגרירה שמאלית מסובבים, Alt וגרירה אמצעית מזיזים, Alt וגרירה ימנית מבצעים זום; גם גרירה אמצעית מזיזה",
"nav.openscad.help": "גרירה שמאלית על שטח ריק מסובבת, Shift וגרירה שמאלית בוחרים במלבן, גרירת גאומטריית סקיצה עורכת אותה; גרירה ימנית או אמצעית מזיזה, Shift וגרירה ימנית או אמצעית מבצעים זום",
"nav.perspective.sketch": "עריכת סקיצה משתמשת בתצוגה אורתוגרפית. יש לסיים את הסקיצה כדי לעבור לפרספקטיבה.",
"nav.right_drag_orbits.help": "כבוי: גרירה בכפתור הימני לא מזיזה את המצלמה, והכפתור הימני רק פותח את תפריט ההקשר. בחירת מיפוי העכבר SolidWorks מכבה זאת.",
"nav.right_drag_orbits.label": "גרירה ימנית מסובבת",
"nav.solidworks.help": "גרירה אמצעית מסובבת, Ctrl וגרירה אמצעית מזיזים, Shift וגרירה אמצעית מבצעים זום, גרירה ימנית מסובבת",
"nav.tinkercad.help": "גרירה ימנית מסובבת, גרירה אמצעית או Shift וגרירה ימנית מזיזים",
"nav.touchpad.help": "להזזת התצוגה יש להחזיק Shift ולהזיז את הסמן; לסיבוב יש להחזיק Alt/Option ולהזיז אותו. אין צורך בלחצן עכבר. גלילה אנכית מבצעת זום.",
@@ -1614,6 +1634,9 @@
"navbar.custom.customise": "התאמת סרגל הניווט…",
"navbar.custom.design": "תכן",
"navbar.custom.duplicate": "המשבצת הזו כבר בסרגל.",
"navbar.custom.edge": "קצה",
"navbar.custom.edge_bottom": "למטה",
"navbar.custom.edge_top": "למעלה",
"navbar.custom.fade": "דהייה",
"navbar.custom.full": "הסירו משבצת כדי להוסיף אחרת.",
"navbar.custom.hidden_design": "מוסתר בתכן",
@@ -1778,6 +1801,9 @@
"prefs.bottom_centre": "למטה במרכז",
"prefs.bottom_right": "למטה מימין",
"prefs.browser": "עץ התכן",
"prefs.browser_reveal_on_hover": "הצגה בריחוף",
"prefs.browser_show_note": "״מוצג״ משאיר את הדפדפן על המסך. ״הצגה בריחוף״ מסתיר אותו עד שהמצביע מגיע למקומו או ללשונית הדקה בקצהו השמאלי, והוא נשאר פתוח כל עוד המצביע בתוכו או שתפריט ממנו פתוח.",
"prefs.browser_shown": "מוצג",
"prefs.built_in": "מובנה",
"prefs.button_left": "גרירה שמאלית",
"prefs.button_middle": "גרירה אמצעית",
@@ -1883,10 +1909,14 @@
"prefs.placement": "מיקום",
"prefs.prefer_free_sketch_points": "העדפת נקודות סקיצה ניתנות להזזה כשנקודות חופפות",
"prefs.preview_choices": "תצוגה מקדימה של הבחירות שלך",
"prefs.project_options_dialog": "תיבת האפשרויות של Project",
"prefs.project_options_dialog_note": "פעיל: לבחור גאומטריה או גופים וקישור הטלה לפני ההטלה. כבוי: להשאיר את זרימת העבודה הקודמת של בחירה ישירה.",
"prefs.quality_default": "ברירת מחדל",
"prefs.quality_fastest": "המהיר ביותר",
"prefs.quality_slowest": "האיטי ביותר",
"prefs.quality_smoother": "חלק יותר",
"prefs.render_previous_geometry_budget": "שימוש בתקציב הגאומטריה הקודם לתצוגה מקדימה של רינדור",
"prefs.render_previous_geometry_budget_help": "שחזור מגבלת הגאומטריה הקבועה הקודמת (512 MiB במחשב שולחני של 64 סיביות, 128 MiB בפלטפורמות אחרות) במקום התקציב המבוסס על ההתקן.",
"prefs.reports_saved": "{n} שמורים",
"prefs.right_click": "לחיצה ימנית",
"prefs.saving": "שמירה אוטומטית ושחזור",
@@ -1952,7 +1982,14 @@
"prefs.toast_place": "הודעות",
"prefs.toggle_item": "הוספת הפריט, או הוצאתו שוב",
"prefs.toolbar": "סרגל כלים",
"prefs.toolbar_tabs": "לשוניות סרגל הכלים",
"prefs.top": "למעלה",
"prefs.trackpad_zoom_modifier": "שינוי תקריב בגלילה בשתי אצבעות בזמן החזקת",
"prefs.trackpad_zoom_modifier.alt": "⌥ / Alt",
"prefs.trackpad_zoom_modifier.cmd": "⌘",
"prefs.trackpad_zoom_modifier.ctrl": "Ctrl",
"prefs.trackpad_zoom_modifier.help": "יש להחזיק רק מקש זה כדי לשנות תקריב ליד המצביע בגלילה אנכית. כיבוי שומר על התנהגות הגלילה הקיימת.",
"prefs.trackpad_zoom_modifier.off": "כבוי",
"prefs.translucent": "שקוף למחצה",
"prefs.under_browser": "מתחת לעץ התכן",
"prefs.under_cursor": "מתחת לסמן",
@@ -1962,6 +1999,9 @@
"prefs.view_confirm_corner": "אישור וביטול בפינת התצוגה",
"prefs.view_confirm_corner_note": "כשתיבת דו־שיח או סקיצה פתוחות, סימן וי וסימן איקס בפינה הימנית העליונה של התצוגה עושים את מה שעושים אישור וביטול של תיבת הדו־שיח, או מסיימים ומבטלים את הסקיצה. ניווט התצוגה זז אל מתחתיהם.",
"prefs.viewport": "אזור התצוגה",
"prefs.viewport_background": "רקע",
"prefs.viewport_background_colour": "צבע",
"prefs.viewport_background_theme": "מעבר הצבעים של ערכת הנושא",
"prefs.wheel": "גלגלת",
"prefs.wheel_trackpad": "גלגלת ומשטח מגע",
"prefs.workspace": "סביבת עבודה",
@@ -3296,6 +3336,7 @@
"select an axis": "יש לבחור ציר",
"select an axis, a straight or circular edge, or a cylinder, cone or torus face first": "יש לבחור תחילה ציר, מקצוע ישר או מעגלי, או פאה של גליל, חרוט או טורוס",
"select bodies first": "יש לבחור גופים תחילה",
"select components first": "יש לבחור רכיבים תחילה",
"select corner points, or the two lines of one corner": "יש לבחור נקודות פינה, או את שני הקווים של פינה אחת",
"select one corner point, or the two lines of one corner": "יש לבחור נקודת פינה אחת, או את שני הקווים של פינה אחת",
"select sheet edges first": "יש לבחור תחילה מקצועות של הפח",
@@ -3372,6 +3413,8 @@
"sketch_text.use_font_file": "שימוש בקובץ הגופן {path}",
"solid": "מוצק",
"status.editing": "עריכת {name}",
"status.navigation": "מיפוי העכבר: {mapping}",
"status.navigation_tip": "איך העכבר מזיז את התצוגה. לחצו כדי לבחור מיפוי אחר",
"status.under_constrained": "אילוצים חסרים ({count} דרגות חופש)",
"status.under_constrained_one": "אילוצים חסרים (דרגת חופש {count})",
"status.units_tip": "יחידות התכנון. לחצו כדי לשנות אותן",
@@ -4220,6 +4263,7 @@
"ui.pan_zoom_orbit": "הזזה, זום, סיבוב מבט",
"ui.parallel": "מקביל",
"ui.parameters": "פרמטרים",
"ui.part_size_needs_a_part": "בחרו חלק סטנדרטי שהוכנס (בורג, אום, דסקית או פין) כדי לשנות את גודלו. כדי להגדיל או להקטין גוף, השתמשו בשינוי קנה מידה.",
"ui.part_size_shared": "בונה מחדש את החלק ומחליף פיצ'רים שנוספו. כל המופעים של רכיב זה מתעדכנים.",
"ui.paste": "הדבקה",
"ui.path": "מסלול",
@@ -4269,6 +4313,18 @@
"ui.project": "הטלה",
"ui.project_face_edges_sketch": "הטלת מקצועות הפאה לסקיצות חדשות",
"ui.properties": "מאפיינים",
"ui.properties.copy_matrix": "העתקת המטריצה",
"ui.properties.first_moments": "מומנטים ראשונים",
"ui.properties.inertia": "מומנט התמד",
"ui.properties.inertia_note": "סביב מרכז מסה, בצירי העולם (g·{unit}²)",
"ui.properties.inertia_unavailable": "לא זמין: הרשת של אחד הגופים אינה סגורה.",
"ui.properties.inertia_x": "Ixx, Ixy, Ixz",
"ui.properties.inertia_y": "Iyx, Iyy, Iyz",
"ui.properties.inertia_z": "Izx, Izy, Izz",
"ui.properties.measuring": "מודד…",
"ui.properties.measuring_failed": "המדידה נעצרה לפני שהסתיימה.",
"ui.properties.principal_axis": "ציר ראשי {number}",
"ui.properties.principal_moments": "מומנטים ראשיים",
"ui.pull_direction": "כיוון משיכה",
"ui.quality": "איכות",
"ui.quantity": "כמות",
@@ -4367,7 +4423,11 @@
"ui.sketch": "סקיצה",
"ui.sketch_dimension": "מידת סקיצה",
"ui.sketch_grid": "רשת הסקיצה",
"ui.sketch_intersect_geometry": "גאומטריה",
"ui.sketch_intersect_projection_link": "קישור הטלה",
"ui.sketch_intersect_selection_filter": "מסנן בחירה",
"ui.sketch_modifiers": "מקשי שינוי בסקיצה",
"ui.sketch_project_geometry": "גאומטריה",
"ui.sketch_scale": "קנה מידה של סקיצה",
"ui.sketches": "סקיצות",
"ui.slice": "חתך",
@@ -4414,6 +4474,11 @@
"ui.symmetric": "סימטרי",
"ui.symmetry": "סימטריה",
"ui.system": "מערכת",
"ui.tab_assemble": "הרכבה",
"ui.tab_manage": "ניהול",
"ui.tab_sheet_metal": "פח",
"ui.tab_view": "תצוגה",
"ui.tab_weldments": "ריתוכים",
"ui.tangent": "משיק",
"ui.taper_angle": "זווית שיפוע",
"ui.tapped": "מוברג",
@@ -4429,6 +4494,7 @@
"ui.tip_angle": "זווית חוד",
"ui.to_object": "עד אובייקט",
"ui.tool_direction": "כיוון הכלי",
"ui.toolbar_tabs_hidden": "כל לשוניות סרגל הכלים מוסתרות. לחצו על + או לחצו כאן לחיצה ימנית כדי להציג לשונית.",
"ui.top": "עליון",
"ui.top_diameter": "קוטר עליון",
"ui.top_radius": "רדיוס עליון",
@@ -4475,6 +4541,7 @@
"ui.visual_shaded_with_edges": "מוצלל עם מקצועות",
"ui.visual_style": "סגנון תצוגה",
"ui.visual_wireframe": "מסגרת תיל",
"ui.wheel_zoom_speed": "מהירות זום בגלגלת העכבר",
"ui.width": "רוחב",
"ui.window_system": "מערכת חלונות",
"ui.window_system_auto": "אוטומטי",
@@ -4528,6 +4595,9 @@
"update.prefs_managed": "זהו SolidCraft {version}, בניהול {manager}. הבדיקה שואלת את GitHub מהי הגרסה האחרונה ולא שולחת דבר נוסף; עדכנו את SolidCraft ב-{manager}.",
"update.prefs_note": "זהו SolidCraft {version} ({kind}). הבדיקה שואלת את GitHub מהי הגרסה האחרונה ואינה שולחת שום דבר אחר.",
"update.prefs_web": "יש לטעון מחדש את הדף כדי לקבל את גרסת אפליקציית הרשת העדכנית. אם אין רשת או שהעדכון לא מסיים לרדת בזמן, תיפתח במקומה הגרסה השמורה; הורדה שבתהליך ממשיכה לטעינה הבאה.",
"update.refresh_checking": "בודק אם יש גרסה חדשה יותר…",
"update.refresh_fallback": "לא ניתן היה לבדוק אם יש גרסה חדשה יותר; מעדכן לגרסה {version} שנמצאה קודם.",
"update.refresh_selected": "מעדכן לגרסה {version}.",
"update.release_page": "פתיחת דף הגרסה",
"update.restart": "הפעלה מחדש עכשיו",
"update.restart_later": "מאוחר יותר",
+1
View File
@@ -3975,6 +3975,7 @@
"ui.pan_zoom_orbit": "Pomicanje, zumiranje, orbitiranje",
"ui.parallel": "Paralelnost",
"ui.parameters": "Parametri",
"ui.part_size_needs_a_part": "Odaberite umetnuti standardni dio (vijak, maticu, podlošku ili zatik) da biste mu promijenili veličinu. Da tijelo povećate ili smanjite, koristite Skaliraj.",
"ui.part_size_shared": "Ponovno gradi dio, zamjenjujući sve dodane značajke. Svi primjerci ove komponente ažuriraju se.",
"ui.paste": "Zalijepi",
"ui.path": "Putanja",
+5
View File
@@ -3950,6 +3950,7 @@
"ui.pan_zoom_orbit": "Pásztázás, nagyítás, keringés",
"ui.parallel": "Párhuzamos",
"ui.parameters": "Paraméterek",
"ui.part_size_needs_a_part": "Jelöljön ki egy beillesztett szabványos alkatrészt (csavart, anyát, alátétet vagy csapot) a méretének módosításához. Test nagyításához vagy kicsinyítéséhez használja az Átlépték parancsot.",
"ui.part_size_shared": "Újraépíti az alkatrészt, a hozzáadott elemeket lecserélve. A komponens minden előfordulása frissül.",
"ui.paste": "Beillesztés",
"ui.path": "Útvonal",
@@ -4144,6 +4145,10 @@
"ui.symmetric": "Szimmetrikus",
"ui.symmetry": "Szimmetria",
"ui.system": "Rendszer",
"ui.tab_assemble": "Összeszerelés",
"ui.tab_manage": "Kezelés",
"ui.tab_sheet_metal": "Lemez",
"ui.tab_view": "Nézet",
"ui.tangent": "Érintő",
"ui.taper_angle": "Kúposodási szög",
"ui.tapped": "Menetes",
+5
View File
@@ -3950,6 +3950,7 @@
"ui.pan_zoom_orbit": "Pan, zoom, orbit",
"ui.parallel": "Paralel",
"ui.parameters": "Parameter",
"ui.part_size_needs_a_part": "Pilih part standar yang disisipkan (sekrup, mur, ring, atau pin) untuk mengubah ukurannya. Untuk memperbesar atau memperkecil bodi, gunakan Skala.",
"ui.part_size_shared": "Membangun ulang part, menggantikan fitur tambahan apa pun. Semua kemunculan komponen ini diperbarui.",
"ui.paste": "Tempel",
"ui.path": "Jalur",
@@ -4144,6 +4145,10 @@
"ui.symmetric": "Simetris",
"ui.symmetry": "Simetri",
"ui.system": "Sistem",
"ui.tab_assemble": "Rakit",
"ui.tab_manage": "Kelola",
"ui.tab_sheet_metal": "Pelat Logam",
"ui.tab_view": "Tampilan",
"ui.tangent": "Tangen",
"ui.taper_angle": "Sudut Taper",
"ui.tapped": "Bertap",
+13
View File
@@ -1589,6 +1589,9 @@
"navbar.custom.customise": "Personalizza barra di navigazione…",
"navbar.custom.design": "Progettazione",
"navbar.custom.duplicate": "Questo elemento è già sulla barra.",
"navbar.custom.edge": "Bordo",
"navbar.custom.edge_bottom": "In basso",
"navbar.custom.edge_top": "In alto",
"navbar.custom.fade": "Attenua",
"navbar.custom.full": "Rimuovi un elemento per aggiungerne un altro.",
"navbar.custom.hidden_design": "Nascosto in Progettazione",
@@ -1928,6 +1931,9 @@
"prefs.view_confirm_corner": "OK e Annulla nell'angolo della vista",
"prefs.view_confirm_corner_note": "Mentre è aperta una finestra di dialogo o uno schizzo, una spunta e una croce in alto a destra nella vista fanno ciò che fanno OK e Annulla della finestra, oppure terminano e annullano lo schizzo. Il navigatore della vista scende sotto di esse.",
"prefs.viewport": "Vista",
"prefs.viewport_background": "Sfondo",
"prefs.viewport_background_colour": "Colore",
"prefs.viewport_background_theme": "Sfumatura del tema",
"prefs.wheel": "Rotellina",
"prefs.wheel_trackpad": "Rotellina e trackpad",
"prefs.workspace": "Area di lavoro",
@@ -3106,6 +3112,8 @@
"sketch_text.use_font_file": "Usa il file di carattere {path}",
"solid": "solido",
"status.editing": "Modifica di {name}",
"status.navigation": "Assegnazione del mouse: {mapping}",
"status.navigation_tip": "Come il mouse muove la vista. Fai clic per sceglierne un'altra",
"status.under_constrained": "Sottovincolato ({count} gradi di libertà)",
"status.under_constrained_one": "Sottovincolato ({count} grado di libertà)",
"status.units_tip": "Le unità del progetto. Fai clic per cambiarle",
@@ -3950,6 +3958,7 @@
"ui.pan_zoom_orbit": "Panoramica, zoom, orbita",
"ui.parallel": "Parallelo",
"ui.parameters": "Parametri",
"ui.part_size_needs_a_part": "Seleziona una parte standard inserita (vite, dado, rondella o spina) per cambiarne le dimensioni. Per ingrandire o rimpicciolire un corpo usa Scala.",
"ui.part_size_shared": "Ricostruisce la parte, sostituendo le eventuali lavorazioni aggiunte. Si aggiornano tutte le occorrenze del componente.",
"ui.paste": "Incolla",
"ui.path": "Percorso",
@@ -4144,6 +4153,10 @@
"ui.symmetric": "Simmetrico",
"ui.symmetry": "Simmetria",
"ui.system": "Sistema",
"ui.tab_assemble": "Assembla",
"ui.tab_manage": "Gestisci",
"ui.tab_sheet_metal": "Lamiera",
"ui.tab_view": "Vista",
"ui.tangent": "Tangente",
"ui.taper_angle": "Angolo di rastremazione",
"ui.tapped": "Maschiato",
+70
View File
@@ -652,6 +652,7 @@
"Third point": "3点目",
"This child is shared. Deleting it removes it from every copy of its parent.": "この子要素は共有されています。削除すると親のすべてのコピーから削除されます。",
"This child is shared. Position changes apply to every copy of its parent.": "この子要素は共有されています。位置の変更は親のすべてのコピーに適用されます。",
"This component is inside a subassembly placed more than once: aligning it moves it in every placement.": "このコンポーネントは複数配置されたサブアセンブリ内にあります。整列すると、すべての配置で移動します。",
"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で添付してください。",
"Thread depth": "ねじ深さ",
@@ -984,6 +985,7 @@
"cmd.motion_trace": "モーション トレース",
"cmd.motion_trace_edit": "モーション トレースを編集",
"cmd.motion_trace_keep": "モーション トレースを保持",
"cmd.occurrence_align": "オカレンスを整列",
"cmd.occurrence_copy": "貼り付け(インスタンス)",
"cmd.occurrence_drag": "オカレンスをドラッグ",
"cmd.occurrence_ground": "空間固定",
@@ -1369,6 +1371,7 @@
"help.material.assign": "選択した立体の材料を、鋼、アルミニウム、ABS などに設定します。質量と重さを実際の部品に合わせるときに使います。",
"help.mit": "MITライセンス",
"help.notice": "NOTICE",
"help.occurrence.move": "アセンブリ部品をジョイントが許す自由度だけで動かします。部品の面を選択してから、操作可能な矢印または円弧をドラッグしてください。",
"help.parameters.change": "デザインの寸法を決める名前付きの値と式の表を開きます。1か所で寸法を変え、それを使うすべてのフィーチャに反映させるときに使います。",
"help.pointcloud.insert": "PLY・XYZ・PTS形式のスキャンを点群としてモデルと一緒に挿入します。計測データを参考にモデリングするときに使います。",
"help.solid.align": "選択した立体を動かし、その点、面、平面を別の場所の点、面、平面に合わせます。距離を測らずに部品を別の部品に当てて配置するときに使います。",
@@ -1499,6 +1502,21 @@
"joint.one_component": "これらの部品は 1 つのコンポーネントのボディです: {bodies}。ジョイントはコンポーネント同士を動かすため、部品ごとに専用のコンポーネントが必要です。",
"joint.rigid_group_one_component": "これらの {count} 個のボディは、すべて 1 つのコンポーネント内(またはデザイン自体)にあります。先に「{command}」を使用してください。",
"joint.rigid_group_select_components": "2 つ以上のコンポーネントを選択してください。",
"joint_move.blocked_hint": "Rigid合致、部品の固定状態、ジョイントの制限値、パラメーターでの拘束を確認してください。",
"joint_move.blocked_title": "この部品は移動できません",
"joint_move.coarse": "軸に近い位置の回転刻み(°)",
"joint_move.distance": "開始位置からの移動距離",
"joint_move.done": "完了",
"joint_move.fine": "軸から遠い位置の回転刻み(°)",
"joint_move.fixed": "この部品には操作できるジョイントの自由度がありません。",
"joint_move.help": "移動 — 矢印または円弧をドラッグ。Escでドラッグを取消。",
"joint_move.legacy_hint": "オフにすると従来の移動パネルを使います。",
"joint_move.precision": "破線ガイドの外側へ引くと回転を微調整できます。",
"joint_move.rotation": "開始位置からの回転角",
"joint_move.select": "面またはアセンブリ部品を1つ選択してから移動を実行してください。",
"joint_move.settings": "アセンブリの移動",
"joint_move.step": "刻み",
"joint_move.use_handles": "アセンブリの移動にジョイントの操作ハンドルを使う",
"keymap.command_id": "コマンドID",
"keymap.empty": "一致するコマンドはありません",
"keymap.use_anyway": "ここでは{key}をそのまま使用",
@@ -1593,6 +1611,8 @@
"nav.maya.help": "Alt+左ドラッグで回転、Alt+中ドラッグでパン、Alt+右ドラッグでズーム。中ドラッグでもパン。",
"nav.openscad.help": "空きスペースで左ドラッグすると回転、Shift+左ドラッグで矩形選択、スケッチ ジオメトリはドラッグで編集。右または中ドラッグでパン、Shift+右または Shift+中ドラッグでズーム。",
"nav.perspective.sketch": "スケッチ編集中は平行投影で表示されます。透視投影に切り替えるにはスケッチを終了してください。",
"nav.right_drag_orbits.help": "オフ:右ドラッグではカメラは動かず、右ボタンはコンテキストメニューを開くだけです。マウス割り当てで SolidWorks を選ぶとオフになります。",
"nav.right_drag_orbits.label": "右ドラッグで回転",
"nav.solidworks.help": "中ドラッグで回転、Ctrl+中ドラッグでパン、Shift+中ドラッグでズーム、右ドラッグで回転。",
"nav.tinkercad.help": "右ドラッグで回転、中ドラッグまたは Shift+右ドラッグでパン。",
"nav.touchpad.help": "Shift を押しながらポインターを動かすとパン、Alt/Option を押しながら動かすと回転します。マウスボタンは不要です。縦スクロールでズームします。",
@@ -1614,6 +1634,9 @@
"navbar.custom.customise": "ナビゲーションバーをカスタマイズ…",
"navbar.custom.design": "デザイン",
"navbar.custom.duplicate": "このスロットは既にバーにあります。",
"navbar.custom.edge": "端",
"navbar.custom.edge_bottom": "下",
"navbar.custom.edge_top": "上",
"navbar.custom.fade": "薄く表示",
"navbar.custom.full": "別のスロットを追加するには、スロットを削除してください。",
"navbar.custom.hidden_design": "デザインでは非表示",
@@ -1778,6 +1801,9 @@
"prefs.bottom_centre": "下中央",
"prefs.bottom_right": "右下",
"prefs.browser": "ブラウザー",
"prefs.browser_reveal_on_hover": "ホバーで表示",
"prefs.browser_show_note": "「表示」はブラウザーを常に画面に表示します。「ホバーで表示」はポインターがその位置か左端の細いタブに来るまで隠し、ポインターが中にある間やそのメニューが開いている間は表示したままにします。",
"prefs.browser_shown": "表示",
"prefs.built_in": "組み込み",
"prefs.button_left": "左ドラッグ",
"prefs.button_middle": "中央ドラッグ",
@@ -1883,10 +1909,14 @@
"prefs.placement": "配置",
"prefs.prefer_free_sketch_points": "スケッチ点が重なったときは移動可能な点を優先する",
"prefs.preview_choices": "選択した設定のプレビュー",
"prefs.project_options_dialog": "Projectのオプションダイアログ",
"prefs.project_options_dialog_note": "オン: 投影前に「ジオメトリ」または「ボディ」と「投影リンク」を選択します。オフ: 従来の直接選択方式を使用します。",
"prefs.quality_default": "既定",
"prefs.quality_fastest": "最速",
"prefs.quality_slowest": "最も低速",
"prefs.quality_smoother": "より滑らか",
"prefs.render_previous_geometry_budget": "レンダープレビューで以前のジオメトリ予算を使用",
"prefs.render_previous_geometry_budget_help": "デバイスに基づく予算の代わりに、以前の固定ジオメトリ上限(64ビットデスクトップでは512 MiB、その他では128 MiB)に戻します。",
"prefs.reports_saved": "{n} 件保存済み",
"prefs.right_click": "右クリック",
"prefs.saving": "自動保存と復元",
@@ -1952,7 +1982,14 @@
"prefs.toast_place": "通知",
"prefs.toggle_item": "項目を追加、または選択から外す",
"prefs.toolbar": "ツールバー",
"prefs.toolbar_tabs": "ツールバーのタブ",
"prefs.top": "上",
"prefs.trackpad_zoom_modifier": "2本指スクロールでズームする修飾キー",
"prefs.trackpad_zoom_modifier.alt": "⌥ / Alt",
"prefs.trackpad_zoom_modifier.cmd": "⌘",
"prefs.trackpad_zoom_modifier.ctrl": "Ctrl",
"prefs.trackpad_zoom_modifier.help": "このキーだけを押しながら縦にスクロールすると、ポインター位置を基準にズームします。オフでは従来のスクロール動作を維持します。",
"prefs.trackpad_zoom_modifier.off": "オフ",
"prefs.translucent": "半透明",
"prefs.under_browser": "ブラウザー下",
"prefs.under_cursor": "カーソル下",
@@ -1962,6 +1999,9 @@
"prefs.view_confirm_corner": "ビューの隅に OK とキャンセル",
"prefs.view_confirm_corner_note": "ダイアログまたはスケッチを開いている間、ビュー右上のチェックとバツが、ダイアログの OK とキャンセルと同じ操作を行うか、スケッチを終了またはキャンセルします。ビューナビゲーターはその下に移動します。",
"prefs.viewport": "ビューポート",
"prefs.viewport_background": "背景",
"prefs.viewport_background_colour": "色",
"prefs.viewport_background_theme": "テーマのグラデーション",
"prefs.wheel": "ホイール",
"prefs.wheel_trackpad": "ホイールとトラックパッド",
"prefs.workspace": "ワークスペース",
@@ -3296,6 +3336,7 @@
"select an axis": "軸を選択してください",
"select an axis, a straight or circular edge, or a cylinder, cone or torus face first": "軸、直線または円のエッジ、あるいは円柱・円錐・トーラスの面を先に選択してください",
"select bodies first": "ボディを先に選択してください",
"select components first": "コンポーネントを先に選択してください",
"select corner points, or the two lines of one corner": "コーナーの点、または1つのコーナーの2本の線を選択してください",
"select one corner point, or the two lines of one corner": "コーナーの点を1つ、または1つのコーナーの2本の線を選択してください",
"select sheet edges first": "板金のエッジを先に選択してください",
@@ -3372,6 +3413,8 @@
"sketch_text.use_font_file": "フォント ファイル {path} を使用",
"solid": "中実",
"status.editing": "編集中: {name}",
"status.navigation": "マウスの割り当て: {mapping}",
"status.navigation_tip": "マウスでビューを動かす方法。クリックして別の割り当てを選択",
"status.under_constrained": "拘束不足(自由度 {count})",
"status.under_constrained_one": "拘束不足(自由度 {count})",
"status.units_tip": "デザインの単位。クリックして変更",
@@ -4220,6 +4263,7 @@
"ui.pan_zoom_orbit": "パン、ズーム、オービット",
"ui.parallel": "平行",
"ui.parameters": "パラメータ",
"ui.part_size_needs_a_part": "サイズを変更する挿入済みの標準パーツ(ねじ、ナット、座金、ピン)を選択してください。ボディを拡大・縮小するには尺度を使います。",
"ui.part_size_shared": "パーツを再構築し、追加したフィーチャを置き換えます。このコンポーネントのすべての配置が更新されます。",
"ui.paste": "貼り付け",
"ui.path": "パス",
@@ -4269,6 +4313,18 @@
"ui.project": "投影",
"ui.project_face_edges_sketch": "新規スケッチに面のエッジを自動投影",
"ui.properties": "プロパティ",
"ui.properties.copy_matrix": "行列をコピー",
"ui.properties.first_moments": "一次モーメント",
"ui.properties.inertia": "慣性モーメント",
"ui.properties.inertia_note": "重心まわり、ワールド軸 (g·{unit}²)",
"ui.properties.inertia_unavailable": "利用できません: ボディのメッシュが閉じていません。",
"ui.properties.inertia_x": "Ixx, Ixy, Ixz",
"ui.properties.inertia_y": "Iyx, Iyy, Iyz",
"ui.properties.inertia_z": "Izx, Izy, Izz",
"ui.properties.measuring": "測定中…",
"ui.properties.measuring_failed": "測定が完了する前に停止しました。",
"ui.properties.principal_axis": "主軸 {number}",
"ui.properties.principal_moments": "主慣性モーメント",
"ui.pull_direction": "抜き方向",
"ui.quality": "品質",
"ui.quantity": "数量",
@@ -4367,7 +4423,11 @@
"ui.sketch": "スケッチ",
"ui.sketch_dimension": "スケッチ寸法",
"ui.sketch_grid": "スケッチ グリッド",
"ui.sketch_intersect_geometry": "ジオメトリ",
"ui.sketch_intersect_projection_link": "投影リンク",
"ui.sketch_intersect_selection_filter": "選択フィルター",
"ui.sketch_modifiers": "スケッチ修飾キー",
"ui.sketch_project_geometry": "ジオメトリ",
"ui.sketch_scale": "スケッチ尺度",
"ui.sketches": "スケッチ",
"ui.slice": "スライス",
@@ -4414,6 +4474,11 @@
"ui.symmetric": "対称",
"ui.symmetry": "対称",
"ui.system": "システム",
"ui.tab_assemble": "アセンブリ",
"ui.tab_manage": "管理",
"ui.tab_sheet_metal": "板金",
"ui.tab_view": "表示",
"ui.tab_weldments": "溶接構造",
"ui.tangent": "正接",
"ui.taper_angle": "テーパ角度",
"ui.tapped": "ねじ穴",
@@ -4429,6 +4494,7 @@
"ui.tip_angle": "先端角度",
"ui.to_object": "オブジェクトまで",
"ui.tool_direction": "工具方向",
"ui.toolbar_tabs_hidden": "ツールバーのタブはすべて非表示です。+ をクリックするか、ここを右クリックして表示します。",
"ui.top": "上面",
"ui.top_diameter": "上面の直径",
"ui.top_radius": "上面の半径",
@@ -4475,6 +4541,7 @@
"ui.visual_shaded_with_edges": "エッジ付きシェーディング",
"ui.visual_style": "表示スタイル",
"ui.visual_wireframe": "ワイヤーフレーム",
"ui.wheel_zoom_speed": "マウスホイールのズーム速度",
"ui.width": "幅",
"ui.window_system": "ウィンドウシステム",
"ui.window_system_auto": "自動",
@@ -4528,6 +4595,9 @@
"update.prefs_managed": "これは {manager} が管理している SolidCraft {version} です。確認では GitHub に最新リリースを問い合わせるだけで、ほかには何も送信しません。SolidCraft は {manager} で更新してください。",
"update.prefs_note": "これは SolidCraft {version} ({kind}) です。確認では GitHub に最新リリースを問い合わせるだけで、ほかには何も送信しません。",
"update.prefs_web": "ページを再読み込みすると、最新のウェブアプリを取得します。ネットワークが利用できない場合や、更新のダウンロードが時間内に完了しない場合は、保存済みのバージョンを開きます。進行中のダウンロードは次回の再読み込みに向けて続行されます。",
"update.refresh_checking": "最新のリリースを確認中…",
"update.refresh_fallback": "新しいリリースを確認できませんでした。以前見つかった {version} に更新します。",
"update.refresh_selected": "{version} に更新中。",
"update.release_page": "リリースページを開く",
"update.restart": "今すぐ再起動",
"update.restart_later": "後で",
+37
View File
@@ -1330,6 +1330,8 @@
"nav.maya.help": "Alt+왼쪽 드래그는 회전, Alt+가운데 드래그는 이동, Alt+오른쪽 드래그는 확대/축소입니다. 가운데 드래그로도 이동합니다",
"nav.openscad.help": "빈 공간에서 왼쪽 드래그는 회전, Shift+왼쪽 드래그는 상자 선택, 스케치 형상 드래그는 편집입니다. 오른쪽 또는 가운데 드래그는 이동, Shift+오른쪽 또는 Shift+가운데 드래그는 확대/축소입니다",
"nav.perspective.sketch": "스케치 편집 중에는 직교 뷰를 사용합니다. 원근 뷰를 전환하려면 스케치를 마치세요.",
"nav.right_drag_orbits.help": "끄면 오른쪽 드래그는 카메라에 영향을 주지 않고 오른쪽 버튼은 컨텍스트 메뉴만 엽니다. SolidWorks 마우스 매핑을 선택하면 꺼집니다.",
"nav.right_drag_orbits.label": "오른쪽 드래그로 회전",
"nav.solidworks.help": "가운데 드래그는 회전, Ctrl+가운데 드래그는 이동, Shift+가운데 드래그는 확대/축소, 오른쪽 드래그는 회전입니다",
"nav.tinkercad.help": "오른쪽 드래그는 회전, 가운데 또는 Shift+오른쪽 드래그는 이동입니다",
"nav.touchpad.help": "Shift를 누른 채 포인터를 움직이면 화면이 이동하고, Alt/Option을 누른 채 움직이면 회전합니다. 마우스 버튼은 필요 없습니다. 세로 스크롤로 확대/축소합니다.",
@@ -1583,6 +1585,8 @@
"prefs.origin_plane_names_note": "브라우저, 대화 상자, 목록에 표시되는 이름입니다. {top}은 XY, {front}는 XZ, {right}는 YZ이며 Z가 위쪽입니다. 명령, 스크립트, 저장된 디자인은 XY, XZ, YZ를 유지합니다.",
"prefs.overall_speed": "전체 속도",
"prefs.preview_choices": "선택 항목 미리보기",
"prefs.project_options_dialog": "Project 옵션 대화상자",
"prefs.project_options_dialog_note": "켜기: 투영 전에 지오메트리 또는 바디와 투영 링크를 선택합니다. 끄기: 기존 직접 선택 작업 흐름을 유지합니다.",
"prefs.quality_default": "기본값",
"prefs.quality_fastest": "가장 빠름",
"prefs.quality_slowest": "가장 느림",
@@ -1638,7 +1642,14 @@
"prefs.toast_place": "알림",
"prefs.toggle_item": "항목을 추가하거나 다시 제거",
"prefs.toolbar": "도구 모음",
"prefs.toolbar_tabs": "도구 모음 탭",
"prefs.top": "위쪽",
"prefs.trackpad_zoom_modifier": "다음 키를 누른 채 두 손가락으로 스크롤하여 확대/축소",
"prefs.trackpad_zoom_modifier.alt": "⌥ / Alt",
"prefs.trackpad_zoom_modifier.cmd": "⌘",
"prefs.trackpad_zoom_modifier.ctrl": "Ctrl",
"prefs.trackpad_zoom_modifier.help": "이 키만 누른 채 세로로 스크롤하면 포인터 위치를 기준으로 확대/축소합니다. 끄면 기존 스크롤 동작을 유지합니다.",
"prefs.trackpad_zoom_modifier.off": "끄기",
"prefs.translucent": "반투명",
"prefs.under_browser": "브라우저 아래",
"prefs.under_cursor": "커서 아래",
@@ -3354,6 +3365,7 @@
"ui.pan_zoom_orbit": "이동, 확대/축소, 궤도 회전",
"ui.parallel": "평행",
"ui.parameters": "매개변수",
"ui.part_size_needs_a_part": "크기를 바꿀 삽입된 표준 부품(나사, 너트, 와셔 또는 핀)을 선택하십시오. 바디를 키우거나 줄이려면 축척을 사용하십시오.",
"ui.part_size_shared": "부품을 다시 빌드하며 추가된 피처는 모두 대체됩니다. 이 구성요소의 모든 인스턴스가 업데이트됩니다.",
"ui.paste": "붙여넣기",
"ui.path": "경로",
@@ -3394,6 +3406,18 @@
"ui.project": "투영",
"ui.project_face_edges_sketch": "면 모서리를 새 스케치로 투영",
"ui.properties": "속성",
"ui.properties.copy_matrix": "행렬 복사",
"ui.properties.first_moments": "1차 모멘트",
"ui.properties.inertia": "관성 모멘트",
"ui.properties.inertia_note": "질량 중심 기준, 월드 축 (g·{unit}²)",
"ui.properties.inertia_unavailable": "사용할 수 없음: 바디의 메시가 닫혀 있지 않습니다.",
"ui.properties.inertia_x": "Ixx, Ixy, Ixz",
"ui.properties.inertia_y": "Iyx, Iyy, Iyz",
"ui.properties.inertia_z": "Izx, Izy, Izz",
"ui.properties.measuring": "측정 중…",
"ui.properties.measuring_failed": "측정이 끝나기 전에 중단되었습니다.",
"ui.properties.principal_axis": "주축 {number}",
"ui.properties.principal_moments": "주관성 모멘트",
"ui.pull_direction": "빼기 방향",
"ui.quality": "품질",
"ui.quantity": "수량",
@@ -3483,7 +3507,11 @@
"ui.sketch": "스케치",
"ui.sketch_dimension": "스케치 치수",
"ui.sketch_grid": "스케치 격자",
"ui.sketch_intersect_geometry": "형상",
"ui.sketch_intersect_projection_link": "투영 링크",
"ui.sketch_intersect_selection_filter": "선택 필터",
"ui.sketch_modifiers": "스케치 보조 키",
"ui.sketch_project_geometry": "형상",
"ui.sketch_scale": "스케치 축척",
"ui.sketches": "스케치",
"ui.slice": "슬라이스",
@@ -3519,6 +3547,11 @@
"ui.symmetric": "대칭",
"ui.symmetry": "대칭",
"ui.system": "시스템",
"ui.tab_assemble": "조립",
"ui.tab_manage": "관리",
"ui.tab_sheet_metal": "판금",
"ui.tab_view": "보기",
"ui.tab_weldments": "용접 구조물",
"ui.tangent": "접선",
"ui.taper_angle": "테이퍼 각도",
"ui.tapped": "탭",
@@ -3534,6 +3567,7 @@
"ui.tip_angle": "선단 각도",
"ui.to_object": "객체까지",
"ui.tool_direction": "공구 방향",
"ui.toolbar_tabs_hidden": "모든 도구 모음 탭이 숨겨져 있습니다. +를 클릭하거나 여기를 마우스 오른쪽 버튼으로 클릭하여 표시하세요.",
"ui.top": "상면",
"ui.top_diameter": "윗면 지름",
"ui.top_radius": "윗면 반지름",
@@ -3625,6 +3659,9 @@
"update.prefs_label": "업데이트",
"update.prefs_note": "SolidCraft {version} ({kind})입니다. 확인 시 GitHub에 최신 릴리스만 요청하며 다른 정보는 보내지 않습니다.",
"update.prefs_web": "페이지를 새로고침하면 최신 웹 앱을 가져옵니다. 네트워크를 사용할 수 없거나 업데이트 다운로드가 제한 시간 안에 완료되지 않으면 저장된 버전이 열립니다. 진행 중인 다운로드는 다음 새로고침을 위해 계속됩니다.",
"update.refresh_checking": "최신 릴리스 확인 중…",
"update.refresh_fallback": "새 릴리스를 확인하지 못했습니다. 이전에 찾은 {version}(으)로 업데이트합니다.",
"update.refresh_selected": "{version}(으)로 업데이트 중.",
"update.release_page": "릴리스 페이지 열기",
"update.restart": "지금 다시 시작",
"update.restart_later": "나중에",
+5
View File
@@ -3950,6 +3950,7 @@
"ui.pan_zoom_orbit": "Sorot, zum, orbit",
"ui.parallel": "Selari",
"ui.parameters": "Parameter",
"ui.part_size_needs_a_part": "Pilih bahagian piawai yang disisipkan (skru, nat, sesendal atau pin) untuk mengubah saiznya. Untuk membesarkan atau mengecilkan jasad, gunakan Skala.",
"ui.part_size_shared": "Membina semula bahagian, menggantikan sebarang ciri yang ditambah. Semua kejadian komponen ini dikemas kini.",
"ui.paste": "Tampal",
"ui.path": "Laluan",
@@ -4144,6 +4145,10 @@
"ui.symmetric": "Simetri",
"ui.symmetry": "Simetri",
"ui.system": "Sistem",
"ui.tab_assemble": "Pasang",
"ui.tab_manage": "Urus",
"ui.tab_sheet_metal": "Logam Kepingan",
"ui.tab_view": "Pandangan",
"ui.tangent": "Tangen",
"ui.taper_angle": "Sudut Tirus",
"ui.tapped": "Berbebenang",
+5
View File
@@ -3950,6 +3950,7 @@
"ui.pan_zoom_orbit": "Panorer, zoom og roter visningen",
"ui.parallel": "Parallell",
"ui.parameters": "Parametere",
"ui.part_size_needs_a_part": "Velg en innsatt standarddel (en skrue, mutter, skive eller stift) for å endre størrelsen. Bruk Skaler for å gjøre en kropp større eller mindre.",
"ui.part_size_shared": "Bygger delen på nytt og erstatter eventuelle funksjoner som er lagt til. Alle forekomster av denne komponenten oppdateres.",
"ui.paste": "Lim inn",
"ui.path": "Bane",
@@ -4144,6 +4145,10 @@
"ui.symmetric": "Symmetrisk",
"ui.symmetry": "Symmetri",
"ui.system": "System",
"ui.tab_assemble": "Monter",
"ui.tab_manage": "Administrer",
"ui.tab_sheet_metal": "Plate",
"ui.tab_view": "Visning",
"ui.tangent": "Tangent",
"ui.taper_angle": "Konusvinkel",
"ui.tapped": "Gjenget",
+6 -1
View File
@@ -496,7 +496,7 @@
"Report text copied. Paste it into GitHub and submit your report there.": "Rapporttekst gekopieerd. Plak de tekst in GitHub en dien uw rapport daar in.",
"Reports are public. A GitHub account is required.": "Rapporten zijn openbaar. Een GitHub-account is vereist.",
"Reset Toolbar": "Werkbalk herstellen",
"Resize Part": "Onderdeel schalen",
"Resize Part": "Onderdeelgrootte wijzigen",
"Restore View": "Weergave herstellen",
"Restore outer boundary": "Buitenste begrenzing herstellen",
"Restore the inherited component or physical material appearance": "Het overgeërfde component- of fysieke materiaaluiterlijk herstellen",
@@ -3954,6 +3954,7 @@
"ui.pan_zoom_orbit": "Pannen, zoomen, roteren",
"ui.parallel": "Evenwijdig",
"ui.parameters": "Parameters",
"ui.part_size_needs_a_part": "Selecteer een ingevoegd standaardonderdeel (een schroef, moer, sluitring of pen) om de grootte te wijzigen. Gebruik Schalen om een lichaam groter of kleiner te maken.",
"ui.part_size_shared": "Bouwt het onderdeel opnieuw op en vervangt alle toegevoegde kenmerken. Alle voorkomens van dit component worden bijgewerkt.",
"ui.paste": "Plakken",
"ui.path": "Pad",
@@ -4148,6 +4149,10 @@
"ui.symmetric": "Symmetrisch",
"ui.symmetry": "Symmetrie",
"ui.system": "Systeem",
"ui.tab_assemble": "Samenstellen",
"ui.tab_manage": "Beheren",
"ui.tab_sheet_metal": "Plaatwerk",
"ui.tab_view": "Weergave",
"ui.tangent": "Tangentieel",
"ui.taper_angle": "Tapsheidshoek",
"ui.tapped": "Met schroefdraad",
+5
View File
@@ -2030,6 +2030,7 @@
"ui.pan_zoom_orbit": "Przesuwanie, powiększanie, obracanie",
"ui.parallel": "Równoległość",
"ui.parameters": "Parametry",
"ui.part_size_needs_a_part": "Zaznacz wstawioną część znormalizowaną (śrubę, nakrętkę, podkładkę lub kołek), aby zmienić jej rozmiar. Aby powiększyć lub pomniejszyć bryłę, użyj polecenia Skala.",
"ui.part_size_shared": "Przebudowuje część, zastępując wszystkie dodane elementy. Wszystkie wystąpienia tego komponentu zostaną zaktualizowane.",
"ui.paste": "Wklej",
"ui.path": "Ścieżka",
@@ -2174,6 +2175,10 @@
"ui.symmetric": "Symetrycznie",
"ui.symmetry": "Symetria",
"ui.system": "Systemowy",
"ui.tab_assemble": "Złożenie",
"ui.tab_manage": "Zarządzanie",
"ui.tab_sheet_metal": "Blacha",
"ui.tab_view": "Widok",
"ui.tangent": "Styczność",
"ui.taper_angle": "Kąt pochylenia",
"ui.tapped": "Gwintowany",
+5
View File
@@ -2161,6 +2161,7 @@
"ui.pan_zoom_orbit": "Deslocar, zoom, orbitar",
"ui.parallel": "Paralelo",
"ui.parameters": "Parâmetros",
"ui.part_size_needs_a_part": "Selecione uma peça padrão inserida (um parafuso, uma porca, uma arruela ou um pino) para alterar o tamanho. Para aumentar ou diminuir um corpo, use Escala.",
"ui.part_size_shared": "Reconstrói a peça, substituindo quaisquer recursos adicionados. Todas as ocorrências deste componente são atualizadas.",
"ui.paste": "Colar",
"ui.path": "Caminho",
@@ -2304,6 +2305,10 @@
"ui.symmetric": "Simétrico",
"ui.symmetry": "Simetria",
"ui.system": "Sistema",
"ui.tab_assemble": "Montar",
"ui.tab_manage": "Gerenciar",
"ui.tab_sheet_metal": "Chapa Metálica",
"ui.tab_view": "Vista",
"ui.tangent": "Tangente",
"ui.taper_angle": "Ângulo de inclinação",
"ui.tapped": "Roscado",
+5
View File
@@ -3950,6 +3950,7 @@
"ui.pan_zoom_orbit": "Panoramare, zoom, orbitare",
"ui.parallel": "Paralel",
"ui.parameters": "Parametri",
"ui.part_size_needs_a_part": "Selectați o piesă standard inserată (un șurub, o piuliță, o șaibă sau un știft) pentru a-i schimba dimensiunea. Pentru a mări sau micșora un corp, folosiți Scalare.",
"ui.part_size_shared": "Reconstruiește piesa, înlocuind orice operații adăugate. Toate aparițiile acestei componente se actualizează.",
"ui.paste": "Lipește",
"ui.path": "Traseu",
@@ -4144,6 +4145,10 @@
"ui.symmetric": "Simetric",
"ui.symmetry": "Simetrie",
"ui.system": "Sistem",
"ui.tab_assemble": "Asamblare",
"ui.tab_manage": "Gestionare",
"ui.tab_sheet_metal": "Tablă",
"ui.tab_view": "Vizualizare",
"ui.tangent": "Tangent",
"ui.taper_angle": "Unghi de conicitate",
"ui.tapped": "Filetat",
+5
View File
@@ -3702,6 +3702,7 @@
"ui.pan_zoom_orbit": "Панорама, масштаб, орбита",
"ui.parallel": "Параллельность",
"ui.parameters": "Параметры",
"ui.part_size_needs_a_part": "Выберите вставленную стандартную деталь (винт, гайку, шайбу или штифт), чтобы изменить её размер. Чтобы увеличить или уменьшить тело, используйте «Масштаб».",
"ui.part_size_shared": "Перестраивает деталь, заменяя все добавленные операции. Обновляются все вхождения этого компонента.",
"ui.paste": "Вставить",
"ui.path": "Траектория",
@@ -3885,6 +3886,10 @@
"ui.symmetric": "Симметрично",
"ui.symmetry": "Симметрия",
"ui.system": "Системная",
"ui.tab_assemble": "Сборка",
"ui.tab_manage": "Управление",
"ui.tab_sheet_metal": "Листовой Металл",
"ui.tab_view": "Вид",
"ui.tangent": "Касание",
"ui.taper_angle": "Угол наклона",
"ui.tapped": "С резьбой",
+1
View File
@@ -3975,6 +3975,7 @@
"ui.pan_zoom_orbit": "Померање, зумирање, орбитирање",
"ui.parallel": "Паралелност",
"ui.parameters": "Параметри",
"ui.part_size_needs_a_part": "Изаберите уметнути стандардни део (завртањ, навртку, подлошку или чивију) да бисте му променили величину. Да тело повећате или смањите, користите Скалирај.",
"ui.part_size_shared": "Поново гради део, замењујући све додате операције. Сви примерци ове компоненте се ажурирају.",
"ui.paste": "Налепи",
"ui.path": "Путања",
+5
View File
@@ -3950,6 +3950,7 @@
"ui.pan_zoom_orbit": "Panorera, zooma, orbitera",
"ui.parallel": "Parallell",
"ui.parameters": "Parametrar",
"ui.part_size_needs_a_part": "Välj en infogad standarddel (en skruv, mutter, bricka eller ett stift) för att ändra dess storlek. Använd Skala för att göra en kropp större eller mindre.",
"ui.part_size_shared": "Återbygger delen och ersätter eventuella tillagda funktioner. Alla förekomster av denna komponent uppdateras.",
"ui.paste": "Klistra in",
"ui.path": "Bana",
@@ -4144,6 +4145,10 @@
"ui.symmetric": "Symmetrisk",
"ui.symmetry": "Symmetri",
"ui.system": "System",
"ui.tab_assemble": "Montera",
"ui.tab_manage": "Hantera",
"ui.tab_sheet_metal": "Plåt",
"ui.tab_view": "Vy",
"ui.tangent": "Tangent",
"ui.taper_angle": "Avsmalningsvinkel",
"ui.tapped": "Gängad",
+21
View File
@@ -1347,6 +1347,7 @@
"help.material.assign": "Seçili gövdelerin neyden yapıldığını belirler: çelik, alüminyum veya ABS gibi. Kütle ve ağırlığın gerçek parçayla uyuşması için kullanın.",
"help.mit": "MIT lisansı",
"help.notice": "NOTICE",
"help.occurrence.move": "Montaj parçasını yalnızca eklemlerinin izin verdiği serbestliklerde taşır. Bir parça yüzü seçtikten sonra kullanılabilir oku veya yayı sürükleyin.",
"help.parameters.change": "Tasarımın ölçülerini yöneten adlandırılmış değerlerin ve ifadelerin tablosunu açar. Bir ölçüyü tek yerden değiştirip onu kullanan her özelliğin buna uymasını sağlamak için kullanın.",
"help.pointcloud.insert": "PLY, XYZ veya PTS taramasını modelin yanına nokta bulutu olarak ekler. Ölçülmüş verilerden model oluştururken referans olarak kullanın.",
"help.solid.align": "Seçili gövdeleri, üzerlerindeki bir nokta, yüz veya düzlem başka bir yerdeki nokta, yüz veya düzlemle buluşacak şekilde taşır. Ofseti ölçmeden bir parçayı diğerinin yanına yerleştirmek için kullanın.",
@@ -1475,6 +1476,21 @@
"joint.one_component": "Bu parçalar tek bir bileşenin gövdeleridir: {bodies}. Bir bağlantı bir bileşeni diğerine göre hareket ettirir, bu nedenle her parçanın kendi bileşeni olması gerekir.",
"joint.rigid_group_one_component": "Bu {count} gövdenin tümü tek bir bileşende (veya tasarımın kendisinde): önce {command} kullanın.",
"joint.rigid_group_select_components": "İki veya daha fazla bileşen seçin.",
"joint_move.blocked_hint": "Eklem türünü (ör. Sabit), parçanın sabitlenme durumunu, eklem sınırlarını ve parametreyle belirlenen değerleri kontrol edin.",
"joint_move.blocked_title": "Bu parça taşınamıyor",
"joint_move.coarse": "Eksene yakın dönüş adımı (°)",
"joint_move.distance": "Başlangıçtan itibaren mesafe",
"joint_move.done": "Tamam",
"joint_move.fine": "Eksenden uzak dönüş adımı (°)",
"joint_move.fixed": "Bu parçanın hareket ettirilebilir bir eklem ekseni yok.",
"joint_move.help": "Taşı — oku veya yayı sürükleyin. Esc geçerli sürüklemeyi iptal eder.",
"joint_move.legacy_hint": "Önceki Taşı panelini kullanmak için kapatın.",
"joint_move.precision": "Dönüşü hassas ayarlamak için kesikli kılavuzun dışına sürükleyin.",
"joint_move.rotation": "Başlangıçtan itibaren dönüş",
"joint_move.select": "Bir yüz veya montajdaki bir parçayı seçin, ardından Taşı'yı seçin.",
"joint_move.settings": "Montajda Taşıma",
"joint_move.step": "Adım",
"joint_move.use_handles": "Montajda Taşıma için eklem hareket tutamaçlarını kullan",
"keymap.command_id": "Komut kimliği",
"keymap.empty": "Eşleşen komut yok",
"keymap.use_anyway": "Yine de burada {key} kullan",
@@ -3953,6 +3969,7 @@
"ui.pan_zoom_orbit": "Kaydır, yakınlaştır, döndür",
"ui.parallel": "Paralel",
"ui.parameters": "Parametreler",
"ui.part_size_needs_a_part": "Boyutunu değiştirmek için eklenmiş bir standart parça (vida, somun, pul veya pim) seçin. Bir gövdeyi büyütmek veya küçültmek için Ölçekle'yi kullanın.",
"ui.part_size_shared": "Parçayı yeniden oluşturur ve eklenen tüm özelliklerin yerine geçer. Bu bileşenin tüm örnekleri güncellenir.",
"ui.paste": "Yapıştır",
"ui.path": "Yol",
@@ -4147,6 +4164,10 @@
"ui.symmetric": "Simetrik",
"ui.symmetry": "Simetri",
"ui.system": "Sistem",
"ui.tab_assemble": "Montaj",
"ui.tab_manage": "Yönet",
"ui.tab_sheet_metal": "Sac Metal",
"ui.tab_view": "Görünüm",
"ui.tangent": "Teğet",
"ui.taper_angle": "Konik açı",
"ui.tapped": "Kılavuzlu",
+5
View File
@@ -3950,6 +3950,7 @@
"ui.pan_zoom_orbit": "Панорамування, масштаб, обертання",
"ui.parallel": "Паралельність",
"ui.parameters": "Параметри",
"ui.part_size_needs_a_part": "Виберіть вставлену стандартну деталь (гвинт, гайку, шайбу або штифт), щоб змінити її розмір. Щоб збільшити або зменшити тіло, скористайтеся «Масштаб».",
"ui.part_size_shared": "Перебудовує деталь, замінюючи всі додані елементи. Усі входження цього компонента оновлюються.",
"ui.paste": "Вставити",
"ui.path": "Траєкторія",
@@ -4144,6 +4145,10 @@
"ui.symmetric": "Симетричний",
"ui.symmetry": "Симетрія",
"ui.system": "Системна",
"ui.tab_assemble": "Складання",
"ui.tab_manage": "Керування",
"ui.tab_sheet_metal": "Листовий Метал",
"ui.tab_view": "Вид",
"ui.tangent": "Дотична",
"ui.taper_angle": "Кут звуження",
"ui.tapped": "З різьбою",
+70
View File
@@ -652,6 +652,7 @@
"Third point": "Điểm thứ ba",
"This child is shared. Deleting it removes it from every copy of its parent.": "Đối tượng con này được dùng chung. Việc xóa nó sẽ xóa khỏi mọi bản sao của đối tượng cha.",
"This child is shared. Position changes apply to every copy of its parent.": "Đối tượng con này được dùng chung. Thay đổi vị trí sẽ áp dụng cho mọi bản sao của đối tượng cha.",
"This component is inside a subassembly placed more than once: aligning it moves it in every placement.": "Thành phần này nằm trong một cụm lắp phụ được đặt nhiều lần: căn chỉnh nó sẽ làm nó di chuyển ở mọi vị trí đặt.",
"This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "Bề mặt này không thể tạo liên kết bền vững. Hãy chọn bề mặt khác hoặc sử dụng mặt phẳng cố định.",
"This image is not attached to GitHub yet. Copy or save it, then attach it on GitHub.": "Ảnh này chưa được đính kèm vào GitHub. Vui lòng sao chép hoặc lưu ảnh, sau đó đính kèm lên GitHub.",
"Thread depth": "Chiều sâu ren",
@@ -984,6 +985,7 @@
"cmd.motion_trace": "Quỹ đạo chuyển động",
"cmd.motion_trace_edit": "Chỉnh sửa quỹ đạo chuyển động",
"cmd.motion_trace_keep": "Giữ lại đường quỹ đạo chuyển động",
"cmd.occurrence_align": "Căn chỉnh bản thể",
"cmd.occurrence_copy": "Dán (Bản thể)",
"cmd.occurrence_drag": "Kéo chi tiết xuất hiện",
"cmd.occurrence_ground": "Cố định vị trí đối tượng",
@@ -1369,6 +1371,7 @@
"help.material.assign": "Đặt vật liệu cho các khối đã chọn, chẳng hạn thép, nhôm hoặc ABS. Dùng để khối lượng và trọng lượng khớp với chi tiết thật.",
"help.mit": "Giấy phép MIT",
"help.notice": "NOTICE",
"help.occurrence.move": "Chỉ di chuyển chi tiết lắp ráp theo các bậc tự do mà khớp nối cho phép. Chọn một mặt của chi tiết, rồi kéo mũi tên hoặc cung tròn có thể sử dụng.",
"help.parameters.change": "Mở bảng các giá trị có tên và biểu thức điều khiển kích thước của thiết kế. Dùng để sửa một kích thước ở một chỗ và mọi tính năng dùng nó sẽ cập nhật theo.",
"help.pointcloud.insert": "Chèn bản quét PLY, XYZ hoặc PTS dưới dạng đám mây điểm bên cạnh mô hình. Dùng làm tham chiếu khi dựng mô hình từ dữ liệu đo.",
"help.solid.align": "Di chuyển các khối đã chọn để một điểm, mặt hoặc mặt phẳng trên chúng gặp một điểm, mặt hoặc mặt phẳng ở nơi khác. Dùng để đặt chi tiết áp vào chi tiết khác mà không cần đo khoảng cách.",
@@ -1499,6 +1502,21 @@
"joint.one_component": "Các chi tiết này thuộc cùng một cụm chi tiết: {bodies}. Khớp nối điều khiển một cụm chi tiết so với cụm chi tiết khác, vì vậy mỗi chi tiết cần một cụm chi tiết riêng.",
"joint.rigid_group_one_component": "Tất cả {count} thân khối này đều nằm trong cùng một cụm chi tiết (hoặc trong chính thiết kế): hãy dùng lệnh {command} trước.",
"joint.rigid_group_select_components": "Chọn từ hai cụm chi tiết trở lên.",
"joint_move.blocked_hint": "Kiểm tra loại khớp nối (chẳng hạn khớp nối cố định), trạng thái cố định của chi tiết, giới hạn khớp nối và các giá trị do tham số điều khiển.",
"joint_move.blocked_title": "Không thể di chuyển chi tiết này",
"joint_move.coarse": "Bước xoay khi ở gần trục (°)",
"joint_move.distance": "Quãng đường từ vị trí bắt đầu",
"joint_move.done": "Xong",
"joint_move.fine": "Bước xoay khi ở xa trục (°)",
"joint_move.fixed": "Chi tiết này không có trục khớp nối nào có thể di chuyển.",
"joint_move.help": "Di chuyển — kéo mũi tên hoặc cung tròn. Nhấn Esc để hủy thao tác kéo hiện tại.",
"joint_move.legacy_hint": "Tắt để sử dụng bảng Di chuyển trước đây.",
"joint_move.precision": "Kéo ra ngoài đường dẫn nét đứt để tinh chỉnh góc xoay.",
"joint_move.rotation": "Góc xoay từ vị trí bắt đầu",
"joint_move.select": "Chọn một mặt hoặc một chi tiết trong cụm lắp ráp, rồi chọn Di chuyển.",
"joint_move.settings": "Di chuyển trong cụm lắp ráp",
"joint_move.step": "Bước",
"joint_move.use_handles": "Dùng tay nắm chuyển động khớp nối khi di chuyển trong cụm lắp ráp",
"keymap.command_id": "Mã lệnh",
"keymap.empty": "Không có lệnh phù hợp",
"keymap.use_anyway": "Vẫn dùng {key} tại đây",
@@ -1593,6 +1611,8 @@
"nav.maya.help": "Alt+kéo chuột trái để xoay, Alt+chuột giữa để trượt, Alt+chuột phải để thu phóng; kéo chuột giữa cũng trượt",
"nav.openscad.help": "Kéo chuột trái trên vùng trống để xoay, Shift + chuột trái để quét chọn vùng, kéo hình học phác thảo để chỉnh sửa; kéo chuột phải hoặc chuột giữa để trượt, Shift + kéo chuột phải hoặc chuột giữa để thu phóng",
"nav.perspective.sketch": "Chế độ chỉnh sửa phác thảo sử dụng góc nhìn trực giao. Hãy hoàn tất phác thảo để chuyển sang góc nhìn phối cảnh.",
"nav.right_drag_orbits.help": "Tắt: kéo chuột phải không di chuyển camera, và nút phải chỉ mở menu ngữ cảnh. Chọn kiểu chuột SolidWorks sẽ tắt tùy chọn này.",
"nav.right_drag_orbits.label": "Kéo chuột phải để xoay",
"nav.solidworks.help": "Kéo chuột giữa để xoay, Ctrl + chuột giữa để trượt, Shift + chuột giữa để thu phóng, kéo chuột phải để xoay",
"nav.tinkercad.help": "Kéo chuột phải để xoay, kéo chuột giữa hoặc Shift + kéo chuột phải để trượt",
"nav.touchpad.help": "Giữ Shift và di chuyển con trỏ để dời góc nhìn; giữ Alt/Option và di chuyển để xoay quanh tâm. Không cần nhấn nút chuột. Cuộn dọc để thu phóng.",
@@ -1614,6 +1634,9 @@
"navbar.custom.customise": "Tùy chỉnh thanh điều hướng…",
"navbar.custom.design": "Thiết kế",
"navbar.custom.duplicate": "Ô này đã có trên thanh.",
"navbar.custom.edge": "Cạnh",
"navbar.custom.edge_bottom": "Dưới",
"navbar.custom.edge_top": "Trên",
"navbar.custom.fade": "Mờ dần",
"navbar.custom.full": "Hãy xóa một ô để thêm ô khác.",
"navbar.custom.hidden_design": "Ẩn trong Thiết kế",
@@ -1778,6 +1801,9 @@
"prefs.bottom_centre": "Giữa phía dưới",
"prefs.bottom_right": "Góc dưới bên phải",
"prefs.browser": "Cây thư mục",
"prefs.browser_reveal_on_hover": "Hiện khi di chuột",
"prefs.browser_show_note": "Hiển thị giữ trình duyệt trên màn hình. Hiện khi di chuột ẩn nó cho đến khi con trỏ đến vị trí của nó hoặc thẻ mảnh ở cạnh trái, và nó vẫn mở khi con trỏ ở bên trong hoặc một menu của nó đang mở.",
"prefs.browser_shown": "Hiển thị",
"prefs.built_in": "Mặc định tích hợp",
"prefs.button_left": "Kéo chuột trái",
"prefs.button_middle": "Kéo chuột giữa",
@@ -1883,10 +1909,14 @@
"prefs.placement": "Vị trí đặt",
"prefs.prefer_free_sketch_points": "Ưu tiên điểm phác thảo có thể di chuyển khi các điểm chồng lên nhau",
"prefs.preview_choices": "Xem trước các lựa chọn của bạn",
"prefs.project_options_dialog": "Hộp thoại tùy chọn Project",
"prefs.project_options_dialog_note": "Bật: chọn Hình học hoặc Thân và Liên kết chiếu trước khi chiếu. Tắt: giữ quy trình chọn trực tiếp trước đây.",
"prefs.quality_default": "mặc định",
"prefs.quality_fastest": "nhanh nhất",
"prefs.quality_slowest": "chậm nhất (chất lượng cao nhất)",
"prefs.quality_smoother": "mượt mà hơn",
"prefs.render_previous_geometry_budget": "Dùng ngân sách hình học trước đây cho bản xem trước kết xuất",
"prefs.render_previous_geometry_budget_help": "Khôi phục giới hạn hình học cố định trước đây (512 MiB trên máy tính 64 bit, 128 MiB trên các nền tảng khác) thay cho ngân sách dựa trên thiết bị.",
"prefs.reports_saved": "Đã lưu {n}",
"prefs.right_click": "Nhấp chuột phải",
"prefs.saving": "Tự động lưu và khôi phục",
@@ -1952,7 +1982,14 @@
"prefs.toast_place": "Thông báo",
"prefs.toggle_item": "Thêm mục, hoặc bỏ chọn lại",
"prefs.toolbar": "Thanh công cụ",
"prefs.toolbar_tabs": "Thẻ thanh công cụ",
"prefs.top": "Trên cùng",
"prefs.trackpad_zoom_modifier": "Thu phóng bằng cuộn hai ngón tay khi giữ",
"prefs.trackpad_zoom_modifier.alt": "⌥ / Alt",
"prefs.trackpad_zoom_modifier.cmd": "⌘",
"prefs.trackpad_zoom_modifier.ctrl": "Ctrl",
"prefs.trackpad_zoom_modifier.help": "Chỉ giữ phím này để thu phóng tại con trỏ bằng cuộn dọc. Tắt giữ nguyên hành vi cuộn hiện tại.",
"prefs.trackpad_zoom_modifier.off": "Tắt",
"prefs.translucent": "Bán trong suốt",
"prefs.under_browser": "Dưới cây thư mục",
"prefs.under_cursor": "Dưới con trỏ chuột",
@@ -1962,6 +1999,9 @@
"prefs.view_confirm_corner": "Nút Đồng ý và Hủy bỏ ở góc khung nhìn",
"prefs.view_confirm_corner_note": "Khi hộp thoại hoặc môi trường phác thảo đang mở, dấu tích và dấu chéo ở góc trên bên phải khung nhìn có chức năng tương tự nút Đồng ý và Hủy bỏ của hộp thoại, hoặc để hoàn tất và hủy phác thảo. Khối điều hướng góc nhìn sẽ dời xuống bên dưới chúng.",
"prefs.viewport": "Khung nhìn 3D",
"prefs.viewport_background": "Nền",
"prefs.viewport_background_colour": "Màu",
"prefs.viewport_background_theme": "Dải màu của chủ đề",
"prefs.wheel": "Con lăn chuột",
"prefs.wheel_trackpad": "Con lăn chuột và bàn di cảm ứng",
"prefs.workspace": "Không gian làm việc",
@@ -3296,6 +3336,7 @@
"select an axis": "chọn một trục",
"select an axis, a straight or circular edge, or a cylinder, cone or torus face first": "hãy chọn một trục, một cạnh thẳng hoặc cạnh tròn, hoặc một mặt trụ, mặt nón hay mặt xuyến trước",
"select bodies first": "hãy chọn các thân trước",
"select components first": "hãy chọn các thành phần trước",
"select corner points, or the two lines of one corner": "chọn các điểm góc, hoặc hai đường thẳng của một góc",
"select one corner point, or the two lines of one corner": "chọn một điểm góc, hoặc hai đường thẳng của một góc",
"select sheet edges first": "hãy chọn các cạnh của tấm trước",
@@ -3372,6 +3413,8 @@
"sketch_text.use_font_file": "Sử dụng tệp phông chữ {path}",
"solid": "khối đặc",
"status.editing": "Đang sửa {name}",
"status.navigation": "Gán chức năng chuột: {mapping}",
"status.navigation_tip": "Cách chuột di chuyển khung nhìn. Nhấp để chọn cách gán khác",
"status.under_constrained": "Thiếu ràng buộc ({count} bậc tự do)",
"status.under_constrained_one": "Thiếu ràng buộc ({count} bậc tự do)",
"status.units_tip": "Đơn vị của thiết kế. Nhấp để đổi",
@@ -4220,6 +4263,7 @@
"ui.pan_zoom_orbit": "Dời góc nhìn, thu phóng, xoay quanh tâm",
"ui.parallel": "Song song",
"ui.parameters": "Bảng tham số",
"ui.part_size_needs_a_part": "Hãy chọn một chi tiết tiêu chuẩn đã chèn (vít, đai ốc, vòng đệm hoặc chốt) để đổi kích thước. Để phóng to hoặc thu nhỏ một thân khối, hãy dùng Thay đổi tỷ lệ.",
"ui.part_size_shared": "Dựng lại chi tiết, thay thế mọi tính năng đã thêm. Tất cả các lần xuất hiện của chi tiết này đều được cập nhật.",
"ui.paste": "Dán",
"ui.path": "Đường dẫn",
@@ -4269,6 +4313,18 @@
"ui.project": "Chiếu hình học",
"ui.project_face_edges_sketch": "Chiếu các cạnh bề mặt vào phác thảo mới",
"ui.properties": "Thuộc tính cơ lý",
"ui.properties.copy_matrix": "Sao chép ma trận",
"ui.properties.first_moments": "Mômen bậc nhất",
"ui.properties.inertia": "Mômen quán tính",
"ui.properties.inertia_note": "Quanh trọng tâm, theo các trục thế giới (g·{unit}²)",
"ui.properties.inertia_unavailable": "Không khả dụng: lưới của một thân khối không kín.",
"ui.properties.inertia_x": "Ixx, Ixy, Ixz",
"ui.properties.inertia_y": "Iyx, Iyy, Iyz",
"ui.properties.inertia_z": "Izx, Izy, Izz",
"ui.properties.measuring": "Đang đo…",
"ui.properties.measuring_failed": "Phép đo đã dừng trước khi hoàn tất.",
"ui.properties.principal_axis": "Trục chính {number}",
"ui.properties.principal_moments": "Mômen quán tính chính",
"ui.pull_direction": "Hướng kéo",
"ui.quality": "Chất lượng",
"ui.quantity": "Số lượng",
@@ -4367,7 +4423,11 @@
"ui.sketch": "Phác thảo",
"ui.sketch_dimension": "Phác thảo Kích thước",
"ui.sketch_grid": "Lưới phác thảo",
"ui.sketch_intersect_geometry": "Hình học",
"ui.sketch_intersect_projection_link": "Liên kết chiếu",
"ui.sketch_intersect_selection_filter": "Bộ lọc lựa chọn",
"ui.sketch_modifiers": "Phím bổ trợ phác thảo",
"ui.sketch_project_geometry": "Hình học",
"ui.sketch_scale": "Tỷ lệ phác thảo",
"ui.sketches": "Các phác thảo",
"ui.slice": "Cắt lát",
@@ -4414,6 +4474,11 @@
"ui.symmetric": "Đối xứng hai phía",
"ui.symmetry": "Đối xứng",
"ui.system": "Hệ thống",
"ui.tab_assemble": "Lắp Ráp",
"ui.tab_manage": "Quản Lý",
"ui.tab_sheet_metal": "Kim Loại Tấm",
"ui.tab_view": "Góc Nhìn",
"ui.tab_weldments": "Kết Cấu Hàn",
"ui.tangent": "Tiếp tuyến",
"ui.taper_angle": "Góc côn (Taper)",
"ui.tapped": "Ren ta-rô",
@@ -4429,6 +4494,7 @@
"ui.tip_angle": "Góc đỉnh mũi khoan",
"ui.to_object": "Tới đối tượng",
"ui.tool_direction": "Hướng công cụ",
"ui.toolbar_tabs_hidden": "Tất cả các thẻ thanh công cụ đều bị ẩn. Nhấp vào + hoặc nhấp chuột phải tại đây để hiện một thẻ.",
"ui.top": "Trên (Hình chiếu bằng)",
"ui.top_diameter": "Đường kính đỉnh",
"ui.top_radius": "Bán kính đỉnh",
@@ -4475,6 +4541,7 @@
"ui.visual_shaded_with_edges": "Tô bóng kèm hiển thị cạnh",
"ui.visual_style": "Kiểu hiển thị trực quan",
"ui.visual_wireframe": "Khung dây (Wireframe)",
"ui.wheel_zoom_speed": "Tốc độ thu phóng bằng con lăn chuột",
"ui.width": "Bề rộng",
"ui.window_system": "Hệ thống cửa sổ",
"ui.window_system_auto": "Tự động",
@@ -4528,6 +4595,9 @@
"update.prefs_managed": "Đây là SolidCraft {version}, do {manager} quản lý. Lần kiểm tra chỉ hỏi GitHub về bản phát hành mới nhất và không gửi gì khác; hãy cập nhật SolidCraft trong {manager}.",
"update.prefs_note": "Đây là SolidCraft {version} ({kind}). Quá trình kiểm tra gửi yêu cầu đến GitHub để tìm bản mới nhất và không gửi thông tin nào khác.",
"update.prefs_web": "Tải lại trang để nhận ứng dụng web mới nhất. Nếu mạng không khả dụng hoặc bản cập nhật không thể tải về kịp thời gian, phiên bản đã lưu sẽ mở thay thế; quá trình tải xuống đang diễn ra sẽ tiếp tục cho lần tải lại tiếp theo.",
"update.refresh_checking": "Đang kiểm tra bản phát hành mới nhất…",
"update.refresh_fallback": "Không thể kiểm tra bản phát hành mới hơn; đang cập nhật lên {version} đã tìm thấy trước đó.",
"update.refresh_selected": "Đang cập nhật lên {version}.",
"update.release_page": "Mở trang phát hành",
"update.restart": "Khởi động lại ngay",
"update.restart_later": "Để sau",
+5
View File
@@ -3950,6 +3950,7 @@
"ui.pan_zoom_orbit": "平移、縮放、環轉",
"ui.parallel": "平行",
"ui.parameters": "參數",
"ui.part_size_needs_a_part": "請選取已插入的標準零件(螺絲、螺帽、墊圈或銷)以變更其尺寸。要放大或縮小實體,請使用縮放。",
"ui.part_size_shared": "重建零件並取代額外新增的特徵。此元件的所有實例都會更新。",
"ui.paste": "貼上",
"ui.path": "路徑",
@@ -4144,6 +4145,10 @@
"ui.symmetric": "對稱",
"ui.symmetry": "對稱",
"ui.system": "系統",
"ui.tab_assemble": "組裝",
"ui.tab_manage": "管理",
"ui.tab_sheet_metal": "鈑金",
"ui.tab_view": "檢視",
"ui.tangent": "相切",
"ui.taper_angle": "錐度角",
"ui.tapped": "攻牙",
+70
View File
@@ -652,6 +652,7 @@
"Third point": "第三点",
"This child is shared. Deleting it removes it from every copy of its parent.": "此子项是共享的。删除它会从其父项的每个副本中移除。",
"This child is shared. Position changes apply to every copy of its parent.": "此子项是共享的。位置更改会应用到其父项的每个副本。",
"This component is inside a subassembly placed more than once: aligning it moves it in every placement.": "此组件位于多次放置的子装配体中:对齐它会使其在每个放置位置中都移动。",
"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中附加。",
"Thread depth": "螺纹深度",
@@ -984,6 +985,7 @@
"cmd.motion_trace": "运动轨迹",
"cmd.motion_trace_edit": "编辑运动轨迹",
"cmd.motion_trace_keep": "保留运动轨迹",
"cmd.occurrence_align": "对齐实例",
"cmd.occurrence_copy": "粘贴(实例)",
"cmd.occurrence_drag": "拖动实例",
"cmd.occurrence_ground": "固定",
@@ -1369,6 +1371,7 @@
"help.material.assign": "设置所选实体的材料,例如钢、铝或 ABS。用于让质量和重量与实际零件一致。",
"help.mit": "MIT 许可证",
"help.notice": "NOTICE",
"help.occurrence.move": "仅沿关节允许的自由度移动装配零部件。选中零部件的面后,拖动可用的箭头或圆弧。",
"help.parameters.change": "打开驱动设计尺寸的命名数值和表达式表。用于在一处修改尺寸,让所有引用它的特征随之更新。",
"help.pointcloud.insert": "将 PLY、XYZ 或 PTS 扫描作为点云插入到模型旁。根据测量数据建模时,可将其用作参考。",
"help.solid.align": "移动所选实体,使其上的点、面或平面与别处的点、面或平面对齐。用于把一个零件贴靠到另一个零件上,而无需测量距离。",
@@ -1499,6 +1502,21 @@
"joint.one_component": "这些零件是同一组件的实体:{bodies}。接头使一个组件相对另一个组件运动,因此每个零件都需要自己的组件。",
"joint.rigid_group_one_component": "这 {count} 个实体都位于同一组件中(或直接位于设计中):请先使用“{command}”。",
"joint.rigid_group_select_components": "请选择两个或更多组件。",
"joint_move.blocked_hint": "请检查关节类型(例如 Rigid)、固定状态、关节限位和参数驱动值。",
"joint_move.blocked_title": "无法移动此零部件",
"joint_move.coarse": "靠近轴线时的旋转步长(°)",
"joint_move.distance": "相对起点的移动距离",
"joint_move.done": "完成",
"joint_move.fine": "远离轴线时的旋转步长(°)",
"joint_move.fixed": "此零部件没有可操作的关节自由度。",
"joint_move.help": "移动 — 拖动箭头或圆弧。按 Esc 取消当前拖动。",
"joint_move.legacy_hint": "关闭后使用原有的移动面板。",
"joint_move.precision": "将光标移到虚线引导圈外侧可微调旋转。",
"joint_move.rotation": "相对起点的旋转角度",
"joint_move.select": "请选择一个面或一个装配零部件,然后执行移动。",
"joint_move.settings": "装配移动",
"joint_move.step": "步长",
"joint_move.use_handles": "装配移动使用关节运动手柄",
"keymap.command_id": "命令 ID",
"keymap.empty": "没有匹配的命令",
"keymap.use_anyway": "仍在此处使用 {key}",
@@ -1593,6 +1611,8 @@
"nav.maya.help": "Alt+左键拖动旋转,Alt+中键拖动平移,Alt+右键拖动缩放;中键拖动也可平移",
"nav.openscad.help": "在空白处左键拖动旋转,Shift+左键拖动框选,拖动草图几何可编辑;右键或中键拖动平移,Shift+右键或 Shift+中键拖动缩放",
"nav.perspective.sketch": "编辑草图时使用正交视图。完成草图后才能切换透视。",
"nav.right_drag_orbits.help": "关闭:右键拖动不会移动相机,右键只打开上下文菜单。选择 SolidWorks 鼠标映射时会关闭此项。",
"nav.right_drag_orbits.label": "右键拖动旋转",
"nav.solidworks.help": "中键拖动旋转,Ctrl+中键拖动平移,Shift+中键拖动缩放,右键拖动旋转",
"nav.tinkercad.help": "右键拖动旋转,中键或 Shift+右键拖动平移",
"nav.touchpad.help": "按住 Shift 并移动指针可平移;按住 Alt/Option 并移动可旋转。无需按鼠标按钮。垂直滚动可缩放。",
@@ -1614,6 +1634,9 @@
"navbar.custom.customise": "自定义导航栏…",
"navbar.custom.design": "设计",
"navbar.custom.duplicate": "此槽位已在栏上。",
"navbar.custom.edge": "边缘",
"navbar.custom.edge_bottom": "底部",
"navbar.custom.edge_top": "顶部",
"navbar.custom.fade": "淡化",
"navbar.custom.full": "请先移除一个槽位以添加其他槽位。",
"navbar.custom.hidden_design": "在设计中隐藏",
@@ -1778,6 +1801,9 @@
"prefs.bottom_centre": "底部居中",
"prefs.bottom_right": "右下角",
"prefs.browser": "浏览器",
"prefs.browser_reveal_on_hover": "悬停时显示",
"prefs.browser_show_note": "“显示”让浏览器始终留在屏幕上。“悬停时显示”会将其隐藏,直到指针移到它的位置或其左边缘的细标签上;指针在其中或其菜单打开时,它保持打开。",
"prefs.browser_shown": "显示",
"prefs.built_in": "内置",
"prefs.button_left": "左键拖动",
"prefs.button_middle": "中键拖动",
@@ -1883,10 +1909,14 @@
"prefs.placement": "放置",
"prefs.prefer_free_sketch_points": "草图点重合时优先选择可移动的点",
"prefs.preview_choices": "所选设置预览",
"prefs.project_options_dialog": "Project 选项对话框",
"prefs.project_options_dialog_note": "开启:投影前选择“几何体”或“实体”以及“投影链接”。关闭:保留原有的直接选择工作流。",
"prefs.quality_default": "默认",
"prefs.quality_fastest": "最快",
"prefs.quality_slowest": "最慢",
"prefs.quality_smoother": "更平滑",
"prefs.render_previous_geometry_budget": "使用先前的渲染预览几何内存预算",
"prefs.render_previous_geometry_budget_help": "恢复先前的固定几何内存上限(64位桌面为512 MiB,其他平台为128 MiB),替代基于设备的预算。",
"prefs.reports_saved": "已保存 {n} 份",
"prefs.right_click": "右键单击",
"prefs.saving": "自动保存和恢复",
@@ -1952,7 +1982,14 @@
"prefs.toast_place": "通知",
"prefs.toggle_item": "添加项目,或取消选择",
"prefs.toolbar": "工具栏",
"prefs.toolbar_tabs": "工具栏选项卡",
"prefs.top": "顶部",
"prefs.trackpad_zoom_modifier": "按住以下按键时用双指滚动缩放",
"prefs.trackpad_zoom_modifier.alt": "⌥ / Alt",
"prefs.trackpad_zoom_modifier.cmd": "⌘",
"prefs.trackpad_zoom_modifier.ctrl": "Ctrl",
"prefs.trackpad_zoom_modifier.help": "仅按住此键并垂直滚动,即可以指针位置为中心缩放。关闭时保留现有滚动行为。",
"prefs.trackpad_zoom_modifier.off": "关闭",
"prefs.translucent": "半透明",
"prefs.under_browser": "浏览器下方",
"prefs.under_cursor": "光标下方",
@@ -1962,6 +1999,9 @@
"prefs.view_confirm_corner": "在视图角落显示确定和取消",
"prefs.view_confirm_corner_note": "打开对话框或草图时,视图右上角的勾号和叉号与对话框的确定和取消作用相同,或完成和取消草图。视图导航器会下移到它们下方。",
"prefs.viewport": "视口",
"prefs.viewport_background": "背景",
"prefs.viewport_background_colour": "颜色",
"prefs.viewport_background_theme": "主题渐变",
"prefs.wheel": "滚轮",
"prefs.wheel_trackpad": "滚轮与触控板",
"prefs.workspace": "工作区",
@@ -3296,6 +3336,7 @@
"select an axis": "请选择一个轴",
"select an axis, a straight or circular edge, or a cylinder, cone or torus face first": "请先选择轴、直边或圆边,或圆柱面、圆锥面或圆环面",
"select bodies first": "请先选择实体",
"select components first": "请先选择组件",
"select corner points, or the two lines of one corner": "请选择角点,或同一个角的两条直线",
"select one corner point, or the two lines of one corner": "请选择一个角点,或同一个角的两条直线",
"select sheet edges first": "请先选择钣金边",
@@ -3372,6 +3413,8 @@
"sketch_text.use_font_file": "使用字体文件 {path}",
"solid": "实心",
"status.editing": "正在编辑 {name}",
"status.navigation": "鼠标映射:{mapping}",
"status.navigation_tip": "鼠标移动视图的方式。单击可选择其他映射",
"status.under_constrained": "欠约束({count} 个自由度)",
"status.under_constrained_one": "欠约束({count} 个自由度)",
"status.units_tip": "设计的单位。单击可更改",
@@ -4220,6 +4263,7 @@
"ui.pan_zoom_orbit": "平移、缩放、环绕",
"ui.parallel": "平行",
"ui.parameters": "参数",
"ui.part_size_needs_a_part": "请选择已插入的标准零件(螺钉、螺母、垫圈或销)以更改其尺寸。要放大或缩小实体,请使用缩放。",
"ui.part_size_shared": "重新生成零件并替换额外添加的特征。此零部件的所有实例都会更新。",
"ui.paste": "粘贴",
"ui.path": "路径",
@@ -4269,6 +4313,18 @@
"ui.project": "投影",
"ui.project_face_edges_sketch": "将面的边投影到新草图",
"ui.properties": "属性",
"ui.properties.copy_matrix": "复制矩阵",
"ui.properties.first_moments": "一阶矩",
"ui.properties.inertia": "转动惯量",
"ui.properties.inertia_note": "相对质心,世界坐标轴 (g·{unit}²)",
"ui.properties.inertia_unavailable": "不可用:某个实体的网格未闭合。",
"ui.properties.inertia_x": "Ixx, Ixy, Ixz",
"ui.properties.inertia_y": "Iyx, Iyy, Iyz",
"ui.properties.inertia_z": "Izx, Izy, Izz",
"ui.properties.measuring": "正在测量…",
"ui.properties.measuring_failed": "测量在完成前已停止。",
"ui.properties.principal_axis": "主轴 {number}",
"ui.properties.principal_moments": "主惯性矩",
"ui.pull_direction": "拔模方向",
"ui.quality": "质量",
"ui.quantity": "数量",
@@ -4367,7 +4423,11 @@
"ui.sketch": "草图",
"ui.sketch_dimension": "草图尺寸",
"ui.sketch_grid": "草图网格",
"ui.sketch_intersect_geometry": "几何体",
"ui.sketch_intersect_projection_link": "投影链接",
"ui.sketch_intersect_selection_filter": "选择过滤器",
"ui.sketch_modifiers": "草图修饰键",
"ui.sketch_project_geometry": "几何体",
"ui.sketch_scale": "草图比例",
"ui.sketches": "草图",
"ui.slice": "切片",
@@ -4414,6 +4474,11 @@
"ui.symmetric": "对称",
"ui.symmetry": "对称",
"ui.system": "系统",
"ui.tab_assemble": "装配",
"ui.tab_manage": "管理",
"ui.tab_sheet_metal": "钣金",
"ui.tab_view": "视图",
"ui.tab_weldments": "焊件",
"ui.tangent": "相切",
"ui.taper_angle": "锥角",
"ui.tapped": "攻丝",
@@ -4429,6 +4494,7 @@
"ui.tip_angle": "尖端角度",
"ui.to_object": "到对象",
"ui.tool_direction": "刀具方向",
"ui.toolbar_tabs_hidden": "所有工具栏选项卡均已隐藏。点击 + 或在此处右键单击即可显示。",
"ui.top": "上视",
"ui.top_diameter": "顶部直径",
"ui.top_radius": "顶部半径",
@@ -4475,6 +4541,7 @@
"ui.visual_shaded_with_edges": "带边着色",
"ui.visual_style": "视觉样式",
"ui.visual_wireframe": "线框",
"ui.wheel_zoom_speed": "鼠标滚轮缩放速度",
"ui.width": "宽度",
"ui.window_system": "窗口系统",
"ui.window_system_auto": "自动",
@@ -4528,6 +4595,9 @@
"update.prefs_managed": "这是由 {manager} 管理的 SolidCraft {version}。检查时只向 GitHub 询问最新版本,不会发送其他任何内容;请在 {manager} 中更新 SolidCraft。",
"update.prefs_note": "当前为 SolidCraft {version}({kind})。检查时只向 GitHub 查询最新版本,不发送其他任何数据。",
"update.prefs_web": "重新加载页面以获取最新的网页应用。如果网络不可用或更新无法及时下载完成,则打开已保存的版本;正在进行的下载会继续,供下次重新加载时使用。",
"update.refresh_checking": "正在检查最新版本…",
"update.refresh_fallback": "无法检查更新的版本;将更新至先前找到的 {version}。",
"update.refresh_selected": "正在更新至 {version}。",
"update.release_page": "打开发布页面",
"update.restart": "立即重启",
"update.restart_later": "稍后",
+3
View File
@@ -175,6 +175,8 @@ impl Tokens {
body: Color32::from_rgb(176, 186, 198),
body_edge: Color32::from_rgb(40, 44, 52),
manipulator: Color32::from_rgb(40, 110, 220),
joint_manipulator: Color32::from_rgb(235, 157, 0),
joint_axis: Color32::from_rgb(0, 95, 110),
preview_add: Color32::from_rgb(112, 160, 226),
preview_new: Color32::from_rgb(240, 225, 105),
preview_sketch: Color32::from_rgb(32, 190, 96),
@@ -352,6 +354,7 @@ impl Tokens {
t.hover = Color32::from_rgb(54, 61, 74);
t.hover_edge_halo = Color32::from_rgb(203, 203, 203);
t.origin_point = Color32::from_rgb(214, 215, 218);
t.joint_axis = Color32::from_rgb(110, 240, 250);
t.viewport_top = Color32::from_rgb(46, 52, 64);
t.viewport_bottom = Color32::from_rgb(46, 52, 64);
t.grid = Color32::from_rgb(59, 65, 76);
+2
View File
@@ -93,6 +93,8 @@
"icons.highlight": "#000000",
"icons.marker_halo": "#000000",
"interference": "#ffffff",
"joint_axis": "#ffffff",
"joint_manipulator": "#ffffff",
"logo": [
"#ffffff",
"#ffffff",
+2
View File
@@ -93,6 +93,8 @@
"icons.highlight": "#ffffff",
"icons.marker_halo": "#ffffff",
"interference": "#000000",
"joint_axis": "#000000",
"joint_manipulator": "#000000",
"logo": [
"#000000",
"#000000",
+2
View File
@@ -93,6 +93,8 @@
"icons.highlight": "#ffffff",
"icons.marker_halo": "#000000cc",
"interference": "#ff5555",
"joint_axis": "#aaaaaa",
"joint_manipulator": "#ffff55",
"logo": [
"#ffffff",
"#55ffff",
+3
View File
@@ -81,6 +81,9 @@ impl Document {
.filter(|j| occurrences.contains(&j.a.occurrence) || occurrences.contains(&j.b.occurrence))
.map(|j| j.id)
.collect();
for group in &mut self.timeline_groups {
group.joints.retain(|id| !joints.contains(id));
}
let assembly = &mut self.assembly;
assembly.joints.retain(|j| !joints.contains(&j.id));
assembly.links.retain(|l| !joints.contains(&l.a) && !joints.contains(&l.b));
+4 -1
View File
@@ -2851,7 +2851,7 @@ pub struct BrowserGroup {
pub items: Vec<String>,
}
/// A saved feature-history folder. Folders may nest, and a feature can belong to at most one folder.
/// A saved history folder. Folders may nest; each feature or joint belongs to at most one folder.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct TimelineGroup {
pub id: u64,
@@ -2861,6 +2861,9 @@ pub struct TimelineGroup {
pub parent: Option<u64>,
#[serde(default)]
pub features: Vec<u64>,
/// Joints stored in this timeline folder.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub joints: Vec<u64>,
}
/// The first feature-history folder whose own and nested features do not form one
+8 -1
View File
@@ -712,7 +712,9 @@ pub fn read(s: &str) -> Result<Document> {
|| d.params.len() > crate::document::MAX_PARAMS
|| d.components.len() > crate::document::MAX_FEATURES
|| d.timeline_groups.len() > crate::document::MAX_FEATURES
|| d.timeline_groups.iter().any(|group| group.features.len() > crate::document::MAX_FEATURES)
|| d.timeline_groups
.iter()
.any(|group| group.features.len() > crate::document::MAX_FEATURES || group.joints.len() > crate::document::MAX_FEATURES)
{
return Err(DocError::Invalid("document too large".into()));
}
@@ -720,6 +722,8 @@ pub fn read(s: &str) -> Result<Document> {
let feature_ids: std::collections::BTreeSet<_> = d.features.iter().map(|feature| feature.id).collect();
let mut group_ids = std::collections::BTreeSet::new();
let mut grouped_features = std::collections::BTreeSet::new();
let joint_ids: std::collections::BTreeSet<_> = d.assembly.joints.iter().map(|joint| joint.id).collect();
let mut grouped_joints = std::collections::BTreeSet::new();
for group in &d.timeline_groups {
if group.name.trim().is_empty() || group.name.len() > 128 || !group_ids.insert(group.id) {
return Err(DocError::Invalid("damaged design: invalid timeline folder".into()));
@@ -727,6 +731,9 @@ pub fn read(s: &str) -> Result<Document> {
if group.parent.is_some_and(|parent| parent == group.id) {
return Err(DocError::Invalid("damaged design: timeline folder cannot contain itself".into()));
}
if group.joints.iter().any(|joint| !joint_ids.contains(joint) || !grouped_joints.insert(*joint)) {
return Err(DocError::Invalid("damaged design: invalid joint in a timeline folder".into()));
}
if group.features.iter().any(|feature| !feature_ids.contains(feature) || !grouped_features.insert(*feature)) {
return Err(DocError::Invalid("damaged design: invalid feature in a timeline folder".into()));
}
@@ -0,0 +1,3 @@
//! Timeline folders save joint ids alongside their feature ids. Older designs default to no
//! joint membership; empty membership is omitted. The last release refuses the newer format
//! and asks for an update instead of losing folder membership.
+4
View File
@@ -23,6 +23,10 @@ use solidcraft_sketch::linalg;
use crate::{DocError, Document, IDENTITY, Mat, ModelState, Result, apply_point, apply_vector, mat_inverse, mat_mul};
#[path = "joints_handles.rs"]
mod handles;
pub use handles::{MotionAxis, motion_axes};
/// Joints the solver (and the URDF export) reads at most.
pub const MAX_JOINTS: usize = 10_000;
+124
View File
@@ -0,0 +1,124 @@
//! The instantaneous motion a joint value gives a selected occurrence. Probe the same
//! placement graph and loop solver as a drive, including reversed joints and motion links.
use std::collections::BTreeSet;
use super::{Analysis, Held, MAX_LOOP_VALUES, Solution, apply_links, build, joint_values, link_chains, solve_holding_with_analysis};
use crate::{Document, Mat, ModelState, apply_point, apply_vector, mat_inverse, mat_mul};
use solidcraft_geom::Vec3;
/// A driveable value, expressed as an axis of the selected occurrence in world coordinates.
#[derive(Clone, Debug)]
pub struct MotionAxis {
pub joint: u64,
pub index: usize,
pub origin: Vec3,
/// Increasing the value moves/turns the selected occurrence along/about this unit axis.
pub axis: Vec3,
pub angular: bool,
/// Selected motion (mm or radians) per native joint-value unit (mm or radians).
pub scale: f64,
}
/// Independent values on this occurrence's anchored chain, excluding fixed, suppressed,
/// invalid and zero-range motion. Closed loops must admit the probe before an axis is shown.
/// This is a bounded query for an explicit Move operation, not a per-hover rank analysis.
pub fn motion_axes(doc: &Document, st: &ModelState, occurrence: u64) -> Vec<MotionAxis> {
let asm = &doc.assembly;
let values = joint_values(asm);
let mut report = Solution::default();
let built = build(doc, st, &values, &mut report);
let tree = &built.tree;
if tree.fixed.contains_key(&occurrence) || !tree.parent.contains_key(&occurrence) || !report.errors.is_empty() {
return Vec::new();
}
let Some(base) = tree.world(occurrence, &values) else { return Vec::new() };
let Some(inverse) = mat_inverse(&base) else { return Vec::new() };
let path: BTreeSet<u64> = tree.path(occurrence).1.iter().map(|(id, _)| *id).collect();
let linked: BTreeSet<_> = asm.links.iter().map(|l| (l.b, l.ib)).collect();
let mut variables: Vec<_> = tree
.path(occurrence)
.1
.iter()
.filter_map(|(id, _)| tree.joints.get(id))
.flat_map(|j| (0..j.kind.dofs().len()).map(move |i| (j.id, i)))
.filter(|v| !linked.contains(v))
.collect();
variables.extend(link_chains(asm, tree, &path, &linked).1);
let mut seen = BTreeSet::new();
variables.retain(|v| seen.insert(*v));
let reference = tree
.path(occurrence)
.1
.first()
.and_then(|(id, from_a)| {
let (j, frames) = (tree.joints.get(id)?, tree.frames.get(id)?);
let (owner, frame) = if *from_a { (j.b.occurrence, &frames.b) } else { (j.a.occurrence, &frames.a) };
Some(apply_point(&mat_mul(&tree.world(owner, &values)?, frame), Vec3::ZERO))
})
.unwrap_or_else(|| apply_point(&base, Vec3::ZERO));
let mut out: Vec<MotionAxis> = Vec::new();
for (id, index) in variables.into_iter().take(MAX_LOOP_VALUES) {
let Some(joint) = tree.joints.get(&id) else { continue };
// A parameter expression constrains this value; moving it must not silently erase it.
if joint.exprs.value(index).is_some() {
continue;
}
let Some(q) = values.get(&id).and_then(|q| q.get(index)).copied() else { continue };
for requested in [1e-3, -1e-3] {
let mut probe = values.clone();
let Some(slot) = probe.get_mut(&id).and_then(|v| v.get_mut(index)) else { break };
*slot = q + requested;
if let Some((lo, hi)) = joint.limits.get(index).copied().flatten() {
*slot = (*slot).clamp(lo.min(hi), lo.max(hi));
}
let step = *slot - q;
if step.abs() < 1e-10 {
continue;
}
apply_links(asm, &mut probe);
let next = if built.closing.is_empty() {
tree.world(occurrence, &probe)
} else {
let mut changed = doc.clone();
for j in &mut changed.assembly.joints {
if let Some(q) = probe.get(&j.id) {
j.values.clone_from(q);
}
}
let held = Held::from([((id, index), q)]);
let solved = solve_holding_with_analysis(&changed, st, &held, Analysis::Placement);
if solved.conflicts.is_empty() && solved.errors.is_empty() { solved.world.get(&occurrence).copied() } else { None }
};
let Some(next) = next else { continue };
let delta = mat_mul(&next, &inverse);
if let Some(axis) = axis_of(delta, step, reference, id, index) {
// Coincident values describe the same visible motion; retain the nearest joint.
if !out.iter().any(|a| {
a.angular == axis.angular
&& a.axis.dot(axis.axis).abs() > 1.0 - 1e-6
&& (!a.angular || (a.origin - axis.origin).cross(a.axis).len() < 1e-4)
}) {
out.push(axis);
}
break;
}
}
}
out
}
fn axis_of(delta: Mat, step: f64, reference: Vec3, joint: u64, index: usize) -> Option<MotionAxis> {
let (x, y, z) = (apply_vector(&delta, Vec3::X), apply_vector(&delta, Vec3::Y), apply_vector(&delta, Vec3::Z));
let sine = Vec3::new(y.z - z.y, z.x - x.z, x.y - y.x);
let angle = sine.len().atan2(x.x + y.y + z.z - 1.0);
let translation = apply_point(&delta, Vec3::ZERO);
let (origin, axis, angular, scale) = if angle > 1e-8 {
let axis = sine.normalized()? * step.signum();
let transverse = translation - axis * translation.dot(axis);
let centre = (transverse + axis.cross(transverse) / (angle * step.signum() * 0.5).tan()) * 0.5;
(centre + axis * reference.dot(axis), axis, true, angle / step.abs())
} else {
(reference, (translation / step).normalized()?, false, translation.len() / step.abs())
};
(origin.is_finite() && axis.is_finite() && scale.is_finite() && scale > 1e-8).then_some(MotionAxis { joint, index, origin, axis, angular, scale })
}
+123 -19
View File
@@ -729,9 +729,14 @@ fn refresh_links_in(
// Words built from text parameters change shape when the parameters do: that
// rebuild is the text following them, not a reference that changed under it.
let words_follow = matches!(&l.source, LinkSource::Text { exprs, .. } if exprs.text.is_some());
let others = |sk: &Sketch| (sk.constraints.len(), sk.curves.len().saturating_sub(sk.link_curves(&l.id).len()));
let before = others(sk);
match sk.update_link(&l.id, &r.geom) {
Ok(true) if words_follow => {}
Ok(true) => warn.push(format!("projected {} changed shape and was rebuilt", l.source.describe())),
// A rebuilt projection is only worth a warning when it took something of
// the user's with it: a constraint, a dimension or a curve drawn on it.
Ok(true) if others(sk) == before => {}
Ok(true) => warn.push(format!("projected {} changed shape and was rebuilt; constraints on it were removed", l.source.describe())),
Ok(false) if r.moved => warn.push(format!("projected {} was re-found on the nearest geometry", l.source.describe())),
Ok(false) => {}
Err(e) => {
@@ -1079,7 +1084,7 @@ fn silhouette(plane: &Plane, m: &Mesh) -> Vec<LinkGeom> {
})
.map(|(a, b, _)| (plane.to_local(*a), plane.to_local(*b)))
.collect();
fit_segments(&segs, size)
fit_segments(&segs, size, true)
}
/// Section of a mesh (or one of its faces) with the sketch plane.
@@ -1088,7 +1093,13 @@ pub(crate) fn section(plane: &Plane, m: &Mesh, face: Option<u32>) -> Vec<LinkGeo
let (segs, exact) = section_segments(plane, m, face);
let segs: Vec<(Vec2, Vec2)> = segs.into_iter().map(|(a, b, _)| (a, b)).collect();
let loose = size * 2e-3;
fit_segments(&segs, size).into_iter().map(|g| refine_round(g, &exact, loose)).collect()
let refine = |v: Vec<LinkGeom>| -> Vec<LinkGeom> { v.into_iter().map(|g| refine_round(g, &exact, loose)).collect() };
let lossy = refine(fit_segments(&segs, size, false));
// Lines and arcs that follow the section closely (the corners of a rounded profile stay
// where they are) are kept when they take no more curves than the loose fit.
let close = refine(fit_segments(&segs, size, true));
let plain = |v: &[LinkGeom]| v.iter().all(|g| matches!(g, LinkGeom::Line(..) | LinkGeom::Arc { .. } | LinkGeom::Circle(..)));
if plain(&close) && close.len() <= lossy.len() { close } else { lossy }
}
/// The segments where the plane crosses the mesh's triangles (in plane coordinates, each with
@@ -1253,9 +1264,11 @@ fn refine_round(g: LinkGeom, exact: &[Vec2], tol: f64) -> LinkGeom {
}
/// Chain loose 2D segments into polylines (merging coincident ends) and fit each chain.
fn fit_segments(segs: &[(Vec2, Vec2)], size: f64) -> Vec<LinkGeom> {
fn fit_segments(segs: &[(Vec2, Vec2)], size: f64, preserve_arcs: bool) -> Vec<LinkGeom> {
// Mesh chords stray from the surface by up to the display tolerance (1/1000 of the size).
let loose = Some(size * 2e-3);
// A silhouette's vertices lie on the surface, so it is fitted tightly: a loose fit lets a line
// swallow the start of a tangent arc, or an arc run on into the straight edge after it.
let loose = Some(if preserve_arcs { size * 1e-5 } else { size * 2e-3 });
let q = size * 1e-7;
let key = |p: Vec2| ((p.x / q).round() as i64, (p.y / q).round() as i64);
let mut nodes: BTreeMap<(i64, i64), (Vec2, Vec<usize>)> = BTreeMap::new();
@@ -1303,19 +1316,96 @@ fn fit_segments(segs: &[(Vec2, Vec2)], size: f64) -> Vec<LinkGeom> {
for s in starts {
while nodes.get(&s).is_some_and(|n| n.1.iter().any(|e| !used.get(*e).copied().unwrap_or(true))) {
let c = walk(s, &mut used);
fit_chain(&c, loose, &mut out);
fit_chain_with_arcs(&c, loose, &mut out, preserve_arcs);
}
}
let keys: Vec<(i64, i64)> = nodes.keys().copied().collect();
for s in keys {
while nodes.get(&s).is_some_and(|n| n.1.iter().any(|e| !used.get(*e).copied().unwrap_or(true))) {
let c = walk(s, &mut used);
fit_chain(&c, loose, &mut out);
fit_chain_with_arcs(&c, loose, &mut out, preserve_arcs);
}
}
if preserve_arcs {
snap_tangent_arcs(&mut out, size * 1e-3);
}
out
}
/// A fitted arc is only a least-squares circle: make it exactly tangent to the straight edges
/// it runs into, ending on them where they touch it, with one radius.
fn snap_tangent_arcs(out: &mut [LinkGeom], tol: f64) {
for i in 0..out.len() {
let Some(LinkGeom::Arc { c, a, b }) = out.get(i).cloned() else { continue };
let r = (c.dist(a) + c.dist(b)) / 2.0;
// (arc end, line index, line end index, tangent point, unit normal towards the centre, centre distance)
let mut hits: Vec<(usize, usize, usize, Vec2, Vec2, f64)> = Vec::new();
for (end, e) in [a, b].into_iter().enumerate() {
for (j, g) in out.iter().enumerate() {
let LinkGeom::Line(p, q) = g else { continue };
let Some(u) = (*q - *p).normalized() else { continue };
let foot = *p + u * (c - *p).dot(u);
let d = c.dist(foot);
if (d - r).abs() > tol || foot.dist(e) > 0.5 * r {
continue;
}
let Some(n) = (c - foot).normalized() else { continue };
// The end of the line nearest the arc, and the tangent point must lie before it.
let (near, far) = if p.dist(e) <= q.dist(e) { (0, *q) } else { (1, *p) };
let near_pt = if near == 0 { *p } else { *q };
// The line must itself end next to the arc: not just lie on the same infinite line.
if (foot - far).dot(near_pt - far) <= 0.0 || near_pt.dist(foot) > 0.25 * r {
continue;
}
hits.push((end, j, near, foot, n, d));
}
}
let mut hits_by_end: [Option<(usize, usize, Vec2, Vec2, f64)>; 2] = [None, None];
for (end, j, near, foot, n, d) in hits {
if let Some(slot) = hits_by_end.get_mut(end)
&& slot.is_none()
{
*slot = Some((j, near, foot, n, d));
}
}
let (mut c, mut r) = (c, r);
if let [Some((_, _, _, n1, d1)), Some((_, _, _, n2, d2))] = hits_by_end {
// One circle tangent to both lines: equal distance to each.
let det = n1.cross(n2);
if det.abs() > 1e-6 {
let m = (d1 + d2) / 2.0;
let (r1, r2) = (m - d1, m - d2);
let delta = Vec2::new((r1 * n2.y - r2 * n1.y) / det, (n1.x * r2 - n2.x * r1) / det);
c += delta;
r = m;
}
} else if let [Some((_, _, _, _, d)), None] | [None, Some((_, _, _, _, d))] = hits_by_end {
r = d;
}
let on = |q: Vec2| c + (q - c).normalized().unwrap_or(Vec2::X) * r;
let (mut na, mut nb) = (on(a), on(b));
for (end, hit) in hits_by_end.into_iter().enumerate() {
let Some((j, near, _, n, _)) = hit else { continue };
let t = c - n * r;
if let Some(LinkGeom::Line(p, q)) = out.get_mut(j) {
if near == 0 {
*p = t;
} else {
*q = t;
}
}
if end == 0 {
na = t;
} else {
nb = t;
}
}
if let Some(LinkGeom::Arc { c: cc, a: aa, b: bb }) = out.get_mut(i) {
(*cc, *aa, *bb) = (c, na, nb);
}
}
}
// ---------------------------------------------------------------------------------------------
// Fitting
@@ -1392,9 +1482,9 @@ fn fits_line(p: &[Vec2], tol: f64) -> bool {
/// Circle through the chain (centre, radius, signed sweep from the first to the last point).
/// `exact`: the points lie on the curve (edge tessellation), else they are approximate
/// (mesh sections and silhouettes) and the circle is a least-squares fit.
fn fits_circle(p: &[Vec2], tol: f64, exact: bool) -> Option<(Vec2, f64, f64)> {
fn fits_circle(p: &[Vec2], tol: f64, exact: bool, preserve_arcs: bool) -> Option<(Vec2, f64, f64)> {
let n = p.len();
if n < 3 || (!exact && n < 5) {
if n < 3 || (!exact && !preserve_arcs && n < 5) {
return None;
}
let closed = p.first()?.dist(*p.last()?) <= tol;
@@ -1442,6 +1532,10 @@ fn arc_geom(p: &[Vec2], c: Vec2, r: f64, sweep: f64, tol: f64) -> Option<LinkGeo
/// else greedy maximal pieces. `loose`: the points are approximate (within `loose` mm), and
/// sharp corners always split the chain.
pub(crate) fn fit_chain(raw: &[Vec2], loose: Option<f64>, out: &mut Vec<LinkGeom>) {
fit_chain_with_arcs(raw, loose, out, false);
}
fn fit_chain_with_arcs(raw: &[Vec2], loose: Option<f64>, out: &mut Vec<LinkGeom>, preserve_arcs: bool) {
let mut p: Vec<Vec2> = Vec::with_capacity(raw.len());
let scale = raw.iter().fold(0.0_f64, |m, q| m.max(q.x.abs()).max(q.y.abs())).max(1.0);
let ext = match (
@@ -1491,10 +1585,12 @@ pub(crate) fn fit_chain(raw: &[Vec2], loose: Option<f64>, out: &mut Vec<LinkGeom
_ => 0.0,
}
};
let corners: Vec<usize> = (0..n).filter(|i| turn(*i) > 30f64.to_radians()).collect();
// A coarsely tessellated round turns up to ~35 degrees per chord; keep those together.
let corner_turn = if preserve_arcs { 50f64 } else { 30f64 }.to_radians();
let corners: Vec<usize> = (0..n).filter(|i| turn(*i) > corner_turn).collect();
let cut = |a: usize, b: usize, out: &mut Vec<LinkGeom>| {
if let Some(s) = p.get(a..=b) {
fit_piece(s, tol, exact, out);
fit_piece(s, tol, exact, preserve_arcs, out);
}
};
if closed && let Some(&c0) = corners.first() {
@@ -1508,7 +1604,7 @@ pub(crate) fn fit_chain(raw: &[Vec2], loose: Option<f64>, out: &mut Vec<LinkGeom
let mut start = 0;
for c in cs.into_iter().chain([m - 1]) {
if let Some(s) = q.get(start..=c) {
fit_piece(s, tol, exact, out);
fit_piece(s, tol, exact, preserve_arcs, out);
}
start = c;
}
@@ -1521,22 +1617,30 @@ pub(crate) fn fit_chain(raw: &[Vec2], loose: Option<f64>, out: &mut Vec<LinkGeom
}
return;
}
fit_piece(&p, tol, exact, out);
fit_piece(&p, tol, exact, preserve_arcs, out);
}
fn fit_piece(p: &[Vec2], tol: f64, exact: bool, out: &mut Vec<LinkGeom>) {
fn fit_piece(p: &[Vec2], tol: f64, exact: bool, preserve_arcs: bool, out: &mut Vec<LinkGeom>) {
if p.len() < 2 {
return;
}
// A near-straight run is a line, not an arc of huge radius.
let curved = |sw: f64| !preserve_arcs || exact || sw.abs() >= 20f64.to_radians();
let fitted_arc = || fits_circle(p, tol, exact, preserve_arcs).filter(|(_, _, sw)| curved(*sw)).and_then(|(c, r, sw)| arc_geom(p, c, r, sw, tol));
if preserve_arcs
&& (exact || p.len() >= 4)
&& let Some(g) = fitted_arc()
{
out.push(g);
return;
}
if fits_line(p, tol) {
if let (Some(a), Some(b)) = (p.first(), p.last()) {
out.push(LinkGeom::Line(*a, *b));
}
return;
}
if let Some((c, r, sw)) = fits_circle(p, tol, exact)
&& let Some(g) = arc_geom(p, c, r, sw, tol)
{
if !preserve_arcs && let Some(g) = fitted_arc() {
out.push(g);
return;
}
@@ -1553,7 +1657,7 @@ fn fit_piece(p: &[Vec2], tol: f64, exact: bool, out: &mut Vec<LinkGeom>) {
let mut jc = i + 1;
let mut arc = None;
while jc + 1 < n {
match p.get(i..=jc + 1).and_then(|s| fits_circle(s, tol, exact)) {
match p.get(i..=jc + 1).and_then(|s| fits_circle(s, tol, exact, preserve_arcs)) {
Some(f) => {
arc = Some(f);
jc += 1;
@@ -1563,7 +1667,7 @@ fn fit_piece(p: &[Vec2], tol: f64, exact: bool, out: &mut Vec<LinkGeom>) {
}
if jc >= jl + 2
&& jc - i >= 3
&& let Some((c, r, sw)) = arc
&& let Some((c, r, sw)) = arc.filter(|(_, _, sw)| curved(*sw))
&& let Some(g) = p.get(i..=jc).and_then(|s| arc_geom(s, c, r, sw, tol))
{
out.push(g);
+29
View File
@@ -206,6 +206,35 @@ mod tests {
assert!(family_edges(&mesh, &selected, &Plane::XY).is_err());
}
#[test]
fn coarsely_tessellated_round_silhouette_fits_an_arc() {
use solidcraft_geom::Vec2;
use solidcraft_sketch::LinkGeom;
let on = |deg: f64| Vec2::new(5. + 5. * deg.to_radians().cos(), 5. + 5. * deg.to_radians().sin());
let mut chain = vec![Vec2::new(0., -20.)];
chain.extend([180., 169.5, 135., 100.5, 90.].map(on));
chain.push(Vec2::new(45., 10.));
let mut out = Vec::new();
super::super::fit_chain_with_arcs(&chain, Some(0.05), &mut out, true);
assert!(out.iter().any(|g| matches!(g, LinkGeom::Arc { .. })), "{out:?}");
}
#[test]
fn tangent_snapping_leaves_unrelated_collinear_lines_alone() {
use solidcraft_geom::Vec2;
use solidcraft_sketch::LinkGeom;
let v = Vec2::new;
// A rounded corner of radius 2 meeting the top edge, and an unrelated collinear edge.
let mut out = vec![
LinkGeom::Line(v(21., 8.), v(25., 8.)),
LinkGeom::Arc { c: v(2.0008, 5.9992), a: v(2.2808, 8.0), b: v(0., 6.0) },
LinkGeom::Line(v(2.0668, 8.), v(48., 8.)),
];
super::super::snap_tangent_arcs(&mut out, 0.05);
assert_eq!(out.first(), Some(&LinkGeom::Line(v(21., 8.), v(25., 8.))));
let (Some(LinkGeom::Arc { a, .. }), Some(LinkGeom::Line(p, _))) = (out.get(1), out.get(2)) else { panic!("{out:?}") };
assert!(a.dist(*p) < 1e-9, "{out:?}");
}
#[test]
fn boundaries_touching_at_a_vertex_project() {
// Two squares of one face family meet only at the corner (1, 1): four boundary edges meet
+16 -1
View File
@@ -566,7 +566,7 @@ fn feature_inserted_at_a_marker_inside_a_timeline_folder_joins_it() {
let first = doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch: Sketch::new() }, Some("First")).unwrap();
let second =
doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch: Sketch::new() }, Some("Second")).unwrap();
doc.timeline_groups.push(TimelineGroup { id: 100, name: "Pair".into(), parent: None, features: vec![first, second] });
doc.timeline_groups.push(TimelineGroup { id: 100, name: "Pair".into(), parent: None, features: vec![first, second], joints: vec![] });
doc.marker = Some(1);
let inserted =
@@ -700,3 +700,18 @@ fn empty_boolean_targets_without_overlap_keep_plain_message() {
assert_eq!(model.result(cut).unwrap().error.as_deref(), Some("there is no body to cut or intersect"));
}
}
#[test]
fn timeline_group_legacy_json_omits_empty_joint_membership() {
let old = serde_json::json!({"id":100, "name":"Folder", "parent":null, "features":[]});
let group: TimelineGroup = serde_json::from_value(old.clone()).unwrap();
assert!(group.joints.is_empty());
assert_eq!(serde_json::to_value(&group).unwrap(), old);
let mut doc = Document::new("legacy folders");
doc.timeline_groups.push(group);
doc.next_id = 101;
let saved = doc.to_json();
let value: serde_json::Value = serde_json::from_str(&saved).unwrap();
assert!(value["timeline_groups"][0].get("joints").is_none());
assert_eq!(Document::from_json(&saved).unwrap().to_json(), saved);
}
+4 -4
View File
@@ -290,7 +290,7 @@ fn visibility(s: &mut Session, p: &Value) -> Result<Value> {
s.visibility.hidden_folders.push(k.clone());
}
}
s.revision += 1;
s.refresh_display();
return Ok(json!({"visible":visible,"changed":keys.len(),"visibility":s.visibility}));
}
if let Some(value) = p.get("paths") {
@@ -341,7 +341,7 @@ fn visibility(s: &mut Session, p: &Value) -> Result<Value> {
root_bodies.extend(ancestor_bodies);
s.visibility.hidden_occurrences = hidden;
s.visibility.hidden_bodies = root_bodies;
s.revision += 1;
s.refresh_display();
return Ok(json!({"visible":true,"isolated":paths,"visibility":s.visibility}));
}
for path in &paths {
@@ -350,7 +350,7 @@ fn visibility(s: &mut Session, p: &Value) -> Result<Value> {
s.visibility.hidden_occurrences.push(path.clone());
}
}
s.revision += 1;
s.refresh_display();
return Ok(json!({"visible":visible,"changed":paths.len(),"visibility":s.visibility}));
}
let items: Vec<Shown> = match (str_(p, "folder"), p.get("items")) {
@@ -462,7 +462,7 @@ fn visibility(s: &mut Session, p: &Value) -> Result<Value> {
}
}
}
s.revision += 1;
s.refresh_display();
Ok(json!({ "visible": visible, "changed": items.len(), "visibility": s.visibility }))
}
+22 -3
View File
@@ -11,13 +11,17 @@ use solidcraft_geom::Vec3;
use super::CommandSpec;
use crate::params::{bad, bool_, str_, string_list, vec3};
use crate::schema::component::{EditInPlaceParams, EditInPlaceResult, FinishEditParams, FinishEditResult};
use crate::schema::component::{
EditInPlaceParams, EditInPlaceResult, FinishEditParams, FinishEditResult, OccurrenceAlignParams, OccurrenceAlignResult,
};
use crate::schema::{TypedContract, output_schema_for};
use crate::{Result, Session};
const EDIT_IN_PLACE_CONTRACT: TypedContract<EditInPlaceParams> = TypedContract::new();
const FINISH_EDIT_CONTRACT: TypedContract<FinishEditParams> = TypedContract::new();
#[path = "component_align.rs"]
mod align;
#[path = "component_drag.rs"]
mod drag;
#[path = "component_matrix.rs"]
@@ -78,10 +82,10 @@ pub static COMMANDS: &[CommandSpec] = &[
),
CommandSpec::new("component.properties", "Component Physical Properties", properties::component)
.icon("measure").noundo()
.params("component?: id or name (default active); descendants?: bool (default true). Measures the first placement, or all placements for root, including pending moves; material-aware mass and center of mass in world coordinates; volume, density, mass and center of mass only for closed, consistently oriented bodies; total.open_bodies_skipped counts open bodies excluded from volume/mass totals"),
.params("component?: id or name (default active); descendants?: bool (default true); about?: [x, y, z] (mm). Measures the first placement, or all placements for root, including pending moves; material-aware mass and center of mass in world coordinates; volume, density, mass, center of mass and inertia_g_mm2 (the inertia tensor about the body's center of mass, world axes, from its tessellation) only for closed, consistently oriented bodies; total.open_bodies_skipped counts open bodies excluded from volume/mass totals; total.first_moment_g_mm (mass × center of mass), inertia_g_mm2 about the total center of mass, principal_moments_g_mm2 (smallest first) and principal_axes (unit, right-handed); with about, total.inertia_about {point, inertia_g_mm2}"),
CommandSpec::new("occurrence.properties", "Occurrence Physical Properties", properties::occurrence)
.icon("measure").noundo()
.params("occurrence: id or name; path?: [occurrence ids] (required if nested under multiple instances); descendants?: bool (default true). Physical properties of this placement in world coordinates, including pending moves; volume, density, mass and center of mass only for closed, consistently oriented bodies; total.open_bodies_skipped counts open bodies excluded from volume/mass totals"),
.params("occurrence: id or name; path?: [occurrence ids] (required if nested under multiple instances); descendants?: bool (default true); about?: [x, y, z] (mm). Physical properties of this placement in world coordinates, including pending moves; volume, density, mass, center of mass and inertia_g_mm2 only for closed, consistently oriented bodies; total.open_bodies_skipped counts open bodies excluded from volume/mass totals; total inertia as in component.properties"),
CommandSpec::new("component.rename", "Rename Component", rename).icon("rename").params("component, name"),
CommandSpec::new("component.delete", "Delete Component", delete).icon("delete").params("component: deletes it, its subcomponents, occurrences and features"),
CommandSpec::new("component.unlock", "Unlock Contents", unlock).icon("rigid_group").params(
@@ -110,6 +114,16 @@ pub static COMMANDS: &[CommandSpec] = &[
point - at (with any chain of joints it is in that is joined to nothing grounded) and captures its position; other pending \
moves stay pending. Shared definitions move in all placements. Grounded parts and parts joints hold to something fixed are refused (joint.reach moves those)",
),
CommandSpec::new("occurrence.align", "Align Occurrence", crate::checked!("occurrence.align", align::align))
.icon("align")
.contract(&ALIGN_CONTRACT)
.output(output_schema_for::<OccurrenceAlignResult>)
.params(
"occurrence?, occurrences?, path?, paths?: placements to move together; from | from_face | from_plane; to | to_face | to_plane; \
flip?: bool; capture?: bool (default true); move_grounded?: bool (default false). Moves only the chosen occurrence placements in \
world coordinates without changing shared component geometry; a placement inside a component placed more than once moves in \
every placement of it, and the reply's `shared` lists those paths",
),
CommandSpec::new("occurrence.ground", "Ground", ground).icon("ground").params("occurrence; grounded?: bool (default toggles)"),
CommandSpec::new("occurrence.reparent", "Move to Component", reparent::reparent).icon("move").params(
"occurrence; path?: exact placement; parent: component id or name (0 or \"root\" for the design); parent_path?: the parent's \
@@ -120,6 +134,8 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("component.revert_position", "Revert Position", revert).at("SOLID", "POSITION").icon("revert").noundo().params("occurrences?: [ids] (default all); drops selected pending occurrence moves"),
];
const ALIGN_CONTRACT: TypedContract<OccurrenceAlignParams> = TypedContract::new();
fn key(v: Option<&Value>) -> Option<String> {
match v {
Some(Value::Number(n)) => Some(n.to_string()),
@@ -366,6 +382,9 @@ fn unlock(s: &mut Session, p: &Value) -> Result<Value> {
}
let doc = s.doc_mut();
doc.assembly.joints.retain(|j| !gone.contains(&j.id));
for group in &mut doc.timeline_groups {
group.joints.retain(|id| !gone.contains(id));
}
doc.assembly.links.retain(|l| !gone.contains(&l.a) && !gone.contains(&l.b));
s.visibility.hidden_joints.retain(|h| !gone.contains(h));
Ok(json!({"component": id, "joints": gone}))
+244
View File
@@ -0,0 +1,244 @@
//! Aligning component occurrences (`occurrence.align`): moves only the chosen occurrence
//! placements in world coordinates using the same `from` / `to` / `flip` rules as `solid.align`,
//! without modifying shared component geometry across other instances.
use serde_json::{Value, json};
use solidcraft_doc::{IDENTITY, mat_inverse, mat_mul, rigid};
use solidcraft_geom::Vec3;
use super::{occurrence, occurrence_path, valid_position};
use crate::params::{bad, vec3};
use crate::{Result, Session};
const MAX_ALIGNED: usize = 64;
fn opt_bool(p: &Value, key: &str, default: bool, cmd: &str) -> Result<bool> {
match p.get(key) {
None => Ok(default),
Some(Value::Bool(b)) => Ok(*b),
_ => Err(bad(cmd, format!("`{key}` must be a boolean"))),
}
}
fn parse_path(v: &Value, key: &str, cmd: &str) -> Result<Vec<u64>> {
v.as_array()
.filter(|a| !a.is_empty() && a.len() <= 64)
.and_then(|a| a.iter().map(Value::as_u64).collect::<Option<Vec<_>>>())
.ok_or_else(|| bad(cmd, format!("`{key}` must list 1..64 occurrence ids")))
}
fn resolve_paths(s: &Session, p: &Value, cmd: &str) -> Result<Vec<Vec<u64>>> {
let raw: Vec<Vec<u64>> = if let Some(v) = p.get("paths") {
if ["occurrences", "occurrence", "path"].into_iter().any(|k| p.get(k).is_some()) {
return Err(bad(cmd, "give `paths`, `occurrences`, or `occurrence`/`path`, not more than one"));
}
let items = v
.as_array()
.filter(|a| !a.is_empty() && a.len() <= MAX_ALIGNED)
.ok_or_else(|| bad(cmd, format!("`paths` must list 1..{MAX_ALIGNED} occurrence paths")))?;
let mut out = Vec::with_capacity(items.len());
for item in items {
let path = parse_path(item, "paths", cmd)?;
s.occurrence_placement(&path).map_err(|e| bad(cmd, e.to_string()))?;
out.push(path);
}
out
} else if let Some(v) = p.get("occurrences") {
if p.get("occurrence").is_some() || p.get("path").is_some() {
return Err(bad(cmd, "give `occurrences` or `occurrence`/`path`, not both"));
}
let items: Vec<&Value> = match v {
Value::Number(_) | Value::String(_) => vec![v],
Value::Array(a) if !a.is_empty() && a.len() <= MAX_ALIGNED => a.iter().collect(),
_ => return Err(bad(cmd, format!("`occurrences` must be an occurrence id/name or a list of 1..{MAX_ALIGNED} ids/names"))),
};
let mut out = Vec::with_capacity(items.len());
for item in items {
let q = json!({ "occurrence": item });
let id = occurrence(s, &q, cmd)?;
out.push(occurrence_path(s, &json!({}), id, cmd)?);
}
out
} else if p.get("occurrence").is_some() {
let id = occurrence(s, p, cmd)?;
vec![occurrence_path(s, p, id, cmd)?]
} else if let Some(v) = p.get("path") {
let path = parse_path(v, "path", cmd)?;
s.occurrence_placement(&path).map_err(|e| bad(cmd, e.to_string()))?;
vec![path]
} else {
return Err(bad(cmd, "give `occurrence`, `occurrences`, `path` or `paths`"));
};
let mut deduped: Vec<Vec<u64>> = Vec::with_capacity(raw.len());
for path in raw {
if !deduped.contains(&path) {
deduped.push(path);
}
}
// If both an ancestor placement and a placement under it were selected, moving the ancestor
// already carries the descendant along; skip the descendant so it moves once. Only a path
// prefix means "under it": another placement of the same component does not carry it.
let all = deduped.clone();
deduped.retain(|cand| !all.iter().any(|other| other.len() < cand.len() && cand.starts_with(other)));
// Two distinct paths ending in the same nested occurrence id share one definition transform.
for (i, a) in deduped.iter().enumerate() {
if deduped[..i].iter().any(|b| b.last() == a.last()) {
return Err(bad(cmd, "cannot align two placements of the same shared child occurrence at once; align their parent occurrences instead"));
}
}
Ok(deduped)
}
fn world_pick(
s: &Session,
p: &Value,
pk: &str,
fk: &str,
plane: &Option<solidcraft_doc::PlaneRef>,
vals: &std::collections::BTreeMap<String, solidcraft_doc::expr::Value>,
cmd: &str,
) -> Result<(Vec3, Option<Vec3>)> {
if let Some(pl_ref) = plane {
if p.get(pk).is_some() || p.get(fk).is_some() {
return Err(bad(cmd, format!("give `{pk}_plane` or `{pk}`/`{fk}`, not both")));
}
let world = s.world_state();
let pl = s.placed_document().resolve_plane_in(vals, pl_ref, 0, Some(&*world)).map_err(|e| bad(cmd, e.to_string()))?;
return Ok((pl.origin, Some(pl.normal())));
}
if let Some(fv) = p.get(fk) {
if p.get(pk).is_some() {
return Err(bad(cmd, format!("give `{pk}` or `{fk}`, not both")));
}
let fp = vec3(fv).ok_or_else(|| bad(cmd, format!("`{fk}` must be [x, y, z]")))?;
let n = super::super::face::face_normal_world(s, fp).ok_or_else(|| bad(cmd, format!("`{fk}` must be on a planar face")))?;
return Ok((fp, Some(n)));
}
if p.get(pk).and_then(Value::as_str).is_some_and(|o| o.eq_ignore_ascii_case("origin")) {
return Ok((Vec3::ZERO, None));
}
if let Some(o) = p.get(pk).and_then(Value::as_object)
&& (o.contains_key("at") || o.contains_key("snap"))
{
let at = o.get("at").and_then(vec3).ok_or_else(|| bad(cmd, format!("`{pk}.at` must be [x, y, z]")))?;
let kind = match o.get("snap") {
None => "vertex",
Some(Value::String(k)) if matches!(k.as_str(), "vertex" | "edge_mid" | "face_center" | "circle_center") => k.as_str(),
_ => return Err(bad(cmd, format!("`{pk}.snap` must be vertex, edge_mid, face_center or circle_center"))),
};
return super::super::features_more::snap_point(s, kind, at)
.map(|q| (q, None))
.ok_or_else(|| bad(cmd, format!("no {kind} to snap to near `{pk}`")));
}
p.get(pk)
.and_then(vec3)
.map(|v| (v, None))
.ok_or_else(|| bad(cmd, format!("give `{pk}`, `{fk}` or `{pk}_plane` ([x, y, z], \"origin\", or a plane)")))
}
pub(super) fn align(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "occurrence.align";
let paths = resolve_paths(s, p, cmd)?;
let flip = opt_bool(p, "flip", false, cmd)?;
let capture = opt_bool(p, "capture", true, cmd)?;
let move_grounded = opt_bool(p, "move_grounded", false, cmd)?;
for path in &paths {
let Some(&id) = path.last() else {
return Err(bad(cmd, "occurrence path is empty"));
};
let o = s.doc.occurrences.iter().find(|o| o.id == id).ok_or_else(|| bad(cmd, "no occurrence"))?;
if o.grounded && !move_grounded {
return Err(bad(
cmd,
format!("{} is grounded: unground it first (occurrence.ground; a design's first part is grounded when it is made)", o.name),
));
}
if let Some(mut group) = s.held_by_rigid_group(id) {
for _ in 0..64 {
match s.doc.occurrence_of(group).and_then(|occ| s.held_by_rigid_group(occ.id)) {
Some(up) => group = up,
None => break,
}
}
let name = s.doc.components.iter().find(|c| c.id == group).map(|c| c.name.clone()).unwrap_or_default();
return Err(bad(cmd, format!("{} is held in {name} by a rigid group: move {name}, or Unlock Contents (component.unlock) first", o.name)));
}
}
let plane = |key: &str| -> Result<Option<solidcraft_doc::PlaneRef>> {
p.get(key).map(|v| super::super::features::plane_param(s, Some(v), cmd)).transpose()
};
let (from_plane, to_plane) = (plane("from_plane")?, plane("to_plane")?);
let (vals, _) = s.doc.param_values();
let (from, from_normal) = world_pick(s, p, "from", "from_face", &from_plane, &vals, cmd)?;
let (to, to_normal) = world_pick(s, p, "to", "to_face", &to_plane, &vals, cmd)?;
let (mut axis, mut ang) = (Vec3::Z, 0.0);
if let (Some(a), Some(b)) = (from_normal.and_then(Vec3::normalized), to_normal.and_then(Vec3::normalized)) {
let want = if flip { b } else { b * -1.0 };
let cross = a.cross(want);
let theta = cross.len().atan2(a.dot(want));
if theta.abs() > 1e-12 {
axis = cross.normalized().unwrap_or_else(|| a.any_perp());
ang = theta;
}
}
let d = match (&to_plane, to_normal.and_then(Vec3::normalized)) {
(Some(_), Some(n)) => n * (to - from).dot(n),
_ => to - from,
};
if !(d.is_finite() && from.is_finite() && axis.is_finite() && ang.is_finite()) {
return Err(bad(cmd, "non-finite alignment transform"));
}
let world_move = rigid(d, from, axis, ang);
// Compute every new local placement from the current placements before mutating anything.
let mut updates = Vec::with_capacity(paths.len());
for path in &paths {
let placement = s.occurrence_placement(path).map_err(|e| bad(cmd, e.to_string()))?;
let inverse = mat_inverse(&placement.parent_world).ok_or_else(|| bad(cmd, "parent frame is singular"))?;
let local_delta = mat_mul(&mat_mul(&inverse, &world_move), &placement.parent_world);
let next = mat_mul(&local_delta, &placement.local);
if !valid_position(&next) {
return Err(bad(cmd, "alignment transform exceeds supported numeric range"));
}
updates.push((placement.occurrence, next));
}
let moved: Vec<u64> = updates.iter().map(|(id, _)| *id).collect();
// Placements inside a component placed more than once move in every placement of it: the
// command still runs (as occurrence.move does), and says so for the dialog's warning.
let shared: Vec<&Vec<u64>> = paths.iter().filter(|path| s.occurrence_shared(path)).collect();
if capture {
if world_move != IDENTITY || updates.iter().any(|(id, _)| s.pending_moves.contains_key(id)) {
for (id, next) in updates {
s.pending_moves.remove(&id);
if let Some(x) = s.doc_mut().occurrences.iter_mut().find(|x| x.id == id) {
x.transform = next;
}
}
}
} else {
for (id, next) in updates {
s.pending_moves.insert(id, next);
}
s.revision += 1;
}
let mut reply = json!({
"occurrences": moved,
"paths": paths,
"world_transform": world_move,
"pending": !capture,
});
if !shared.is_empty()
&& let Some(o) = reply.as_object_mut()
{
o.insert("shared".into(), json!(shared));
}
Ok(reply)
}
@@ -267,6 +267,17 @@ pub(super) fn clone_assembly(
features: &BTreeMap<u64, u64>,
bodies: &BTreeMap<String, String>,
) -> Result<Vec<String>> {
clone_assembly_with_joint_ids(doc, source, occurrences, features, bodies).map(|(messages, _)| messages)
}
/// The copied joint identity map is also needed when extracting saved timeline folders.
pub(super) fn clone_assembly_with_joint_ids(
doc: &mut Document,
source: &Document,
occurrences: &BTreeMap<u64, u64>,
features: &BTreeMap<u64, u64>,
bodies: &BTreeMap<String, String>,
) -> Result<(Vec<String>, BTreeMap<u64, u64>)> {
budget(&source.assembly)?;
budget(&doc.assembly)?;
validate_map(occurrences)?;
@@ -419,7 +430,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)
Ok((messages, joints))
}
#[cfg(test)]
+10 -3
View File
@@ -357,8 +357,6 @@ fn part_design(s: &Session, sc: &Scope, stem: &str, cmd: &str) -> Result<(Sessio
for g in &mut part.timeline_groups {
g.features.retain(|f| sc.inside.contains(f));
}
let parents: BTreeSet<u64> = part.timeline_groups.iter().filter_map(|g| g.parent).collect();
part.timeline_groups.retain(|g| !g.features.is_empty() || parents.contains(&g.id));
part.named_views.clear();
part.configs = Default::default();
part.assembly = Default::default();
@@ -371,7 +369,14 @@ fn part_design(s: &Session, sc: &Scope, stem: &str, cmd: &str) -> Result<(Sessio
scope.assembly.origins.retain(|o| mapped(&o.origin.occurrence));
scope.assembly.contact_sets.retain(|c| c.occurrences.iter().all(mapped));
scope.assembly.exploded.retain(|v| v.moves.iter().all(|(o, _)| mapped(o)));
let mut notices = super::super::component_clone_assembly::clone_assembly(&mut part, &scope, &sc.occurrences, &features, &body_map)?;
let (mut notices, joints) =
super::super::component_clone_assembly::clone_assembly_with_joint_ids(&mut part, &scope, &sc.occurrences, &features, &body_map)?;
// Extracted joints receive new ids. References to joints outside the extracted part go away.
for group in &mut part.timeline_groups {
group.joints = group.joints.iter().filter_map(|id| joints.get(id).copied()).collect();
}
let parents: BTreeSet<u64> = part.timeline_groups.iter().filter_map(|g| g.parent).collect();
part.timeline_groups.retain(|g| !g.features.is_empty() || !g.joints.is_empty() || parents.contains(&g.id));
prune_params(&mut part);
if !source.configs.is_empty() {
notices.push(format!("the configurations stay in this design; {stem} has none"));
@@ -785,6 +790,7 @@ mod tests {
fn extract_moves_the_history_to_its_own_design_and_links_it() {
let dir = folder("basic");
let (mut s, plate, other) = assembly();
run(&mut s, "timeline.group", json!({"name":"Internal joints", "joints":["PinHold"]}));
run(&mut s, "file.save", json!({"path": dir.join("asm.solidcraft")}));
let (vol, cen) = (volumes(&s), centroids(&s));
let other_features: Vec<u64> = s.doc.features.iter().filter(|f| f.component == other).map(|f| f.id).collect();
@@ -815,6 +821,7 @@ mod tests {
assert_eq!(part.doc.components.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(), ["Pin"]);
assert_eq!(part.doc.occurrences.len(), 1);
assert_eq!(part.doc.assembly.joints.iter().map(|j| j.name.as_str()).collect::<Vec<_>>(), ["PinHold"]);
assert_eq!(part.doc.timeline_groups[0].joints, [part.doc.assembly.joints[0].id], "joint folders follow the extracted ids");
assert!(part.doc.param("plate_h").is_some() && part.doc.param("unused").is_none());
assert!(part.model.results.iter().all(|r| r.error.is_none()));
let part_vol = volumes(&part);
+66 -6
View File
@@ -1,6 +1,9 @@
//! Physical properties of an assembly placement, with original body material assignments.
use std::collections::{BTreeMap, BTreeSet};
use serde_json::{Value, json};
use solidcraft_doc::{Document, Mat};
use solidcraft_geom::inertia::{self, Tensor3};
use solidcraft_geom::{Aabb3, Vec3};
use crate::params::bad;
@@ -14,6 +17,23 @@ fn descendants(p: &Value, cmd: &str) -> Result<bool> {
}
}
/// Principal moments this close (a share of the largest) count as equal: a round body's
/// tessellation tells its two equal moments apart by about 1e-4, which turns their axes at random.
const EQUAL_MOMENTS: f64 = 1e-3;
/// The point `about` asks for the inertia tensor about, if any.
fn about(p: &Value, cmd: &str) -> Result<Option<Vec3>> {
match p.get("about") {
None | Some(Value::Null) => Ok(None),
Some(v) => crate::params::vec3(v).map(Some).ok_or_else(|| bad(cmd, "`about` must be a point [x, y, z] in mm")),
}
}
/// The 3 × 3 linear part of a placement, row by row: its turn, and any scale or mirror.
fn linear(m: &Mat) -> Tensor3 {
[0, 1, 2].map(|i| [0, 1, 2].map(|j| m[j][i]))
}
fn placed_doc(s: &Session) -> Document {
let mut doc = (*s.doc).clone();
for occurrence in &mut doc.occurrences {
@@ -58,7 +78,7 @@ pub(super) fn component(s: &mut Session, p: &Value) -> Result<Value> {
} else {
doc.components.iter().find(|c| c.id == id).map(|c| c.name.clone()).ok_or_else(|| bad(cmd, "no active component"))?
};
let mut out = properties(s, &doc, &path, descendants(p, cmd)?, cmd)?;
let mut out = properties(s, &doc, &path, descendants(p, cmd)?, about(p, cmd)?, cmd)?;
out["component"] = json!(id);
out["name"] = json!(name);
Ok(out)
@@ -79,13 +99,13 @@ pub(super) fn occurrence(s: &mut Session, p: &Value) -> Result<Value> {
_ => return Err(bad(cmd, "occurrence has multiple placements; supply its `path`")),
}
};
let mut out = properties(s, &doc, &path, descendants(p, cmd)?, cmd)?;
let mut out = properties(s, &doc, &path, descendants(p, cmd)?, about(p, cmd)?, cmd)?;
out["occurrence"] = json!(id);
out["name"] = json!(doc.occurrences.iter().find(|o| o.id == id).map(|o| o.name.as_str()));
Ok(out)
}
fn properties(s: &Session, doc: &Document, selected: &[u64], descendants: bool, cmd: &str) -> Result<Value> {
fn properties(s: &Session, doc: &Document, selected: &[u64], descendants: bool, about: Option<Vec3>, cmd: &str) -> Result<Value> {
let state = s.model.state();
let mut bodies = Vec::new();
let (mut volume, mut area, mut mass) = (0.0, 0.0, 0.0);
@@ -114,19 +134,40 @@ fn properties(s: &Session, doc: &Document, selected: &[u64], descendants: bool,
}
out
};
// How each closed body's volume spreads about its centre of mass (#8095): one tessellation
// however often it is placed, which each placement then turns.
let components: BTreeSet<u64> = selected_placements.iter().map(|(component, _, _)| *component).collect();
let spread_bodies: Vec<usize> = (0..state.bodies.len())
.filter(|&i| {
state.bodies.get(i).is_some_and(|b| components.contains(&doc.model_body_component(b)) && crate::cmd::inspect::encloses_volume(&b.body))
})
.collect();
let spreads = solidcraft_kernel::map_on_threads(&spread_bodies, 16, |&i| {
state.bodies.get(i).and_then(|b| solidcraft_io::mass_properties(&b.body).ok().flatten()).and_then(|m| m.gyration())
});
let spread: BTreeMap<usize, Tensor3> = spread_bodies.into_iter().zip(spreads).filter_map(|(i, g)| Some((i, g.flatten()?))).collect();
// Closed bodies' (mass, centre of mass, inertia about it), all in world axes.
let mut parts: Vec<(f64, Vec3, Option<Tensor3>)> = Vec::new();
for (component, path, transform) in selected_placements {
placement_count += 1;
for body in state.bodies.iter().filter(|b| doc.model_body_component(b) == component) {
for (index, body) in state.bodies.iter().enumerate().filter(|(_, b)| doc.model_body_component(b) == component) {
let transform: Mat = doc.model_body_offset(body).map(|offset| solidcraft_doc::mat_mul(&transform, offset)).unwrap_or(transform);
let placed = solidcraft_kernel::transform_matrix(&body.body, transform)?;
let measured = solidcraft_kernel::measure(&placed)?;
let density = doc.density(&body.name);
let body_mass = measured.volume * density / 1000.0;
let closed = crate::cmd::inspect::encloses_volume(&placed);
// The mesh's spread with the exact mass, so the tensor agrees with the mass shown.
let body_inertia = spread
.get(&index)
.filter(|_| closed)
.map(|g| inertia::from_gyration(&inertia::transformed(g, &linear(&transform)), body_mass))
.filter(|i| i.iter().flatten().all(|x| x.is_finite()));
if closed {
volume += measured.volume;
mass += body_mass;
moment += measured.centroid * body_mass;
parts.push((body_mass, measured.centroid, body_inertia));
} else {
skipped += 1;
}
@@ -140,6 +181,7 @@ fn properties(s: &Session, doc: &Document, selected: &[u64], descendants: bool,
"closed": closed, "density_g_cm3": closed.then_some(density),
"mass_g": closed.then_some(body_mass), "volume_mm3": closed.then_some(measured.volume),
"area_mm2": measured.area, "center_of_mass": closed.then_some(measured.centroid),
"inertia_g_mm2": body_inertia,
"bbox": {"min": measured.bbox.min, "max": measured.bbox.max}}));
}
}
@@ -150,11 +192,29 @@ fn properties(s: &Session, doc: &Document, selected: &[u64], descendants: bool,
if centroid.is_some_and(|point| !point.is_finite()) {
return Err(bad(cmd, "center of mass exceeds supported numeric range"));
}
// About the centre of mass: each body's own tensor, plus its mass at its distance from that
// centre (parallel axes). Unknown when any closed body's mesh did not close.
let at_centre = centroid.and_then(|c| {
parts.iter().try_fold([[0.0f64; 3]; 3], |sum, (m, at, i)| {
let i = inertia::shifted(&(*i)?, *m, *at - c);
Some([0, 1, 2].map(|r| [0, 1, 2].map(|k| sum[r][k] + i[r][k])))
})
});
let at_centre = at_centre.filter(|i| i.iter().flatten().all(|x| x.is_finite()));
let principal = at_centre.and_then(|i| inertia::principal(&i, EQUAL_MOMENTS));
let bbox = if bodies.is_empty() { Value::Null } else { json!({"min": bounds.min, "max": bounds.max}) };
Ok(json!({"path": selected, "coordinate_frame": "world", "descendants": descendants,
let mut out = json!({"path": selected, "coordinate_frame": "world", "descendants": descendants,
"placement_count": placement_count, "body_count": bodies.len(), "bodies": bodies,
"total": {"open_bodies_skipped": skipped, "volume_mm3": volume, "area_mm2": area, "mass_g": mass,
"center_of_mass": centroid, "bbox": bbox, "faces": faces, "edges": edges, "vertices": vertices}}))
"center_of_mass": centroid, "first_moment_g_mm": moment, "inertia_g_mm2": at_centre,
"principal_moments_g_mm2": principal.map(|(moments, _)| moments), "principal_axes": principal.map(|(_, axes)| axes),
"bbox": bbox, "faces": faces, "edges": edges, "vertices": vertices}});
if let Some(point) = about {
let shifted =
centroid.zip(at_centre).map(|(c, i)| inertia::shifted(&i, mass, point - c)).filter(|i| i.iter().flatten().all(|x| x.is_finite()));
out["total"]["inertia_about"] = json!({"point": point, "inertia_g_mm2": shifted});
}
Ok(out)
}
#[cfg(test)]
@@ -98,6 +98,114 @@ fn properties_reject_hostile_identifiers_and_options() {
for path in [json!([]), json!(["bad"]), json!([0]), json!(vec![occurrence; 65])] {
assert!(s.execute("occurrence.properties", &json!({"occurrence": occurrence, "path": path})).is_err());
}
for about in [json!("origin"), json!([1, 2]), json!([1, 2, "z"]), json!([f64::MAX, 0, 0]), json!({"x": 1})] {
assert!(s.execute("component.properties", &json!({"about": about})).is_err(), "{about}");
}
}
fn tensor(v: &Value) -> [[f64; 3]; 3] {
[0, 1, 2].map(|r| [0, 1, 2].map(|c| v[r][c].as_f64().unwrap_or_else(|| panic!("no [{r}][{c}] in {v}"))))
}
fn tensor_close(actual: &Value, want: [[f64; 3]; 3], rel: f64) {
let got = tensor(actual);
let scale = want.iter().flatten().fold(0.0f64, |m, x| m.max(x.abs()));
for (g, w) in got.iter().flatten().zip(want.iter().flatten()) {
assert!((g - w).abs() <= rel * scale, "{got:?} != {want:?}");
}
}
/// #8095: a 10 × 20 × 30 mm steel box's inertia tensor about its centre of mass is
/// m (b² + c²) / 12 … on the diagonal, its principal axes are its edges, and turning its
/// placement turns the tensor with it.
#[test]
fn box_inertia_matches_the_closed_form_and_turns_with_its_placement() {
let mut s = Session::default();
let c = run(&mut s, "component.create", json!({"name": "Block", "grounded": false}));
run(&mut s, "solid.box", json!({"length": 10, "width": 20, "height": 30, "body_name": "Block"}));
run(&mut s, "material.assign", json!({"bodies": ["Block"], "material": "Steel"}));
let r = run(&mut s, "component.properties", json!({"component": c["component"]}));
let total = &r["total"];
close(&total["bbox"]["max"][1], 20.0);
let m = total["mass_g"].as_f64().unwrap();
close(&total["mass_g"], 6000.0 * s.doc.density("Block") / 1000.0);
let (a, b, h) = (10.0, 20.0, 30.0);
let (ixx, iyy, izz) = (m * (b * b + h * h) / 12.0, m * (a * a + h * h) / 12.0, m * (a * a + b * b) / 12.0);
tensor_close(&total["inertia_g_mm2"], [[ixx, 0.0, 0.0], [0.0, iyy, 0.0], [0.0, 0.0, izz]], 1e-9);
tensor_close(&r["bodies"][0]["inertia_g_mm2"], [[ixx, 0.0, 0.0], [0.0, iyy, 0.0], [0.0, 0.0, izz]], 1e-9);
// Smallest first: about the longest edge (z), largest about the shortest (x).
for (k, want) in [izz, iyy, ixx].into_iter().enumerate() {
assert!((total["principal_moments_g_mm2"][k].as_f64().unwrap() - want).abs() < 1e-9 * ixx, "{total}");
}
// Right-handed: z × y is −x.
assert_eq!(total["principal_axes"], json!([[0.0, 0.0, 1.0], [0.0, 1.0, 0.0], [-1.0, 0.0, 0.0]]));
for (k, want) in [5.0, 10.0, 15.0].iter().enumerate() {
close(&total["first_moment_g_mm"][k], m * want);
}
// A quarter turn about z swaps the x and y moments; the principal moments stay.
run(&mut s, "occurrence.move", json!({"occurrence": c["occurrence"], "axis": [0, 0, 1], "angle": "90 deg", "capture": false}));
let turned = run(&mut s, "component.properties", json!({"component": c["component"]}));
tensor_close(&turned["total"]["inertia_g_mm2"], [[iyy, 0.0, 0.0], [0.0, ixx, 0.0], [0.0, 0.0, izz]], 1e-9);
assert_eq!(turned["total"]["principal_moments_g_mm2"], total["principal_moments_g_mm2"]);
let x_axis = &turned["total"]["principal_axes"][2];
assert!(x_axis[0].as_f64().unwrap().abs() < 1e-9 && (x_axis[1].as_f64().unwrap().abs() - 1.0).abs() < 1e-9, "{turned}");
}
/// #8095: an assembly's tensor adds each placement's own about the shared centre of mass by
/// parallel axes, and `about` takes it about any point; a cylinder's comes from its
/// tessellation, close to m r² / 2 about its axis.
#[test]
fn assembly_inertia_adds_placements_by_parallel_axes_and_about_any_point() {
let mut s = Session::default();
let (component, _, body) = box_in(&mut s, "Block");
run(&mut s, "occurrence.copy", json!({"component": component, "translate": [20, 0, 0]}));
let root = run(&mut s, "component.properties", json!({"component": "root", "about": [0, 0, 0]}));
let total = &root["total"];
let m = s.doc.density(&body);
close(&total["mass_g"], 2.0 * m);
// Each 10 mm cube: m (10² + 10²) / 12 on its diagonal; each sits 10 mm along x from (15, 5, 5).
let own = m * 200.0 / 12.0;
let at_centre = [[2.0 * own, 0.0, 0.0], [0.0, 2.0 * own + 2.0 * m * 100.0, 0.0], [0.0, 0.0, 2.0 * own + 2.0 * m * 100.0]];
tensor_close(&total["inertia_g_mm2"], at_centre, 1e-9);
// About the origin: the parallel-axis term of the whole mass at (15, 5, 5).
let (mm, cv) = (2.0 * m, [15.0, 5.0, 5.0]);
let c2: f64 = cv.iter().map(|x| x * x).sum();
let at_origin = [0, 1, 2].map(|r| [0, 1, 2].map(|k| at_centre[r][k] + mm * (if r == k { c2 } else { 0.0 } - cv[r] * cv[k])));
assert_eq!(total["inertia_about"]["point"], json!([0.0, 0.0, 0.0]));
tensor_close(&total["inertia_about"]["inertia_g_mm2"], at_origin, 1e-9);
assert!(total.get("inertia_about").is_some() && run(&mut s, "component.properties", json!({}))["total"].get("inertia_about").is_none());
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"radius": 8, "height": 10, "body_name": "Pin"}));
let r = run(&mut s, "component.properties", json!({}));
let (m, r2) = (r["total"]["mass_g"].as_f64().unwrap(), 64.0);
let i = tensor(&r["total"]["inertia_g_mm2"]);
assert!((i[2][2] - m * r2 / 2.0).abs() < 1e-3 * m * r2 / 2.0, "{i:?}");
assert!((i[0][0] - m * (3.0 * r2 + 100.0) / 12.0).abs() < 1e-3 * i[0][0], "{i:?}");
}
/// #8095: an open surface has no volume, so no tensor; the closed bodies beside it keep theirs.
#[test]
fn open_bodies_have_no_inertia_and_leave_the_closed_ones_alone() {
let mut s = Session::default();
for (command, params) in [
("sketch.create", json!({"plane": "XY", "name": "Profile"})),
("sketch.line", json!({"points": [[30, 0], [30, 40]], "ids": ["side"]})),
("sketch.finish", json!({})),
("surface.revolve", json!({"sketch": "Profile", "curves": ["side"], "axis": "y", "angle": "270 deg", "body_name": "Tube"})),
] {
run(&mut s, command, params);
}
let open = run(&mut s, "component.properties", json!({"about": [1, 2, 3]}));
assert_eq!(open["bodies"][0]["inertia_g_mm2"], Value::Null);
for key in ["inertia_g_mm2", "principal_moments_g_mm2", "principal_axes"] {
assert_eq!(open["total"][key], Value::Null, "{key}");
}
assert_eq!(open["total"]["inertia_about"]["inertia_g_mm2"], Value::Null);
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [100, 0, 0], "body_name": "Box"}));
let both = run(&mut s, "component.properties", json!({}));
let m = both["total"]["mass_g"].as_f64().unwrap();
tensor_close(&both["total"]["inertia_g_mm2"], [[m * 200.0 / 12.0, 0.0, 0.0], [0.0, m * 200.0 / 12.0, 0.0], [0.0, 0.0, m * 200.0 / 12.0]], 1e-9);
}
#[test]
+63 -20
View File
@@ -42,19 +42,19 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SOLID", "MODIFY")
.icon("delete")
.key("Delete")
.params("feature: id|name | features: [id|name] | groups: [timeline folder ids] (a folder goes with all its features and nested folders; one undo step; a sketch takes its dependent features with it) → deleted: [ids]; groups?: [folder ids deleted]; failing?: [{feature, error}] (later features that worked and now fail)"),
.params("feature: id|name | features: [id|name] | groups: [timeline folder ids] (a folder goes with all its features, joints and nested folders; one undo step; a sketch takes its dependent features with it) → deleted: [feature ids]; joints?: [joint ids]; groups?: [folder ids deleted]; failing?: [{feature, error}] (later features that worked and now fail)"),
CommandSpec::new("timeline.rename", "Rename", rename).icon("rename").params("feature: id|name, name (references to it follow)"),
CommandSpec::new("timeline.group", "New Feature Folder", group)
.icon("folder")
.doc_only()
.params("name?; features?: [id|name]; parent?: folder id (features leave any other timeline folder)"),
.params("name?; features?: [id|name]; joints?: [id|name]; groups?: [folder ids]; parent?: folder id (features and joints leave any other timeline folder)"),
CommandSpec::new("timeline.ungroup", "Remove Feature Folders", ungroup).icon("folder")
.doc_only()
.params("group: id | groups: [ids] (features remain in the timeline)"),
CommandSpec::new("timeline.rename_group", "Rename Feature Folder", rename_group).icon("rename").doc_only().params("group: id, name"),
.params("group: id | groups: [ids] (features and joints remain in the timeline)"),
CommandSpec::new("timeline.rename_group", "Rename Feature Folder", rename_group).icon("rename").doc_only().params("group: id, name (renames the folder holding features and joints)"),
CommandSpec::new("timeline.move_to_group", "Move Features to Folder", move_to_group).icon("folder")
.doc_only()
.params("features?: [id|name], groups?: [ids], group?: id (omit or null to move to the timeline root)"),
.params("features?: [id|name], joints?: [id|name], groups?: [ids], group?: id (omit or null to move to the timeline root)"),
CommandSpec::new("timeline.move_many", "Move Timeline Items", move_many).icon("reorder").params(
"features?: [id|name], groups?: [folder ids], group?: folder id, position: insertion slot, active?: bool (true = the moved contents end up before the history marker, false = behind it; other features keep their state); atomically changes folder membership and moves all selected contents as one block",
),
@@ -72,9 +72,9 @@ pub static COMMANDS: &[CommandSpec] = &[
.noundo()
.params("feature: id|name → features deleted with it, and features that would fail without it"),
CommandSpec::new("timeline.suppress", "Suppress Feature", suppress).icon("suppress").params(
"feature: id|name | features: [id|name] | groups: [timeline folder ids] (every feature in those folders and their nested \
folders; one undo step), suppressed?: bool (default toggles: several features are all \
suppressed unless all of them already are, then all are unsuppressed) → feature: id (or features: [ids]; groups: [ids] when given), suppressed",
"feature: id|name | features: [id|name] | groups: [timeline folder ids] (every feature and joint in those folders and their nested \
folders; one undo step), suppressed?: bool (default toggles: features and joints are all \
suppressed unless all of them already are, then all are unsuppressed) → feature: id (or features: [ids]; groups: [ids] and joints: [ids] when groups are given), suppressed",
),
CommandSpec::new("timeline.edit", "Edit Feature", edit_feature).icon("edit").params(
"feature: id|name, set: {fields to change, merged into the feature's definition as timeline.inspect returns it, \
@@ -83,7 +83,7 @@ pub static COMMANDS: &[CommandSpec] = &[
expressions are text, or a number in mm or degrees} → recomputed: features rebuilt, restored: features whose earlier result for the same inputs was reused",
),
CommandSpec::new("timeline.inspect", "Inspect Feature", inspect_feature).icon("inspect").noundo().params(
"feature: id|name → type, definition (the fields timeline.edit's set changes, with their current values), \
"group: folder id → group, name, parent, features, joints; or feature: id|name → type, definition (the fields timeline.edit's set changes, with their current values), \
inputs: [{label, param, expression, unit}] (named inputs parameters.change can also set), error, warning",
),
CommandSpec::new("select.set", "Select", select_set).icon("select")
@@ -205,10 +205,12 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
}
// A folder goes with its features and nested folders, its features in timeline order.
let (folders, contents) = group_tree(&s.doc, &groups);
let joints = group_joint_ids(&s.doc, &groups);
keys.extend(s.doc.features.iter().filter(|f| contents.contains(&f.id)).map(|f| f.id.to_string()));
// Features that work now; any of them failing after the delete is reported (a later
// feature that names a deleted body, #267).
let worked: std::collections::BTreeSet<u64> = s.model.results.iter().filter(|r| r.error.is_none() && !r.skipped).map(|r| r.id).collect();
super::joints::delete_joints(s.doc_mut(), &joints);
let mut gone = Vec::new();
for k in keys {
let Some(id) = s.doc.find_feature(&k).map(|f| f.id) else {
@@ -239,6 +241,9 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
.filter_map(|r| r.error.as_ref().map(|e| json!({"feature": r.name, "error": e})))
.collect();
let mut out = json!({"deleted": gone});
if !joints.is_empty() {
out["joints"] = json!(joints);
}
if !folders.is_empty() {
out["groups"] = json!(folders);
}
@@ -315,6 +320,26 @@ fn group_features(s: &Session, p: &Value, required: bool, cmd: &str) -> Result<V
Ok(ids)
}
fn group_joints(s: &Session, p: &Value, cmd: &str) -> Result<Vec<u64>> {
let values = match p.get("joints") {
None => return Ok(Vec::new()),
Some(Value::Array(values)) if values.len() <= 10_000 => values,
_ => return Err(bad(cmd, "`joints` must list at most 10000 joint ids or names")),
};
let mut ids = std::collections::BTreeSet::new();
for value in values {
ids.insert(super::joints::joint_id(s, Some(value), cmd)?);
}
Ok(ids.into_iter().collect())
}
fn group_joint_ids(doc: &solidcraft_doc::Document, roots: &[u64]) -> Vec<u64> {
let folders = group_tree(doc, roots).0;
let ids: std::collections::BTreeSet<u64> =
doc.timeline_groups.iter().filter(|group| folders.contains(&group.id)).flat_map(|group| group.joints.iter().copied()).collect();
doc.assembly.joints.iter().filter(|joint| ids.contains(&joint.id)).map(|joint| joint.id).collect()
}
fn group_ids(s: &Session, p: &Value, required: bool, cmd: &str) -> Result<Vec<u64>> {
let values = match p.get("groups") {
Some(Value::Array(values)) => values,
@@ -403,6 +428,7 @@ fn require_contiguous_groups(doc: &solidcraft_doc::Document, cmd: &str) -> Resul
fn group(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "timeline.group";
let mut features = group_features(s, p, false, cmd)?;
let mut joints = group_joints(s, p, cmd)?;
let groups = group_ids(s, p, false, cmd)?;
let name = match str_(p, "name").map(str::trim).filter(|name| !name.is_empty()) {
Some(name) if name.len() <= 128 => name.to_string(),
@@ -421,14 +447,17 @@ fn group(s: &mut Session, p: &Value) -> Result<Value> {
}
let nested = group_feature_ids(&s.doc, &groups);
features.retain(|feature| !nested.contains(feature));
let nested_joints = group_joint_ids(&s.doc, &groups);
joints.retain(|joint| !nested_joints.contains(joint));
let mut next = (*s.doc).clone();
for group in &mut next.timeline_groups {
group.joints.retain(|joint| !joints.contains(joint));
group.features.retain(|feature| !features.contains(feature));
if groups.contains(&group.id) {
group.parent = Some(id);
}
}
next.timeline_groups.push(solidcraft_doc::TimelineGroup { id, name: name.clone(), parent, features });
next.timeline_groups.push(solidcraft_doc::TimelineGroup { id, name: name.clone(), parent, features, joints });
sort_group_features(&mut next);
require_contiguous_groups(&next, cmd)?;
*s.doc_mut() = next;
@@ -469,18 +498,19 @@ fn ungroup(s: &mut Session, p: &Value) -> Result<Value> {
parent
};
let mut next = (*s.doc).clone();
let moved_features: Vec<(Option<u64>, Vec<u64>)> = next
let moved_features: Vec<(Option<u64>, Vec<u64>, Vec<u64>)> = next
.timeline_groups
.iter()
.filter(|group| ids.contains(&group.id))
.map(|group| (surviving_parent(group.parent), group.features.clone()))
.map(|group| (surviving_parent(group.parent), group.features.clone(), group.joints.clone()))
.collect();
for group in &mut next.timeline_groups {
if ids.contains(&group.id) {
continue;
}
group.parent = surviving_parent(group.parent);
for (_, features) in moved_features.iter().filter(|(parent, _)| *parent == Some(group.id)) {
for (_, features, joints) in moved_features.iter().filter(|(parent, _, _)| *parent == Some(group.id)) {
group.joints.extend(joints.iter().copied());
group.features.extend(features.iter().copied());
}
}
@@ -507,9 +537,10 @@ fn rename_group(s: &mut Session, p: &Value) -> Result<Value> {
fn move_to_group(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "timeline.move_to_group";
let features = group_features(s, p, false, cmd)?;
let joints = group_joints(s, p, cmd)?;
let groups = group_ids(s, p, false, cmd)?;
if features.is_empty() && groups.is_empty() {
return Err(bad(cmd, "list at least one feature or folder"));
if features.is_empty() && joints.is_empty() && groups.is_empty() {
return Err(bad(cmd, "list at least one feature, joint or folder"));
}
let target = match p.get("group") {
Some(Value::Null) | None => None,
@@ -522,8 +553,10 @@ fn move_to_group(s: &mut Session, p: &Value) -> Result<Value> {
}
let mut next = (*s.doc).clone();
for group in &mut next.timeline_groups {
group.joints.retain(|joint| !joints.contains(joint));
group.features.retain(|feature| !features.contains(feature));
if Some(group.id) == target {
group.joints.extend(joints.iter().copied());
group.features.extend(features.iter().copied());
}
}
@@ -535,7 +568,7 @@ fn move_to_group(s: &mut Session, p: &Value) -> Result<Value> {
sort_group_features(&mut next);
require_contiguous_groups(&next, cmd)?;
*s.doc_mut() = next;
Ok(json!({"features": features, "groups": groups, "group": target}))
Ok(json!({"features": features, "joints": joints, "groups": groups, "group": target}))
}
fn move_many(s: &mut Session, p: &Value) -> Result<Value> {
@@ -679,14 +712,18 @@ fn suppress(s: &mut Session, p: &Value) -> Result<Value> {
let contents = group_feature_ids(&s.doc, &groups);
let more: Vec<u64> = s.doc.features.iter().map(|f| f.id).filter(|id| contents.contains(id) && !ids.contains(id)).collect();
ids.extend(more);
if ids.is_empty() {
return Err(bad(cmd, "the timeline folders hold no features"));
let joints = group_joint_ids(&s.doc, &groups);
if ids.is_empty() && joints.is_empty() {
return Err(bad(cmd, "the timeline folders hold no features or joints"));
}
let want = match bool_(p, "suppressed") {
Some(b) => b,
None if p.get("suppressed").is_some_and(|v| !v.is_null()) => return Err(bad(cmd, "`suppressed` must be true or false")),
// Toggle: one feature flips; several are all suppressed unless all already are.
None => !ids.iter().all(|id| s.doc.feature(*id).is_some_and(|f| f.suppressed)),
None => {
!(ids.iter().all(|id| s.doc.feature(*id).is_some_and(|f| f.suppressed))
&& joints.iter().all(|id| s.doc.assembly.joints.iter().any(|joint| joint.id == *id && joint.suppressed)))
}
};
let keep = if want { counted_names_from(s, &ids) } else { Vec::new() };
// One command, so one undo step however many features it changes.
@@ -700,10 +737,11 @@ fn suppress(s: &mut Session, p: &Value) -> Result<Value> {
let f = doc.feature_mut(*id).ok_or_else(|| bad(cmd, format!("no feature {id}")))?;
f.suppressed = want;
}
super::joints::suppress_joints(s.doc_mut(), &joints, want);
match ids.as_slice() {
[id] if groups.is_empty() && p.get("features").is_none_or(Value::is_null) => Ok(json!({"feature": id, "suppressed": want})),
_ if groups.is_empty() => Ok(json!({"features": ids, "suppressed": want})),
_ => Ok(json!({"features": ids, "groups": groups, "suppressed": want})),
_ => Ok(json!({"features": ids, "joints": joints, "groups": groups, "suppressed": want})),
}
}
@@ -1063,6 +1101,11 @@ fn heavy_fields(kind: &FeatureKind) -> &'static [(&'static str, &'static str)] {
fn inspect_feature(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "timeline.inspect";
if p.get("group").is_some() {
let id = timeline_group_id(s, p, cmd)?;
let group = s.doc.timeline_groups.iter().find(|group| group.id == id).ok_or_else(|| bad(cmd, "folder"))?;
return Ok(json!({"group": id, "name": group.name, "parent": group.parent, "features": group.features, "joints": group.joints}));
}
let id = feature_id(s, p.get("feature"), cmd)?;
let f = s.doc.feature(id).ok_or_else(|| bad(cmd, "feature"))?;
let mut def = serde_json::to_value(f).map_err(|e| EngineError::Other(e.to_string()))?;
+83
View File
@@ -1354,3 +1354,86 @@ fn a_move_before_a_long_history_of_one_body_fits_a_window_threads_stack() {
.join();
assert!(moved.is_ok());
}
#[test]
fn timeline_joint_folders_are_mixed_saved_and_undoable() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane":"XY", "name":"Profile"}));
run(&mut s, "component.create", json!({"name":"Part"}));
let joint = run(&mut s, "joint.as_built", json!({"type":"revolute", "a":0, "b":"Part", "name":"Hinge"}))["joint"].as_u64().unwrap();
let folder = run(&mut s, "timeline.group", json!({"name":"Mixed", "features":["Profile"], "joints":["Hinge", joint]}))["group"].as_u64().unwrap();
assert_eq!(s.doc.timeline_groups[0].joints, [joint]);
assert_eq!(s.doc.timeline_groups[0].features.len(), 1);
assert_eq!(run(&mut s, "timeline.inspect", json!({"group":folder}))["joints"], json!([joint]));
let saved = s.doc.to_json();
let reopened = solidcraft_doc::Document::from_json(&saved).unwrap();
assert_eq!(reopened.to_json(), saved);
let mut damaged: Value = serde_json::from_str(&saved).unwrap();
damaged["timeline_groups"][0]["joints"] = json!([u64::MAX]);
assert!(solidcraft_doc::Document::from_json(&damaged.to_string()).is_err());
damaged["timeline_groups"][0]["joints"] = json!([joint, joint]);
assert!(solidcraft_doc::Document::from_json(&damaged.to_string()).is_err());
let destination = run(&mut s, "timeline.group", json!({"name":"Destination"}))["group"].as_u64().unwrap();
run(&mut s, "timeline.move_to_group", json!({"joints":[joint], "group":destination}));
assert!(s.doc.timeline_groups[0].joints.is_empty());
assert_eq!(s.doc.timeline_groups[1].joints, [joint]);
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.timeline_groups[0].joints, [joint]);
// Both contents of a nested folder participate in the same toggle and undo.
run(&mut s, "timeline.move_to_group", json!({"groups":[folder], "group":destination}));
let before = s.doc.clone();
run(&mut s, "timeline.suppress", json!({"groups":[destination]}));
assert!(s.doc.features[0].suppressed && s.doc.assembly.joints[0].suppressed);
run(&mut s, "timeline.suppress", json!({"groups":[destination]}));
assert!(!s.doc.features[0].suppressed && !s.doc.assembly.joints[0].suppressed);
run(&mut s, "edit.undo", json!({}));
assert!(s.doc.features[0].suppressed && s.doc.assembly.joints[0].suppressed);
run(&mut s, "edit.undo", json!({}));
assert_eq!(*s.doc, *before);
run(&mut s, "timeline.delete", json!({"groups":[destination]}));
assert!(s.doc.features.is_empty() && s.doc.assembly.joints.is_empty() && s.doc.timeline_groups.is_empty());
run(&mut s, "edit.undo", json!({}));
assert_eq!(*s.doc, *before);
run(&mut s, "timeline.ungroup", json!({"group":folder}));
assert_eq!(s.doc.timeline_groups[0].joints, [joint], "contents promote to the surviving parent");
run(&mut s, "timeline.ungroup", json!({"group":destination}));
assert!(s.doc.timeline_groups.is_empty());
assert_eq!(s.doc.assembly.joints[0].id, joint);
let folder = run(&mut s, "timeline.group", json!({"joints":vec![joint; 10_000]}))["group"].as_u64().unwrap();
assert_eq!(s.doc.timeline_groups[0].joints, [joint], "the 10000-entry cap is inclusive and deduplicated");
run(&mut s, "timeline.move_to_group", json!({"joints":["Hinge"], "group":null}));
assert!(s.doc.timeline_groups[0].joints.is_empty());
run(&mut s, "timeline.move_to_group", json!({"joints":["Hinge"], "group":folder}));
run(&mut s, "timeline.suppress", json!({"groups":[folder]}));
assert!(s.doc.assembly.joints[0].suppressed, "joint-only folders suppress too");
run(&mut s, "joint.delete", json!({"joint":joint}));
assert!(s.doc.timeline_groups[0].joints.is_empty());
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.timeline_groups[0].joints, [joint]);
run(&mut s, "component.delete", json!({"component":"Part"}));
assert!(s.doc.assembly.joints.is_empty());
assert!(s.doc.timeline_groups[0].joints.is_empty());
assert!(solidcraft_doc::Document::from_json(&s.doc.to_json()).is_ok());
}
#[test]
fn timeline_joint_folders_reject_hostile_joint_references_atomically() {
let mut s = Session::default();
run(&mut s, "component.create", json!({"name":"Part"}));
let joint = run(&mut s, "joint.as_built", json!({"type":"rigid", "a":0, "b":"Part"}))["joint"].as_u64().unwrap();
let folder = run(&mut s, "timeline.group", json!({}))["group"].as_u64().unwrap();
for command in ["timeline.group", "timeline.move_to_group"] {
for joints in
[json!([u64::MAX]), json!([-1]), json!([1e100]), json!(["missing"]), json!([{}]), json!([null]), json!("bad"), json!(vec![joint; 10_001])]
{
let before = s.doc.to_json();
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| s.execute(command, &json!({"joints":joints, "group":folder}))));
let error = result.unwrap().unwrap_err();
assert!(
matches!(&error, crate::EngineError::BadParams { .. }),
"{command} must reject invalid joint references without an internal panic: {error}"
);
assert_eq!(s.doc.to_json(), before);
}
}
}
+1 -1
View File
@@ -506,7 +506,7 @@ mod tests;
/// A snap point near `at` on the model: the nearest vertex, edge midpoint, planar face's centre
/// or circular edge's centre (the end of a cylindrical face).
pub(super) fn snap_point(s: &Session, kind: &str, at: Vec3) -> Option<Vec3> {
pub(crate) fn snap_point(s: &Session, kind: &str, at: Vec3) -> Option<Vec3> {
let st = s.world_state();
let near = |pts: Vec<Vec3>| pts.into_iter().min_by(|a, b| a.dist(at).total_cmp(&b.dist(at)));
match kind {
+59 -13
View File
@@ -43,7 +43,7 @@ pub static COMMANDS: &[CommandSpec] = &[
closed loop follow, and the motion stops where the loop can no longer close → stopped_by_loop); quiet?: bool (leave out dof \
and fixed) → values: [{name, value, unit: deg|mm, limits}] as joint.list gives them; moved: whether any occurrence moved; \
reason: why the joint can move nothing (suppressed, its origins lost, or its chain joined to nothing grounded → unconnected: \
the occurrence named)",
the occurrence named); index?: zero-based coordinate to drive with value (the other coordinates follow); continuous?: bool (drag frame, suppress UI echo)",
),
CommandSpec::new("joint.reach", "Reach Point", reach).icon("reach").params(
"occurrence: id|name; point: [x,y,z] (where its point goes, world); at?: [x,y,z] (its point, in the world where it is now; default: the centre of its bodies' box); z?: [x,y,z] (the way its axis then points); axis?: [x,y,z] (that axis, in the world now; default: its own z); joints?: [ids or names] (only these move toward the point; the other joints of a closed loop they are in follow); quiet?: bool; continuous?: bool (a frame of a drag: the answer says continuous, and the app's command line doesn't echo it). Moves the values of the joints on its chain from where they are to the nearest pose, within their limits, through motion links, keeping closed loops closed as joint.drive does (contact sets are not checked) → reached; error_mm (and error_deg with z): how close it got; point: where its point is; values: [{joint, name, values}] of the joints that moved, as joint.list gives them; moved; stopped_by_limit: [{joint, value, limit: min|max}]: the limits it rests on short of the point; stopped_by_loop: {joint}: the closed loop that stopped it (its chain alone gets closer); reason: why nothing can move",
@@ -858,7 +858,27 @@ fn drive(s: &mut Session, p: &Value) -> Result<Value> {
if kind == JointKind::Rigid {
return Err(bad(cmd, "a rigid joint does not move: joint.edit can make it revolute (turns), slider (slides) or cylindrical (both)"));
}
let mut q = values_param(s, p, kind, cmd)?;
let index = match p.get("index") {
None => None,
Some(v) => Some(
v.as_u64()
.and_then(|i| usize::try_from(i).ok())
.filter(|i| *i < kind.dofs().len())
.ok_or_else(|| bad(cmd, "index is outside this joint's coordinates"))?,
),
};
let mut q = if let Some(index) = index {
if p.get("values").is_some() {
return Err(bad(cmd, "index goes with value, not values"));
}
let mut q = joints::joint_values(&s.doc.assembly).remove(&id).unwrap_or_else(|| vec![0.0; kind.dofs().len()]);
if let Some(slot) = q.get_mut(index) {
*slot = value(s, p.get("value").ok_or_else(|| bad(cmd, "give value"))?, kind.is_angle(index), cmd)?;
}
q
} else {
values_param(s, p, kind, cmd)?
};
if p.get("values").is_none() && p.get("value").is_none() {
return Err(bad(cmd, "give `value` or `values`"));
}
@@ -869,23 +889,32 @@ fn drive(s: &mut Session, p: &Value) -> Result<Value> {
Some(Value::Array(a)) => a.len(),
_ => 1,
};
for (i, v) in q.iter_mut().enumerate().skip(given) {
*v = j.values.get(i).copied().unwrap_or(*v);
if index.is_none() {
for (i, v) in q.iter_mut().enumerate().skip(given) {
*v = j.values.get(i).copied().unwrap_or(*v);
}
}
joints::clamp(j, &mut q);
// Moved as far toward `q` as contact sets and closed loops allow. Values left out of
// `values` are not driven: in a closed loop they follow.
// Driving moves the joint by hand: the values driven drop their expressions (an expression
// given here is worked out once, as contact or a loop may stop the motion short of it).
for i in 0..given.min(kind.dofs().len()) {
j.exprs.set_value(i, None);
if let Some(index) = index {
j.exprs.set_value(index, None);
} else {
for i in 0..given.min(kind.dofs().len()) {
j.exprs.set_value(i, None);
}
}
let before = super::motion::positions(s);
let r = super::motion::drive_joint(s, id, q, given)?;
let r = if let Some(index) = index { super::motion::drive_joint_index(s, id, q, index)? } else { super::motion::drive_joint(s, id, q, given)? };
let sol = solution(s, &BTreeMap::new(), response_analysis(p));
let mut out = sol_json(&sol, p);
out["joint"] = json!(id);
out["values"] = json!(effective_values(s, id));
if bool_(p, "continuous") == Some(true) {
out["continuous"] = json!(true);
}
out["stopped_by_contact"] = r["stopped_by_contact"].clone();
out["stopped_by_loop"] = r["stopped_by_loop"].clone();
out["moved"] = json!(super::motion::moved(&before, &super::motion::positions(s)).next().is_some());
@@ -1364,12 +1393,12 @@ fn edit(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(f) = bool_(p, "flip") {
j.flip = f;
}
if let Some(x) = bool_(p, "suppressed") {
j.suppressed = x;
}
if let Some(n) = str_(p, "name").map(str::trim).filter(|n| !n.is_empty() && n.len() <= 128) {
j.name = n.to_string();
}
if let Some(x) = bool_(p, "suppressed") {
suppress_joints(s.doc_mut(), &[id], x);
}
// A relation whose type, offset, tilt or flip changes starts again from where its parts are.
let reseat = kind.is_some() || offset.is_some() || angle.is_some() || p.get("flip").is_some();
if reseat {
@@ -1384,6 +1413,25 @@ fn edit(s: &mut Session, p: &Value) -> Result<Value> {
Ok(sol_json(&sol, p))
}
/// Shared mutation for individual joints and timeline folders; the caller resolves once.
pub(super) fn suppress_joints(doc: &mut solidcraft_doc::Document, ids: &[u64], suppressed: bool) {
let ids: std::collections::BTreeSet<_> = ids.iter().copied().collect();
for joint in &mut doc.assembly.joints {
if ids.contains(&joint.id) {
joint.suppressed = suppressed;
}
}
}
pub(super) fn delete_joints(doc: &mut solidcraft_doc::Document, ids: &[u64]) {
let ids: std::collections::BTreeSet<_> = ids.iter().copied().collect();
doc.assembly.joints.retain(|joint| !ids.contains(&joint.id));
doc.assembly.links.retain(|link| !ids.contains(&link.a) && !ids.contains(&link.b));
for group in &mut doc.timeline_groups {
group.joints.retain(|id| !ids.contains(id));
}
}
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "joint.delete";
if let Some(name) = str_(p, "origin") {
@@ -1406,9 +1454,7 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
return Ok(json!({"deleted_link": i}));
}
let id = joint_id(s, p.get("joint"), cmd)?;
let doc = s.doc_mut();
doc.assembly.joints.retain(|j| j.id != id);
doc.assembly.links.retain(|l| l.a != id && l.b != id);
delete_joints(s.doc_mut(), &[id]);
let sol = resolve(s);
let mut out = sol_json(&sol, p);
out["deleted"] = json!(id);
+31
View File
@@ -2208,3 +2208,34 @@ fn a_parameter_change_solves_the_joints_once() {
assert!(super::solve_counts::take().0 > 0);
assert!(near(place(&s, "Bracket", Vec3::new(110.0, 210.0, 0.0)), Vec3::new(180.0, 50.0, 1.0)));
}
#[test]
fn driving_one_cylindrical_coordinate_preserves_the_other_expression_and_reports_continuous() {
let mut s = Session::default();
run(&mut s, "component.create", json!({"name":"Part","grounded":false}));
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10}));
run(&mut s, "component.activate", json!({"component":"root"}));
run(&mut s, "parameters.add", json!({"name":"turn","expression":"30 deg"}));
let joint = run(
&mut s,
"joint.create",
json!({"type":"cylindrical","a":{"occurrence":0,"point":[0,0,0]},"b":{"occurrence":"Part","point":[0,0,0]},"flip":true,"values":["turn",0]}),
)["joint"]
.as_u64()
.unwrap();
let before = s.doc.assembly.joints.iter().find(|j| j.id == joint).unwrap().exprs.value(0).map(str::to_string);
assert!(before.is_some());
let reply = run(&mut s, "joint.drive", json!({"joint":joint,"index":1,"value":12,"continuous":true,"quiet":true}));
assert_eq!(reply["continuous"], true);
let j = s.doc.assembly.joints.iter().find(|j| j.id == joint).unwrap();
assert_eq!(j.exprs.value(0).map(str::to_string), before);
assert!((j.values[0].to_degrees() - 30.).abs() < 1e-7);
assert!((j.values[1] - 12.).abs() < 1e-7);
run(&mut s, "parameters.change", json!({"name":"turn","expression":"40 deg"}));
let j = s.doc.assembly.joints.iter().find(|j| j.id == joint).unwrap();
assert!((j.values[0].to_degrees() - 40.).abs() < 1e-7, "the preserved expression still drives the angle");
assert!((j.values[1] - 12.).abs() < 1e-7);
for bad in [json!(-1), json!(2), json!(1000000), json!("0")] {
assert!(s.execute("joint.drive", &json!({"joint":joint,"index":bad,"value":0})).is_err());
}
}
+5
View File
@@ -461,6 +461,11 @@ pub fn drive_joint(s: &mut Session, id: u64, q: Vec<f64>, given: usize) -> Resul
Ok(drive_with_contact(s, &[(id, q)], &given)?.0)
}
/// Drive exactly one coordinate, letting the other values follow loops and preserving expressions.
pub fn drive_joint_index(s: &mut Session, id: u64, q: Vec<f64>, index: usize) -> Result<Value> {
Ok(drive_with_contact(s, &[(id, q)], &[(id, index)].into_iter().collect())?.0)
}
fn enable_sets(s: &mut Session, p: &Value) -> Result<Value> {
let on = bool_(p, "enabled").unwrap_or(true);
s.doc_mut().assembly.contact_enabled = on;
+131
View File
@@ -1507,3 +1507,134 @@ fn a_pattern_of_another_components_body_follows_its_own_component_when_moved() {
fresh.evaluate(&s.doc);
assert_eq!(boxes(&s.model.state()), boxes(&fresh.state()), "the kept model against a fresh evaluation");
}
#[test]
fn occurrence_align_moves_only_selected_instance_including_90_degree_face_mate() {
let mut s = Session::default();
let c = run(&mut s, "component.create", json!({"name": "Extrusion"}));
let (comp, first) = (c["component"].as_u64().unwrap(), c["occurrence"].as_u64().unwrap());
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 60, "body_name": "Beam"}));
run(&mut s, "component.activate", json!({"component": "root"}));
let second = run(&mut s, "occurrence.copy", json!({"component": comp, "translate": [40, 0, 0]}))["occurrence"].as_u64().unwrap();
// Activate the second instance (the user's exact workflow) and align its bottom face (-Z at
// [45, 5, 0]) onto the first instance's +X side face ([10, 5, 30]): instance 2 turns 90 deg
// to extend along +X, while instance 1 remains untouched.
run(&mut s, "component.activate", json!({"component": comp, "occurrence": second, "path": [second]}));
let features_before = s.doc.features.len();
run(
&mut s,
"occurrence.align",
json!({
"occurrence": second,
"path": [second],
"from_face": [45, 5, 0],
"to_face": [10, 5, 30]
}),
);
assert_eq!(s.doc.features.len(), features_before, "aligning an occurrence must not add a body feature to the shared component");
let st = s.world_state();
let b1 = st.body("Beam").unwrap().mesh().bounds();
assert!((b1.min.x - 0.0).abs() < 1e-5 && (b1.max.x - 10.0).abs() < 1e-5, "{b1:?}");
assert!((b1.min.y - 0.0).abs() < 1e-5 && (b1.max.y - 10.0).abs() < 1e-5, "{b1:?}");
assert!((b1.min.z - 0.0).abs() < 1e-5 && (b1.max.z - 60.0).abs() < 1e-5, "{b1:?}");
let b2 = st.body("Beam (Extrusion:2)").unwrap().mesh().bounds();
assert!((b2.min.x - 10.0).abs() < 1e-5 && (b2.max.x - 70.0).abs() < 1e-5, "{b2:?}");
assert!((b2.min.y - 0.0).abs() < 1e-5 && (b2.max.y - 10.0).abs() < 1e-5, "{b2:?}");
assert!((b2.min.z - 25.0).abs() < 1e-5 && (b2.max.z - 35.0).abs() < 1e-5, "{b2:?}");
// Grounded first part refuses without move_grounded, leaving both placements untouched.
let doc_before = s.doc.clone();
let err = s.execute("occurrence.align", &json!({"occurrence": first, "from": [0, 0, 0], "to": [5, 0, 0]})).unwrap_err().to_string();
assert!(err.contains("grounded"), "{err}");
assert_eq!(s.doc, doc_before);
}
#[test]
fn occurrence_align_supports_snaps_planes_flip_nested_paths_and_pending_capture() {
let mut s = Session::default();
let (_, parent) = created(&mut s, "Frame");
run(&mut s, "occurrence.move", json!({"occurrence": parent, "axis": [0, 0, 1], "angle": "90 deg"}));
let (_, child) = created(&mut s, "Bracket");
run(&mut s, "solid.box", json!({"length": 10, "width": 20, "height": 4, "corner": [5, 5, 10], "body_name": "Plate"}));
run(&mut s, "component.activate", json!({"component": "root"}));
// Align the child under a 90-deg rotated parent onto XY plane along plane normal only (capture: false).
let before = s.world_state().body("Plate").unwrap().mesh().bounds();
run(
&mut s,
"occurrence.align",
json!({
"paths": [[parent, child]],
"from_face": [before.center().x, before.center().y, 10.0],
"to_plane": "XY",
"capture": false
}),
);
let moved = s.world_state().body("Plate").unwrap().mesh().bounds();
assert!((moved.min.z - 0.0).abs() < 1e-5 && (moved.max.z - 4.0).abs() < 1e-5, "{moved:?}");
assert!((moved.center().x - before.center().x).abs() < 1e-5 && (moved.center().y - before.center().y).abs() < 1e-5, "{moved:?}");
// Revert drops the pending alignment; re-align with capture: true and vertex snap to origin.
run(&mut s, "component.revert_position", json!({}));
assert!((s.world_state().body("Plate").unwrap().mesh().bounds().min.z - 10.0).abs() < 1e-5);
run(
&mut s,
"occurrence.align",
json!({
"occurrence": child,
"path": [parent, child],
"from": {"snap": "vertex", "at": [before.min.x, before.min.y, before.min.z]},
"to": "origin"
}),
);
let at_origin = s.world_state().body("Plate").unwrap().mesh().bounds();
assert!(at_origin.min.dist(solidcraft_geom::Vec3::ZERO) < 1e-5, "{at_origin:?}");
// Hostile / invalid parameters never panic.
for bad_params in [
json!({}),
json!({"occurrence": 999999, "from": [0, 0, 0], "to": [1, 0, 0]}),
json!({"paths": [], "from": [0, 0, 0], "to": [1, 0, 0]}),
json!({"occurrence": child, "path": [parent, child], "from": [1e12, 0, 0], "to": [0, 0, 0]}),
json!({"occurrence": child, "path": [parent, child], "from": {"snap": "bogus", "at": [0, 0, 0]}, "to": [0, 0, 0]}),
json!({"occurrence": child, "path": [parent, child], "from_plane": "XY", "from": [0, 0, 0], "to": [0, 0, 1]}),
] {
assert!(s.execute("occurrence.align", &bad_params).is_err(), "{bad_params}");
}
}
#[test]
fn occurrence_align_keeps_every_pick_and_reports_shared_definitions() {
// Frame holds Bracket and is placed twice (A and B). Aligning [A, Bracket] together with B
// must move both picks: B is another placement of Frame, not an ancestor of [A, Bracket].
let mut s = Session::default();
let (frame, a) = created(&mut s, "Frame");
let (_, bracket) = created(&mut s, "Bracket");
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 4, "body_name": "Plate"}));
run(&mut s, "component.activate", json!({"component": "root"}));
let b = run(&mut s, "occurrence.copy", json!({"component": frame, "translate": [0, 50, 0]}))["occurrence"].as_u64().unwrap();
let origin = |s: &Session, path: &[u64]| solidcraft_doc::apply_point(&s.occurrence_placement(path).unwrap().world, solidcraft_geom::Vec3::ZERO);
let (a0, ab0, b0, bb0) = (origin(&s, &[a]), origin(&s, &[a, bracket]), origin(&s, &[b]), origin(&s, &[b, bracket]));
assert!(!s.occurrence_shared(&[a]) && !s.occurrence_shared(&[b]), "top-level placements are their own");
assert!(s.occurrence_shared(&[a, bracket]) && s.occurrence_shared(&[b, bracket]), "Bracket's placement lives in Frame, placed twice");
let r = run(&mut s, "occurrence.align", json!({"paths": [[a, bracket], [b]], "from": [0, 0, 0], "to": [5, 0, 0]}));
assert_eq!(r["paths"], json!([[a, bracket], [b]]), "{r}");
assert_eq!(r["shared"], json!([[a, bracket]]), "{r}");
let shift = solidcraft_geom::Vec3::new(5.0, 0.0, 0.0);
assert!(origin(&s, &[a]).dist(a0) < 1e-9, "A itself stays");
assert!(origin(&s, &[a, bracket]).dist(ab0 + shift) < 1e-9, "the picked Bracket moves");
assert!(origin(&s, &[b]).dist(b0 + shift) < 1e-9, "B moves");
// B's Bracket moves with B and, sharing Frame's definition, with the other pick too.
assert!(origin(&s, &[b, bracket]).dist(bb0 + shift + shift) < 1e-9);
// A top-level-only alignment reports nothing shared.
let r = run(&mut s, "occurrence.align", json!({"paths": [[b]], "from": [0, 0, 0], "to": [1, 0, 0]}));
assert!(r.get("shared").is_none(), "{r}");
}
+1 -1
View File
@@ -1071,7 +1071,7 @@ fn dielectric_preflight_and_start_resolve_refraction_without_broadening_boundari
let status = handle.wait_timeout(std::time::Duration::from_secs(10)).unwrap();
assert!(
matches!(status.progress.phase, solidcraft_render::job::Phase::Cancelled | solidcraft_render::job::Phase::Completed),
"phase {:?}, error {:?}",
"phase {:?}, error {:?}; full status {status:?}",
status.progress.phase,
status.error
);
+6 -6
View File
@@ -14,13 +14,13 @@ use crate::params::{bad, req_vec3, vec3};
use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.line3d", "3D Line", line3d).icon("line").enabled(in_sketch).params(concat!(
CommandSpec::new("sketch.line3d", "3D Line", line3d).icon("line").enabled(in_sketch_3d).params(concat!(
"points: [[x,y,z]…] world points (2 or more): one 3D line per segment, joined end to end (needs 3D Sketch on)",
created_doc!()
)),
CommandSpec::new("sketch.spline3d", "3D Spline", spline3d)
.icon("spline")
.enabled(in_sketch)
.enabled(in_sketch_3d)
.params(concat!("points: [[x,y,z]…] world fit points (2 or more): a smooth 3D curve through them (needs 3D Sketch on)", created_doc!())),
CommandSpec::new("sketch.arc3d", "3D Arc", arc3d)
.icon("arc3")
@@ -31,7 +31,7 @@ pub static COMMANDS: &[CommandSpec] = &[
)),
CommandSpec::new("sketch.point3d", "3D Point", point3d)
.icon("point")
.enabled(in_sketch)
.enabled(in_sketch_3d)
.params(concat!("point: [x,y,z] world point (needs 3D Sketch on)", created_doc!())),
CommandSpec::new("sketch.move3d", "Move 3D Point", move3d)
.icon("sketch_move")
@@ -51,13 +51,13 @@ fn needs_3d(sk: &solidcraft_sketch::Sketch, cmd: &str) -> Result<()> {
if sk.view.three_d { Ok(()) } else { Err(bad(cmd, "turn on 3D Sketch (sketch.options sketch_3d: true) to draw off the sketch plane")) }
}
/// The 3D Arc button starts an interactive tool, so it is only offered while a 3D sketch is
/// being edited; otherwise the tool could only fail on its third click.
/// The 3D Line, Spline, Arc and Point buttons start interactive tools, so they are only offered
/// while a 3D sketch is being edited; otherwise the tools could only fail on their first click.
fn in_sketch_3d(s: &Session) -> std::result::Result<(), String> {
let id = sketch_store::active(s).ok_or("no sketch is being edited (Create Sketch or Edit Sketch first)")?;
match sketch_store::read(&s.doc, id) {
Ok(sk) if sk.view.three_d => Ok(()),
Ok(_) => Err("turn on 3D Sketch (sketch.options sketch_3d: true) to draw 3D arcs".into()),
Ok(_) => Err("turn on 3D Sketch (sketch.options sketch_3d: true) to draw 3D curves".into()),
Err(_) => Err("no sketch is being edited (Create Sketch or Edit Sketch first)".into()),
}
}
@@ -417,3 +417,25 @@ fn arc3d_pipes_along_the_arc() {
let quarter = 20.0 * std::f64::consts::FRAC_PI_2;
assert!((measure.volume - 4.0 * std::f64::consts::PI * quarter).abs() < 0.5, "{}", measure.volume);
}
#[test]
fn line3d_spline3d_point3d_need_a_3d_sketch() {
// Same rule as sketch.arc3d: these buttons start interactive tools, so they are only
// offered while a 3D sketch is being edited.
let ids = ["sketch.line3d", "sketch.spline3d", "sketch.point3d"];
let mut s = Session::default();
for id in ids {
let info = crate::cmd::find_command(id).unwrap().info(&s);
assert!(!info.enabled, "{id} offered with no sketch being edited");
}
run(&mut s, "sketch.create", json!({"plane": "XY"}));
for id in ids {
let info = crate::cmd::find_command(id).unwrap().info(&s);
assert!(!info.enabled, "{id} offered in a plain 2D sketch: {:?}", info.disabled_reason);
}
run(&mut s, "sketch.options", json!({"sketch_3d": true}));
for id in ids {
let info = crate::cmd::find_command(id).unwrap().info(&s);
assert!(info.enabled, "{id} not offered inside a 3D sketch: {:?}", info.disabled_reason);
}
}
@@ -49,6 +49,37 @@ fn redundant_dimensions_are_refused_driven_ones_measure() {
assert_eq!(g["fully_constrained"], true);
}
/// The same constraint between the same two lines, picked again (in either order), adds nothing:
/// the one already there is reported back as redundant, and the sketch keeps one glyph (#5224).
#[test]
fn the_same_constraint_twice_adds_nothing() {
let mut s = new_sketch();
let a = ids(&run(&mut s, "sketch.line", json!({"points": [[1, 1], [10, 1]]}))["curves"])[0].clone();
let b = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 5], [10, 7]]}))["curves"])[0].clone();
let first = run(&mut s, "sketch.constraint.parallel", json!({"a": a, "b": b}));
for (x, y) in [(&a, &b), (&b, &a), (&a, &b)] {
let r = run(&mut s, "sketch.constraint.parallel", json!({"a": x, "b": y}));
assert_eq!(r["redundant"], true, "{r}");
assert_eq!(r["constraint"], first["constraint"], "the one already there: {r}");
}
let e = s.execute("sketch.constraint.parallel", &json!({"a": a, "b": b, "if_redundant": "refuse"})).unwrap_err().to_string();
assert!(e.contains("already determined"), "{e}");
let parallels = sketch(&s).constraints.iter().filter(|c| matches!(c.kind, ConstraintKind::Parallel { .. })).count();
assert_eq!(parallels, 1);
let g = run(&mut s, "sketch.glyphs", json!({}));
assert_eq!(g["glyphs"].as_array().map(Vec::len), Some(1), "{g}");
// The sketch model itself hands back the one already there.
let mut sk = sketch(&s);
let n = sk.constraints.len();
let k = sk.constraints.iter().find(|c| matches!(c.kind, ConstraintKind::Parallel { .. })).unwrap().clone();
let (ia, ib) = match k.kind {
ConstraintKind::Parallel { a, b } => (a, b),
_ => (0, 0),
};
assert_eq!(sk.add_constraint(ConstraintKind::Parallel { a: ib, b: ia }, None).unwrap(), k.id);
assert_eq!(sk.constraints.len(), n);
}
/// `measured` is the dimension on the solved sketch: a trough drawn 99.84 long and dimensioned
/// 100 reports 100, not the length it had before solving.
#[test]
@@ -97,6 +97,26 @@ fn fillet_and_chamfer_corners() {
assert!((area(&s) - (a0 - 8.0)).abs() < 1e-6, "{}", area(&s));
}
/// A chamfer where the two lines have no corner is refused, as a fillet there is: two lines
/// lying on each other made a chamfer line of no length between their cut-back ends (found by a
/// local trim fuzzer), and two lines in line with each other have no corner either.
#[test]
fn a_chamfer_where_two_lines_lie_in_line_is_refused() {
for far in [[0, 10], [0, 30]] {
let mut s = new_sketch();
let a = curves(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [0, 20]], "infer": false})))[0].clone();
let b = curves(&run(&mut s, "sketch.line", json!({"points": [far, [0, 20]], "infer": false, "connect": false})))[0].clone();
run(&mut s, "sketch.constraint.coincident", json!({"a": format!("{a}.end"), "b": format!("{b}.end")}));
let n = sk(&s).curves.len();
for cmd in ["sketch.chamfer.equal_distance", "sketch.fillet"] {
let p = if cmd == "sketch.fillet" { json!({"a": a, "b": b, "radius": 2}) } else { json!({"a": a, "b": b, "distance": 2}) };
let e = s.execute(cmd, &p).unwrap_err().to_string();
assert!(e.contains("parallel"), "{cmd} at {far:?}: {e}");
assert_eq!(sk(&s).curves.len(), n, "{cmd} at {far:?}");
}
}
}
/// The ids of the constraints driven by the model parameter `name`.
fn driven_by(s: &Session, name: &str) -> Vec<String> {
sk(s).constraints.iter().filter(|c| c.param.as_deref() == Some(name)).map(|c| c.id.clone()).collect()
+6
View File
@@ -1789,6 +1789,12 @@ fn chamfer(s: &mut Session, p: &Value, cmd: &str, dists: impl FnOnce(f64) -> Res
let (pc, pa, pb) = (sk.point(c).unwrap_or_default(), sk.point(fa).unwrap_or_default(), sk.point(fb).unwrap_or_default());
let (u, v) = ((pa - pc).normalized().unwrap_or(Vec2::X), (pb - pc).normalized().unwrap_or(Vec2::Y));
let corner_angle = u.cross(v).atan2(u.dot(v)).abs();
// Lines in line with each other, or lying on each other, have no corner to cut, as a
// fillet refuses them: on two lines lying on each other the chamfer was a line of no
// length between their cut-back ends.
if !(2e-6..=std::f64::consts::PI - 2e-9).contains(&corner_angle) {
return Err(bad(cmd, "the lines are parallel"));
}
let (da, db) = dists(corner_angle)?;
let (ta, tb, _, kept) = cut_corner(sk, la, lb, c, fa, fb, da, db, cmd)?;
let l = sk.add_line_pts(ta, tb, None)?;
@@ -2492,6 +2492,26 @@ fn ends_tied_through_a_trimmed_away_end_stay_tied() {
assert!(at("l1.end").dist(at("l2.start")) < 1e-9);
}
/// An end held on a line exactly where that line meets another curve stays tied to it when a
/// trim takes the line away (found by the local trim fuzzer: a trimmed circle ended on a line
/// where a fillet started, and trimming the line left the circle's end loose on the fillet's).
#[test]
fn an_end_held_on_a_trimmed_away_line_at_its_end_stays_tied() {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0], [10, 10]], "infer": false}));
run(&mut s, "sketch.line", json!({"points": [[20, 5], [10, 0]], "infer": false, "connect": false}));
run(&mut s, "sketch.constraint.coincident", json!({"a": "l3.end", "b": "l2"}));
assert!(solidcraft_sketch::loose_ends(&sketch(&s)).untied.is_empty());
let r = run(&mut s, "sketch.trim", json!({"curve": "l2", "at": [10, 5]}));
assert_eq!(r["result"]["deleted"], "l2");
let ends = solidcraft_sketch::loose_ends(&sketch(&s));
assert!(ends.untied.is_empty(), "l1 and l3 still meet at (10, 0): {ends:?}");
run(&mut s, "sketch.drag", json!({"entity": "l1.end", "from": [10, 0], "to": [12, 1]}));
let sk = sketch(&s);
let at = |r: &str| sk.point(sk.resolve_point(r).unwrap()).unwrap();
assert!(at("l1.end").dist(at("l3.end")) < 1e-9);
}
#[test]
fn trim_keeps_the_rest_of_a_dimensioned_line_where_it_was() {
// A 40 x 40 square, its right side dimensioned with the Dimension tool (a line length).
+26 -7
View File
@@ -21,9 +21,11 @@ macro_rules! refs_doc {
}
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.project", "Project", project_cmd).at("SKETCH", "CREATE").icon("project").key("P").enabled(in_sketch).params(concat!(refs_doc!(), created_doc!())),
CommandSpec::new("sketch.project", "Project", project_cmd).at("SKETCH", "CREATE").icon("project").key("P").enabled(in_sketch).params(
concat!(refs_doc!(), "; projection_link?: bool (default true)", created_doc!()),
),
CommandSpec::new("sketch.intersect", "Intersect", intersect_cmd).at("SKETCH", "CREATE").icon("intersect").enabled(in_sketch).params(
concat!("refs: [{body}, {face: [x,y,z], body?}, {edge: [x,y,z], body?}, {sketch, curve}]: section curves (or points) with the sketch plane, linked", created_doc!()),
concat!("refs: [{body}, {face: [x,y,z], body?}, {edge: [x,y,z], body?}, {sketch, curve}]: section curves (or points) with the sketch plane; projection_link?: bool (default true)", created_doc!()),
),
CommandSpec::new("sketch.include_3d", "Include 3D Geometry", include_cmd)
.at("SKETCH", "CREATE")
@@ -307,7 +309,7 @@ fn link_json(sk: &Sketch, link: &str) -> Value {
json!({"link": link, "curves": curves, "points": points})
}
fn run_links(s: &mut Session, p: &Value, cmd: &str, kind: LinkKind) -> Result<Value> {
fn run_links(s: &mut Session, p: &Value, cmd: &str, kind: LinkKind, projection_link: bool) -> Result<Value> {
let refs: Vec<Value> = match p.get("refs").or_else(|| p.get("ref")) {
Some(Value::Array(a)) => a.iter().take(1000).cloned().collect(),
Some(v) => vec![v.clone()],
@@ -327,7 +329,14 @@ fn run_links(s: &mut Session, p: &Value, cmd: &str, kind: LinkKind) -> Result<Va
for src in sources {
let what = src.describe();
match add_link_unless_present(s, doc, id, sk, kind, src, kind == LinkKind::Project) {
Ok(Some(l)) => out.push(link_json(sk, &l)),
Ok(Some(l)) => {
let mut result = link_json(sk, &l);
if !projection_link {
result["link"] = Value::Null;
sk.break_link(&l)?;
}
out.push(result);
}
Ok(None) => present.push(what),
Err(e) => return Err(bad(cmd, format!("{what}: {e}"))),
}
@@ -346,15 +355,25 @@ fn run_links(s: &mut Session, p: &Value, cmd: &str, kind: LinkKind) -> Result<Va
}
fn project_cmd(s: &mut Session, p: &Value) -> Result<Value> {
run_links(s, p, "sketch.project", LinkKind::Project)
let projection_link = match p.get("projection_link") {
None => true,
Some(Value::Bool(value)) => *value,
Some(_) => return Err(bad("sketch.project", "`projection_link` must be a boolean")),
};
run_links(s, p, "sketch.project", LinkKind::Project, projection_link)
}
fn intersect_cmd(s: &mut Session, p: &Value) -> Result<Value> {
run_links(s, p, "sketch.intersect", LinkKind::Intersect)
let projection_link = match p.get("projection_link") {
None => true,
Some(Value::Bool(value)) => *value,
Some(_) => return Err(bad("sketch.intersect", "`projection_link` must be a boolean")),
};
run_links(s, p, "sketch.intersect", LinkKind::Intersect, projection_link)
}
fn include_cmd(s: &mut Session, p: &Value) -> Result<Value> {
run_links(s, p, "sketch.include_3d", LinkKind::Include)
run_links(s, p, "sketch.include_3d", LinkKind::Include, true)
}
/// A constraint's operands may be model geometry (#6348): an `{edge: [x,y,z], body?}` or
@@ -216,6 +216,20 @@ fn project_edges_bodies_sketches_and_origin() {
assert!(s.execute("sketch.project", &json!({"refs": [{"plane": "XY"}]})).is_err(), "parallel plane projects to nothing");
}
#[test]
fn project_whole_body_can_create_unlinked_geometry() {
let mut s = Session::default();
box_part(&mut s);
let sketch = run(&mut s, "sketch.create", json!({"plane":"XY"}))["sketch"].as_u64().unwrap();
let body = s.model.state().bodies[0].name.clone();
let r = run(&mut s, "sketch.project", json!({"refs":[{"body":body}],"projection_link":false}));
assert_eq!(r["links"][0]["link"], Value::Null);
let sk = s.doc.sketch(sketch).unwrap();
assert!(sk.links.is_empty());
assert_eq!(sk.curves.len(), 4);
assert!(s.execute("sketch.project", &json!({"refs":[{"body":body}],"projection_link":"false"})).is_err());
}
#[test]
fn intersect_cuts_a_body_with_the_sketch_plane() {
let mut s = Session::default();
@@ -873,3 +887,199 @@ fn intersect_on_a_top_face_gives_its_outline() {
ends.sort_by(|a, b| a.partial_cmp(b).unwrap());
assert_eq!(ends, vec![[0.0, 0.0, 40.0, 0.0], [0.0, 30.0, 0.0, 0.0], [40.0, 0.0, 40.0, 30.0], [40.0, 30.0, 0.0, 30.0]]);
}
#[test]
fn intersect_whole_body_can_create_unlinked_section_geometry() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20, "body_name": "Post"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": 10}));
let result = run(&mut s, "sketch.intersect", json!({"refs": [{"body": "Post"}], "projection_link": false}));
assert_eq!(result["links"][0]["link"], Value::Null, "{result}");
assert_eq!(result["links"][0]["curves"].as_array().unwrap().len(), 4, "{result}");
let sketch = s.doc.sketch(s.active_sketch.unwrap()).unwrap();
assert!(sketch.links.is_empty(), "projection links were disabled");
assert_eq!(sketch.curves.len(), 4, "the whole body's section remains as normal sketch geometry");
assert!(s.execute("sketch.intersect", &json!({"refs": [{"body": "Post"}], "projection_link": "false"})).is_err());
}
#[test]
fn whole_body_project_preserves_the_arc_of_a_chamfered_round_edge() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20, "body_name": "Plate"}));
run(&mut s, "solid.fillet", json!({"edges": [[40, 0, 10]], "radius": 5}));
let sketch = run(&mut s, "sketch.create", json!({"plane": {"face": [20, 15, 20]}, "project_edges": false}))["sketch"].as_u64().unwrap();
run(&mut s, "sketch.project", json!({"refs": [{"body": "Plate"}]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "timeline.roll_to", json!({"position": 2}));
run(&mut s, "solid.chamfer", json!({"edges": [[40, 15, 20]], "distance": 1}));
run(&mut s, "timeline.roll_to", json!({}));
let result = s.model.result(sketch).unwrap();
let state = s.model.state();
let solved = state.sketch(sketch).unwrap();
let sketch = &solved.sketch;
assert!(result.error.is_none(), "{:?}", result.error);
assert!(result.warning.is_none(), "a rebuilt projection nothing depends on needs no warning: {:?}", result.warning);
assert!((solved.plane.origin.z - 20.).abs() < 1e-6);
assert!(
sketch.curves.iter().any(|curve| matches!(curve.kind, solidcraft_sketch::CurveKind::Arc { .. })),
"the rounded chamfer silhouette should contain an arc, not only line segments: {:?}",
sketch.curves
);
}
#[test]
fn whole_body_project_stays_on_the_body_outline_after_a_late_chamfer() {
use solidcraft_sketch::CurveKind;
let planes: [([i32; 3], &str); 3] = [([20, 15, 20], "top"), ([20, 0, 10], "front"), ([40, 15, 10], "side")];
let chamfers: [[i32; 3]; 4] = [[40, 15, 20], [20, 0, 20], [40, 0, 10], [38, 2, 20]];
for (face, plane) in planes {
for chamfer in chamfers {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20, "body_name": "Plate"}));
run(&mut s, "solid.fillet", json!({"edges": [[40, 0, 10]], "radius": 5}));
let sketch = run(&mut s, "sketch.create", json!({"plane": {"face": face}, "project_edges": false}))["sketch"].as_u64().unwrap();
run(&mut s, "sketch.project", json!({"refs": [{"body": "Plate"}]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "timeline.roll_to", json!({"position": 2}));
if s.execute("solid.chamfer", &json!({"edges": [chamfer], "distance": 1})).is_err() {
continue;
}
run(&mut s, "timeline.roll_to", json!({}));
let state = s.model.state();
let solved = state.sketch(sketch).unwrap();
let sk = &solved.sketch;
let pts: Vec<_> = sk.points.iter().map(|p| p.pos).collect();
let (x0, x1) = pts.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.x), a.1.max(p.x)));
let (y0, y1) = pts.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.y), a.1.max(p.y)));
let mut worst = 0.0_f64;
for curve in &sk.curves {
if let CurveKind::Arc { c, a, b } = curve.kind {
let (c, a, b) = (pts[c], pts[a], pts[b]);
let r = c.dist(a);
let (t0, mut sweep) = ((a - c).angle(), (b - c).angle() - (a - c).angle());
while sweep < 0.0 {
sweep += std::f64::consts::TAU;
}
for i in 0..=16 {
let t = t0 + sweep * f64::from(i) / 16.0;
let q = solidcraft_geom::Vec2::new(c.x + r * t.cos(), c.y + r * t.sin());
worst = worst.max((x0 - q.x).max(q.x - x1).max(y0 - q.y).max(q.y - y1));
}
assert!(sweep < std::f64::consts::PI + 0.01, "{plane} {chamfer:?}: arc sweeps {sweep} rad, not a rounded corner");
}
}
assert!(worst < 0.01, "{plane} {chamfer:?}: an arc leaves the body outline by {worst}");
}
}
}
#[test]
fn whole_body_project_of_a_filleted_plate_keeps_exact_lines_and_arcs() {
use solidcraft_sketch::CurveKind;
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 80, "width": 50, "height": 8, "body_name": "Plate"}));
run(&mut s, "solid.fillet", json!({"edges": [[0, 0, 4], [80, 0, 4], [80, 50, 4], [0, 50, 4]], "radius": 6}));
let sketch = run(&mut s, "sketch.create", json!({"plane": "YZ", "offset": 36, "project_edges": false}))["sketch"].as_u64().unwrap();
run(&mut s, "sketch.project", json!({"refs": [{"body": "Plate"}]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "timeline.roll_to", json!({"position": 2}));
let top = [[40., 0.], [1.7574, 1.7574], [78.2426, 1.7574], [80., 25.], [78.2426, 48.2426], [40., 50.], [1.7574, 48.2426], [0., 25.]];
let edges: Vec<_> = top.iter().map(|p| json!([p[0], p[1], 8])).collect();
run(&mut s, "solid.fillet", json!({"edges": edges, "radius": 2}));
run(&mut s, "timeline.roll_to", json!({}));
let state = s.model.state();
let sk = &state.sketch(sketch).unwrap().sketch;
let pos = |i: usize| sk.points[i].pos;
let mut arcs = Vec::new();
for curve in &sk.curves {
match curve.kind {
CurveKind::Line { a, b } => {
let d = pos(b) - pos(a);
assert!(d.x.abs() < 1e-3 || d.y.abs() < 1e-3, "slanted line {:?} -> {:?}", pos(a), pos(b));
}
CurveKind::Arc { c, a, .. } => arcs.push(pos(c).dist(pos(a))),
_ => {}
}
}
assert_eq!(arcs.len(), 2, "{arcs:?}");
assert!(arcs.iter().all(|r| (r - 2.).abs() < 5e-3), "{arcs:?}");
// Every arc end is exactly the end of a straight edge, and meets it tangentially.
for curve in &sk.curves {
let CurveKind::Arc { c, a, b } = curve.kind else { continue };
for end in [a, b] {
let touching = sk
.curves
.iter()
.filter(|o| matches!(o.kind, CurveKind::Line { a: p, b: q } if pos(p).dist(pos(end)) < 1e-6 || pos(q).dist(pos(end)) < 1e-6))
.count();
assert!(touching >= 1, "arc end {:?} meets no line end", pos(end));
let radial = pos(end) - pos(c);
assert!((radial.len() - 2.).abs() < 5e-3);
}
}
}
#[test]
fn whole_body_intersect_of_a_filleted_plate_keeps_exact_lines_and_arcs() {
use solidcraft_sketch::CurveKind;
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 80, "width": 50, "height": 8, "body_name": "Plate"}));
let sketch = run(&mut s, "sketch.create", json!({"plane": "YZ", "offset": 36, "project_edges": false}))["sketch"].as_u64().unwrap();
run(&mut s, "sketch.intersect", json!({"refs": [{"body": "Plate"}]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "timeline.roll_to", json!({"position": 1}));
let top = [[40., 0.], [80., 25.], [40., 50.], [0., 25.]];
let edges: Vec<_> = top.iter().map(|p| json!([p[0], p[1], 8])).collect();
run(&mut s, "solid.fillet", json!({"edges": edges, "radius": 2}));
run(&mut s, "timeline.roll_to", json!({}));
let result = s.model.result(sketch).unwrap().warning.clone();
assert!(result.is_none(), "{result:?}");
let state = s.model.state();
let sk = &state.sketch(sketch).unwrap().sketch;
let pos = |i: usize| sk.points[i].pos;
let mut arcs = Vec::new();
for curve in &sk.curves {
match curve.kind {
CurveKind::Line { a, b } => {
let d = pos(b) - pos(a);
assert!(d.x.abs() < 1e-3 || d.y.abs() < 1e-3, "slanted line {:?} -> {:?}", pos(a), pos(b));
}
CurveKind::Arc { c, a, .. } => arcs.push(pos(c).dist(pos(a))),
_ => {}
}
}
assert_eq!(sk.curves.len(), 6, "{} curves", sk.curves.len());
assert_eq!(arcs.len(), 2, "{arcs:?}");
assert!(arcs.iter().all(|r| (r - 2.).abs() < 5e-3), "{arcs:?}");
}
#[test]
fn a_rebuilt_projection_keeps_user_constraints_without_a_warning() {
for dimensioned in [false, true] {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20, "body_name": "Plate"}));
run(&mut s, "solid.fillet", json!({"edges": [[40, 0, 10]], "radius": 5}));
let sketch = run(&mut s, "sketch.create", json!({"plane": {"face": [20, 15, 20]}, "project_edges": false}))["sketch"].as_u64().unwrap();
run(&mut s, "sketch.project", json!({"refs": [{"body": "Plate"}]}));
if dimensioned {
// A line of the user's, pinned to the projected corner.
let line = ids(&run(&mut s, "sketch.line", json!({"points": [[5, 5], [10, 5]]}))["curves"]);
run(&mut s, "sketch.constraint.coincident", json!({"a": format!("{}.start", line[0]), "b": "p4"}));
}
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "timeline.roll_to", json!({"position": 2}));
run(&mut s, "solid.chamfer", json!({"edges": [[40, 15, 20]], "distance": 1}));
run(&mut s, "timeline.roll_to", json!({}));
let warning = s.model.result(sketch).unwrap().warning.clone();
let st = s.model.state();
let k = &st.sketch(sketch).unwrap().sketch;
// The user's constraint survives the rebuild, so there is nothing to warn about.
assert_eq!(k.constraints.len(), usize::from(dimensioned));
assert!(warning.is_none(), "{warning:?}");
}
}
@@ -87,3 +87,167 @@ fn an_editor_cannot_take_a_pinned_model_from_another_document() {
assert!(session.scratch().compute_editor_commands(&[], &source, Session::default()).is_err());
assert!(Arc::ptr_eq(&document, &session.doc));
}
fn extrusion_editor() -> (Session, Session, Session, Vec<(String, serde_json::Value)>) {
let mut session = Session::default();
session.execute("solid.box", &json!({"corner":[-10,0,0],"length":2,"width":3,"height":4,"body_name":"Other"})).unwrap();
let sketch = session.execute("sketch.create", &json!({"plane":"XY"})).unwrap()["sketch"].as_u64().unwrap();
session.execute("sketch.rectangle.two_point", &json!({"p0":[0,0],"p1":[30,20]})).unwrap();
session.execute("sketch.finish", &json!({})).unwrap();
let feature = session.execute("solid.extrude", &json!({"distance":10,"operation":"new"})).unwrap()["feature"].as_u64().unwrap();
let original = session.scratch();
// The UI's temporary rollback does not create an undo step.
let undo = std::mem::take(&mut session.undo);
session.execute("timeline.roll_to", &json!({"position":2})).unwrap();
session.undo = undo;
let source = session.scratch();
let commands = vec![(
"timeline.redefine".into(),
json!({"feature":feature,"command":"solid.extrude","params":{"sketch":sketch,"profiles":"all","distance":20,"operation":"new"}}),
)];
(session, source, original, commands)
}
fn assert_editor_volume(session: &mut Session, volume: f64) {
assert_eq!(session.doc.marker, None, "visibility history must not restore the editor's temporary rollback");
let actual = session.execute("inspect.measure", &json!({})).unwrap()["total"]["volume_mm3"].as_f64().unwrap();
assert!((actual - volume).abs() < 1e-6, "expected {volume}, got {actual}");
}
#[test]
fn editor_visibility_changes_allow_apply_and_preserve_separate_undo_steps() {
for params in [
json!({"items":["Other"],"visible":false}),
json!({"items":["XY"],"visible":false}),
json!({"items":["Sketch1"],"visible":false}),
json!({"tree_folders":["c0/bodies"],"visible":false}),
] {
let (mut session, source, original, commands) = extrusion_editor();
let shown = session.visibility.clone();
let history = session.undo.len();
session.execute("browser.visibility", &params).unwrap();
let hidden = session.visibility.clone();
assert_ne!(hidden, shown, "{params}");
assert!(Arc::ptr_eq(&source.doc, &session.doc));
let prepared = session.scratch().compute_editor_commands(&commands, &source, original).unwrap();
session.accept_commands(prepared).unwrap();
assert_eq!(session.undo.len(), history + 2, "hide and edit are separate undo steps");
assert_eq!(session.visibility, hidden);
assert_editor_volume(&mut session, 12024.0);
session.execute("edit.undo", &json!({})).unwrap();
assert_eq!(session.visibility, hidden);
assert_editor_volume(&mut session, 6024.0);
session.execute("edit.undo", &json!({})).unwrap();
assert_eq!(session.visibility, shown);
assert_editor_volume(&mut session, 6024.0);
session.execute("edit.redo", &json!({})).unwrap();
assert_eq!(session.visibility, hidden);
assert_editor_volume(&mut session, 6024.0);
session.execute("edit.redo", &json!({})).unwrap();
assert_eq!(session.visibility, hidden);
assert_editor_volume(&mut session, 12024.0);
}
}
#[test]
fn editor_visibility_changes_allow_cancel_and_keep_undo_and_redo_at_the_original_marker() {
for undo_hide in [false, true] {
let (mut session, source, original, _) = extrusion_editor();
let shown = session.visibility.clone();
session.execute("browser.visibility", &json!({"items":["Other","XY"],"visible":false})).unwrap();
let hidden = session.visibility.clone();
if undo_hide {
session.execute("edit.undo", &json!({})).unwrap();
}
let history = (session.undo.len(), session.redo.len());
let live = session.visibility.clone();
*session.model.cache.lock().unwrap() = Default::default();
let evaluations = session.evaluations;
session.restore_editor(&source, original).unwrap();
assert_eq!(session.evaluations, evaluations, "Cancel reuses the pinned model");
assert_eq!(session.visibility, live);
assert_eq!((session.undo.len(), session.redo.len()), history);
assert_editor_volume(&mut session, 6024.0);
if undo_hide {
session.execute("edit.redo", &json!({})).unwrap();
assert_eq!(session.visibility, hidden);
} else {
session.execute("edit.undo", &json!({})).unwrap();
assert_eq!(session.visibility, shown);
}
assert_editor_volume(&mut session, 6024.0);
}
}
#[test]
fn visibility_changes_while_an_editor_computes_preserve_the_latest_view() {
let (mut session, source, original, commands) = extrusion_editor();
let prepared = session.scratch().compute_editor_commands(&commands, &source, original).unwrap();
session.execute("browser.visibility", &json!({"items":["Other","XY"],"visible":false})).unwrap();
let visibility = session.visibility.clone();
assert!(prepared.is_current(&session));
session.accept_commands(prepared).unwrap();
assert_eq!(session.visibility, visibility);
assert_editor_volume(&mut session, 12024.0);
session.execute("edit.undo", &json!({})).unwrap();
assert_eq!(session.visibility, visibility);
assert_editor_volume(&mut session, 6024.0);
session.execute("edit.undo", &json!({})).unwrap();
assert_editor_volume(&mut session, 6024.0);
}
#[test]
fn visibility_does_not_make_a_stale_editor_current() {
for change in 0..7 {
let (mut session, source, original, commands) = extrusion_editor();
match change {
0 => session.revision += 1,
1 => session.active_sketch = Some(2),
2 => session.active_component = 17,
3 => session.active_occurrence_path = Some(vec![1]),
4 => {
session.execute("solid.box", &json!({"length":1,"width":1,"height":1})).unwrap();
}
5 => {
session.pending_moves.insert(1, solidcraft_doc::IDENTITY);
}
_ => session = Session::default(),
}
session.execute("browser.visibility", &json!({"items":["XY"],"visible":false})).unwrap();
let doc = session.doc.clone();
let visibility = session.visibility.clone();
assert!(session.scratch().compute_editor_commands(&commands, &source, original.scratch()).is_err(), "change {change}");
assert!(session.restore_editor(&source, original).is_err(), "change {change}");
assert!(Arc::ptr_eq(&session.doc, &doc));
assert_eq!(session.visibility, visibility);
}
}
#[test]
fn accepting_commands_keeps_a_joint_shown_while_the_worker_ran() {
let mut session = Session::default();
let part = session.execute("component.create", &json!({"name":"Part","activate":false})).unwrap();
let occurrence = part["occurrence"].as_u64().unwrap();
let joint = session.execute("joint.as_built", &json!({"type":"revolute","a":0,"b":occurrence})).unwrap()["joint"].as_u64().unwrap();
session.execute("browser.visibility", &json!({"joints":[joint],"visible":false})).unwrap();
let prepared = session.scratch().compute_commands(&commands(), None).unwrap();
session.execute("browser.visibility", &json!({"joints":[joint],"visible":true})).unwrap();
session.execute("browser.visibility", &json!({"paths":[[occurrence]],"visible":false})).unwrap();
let visibility = session.visibility.clone();
session.accept_commands(prepared).unwrap();
assert_eq!(session.visibility, visibility, "the worker's old joint hide must not be merged back");
session.execute("edit.undo", &json!({})).unwrap();
assert_eq!(session.visibility, visibility, "undoing geometry keeps both concurrent visibility changes");
}
#[test]
fn cancelling_an_editor_with_visibility_changes_restores_an_existing_history_marker() {
let (mut session, source, mut original, _) = extrusion_editor();
original.execute("timeline.roll_to", &json!({"position":1})).unwrap();
session.execute("browser.visibility", &json!({"items":["XY"],"visible":false})).unwrap();
session.restore_editor(&source, original).unwrap();
assert_eq!(session.doc.marker, Some(1));
session.execute("edit.undo", &json!({})).unwrap();
assert_eq!(session.doc.marker, Some(1));
assert!(session.visibility.hidden_origin.is_empty());
}
+80 -14
View File
@@ -91,15 +91,28 @@ fn parse_port(text: &str) -> Option<u16> {
text.trim().parse::<u16>().ok().filter(|p| *p != 0)
}
/// Remove the discovery file at exit, unless a later app has taken it over (it names another port).
pub fn remove_current_port(port: u16) {
if read_current_port() == Some(port)
&& let Some(path) = current_port_path()
{
let _ = std::fs::remove_file(path);
/// Remove the discovery file (and its process file) at exit, unless a later app has taken it
/// over: it names another port, or another launch now holds this port's token file. Under
/// Flatpak every instance has its own loopback, so two windows can listen on the same port
/// number and write the same file names; the one closing first must leave the other's (#8794).
/// Run it after [`remove_token_file`], which takes this launch's own token file away first.
pub fn remove_current_port(port: u16, token: &str) {
if let (Some(current), Some(file)) = (current_port_path(), token_path(port)) {
remove_discovery(&current, &file, port, token);
}
}
/// [`remove_current_port`] on given files: `current` is the discovery file, `file` the token file
/// of `port`.
fn remove_discovery(current: &Path, file: &Path, port: u16, token: &str) {
let named = std::fs::read_to_string(current).ok().filter(|t| t.len() <= 64).and_then(|t| parse_port(&t));
if named != Some(port) || held_by_another(file, token) {
return;
}
let _ = std::fs::remove_file(current);
let _ = std::fs::remove_file(pid_path(current));
}
/// A token set on purpose with `SOLIDCRAFT_CONTROL_TOKEN` (test harnesses, sandboxed packages
/// whose data folder the client can't see), if it is long enough.
pub fn env_token() -> Option<String> {
@@ -289,10 +302,28 @@ pub fn write_token_file(path: &Path, token: &str) -> Result<()> {
Ok(())
}
/// Remove a token file this launch wrote, and its process file.
pub fn remove_token_file(path: &Path) {
let _ = std::fs::remove_file(path);
let _ = std::fs::remove_file(pid_path(path));
/// Remove a token file this launch wrote, and its process file, while it still holds `token`.
/// Another launch may have written the same file since (a second window on the same port,
/// #8794); its file stays. The process id can't tell the two apart, as every Flatpak instance
/// has the same one, but each launch's token is its own.
pub fn remove_token_file(path: &Path, token: &str) {
if read_token(path).is_some_and(|held| tokens_match(&held, token.trim())) {
let _ = std::fs::remove_file(path);
let _ = std::fs::remove_file(pid_path(path));
}
}
/// The token a token file holds, trimmed; `None` when there is no readable token file.
fn read_token(path: &Path) -> Option<String> {
let mut text = String::new();
std::fs::File::open(path).ok()?.take(MAX_TOKEN_FILE + 1).read_to_string(&mut text).ok()?;
let token = text.trim();
(text.len() as u64 <= MAX_TOKEN_FILE && !token.is_empty()).then(|| token.to_string())
}
/// Whether the token file at `path` holds a token other than `token`: another launch's.
fn held_by_another(path: &Path, token: &str) -> bool {
read_token(path).is_some_and(|held| !tokens_match(&held, token.trim()))
}
/// How long `icacls` may take (#6145). It finishes at once; on some computers the new process
@@ -341,12 +372,12 @@ fn wait_within(mut child: std::process::Child, limit: std::time::Duration) -> Re
}
}
/// Removes the token file when the app ends.
pub struct TokenFile(pub PathBuf);
/// Removes the token file (path, token) when the app ends, while it still holds that token.
pub struct TokenFile(pub PathBuf, pub String);
impl Drop for TokenFile {
fn drop(&mut self) {
remove_token_file(&self.0);
remove_token_file(&self.0, &self.1);
}
}
@@ -490,8 +521,43 @@ mod tests {
assert_eq!(std::fs::read_to_string(&path).unwrap().trim(), "0123456789abcdef0123456789abcdef");
let writer = read_token_file(&path).unwrap().file.unwrap().writer;
assert_eq!(writer.map(|(pid, _)| pid), Some(std::process::id()));
drop(TokenFile(path.clone()));
drop(TokenFile(path.clone(), "0123456789abcdef0123456789abcdef".into()));
assert!(!path.exists() && !pid_path(&path).exists());
let _ = std::fs::remove_dir_all(&dir);
}
/// #8794: under Flatpak two windows can listen on the same port number (each has its own
/// loopback) and write the same files. The one closing first leaves the other's token, process
/// and discovery files; the last one takes them all away, `current.pid` too.
#[test]
fn a_closing_launch_leaves_another_launchs_files() {
let dir = std::env::temp_dir().join(format!("solidcraft-control-two-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let (file, current) = (dir.join("27878.token"), dir.join("current.port"));
let (first, second) = ("a".repeat(64), "b".repeat(64));
// The first window, then the second on the same port, each writing both files.
for token in [&first, &second] {
write_token_file(&file, token).unwrap();
write_token_file(&current, "27878").unwrap();
}
// The first closes: the second's files stay.
remove_token_file(&file, &first);
remove_discovery(&current, &file, 27878, &first);
assert_eq!(read_token(&file).as_deref(), Some(second.as_str()));
assert!(pid_path(&file).exists() && current.exists() && pid_path(&current).exists());
// Another port named in the discovery file is another app's too.
remove_discovery(&current, &file, 27879, &second);
assert!(current.exists());
// The second closes: nothing of either is left.
remove_token_file(&file, &format!(" {second}\n"));
remove_discovery(&current, &file, 27878, &second);
assert!(!file.exists() && !pid_path(&file).exists() && !current.exists() && !pid_path(&current).exists());
// With no token file left (removed by hand, or never written), the discovery file of the
// port still goes; a missing file is no error.
write_token_file(&current, "27878").unwrap();
remove_token_file(&file, &second);
remove_discovery(&current, &file, 27878, &second);
assert!(!current.exists());
let _ = std::fs::remove_dir_all(&dir);
}
}
+36 -3
View File
@@ -294,6 +294,7 @@ pub struct ComputedCommands {
source: Session,
computed: Session,
steps: Vec<ComputedCommand>,
editor_original: Option<Arc<Document>>,
}
struct ComputedCommand {
@@ -360,6 +361,9 @@ pub struct Session {
pub log: Vec<String>,
/// Increments on every document change (views use it to refresh).
pub revision: u64,
/// Revision increments caused only by visibility. Comparing both counters proves that
/// every intervening increment left an editor's document and model untouched.
visibility_revision: u64,
/// This open design's id for programs that follow it (`document.revision`, #6646): kept
/// while it stays open, through saves and renames; another for each design started or opened.
pub document_id: String,
@@ -568,6 +572,7 @@ impl Session {
open_preview: None,
log: Vec::new(),
revision: 1,
visibility_revision: 0,
document_id: cmd::inspect::new_document_id(),
followed: None,
auto_project: true,
@@ -1069,6 +1074,7 @@ impl Session {
/// moves (the visibility caches are keyed on it) and the viewport redraws.
pub(crate) fn refresh_display(&mut self) {
self.revision += 1;
self.visibility_revision += 1;
}
/// Show the sketch a live frame changed, leaving the features after it as they were last
@@ -1409,6 +1415,7 @@ impl Session {
saved: self.saved.clone(),
log: Vec::new(),
revision: self.revision,
visibility_revision: self.visibility_revision,
document_id: self.document_id.clone(),
followed: None,
auto_project: self.auto_project,
@@ -1477,6 +1484,7 @@ impl Session {
marker: Option<Option<usize>>,
original: Option<Session>,
) -> Result<ComputedCommands> {
let editor_original = original.as_ref().map(|session| session.doc.clone());
let mut computed = self.scratch();
let mut steps = Vec::new();
if let Some(marker) = marker {
@@ -1513,15 +1521,26 @@ impl Session {
}
// What the source was primed with stays out of the session that is adopted (#6901).
computed.unprime();
Ok(ComputedCommands { source: self, computed, steps })
Ok(ComputedCommands { source: self, computed, steps, editor_original })
}
/// Adopt evaluated commands only if their document and authoring context are still current.
/// No model evaluation occurs here. Rejected or cancelled work never enters the journal.
pub fn accept_commands(&mut self, prepared: ComputedCommands) -> Result<Vec<(String, Value)>> {
pub fn accept_commands(&mut self, mut prepared: ComputedCommands) -> Result<Vec<(String, Value)>> {
if !prepared.is_current(self) {
return Err(EngineError::Other("the design changed while the feature was computing".into()));
}
if let Some(original) = &prepared.editor_original {
self.restore_editor_history(&prepared.source.doc, original);
}
// Visibility may have changed while the worker ran. Undoing its first command must
// restore that current view, including sketches, folders and joint glyphs.
if let Some(before) = prepared.computed.undo.first_mut() {
before.visibility.clone_from(&self.visibility);
}
// Only joints newly hidden by the computation (e.g. an import's rigid group) join
// the live view. A joint shown meanwhile must not inherit the worker's old hide.
prepared.computed.visibility.hidden_joints.retain(|joint| !prepared.source.visibility.hidden_joints.contains(joint));
for step in &prepared.steps {
crash::accepted_command(self, &step.before, &step.after, &step.id, &step.params, step.changed);
}
@@ -1532,7 +1551,10 @@ impl Session {
fn same_document_context(&self, source: &Session) -> bool {
Arc::ptr_eq(&self.doc, &source.doc)
&& self.revision == source.revision
&& self
.revision
.checked_sub(source.revision)
.is_some_and(|delta| self.visibility_revision.checked_sub(source.visibility_revision) == Some(delta))
&& self.active_sketch == source.active_sketch
&& self.active_component == source.active_component
&& self.active_occurrence_path == source.active_occurrence_path
@@ -1547,6 +1569,7 @@ impl Session {
}
let params = original.doc.marker.map(|position| serde_json::json!({"position":position})).unwrap_or_else(|| serde_json::json!({}));
crash::accepted_command(self, &self.doc, &original.doc, "timeline.roll_to", &params, !Arc::ptr_eq(&self.doc, &original.doc));
self.restore_editor_history(&source.doc, &original.doc);
let undo = std::mem::take(&mut self.undo);
let redo = std::mem::take(&mut self.redo);
let selection = std::mem::take(&mut self.selection);
@@ -1558,6 +1581,16 @@ impl Session {
Ok(())
}
/// Hide/show steps taken in an editor belong to the complete design, not its temporary
/// rollback. Keep each step's visibility and ordering, on both sides of the history.
fn restore_editor_history(&mut self, source: &Arc<Document>, original: &Arc<Document>) {
for snapshot in self.undo.iter_mut().chain(&mut self.redo) {
if Arc::ptr_eq(&snapshot.doc, source) {
snapshot.doc = original.clone();
}
}
}
/// Refresh UI caches when adopting a session or switching tabs, without discarding its
/// already prepared world-space geometry. This does not change the document.
pub fn invalidate_view(&mut self, previous_revision: u64) {
+14
View File
@@ -326,6 +326,20 @@ impl Session {
})
}
/// Is the leaf of `path` placed inside a component that has more than one placement? Its
/// transform lives in that shared definition, so moving it moves it in every placement.
/// Top-level placements and unknown ids are never shared.
pub fn occurrence_shared(&self, path: &[u64]) -> bool {
let Some((_, ancestors)) = path.split_last() else { return false };
ancestors.iter().take(64).any(|id| {
self.doc
.occurrences
.iter()
.find(|o| o.id == *id)
.is_some_and(|a| self.doc.occurrences.iter().filter(|o| o.component == a.component).nth(1).is_some())
})
}
/// Children are definitions shared by every placement of the parent component.
pub fn occurrence_children(&self, path: &[u64]) -> Result<Vec<OccurrencePlacement>> {
let component = if path.is_empty() { 0 } else { self.occurrence_placement(path)?.component };
+292 -3
View File
@@ -1,9 +1,14 @@
//! Contracts and result types for the commands in `cmd/component.rs`.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::borrow::Cow;
use super::types::{IdOrName, present};
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
use serde::de::Error as _;
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::{Value, json};
use super::types::{IdOrName, Point3, PointExpr3, not_blank_pattern, present};
use crate::params;
/// Edit a linked component's design in place. In the app window: open the design it links in a
/// tab of its own, with the rest of the assembly drawn faded around it as the placement `path`
@@ -60,6 +65,290 @@ pub struct FinishEditResult {
pub param_warnings: Vec<String>,
}
/// One exact occurrence placement path (`1..=64` occurrence ids, root-first).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OccurrencePath(pub Vec<u64>);
impl<'de> Deserialize<'de> for OccurrencePath {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let v = Value::deserialize(d)?;
v.as_array()
.filter(|a| !a.is_empty() && a.len() <= 64)
.and_then(|a| a.iter().map(Value::as_u64).collect::<Option<Vec<_>>>())
.map(OccurrencePath)
.ok_or_else(|| D::Error::custom("must list 1..64 occurrence ids"))
}
}
impl JsonSchema for OccurrencePath {
fn schema_name() -> Cow<'static, str> {
"OccurrencePath".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "An exact placement path: 1 to 64 occurrence ids from the root down.",
"type": "array",
"items": {"type": "integer", "minimum": 0, "maximum": u64::MAX},
"minItems": 1,
"maxItems": 64
})
}
}
/// A list of `1..=64` exact occurrence placement paths.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OccurrencePaths(pub Vec<Vec<u64>>);
impl<'de> Deserialize<'de> for OccurrencePaths {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let v = Value::deserialize(d)?;
let items = v.as_array().filter(|a| !a.is_empty() && a.len() <= 64).ok_or_else(|| D::Error::custom("must list 1..64 occurrence paths"))?;
items
.iter()
.map(|item| OccurrencePath::deserialize(item).map(|p| p.0).map_err(D::Error::custom))
.collect::<Result<Vec<_>, _>>()
.map(OccurrencePaths)
}
}
impl JsonSchema for OccurrencePaths {
fn schema_name() -> Cow<'static, str> {
"OccurrencePaths".into()
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
let path = generator.subschema_for::<OccurrencePath>();
json_schema!({
"description": "1 to 64 exact occurrence placement paths.",
"type": "array",
"items": path,
"minItems": 1,
"maxItems": 64
})
}
}
/// One occurrence or a list of `1..=64` occurrences, each by id or name.
#[derive(Clone, Debug, PartialEq)]
pub struct OccurrenceList(pub Vec<String>);
impl<'de> Deserialize<'de> for OccurrenceList {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let fail = || D::Error::custom("must be an occurrence id/name or a list of 1..64 ids/names");
match Value::deserialize(d)? {
v @ (Value::Number(_) | Value::String(_)) => IdOrName::deserialize(v).map(|x| OccurrenceList(vec![x.0])).map_err(|_| fail()),
Value::Array(a) if !a.is_empty() && a.len() <= 64 => {
a.into_iter().map(|v| IdOrName::deserialize(v).ok().map(|x| x.0)).collect::<Option<Vec<_>>>().map(OccurrenceList).ok_or_else(fail)
}
_ => Err(fail()),
}
}
}
impl JsonSchema for OccurrenceList {
fn schema_name() -> Cow<'static, str> {
"OccurrenceList".into()
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
let item = generator.subschema_for::<IdOrName>();
json_schema!({
"description": "An occurrence id/name, or 1 to 64 occurrence ids/names.",
"anyOf": [
item.clone(),
{"type": "array", "items": item, "minItems": 1, "maxItems": 64}
]
})
}
}
const SNAPS: [&str; 4] = ["vertex", "edge_mid", "face_center", "circle_center"];
/// An alignment point: `[x, y, z]`, `"origin"`, or `{snap?: vertex|edge_mid|face_center|circle_center, at: [x, y, z]}`.
#[derive(Clone, Debug, PartialEq)]
pub struct AlignPickParam(pub Value);
impl<'de> Deserialize<'de> for AlignPickParam {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let v = Value::deserialize(d)?;
if v.as_str().is_some_and(|s| s.eq_ignore_ascii_case("origin")) {
return Ok(AlignPickParam(v));
}
if let Some(o) = v.as_object()
&& (o.contains_key("at") || o.contains_key("snap"))
{
if o.get("at").and_then(params::vec3).is_none() {
return Err(D::Error::custom("`.at` must be [x, y, z]"));
}
if let Some(snap) = o.get("snap") {
let Some(s) = snap.as_str() else {
return Err(D::Error::custom("`.snap` must be vertex, edge_mid, face_center or circle_center"));
};
if !SNAPS.contains(&s) {
return Err(D::Error::custom("`.snap` must be vertex, edge_mid, face_center or circle_center"));
}
}
return Ok(AlignPickParam(v));
}
if params::vec3(&v).is_some() {
return Ok(AlignPickParam(v));
}
Err(D::Error::custom("must be [x, y, z], \"origin\", or {snap, at: [x, y, z]}"))
}
}
impl JsonSchema for AlignPickParam {
fn schema_name() -> Cow<'static, str> {
"AlignPick".into()
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
let point = generator.subschema_for::<Point3>();
json_schema!({
"description": "A world point [x, y, z], \"origin\", or a snap point {\"snap\": \"vertex\"|\"edge_mid\"|\"face_center\"|\"circle_center\", \"at\": [x, y, z]}.",
"anyOf": [
point.clone(),
{"type": "string", "pattern": "^[Oo][Rr][Ii][Gg][Ii][Nn]$"},
{
"type": "object",
"properties": {
"snap": {"type": "string", "enum": SNAPS},
"at": point
},
"required": ["at"]
}
]
})
}
}
/// A plane parameter as `features::plane_param` reads it: `"XY" | "XZ" | "YZ"`, a construction
/// plane name or feature id, or `{origin?, normal}` / `{origin?, x_dir, y_dir}`.
#[derive(Clone, Debug, PartialEq)]
pub struct AlignPlaneParam(pub Value);
impl<'de> Deserialize<'de> for AlignPlaneParam {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let v = Value::deserialize(d)?;
match &v {
Value::String(s) if !s.trim().is_empty() && s.len() <= 4096 => Ok(AlignPlaneParam(v)),
Value::Number(n) if n.as_u64().is_some() => Ok(AlignPlaneParam(v)),
Value::Object(o) => {
if let Some(origin) = o.get("origin") {
PointExpr3::deserialize(origin).map_err(|_| D::Error::custom("plane `origin` must be [x, y, z]"))?;
}
let has_xy = o.get("x_dir").and_then(params::vec3).is_some() && o.get("y_dir").and_then(params::vec3).is_some();
let has_n = o.get("normal").and_then(params::vec3).is_some();
if has_xy || has_n { Ok(AlignPlaneParam(v)) } else { Err(D::Error::custom("plane needs x_dir and y_dir, or normal")) }
}
_ => Err(D::Error::custom("must be XY, XZ, YZ, a construction plane name or id, or {origin, normal}")),
}
}
}
impl JsonSchema for AlignPlaneParam {
fn schema_name() -> Cow<'static, str> {
"AlignPlane".into()
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
let point = generator.subschema_for::<Point3>();
let expr_point = generator.subschema_for::<PointExpr3>();
json_schema!({
"description": "XY, XZ, YZ, a construction plane name or feature id, or an explicit plane {origin?, normal} / {origin?, x_dir, y_dir}.",
"anyOf": [
{"type": "string", "minLength": 1, "maxLength": 4096, "pattern": not_blank_pattern()},
{"type": "integer", "minimum": 0, "maximum": u64::MAX},
json!({
"type": "object",
"properties": {"origin": expr_point.clone(), "normal": point.clone()},
"required": ["normal"]
}),
json!({
"type": "object",
"properties": {"origin": expr_point, "x_dir": point.clone(), "y_dir": point},
"required": ["x_dir", "y_dir"]
})
]
})
}
}
/// Align one or more component occurrence placements in world coordinates without modifying
/// shared component geometry.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct OccurrenceAlignParams {
/// A single occurrence to align, by id or name (optionally disambiguated by `path`).
#[serde(default, deserialize_with = "present")]
#[schemars(with = "IdOrName")]
pub occurrence: Option<IdOrName>,
/// One or more occurrences to align together, by id or name.
#[serde(default, deserialize_with = "present")]
#[schemars(with = "OccurrenceList")]
pub occurrences: Option<OccurrenceList>,
/// Exact placement path `[occ_id, ...]` of a single occurrence.
#[serde(default, deserialize_with = "present")]
#[schemars(with = "OccurrencePath")]
pub path: Option<OccurrencePath>,
/// Exact placement paths `[[occ_id, ...], ...]` of one or more occurrences to align together.
#[serde(default, deserialize_with = "present")]
#[schemars(with = "OccurrencePaths")]
pub paths: Option<OccurrencePaths>,
/// Source point (`[x, y, z]`, `"origin"`, or `{snap, at}`).
#[serde(default, deserialize_with = "present")]
#[schemars(with = "AlignPickParam")]
pub from: Option<AlignPickParam>,
/// Point on a planar source face in world coordinates (turns to meet `to_face` or `to_plane`).
#[serde(default, deserialize_with = "present")]
#[schemars(with = "Point3")]
pub from_face: Option<Point3>,
/// Source plane (`XY`, `XZ`, `YZ`, a construction plane, or `{origin, normal}`).
#[serde(default, deserialize_with = "present")]
#[schemars(with = "AlignPlaneParam")]
pub from_plane: Option<AlignPlaneParam>,
/// Destination point (`[x, y, z]`, `"origin"`, or `{snap, at}`).
#[serde(default, deserialize_with = "present")]
#[schemars(with = "AlignPickParam")]
pub to: Option<AlignPickParam>,
/// Point on a planar destination face in world coordinates.
#[serde(default, deserialize_with = "present")]
#[schemars(with = "Point3")]
pub to_face: Option<Point3>,
/// Destination plane (`XY`, `XZ`, `YZ`, a construction plane, or `{origin, normal}`).
#[serde(default, deserialize_with = "present")]
#[schemars(with = "AlignPlaneParam")]
pub to_plane: Option<AlignPlaneParam>,
/// Flip face/plane normal alignment so normals point the same way instead of meeting opposite.
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "bool")]
pub flip: Option<bool>,
/// Capture the aligned position immediately (default: `true`); `false` leaves it pending.
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "bool")]
pub capture: Option<bool>,
/// Allow aligning a grounded occurrence while keeping it grounded (default: `false`).
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "bool")]
pub move_grounded: Option<bool>,
}
/// Result of `occurrence.align`.
#[derive(Debug, Serialize, JsonSchema)]
pub struct OccurrenceAlignResult {
/// Occurrence ids whose placements were updated, in order.
pub occurrences: Vec<u64>,
/// Exact placement paths aligned, in order.
pub paths: Vec<Vec<u64>>,
/// World-space rigid transform applied (column-major 4×4 matrix).
pub world_transform: [[f64; 4]; 4],
/// True when `capture: false` kept the aligned positions as pending moves.
pub pending: bool,
/// Aligned paths that sit inside a component placed more than once: they moved in every
/// placement of it. Left out when there are none.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub shared: Vec<Vec<u64>>,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
#[cfg(test)]
#[path = "component_tests.rs"]
mod tests;
+49 -4
View File
@@ -1,16 +1,34 @@
use std::sync::Arc;
use serde_json::json;
use serde_json::{Value, json};
use super::{EditInPlaceParams, FinishEditParams};
use super::{EditInPlaceParams, FinishEditParams, OccurrenceAlignParams, OccurrenceAlignResult};
use crate::Session;
use crate::schema::input_schema_for;
use crate::schema::tests::assert_keys;
use crate::schema::tests::{assert_keys, conforms};
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
#[test]
fn schemas_and_the_key_check_agree() {
assert_keys(input_schema_for::<EditInPlaceParams>(), &["component", "path"]);
assert_keys(input_schema_for::<FinishEditParams>(), &[]);
assert_keys(
input_schema_for::<OccurrenceAlignParams>(),
&[
"capture",
"flip",
"from",
"from_face",
"from_plane",
"move_grounded",
"occurrence",
"occurrences",
"path",
"paths",
"to",
"to_face",
"to_plane",
],
);
}
#[test]
@@ -32,3 +50,30 @@ fn finishing_an_edit_outside_the_window_changes_nothing() {
assert!(r.as_object().is_some_and(serde_json::Map::is_empty), "{r}");
assert!(Arc::ptr_eq(&doc, &s.doc) && s.undo.len() == undo);
}
#[test]
fn replies_match_the_output_schema() {
let schema = output_schema_for::<OccurrenceAlignResult>();
let schema = schema.as_value();
let required: Vec<&str> = schema.get("required").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).collect();
assert!(!required.contains(&"param_warnings"));
assert!(!required.contains(&"shared"), "`shared` is left out when nothing shared moved");
let mut s = Session::default();
let comp = s.execute("component.create", &json!({"name": "Extrusion"})).unwrap_or_else(|e| panic!("{e}"));
let comp_id = comp["component"].as_u64().unwrap_or(0);
s.execute("solid.box", &json!({"length": 10, "width": 10, "height": 40})).unwrap_or_else(|e| panic!("{e}"));
s.execute("component.activate", &json!({"component": "root"})).unwrap_or_else(|e| panic!("{e}"));
let copy = s.execute("occurrence.copy", &json!({"component": comp_id, "translate": [30, 0, 0]})).unwrap_or_else(|e| panic!("{e}"));
let second = copy["occurrence"].as_u64().unwrap_or(0);
s.param_check = ParamCheck::Warn;
let r1 = s.execute("occurrence.align", &json!({"occurrence": second, "from": [30, 0, 0], "to": [10, 0, 0]})).unwrap_or_else(|e| panic!("{e}"));
assert!(conforms(schema, schema, &r1), "{r1} doesn't match {schema}");
let r2 = s
.execute("occurrence.align", &json!({"paths": [[second]], "from": [10, 0, 0], "to": [20, 0, 0], "unknown_extra": 1}))
.unwrap_or_else(|e| panic!("{e}"));
assert!(r2.get("param_warnings").is_some(), "{r2}");
assert!(conforms(schema, schema, &r2), "{r2} doesn't match {schema}");
}
+158
View File
@@ -0,0 +1,158 @@
//! Inertia tensors (#8095): built from how a body's volume spreads about its centre of mass,
//! taken about another point (parallel axes), and their principal moments and axes.
use crate::{MassProperties, Vec3};
/// A symmetric 3 × 3 tensor, row by row.
pub type Tensor3 = [[f64; 3]; 3];
/// Jacobi sweeps before [`principal`] gives up; a 3 × 3 tensor settles in well under ten.
const MAX_SWEEPS: usize = 50;
const IDENTITY3: Tensor3 = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
impl MassProperties {
/// Second moments about the centroid per unit volume, ∫ (x − c)(x − c)ᵀ dV / V. Unlike the
/// inertia it follows any linear map L of the body as L G Lᵀ (a scale or a mirror too), and
/// [`from_gyration`] turns it into the inertia of a mass spread that way.
pub fn gyration(&self) -> Option<Tensor3> {
// I = tr(Σ) E − Σ, so tr(I) = 2 tr(Σ) and Σ = tr(I)/2 E − I.
let i = &self.inertia;
let half = (i[0][0] + i[1][1] + i[2][2]) / 2.0;
let mut g = [[0.0f64; 3]; 3];
for (row_out, (a, row)) in g.iter_mut().zip(i.iter().enumerate()) {
for (cell, (b, x)) in row_out.iter_mut().zip(row.iter().enumerate()) {
*cell = (if a == b { half } else { 0.0 } - x) / self.volume;
}
}
g.iter().flatten().all(|x| x.is_finite()).then_some(g)
}
}
/// The inertia tensor of `mass` spread as `gyration` about its centre of mass: m (tr(G) E − G).
pub fn from_gyration(gyration: &Tensor3, mass: f64) -> Tensor3 {
let trace = gyration[0][0] + gyration[1][1] + gyration[2][2];
let mut out = [[0.0f64; 3]; 3];
for (i, (row_out, row)) in out.iter_mut().zip(gyration).enumerate() {
for (j, (cell, x)) in row_out.iter_mut().zip(row).enumerate() {
*cell = mass * (if i == j { trace } else { 0.0 } - x);
}
}
out
}
/// `g` of a body moved by the linear map `l` (row by row): L G Lᵀ. A gyration follows any
/// linear map; an inertia tensor follows a rotation this way.
pub fn transformed(g: &Tensor3, l: &Tensor3) -> Tensor3 {
mul(&mul(l, g), &transpose(l))
}
/// `inertia` of `mass` about its centre of mass, taken about a point `d` from that centre
/// instead (parallel-axis theorem): I + m (|d|² E − d dᵀ).
pub fn shifted(inertia: &Tensor3, mass: f64, d: Vec3) -> Tensor3 {
let dv = [d.x, d.y, d.z];
let d2 = d.dot(d);
let mut out = *inertia;
for (i, (row, di)) in out.iter_mut().zip(dv).enumerate() {
for (j, (cell, dj)) in row.iter_mut().zip(dv).enumerate() {
*cell += mass * (if i == j { d2 } else { 0.0 } - di * dj);
}
}
out
}
/// Principal moments of `inertia`, smallest first, and the unit axis each is about: the axes
/// with no products of inertia between them. The first two point along the world axis each is
/// nearest to, and the third is the first × the second (right-handed). Moments within `equal`
/// (a share of the largest) of each other count as equal, as a round body's do though its
/// tessellation tells them apart: their axes are free in the plane (or space) they share, and
/// the world axes nearest it are given. `None` for a tensor with non-finite entries.
pub fn principal(inertia: &Tensor3, equal: f64) -> Option<([f64; 3], [Vec3; 3])> {
if !inertia.iter().flatten().all(|x| x.is_finite()) {
return None;
}
// The symmetric part: products of inertia summed with round-off can differ in the last bit.
let mut a = *inertia;
for (i, row) in a.iter_mut().enumerate() {
for (j, cell) in row.iter_mut().enumerate() {
*cell = 0.5 * (inertia[i][j] + inertia[j][i]);
}
}
let scale = a.iter().flatten().fold(0.0f64, |m, x| m.max(x.abs()));
let mut v = IDENTITY3;
// Cyclic Jacobi: each rotation zeroes one product of inertia; the sweep repeats until all are
// round-off small against the largest entry.
for _ in 0..MAX_SWEEPS {
if a[0][1].abs() + a[0][2].abs() + a[1][2].abs() <= scale * 1e-15 {
break;
}
for (p, q) in [(0, 1), (0, 2), (1, 2)] {
let apq = a[p][q];
if apq == 0.0 {
continue;
}
let theta = (a[q][q] - a[p][p]) / (2.0 * apq);
let t = theta.signum() / (theta.abs() + (theta * theta + 1.0).sqrt());
let c = 1.0 / (t * t + 1.0).sqrt();
let s = t * c;
let mut j = IDENTITY3;
j[p][p] = c;
j[q][q] = c;
j[p][q] = s;
j[q][p] = -s;
a = mul(&mul(&transpose(&j), &a), &j);
v = mul(&v, &j);
}
}
let mut order = [0usize, 1, 2];
order.sort_by(|&x, &y| a[x][x].total_cmp(&a[y][y]));
let moments = order.map(|k| a[k][k]);
let [e0, e1, _] = order.map(|k| Vec3::new(v[0][k], v[1][k], v[2][k]));
let largest = moments.iter().fold(0.0f64, |m, x| m.max(x.abs()));
let same = |x: f64, y: f64| (y - x).abs() <= equal * largest;
let (low, high) = (same(moments[0], moments[1]), same(moments[1], moments[2]));
let (first, second) = if low && high {
(Vec3::X, Vec3::Y)
} else if low {
// The first two share a plane: the world axis nearest it, then its perpendicular there.
let normal = e0.cross(e1).normalized()?;
let first = nearest_in_plane(normal)?;
(first, normal.cross(first).normalized()?)
} else if high {
(e0, nearest_in_plane(e0)?)
} else {
(e0, e1)
};
let (first, second) = (forward(first), forward(second));
let axes = [first, second, first.cross(second)];
(moments.iter().all(|x| x.is_finite()) && axes.iter().all(|e| e.is_finite())).then_some((moments, axes))
}
/// `e` turned to point along the world axis it is nearest to, so the same body gives the same
/// axes every time.
fn forward(e: Vec3) -> Vec3 {
let lead = [e.x, e.y, e.z].into_iter().fold(0.0f64, |m, x| if x.abs() > m.abs() { x } else { m });
if lead < 0.0 { e * -1.0 } else { e }
}
/// The unit direction in the plane through the origin with unit `normal` nearest a world axis
/// (X first when two are as near).
fn nearest_in_plane(normal: Vec3) -> Option<Vec3> {
let along = |w: Vec3| w - normal * normal.dot(w);
let best = [Vec3::X, Vec3::Y, Vec3::Z].into_iter().map(along).fold(Vec3::ZERO, |b, p| if p.len2() > b.len2() * (1.0 + 1e-9) { p } else { b });
best.normalized()
}
fn mul(a: &Tensor3, b: &Tensor3) -> Tensor3 {
let mut out = [[0.0f64; 3]; 3];
for (row_out, row) in out.iter_mut().zip(a) {
for (j, cell) in row_out.iter_mut().enumerate() {
*cell = row.iter().zip(b).map(|(x, b_row)| x * b_row[j]).sum();
}
}
out
}
fn transpose(a: &Tensor3) -> Tensor3 {
[[a[0][0], a[1][0], a[2][0]], [a[0][1], a[1][1], a[2][1]], [a[0][2], a[1][2], a[2][2]]]
}
+1
View File
@@ -25,6 +25,7 @@ pub mod fill;
pub mod fill_general;
mod grid;
pub mod hlr;
pub mod inertia;
mod mesh;
pub mod mesh_distance;
pub mod network;
+93
View File
@@ -530,6 +530,99 @@ fn mass_properties_need_consistently_oriented_faces() {
assert!(m.mass_properties().is_none());
}
/// `m` with every vertex moved by the linear map `l` (row by row).
fn mapped(m: &Mesh, l: &inertia::Tensor3) -> Mesh {
let apply = |p: Vec3| {
let [x, y, z] = l.map(|r| r[0] * p.x + r[1] * p.y + r[2] * p.z);
Vec3::new(x, y, z)
};
Mesh { positions: m.positions.iter().map(|p| apply(*p)).collect(), triangles: m.triangles.clone(), ..Default::default() }
}
fn tensors_close(a: &inertia::Tensor3, b: &inertia::Tensor3, tol: f64) -> bool {
a.iter().flatten().zip(b.iter().flatten()).all(|(x, y)| (x - y).abs() <= tol)
}
#[test]
fn gyration_follows_a_rotation_a_scale_and_a_mirror_of_the_body() {
let base = offset_box(Vec3::new(5.0, -3.0, 2.0), 20.0, 30.0, 40.0);
let props = base.mass_properties().unwrap();
let g = props.gyration().unwrap();
assert!(tensors_close(&inertia::from_gyration(&g, props.volume), &props.inertia, 1e-6 * props.inertia[0][0]));
let (c, s) = (30f64.to_radians().cos(), 30f64.to_radians().sin());
let (cx, sx) = (20f64.to_radians().cos(), 20f64.to_radians().sin());
let turn_z = [[c, -s, 0.0], [s, c, 0.0], [0.0, 0.0, 1.0]];
let turn_x = [[1.0, 0.0, 0.0], [0.0, cx, -sx], [0.0, sx, cx]];
let turn = [0, 1, 2].map(|i| [0, 1, 2].map(|j| (0..3).map(|k| turn_z[i][k] * turn_x[k][j]).sum::<f64>()));
for l in [turn, [[2.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 0.5]], [[-1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]] {
let moved = mapped(&base, &l).mass_properties().expect("a mapped box is still closed");
let want = moved.inertia;
let got = inertia::from_gyration(&inertia::transformed(&g, &l), moved.volume);
assert!(tensors_close(&got, &want, 1e-6 * want[0][0].abs().max(want[2][2].abs())), "{l:?}\n{got:?}\n{want:?}");
}
}
#[test]
fn principal_axes_of_a_turned_box_are_its_edges() {
let (a, b, c) = (20.0, 30.0, 40.0);
let v = a * b * c;
let (cs, sn) = (30f64.to_radians().cos(), 30f64.to_radians().sin());
let turn = [[cs, -sn, 0.0], [sn, cs, 0.0], [0.0, 0.0, 1.0]];
let m = mapped(&offset_box(Vec3::ZERO, a, b, c), &turn).mass_properties().unwrap();
assert!(m.inertia[0][1].abs() > 1.0, "turned about z, the box has a product of inertia: {:?}", m.inertia);
let (moments, axes) = inertia::principal(&m.inertia, 0.0).unwrap();
// Smallest about the longest edge (z), largest about the shortest (the turned x).
let want = [v * (a * a + b * b) / 12.0, v * (a * a + c * c) / 12.0, v * (b * b + c * c) / 12.0];
for (got, want) in moments.iter().zip(want) {
assert!(close(*got, want, 1e-9), "{moments:?} {want}");
}
let edges = [Vec3::Z, Vec3::new(-sn, cs, 0.0), Vec3::new(cs, sn, 0.0)];
for (axis, edge) in axes.iter().zip(edges) {
assert!((axis.dot(edge).abs() - 1.0).abs() < 1e-9, "{axes:?}");
}
assert!((axes[0].cross(axes[1]) - axes[2]).len() < 1e-12, "right-handed: {axes:?}");
// The first two point along the world axis nearest them: +z, then +y.
assert!(axes[0].z > 0.0 && axes[1].y > 0.0, "{axes:?}");
}
#[test]
fn shifted_inertia_about_a_box_corner_matches_the_closed_form() {
let (a, b, c) = (20.0, 30.0, 40.0);
let m = offset_box(Vec3::ZERO, a, b, c).mass_properties().unwrap();
let mass = 3.0 * m.volume;
let at_centre = m.inertia.map(|r| r.map(|x| 3.0 * x));
let corner = inertia::shifted(&at_centre, mass, Vec3::ZERO - m.centroid);
let want = [
[mass * (b * b + c * c) / 3.0, -mass * a * b / 4.0, -mass * a * c / 4.0],
[-mass * a * b / 4.0, mass * (a * a + c * c) / 3.0, -mass * b * c / 4.0],
[-mass * a * c / 4.0, -mass * b * c / 4.0, mass * (a * a + b * b) / 3.0],
];
assert!(tensors_close(&corner, &want, 1e-9 * want[0][0]), "{corner:?}");
}
#[test]
fn principal_axes_of_equal_zero_or_broken_tensors() {
let sphere = [[5.0, 0.0, 0.0], [0.0, 5.0, 0.0], [0.0, 0.0, 5.0]];
assert_eq!(inertia::principal(&sphere, 0.0), Some(([5.0; 3], [Vec3::X, Vec3::Y, Vec3::Z])));
assert_eq!(inertia::principal(&[[0.0; 3]; 3], 0.0), Some(([0.0; 3], [Vec3::X, Vec3::Y, Vec3::Z])));
assert!(inertia::principal(&[[f64::NAN, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], 0.0).is_none());
assert!(inertia::principal(&[[f64::INFINITY, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], 0.0).is_none());
// Products of inertia far larger than the moments still settle.
let (moments, _) = inertia::principal(&[[1.0, 1e12, 0.0], [1e12, 1.0, 0.0], [0.0, 0.0, 1.0]], 0.0).unwrap();
assert!(close(moments[0], -1e12, 1e-9) && close(moments[2], 1e12, 1e-9), "{moments:?}");
// A tessellated pin along z: x and y differ in the fifth digit, with a stray product between
// them. Exactly, their axes are turned at random; counted as equal, they are X and Y.
let pin = [[7.99497, -0.000535, 0.0], [-0.000535, 7.99536, 0.0], [0.0, 0.0, 5.18447]];
let (_, exact) = inertia::principal(&pin, 0.0).unwrap();
assert!(exact[1].x.abs() < 0.99, "{exact:?}");
assert_eq!(inertia::principal(&pin, 1e-3).map(|(_, axes)| axes), Some([Vec3::Z, Vec3::X, Vec3::Y]));
// The same with the round pair smallest (a disc), and all three alike (a cube's tessellation).
let disc = [[3.00001, 0.0002, 0.0], [0.0002, 3.0, 0.0], [0.0, 0.0, 6.0]];
assert_eq!(inertia::principal(&disc, 1e-3).map(|(_, axes)| axes), Some([Vec3::X, Vec3::Y, Vec3::Z]));
let cube = [[2.0, 1e-5, 0.0], [1e-5, 2.0001, 0.0], [0.0, 0.0, 1.9999]];
assert_eq!(inertia::principal(&cube, 1e-3).map(|(_, axes)| axes), Some([Vec3::X, Vec3::Y, Vec3::Z]));
}
mod close_pairs {
use super::*;
+1
View File
@@ -379,6 +379,7 @@ fn frame(scene: &Scene, cam: &Camera, [width, height]: [u32; 2], key: u64, acces
Frame {
product_lights: None,
product: false,
upload_error: None,
key,
slot: Arc::new(Mutex::new(Some(scene_geometry(scene)))),
sketch_key: 0,
+4 -4
View File
@@ -144,10 +144,10 @@ fn context() -> Option<Arc<Context>> {
let (_, device, queue) = match open_headless() {
Ok(v) => v,
Err(e) => {
// The path tracer runs on Vulkan only: where the backends in use leave Vulkan out
// (macOS, or `WGPU_BACKEND=dx12`), it is unavailable by design, not missing.
let vulkan = crate::select::native_backends().contains(wgpu::Backends::VULKAN);
assert!(!vulkan || std::env::var_os("SOLIDCRAFT_REQUIRE_GPU").is_none(), "required GPU unavailable: {e}");
// The path tracer runs on Vulkan or Metal only: where the backends in use leave both out
// (`WGPU_BACKEND=dx12`), it is unavailable by design, not missing.
let available = !crate::select::path_backends().is_empty();
assert!(!available || std::env::var_os("SOLIDCRAFT_REQUIRE_GPU").is_none(), "required GPU unavailable: {e}");
return None;
}
};
+13 -13
View File
@@ -92,24 +92,24 @@ pub fn host() -> Result<ImageHost> {
/// Open an independent job device on the viewport's selected adapter. In particular,
/// software fallback and safe mode must not rediscover a failing hardware driver.
/// An explicit incompatible SOLIDCRAFT_GPU policy leaves this executor unavailable.
/// The path tracer runs on Vulkan only. On Windows it must keep the viewport's backend:
/// opening Vulkan drivers that startup avoided can crash inside native driver code.
/// Elsewhere a viewport on another backend is reopened on the same GPU through Vulkan.
/// Without a usable Vulkan adapter the executor is unavailable; CPU image methods remain usable.
/// The path tracer runs on Vulkan, or Metal on macOS. On Windows it must keep the viewport's
/// backend: opening Vulkan drivers that startup avoided can crash inside native driver code.
/// Elsewhere a viewport on another backend is reopened on the same GPU through one of them.
/// Without a usable adapter the executor is unavailable; CPU image methods remain usable.
pub fn host_on_adapter(adapter: &wgpu::Adapter) -> Result<ImageHost> {
let policy = crate::select::Policy::from_env().map_err(gpu)?;
let info = adapter.get_info();
if !viewport_path_backends(info.backend, cfg!(target_os = "windows")).contains(wgpu::Backends::VULKAN) {
if !viewport_path_backends(info.backend, cfg!(target_os = "windows")).intersects(crate::select::path_backends()) {
return Err(gpu(format!("{VULKAN_ONLY}; the viewport uses {:?}", info.backend)));
}
if !policy.admits(crate::select::tier_of(info.device_type)) {
return Err(gpu(format!("viewport adapter does not satisfy SOLIDCRAFT_GPU={}", policy.name())));
}
if info.backend != wgpu::Backend::Vulkan {
let (vulkan, _) = crate::select::pick_vulkan(policy, |i| i.vendor == info.vendor && i.device == info.device)
if !matches!(info.backend, wgpu::Backend::Vulkan | wgpu::Backend::Metal) {
let (picked, _) = crate::select::pick_path(policy, |i| i.vendor == info.vendor && i.device == info.device)
.ok_or_else(|| gpu(format!("{VULKAN_ONLY}; the viewport uses {:?}", info.backend)))?;
let (device, queue) = open_device(&vulkan)?;
log::info!("gpu-path viewport GPU: {} through Vulkan", info.name);
let (device, queue) = open_device(&picked)?;
log::info!("gpu-path viewport GPU: {} through Vulkan or Metal", info.name);
return ImageHost::with_executor(Arc::new(Executor::new(device, queue)?));
}
let (device, queue) = open_device(adapter)?;
@@ -118,11 +118,11 @@ pub fn host_on_adapter(adapter: &wgpu::Adapter) -> Result<ImageHost> {
}
// D3D12 shader compilation is avoided; the user can select a CPU image method instead.
const VULKAN_ONLY: &str = "the GPU path tracer requires a Vulkan driver; select a CPU image method when Vulkan is unavailable";
const VULKAN_ONLY: &str = "the GPU path tracer requires a Vulkan driver (Metal on macOS); select a CPU image method when none is available";
/// Backends a viewport's path device may open, before any driver is loaded.
fn viewport_path_backends(backend: wgpu::Backend, windows: bool) -> wgpu::Backends {
if windows { wgpu::Backends::from(backend) } else { wgpu::Backends::VULKAN }
if windows { wgpu::Backends::from(backend) } else { crate::select::path_backends() }
}
// Path resources need six storage buffers (geometry, BVH, accumulation, HDRI,
@@ -155,8 +155,8 @@ fn open_device(adapter: &wgpu::Adapter) -> Result<(wgpu::Device, wgpu::Queue)> {
}
fn open_headless() -> Result<(wgpu::Adapter, wgpu::Device, wgpu::Queue)> {
let policy = crate::select::Policy::from_env().map_err(gpu)?;
let (adapter, _) = crate::select::pick_vulkan(policy, |_| true)
.ok_or_else(|| gpu(format!("{VULKAN_ONLY}; no Vulkan adapter satisfies SOLIDCRAFT_GPU={}", policy.name())))?;
let (adapter, _) = crate::select::pick_path(policy, |_| true)
.ok_or_else(|| gpu(format!("{VULKAN_ONLY}; no Vulkan or Metal adapter satisfies SOLIDCRAFT_GPU={}", policy.name())))?;
let (device, queue) = open_device(&adapter)?;
Ok((adapter, device, queue))
}
+15 -17
View File
@@ -6,10 +6,10 @@ fn executor() -> Option<Executor> {
let (adapter, device, queue) = match open_headless() {
Ok(device) => device,
Err(e) => {
// The path tracer runs on Vulkan only: where the backends in use leave Vulkan out
// (macOS, or `WGPU_BACKEND=dx12`), it is unavailable by design, not missing.
let vulkan = crate::select::native_backends().contains(wgpu::Backends::VULKAN);
assert!(!vulkan || std::env::var_os("SOLIDCRAFT_REQUIRE_GPU").is_none(), "required GPU unavailable: {e}");
// The path tracer runs on Vulkan or Metal only: where the backends in use leave both out
// (`WGPU_BACKEND=dx12`), it is unavailable by design, not missing.
let available = !crate::select::path_backends().is_empty();
assert!(!available || std::env::var_os("SOLIDCRAFT_REQUIRE_GPU").is_none(), "required GPU unavailable: {e}");
eprintln!("gpu-path device test skipped: {e}");
return None;
}
@@ -538,16 +538,14 @@ fn render_lights_path_visibility_uniform_keeps_the_shader_contract() {
assert!(request.studio_weights.is_none());
}
/// #3217: the path tracer opens Vulkan adapters only, and none where the platform has no
/// Vulkan backend (macOS).
/// #3217: the path tracer opens Vulkan adapters, or Metal on macOS, never D3D12 or GL.
#[test]
fn path_devices_are_vulkan_only() {
let picked = crate::select::pick_vulkan(crate::select::Policy::Auto, |_| true);
if let Some((adapter, report)) = &picked {
assert_eq!(adapter.get_info().backend, wgpu::Backend::Vulkan);
assert_eq!(report.backend, "Vulkan");
fn path_devices_are_vulkan_or_metal_only() {
let picked = crate::select::pick_path(crate::select::Policy::Auto, |_| true);
if let Some((adapter, _)) = &picked {
assert!(matches!(adapter.get_info().backend, wgpu::Backend::Vulkan | wgpu::Backend::Metal));
}
if cfg!(target_os = "macos") || !crate::select::native_backends().contains(wgpu::Backends::VULKAN) {
if crate::select::path_backends().is_empty() {
assert!(picked.is_none());
}
}
@@ -564,7 +562,7 @@ fn viewport_path_backends_keep_windows_on_its_selected_backend() {
#[test]
fn viewport_path_backends_preserve_other_platforms() {
for backend in [wgpu::Backend::Vulkan, wgpu::Backend::Gl, wgpu::Backend::Metal] {
assert_eq!(viewport_path_backends(backend, false), wgpu::Backends::VULKAN);
assert_eq!(viewport_path_backends(backend, false), crate::select::path_backends());
}
}
@@ -3255,8 +3253,8 @@ fn display_bloom_slices_refuse_invalid_ranges_without_panicking() {
fn cancelled_environment_preparation_unwinds_gpu_error_scopes() {
use solidcraft_render::lighting::environment::EnvironmentSource;
let Ok((_, device, _queue)) = open_headless() else {
let vulkan = crate::select::native_backends().contains(wgpu::Backends::VULKAN);
assert!(!vulkan || std::env::var_os("SOLIDCRAFT_REQUIRE_GPU").is_none(), "required Vulkan device unavailable");
let available = !crate::select::path_backends().is_empty();
assert!(!available || std::env::var_os("SOLIDCRAFT_REQUIRE_GPU").is_none(), "required Vulkan device unavailable");
return;
};
let mut image = request(1);
@@ -3281,8 +3279,8 @@ fn cancelled_environment_preparation_unwinds_gpu_error_scopes() {
#[test]
fn failed_resource_creation_unwinds_gpu_error_scopes() {
let Ok((_, device, _queue)) = open_headless() else {
let vulkan = crate::select::native_backends().contains(wgpu::Backends::VULKAN);
assert!(!vulkan || std::env::var_os("SOLIDCRAFT_REQUIRE_GPU").is_none(), "required Vulkan device unavailable");
let available = !crate::select::path_backends().is_empty();
assert!(!available || std::env::var_os("SOLIDCRAFT_REQUIRE_GPU").is_none(), "required Vulkan device unavailable");
return;
};
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
+14 -8
View File
@@ -201,18 +201,24 @@ pub fn survey(policy: Policy) -> Survey {
Survey { backends: format!("{:?}", native_backends()), adapters: found, chosen, lands_on }
}
/// The Vulkan adapter `policy` picks among those `keep` accepts, with its report; `None` where
/// the native backends (or `WGPU_BACKEND`) leave out Vulkan, as on macOS. The GPU path tracer
/// runs on Vulkan only (#3217): D3D12 was never faster in our measurements and needs minutes to
/// compile its shader at every start, where Vulkan keeps compiled pipelines between runs.
/// The backends the GPU path tracer may use among the native ones: Vulkan, and Metal on macOS
/// (D3D12 is out, #3217: it was never faster in our measurements and needs minutes to compile
/// its shader at every start, where Vulkan keeps compiled pipelines between runs).
pub fn path_backends() -> wgpu::Backends {
native_backends() & (wgpu::Backends::VULKAN | wgpu::Backends::METAL)
}
/// The path-tracer adapter `policy` picks among those `keep` accepts, with its report; `None`
/// where `path_backends` is empty (`WGPU_BACKEND=dx12`) or no adapter qualifies.
#[cfg(not(target_arch = "wasm32"))]
pub fn pick_vulkan(policy: Policy, keep: impl Fn(&wgpu::AdapterInfo) -> bool) -> Option<(wgpu::Adapter, AdapterReport)> {
if !native_backends().contains(wgpu::Backends::VULKAN) {
pub fn pick_path(policy: Policy, keep: impl Fn(&wgpu::AdapterInfo) -> bool) -> Option<(wgpu::Adapter, AdapterReport)> {
let backends = path_backends();
if backends.is_empty() {
return None;
}
let mut desc = wgpu::InstanceDescriptor::new_without_display_handle_from_env();
desc.backends = wgpu::Backends::VULKAN;
let mut all = pollster::block_on(wgpu::Instance::new(desc).enumerate_adapters(wgpu::Backends::VULKAN));
desc.backends = backends;
let mut all = pollster::block_on(wgpu::Instance::new(desc).enumerate_adapters(backends));
all.sort_by_key(|a| std::cmp::Reverse(rank(a.get_info().device_type)));
all.into_iter()
.filter(|a| keep(&a.get_info()))
+69 -16
View File
@@ -12,9 +12,9 @@
use std::sync::{Arc, Mutex};
use solidcraft_geom::Vec3;
use wgpu::util::DeviceExt;
use crate::pbr;
use wgpu::util::DeviceExt;
mod ao;
#[cfg(not(target_arch = "wasm32"))]
@@ -900,6 +900,10 @@ fn line_record(a: [f32; 3], b: [f32; 3], c: PaletteIndex, width: u32) -> [u8; LI
pub type SceneSlot = Arc<Mutex<Option<GpuScene>>>;
/// Where the product preview's geometry upload reports a GPU error it caught (out of memory, a
/// buffer the device refused): the preview shows it instead of drawing.
pub type UploadError = Arc<Mutex<Option<String>>>;
/// A reference image (canvas or decal): a textured quad, depth-tested against the model.
#[derive(Clone)]
pub struct GpuImage {
@@ -959,6 +963,8 @@ pub struct Frame {
pub product_lights: Option<Vec<[f32; 16]>>,
/// Product preview uses its own batches, retaining the Design viewport's uploads.
pub product: bool,
/// Product preview only: receives a GPU error raised by its geometry upload.
pub upload_error: Option<UploadError>,
pub key: u64,
pub slot: SceneSlot,
pub sketch_key: u64,
@@ -1165,13 +1171,11 @@ impl SharedGpu {
if tris.vertices.len() as u64 > limit || index_bytes > limit {
return upload(device, "sc_instanced_unshared", &tris.unshared(), SHARED_SIZE).map(SharedGpu::Unshared);
}
let buffer = |label: &str, contents: &[u8], usage: wgpu::BufferUsages| {
device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some(label), contents, usage })
};
let buffer = |label: &str, contents: &[u8], usage: wgpu::BufferUsages| buffer_init(device, label, contents, usage);
let indices: Vec<u8> = tris.indices.iter().flat_map(|i| i.to_le_bytes()).collect();
Some(SharedGpu::Indexed {
vertices: buffer("sc_instanced_vertices", &tris.vertices, wgpu::BufferUsages::VERTEX),
indices: buffer("sc_instanced_indices", &indices, wgpu::BufferUsages::INDEX),
vertices: buffer("sc_instanced_vertices", &tris.vertices, wgpu::BufferUsages::VERTEX)?,
indices: buffer("sc_instanced_indices", &indices, wgpu::BufferUsages::INDEX)?,
count: u32::try_from(tris.indices.len()).unwrap_or(u32::MAX),
})
}
@@ -1798,11 +1802,10 @@ impl Batches {
},
};
// Until the first cull, every placement is drawn in full.
let instances = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("sc_instances"),
contents: &placements,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
});
let Some(instances) = buffer_init(device, "sc_instances", &placements, wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST)
else {
continue;
};
self.uploads.placements += placements.len() as u64;
let count = if i == 0 { count } else { 0 };
levels.push(LevelDraw { tris: gpu.1.clone(), flat: tris.flat, error, instances, count });
@@ -3077,14 +3080,28 @@ fn upload_with(device: &wgpu::Device, label: &str, bytes: &[u8], stride: usize,
let parts = bytes
.chunks(chunk)
.filter(|part| part.len() >= stride)
.map(|part| {
let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some(label), contents: part, usage });
(buffer, u32::try_from(part.len() / stride).unwrap_or(0))
})
.collect();
.map(|part| Some((buffer_init(device, label, part, usage)?, u32::try_from(part.len() / stride).unwrap_or(0))))
.collect::<Option<Vec<_>>>()?;
Some(Batch { parts, chunk })
}
/// As `create_buffer_init`, without its panic when the device refused the buffer (an allocation
/// error caught by an error scope): `None` instead, so the upload fails.
fn buffer_init(device: &wgpu::Device, label: &str, contents: &[u8], usage: wgpu::BufferUsages) -> Option<wgpu::Buffer> {
let len = contents.len() as u64;
let size = len.div_ceil(wgpu::COPY_BUFFER_ALIGNMENT) * wgpu::COPY_BUFFER_ALIGNMENT;
let buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some(label), size, usage, mapped_at_creation: true });
match buffer.get_mapped_range_mut(..len) {
Ok(mut mapped) => mapped.copy_from_slice(contents),
Err(error) => {
log::error!("geometry upload failed: {error}");
return None;
}
}
buffer.unmap();
Some(buffer)
}
/// Bytes per GPU buffer for vertices of `stride` bytes: within the device's limit and a
/// whole number of triangles and line pairs, so no primitive straddles two buffers.
fn buffer_chunk(stride: usize, max_buffer_size: u64) -> usize {
@@ -3306,7 +3323,25 @@ impl Renderer {
}
res.cached = None;
if cb.product {
// A large product scene can exceed what the device will allocate. Its upload runs under
// its own error scopes, so the failure reaches the preview instead of the device's
// uncaptured-error handler, and nothing half-uploaded is drawn.
let scopes =
[wgpu::ErrorFilter::OutOfMemory, wgpu::ErrorFilter::Internal, wgpu::ErrorFilter::Validation].map(|f| device.push_error_scope(f));
res.product.take(device, queue, &res.uniform, &res.uniform_bgl, cb.key, &cb.slot, None, false);
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(error) = failed {
log::error!("product preview upload failed: {error}");
res.product = Batches { key: Some(cb.key), ..Batches::default() };
if let Some(Ok(mut sink)) = cb.upload_error.as_ref().map(|e| e.lock()) {
*sink = Some(error);
}
}
res.product.sort_glass(queue, cb.back);
res.highlight.take(device, queue, &res.uniform, &res.uniform_bgl, cb.hl_key, &cb.hl_slot, None, false);
queue.write_buffer(&res.uniform, 0, &uniform_bytes(cb, cb.size_px[0], cb.size_px[1], res.linear_out, res.target.reversed_depth()));
@@ -4226,6 +4261,21 @@ mod tests {
assert_eq!(SharedTris::new(&smooth).indices.len(), 6);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn rejected_geometry_allocation_returns_without_panicking() {
let Ok((_, device, _)) = crate::open_headless() else {
eprintln!("skipping: no GPU adapter");
return;
};
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
// Invalid mapping usage provides a deterministic failed allocation without exhausting memory.
let result =
super::upload_with(&device, "rejected geometry", &[0; 288], super::PBR_SIZE, wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::VERTEX);
assert!(result.is_none());
assert!(pollster::block_on(scope.pop()).is_some());
}
/// Only the FirePro under Metal skips GPU evaluation; the same card under another backend
/// and other Metal GPUs keep it.
#[test]
@@ -4311,6 +4361,7 @@ mod tests {
super::Frame {
product_lights: None,
product: false,
upload_error: None,
key: 1,
slot: slot(),
sketch_key: 2,
@@ -4697,6 +4748,7 @@ mod glass_tests {
let frame = Frame {
product_lights: None,
product: false,
upload_error: None,
key: 1,
slot: Arc::new(Mutex::new(Some(scene))),
sketch_key: 0,
@@ -4871,6 +4923,7 @@ mod glass_tests {
Frame {
product_lights: None,
product: false,
upload_error: None,
key: 1,
slot: Arc::new(Mutex::new(Some(scene))),
sketch_key: 0,
+1
View File
@@ -97,6 +97,7 @@ impl Renderer {
let frame = Frame {
product_lights: None,
product: false,
upload_error: None,
key: 1,
slot: slot(Some(model)),
sketch_key: 2,
+9 -1
View File
@@ -54,7 +54,7 @@ pub use threemf::{
pub use urdf::{UrdfOptions, UrdfPackage, urdf};
use solidcraft_doc::{Document, Metadata, ModelState};
use solidcraft_geom::{Mesh, Vec3};
use solidcraft_geom::{MassProperties, Mesh, Vec3};
/// Design file extension: new designs, autosaves and recovery files are written as `.sc`.
pub const DESIGN_EXT: &str = "sc";
@@ -425,6 +425,14 @@ fn weld_eps(b: &solidcraft_kernel::Body) -> f64 {
(b.size() * 1e-6).max(1e-7)
}
/// Unit-density [`MassProperties`] of a body from its welded [`export_mesh`], the mesh URDF links
/// take their inertia from; `None` when that mesh does not close (an open surface).
pub fn mass_properties(b: &solidcraft_kernel::Body) -> Result<Option<MassProperties>> {
let m = export_mesh(b)?;
let (positions, triangles) = weld(&m.positions, &m.triangles, weld_eps(b));
Ok(Mesh { positions, triangles, ..Mesh::default() }.mass_properties())
}
/// Binary STL of several meshes (one solid).
pub fn stl_binary(meshes: &[Mesh], name: &str) -> Vec<u8> {
let n: usize = meshes.iter().map(|m| m.triangles.len()).sum();
+48 -2
View File
@@ -1,7 +1,7 @@
//! Booleans of prisms in 2D: when the target is a prism (two caps square to one direction,
//! walls of planes and cylinders along it) and the tool a prism along the same direction
//! spanning the target's height, the result is the prism over the 2D boolean of their
//! sections. Plates with holes, slots and patterns of them: exact, and immune to the mesh
//! sections. Hole-free intersections may instead use the common height interval. Plates with holes, slots and patterns of them: exact, and immune to the mesh
//! intersection's trouble with walls meeting along lines.
//! Stepped extrusions (case floors and walls) use sections between every cap level, so a
//! circular boss can meet the rim without shifting either operand off its exact plane.
@@ -2238,7 +2238,15 @@ fn simple_prism_indexed(a: &Body, b: &Body, op: BoolOp, [ia, ib]: [&LazyIndex; 2
// Prisms of one section overlapping or touching end to end: one longer prism.
let stacked = op == BoolOp::Union && !same && pb.lo <= pa.hi + tol && pa.lo <= pb.hi + tol;
let ok = match op {
BoolOp::Cut | BoolOp::Intersect => spans,
BoolOp::Cut => spans,
// An intersection keeps only the common height. Unlike a through cut, its
// other operand need not span the target's entire extrusion.
BoolOp::Intersect => {
if pa.hi <= pb.lo || pb.hi <= pa.lo {
return Some(Ok(None));
}
true
}
BoolOp::Union => same || stacked,
};
if !ok {
@@ -2249,6 +2257,11 @@ fn simple_prism_indexed(a: &Body, b: &Body, op: BoolOp, [ia, ib]: [&LazyIndex; 2
if regions.is_empty() {
return Some(Ok(None));
}
// Keep newly clipped perforated sections on the established B-rep route. Re-extruding
// a thin annulus with different circular seam splits can leave both cap meshes empty.
if op == BoolOp::Intersect && !spans && regions.iter().any(|r| !r.holes.is_empty()) {
return None;
}
let (u, x, y) = (area(regions), area(&pa.section.regions), area(&pb.section.regions));
let alike = |v: f64| (u - v).abs() <= tol * (1.0 + u);
// A tool that misses its target (or an intersection that is one operand whole) leaves it
@@ -2269,6 +2282,8 @@ fn simple_prism_indexed(a: &Body, b: &Body, op: BoolOp, [ia, ib]: [&LazyIndex; 2
return Some(Ok(Some(body)));
}
(pa.lo.min(pb.lo), pa.hi.max(pb.hi))
} else if op == BoolOp::Intersect {
(pa.lo.max(pb.lo), pa.hi.min(pb.hi))
} else {
(pa.lo, pa.hi)
};
@@ -2585,6 +2600,37 @@ pub(crate) fn prism_union_between_caps(a: &Body, b: &Body, op: BoolOp) -> Option
mod planar_tests {
use super::*;
#[test]
fn single_prism_intersection_uses_common_height_in_both_orders() {
for k in [1.0, 0.05] {
for (lo, height, expected) in [
(7.5, 10.0, Some((7.5, 17.5))),
(15.0, 10.0, Some((15.0, 22.5))),
(-5.0, 15.0, Some((5.0, 10.0))),
(22.5, 10.0, None),
(25.0, 10.0, None),
] {
let a = crate::cylinder(Vec3::new(12.5, 5.0, 5.0) * k, Vec3::Z, 2.5 * k, 17.5 * k).unwrap();
let b = crate::box_solid(Vec3::new(10.0, 5.0, lo) * k, Vec3::new(22.5, 10.0, lo + height) * k).unwrap();
for (lhs, rhs) in [(&a, &b), (&b, &a)] {
let result = simple_prism_boolean(lhs, rhs, BoolOp::Intersect).expect("the exact prism route applies").unwrap();
if let Some((bottom, top)) = expected {
let body = result.expect("a positive-height half disc is a solid");
assert!(body.validity().is_empty());
assert!(body.history().is_some(), "clipped caps and walls retain their operand history");
let expected = std::f64::consts::PI * (2.5 * k).powi(2) * (top - bottom) * k / 2.0;
let actual = crate::measure(&body).unwrap().volume;
assert!((actual - expected).abs() < expected * 2e-3, "{lo} {height} {k}: {actual} != {expected}");
let bounds = body.tessellate(k * 1e-3).unwrap().bounds();
assert!((bounds.min.z - bottom * k).abs() < k * 1e-7);
assert!((bounds.max.z - top * k).abs() < k * 1e-7);
} else {
assert!(result.is_none(), "touching or separated caps have no intersection volume");
}
}
}
}
}
#[test]
fn shared_arc_line_endpoint_roundoff_does_not_open_a_union() {
let a = Region2 { outer: Loop2::polygon(&[Vec2::new(-2., 0.), Vec2::new(2., 0.), Vec2::new(2., 2.), Vec2::new(-2., 2.)]), holes: vec![] };
let line = |a: (f64, f64), b: (f64, f64)| Seg2::Line { a: Vec2::new(a.0, a.1), b: Vec2::new(b.0, b.1) };
@@ -7,3 +7,4 @@
cc 739d5cbd9e471e0ff65bc2b7c1b9a323c2e769939c73def2e74fb17b0536c1d0 # shrinks to a = [0.0, 2.5, 30.0], sa = [22.5, 5.0, 5.0], b = [22.5, 7.5, 15.0], sb = [5.0, 5.0, 17.5]
cc 0f8ee4d4597db6bdec2ad2cc7e5cfe7d4ebba351e24642118135da34bf489d0c # shrinks to a = Sph { at: [20.0, 10.0, 27.5], r: 3.75 }, b = Box { at: [15.0, 0.0, 22.5], size: [7.5, 20.0, 5.0] }
cc 2adfe5ac6b6916fa22a415348bdd7ece29ad33e8114b94dea86ba8bc39156dee # shrinks to a = Box { at: [15.0, 22.5, 0.0], size: [5.0, 5.0, 17.5] }, b = Box { at: [2.5, 12.5, 0.0], size: [12.5, 10.0, 5.0] }
cc 00931f1657c5f31ab3593ef30eb6f452eddcf3fd0662d280ca2f6a86a409666b # shrinks to a = Cyl { at: [12.5, 5.0, 5.0], axis: 2, r: 2.5, h: 17.5 }, b = Box { at: [10.0, 5.0, 7.5], size: [12.5, 5.0, 10.0] }, k = 0.05
+23
View File
@@ -343,3 +343,26 @@ fn crossing_cylinders_union_moved_far() {
let there = run(&mv(&a), &mv(&b), BoolOp::Union).unwrap();
assert!(close(here, there, s), "union at origin {here:.4}, moved by 1000 gives {there:.4}");
}
/// The box takes half the cylinder's disc across 10 mm of its height. Its overlap is
/// pi * 2.5^2 * 10 / 2, including after a scale that makes the radius 0.125 mm.
#[test]
fn half_cylinder_intersection_scales_with_the_solid() {
let overlap = std::f64::consts::PI * 2.5f64.powi(2) * 10.0 / 2.0;
let cylinder_volume = std::f64::consts::PI * 2.5f64.powi(2) * 17.5;
let box_volume = 12.5 * 5.0 * 10.0;
for k in [1.0, 0.05] {
let a = Prim::Cyl { at: [12.5, 5.0, 5.0].map(|x| x * k), axis: 2, r: 2.5 * k, h: 17.5 * k }.body();
let b = Prim::Box { at: [10.0, 5.0, 7.5].map(|x| x * k), size: [12.5, 5.0, 10.0].map(|x| x * k) }.body();
for (op, expected) in
[(BoolOp::Union, cylinder_volume + box_volume - overlap), (BoolOp::Intersect, overlap), (BoolOp::Cut, cylinder_volume - overlap)]
{
let result = boolean(&a, &b, op)
.unwrap_or_else(|e| panic!("{op:?} at scale {k}: {e}"))
.unwrap_or_else(|| panic!("{op:?} at scale {k} is not empty"));
assert!(result.validity().is_empty(), "{op:?} at scale {k}: {:?}", result.validity());
let actual = measure(&result).unwrap().volume / (k * k * k);
assert!(close(actual, expected, expected), "{op:?} at scale {k}: {actual} != {expected}");
}
}
}
+252
View File
@@ -1,6 +1,258 @@
use super::*;
use solidcraft_geom::{Aabb3, Mesh};
mod interactive {
use super::*;
use crate::job::{RaytraceOptions, interactive::*};
use std::time::Duration;
fn cpu() -> ImageRequest {
ImageRequest { integrator: Integrator::Raytrace, raytrace: Some(RaytraceOptions::default()), ..request() }
}
fn revision(visual_revision: u64) -> VisualRevision {
VisualRevision { scene_generation: 19, visual_revision }
}
fn ms(value: u64) -> Duration {
Duration::from_millis(value)
}
#[test]
fn preview_fits_landscape_portrait_square_and_small_outputs_without_mutating_final_intent() {
for (width, height, expected) in
[(3840, 2160, (1024, 576)), (2160, 3840, (576, 1024)), (4096, 4096, (1024, 1024)), (81, 53, (81, 53)), (u32::MAX, 1, (1024, 1))]
{
let mut source = cpu();
source.width = width;
source.height = height;
source.camera.roll = 0.37;
source.background = [19, 31, 97];
source.studio_weights = Some([0.1, 0.2, 0.9]);
source.raytrace = Some(RaytraceOptions { primary_samples: 16, exposure_ev: -2.0, shadows: false, ..Default::default() });
let before = serde_json::to_value(&source).unwrap();
let preview = cpu_request(&source).unwrap();
assert_eq!((preview.width, preview.height), expected);
assert_eq!(preview.camera, source.camera);
assert_eq!(preview.background, source.background);
assert_eq!(preview.studio_weights, source.studio_weights);
let options = preview.raytrace.unwrap();
assert_eq!(options.progressive, Some(crate::job::CpuProgressiveOptions { samples_per_pixel: 16, seed: 0 }));
assert_eq!(options.primary_samples, 1);
assert_eq!(options.exposure_ev, -2.0);
assert!(!options.shadows);
assert_eq!(serde_json::to_value(source).unwrap(), before);
}
}
#[test]
fn preview_refuses_incompatible_transport_and_invalid_input_without_erasing_it() {
let mut source = cpu();
source.raytrace.as_mut().unwrap().area_lights = Some(vec![crate::job::RectangleLightOptions {
center_mm: [0.0, 0.0, 10.0],
edge_u_mm: [2.0, 0.0, 0.0],
edge_v_mm: [0.0, 2.0, 0.0],
color: [1.0; 3],
intensity: 2.0,
}]);
let before = serde_json::to_value(&source).unwrap();
assert!(cpu_request(&source).is_err());
assert_eq!(serde_json::to_value(&source).unwrap(), before);
for source in [
ImageRequest { transparent_background: true, ..cpu() },
ImageRequest { width: 0, ..cpu() },
request(),
ImageRequest { raytrace: Some(RaytraceOptions { primary_samples: 3, ..Default::default() }), ..cpu() },
] {
assert!(cpu_request(&source).is_err());
}
}
#[test]
fn explicit_enable_settles_edits_and_keeps_only_one_capture_even_without_cad_revision_changes() {
let mut interactive = Interactive::new(9001);
interactive.edit(revision(1), ms(0)).unwrap();
assert_eq!(interactive.state().unwrap(), State::Off);
assert!(interactive.capture(ms(1000)).unwrap().is_none());
interactive.enable(revision(1), ms(1000)).unwrap();
assert!(interactive.capture(ms(1199)).unwrap().is_none());
interactive.edit(revision(2), ms(1199)).unwrap();
assert!(interactive.capture(ms(1398)).unwrap().is_none());
let b = interactive.capture(ms(1399)).unwrap().unwrap();
assert_eq!(b.revision(), revision(2));
assert!(interactive.capture(ms(9999)).unwrap().is_none());
interactive.edit(revision(3), ms(1400)).unwrap();
// Delayed failure of B cannot poison C.
interactive.capture_failed(b, JobError::new(ErrorCode::InvalidScene, "old capture"));
assert_eq!(interactive.state().unwrap(), State::Updating);
let c = interactive.capture(ms(1600)).unwrap().unwrap();
interactive.capture_failed(c, JobError::new(ErrorCode::InvalidScene, "current capture"));
assert_eq!(interactive.state().unwrap(), State::Error);
assert_eq!(interactive.error().unwrap().message, "current capture");
assert!(interactive.capture(ms(9999)).unwrap().is_none(), "no automatic failure retry loop");
interactive.resume(ms(2000)).unwrap();
assert!(interactive.capture(ms(2200)).unwrap().is_some());
}
#[test]
fn pause_edits_final_priority_workspace_departure_and_clock_regression_do_not_start_jobs() {
let mut interactive = Interactive::new(9002);
interactive.enable(revision(1), ms(1000)).unwrap();
assert!(interactive.capture(ms(999)).unwrap().is_none());
interactive.pause().unwrap();
interactive.edit(revision(2), ms(2000)).unwrap();
assert_eq!(interactive.state().unwrap(), State::Paused);
assert!(interactive.capture(ms(3000)).unwrap().is_none());
interactive.set_final_active(true, ms(3000)).unwrap();
interactive.resume(ms(3000)).unwrap();
assert!(interactive.capture(ms(4000)).unwrap().is_none());
interactive.set_final_active(false, ms(4000)).unwrap();
assert!(interactive.capture(ms(4199)).unwrap().is_none());
assert_eq!(interactive.capture(ms(4200)).unwrap().unwrap().revision(), revision(2));
interactive.disable().unwrap();
assert_eq!(interactive.state().unwrap(), State::Off);
assert!(interactive.capture(ms(9999)).unwrap().is_none());
}
#[cfg(all(feature = "raytrace-cpu", not(target_arch = "wasm32")))]
fn host() -> CpuHost {
// Real native admission/worker steps, explicitly pumped like the existing
// checkpoint tests. No test clock sleeps, mock renderer or OS race.
ImageHost(Arc::new(Owner {
executor: None,
shared: CpuHost::shared(MEMORY_LIMIT),
kind: HostKind::NativeWorker,
running: Mutex::new(None),
_worker: None,
}))
}
#[cfg(all(feature = "raytrace-cpu", not(target_arch = "wasm32")))]
#[test]
fn superseded_jobs_are_cancelled_and_foreign_jobs_remain_untouched() {
let host = host();
let mut interactive = Interactive::new(9003);
interactive.enable(revision(1), ms(0)).unwrap();
let a = interactive.capture(ms(200)).unwrap().unwrap();
let first = host.submit(id(9003, 1), 19, empty(), cpu_request(&cpu()).unwrap()).unwrap();
assert!(interactive.attach(a, first.clone()).unwrap());
interactive.edit(revision(2), ms(201)).unwrap();
assert_eq!(first.status().unwrap().progress.phase, Phase::Cancelled);
assert!(interactive.current().is_none());
let b = interactive.capture(ms(401)).unwrap().unwrap();
let late = host.submit(id(9003, 2), 19, empty(), cpu_request(&cpu()).unwrap()).unwrap();
assert!(!interactive.attach(a, late.clone()).unwrap());
assert_eq!(late.status().unwrap().progress.phase, Phase::Cancelled);
// Drain cancelled queue entries before admitting another real job.
while let Some(work) = host.0.shared.queue.lock().unwrap().pop_front() {
drop(work);
}
let foreign = host.submit(id(9999, 1), 19, empty(), cpu_request(&cpu()).unwrap()).unwrap();
assert!(!interactive.attach(b, foreign.clone()).unwrap());
assert_eq!(foreign.status().unwrap().progress.phase, Phase::Queued);
assert_eq!(interactive.state().unwrap(), State::Error);
interactive.disable().unwrap();
drop(interactive);
let work = host.0.shared.queue.lock().unwrap().pop_front().unwrap();
let mut running = Running::begin(work).unwrap();
for _ in 0..100_000 {
if !running.advance(16).unwrap() {
break;
}
}
assert_eq!(foreign.status().unwrap().progress.phase, Phase::Completed, "the foreign job can still finish after our controller is closed");
assert!(foreign.frame().unwrap().is_some());
}
#[cfg(all(feature = "raytrace-cpu", not(target_arch = "wasm32")))]
#[test]
fn progressive_checkpoints_reach_target_and_final_suspension_releases_owned_work() {
let host = host();
let mut interactive = Interactive::new(9004);
interactive.enable(revision(1), ms(0)).unwrap();
let ticket = interactive.capture(ms(200)).unwrap().unwrap();
let job = host.submit(id(9004, 1), 19, empty(), cpu_request(&cpu()).unwrap()).unwrap();
assert!(interactive.attach(ticket, job.clone()).unwrap());
let work = host.0.shared.queue.lock().unwrap().pop_front().unwrap();
let mut running = Running::begin(work).unwrap();
let mut seen = Vec::new();
for _ in 0..100_000 {
let more = running.advance(16).unwrap();
if let Some(preview) = interactive.current().unwrap().preview().unwrap() {
let spp = preview.info.samples_per_pixel;
if seen.last() != Some(&spp) {
seen.push(spp);
}
}
if !more {
break;
}
}
assert_eq!(seen, [1, 2, 4, 8]);
assert_eq!(interactive.state().unwrap(), State::TargetReached);
assert!(job.frame().unwrap().is_some());
assert!(!interactive.attach(ticket, job.clone()).unwrap(), "duplicate acknowledgement cannot restart or cancel the accepted job");
interactive.set_final_active(true, ms(300)).unwrap();
assert_eq!(interactive.state().unwrap(), State::Paused);
assert!(interactive.current().is_none());
assert!(interactive.capture(ms(9999)).unwrap().is_none());
drop(running);
drop(job);
assert_eq!(host.admitted_bytes(), 0);
}
#[cfg(all(feature = "raytrace-cpu", not(target_arch = "wasm32")))]
#[test]
fn same_scene_stale_serial_generation_and_delayed_success_after_failure_are_rejected() {
for mismatch in 0..3 {
let host = host();
let mut interactive = Interactive::new(9005);
interactive.enable(revision(1), ms(0)).unwrap();
let ticket = interactive.capture(ms(200)).unwrap().unwrap();
let mut job_id = ticket.job_id();
let mut scene_generation = ticket.revision().scene_generation;
match mismatch {
0 => job_id.serial += 1,
1 => job_id.generation += 1,
_ => scene_generation += 1,
}
let job = host.submit(job_id, scene_generation, empty(), cpu_request(&cpu()).unwrap()).unwrap();
assert!(!interactive.attach(ticket, job.clone()).unwrap());
assert_eq!(job.status().unwrap().progress.phase, Phase::Cancelled);
assert_eq!(interactive.state().unwrap(), State::Error);
}
let host = host();
let mut interactive = Interactive::new(9006);
interactive.enable(revision(1), ms(0)).unwrap();
let ticket = interactive.capture(ms(200)).unwrap().unwrap();
interactive.capture_failed(ticket, JobError::new(ErrorCode::InvalidScene, "capture failed"));
let late = host.submit(ticket.job_id(), 19, empty(), cpu_request(&cpu()).unwrap()).unwrap();
assert!(!interactive.attach(ticket, late.clone()).unwrap());
assert_eq!(late.status().unwrap().progress.phase, Phase::Cancelled);
assert!(interactive.current().is_none());
}
#[cfg(all(feature = "raytrace-cpu", not(target_arch = "wasm32")))]
#[test]
fn delayed_ticket_cannot_cancel_the_current_handle_but_still_cancels_its_obsolete_job() {
let host = host();
let mut interactive = Interactive::new(9007);
interactive.enable(revision(1), ms(0)).unwrap();
let old = interactive.capture(ms(200)).unwrap().unwrap();
interactive.edit(revision(2), ms(201)).unwrap();
let current = interactive.capture(ms(401)).unwrap().unwrap();
let job = host.submit(current.job_id(), 19, empty(), cpu_request(&cpu()).unwrap()).unwrap();
assert!(interactive.attach(current, job.clone()).unwrap());
assert!(!interactive.attach(old, job.clone()).unwrap());
assert_eq!(job.status().unwrap().progress.phase, Phase::Queued);
let obsolete = host.submit(old.job_id(), 19, empty(), cpu_request(&cpu()).unwrap()).unwrap();
assert!(!interactive.attach(old, obsolete.clone()).unwrap());
assert_eq!(obsolete.status().unwrap().progress.phase, Phase::Cancelled);
assert_eq!(job.status().unwrap().progress.phase, Phase::Queued);
assert_eq!(interactive.current().unwrap().id(), current.job_id());
drop(interactive);
assert_eq!(job.status().unwrap().progress.phase, Phase::Cancelled);
}
}
#[cfg(all(feature = "raytrace-cpu", not(target_arch = "wasm32")))]
mod progressive_checkpoints {
use super::*;
+276
View File
@@ -0,0 +1,276 @@
//! Transient CPU Interactive scheduling. The UI adapter owns visual revisions;
//! this controller owns cancellation, not document settings or final-image export.
use std::time::Duration;
use super::{CpuProgressiveOptions, ErrorCode, ImageRequest, Integrator, JobError, JobHandle, JobId, Phase, Result};
const SETTLE: Duration = Duration::from_millis(200);
const MAX_EDGE: u32 = 1024;
/// Prepare a separate low-resolution request. Never edits the final request,
/// changes renderer, removes optics/lights, or promises geometry admission.
pub fn cpu_request(final_request: &ImageRequest) -> Result<ImageRequest> {
if final_request.integrator != Integrator::Raytrace || final_request.transparent_background {
return Err(JobError::new(ErrorCode::InvalidRequest, "Interactive CPU requires opaque ray tracing"));
}
let (width, height) = (final_request.width, final_request.height);
if width == 0 || height == 0 {
return Err(JobError::new(ErrorCode::InvalidRequest, "Interactive output dimensions must be positive"));
}
if let Some(options) = &final_request.raytrace {
options.validate()?;
}
let edge = width.max(height);
let fit = |side: u32| {
if edge <= MAX_EDGE { side } else { ((u64::from(side) * u64::from(MAX_EDGE) + u64::from(edge) / 2) / u64::from(edge)).max(1) as u32 }
};
let mut request = final_request.clone();
request.width = fit(width);
request.height = fit(height);
let options = request.raytrace.get_or_insert_default();
options.primary_samples = 1;
// These are preview sampling choices only. Incompatible transport remains an
// explicit error, rather than silently removing the user's requested effects.
options.progressive = Some(CpuProgressiveOptions::default());
if let Some(reflections) = &mut options.reflections {
reflections.samples = 1;
}
options.validate()?;
options.budget.validate_image(request.width, request.height, options.progressive)?;
Ok(request)
}
/// Caller increments visual_revision for every camera/draft/appearance/light/HDR
/// or output edit, even if scene_generation did not change. Never use only a CAD
/// revision, a body name, or the selected setup name as the visual identity.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct VisualRevision {
pub scene_generation: u64,
pub visual_revision: u64,
}
/// Issued only after the edit group settles. Private fields prevent fabricating
/// a ticket for a newer request from a delayed capture.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct CaptureTicket {
session: u64,
serial: u64,
revision: VisualRevision,
}
impl CaptureTicket {
pub fn revision(self) -> VisualRevision {
self.revision
}
/// Submit this identity to the preview host, outside the final-job registry.
pub fn job_id(self) -> JobId {
JobId { session: self.session, serial: self.serial, generation: self.serial }
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum State {
Off,
Updating,
Refining,
Paused,
TargetReached,
Error,
}
/// One controller per document session. No serialization, clocks, threads, scene
/// extraction or new renderer here. Supply monotonic elapsed time from the host;
/// this keeps both browser integration and deterministic event tests possible.
pub struct Interactive {
session: u64,
serial: u64,
revision: Option<VisualRevision>,
changed_at: Duration,
enabled: bool,
paused: bool,
final_active: bool,
ticket: Option<CaptureTicket>,
active: Option<JobHandle>,
error: Option<JobError>,
}
impl Interactive {
pub fn new(session: u64) -> Self {
Self {
session,
serial: 0,
revision: None,
changed_at: Duration::ZERO,
enabled: false,
paused: false,
final_active: false,
ticket: None,
active: None,
error: None,
}
}
fn cancel(&mut self) -> Result<()> {
// Invalidate first, including when a poisoned host refuses cancellation.
self.ticket = None;
if let Some(job) = self.active.take() {
job.cancel()?;
}
Ok(())
}
/// Explicit user activation. Merely entering Render must not call this.
pub fn enable(&mut self, revision: VisualRevision, now: Duration) -> Result<()> {
self.cancel()?;
self.enabled = true;
self.paused = false;
self.error = None;
self.revision = Some(revision);
self.changed_at = now;
Ok(())
}
/// Workspace/document departure uses disable; manually started final jobs
/// are deliberately outside this controller and are never cancelled here.
pub fn disable(&mut self) -> Result<()> {
self.enabled = false;
self.paused = false;
self.error = None;
self.cancel()
}
pub fn edit(&mut self, revision: VisualRevision, now: Duration) -> Result<()> {
if self.revision != Some(revision) {
self.revision = Some(revision);
self.changed_at = now;
self.error = None;
self.cancel()?;
}
Ok(())
}
/// A view may retain an identified immutable PNG before pausing, but must
/// label it paused/stale. It is never a current or exportable final image.
pub fn pause(&mut self) -> Result<()> {
self.paused = true;
self.cancel()
}
pub fn resume(&mut self, now: Duration) -> Result<()> {
self.cancel()?;
self.paused = false;
self.error = None;
self.changed_at = now;
Ok(())
}
pub fn set_final_active(&mut self, active: bool, now: Duration) -> Result<()> {
if self.final_active != active {
self.final_active = active;
self.changed_at = now;
self.cancel()?;
}
Ok(())
}
/// At most one unacknowledged capture; no queue of obsolete requests.
pub fn capture(&mut self, now: Duration) -> Result<Option<CaptureTicket>> {
if !self.enabled
|| self.paused
|| self.final_active
|| self.ticket.is_some()
|| self.error.is_some()
|| now.checked_sub(self.changed_at).is_none_or(|elapsed| elapsed < SETTLE)
{
return Ok(None);
}
let Some(revision) = self.revision else { return Ok(None) };
self.serial = self.serial.checked_add(1).ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "Interactive capture identity exhausted"))?;
let ticket = CaptureTicket { session: self.session, serial: self.serial, revision };
self.ticket = Some(ticket);
Ok(Some(ticket))
}
/// Attach an admitted preview-only job after immutable capture. Delayed jobs
/// are cancelled, including a previous ticket for the same visual revision.
/// Use a separate engine job session/registry from final-image export.
pub fn attach(&mut self, ticket: CaptureTicket, job: JobHandle) -> Result<bool> {
// A misrouted callback must never cancel another document's work or a
// manual final job. Only our dedicated preview session is ours to stop.
if job.id().session != self.session {
self.capture_failed(ticket, JobError::new(ErrorCode::WrongOwner, "Interactive job belongs to another session"));
return Ok(false);
}
if self.ticket != Some(ticket) || ticket.session != self.session {
if self.active.as_ref().is_none_or(|active| active.id() != job.id()) {
job.cancel()?;
}
return Ok(false);
}
let status = job.status()?;
if status.job != ticket.job_id() || status.scene_generation != ticket.revision.scene_generation {
job.cancel()?;
self.capture_failed(ticket, JobError::new(ErrorCode::WrongOwner, "Interactive job capture identity mismatch"));
return Ok(false);
}
if status.request.integrator != Integrator::Raytrace || status.request.raytrace.as_ref().is_none_or(|options| options.progressive.is_none()) {
job.cancel()?;
self.capture_failed(ticket, JobError::new(ErrorCode::InvalidRequest, "Interactive requires a CPU progressive job"));
return Ok(false);
}
// A ticket is acknowledged exactly once, even if the same capture is
// delivered twice while an earlier attached job is still running.
if self.active.is_some() {
if self.active.as_ref().is_none_or(|active| active.id() != job.id()) {
job.cancel()?;
}
return Ok(false);
}
self.active = Some(job);
Ok(true)
}
pub fn capture_failed(&mut self, ticket: CaptureTicket, error: JobError) {
if self.ticket == Some(ticket) && self.active.is_none() {
self.ticket = None;
self.error = Some(error);
}
}
/// Access only the current capture. The adapter reads checkpoint/final bytes
/// through this handle, displaying both as Interactive/provisional content.
pub fn current(&self) -> Option<&JobHandle> {
self.active.as_ref()
}
pub fn error(&self) -> Option<&JobError> {
self.error.as_ref()
}
pub fn state(&self) -> Result<State> {
if !self.enabled {
return Ok(State::Off);
}
if self.paused || self.final_active {
return Ok(State::Paused);
}
if self.error.is_some() {
return Ok(State::Error);
}
if let Some(job) = &self.active {
return Ok(match job.status()?.progress.phase {
Phase::Completed => State::TargetReached,
Phase::Cancelled | Phase::Failed => State::Error,
_ => State::Refining,
});
}
Ok(State::Updating)
}
}
impl Drop for Interactive {
fn drop(&mut self) {
let _ = self.cancel();
}
}
+1
View File
@@ -19,6 +19,7 @@ pub(crate) mod encoding;
pub mod finishing;
mod host;
pub(crate) mod image;
pub mod interactive;
pub mod led_strip;
mod lens;
#[cfg(test)]
+69 -1
View File
@@ -963,15 +963,48 @@ impl Sketch {
}
pub fn add_constraint(&mut self, kind: ConstraintKind, param: Option<String>) -> Result<String> {
self.validate(&kind)?;
if let Some(same) = self.same_constraint(&kind) {
return Ok(same.to_string());
}
if self.constraints.len() >= MAX_CONSTRAINTS {
return Err(SketchError::TooLarge);
}
self.validate(&kind)?;
let id = self.fresh("k")?;
self.constraints.push(Constraint { id: id.clone(), kind, param, driven: false, text: None });
Ok(id)
}
/// The id of a geometric (not dimensional) constraint that already says what `kind` says: the
/// same kind on the same entities, in either order where the order doesn't matter.
pub fn same_constraint(&self, kind: &ConstraintKind) -> Option<&str> {
use ConstraintKind::*;
let pair = |x: usize, y: usize| if x <= y { (x, y) } else { (y, x) };
let key = |k: &ConstraintKind| -> Option<(u8, usize, usize, usize)> {
Some(match *k {
Coincident { p, q } => (0, pair(p, q).0, pair(p, q).1, 0),
PointOnCurve { p, c } => (1, p, c, 0),
Horizontal { l } => (2, l, 0, 0),
Vertical { l } => (3, l, 0, 0),
HorizontalPoints { p, q } => (4, pair(p, q).0, pair(p, q).1, 0),
VerticalPoints { p, q } => (5, pair(p, q).0, pair(p, q).1, 0),
Parallel { a, b } => (6, pair(a, b).0, pair(a, b).1, 0),
Perpendicular { a, b } => (7, pair(a, b).0, pair(a, b).1, 0),
Collinear { a, b } => (8, pair(a, b).0, pair(a, b).1, 0),
Tangent { a, b } => (9, pair(a, b).0, pair(a, b).1, 0),
Equal { a, b } => (10, pair(a, b).0, pair(a, b).1, 0),
Concentric { a, b } => (11, pair(a, b).0, pair(a, b).1, 0),
Midpoint { p, l } => (12, p, l, 0),
Symmetric { p, q, l } => (13, pair(p, q).0, pair(p, q).1, l),
Fix { p } => (14, p, 0, 0),
Smooth { a, b } => (15, pair(a, b).0, pair(a, b).1, 0),
_ => return None,
})
};
let want = key(kind)?;
self.constraints.iter().find(|c| key(&c.kind) == Some(want)).map(|c| c.id.as_str())
}
/// Check indices and entity kinds of a constraint.
pub fn validate(&self, k: &ConstraintKind) -> Result<()> {
use ConstraintKind::*;
@@ -1599,7 +1632,12 @@ impl Sketch {
owned.extend(c.kind.point_ids());
}
}
let on_ends = self.points_on_ends_of(which);
self.drop_curves(which);
for (p, q) in on_ends {
// Only a full sketch refuses one more; the point then comes loose as before.
let _ = self.add_constraint(ConstraintKind::Coincident { p, q }, None);
}
let mut orphans: Vec<usize> = owned.into_iter().filter(|p| *p != 0 && !self.point_used_by_curve(*p)).collect();
orphans.sort_unstable();
orphans.dedup();
@@ -1609,6 +1647,36 @@ impl Sketch {
self.prune_patterns();
}
/// Points held on one of the curves `which` exactly at one of its ends, and that end: a point
/// on a curve at its end meets whatever else ends there, so when the curve goes the two are
/// tied instead of coming apart (a trimmed circle ending on a line where a fillet starts kept
/// its end there only through the line). Pairs already in one coincident group are left out.
fn points_on_ends_of(&self, which: &[usize]) -> Vec<(usize, usize)> {
let mut groups: Vec<usize> = (0..self.points.len()).collect();
for c in &self.constraints {
if let ConstraintKind::Coincident { p, q } = c.kind
&& p < groups.len()
&& q < groups.len()
{
union(&mut groups, p, q);
}
}
let mut pairs = Vec::new();
for k in &self.constraints {
let ConstraintKind::PointOnCurve { p, c } = k.kind else { continue };
let Some((a, b)) = which.contains(&c).then(|| self.curves.get(c).and_then(|k| k.kind.ends())).flatten() else { continue };
let Some(at) = self.point(p) else { continue };
for end in [a, b] {
let Some(e) = self.point(end) else { continue };
if end != p && at.dist(e) <= crate::END_TOL && find(&mut groups, p) != find(&mut groups, end) {
union(&mut groups, p, end);
pairs.push((p, end));
}
}
}
pairs
}
/// Points held together only through points about to go stay together: two rectangles'
/// corners made coincident with one arc end are tied to each other when a trim takes that
/// end away, instead of coming apart where they still meet.
+3 -1
View File
@@ -608,9 +608,11 @@ pub fn find_drawn_profiles(sk: &Sketch) -> Vec<Profile> {
if sk.links.iter().all(|l| l.kind == crate::LinkKind::Text) {
return find_profiles(sk);
}
let intersect_links: std::collections::HashSet<&str> =
sk.links.iter().filter(|link| link.kind == crate::LinkKind::Intersect).map(|link| link.id.as_str()).collect();
let mut d = sk.clone();
for (ci, c) in d.curves.iter_mut().enumerate() {
if c.link.is_some() && !sk.is_text_curve(ci) {
if c.link.as_ref().is_some_and(|link| !intersect_links.contains(link.as_str())) && !sk.is_text_curve(ci) {
c.construction = true;
}
}
+8 -6
View File
@@ -831,17 +831,19 @@ fn text_beside_other_curves_is_drawn_geometry() {
}
assert_areas(&sorted_areas(&find_drawn_profiles(&sk)), vec![13.5557, 600.0]);
// Projected reference geometry still doesn't count as drawn.
let square = |s: f64| {
let square = |x: f64, s: f64| {
vec![
LinkGeom::Line(v(100.0, 0.0), v(100.0 + s, 0.0)),
LinkGeom::Line(v(100.0 + s, 0.0), v(100.0 + s, s)),
LinkGeom::Line(v(100.0 + s, s), v(100.0, s)),
LinkGeom::Line(v(100.0, s), v(100.0, 0.0)),
LinkGeom::Line(v(x, 0.0), v(x + s, 0.0)),
LinkGeom::Line(v(x + s, 0.0), v(x + s, s)),
LinkGeom::Line(v(x + s, s), v(x, s)),
LinkGeom::Line(v(x, s), v(x, 0.0)),
]
};
sk.add_link(LinkKind::Project, LinkSource::Origin, &square(5.0)).unwrap();
sk.add_link(LinkKind::Project, LinkSource::Origin, &square(100.0, 5.0)).unwrap();
assert_eq!(find_profiles(&sk).len(), 3);
assert_areas(&sorted_areas(&find_drawn_profiles(&sk)), vec![13.5557, 600.0]);
sk.add_link(LinkKind::Intersect, LinkSource::Origin, &square(110.0, 7.0)).unwrap();
assert_areas(&sorted_areas(&find_drawn_profiles(&sk)), vec![13.5557, 49.0, 600.0]);
}
#[test]

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