Compare commits

..

105 Commits

Author SHA1 Message Date
bherbruck 5b0eb08473 Release 0.27.0 2026-10-29 19:53:24 -04:00
bherbruck 3e62553d9c Release 0.27.0 (#9106)
Fold the pending design format changes (sketch-equation-curves, timeline-joint-folders) into format 44.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 19:52:57 -04:00
semmelrinde d4fb5538e6 Sketch: a side squeezed to a hair in two solves is refused too (#9049)
The check for a constraint that shrinks a curve to a point took under 1e-6
for a point. One solve could squeeze a 49 mm side to about 1.2e-6 mm and
the next take it the rest of the way, so neither solve tripped it (two
rectangles' corners made coincident, found by a local sketch fuzzer). Under
1e-5 now counts: in 4000 random sketches the constraints that still
collapsed a line went from 9 to 0, and every refusal rate stayed the same.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 21:52:36 +00:00
github-actions[bot] f6393ef211 Regenerate generated files and fold pending files [skip ci] 2026-10-29 22:12:44 +00:00
SilentWolf75 355b486595 Parameters: unit-less numbers follow the other side's unit (#9015) (#9027)
* claim: #9015

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

* Evaluate bare length offsets in the design's units (#9015)

Feature expressions like baseLength - 1 were subtracting millimetres because val() parsed with mm defaults and only applied design units to a final unit-less result.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-29 21:52:23 +00:00
Rick Morgans 2356a63aea CLI: run takes --param-check (#6921) (#6924)
* claim: #6921

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

* CLI: run refuses unknown options and takes --param-check (#6921)

`solidcraft-cli run` now checks its options through `known_args`, so a
mistyped option fails instead of being ignored, and takes
`--param-check off|warn|strict` with the MCP server's words and default
`off`. `warn` prints each unknown key with a suggestion on stderr and
runs the step; `strict` stops at that step and writes nothing.

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

* CLI: run's option check takes --param-check; warn on timed runs as steps complete

`run` keeps #6919's (#6918) unknown-option check, with `--param-check`
among the options that take a value. `Session::run_script_timed` now
takes the same per-step callback as `run_script_each`, so `run --timings`
or `--profile` at the warn level reports a step's warnings even when a
later step fails, instead of after the script (and not at all on a
failure).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 21:40:30 +00:00
Steven Waters 57c0121cb3 Cut & paste features between components (#5798) (#5805)
* Add timeline.cut and refactor timeline.paste to allow cutting and pasting features

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

* Updated test for new grounded behaviour

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

* Resolved conflicts with #3390

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

* Fixed failing test

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

* Resolved conflicts with #3512

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-29 21:39:53 +00:00
austin62jake 9b97e06f71 Web: open tabs share settings changes, so one tab no longer undoes another's (#8644) (#8832)
* claim: #8644

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

* Web: open tabs share settings changes, so one tab no longer undoes another's (#8644)

Every open tab of the web app saved its whole copy of the settings to the
same browser storage, every 30 s and on focus loss, so a tab still holding
old settings put them back over a toolbar change made in another tab.

A tab now writes its settings in the frame they change, and the other open
tabs load them from the browser's storage event, so their own saves keep
them. Settings loaded that way aren't written back.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-29 21:36:10 +00:00
Colin Faulkingham 9c5a636987 Parameters: accept mixed-case text units (#8960) (#8985)
* claim: #8960

* fix: accept mixed-case text parameter units (#8960)
2026-10-29 21:11:16 +00:00
SilentWolf75 18b00a4192 UI: keep Preferences above the Distance entry (#9016) (#9022)
* claim: #9016

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

* UI: keep Distance entry under Preferences and Report a bug (#9016)

Canvas value boxes used Foreground order, so they covered system windows. Draw them as Background chrome instead.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-29 21:09:28 +00:00
ABDULLAH 988caa83fc Arabic: settings profiles, ribbon reveal, orbit, viewport and history marker wording (#9019)
* Arabic: Settings profiles and command search ribbon reveal wording

* Arabic: orbit, viewport and history marker terms in today's new strings
2026-10-29 21:03:28 +00:00
semmelrinde 18dc05c83d Sketch Trim and Extend: a crossing at a curve's own end is that end, leaving no curve of no length (#9012)
* Sketch Trim: a cut at a curve's own end leaves no piece of no length

A curve that ends where another one ends crosses it there only within the
intersection's tolerance (1e-6), but bracket dropped cuts at the curve's
ends only within 1e-9 of its parameter range. A line ending on an arc's end,
trimmed from its other end, was cut a hair inside its own end and kept a
piece about 1e-7 mm long (found by a local trim fuzzer: 78 such trims in
4000 sketches, and 8 breaks). bracket now also drops a cut within that
tolerance of the curve's ends, as add_cut merges cuts that close together.

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

* Sketch Extend: a touch at an arc's growing end is that end, not a stop

An arc whose end is where a line ends that touches the arc's circle there
found the touch a hair beyond the end (within the intersection's tolerance),
stopped on it, and added an arc from that point to itself. A hit within that
tolerance of the growing end (1e-6, as for the far end) is now the end.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 20:48:18 +00:00
semmelrinde 5cf18ba8c3 Sketch: a constraint that would shrink a curve to a point is refused (#8988)
A constraint whose only solution squeezes curves to a point (a rectangle's
corner made the midpoint of its opposite side, or two opposite corners made
coincident) collapsed the whole rectangle while the sketch said solved. A
solve that shrinks a line or circle from over 1e-3 to under 1e-6 now counts
as one that fails, as one that throws points far away does: a strict edit is
refused, another keeps the sketch from before its solve, and the free solve
after the held ones in apply_in_order refuses the constraint.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 20:21:51 +00:00
P'tite Tête 9454afe13a fix: Draw only the timeline chips in view (#8976)
- Skip drawing and interaction for history chips outside the strip's visible part, after their slot is recorded
- Keep a dragged or focused chip, and every chip while a popup is open
- Frame on a 4,880-body design: 20.3 ms -> 14.6 ms (M4 Max, release, headless)
- Test a_long_timeline_paints_only_the_chips_in_view

Co-authored-by: P'tite Tête <asso@ptitetete.com>
2026-10-29 19:29:54 +00:00
MATHEUS PINHEIRO RAPOSO 70d39bb4f3 Three tests pass on Windows with an 8.3 TEMP path (#8968)
On a Windows account whose %TEMP% is a short (8.3) path, such as
C:\Users\MATHEU~1\AppData\Local\Temp, three tests failed because they built
their folders from std::env::temp_dir(), and the agent file scope refuses short
names ("not allowed for this agent: short (8.3) names such as `PROGRA~1`"):

- engine: cmd::render::settings_tests::embedded_hdr_capture_rejects_parameters_scope_and_cpu_primaries_without_mutation
- ui-egui: agent_chat::tests::file_actions_wait_for_the_user
- ui-egui: agent_chat::tests::permission_modes_decide_what_asks

They now use vfs::temp_dir(), which spells the folder with long names, as
libraries_tests and motion_tests already do (#1385, #1211).

Checked on Windows 11 x64 with the 8.3 TEMP: the agent_chat and
render::settings tests (113) pass, and clippy -D warnings is clean for both
crates.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 19:13:36 +00:00
github-actions[bot] a4fd998539 Regenerate generated files and fold pending files [skip ci] 2026-10-29 19:40:05 +00:00
github-actions[bot] 1c673e38c7 Regenerate generated files and fold pending files [skip ci] 2026-10-29 19:31:29 +00:00
tr1pp4 b43712cdb0 Command search: show a command's place in the ribbon, also inside section menus (#7287) (#7322)
* claim: #7287

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

* Command search: show a command's place in the ribbon, also inside section menus (#7287)

Hovering a command result in Search commands switches the ribbon to the command's
tab and marks its button with its tooltip; a command that has no button there
opens its section's drop-down on its row instead. Leaving the results (or closing
the box) goes back to the tab the user was on, so pressing S, typing and Enter
works as before.

Starting a command from the box makes its button or section label blink twice,
and a command started from a section drop-down marks that section's label while
it runs, like a pressed button.

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

* Command search: the ribbon reveal is a preference, off by default (#7287)

Preferences › Interface › Show search results in the ribbon (`ribbon_reveal`) turns on the hover
preview, the blink after a start and the running mark on a drop-down's panel label. Off, the
ribbon behaves as on main.

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

* Pending locale: Arabic, Hebrew, Japanese, Vietnamese and Chinese for the ribbon reveal setting

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 19:06:44 +00:00
Homero Thompson 184d4720be Sketch: a dimension can measure a model edge (#7912) (#8010)
* claim: #7912

* Sketch: dimension a sketch entity against a model edge

A dimension took sketch entities only, so measuring a sheet-metal body's edge
was refused with "cannot dimension that combination of entities". The
entities now take a model reference too, an {edge: [x,y,z], body?} or a
{vertex: [x,y,z], body?}, which is projected into the sketch first, as the
constraint commands' operands are since #6348, and measured there.

The Dimension tool picks a body's edge or vertex when the sketch has nothing
under the cursor, and reads the pick back into the reference the command
takes.

* style: rustfmt the sketch dimension command

* Cap the entities one call may project

A dimension projects the model references among its entities into the
sketch, one link and one resolve each, and the loop had no cap: a single
request could make the engine resolve thousands of model edges before
the dimension refused the list. The count is now refused before anything
is projected, and the projection loop stops at the same limit the
Project command uses.

* Sketch dimension on a model edge: use the sketch store after the store refactor

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

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 18:54:42 +00:00
Colin Faulkingham f08623be3c Fix V visibility shortcut for browser folders (#8800) (#8925)
* claim: #8800

* Fix folder visibility shortcut in browser (#8800)

* retrigger CI [skip ci]

* retrigger CI
2026-10-29 18:54:34 +00:00
semmelrinde 31218b1451 Sketch drag: an end the cursor can't reach stays where it is (#8944) (#8967)
The cursor correction tries the constraints' free directions a whole step at
a time and took a step as soon as the cursor's distance fell, by any amount.
Along freedom that can't reach the cursor (the far end of a line held only
by its direction), retracting onto the constraints still moves the cursor by
rounding, so such steps were taken: the end walked away a step at a time, and
a line measured by an angle could shrink to nothing, as in #7635. A step now
has to move the cursor itself by more than the solve's tolerance.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 18:54:27 +00:00
tr1pp4 cb34eef6aa Settings profiles: save, switch, export and import input and interface settings (#6237) (#6254)
* claim: #6237

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

* Settings profiles: save, switch, export and import input and interface settings (#6237)

A profile keeps the Interface, Navigation and Sketch preferences (mouse mode,
radial menu and gestures with every studio's lists, theme, scale, font, ...),
the language, the keyboard shortcuts, the toolbar, the S box favourites and
the imported theme it uses under a name. Machine-specific settings stay out
(3D mouse, graphics, units, AI, recent files).

- Preferences > General > Settings profiles: choose the active profile to
  apply it, Save, Rename, Delete, Export... and Import...; a note shows when
  the settings differ from the saved profile; Cancel takes a switch back.
- settings.profile.{list,save,apply,rename,delete,export,import} commands for
  the control channel, palette and scripts; the engine checks the parameters,
  the app does the rest.
- One `solidcraft-profile/1` file; import checks the format and name, drops
  unknown keys and unreadable values, caps sizes, never switches, and never
  panics. Each preference is applied through prefs::set, so a value this
  computer refuses keeps its current one and is reported.

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

* Glossary: settings profile, and "profile" among the words with more than one meaning (#6237)

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

* Settings profiles: new strings in a pending locale file, not the catalogs (#6237)

New UI strings now go to assets/locales/pending/ (CONTRIBUTING.md), so
this pull request no longer edits the shared catalogs.

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

* Preferences snapshot: compare the toolbar, favourites and profile too

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

* Settings profiles: keep a command's several keys (#6237)

Since a command can have several keys (#6288), the keymap saves a list where it has more than
one. A profile's checks only took a single key, so saving or importing a profile dropped such a
command's shortcuts. They now read the keys as the keymap does.

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

* Settings profiles: Arabic, Hebrew and Vietnamese strings (complete catalogs) (#6237)

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

---------

Co-authored-by: Tripper <tripper@localhost>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-29 18:42:36 +00:00
github-actions[bot] b9e9521128 Regenerate generated files and fold pending files [skip ci] 2026-10-29 19:03:47 +00:00
github-actions[bot] e59a2bcf80 Regenerate generated files and fold pending files [skip ci] 2026-10-29 18:56:56 +00:00
Logan Night 6fcdca1bb7 Dialogs: a pre-selected sketch line lying on a body edge goes in as that edge (#8927) (#8942)
* claim: #8927

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

* Dialogs: a pre-selected sketch line lying on a body edge goes in as that edge (#8927)

With a finished sketch shown, a click on a body edge that one of its lines
lies on picks the sketch line. Fillet and Chamfer take edges and no sketch
curves, so the pre-selection found no input to fill: the tool opened with
Edges empty and the selection was dropped, and only a second click with the
tool open (which picks the edge) made it work.

When nothing in the dialog takes a pre-selected sketch curve but an input
takes edges, look for the body edge the curve lies on from end to end
(display polylines, with room for a curved edge's chord sag) and take that
edge instead.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 18:26:56 +00:00
CamWight a985b8ee17 Preferences: Perspective view's field of view, a slider or a typed value (#5364) (#5373)
Preferences > Graphics > Viewport gets a Field of view row under Perspective view: the UI kit's
slider in degrees, 15 to 90, dragged or typed and kept to whole degrees, enabled only while
Perspective view is on. The preference is perspective_fov, in radians (default 0.6, about 34°,
so the default view draws exactly as before), and Restore Defaults puts it back.

One place decides the angle the view is drawn with: SolveApp::view_fov(), the preference with
Perspective view on and 0 with it off or while a sketch is open. A saved value outside 15°-90°
reads as the default. projection and fit_bounds read it (fit_bounds' second hard-coded copy of
the 0.6, tan(0.3), now goes through Camera::half_height), and so does the headless fit.
perspective_view() stays, as view_fov() > 0, so #4291 needs no change.

When the angle changes from one perspective angle to another, the camera's distance scales by
tan(old/2) / tan(new/2), with a running view animation's ends, so the target keeps its size and
the slider changes the depth, not the zoom. prefs::follow_view_fov does it once per frame and
after ui.prefs, Cancel and Restore Defaults; a parallel view keeps the last perspective angle,
and while Render is open the camera is Render's own, so the change waits for the design view.

The Render workspace's Perspective check box turns perspective on at the preference's angle,
not π/4; a saved render setup keeps its own. ui.prefs takes perspective_fov and refuses values
outside the range; ui.camera reports the angle the window draws with, and checks a replayed
camera against it, so its reply still replays. Field of view is in en, ar, de, he, ja, ko, vi and zh;
the ui.prefs settings list in docs/control-protocol.md and the guide's tour describe it.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 18:23:49 +00:00
HiroKenEz c857f2fe70 parameters.add: a parameter with no unit takes the design's units (#8865) (#8880)
* claim: #8865

* parameters.add: a parameter with no unit takes the design's units (#8865)

A new parameter given no unit took "mm" for any length or bare number, so
in an inch design `parameters.add {name: v, expression: "40/2"}` made a
20 mm parameter where the Parameters dialog makes 20 in. The unit a new
parameter infers is now a length in the design's units (mm designs are
unchanged); parameters.change on a new name and parameters.import share
the same inference. Angles and unit-less values keep their units.

A definition typed into a value of no fixed kind (a coupling's pitch,
a joint's value) still reads its bare number as millimetres, as that
field does.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 18:18:04 +00:00
SilentWolf75 9416e975e0 View cube: rapid clicks add full turns from the in-flight target (#8912) (#8929)
* claim: #8912

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

* View cube: rapid clicks add full turns from the in-flight target.

Match arrow-key retargeting so a second cube click turns from the glide destination instead of the mid-animation camera (#8912).

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-29 18:17:02 +00:00
kaltenbrunnermarkus 5f11d92458 Opening a design pins its body names, so the first undo evaluates nothing (#8939)
Problem: a design saved before body names were pinned (#6403) is pinned by its first command
that changes it, as part of that command. Undoing that command went back to the unpinned design,
whose fingerprints no evaluated result matched any more, so the first undo after opening
evaluated the whole timeline again: 63-72 s in the SELENE rover (5,484 features) when opened
from the command line or headless (`Session::opened_at`), 0.08 s with this change. `doc.open`
pinned at the end of the command instead, which kept undo fast but left the design with unsaved
changes on opening.

Solution: `Session::opened_at` pins the names as it opens the design, with the names the file's
bodies have (no result changes, so nothing is evaluated), and takes the pinned design as the
saved one: opening is no change.

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

Co-authored-by: Markus Kaltenbrunner <kaltenbrunnermarkus@users.noreply.github.com>
2026-10-29 18:15:47 +00:00
SilentWolf75 97550dc37e Parameters: one Name-column floor for every table in the dialog. (#8945)
Favorites, User and Model sections stay lined up when only some lists hold a long name (follow-up to #8551).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-29 18:14:35 +00:00
ABDULLAH 4456e0470a Arabic: Preferences instead of Settings, hover and Project option wording (#8949)
home.settings and ai.connected_toast follow the English change from Settings to Preferences (التفضيلات, as ui.preferences). Hover is no longer rendered as scrolling in the browser and navigation bar options, the Project options dialog label is fully in Arabic, and the trackpad zoom key help says to hold the key.
2026-10-29 18:14:26 +00:00
HiroKenEz 561336aab6 Text fields: a right-click menu with Cut, Copy, Paste and Select All (#8875) (#8882)
* claim: #8875

* Text fields: a right-click menu with Cut, Copy, Paste and Select All (#8875)

Every kit text field (dialog and sketch value fields, the sketch dimension
box, canvas value boxes, names, text areas, passwords, read-only text) now
calls one shared ui_kit::text_menu::menu after drawing its editor. Rows are
disabled when nothing is selected, the text is read-only or a password, or
the clipboard holds no text. The edit marks the response changed and gives
the focus back, so owners that commit on lost focus keep the field; a right
press inside the selection keeps it; Esc closes only the menu; expression
suggestions wait while it is open. New message ui.select_all in a pending
locale file; Cut, Copy and Paste reuse ui.cut, ui.copy and ui.paste.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 18:14:09 +00:00
Daniel 82b38f5bae Timeline: delete after the marker counts and deletes the shown features (#8860) (#8866)
* claim: #8860

* Timeline: delete after the marker counts and deletes the shown features (#8860)

With the bottom timeline filtered to one component, "Delete N Features After
History Marker" took the marker as a position among the shown features but
sliced the design's feature list with it. N counted features of other
components, and confirming deleted them too, including features before the
marker.

The entry now gets the feature's design position, the design's marker and
the shown positions, and deletes the shown features after the marker; the
label counts the same set. The history beside the browser shows every
feature and passes no filter, so it behaves as before.

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

* Timeline: look up shown positions by binary search, test the entry before the marker (#8860)

The shown positions are ascending, so a binary search finds each one without
walking the list for every feature after the marker. The test now also checks
that a shown feature before the marker does not offer the entry.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 18:03:31 +00:00
github-actions[bot] 43a22bed46 Regenerate generated files and fold pending files [skip ci] 2026-10-29 18:25:46 +00:00
bherbruck cd12d9d8e7 Scenario: set the history sketches layout through ui.prefs (#8961)
A test-only change: the scenario no longer clicks in the growing Interface page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:23:29 -04:00
github-actions[bot] 15e3245180 Regenerate generated files and fold pending files [skip ci] 2026-10-29 18:19:08 +00:00
semmelrinde 730dc5f6c3 Segmented choices: show the options in the user's language (#8030)
A segmented control measured its options translated but painted them as
the caller passed them, so the many callers that pass English labels
(the Coil's Circular | Square and Inside | Center | Outside, Revolve's
One Side | Two Sides | Symmetric, Shell's direction, ...) showed English
in every language. Only where the row was too narrow and the control
fell back to its dropdown, which translates, did they read right.
Segments now paint, size, describe and tip their options translated;
the handles keep the option as passed.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 17:55:25 +00:00
Colin Faulkingham 3cdc68aaf1 Viewport: opt-in model zoom and Traced 3D mouse orbit (#7435) (#7991)
* claim: #7435

* Viewport: the 3D mouse, the bridge and drag-zoom stop at the model, like the wheel (#7435)

The wheel and pinch hold the model point under the cursor, so the view
comes up to the surface and stops there. Every other zoom path passed no
anchor, so the 3D mouse cap, the navigation bridge and drag-zoom zoomed
straight through the surface into the body.

A device has no pointer, so the cap and the bridge now hold the model
point in the middle of the view; drag-zoom holds the point under the
pointer, like the wheel. With "Zoom to the cursor" off the old
zoom-through-the-model behaviour stays, as it does for the wheel.

The orbit half of the issue is a design decision and is untouched.

* Viewport: drag-zoom holds the point where the drag started (#7435)

The zoom anchor followed the moving pointer, so the zoom slid away from
what was pressed on as the drag went on. It now holds the press point,
the way the wheel holds the point under the cursor.

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

* 3D mouse: turn around the model in the middle of the view (#7435)

The cap and the navigation bridge turned the view around the view's
centre, which after a pan or from far away sits behind the face, so the
part swung away while it turned. They now turn around the model point in
the middle of the view (the middle of the bodies in view over empty
space), and keep it still on screen. Preferences > Navigation > "Turn
around the model in the middle of the view" switches back to the old
behaviour.

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

* 3D mouse: keep the turning point until the cap is back at rest (#7435)

The point the cap turns around was picked again every frame, so it slid
to whatever came into the middle as the view moved. It's now chosen when
the cap leaves rest and kept until the cap comes back, the way a mouse
orbit keeps the point it started on.

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

* 3D mouse: show the point the view turns around (#7435)

While the cap turns the view, a faint ball in the accent colour marks the
model point it turns around, so it's clear what the turn holds still. The
point is now picked when the cap starts turning, not when it only pans or
zooms, so a pan shows no ball.

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

* 3D mouse: orbit is a choice of Traced or Classic (#7435)

The checkbox becomes a two-way choice on the 3D mouse section's form:
Traced turns around the model in the middle of the view when the turn
begins and marks it with a faint ball; Classic turns around the view's
centre as before. Traced stays the default.

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

* Docs: the 3D mouse's Traced and Classic orbit (#7435)

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

* Make model zoom and traced orbit opt-in (#7435)

---------

Co-authored-by: nomadsgalaxy <16734355+nomadsgalaxy@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 17:49:15 +00:00
ReDead e3c08471b6 Sketch: diameter box appears for a second concentric circle (#8899) (#8904)
* claim: #8899

Co-authored-by: ReDead <ReDead@users.noreply.github.com>

* Sketch: keep Diameter box when placing a second concentric circle (#8899)

A centre-point circle's press starts drag-to-draw; releasing on the same
centre (or close to the press) must keep the stage-1 Diameter field instead
of resetting it and risking a second on-click that finishes the shape.

Co-authored-by: ReDead <ReDead@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: ReDead <ReDead@users.noreply.github.com>
2026-10-29 17:48:53 +00:00
Ahmad Khan a7048f41ab Sketch: equation-driven curve, a curve from x(t) and y(t) formulas (#8352) (#8353)
* claim: #8352

* Sketch: equation-driven curve, a fit point spline from x(t) and y(t) formulas (#8352)

sketch.equation_curve draws a curve from formulas in the expression
language and the design's parameters: x(t) and y(t) over a range of t, or
y = f(x) over a range of x. The curve is a cubic fit spline through fixed
points sampled from the formulas, as many as keep it within a tolerance
(0.01 mm by default, at most 500 points); a curve that ends where it starts
closes smoothly. The sketch keeps the formulas (one new design-format
file), places the points again before every solve so they follow the
parameters, and sketch.equation_curve.edit changes them, keeping the joined
end points. Both commands have input and output schemas.

* Sketch: Equation Curve window, its Edit menu item, labels and guide (#8352)

The ribbon's Equation Curve opens a window for the form (parametric or
explicit), the formulas, the range and the tolerance; OK runs
sketch.equation_curve. Right-clicking an equation curve offers Edit
Equation Curve, which reopens the window on its formulas and runs
sketch.equation_curve.edit. New labels and help in a pending locale file
(with ar, de, he, ja, ko, vi and zh), the icon, the glossary term and the
sketching guide.

* Equation curve: one if for closing the sampled points (clippy collapsible_if)
2026-10-29 17:42:59 +00:00
github-actions[bot] 1c0001af8e Regenerate generated files and fold pending files [skip ci] 2026-10-29 17:50:55 +00:00
semmelrinde 8b1ee94296 Sketch: an edit whose solve throws points far away is not taken (#8931)
A held solve applying a constraint, or the edit's own solve, could find a far
solution of the constraints and move points millions of millimetres while
the sketch said solved. Such a result now counts as a failed one: the next
held solve is tried, or the edit is refused.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 17:24:02 +00:00
Ryan 791abbd9b1 Sketch: hold horizontal/vertical and line up with other points at once while drawing (#7828) (#8474)
* claim: #7828

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

* Sketch: hold horizontal/vertical and line up with other points at once while drawing (#7828)

While drawing, the pointer lines up horizontally with the nearest sketch
point level with it and vertically with the nearest one plumb with it, at
the same time, as a dragged point already does; holding H/V with the
previous point still lines up across with another point. Dotted guides run
from the points lined up with (H, V or H+V), and the click adds a
Horizontal or Vertical constraint with each, in the shape's undo step,
unless the sketch already has it.

A click takes the tool out of the app while it runs, so refine_snap
treated every tool as the Line tool then: a rectangle's click could take a
line-extension inference its hover never showed. The tool's points and
kind are kept from the last hover, as the previous point was.

Preferences > Sketch > "Line up with other sketch points while drawing"
(sketch_align_points) turns it off for the old behaviour.

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

* Sketch: lining up with other points is off unless turned on

Review on #8474: sketch_align_points defaults to off, so nothing changes for anyone who
doesn't turn it on. With it off, a click also treats the tool as before (the Line tool's
inferences), instead of keeping the tool's kind from the hover.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 17:11:42 +00:00
Nathan Dane 6255a10e80 Viewport status item: engine list of what is on (#8545) (#8554)
* claim: #8545

* Declare ordered viewport status items (#8545)

* Keep legacy surface analysis coexistence behind a preference (#8545)
2026-10-29 17:11:34 +00:00
Ryan d05a114241 Insert Design: pick one of the other open designs, or browse for a file (#8570) (#8571)
* claim: #8570

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

* Insert Design: pick one of the other open designs, or browse for a file (#8570)

With other designs open in tabs, Insert Design first lists them under the
button: one click inserts a saved one as a linked component; one with
unsaved changes inserts its saved file and the status line says so; one
never saved is greyed out, as there is no file to link. Browse... opens
the file picker, as the button did before, and it still goes straight
there with no other design open.

Preferences > Design > Linked designs > "Insert Design lists the designs
open in other tabs first" (insert_lists_open_designs) turns the list off.

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

* Insert Design list: sit with the other menus' items, so run_item keeps its doc comment

Review on #8571: insert_design_items had landed between run_item and its comment. The
rebased menu-walk list keeps main's Target::HistoryControls beside Target::InsertDesign.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 17:11:25 +00:00
PaddleStroke 6ebfe91661 History: say why an edited feature behind the marker changes nothing (#8100) (#8727)
* claim: #8100

* History: say why an edited feature behind the marker changes nothing (#8100)

Editing a feature that sits after the history marker (double-clicking its
greyed-out item) rolls back to it, previews the change, and on OK puts the
marker back where it was: the feature stays rolled back, so nothing in the
view changes and nothing says why. That is the silent "pattern of a cut
does nothing" in #8100 (the reporter's design has its pattern behind the
marker; on main the pattern itself builds fine once rolled forward).

- timeline.redefine replies `rolled_back` and, when true, a `note`; it also
  carries the feature's `warning` (for a rolled-back feature, the one its
  constructor met building it at its place).
- The window shows that note in the status line after such an edit.
- A rolled-back item's tooltip in both History layouts says it is after
  the history marker and not built.

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

* Pending locale: Arabic, Hebrew, Japanese, Vietnamese and Chinese for the rolled-back notes

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 17:11:10 +00:00
Logan Night d0b357d532 Sketch profiles: close the joints near-coincident curves leave open (#8639) (#8922)
* claim: #8639

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

* Sketch profiles: close the joints near-coincident curves leave open (#8639)

Two free-form curves that lie on each other along a stretch (a DXF that
carries an outline twice, with the same straight sides and different
corners) are cut into sampled pieces whose ends the graph joins into one
node within 1e-4 mm, but the pieces keep their own ends, a few millionths
apart. Only analytic edges were closed at those joints, so the sliver
loops between the two curves stayed open by up to 1e-5 mm, more than the
1e-6 a solid needs. The region around them took those loops as holes:
extruding it failed with "profile loop is not closed" and it drew no
fill when picked, so it looked unselectable.

Close a line's joint in each finished loop: line to line at their middle,
a line onto any other segment's end. On the reporter's DXF the band
between the outer two rings now extrudes (110.5 mm2 by 2 mm = 220.9 mm3),
and the zero-area slivers no longer form profiles (221 -> 216).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 17:11:01 +00:00
HiroKenEz cc047a29c1 Sketch: the status line after a dimension is in the interface language (#8863) (#8879)
* claim: #8863

* Sketch: the status line after a dimension is in the interface language (#8863)

The Dimension tool built its "Dimension applied" status sentences (new
parameter, renamed parameter, plain value) and the driven-dimension note
with English format! strings, so they stayed English under a translated UI.
They now come from the catalog (one pending locale file: en, ar, de, he,
ja, ko, pl, vi, zh), with the typed parameter name passed in as is.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 17:07:31 +00:00
Daniel 32c8b23199 History: Delete on a selected feature deletes the selection in both timelines (#8861) (#8867)
* claim: #8861

* History: Delete on a selected feature deletes the selection in both timelines (#8861)

With several features selected, Delete in a selected feature's menu deleted
the whole selection in the bottom timeline and only that feature in the
history beside the browser, which offered the selection as a separate
Delete Selected Features… entry.

Both menus now use one Delete entry: the selected features when the
right-clicked one is among them, labelled "Delete N Selected Features",
else that feature alone. The history beside the browser no longer adds its
own Delete Selected Features… to a selected feature's menu, since the entry
above covers it; folder menus keep it. The single-feature delete helper had
no callers left and is removed.

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

* History: a feature's menu leaves out Delete Selected Features… by a flag (#8861)

A right-click on a feature outside the selection selects that feature
first, so in a feature's menu the clicked feature is always selected. A
flag that says the menu is a feature's says the same without the lookup.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 17:06:25 +00:00
github-actions[bot] 21366ec47e Regenerate generated files and fold pending files [skip ci] 2026-10-29 17:18:00 +00:00
N3MI-DG 699d730151 Button head cap screws, ISO 7380-1, in the standard parts library (#8006) (#8017)
* claim: #8006

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

* Parts library: button head cap screws, ISO 7380-1 (#8006)

Insert Part offered the socket head cap screw but no button head, so a
design using button heads had to stand in a cap screw and accept the
wrong head. A button_head_cap_screw family now sits beside it, taking
the same parameters from the ISO 7380-1 table over M3 to M16.

The head is a new Head::Dome: the shank and the head revolve as one
outline, with the rim at dk, a short cylindrical flank, the arc of the
crown, and a flat top around the socket, so the hex socket stays a plain
cut on a plane. The standard draws the flank and the flat but dimensions
neither; they are 0.15 k and 0.7 s, and the comment on those constants
records what that costs against the standard's crown radius r1 and why
neither other fractions nor r1 taken as given does better. dk, k, s and
t are exact, each checked against three reproductions of the table.

Insert Part, Change Part Size and the bill of materials need no change:
they read the families and their table rows from parts.library.

The wider fastener request is issue 7692; this is only the one family
from it, and leaves the rest of that list open.

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

* Parts: format after the rebase

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

* Pending locale: the Button head cap screw family name

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-29 16:43:36 +00:00
Lars Jansen a4274f9b59 Line tool: press and drag draws one line (#7247) (#7252)
* claim: #7247

* feat(sketch): press and drag in the Line tool draws one line (#7247)
2026-10-29 16:34:30 +00:00
N3MI-DG d6d6c7d9ee Copy and paste bodies and components between designs (#3511) (#3512)
* Components: copy and paste bodies and components between designs and components (#3390)

Squashed for the rebase onto main.

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

* claim: #3511

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

* Paste New: copy from a design other than the one pasted into

`component_clone::paste` read the design it copies from through the session it pastes into.
`build` now takes the source (document, evaluated model, unapplied moves) apart from the
target session; Paste New within one design passes the session as both, as before. Names are
numbered against the target's names.

From another design (`Source::copied`, a `ForeignCopy` snapshot taken at copy time):
- names are kept where the target doesn't have them, else numbered;
- the user parameters the copy uses come along, `w_2` where the target has a `w`, with the
  copied expressions following;
- a copy whose features use what isn't in it (bodies, sketches, faces, parameters outside it)
  is refused with a list of what; `FeatureKind::input_bodies` lists the bodies a feature uses;
- configurations stay with their design, and the target's timeline marker must be at the end;
- a body moved in from outside comes as an imported base named `BodyImport1`.

Body transfers are pointed at the copy's components before they are added: `add_feature`
checks that the components they name exist, which another design's don't.

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

* Engine: paste bodies and components copied in another design

`component.paste_from_design` pastes what `Session::foreign_copy` holds, at the same world
coordinates as in its design, into the active component or `component` (placed by
`component_path`; a component placed more than once gets it in every instance), as one step
to undo:
- bodies as imported bodies, without their history, in one Import feature (named as any
  import is, "Import1"); a body seen in another placement ("Cube (B:2)") pastes as "Cube";
  names the target has are numbered;
- components as independent copies (Paste New from the snapshot): with their history,
  appearances, materials, joints and motion inside them; joints to the outside stay behind.

Tests: bodies (position, naming, target component, undo; a body of another placement) and
components (history, appearances, a part placed twice, editing independently, save and reopen,
undo; user parameters renamed; refused when using a body outside it; bodies moved in).

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

* Clipboard: copy in one design, paste in another

Copying or cutting bodies or components also keeps a snapshot of them (`ForeignCopy`); another
tab pastes it with `component.paste_from_design`: bodies as imported bodies, components as
independent copies (Paste and Paste New alike), folded and selected. Cut pastes as a copy
there, the original staying; Esc on the cut leaves nothing to paste anywhere.

The last copy in any design is what pastes: each copy or cut is numbered, and a tab whose own
copy is older empties its clipboard when it is switched to (`drop_stale`). A failed copy
anywhere leaves nothing to paste in any tab.

Tests: bodies and components pasted in another tab, the last copy winning across tabs, cut
as a copy. `copied_bodies_stay_with_their_design` gave way to the cross-design test.

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

* Clipboard: what is selected wins over a design row picked earlier

Copy copied the whole design as a linked component whenever the design's
browser row was still picked, even after a component, body or feature had
been selected in the view or another tab, and failed in an unsaved design.
The design row now gives way to anything selected, and switching tabs
drops the picked components of the design left.

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

* Put the Paste from Another Design label in the pending locale file

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

* Tests: unground the placement before moving it (a design's first part is grounded)

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

* Drop locale keys main already folded into en.json

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

* Pending locale: Paste from Another Design in the complete catalogs

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 16:31:09 +00:00
DuBearte 5bd85dded6 Keyboard Shortcuts: the read-only list says so and leads to the editable page (#7409) (#7449)
* claim: #7409

* Keyboard Shortcuts: the read-only list says so and leads to the editable page (#7409)

Home › Keyboard shortcuts and Help › Keyboard Shortcuts open the
Shortcuts page read-only, in the Preferences window with its sidebar and
Cancel/Apply/OK, so it looked exactly like Settings › Shortcuts with
Import…, Reset All and recording missing. The read-only page now says
"Read-only list: shortcuts can't be changed here." and has an "Edit
shortcuts" button that turns the same page into the editable one.

Strings in assets/locales/pending/shortcuts-read-only-note.json (en, ja,
zh). Harness test opens it both ways, checks the note, clicks Edit
shortcuts, and finds Import…, Reset All and a recordable binding.

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

* Pending locale: Arabic, Hebrew and Vietnamese for the read-only shortcuts note

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 16:30:40 +00:00
austin62jake e5ef3bad02 Viewport: with Orbit, Pan or Zoom lit, a press held still keeps the highlight under it (#8864) (#8898)
* claim: #8864

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

* Viewport: with Orbit, Pan or Zoom lit, a press held still keeps the highlight under it (#8864)

A mode lit in the navigation bar, or a held Space, owns drags, not a still
press (#8438), but the hover pick counted any left press under such a mode
as navigation and dropped the highlight, so the line under a Select Other
hold went dark until the list opened. Only a preset's left-button binding
or Ctrl+left orbit navigates from the press on now; a lit mode's drag
still drops the highlight once it starts.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-29 16:25:11 +00: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
286 changed files with 19559 additions and 1184 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
+1
View File
@@ -609,6 +609,7 @@ Original SolidCraft drawings, Open for owner review; no runtime assignments.
| assets/icons/ui/design/svg/model/timeline_stop.svg | SolidCraft contributors | Original history playback icon; canonical grey_cube base and original action cue | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/status_pin.svg | SolidCraft contributors | Original analytic pin icon for persistent viewport results | MIT OR Apache-2.0 |
| assets/locales/pending/render-wear.json | SolidCraft contributors | Original UI labels and translations | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/equation_curve.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
## Code adapted from sibling projects
+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`).
Generated
+17 -17
View File
@@ -4210,7 +4210,7 @@ dependencies = [
[[package]]
name = "solidcraft"
version = "0.26.1"
version = "0.27.0"
dependencies = [
"block2 0.6.2",
"eframe",
@@ -4246,11 +4246,11 @@ dependencies = [
[[package]]
name = "solidcraft-3dx-client"
version = "0.26.1"
version = "0.27.0"
[[package]]
name = "solidcraft-cli"
version = "0.26.1"
version = "0.27.0"
dependencies = [
"image",
"log",
@@ -4262,7 +4262,7 @@ dependencies = [
[[package]]
name = "solidcraft-derive"
version = "0.26.1"
version = "0.27.0"
dependencies = [
"proc-macro2",
"quote",
@@ -4271,7 +4271,7 @@ dependencies = [
[[package]]
name = "solidcraft-doc"
version = "0.26.1"
version = "0.27.0"
dependencies = [
"log",
"serde",
@@ -4288,7 +4288,7 @@ dependencies = [
[[package]]
name = "solidcraft-engine"
version = "0.26.1"
version = "0.27.0"
dependencies = [
"image",
"log",
@@ -4309,7 +4309,7 @@ dependencies = [
[[package]]
name = "solidcraft-geom"
version = "0.26.1"
version = "0.27.0"
dependencies = [
"serde",
"serde_json",
@@ -4318,7 +4318,7 @@ dependencies = [
[[package]]
name = "solidcraft-gpu"
version = "0.26.1"
version = "0.27.0"
dependencies = [
"image",
"log",
@@ -4338,7 +4338,7 @@ dependencies = [
[[package]]
name = "solidcraft-io"
version = "0.26.1"
version = "0.27.0"
dependencies = [
"cadmpeg-core",
"cadmpeg-ir",
@@ -4362,7 +4362,7 @@ dependencies = [
[[package]]
name = "solidcraft-kernel"
version = "0.26.1"
version = "0.27.0"
dependencies = [
"log",
"proptest",
@@ -4382,7 +4382,7 @@ dependencies = [
[[package]]
name = "solidcraft-mcp"
version = "0.26.1"
version = "0.27.0"
dependencies = [
"serde_json",
"solidcraft-engine",
@@ -4391,7 +4391,7 @@ dependencies = [
[[package]]
name = "solidcraft-render"
version = "0.26.1"
version = "0.27.0"
dependencies = [
"image",
"moxcms",
@@ -4405,7 +4405,7 @@ dependencies = [
[[package]]
name = "solidcraft-sketch"
version = "0.26.1"
version = "0.27.0"
dependencies = [
"harfrust",
"serde",
@@ -4418,14 +4418,14 @@ dependencies = [
[[package]]
name = "solidcraft-space-mouse"
version = "0.26.1"
version = "0.27.0"
dependencies = [
"serde",
]
[[package]]
name = "solidcraft-ui-egui"
version = "0.26.1"
version = "0.27.0"
dependencies = [
"arboard",
"egui",
@@ -4455,7 +4455,7 @@ dependencies = [
[[package]]
name = "solidcraft-web"
version = "0.26.1"
version = "0.27.0"
dependencies = [
"eframe",
"egui",
@@ -5998,7 +5998,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.26.1"
version = "0.27.0"
dependencies = [
"proc-macro2",
"serde",
+1 -1
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
exclude = ["vendor"]
[workspace.package]
version = "0.26.1"
version = "0.27.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
+4 -3
View File
@@ -42,8 +42,8 @@
</p>
<p align="center">
<a href="https://trendshift.io/repositories/296178?utm_source=trendshift-badge&amp;utm_medium=badge&amp;utm_campaign=badge-trendshift-296178" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/296178/daily" alt="tangency-labs%2Fsolidcraft | Trendshift" width="250" height="55"/></a>
<a href="https://trendshift.io/repositories/296178?utm_source=trendshift-badge&amp;utm_medium=badge&amp;utm_campaign=badge-trendshift-296178" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/296178/daily?language=Rust" alt="tangency-labs%2Fsolidcraft | Trendshift (Rust)" width="250" height="55"/></a>
<a href="https://trendshift.io/repositories/296178?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-296178" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/296178/daily" alt="tangency-labs%2Fsolidcraft | Trendshift" width="250" height="55"/></a>
<a href="https://trendshift.io/repositories/296178?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-296178" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/296178/daily?language=Rust" alt="tangency-labs%2Fsolidcraft | Trendshift (Rust)" width="250" height="55"/></a>
</p>
![SolidCraft: model a Raspberry Pi 4 case from an empty design: a sketch dimensioned with parameters, extrude, fillets and shell, a rim rounded in one pick, standoffs patterned on the board's mounting holes, port cut-outs, vent slots, "Pi 4" engraved in a font, a PETG material with its mass in Properties, and a height change in Parameters](docs/screenshots/demo.gif)
@@ -98,7 +98,7 @@ More commands, the Windows cross-build and the browser build are in [docs/buildi
runs its own design session, or with `--connect` it drives the running app so you can watch.
Claude Code: `claude mcp add solidcraft -- solidcraft-cli mcp`. If a terminal doesn't find
`solidcraft-cli` (older Windows installers don't put it on `PATH`), give its full path (by default
`"C:\Program Files\SolidCraft\solidcraft-cli.exe"`). Tools, access rules and an example session:
`"C:\\Program Files\\SolidCraft\\solidcraft-cli.exe"`). Tools, access rules and an example session:
[docs/mcp.md](docs/mcp.md).
## Scripts
@@ -150,3 +150,4 @@ Tinkercad are trademarks of their respective owners, named only to describe comp
[linux-appimage]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-linux-x86_64.AppImage
[snap-store]: https://snapcraft.io/solidcraft
[web-app]: https://solidcraft.io/app
@@ -66,7 +66,7 @@ fn run() -> Result<(), String> {
let mut session = Session::default();
eprintln!("Generating original CAD scene from {recipe_path}");
let generation_start = Instant::now();
let (commands, command_timings) = session.run_script_timed(&recipe, false).map_err(|e| e.to_string())?;
let (commands, command_timings) = session.run_script_timed(&recipe, false, &mut |_, _| {}).map_err(|e| e.to_string())?;
let generation_seconds = generation_start.elapsed().as_secs_f64();
let design_path = output.with_extension(solidcraft_engine::io::DESIGN_EXT);
session.execute("file.save_as", &json!({"path":design_path})).map_err(|e| e.to_string())?;
@@ -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 {
+54 -31
View File
@@ -2,6 +2,7 @@
//!
//! ```text
//! solidcraft-cli run <script.json|design.sc|part.step|mesh.3mf> [--in design|part.step|mesh.3mf] [--out FILE]... [--save FILE] [--quiet] [--results] [--timings] [--profile] [--json-out FILE] [--allow-errors]
//! [--param-check off|warn|strict]
//! solidcraft-cli eval <script.json|design.sc|part.step> [--in design.sc|part.step] [--json-out FILE] (alias: inspect) measurements as JSON
//! solidcraft-cli script <design.sc> [--out script.json] the design's history as a command script
//! solidcraft-cli bench-open <design.sc | part.step> [--parse-only] [--save OUT.sc] [--display LEVEL [--edit NAME EXPR]] [--ops script.json] loading timings as JSON lines
@@ -57,7 +58,7 @@ struct Command {
}
/// `run`'s lines in the usage.
const USAGE_RUN: &str = " solidcraft-cli run <script.json|design.sc|part.step> [--in design.sc|part.step] [--out FILE]... [--save FILE] [--quiet] [--results] [--timings] [--profile] [--json-out FILE] [--allow-errors]\n solidcraft-cli run --in <design.sc|part.step> [--out FILE]...\n (a timeline feature that fails fails the run and nothing is written; --allow-errors writes anyway)\n (--timings prints {timings: [{index, command, ms}], total_ms} for a script, with --results its results too;\n --profile adds each command's profile: {boolean_ms, tessellate_ms, other_ms}, the kernel's share)\n";
const USAGE_RUN: &str = " solidcraft-cli run <script.json|design.sc|part.step> [--in design.sc|part.step] [--out FILE]... [--save FILE] [--quiet] [--results] [--timings] [--profile] [--json-out FILE] [--allow-errors]\n [--param-check off|warn|strict]\n solidcraft-cli run --in <design.sc|part.step> [--out FILE]...\n (a timeline feature that fails fails the run and nothing is written; --allow-errors writes anyway)\n (--param-check checks each step against its command's contract: warn reports on stderr, strict stops the run)\n (--timings prints {timings: [{index, command, ms}], total_ms} for a script, with --results its results too;\n --profile adds each command's profile: {boolean_ms, tessellate_ms, other_ms}, the kernel's share)\n";
/// `eval`'s lines in the usage.
const USAGE_EVAL: &str = " solidcraft-cli eval <script.json|design.sc|part.step> [--in design.sc|part.step] [--json-out FILE] (alias: inspect)\n solidcraft-cli eval --in <script.json|design.sc|part.step> [--json-out FILE]\n (with both inputs, runs the positional script on --in before measuring; does not automatically save the modified design)\n (--json-out FILE writes the JSON that run (with --results, else the measurement) and eval print to FILE instead of stdout,\n as plain UTF-8: a shell's > can re-encode it, e.g. Windows PowerShell adds a byte-order mark)\n";
/// `script`'s lines in the usage.
@@ -299,18 +300,36 @@ fn load_checked(path: &str, check: solidcraft_engine::schema::ParamCheck) -> Res
s.execute("doc.open", &json!({"path": path})).map_err(|e| e.to_string())?;
} else {
let script = if v.get("features").is_some() && v.get("commands").is_none() { recipe::to_script(&v)? } else { v };
// Warn level: each step's `param_warnings` as it completes, even when a later step fails
// (stderr, so stdout stays the caller's).
let mut warn = |step: usize, r: &Value| {
for w in r.get("param_warnings").and_then(Value::as_array).into_iter().flatten() {
eprintln!("{path}: step {}: {}", step + 1, w.as_str().unwrap_or_default());
}
};
s.run_script_each(&script, &mut warn).map_err(|e| e.to_string())?;
run_script_warning(&mut s, path, &script)?;
}
Ok(s)
}
/// Run `script`, read from `path`, on `s`. At the warn level each step's `param_warnings` go to
/// stderr as the step completes, even when a later step fails (stdout stays the caller's).
fn run_script_warning(s: &mut Session, path: &str, script: &Value) -> Result<Vec<Value>, String> {
s.run_script_each(script, &mut |step, r| print_param_warnings(path, step, r)).map_err(|e| e.to_string())
}
/// Step `step`'s `param_warnings` on stderr, one per line, named after the script at `path`.
fn print_param_warnings(path: &str, step: usize, r: &Value) {
for w in r.get("param_warnings").and_then(Value::as_array).into_iter().flatten() {
eprintln!("{path}: step {}: {}", step + 1, w.as_str().unwrap_or_default());
}
}
/// `--param-check off|warn|strict`: how calls that break a command's parameter contract are
/// treated (docs/mcp.md); off when the option isn't given.
fn parse_param_check(cmd: &str, args: &[String]) -> Result<solidcraft_engine::schema::ParamCheck, String> {
match flag(args, "--param-check") {
None if args.iter().any(|a| a == "--param-check") => Err(format!("{cmd}: --param-check wants off, warn or strict")),
None => Ok(solidcraft_engine::schema::ParamCheck::Off),
Some(v) => {
solidcraft_engine::schema::ParamCheck::parse(v).ok_or_else(|| format!("{cmd}: --param-check wants off, warn or strict, not `{v}`"))
}
}
}
fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> {
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).map(String::as_str)
}
@@ -367,7 +386,15 @@ fn print_json(v: &Value, file: Option<&str>) -> Result<(), String> {
fn cmd_run(args: &[String]) -> Result<(), String> {
install_renderer();
let json_out = json_out(args)?;
known_args("run", args, &["--in", "--out", "--save", "--json-out"], &["--quiet", "--results", "--timings", "--profile", "--allow-errors"], true)?;
// Before the option check, so a bare `--param-check` gets the words `mcp` uses.
let check = parse_param_check("run", args)?;
known_args(
"run",
args,
&["--in", "--out", "--save", "--json-out", "--param-check"],
&["--quiet", "--results", "--timings", "--profile", "--allow-errors"],
true,
)?;
let base = flag(args, "--in");
let path = positional(args);
if path.is_none() && base.is_none() {
@@ -380,23 +407,25 @@ fn cmd_run(args: &[String]) -> Result<(), String> {
let (mut s, results) = if want_results || timings {
// Collect every command's result or time (scripts only), then process requested files too.
let mut s = match base {
Some(b) => load(b)?,
Some(b) => load_checked(b, check)?,
None => Session::default(),
};
s.param_check = check;
let needs = if want_results { "--results needs a script" } else { "--timings needs a script" };
let v = read_json(path.ok_or(needs)?)?;
let script = path.ok_or(needs)?;
let v = read_json(script)?;
let out = if timings {
let (r, mut t) = s.run_script_timed(&v, profile).map_err(|e| e.to_string())?;
let (r, mut t) = s.run_script_timed(&v, profile, &mut |step, r| print_param_warnings(script, step, r)).map_err(|e| e.to_string())?;
if want_results && let Some(o) = t.as_object_mut() {
o.insert("results".into(), Value::Array(r));
}
t
} else {
Value::Array(s.run_script(&v).map_err(|e| e.to_string())?)
Value::Array(run_script_warning(&mut s, script, &v)?)
};
(s, Some(out))
} else {
(open(base, path)?, None)
(open(base, path, check)?, None)
};
// A feature that fails leaves the bodies as they were before it (a parameter change can
// break a feature without failing the command that made it): writing them would hand out
@@ -421,16 +450,16 @@ fn cmd_run(args: &[String]) -> Result<(), String> {
}
/// The session `run`, `eval` and `snapshot` start from: `--in` (a design or STEP file) with the
/// positional script run on top of it, or either one alone.
fn open(base: Option<&str>, path: Option<&String>) -> Result<Session, String> {
/// positional script run on top of it, or either one alone, with scripts run at `check`.
fn open(base: Option<&str>, path: Option<&String>, check: solidcraft_engine::schema::ParamCheck) -> Result<Session, String> {
match (base, path) {
(Some(b), Some(p)) => {
let mut s = load(b)?;
s.run_script(&read_json(p)?).map_err(|e| e.to_string())?;
let mut s = load_checked(b, check)?;
run_script_warning(&mut s, p, &read_json(p)?)?;
Ok(s)
}
(Some(b), None) => load(b),
(None, Some(p)) => load(p),
(Some(b), None) => load_checked(b, check),
(None, Some(p)) => load_checked(p, check),
(None, None) => Err(usage()),
}
}
@@ -473,7 +502,7 @@ fn timeline_ok(s: &Session) -> Result<(), String> {
fn cmd_eval(args: &[String]) -> Result<(), String> {
let json_out = json_out(args)?;
known_args("eval", args, &["--in", "--json-out"], &[], true)?;
let mut s = open(flag(args, "--in"), positional(args))?;
let mut s = open(flag(args, "--in"), positional(args), solidcraft_engine::schema::ParamCheck::Off)?;
let m = s.execute("inspect.measure", &json!({})).map_err(|e| e.to_string())?;
let d = s.execute("document.inspect", &json!({})).map_err(|e| e.to_string())?;
let out = json!({
@@ -567,7 +596,7 @@ fn cmd_snapshot(args: &[String]) -> Result<(), String> {
Some(x) => x.parse().ok().filter(|n| (16..=8192).contains(n)).ok_or_else(|| format!("{name} must be 16 to 8192 pixels, not `{x}`")),
};
let (w, h) = (size("--width", 1280)?, size("--height", 800)?);
let mut s = open(flag(args, "--in"), positional(args))?;
let mut s = open(flag(args, "--in"), positional(args), solidcraft_engine::schema::ParamCheck::Off)?;
// How the bodies are drawn and where the model is cut, through the commands the app's
// Appearance dialog and Section Analysis run (`preview`).
preview::apply(&mut s, &flags(args, "--body"), &flags(args, "--tool"), flag(args, "--section"))?;
@@ -691,14 +720,8 @@ fn cmd_mcp(args: &[String]) -> Result<(), String> {
eprintln!("solidcraft mcp: the design's folder is left out because {why}; add it with --allow-path if you mean it");
}
}
// How a headless session treats calls that break a command's parameter contract (docs/mcp.md).
let param_check = match flag(args, "--param-check") {
None if args.iter().any(|a| a == "--param-check") => return Err("mcp: --param-check wants off, warn or strict".into()),
None => solidcraft_engine::schema::ParamCheck::Off,
Some(v) => {
solidcraft_engine::schema::ParamCheck::parse(v).ok_or_else(|| format!("mcp: --param-check wants off, warn or strict, not `{v}`"))?
}
};
// How a headless session treats calls that break a command's parameter contract.
let param_check = parse_param_check("mcp", args)?;
if param_check != solidcraft_engine::schema::ParamCheck::Off && flag(args, "--connect").is_some() {
eprintln!("solidcraft mcp: --param-check applies to headless sessions; calls to the app are not checked yet");
}
+44
View File
@@ -288,3 +288,47 @@ fn undoing_an_edit_after_opening_reuses_the_saved_results() {
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(format!("{}.bak", path.display()));
}
/// A design saved before body names were pinned (#6403) pins them as it opens, as its first
/// command would, and opening it is no change. That command pinned them as part of itself, so
/// undoing it went back to the unpinned design, which no evaluated result matched: the first
/// undo after opening from the command line evaluated every feature again (82-93 s in a
/// 5,484-feature design); `doc.open` pinned them after opening, which marked the design changed.
#[test]
fn a_design_saved_before_names_were_pinned_opens_pinned_and_unchanged() {
let plate = solidcraft_engine::sample::samples().into_iter().find(|s| s.name == "Sample Plate").unwrap();
let mut built = Session::default();
for c in (plate.script)()["commands"].as_array().unwrap() {
built.execute(c["command"].as_str().unwrap(), &c["params"]).unwrap();
}
let mut doc = built.design().into_owned();
for f in &mut doc.features {
f.body_names.clear();
}
doc.reserved_body_names.clear();
let unpinned = Session::new(doc);
let design = unpinned.design();
let saved = unpinned.model.saved(&design).unwrap();
let bytes = solidcraft_engine::io::write_design_container_with_cache(&design, solidcraft_engine::io::DesignCompression::Fast, None, Some(&saved))
.unwrap();
let path = temp("unpinned");
std::fs::write(&path, &bytes).unwrap();
let file = path.to_string_lossy().to_string();
// As the command line opens a design (`load`), and as the app does (`doc.open`).
let (doc, saved) = solidcraft_engine::io::read_design_file(&file, None::<fn(solidcraft_engine::io::DesignPreview)>).unwrap();
for (how, mut s) in [("opened_at", Session::opened_at(doc, saved.as_ref(), Some(&file))), ("doc.open", open(&path))] {
assert_eq!(s.model.last_recomputed, 0, "{how}: taken from the file");
assert!(s.doc.features.iter().any(|f| !f.body_names.is_empty()), "{how}: the names are pinned");
assert!(!s.is_dirty(), "{how}: opening is no change");
let read: Vec<Arc<ModelState>> = s.model.results.iter().map(|r| r.state.clone()).collect();
s.execute("solid.box", &json!({"length": 3, "width": 3, "height": 3, "operation": "new"})).unwrap();
// As in a large design, whose evaluation cache keeps only a few results: undo finds the
// results in the model itself or evaluates them again.
*s.model.cache.lock().unwrap() = Default::default();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.model.last_recomputed, 0, "{how}: the first undo evaluates nothing");
assert!(s.model.results.iter().zip(&read).all(|(r, st)| Arc::ptr_eq(&r.state, st)), "{how}: the states read from the file");
assert!(!s.is_dirty(), "{how}: undone to the design as opened");
}
let _ = std::fs::remove_file(&path);
}
+72
View File
@@ -496,6 +496,78 @@ fn timings_and_profile_report_each_command() {
assert!(String::from_utf8_lossy(&output.stderr).contains("--timings needs a script"));
}
/// #6921: `--param-check` checks a script's steps against their contracts on every path `run`
/// takes (a script alone, on an `--in` design or script, with `--results`, `--timings` or
/// `--profile`), and a warning reaches stderr even when a later step fails.
#[test]
fn param_check_reaches_every_script_run_takes() {
let dir = Scratch::new();
let typo = dir.0.join("typo.json");
let step = json!({"command": "solid.box", "params": {"length": 2, "width": 3, "height": 4, "operaton": "new"}});
std::fs::write(&typo, json!({"commands": [step.clone()]}).to_string()).unwrap();
let base = dir.0.join("base.solidcraft");
json_output(cli().arg("run").arg(dir.script()).arg("--save").arg(&base).output().unwrap());
let saved = dir.0.join("saved.solidcraft");
let start = dir.script();
let (typo, base, saved_arg, start) = (typo.to_str().unwrap(), base.to_str().unwrap(), saved.to_str().unwrap(), start.to_str().unwrap());
let paths = [&[][..], &["--in", base], &["--in", start], &["--results"], &["--timings"], &["--timings", "--profile"]];
let run = |level: Option<&str>, extra: &[&str]| {
std::fs::remove_file(&saved).ok();
let check = level.map(|l| ["--param-check", l]).into_iter().flatten();
cli().args(["run", typo, "--save", saved_arg]).args(check).args(extra).output().unwrap()
};
// Off, the default: the step runs as it always has, without a word.
let off = run(None, &[]);
assert!(off.status.success() && !String::from_utf8_lossy(&off.stderr).contains("operaton"), "{off:?}");
assert_eq!(body_count(&saved), 1);
// Warn: each path runs the step, saves, and reports it on stderr; `--results` keeps it too.
for extra in paths {
let output = run(Some("warn"), extra);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(output.status.success(), "{extra:?}: {stderr}");
assert!(stderr.contains("step 1: unknown key `operaton`; did you mean `operation`?"), "{extra:?}: {stderr}");
assert!(saved.exists(), "{extra:?}");
if extra == ["--results"] {
let results: Value = serde_json::from_slice(&output.stdout).unwrap();
assert!(results[0]["param_warnings"][0].as_str().is_some_and(|w| w.contains("operaton")), "{results}");
}
}
// Strict: on each path the run stops at the step, and nothing is written.
for extra in paths {
let output = run(Some("strict"), extra);
let stderr = String::from_utf8_lossy(&output.stderr);
assert_eq!(output.status.code(), Some(1), "{extra:?}: {stderr}");
assert!(stderr.contains("unknown key `operaton`"), "{extra:?}: {stderr}");
assert!(!saved.exists(), "{extra:?}");
}
// Warn, with a later step that fails: the run fails, and the first step's warning is still
// reported, timed or not.
let failing = dir.0.join("failing.json");
let fail = json!({"command": "solid.extrude", "params": {"sketch": "Nope"}});
std::fs::write(&failing, json!({"commands": [step, fail]}).to_string()).unwrap();
for extra in [&[][..], &["--timings"]] {
let output = cli().arg("run").arg(&failing).args(["--param-check", "warn"]).args(extra).output().unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert_eq!(output.status.code(), Some(1), "{extra:?}: {stderr}");
assert!(stderr.contains("step 1: unknown key `operaton`") && stderr.contains("no sketch `Nope`"), "{extra:?}: {stderr}");
}
// A level it doesn't know and a missing value fail.
for (bad, says) in [
(&["--param-check", "loud"][..], "--param-check wants off, warn or strict, not `loud`"),
(&["--param-check"], "run: --param-check wants off, warn or strict"),
] {
let output = cli().args(["run", typo]).args(bad).output().unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert_eq!(output.status.code(), Some(1), "{bad:?}: {stderr}");
assert!(stderr.contains(says), "{bad:?}: {stderr}");
}
}
fn render_setup_script(dir: &Scratch, job: Value) -> PathBuf {
let path = dir.0.join("render.json");
std::fs::write(
+1 -1
View File
@@ -34,7 +34,7 @@ serde_json = { workspace = true }
web-sys = { version = "0.3", features = [
"Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "Navigator", "StorageManager",
"FileSystemHandle", "FileSystemDirectoryHandle", "FileSystemFileHandle", "FileSystemGetFileOptions", "FileSystemWritableFileStream",
"WritableStream", "File", "FileList", "Blob", "BlobPropertyBag", "Url", "HtmlAnchorElement", "HtmlInputElement", "Storage",
"WritableStream", "File", "FileList", "Blob", "BlobPropertyBag", "Url", "HtmlAnchorElement", "HtmlInputElement", "Storage", "StorageEvent",
"IdbFactory", "IdbDatabase", "IdbObjectStore", "IdbOpenDbRequest", "IdbRequest", "IdbTransaction", "IdbTransactionMode",
"Headers", "ReadableStream", "ReadableStreamDefaultReader",
"BeforeUnloadEvent", "Event", "EventTarget", "KeyboardEvent", "Response", "FormData", "RequestInit", "MediaQueryList", "Gamepad", "Performance",
+131 -5
View File
@@ -119,6 +119,43 @@ fn closes_design(key: &str, command: bool, shift: bool, alt: bool, design_shown:
design_shown && command && !shift && !alt && key.eq_ignore_ascii_case("w")
}
/// The settings a tab last wrote to the browser's storage, or loaded from another tab (#8644).
/// Every open tab of the app shares that storage, and eframe saves each tab's whole copy every
/// 30 s and when the page loses focus, so a tab still holding old settings put them back over a
/// change made in another. A change is now written in the frame it is made, and the other tabs
/// load it ([`settings_written`]).
#[cfg(any(target_arch = "wasm32", test))]
#[derive(Default, Debug)]
struct PrefsWriter {
written: Option<String>,
}
#[cfg(any(target_arch = "wasm32", test))]
impl PrefsWriter {
/// `prefs` if they are not the settings written or loaded last (they count as written from
/// now on).
fn take(&mut self, prefs: String) -> Option<String> {
if self.written.as_deref() == Some(prefs.as_str()) {
return None;
}
self.written = Some(prefs.clone());
Some(prefs)
}
/// The settings the tab holds after loading another tab's: the tab doesn't write them back.
fn loaded(&mut self, prefs: String) {
self.written = Some(prefs);
}
}
/// The settings another tab wrote, from a `storage` event (the browser sends one to every other
/// tab of the page when one tab writes). Other keys, and settings taken away (clearing the site's
/// data), leave this tab's settings as they are.
#[cfg(any(target_arch = "wasm32", test))]
fn settings_written(key: Option<&str>, value: Option<String>) -> Option<String> {
value.filter(|_| key == Some(solidcraft_ui_egui::prefs::STORAGE_KEY))
}
#[cfg(target_arch = "wasm32")]
mod web {
use std::rc::Rc;
@@ -217,6 +254,24 @@ mod web {
on_key.forget();
}
/// Settings another tab wrote, waiting for this tab's next frame (#8644).
type PrefsInbox = Rc<std::cell::Cell<Option<String>>>;
/// Settings another tab of the app writes go to `inbox`, and this tab draws a frame, which
/// loads them.
fn hear_settings_from_other_tabs(window: &web_sys::Window, ctx: &egui::Context, inbox: PrefsInbox) {
let ctx = ctx.clone();
let on_storage = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::StorageEvent)>::new(move |e: web_sys::StorageEvent| {
if let Some(prefs) = crate::settings_written(e.key().as_deref(), e.new_value()) {
inbox.set(Some(prefs));
ctx.request_repaint();
}
});
let _ = window.add_event_listener_with_callback("storage", on_storage.as_ref().unchecked_ref());
// The listener lives as long as the page.
on_storage.forget();
}
const JAPANESE_FONT_PATH: &str = "./fonts/BIZUDPGothic-Regular.ttf";
type FontLoadResult = Rc<std::cell::RefCell<Option<Result<Vec<u8>, String>>>>;
@@ -246,9 +301,48 @@ mod web {
font_language: String,
space_mouse: SpaceMouse,
wheel_gestures: solidcraft_ui_egui::WebWheelGestures,
/// The settings this tab wrote or loaded last, and those another tab wrote since (#8644).
prefs_writer: crate::PrefsWriter,
other_tab_prefs: PrefsInbox,
}
impl Shell {
/// The settings as they are kept between visits: with the user's own language, not a
/// session-only English fallback.
fn persisted_prefs(&mut self) -> String {
let saved = self.font.persisted_language(&self.app.preferences.language).to_owned();
let live = std::mem::replace(&mut self.app.preferences.language, saved);
let prefs = self.app.prefs();
self.app.preferences.language = live;
prefs
}
/// A settings change goes to the browser's storage in the frame it is made, and from
/// there to the other open tabs (#8644). Input is what changes settings, so a frame
/// without any is skipped; eframe's own save still catches anything else.
fn store_prefs(&mut self, ctx: &egui::Context) {
if !self.launch.persists() || ctx.input(|i| i.events.is_empty()) {
return;
}
let prefs = self.persisted_prefs();
let Some(prefs) = self.prefs_writer.take(prefs) else { return };
let stored =
web_sys::window().and_then(|w| w.local_storage().ok().flatten()).map(|s| s.set_item(solidcraft_ui_egui::prefs::STORAGE_KEY, &prefs));
if !matches!(stored, Some(Ok(()))) {
log::warn!("settings: the browser's storage did not take them; they are saved again later");
self.prefs_writer = crate::PrefsWriter::default();
}
}
/// Settings another tab wrote replace this tab's, so this tab's own saves keep them
/// instead of putting its older copy back (#8644).
fn load_other_tabs_prefs(&mut self) {
let Some(prefs) = self.other_tab_prefs.take() else { return };
self.app.load_prefs(&prefs);
let held = self.persisted_prefs();
self.prefs_writer.loaded(held);
}
/// Files from the computer, writes to store, messages and the autosave.
fn files_tick(&mut self, ctx: &egui::Context) {
if !self.launch.persists() {
@@ -647,6 +741,8 @@ mod web {
impl eframe::App for Shell {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
// First: a language another tab chose fetches its font this frame.
self.load_other_tabs_prefs();
self.tick_font(ctx);
self.app.logic(ctx);
self.api.poll();
@@ -689,6 +785,7 @@ mod web {
self.recovery_prompt(&ctx);
}
self.watch_text_for_font(&ctx);
self.store_prefs(&ctx);
}
fn persist_egui_memory(&self) -> bool {
self.launch.persists()
@@ -697,11 +794,8 @@ mod web {
if !self.launch.persists() {
return;
}
// Persist the user's own language, not a session-only English fallback.
let saved = self.font.persisted_language(&self.app.preferences.language).to_owned();
let live = std::mem::replace(&mut self.app.preferences.language, saved);
storage.set_string(solidcraft_ui_egui::prefs::STORAGE_KEY, self.app.prefs());
self.app.preferences.language = live;
let prefs = self.persisted_prefs();
storage.set_string(solidcraft_ui_egui::prefs::STORAGE_KEY, prefs);
}
}
@@ -822,6 +916,12 @@ mod web {
let (sender, receiver) = std::sync::mpsc::channel();
let mut app = SolveApp::new(Session::default(), services).with_control(receiver);
app.embedded = launch.embed;
// Listening before reading: settings another tab writes from now on are
// newer than those read here.
let other_tab_prefs = PrefsInbox::default();
if launch.persists() {
hear_settings_from_other_tabs(&window, &cc.egui_ctx, other_tab_prefs.clone());
}
if launch.persists() && let Some(p) = cc.storage.and_then(|s| solidcraft_ui_egui::prefs::stored(|k| s.get_string(k))) {
app.load_prefs(&p);
}
@@ -865,6 +965,8 @@ mod web {
font_language,
space_mouse: SpaceMouse::default(),
wheel_gestures: solidcraft_ui_egui::WebWheelGestures::default(),
prefs_writer: crate::PrefsWriter::default(),
other_tab_prefs,
}))
}),
)
@@ -1001,4 +1103,28 @@ mod tests {
assert!(!closes_design("w", false, false, false, true));
assert!(!closes_design("s", true, false, false, true));
}
/// A tab writes its settings when they change, and not again while they stay the same; the
/// settings it loaded from another tab aren't written back (#8644).
#[test]
fn changed_settings_are_written_once_and_loaded_ones_not_at_all() {
let mut writer = PrefsWriter::default();
assert_eq!(writer.take(r#"{"a":1}"#.into()).as_deref(), Some(r#"{"a":1}"#), "the first settings are written");
assert_eq!(writer.take(r#"{"a":1}"#.into()), None, "the same settings are not written again");
assert_eq!(writer.take(r#"{"a":2}"#.into()).as_deref(), Some(r#"{"a":2}"#), "a change is written at once");
writer.loaded(r#"{"a":3}"#.into());
assert_eq!(writer.take(r#"{"a":3}"#.into()), None, "settings from another tab are not written back");
assert_eq!(writer.take(r#"{"a":4}"#.into()).as_deref(), Some(r#"{"a":4}"#), "this tab's next change is written");
}
/// Only the settings another tab wrote are loaded: not other keys, and not settings taken
/// away by clearing the site's data (#8644).
#[test]
fn only_settings_another_tab_wrote_are_loaded() {
let key = solidcraft_ui_egui::prefs::STORAGE_KEY;
assert_eq!(settings_written(Some(key), Some("{}".into())).as_deref(), Some("{}"));
assert_eq!(settings_written(Some("solidcraft.webgl"), Some("1".into())), None);
assert_eq!(settings_written(Some(key), None), None, "removed settings leave this tab's");
assert_eq!(settings_written(None, None), None, "clearing all storage leaves this tab's settings");
}
}
+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!(
+1 -1
View File
@@ -188,7 +188,7 @@ surface = [
sketch = [
"arc3", "arc_center", "arc_slot", "arc_slot_center", "arc_tangent",
"blend", "circle", "circle2", "circle3", "circle_tt",
"circle_ttt", "conic", "constrainer", "dimension", "ellipse",
"circle_ttt", "conic", "constrainer", "dimension", "ellipse", "equation_curve",
"extend", "finish", "fit_section", "include",
"intersect", "intersection_curve", "iso_curve", "line", "line_mid",
"offset", "offset_two_sides", "offset_symmetric", "point", "polygon", "polygon_circumscribed", "polygon_edge",
@@ -95,6 +95,7 @@ edit false
ellipse false
emboss false
environment_map false
equation_curve false
error false
explode false
export false
@@ -224,6 +225,7 @@ settings_monitor false
settings_mouse false
settings_palette false
settings_plug false
sheet false
sheet_rules false
shell false
shield false
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Equation curve</title>
<path d="M12 8L12 56M6 32L58 32" fill="none" stroke="#6f879c" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M12 32C15.42 24.83 18.72 18 22.5 18C26.28 18 29.58 24.83 33 32C36.42 39.17 39.72 46 43.5 46C47.28 46 50.58 39.17 54 32" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
<circle cx="12" cy="32" r="5.8" fill="#ffffff"/>
<circle cx="12" cy="32" r="3.4" fill="#236ead"/>
<circle cx="54" cy="32" r="5.8" fill="#ffffff"/>
<circle cx="54" cy="32" r="3.4" fill="#236ead"/>
</svg>

After

Width:  |  Height:  |  Size: 666 B

+142 -4
View File
@@ -206,6 +206,7 @@
"Edges": "الحواف",
"Edit": "تعديل",
"Edit Profile Sketch": "تعديل رسم المقطع",
"Edit shortcuts": "تعديل الاختصارات",
"Enable rule": "تفعيل القاعدة",
"Enabled": "مفعّل",
"End Radius": "نصف القطر النهائي",
@@ -468,6 +469,7 @@
"Radius": "نصف القطر",
"Radius Fit": "ملاءمة نصف القطر",
"Ratio": "النسبة",
"Read-only list: shortcuts can't be changed here.": "قائمة للقراءة فقط: لا يمكن تغيير الاختصارات هنا.",
"Reattach selected face": "إعادة ربط الوجه المحدد",
"Recover": "استعادة",
"Recover unsaved design?": "استعادة التصميم غير المحفوظ؟",
@@ -652,6 +654,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": "عمق السن اللولبي",
@@ -729,7 +732,7 @@
"a picked edge is gone": "الحافة المحددة لم تعد موجودة",
"a picked edge's body is gone": "جسم الحافة المحددة لم يعد موجودًا",
"add a key for a joint first": "أضف أولًا مفتاحًا لمفصل",
"ai.connected_toast": "أصبح SolidCraft متاحًا في {clients}. أعد تشغيلها لاستخدامه (الإعدادات > المساعد).",
"ai.connected_toast": "أصبح SolidCraft متاحًا في {clients}. أعد تشغيلها لاستخدامه (التفضيلات > المساعد).",
"ai.prefs.allow_control": "السماح لوكلاء الذكاء الاصطناعي بالاتصال (قناة التحكم / MCP)",
"ai.prefs.allow_control_note": "يفتح قناة تحكم على هذا الحاسوب فقط (127.0.0.1، المنفذ 27878 أو أول منفذ متاح بعده). على البرنامج الذي يتصل أن يقدّم أيضًا رمزًا سريًا لا يقرؤه إلا حساب المستخدم الخاص بك، ويُنشأ من جديد عند كل تشغيل. يُطبّق في المرة القادمة التي يبدأ فيها SolidCraft. عند الإيقاف: لا شيء يستمع ما لم تشغّل SolidCraft مع --control.",
"ai.prefs.availability": "عناصر تحكم المساعد",
@@ -821,6 +824,7 @@
"cmd.browser_move": "نقل عناصر المستعرض",
"cmd.browser_order": "ترتيب عناصر المستعرض",
"cmd.browser_rename_group": "إعادة تسمية المجموعة",
"cmd.browser_reparent": "نقل إلى مكوّن",
"cmd.browser_ungroup": "فك التجميع",
"cmd.browser_visibility": "إظهار/إخفاء",
"cmd.canvas_calibrate": "معايرة اللوحة",
@@ -847,7 +851,9 @@
"cmd.component_metadata": "خصائص القطعة",
"cmd.component_metadata_set": "تغيير خصائص القطعة",
"cmd.component_move_bodies": "نقل الأجسام إلى مكوّن",
"cmd.component_move_features": "نقل الميزات إلى مكوّن",
"cmd.component_move_sketches": "نقل الرسومات إلى مكوّن",
"cmd.component_paste_from_design": "لصق من تصميم آخر",
"cmd.component_paste_new": "لصق كجديد",
"cmd.component_properties": "الخصائص الفيزيائية للمكوّن",
"cmd.component_relink": "إعادة ربط المصدر",
@@ -984,6 +990,7 @@
"cmd.motion_trace": "أثر الحركة",
"cmd.motion_trace_edit": "تحرير أثر الحركة",
"cmd.motion_trace_keep": "الإبقاء على أثر الحركة",
"cmd.occurrence_align": "محاذاة النسخة",
"cmd.occurrence_copy": "لصق (نسخة مرتبطة)",
"cmd.occurrence_drag": "سحب النسخة",
"cmd.occurrence_ground": "تثبيت بالأرض",
@@ -1074,6 +1081,13 @@
"cmd.render_start": "تصيير صورة",
"cmd.render_status": "تقدّم التصيير",
"cmd.select_clear": "مسح التحديد",
"cmd.settings_profile_apply": "تطبيق ملف تعريف الإعدادات",
"cmd.settings_profile_delete": "حذف ملف تعريف الإعدادات",
"cmd.settings_profile_export": "تصدير ملف تعريف الإعدادات",
"cmd.settings_profile_import": "استيراد ملف تعريف الإعدادات",
"cmd.settings_profile_list": "ملفات تعريف الإعدادات",
"cmd.settings_profile_rename": "إعادة تسمية ملف تعريف الإعدادات",
"cmd.settings_profile_save": "حفظ ملف تعريف الإعدادات",
"cmd.sheet_convert": "تحويل إلى صفائح معدنية",
"cmd.sheet_export_bend_table": "تصدير جدول الثني",
"cmd.sheet_export_dxf": "تصدير النمط المسطح بصيغة DXF",
@@ -1119,6 +1133,8 @@
"cmd.sketch_drawing_bounds": "حجم الرسمة",
"cmd.sketch_edit": "تعديل الرسم",
"cmd.sketch_edit_text": "تعديل النص",
"cmd.sketch_equation_curve": "منحنى معادلة",
"cmd.sketch_equation_curve_edit": "تحرير منحنى المعادلة",
"cmd.sketch_export_dxf": "حفظ بصيغة DXF",
"cmd.sketch_finish_auto_constrain": "إنهاء مع التقييد التلقائي",
"cmd.sketch_fit_curves_to_section": "ملاءمة منحنيات لمقطع الشبكة",
@@ -1215,6 +1231,7 @@
"cmd.view.render_browser": "متصفح التصيير",
"cmd.view.render_dock": "نقل لوحة التصيير",
"cmd.view.render_focus": "عرض تركيز التصيير",
"cmd.view.status_items": "عناصر حالة منطقة العرض",
"cmd.view_ambient_occlusion": "الإظلال المحيطي",
"cmd.view_angle_readout": "زوايا العرض",
"cmd.view_apply": "استعادة عرض مسمّى",
@@ -1369,8 +1386,10 @@
"help.material.assign": "يحدد المادة التي صُنعت منها الأجسام المحددة، مثل الفولاذ أو الألومنيوم أو ABS. استخدمه لتطابق الكتلة والوزن القطعة الحقيقية.",
"help.mit": "رخصة MIT",
"help.notice": "NOTICE",
"help.occurrence.move": "يحرّك جزءًا في التجميع فقط ضمن درجات الحرية التي تسمح بها مفاصله. حدد وجهًا للجزء، ثم اسحب سهمًا أو قوسًا متاحًا.",
"help.parameters.change": "يفتح جدول القيم المسماة والتعبيرات التي تتحكم في أبعاد التصميم. استخدمه لتغيير مقاس في مكان واحد فتتبعه كل الميزات التي تستخدمه.",
"help.pointcloud.insert": "أدرج مسحًا بصيغة PLY أو XYZ أو PTS كسحابة نقاط بجانب النموذج. استخدمه كمرجع عند النمذجة انطلاقًا من بيانات القياس.",
"help.sketch.equation_curve": "يرسم منحنى من صيغ على مدى: x(t) و y(t)، أو y = f(x). يمكن أن تستخدم الصيغ المعلمات فيتبعها المنحنى: استخدمه لموجة جيبية أو لولب أو منحنى انفلات لسن ترس.",
"help.solid.align": "يحرّك الأجسام المحددة بحيث تلتقي نقطة أو وجه أو مستوى منها بنقطة أو وجه أو مستوى في مكان آخر. استخدمه لوضع قطعة ملاصقة لأخرى دون قياس المسافة.",
"help.solid.chamfer": "يقطع الحواف المحددة بشطفة مستوية بالمسافة التي تحددها. استخدمه لكسر الحواف الحادة أو لتسهيل إدخال مسمار أو برغي.",
"help.solid.combine": "يضم الأجسام، أو يقتطعها من جسم هدف، أو يُبقي الحجم المشترك بينها فقط، ويمكنه الاحتفاظ بأجسام الأداة. استخدمه لدمج القطع، أو لقطع شكل بشكل آخر، أو لمعرفة موضع تداخل جسمين.",
@@ -1464,7 +1483,7 @@
"home.recovery_hint": "أُغلق SolidCraft قبل حفظ هذه التصاميم.",
"home.report_issue": "الإبلاغ عن مشكلة",
"home.samples": "أمثلة",
"home.settings": "الإعدادات",
"home.settings": "التفضيلات",
"home.short_date": "{day} {month}",
"home.shortcuts": "اختصارات لوحة المفاتيح",
"home.start_hint": "أنشئ تصميمًا، أو افتح ملفًا حديثًا، أو استكشف مثالًا.",
@@ -1499,6 +1518,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 +1627,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,12 +1650,15 @@
"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": "مخفي في التصميم",
"navbar.custom.hidden_render": "مخفي في التصيير",
"navbar.custom.hide": "إخفاء شريط التنقل",
"navbar.custom.hide_idle": "إخفاء حتى التمرير",
"navbar.custom.hide_idle": "إخفاء حتى يمر المؤشر فوقه",
"navbar.custom.icon_only": "الأيقونة فقط",
"navbar.custom.icon_value": "الأيقونة والقيمة",
"navbar.custom.icons": "الأيقونات",
@@ -1720,6 +1759,7 @@
"parts.dimension.thickness": "السماكة",
"parts.dimension.width": "العرض",
"parts.family.bearing": "محمل كريات أخدودي عميق (الغلاف)",
"parts.family.button_head_cap_screw": "برغي برأس زرّي بتجويف سداسي",
"parts.family.dowel_pin": "مسمار توجيه أسطواني",
"parts.family.hex_bolt": "برغي سداسي الرأس",
"parts.family.hex_nut": "صامولة سداسية",
@@ -1778,6 +1818,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": "سحب بالزر الأوسط",
@@ -1848,6 +1891,8 @@
"prefs.layout_note": "المضغوط يثبّت اللوحات على حواف النافذة. شفافية المستعرض متاحة في الوضع العائم. غيّر جهة المساعد من صفحة المساعد.",
"prefs.left": "يسار",
"prefs.left_click": "نقر أيسر",
"prefs.line_drag_draws_one": "الضغط والسحب يرسم خطًا واحدًا",
"prefs.line_drag_draws_one_note": "في أداة الخط، الضغط ثم السحب ثم الإفلات يرسم خطًا واحدًا من نقطة الضغط إلى نقطة الإفلات وينتهي هناك. لا تزال النقرات ترسم سلسلة. عند الإيقاف: يضع السحب نقطة حيث يُفلت، كما تفعل النقرة.",
"prefs.linked_designs": "التصاميم المرتبطة",
"prefs.live_input": "الإدخال المباشر",
"prefs.live_preview": "المعاينة المباشرة",
@@ -1880,14 +1925,36 @@
"prefs.origin_plane_names": "أسماء مستويات الأصل",
"prefs.origin_plane_names_note": "الأسماء المعروضة في المستعرض والحوارات والقوائم. {top} هو XY و{front} هو XZ و{right} هو YZ، والمحور Z للأعلى. تحتفظ الأوامر والسكربتات والتصاميم المحفوظة بـ XY وXZ وYZ.",
"prefs.overall_speed": "السرعة العامة",
"prefs.perspective_fov": "مجال الرؤية",
"prefs.placement": "الموضع",
"prefs.prefer_free_sketch_points": "تفضيل نقاط الرسم القابلة للتحريك عند تداخل النقاط",
"prefs.preview_choices": "معاينة اختياراتك",
"prefs.profile_active": "ملف التعريف النشط",
"prefs.profile_applied": "تم تطبيق ملف التعريف.",
"prefs.profile_changed": "تغيّرت الإعدادات منذ حفظ ملف التعريف. «حفظ» يحفظها فيه.",
"prefs.profile_deleted": "تم حذف ملف التعريف.",
"prefs.profile_export": "تصدير…",
"prefs.profile_exported": "تم تصدير ملف التعريف.",
"prefs.profile_imported": "تم استيراد ملف التعريف. اختره في «ملف التعريف النشط» للتبديل إليه.",
"prefs.profile_name_hint": "اسم ملف التعريف، مثل: حاسوب محمول",
"prefs.profile_rename": "إعادة تسمية",
"prefs.profile_renamed": "تمت إعادة تسمية ملف التعريف.",
"prefs.profile_save": "حفظ",
"prefs.profile_saved": "تم حفظ الإعدادات الحالية في ملف التعريف.",
"prefs.profile_skipped": "تم تطبيق ملف التعريف؛ احتفظ {n} من الإعدادات بقيمها الحالية (راجع السجل).",
"prefs.profiles": "ملفات تعريف الإعدادات",
"prefs.profiles_note": "الاختصارات وشريط الأدوات والتنقل والقائمة الدائرية وإعدادات الواجهة، محفوظة معًا تحت اسم واحد وتُبدَّل دفعة واحدة.",
"prefs.project_options_dialog": "مربع حوار خيارات الإسقاط",
"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.ribbon_reveal": "إظهار مكان نتائج البحث في الشريط",
"prefs.ribbon_reveal_note": "عند تمرير المؤشر فوق نتيجة في «البحث في الأوامر» يظهر مكان الأمر في الشريط: تبويبه أو زره أو صفه في القائمة المنسدلة للوحة. ويومض هناك الأمر الذي بدأ من البحث، ويُعلِّم الأمر الجاري من قائمة منسدلة لوحتَه.",
"prefs.right_click": "نقر أيمن",
"prefs.saving": "الحفظ التلقائي والاستعادة",
"prefs.scale_note": "أو Ctrl + / Ctrl − في النافذة. الأزرار 28 px عند 100%.",
@@ -1901,6 +1968,10 @@
"prefs.shift_add_ctrl_toggle": "Shift يضيف فقط، وCtrl / Cmd يبدّل",
"prefs.shift_add_note": "عند التشغيل: Shift يضيف، وCtrl / Cmd يبدّل. عند الإيقاف (الافتراضي): كلاهما يبدّل.",
"prefs.shortcuts": "الاختصارات",
"prefs.single_surface_analysis": "استخدام تحليل سطح واحد",
"prefs.single_surface_analysis_help": "عند إيقاف هذا الخيار، يمكن إبقاء تظليل السطح وتحليل الاستمرارية مفعّلين معًا. عند تشغيله، يبقى آخر تحليل تم بدؤه أو تغييره فقط.",
"prefs.sketch_align_points": "المحاذاة مع نقاط الرسم الأخرى أثناء الرسم",
"prefs.sketch_align_points_note": "أثناء الرسم، تتحاذى النقطة أفقيًا مع نقطة رسم وعموديًا مع نقطة أخرى في الوقت نفسه، مع خط إرشادي منقط، وتحتفظ النقرة بالمحاذاتين. عند الإيقاف: مع النقطة السابقة فقط، محور واحد في كل مرة.",
"prefs.sketch_conflict_colour": "الهندسة المتعارضة بلون التعارض",
"prefs.sketch_conflict_colour_note": "عندما تتعارض قيود الرسم، تُرسم المنحنيات والنقاط التي يمسكها قيد فاشل بلون التعارض أيضًا، لا رمزه أو بُعده فقط. عند الإيقاف: تحتفظ بألوانها المعتادة.",
"prefs.sketch_exit_view": "عند انتهاء الرسم",
@@ -1952,7 +2023,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,9 +2040,14 @@
"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": "مساحة العمل",
"prefs.zoom_stop_at_model": "إيقاف التكبير عند النموذج",
"prefs.zoom_stop_at_model_tip": "تُكبّر الفأرة ثلاثية الأبعاد وتطبيق الجهاز نحو السطح في منتصف العرض، ويثبّت التكبير بالسحب نقطة بدء السحب. عند الإيقاف يمكن للتكبير المرور عبر النموذج. تستخدم العجلة وإيماءة القرص إعداد التكبير نحو المؤشر بشكل مستقل",
"prefs.zoom_toward_cursor": "التكبير نحو المؤشر",
"properties.title": "الخصائص: {name}",
"recovery.days_ago": "قبل {count} يوم",
@@ -2662,7 +2745,7 @@
"render.light_intensity": "الشدة",
"render.light_missing": "هذا الضوء لم يعد موجودًا. اختر ضوءًا في المتصفح.",
"render.light_place_highlight": "وضع بالتمييز",
"render.light_place_pending": "الوضع بالتمييز يتطلب اختيار الأضواء في منفذ العرض وهو غير مدعوم بعد.",
"render.light_place_pending": "الوضع بالتمييز يتطلب اختيار الأضواء في منطقة العرض وهو غير مدعوم بعد.",
"render.light_position": "الموضع",
"render.light_preview_help": "العرض الحي بلا ظلال؛ الأضواء المساحية تستخدم تقريب عينة المركز. التصيير يستخدم نموذج الضوء الكامل.",
"render.light_product_centre": "مركز المنتج",
@@ -3296,6 +3379,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": "حدد حواف الصفيحة أولًا",
@@ -3314,6 +3398,7 @@
"settings.double_click_components": "انقر نقرًا مزدوجًا على مكوّن لتنشيطه، أو لتحرير مكوّن مرتبط في مكانه",
"settings.frame_rate_details_note": "يضيف رسمًا لأزمنة الإطارات الأخيرة مع المثلثات المرسومة فوقه، ومدرّجًا تكراريًا لأزمنة الإطارات، وما رسمته وحدة GPU. انقر معدل الإطارات في العرض لإظهارها أو إخفائها.",
"settings.frame_rate_note": "يعرض الإطارات في الثانية في الزاوية اليمنى السفلية من العرض. أثناء تفعيله يُعاد رسم العرض باستمرار.",
"settings.insert_lists_open_designs": "يعرض إدراج تصميم أولًا التصاميم المفتوحة في علامات التبويب الأخرى",
"settings.measure_axis_distances": "إظهار المسافات على X/Y/Z",
"settings.measure_axis_distances_help": "إظهار مركّبات الأبعاد على محاور العالم بجانب المسافة الكلية أثناء فتح أداة القياس.",
"settings.msaa_applies_on_restart": "يُطبّق تنعيم الحواف في المرة القادمة التي يبدأ فيها SolidCraft",
@@ -3338,6 +3423,10 @@
"shortcut.unpin": "إلغاء التثبيت",
"sketch is missing": "الرسم مفقود",
"sketch.degrees_of_freedom": "{count} درجات حرية",
"sketch.dimension_added_driven": "الرسم يحدد هذا البُعد بالفعل، لذا أُضيف بُعدًا مُشتقًا (مرجعيًا)",
"sketch.dimension_applied": "تم تطبيق البُعد",
"sketch.dimension_applied_defined": "تم تطبيق البُعد؛ تم تعريف المعامل `{name}`",
"sketch.dimension_applied_renamed": "تم تطبيق البُعد؛ أصبح اسم المعامل `{name}`",
"sketch.dof_left": "تبقّت {count} درجات حرية",
"sketch.dof_left_one": "تبقّت {count} درجة حرية",
"sketch.draw_curves": "الرسم كـ {linetype}",
@@ -3372,6 +3461,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": "وحدات التصميم. انقر لتغييرها",
@@ -3405,8 +3496,10 @@
"timeline.delete_folders_tip": "الإبقاء على ميزاتها ومجلداتها الفرعية في السجل",
"timeline.delete_folders_with_features_tip": "حذف المجلدات مع كل الميزات التي فيها والمجلدات المتداخلة فيها",
"timeline.delete_many": "حذف {count} ميزات محددة",
"timeline.edited_rolled_back": "تم تغيير {name}، لكنه يقع بعد مؤشر السجل ولذلك لا يُبنى: انقل المؤشر إلى ما بعده لرؤية النتيجة",
"timeline.feature_error": "خطأ: {message}",
"timeline.feature_hint": "انقر نقرًا مزدوجًا للتحرير · اسحب لإعادة الترتيب · انقر بالزر الأيمن للمزيد",
"timeline.feature_rolled_back": "تم التراجع: بعد مؤشر السجل، لذلك لا يُبنى",
"timeline.feature_warning": "تحذير: {message}",
"timeline.group": "مجلد ميزات جديد",
"timeline.move_many": "نقل عناصر الخط الزمني",
@@ -3488,6 +3581,10 @@
"ui.3d_mouse_offer": "معايرة الفأرة ثلاثية الأبعاد تصلح المحاور المتبادلة أو المعكوسة وتضبط سرعتها وإحساسها.",
"ui.3d_mouse_offer_never": "عدم الإظهار مرة أخرى",
"ui.3d_mouse_offer_swap": "التبديل إلى Hidraw",
"ui.3d_mouse_orbit": "تدوير العرض",
"ui.3d_mouse_orbit_classic": "كلاسيكي",
"ui.3d_mouse_orbit_tip": "متتبَّع: تُدير الفأرة ثلاثية الأبعاد العرض حول جزء النموذج الموجود في منتصف العرض عند بدء الدوران، وتُعلّمه بكرة باهتة. كلاسيكي: حول مركز العرض",
"ui.3d_mouse_orbit_traced": "متتبَّع",
"ui.3d_mouse_reading": "القراءة",
"ui.3d_mouse_reset_axes": "إعادة تعيين المحاور",
"ui.3d_mouse_reset_view": "إعادة تعيين العرض",
@@ -3965,6 +4062,14 @@
"ui.end_scale": "مقياس النهاية",
"ui.entities": "العناصر",
"ui.equal": "متساوٍ",
"ui.equation_end": "النهاية",
"ui.equation_explicit": "صريح",
"ui.equation_form": "الشكل",
"ui.equation_note": "المتغير رقم بلا وحدة: تأخذه sin(t) بالراديان. والنتيجة التي بلا وحدة تكون بوحدة التصميم.",
"ui.equation_parametric": "بارامتري",
"ui.equation_x_of_t": "x(t)",
"ui.equation_y_of_t": "y(t)",
"ui.equation_y_of_x": "y(x)",
"ui.equivalent_radius": "نصف القطر المكافئ",
"ui.expand": "توسيع",
"ui.exportDxf": "تصدير DXF…",
@@ -4030,6 +4135,7 @@
"ui.heading_design": "التصميم",
"ui.heading_draft": "ميل السحب",
"ui.heading_edit": "تعديل",
"ui.heading_equation_curve": "منحنى معادلة",
"ui.heading_extrude": "بثق",
"ui.heading_fillet": "تقويس",
"ui.heading_finish_editing": "إنهاء التحرير",
@@ -4121,6 +4227,10 @@
"ui.import_ellipsis": "استيراد…",
"ui.infinite": "لا نهائي",
"ui.insert": "إدراج",
"ui.insert_design.browse": "استعراض…",
"ui.insert_design.open_designs": "التصاميم المفتوحة",
"ui.insert_design.saved_file": "في {name} تغييرات غير محفوظة، لذا أُدرج ملفه المحفوظ. احفظه، ثم استخدم تحديث الروابط لإدخال التغييرات.",
"ui.insert_design.unsaved": "تغييرات غير محفوظة",
"ui.inspect": "فحص",
"ui.intersect": "تقاطع",
"ui.isolate": "عزل",
@@ -4220,6 +4330,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 +4380,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": "الكمية",
@@ -4331,6 +4454,7 @@
"ui.select": "تحديد",
"ui.selectOther": "تحديد آخر…",
"ui.selectTangentChain": "تحديد سلسلة مماسية",
"ui.select_all": "تحديد الكل",
"ui.selection": "التحديد",
"ui.selection_modifiers": "مُعدِّلات التحديد",
"ui.selection_shift_add_only": "Shift مع النقر يضيف دون إزالة",
@@ -4367,7 +4491,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 +4542,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 +4562,7 @@
"ui.tip_angle": "زاوية الرأس",
"ui.to_object": "إلى كائن",
"ui.tool_direction": "اتجاه الأداة",
"ui.toolbar_tabs_hidden": "جميع علامات تبويب شريط الأدوات مخفية. انقر على + أو انقر هنا بزر الفأرة الأيمن لإظهار إحداها.",
"ui.top": "أعلى",
"ui.top_diameter": "القطر العلوي",
"ui.top_radius": "نصف القطر العلوي",
@@ -4475,6 +4609,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 +4663,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",
+115 -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",
@@ -810,6 +810,7 @@
"cmd.browser_move": "Browser-Elemente verschieben",
"cmd.browser_order": "Browser-Elemente ordnen",
"cmd.browser_rename_group": "Gruppe umbenennen",
"cmd.browser_reparent": "In andere Komponente verschieben",
"cmd.browser_ungroup": "Gruppierung aufheben",
"cmd.browser_visibility": "Ein-/Ausblenden",
"cmd.canvas_calibrate": "Zeichenfläche kalibrieren",
@@ -836,6 +837,7 @@
"cmd.component_metadata": "Teileeigenschaften",
"cmd.component_metadata_set": "Teileeigenschaften ändern",
"cmd.component_move_bodies": "Körper in Komponente verschieben",
"cmd.component_move_features": "Merkmale in Komponente verschieben",
"cmd.component_move_sketches": "Skizzen in Komponente verschieben",
"cmd.component_paste_new": "Neu einfügen",
"cmd.component_properties": "Physikalische Eigenschaften der Komponente",
@@ -1063,6 +1065,13 @@
"cmd.render_start": "Bild rendern",
"cmd.render_status": "Renderfortschritt",
"cmd.select_clear": "Auswahl aufheben",
"cmd.settings_profile_apply": "Einstellungsprofil anwenden",
"cmd.settings_profile_delete": "Einstellungsprofil löschen",
"cmd.settings_profile_export": "Einstellungsprofil exportieren",
"cmd.settings_profile_import": "Einstellungsprofil importieren",
"cmd.settings_profile_list": "Einstellungsprofile",
"cmd.settings_profile_rename": "Einstellungsprofil umbenennen",
"cmd.settings_profile_save": "Einstellungsprofil speichern",
"cmd.sheet_convert": "In Blech umwandeln",
"cmd.sheet_export_bend_table": "Biegungstabelle exportieren",
"cmd.sheet_export_dxf": "Flachmuster als DXF exportieren",
@@ -1108,6 +1117,8 @@
"cmd.sketch_drawing_bounds": "Zeichnungsgröße",
"cmd.sketch_edit": "Skizze bearbeiten",
"cmd.sketch_edit_text": "Text bearbeiten",
"cmd.sketch_equation_curve": "Gleichungskurve",
"cmd.sketch_equation_curve_edit": "Gleichungskurve bearbeiten",
"cmd.sketch_export_dxf": "Als DXF speichern",
"cmd.sketch_finish_auto_constrain": "Mit Auto-Bedingung fertigstellen",
"cmd.sketch_fit_curves_to_section": "Kurven an Netzquerschnitt anpassen",
@@ -1204,6 +1215,7 @@
"cmd.view.render_browser": "Render-Browser",
"cmd.view.render_dock": "Render-Panel verschieben",
"cmd.view.render_focus": "Render-Fokusansicht",
"cmd.view.status_items": "Ansichtsstatus-Elemente",
"cmd.view_ambient_occlusion": "Umgebungsverdeckung",
"cmd.view_angle_readout": "Ansichtswinkel",
"cmd.view_apply": "Benannte Ansicht wiederherstellen",
@@ -1355,6 +1367,7 @@
"help.mit": "MIT-Lizenz",
"help.notice": "NOTICE",
"help.pointcloud.insert": "Fügt einen PLY-, XYZ- oder PTS-Scan als Punktwolke neben dem Modell ein. Verwenden Sie ihn als Referenz beim Modellieren anhand von Messdaten.",
"help.sketch.equation_curve": "Zeichnet eine Kurve aus Formeln über einen Bereich: x(t) und y(t) oder y = f(x). Die Formeln können Parameter verwenden, und die Kurve folgt ihnen: für eine Sinuswelle, eine Spirale oder die Evolvente eines Zahnrads.",
"help.solid.chamfer": "Schneidet die gewählten Kanten mit einer ebenen Fase im eingestellten Abstand ab. Damit brechen Sie scharfe Kanten oder erleichtern das Einführen eines Stifts oder einer Schraube.",
"help.solid.combine": "Kombiniert Körper: vereinigt sie, zieht sie von einem Zielkörper ab oder behält nur das gemeinsame Volumen; die Werkzeugkörper können erhalten bleiben. Damit fügen Sie Teile zusammen, schneiden eine Form mit einer anderen aus oder finden, wo sich zwei Körper überschneiden.",
"help.solid.extrude": "Zieht ein Skizzenprofil über eine Strecke in die dritte Dimension und fügt dabei Material hinzu oder entfernt es. Damit machen Sie aus einer flachen Kontur einen Block, eine Erhebung oder eine Tasche.",
@@ -1565,6 +1578,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 +1601,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",
@@ -1692,6 +1710,7 @@
"parts.dimension.thickness": "Dicke",
"parts.dimension.width": "Breite",
"parts.family.bearing": "Rillenkugellager (Hüllform)",
"parts.family.button_head_cap_screw": "Linsenkopfschraube mit Innensechskant",
"parts.family.dowel_pin": "Zylinderstift",
"parts.family.hex_bolt": "Sechskantschraube",
"parts.family.hex_nut": "Sechskantmutter",
@@ -1748,6 +1767,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",
@@ -1818,6 +1840,8 @@
"prefs.layout_note": "Im Layout „Kompakt“ werden Bereiche an den Fensterrändern angedockt. Transparenz für den Browser ist nur im Layout „Schwebend“ verfügbar. Die Position des Assistenten ändern Sie auf der Seite „Assistent“.",
"prefs.left": "Links",
"prefs.left_click": "Linksklick",
"prefs.line_drag_draws_one": "Drücken und Ziehen zeichnet eine Linie",
"prefs.line_drag_draws_one_note": "Im Linienwerkzeug zeichnet Drücken, Ziehen und Loslassen eine Linie vom Drückpunkt bis zum Loslassen, und dort endet sie. Klicks zeichnen weiterhin eine Kette. Aus: Ein Ziehen setzt wie ein Klick einen Punkt dort, wo losgelassen wird.",
"prefs.linked_designs": "Verknüpfte Designs",
"prefs.max_file_size": "Maximale Dateigröße zum Öffnen",
"prefs.max_file_size_design": "Designdateien",
@@ -1829,8 +1853,32 @@
"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.perspective_fov": "Sichtfeld",
"prefs.profile_active": "Aktives Profil",
"prefs.profile_applied": "Profil angewendet.",
"prefs.profile_changed": "Die Einstellungen wurden seit dem Speichern des Profils geändert. „Speichern“ übernimmt sie ins Profil.",
"prefs.profile_deleted": "Profil gelöscht.",
"prefs.profile_export": "Exportieren…",
"prefs.profile_exported": "Profil exportiert.",
"prefs.profile_imported": "Profil importiert. Unter „Aktives Profil“ auswählen, um dazu zu wechseln.",
"prefs.profile_name_hint": "Profilname, z. B. Laptop",
"prefs.profile_rename": "Umbenennen",
"prefs.profile_renamed": "Profil umbenannt.",
"prefs.profile_save": "Speichern",
"prefs.profile_saved": "Aktuelle Einstellungen im Profil gespeichert.",
"prefs.profile_skipped": "Profil angewendet; {n} Einstellung(en) behielten ihren aktuellen Wert (siehe Protokoll).",
"prefs.profiles": "Einstellungsprofile",
"prefs.profiles_note": "Tastenkürzel, Werkzeugleiste, Navigation, Radialmenü und Oberflächeneinstellungen, gemeinsam unter einem Namen gespeichert und auf einmal umgeschaltet.",
"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.ribbon_reveal": "Suchergebnisse im Menüband zeigen",
"prefs.ribbon_reveal_note": "Steht der Zeiger über einem Ergebnis in Befehle suchen, zeigt das Menüband, wo der Befehl liegt: seine Registerkarte, seine Schaltfläche oder seine Zeile im Aufklappmenü eines Panels. Ein aus der Suche gestarteter Befehl blinkt dort, und ein laufender Befehl aus einem Aufklappmenü markiert sein Panel.",
"prefs.search": "Einstellungen suchen",
"prefs.search_empty": "Keine passenden Einstellungen",
"prefs.single_surface_analysis": "Nur eine Flächenanalyse verwenden",
"prefs.single_surface_analysis_help": "Wenn deaktiviert, können Flächenschattierung und Stetigkeitsanalyse gleichzeitig aktiv bleiben. Beim Aktivieren bleibt die zuletzt gestartete oder geänderte Analyse erhalten.",
"prefs.sketch_align_points": "Beim Zeichnen an anderen Skizzenpunkten ausrichten",
"prefs.sketch_align_points_note": "Beim Zeichnen richtet sich ein Punkt zugleich waagerecht an einem Skizzenpunkt und senkrecht an einem anderen aus, mit einer gepunkteten Hilfslinie; der Klick behält beide Ausrichtungen. Aus: nur am vorherigen Punkt, jeweils eine Achse.",
"prefs.sketch_conflict_colour": "Widersprüchliche Geometrie in der Konfliktfarbe",
"prefs.sketch_conflict_colour_note": "Solange die Bedingungen einer Skizze im Konflikt stehen, werden auch die Kurven und Punkte, die eine nicht erfüllte Bedingung hält, in der Konfliktfarbe gezeichnet, nicht nur ihr Symbol oder ihre Bemaßung. Aus: Sie behalten ihre üblichen Farben.",
"prefs.sketch_state_icons": "Skizzensymbole in der Farbe ihres Zustands",
@@ -1857,6 +1905,18 @@
"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",
"prefs.zoom_stop_at_model": "Zoom am Modell stoppen",
"prefs.zoom_stop_at_model_tip": "3D-Maus und Geräte-App zoomen zur Oberfläche in der Ansichtsmitte; Zieh-Zoom hält den Punkt am Beginn des Ziehens fest. Aus: Zoom kann durch das Modell gehen. Mausrad und Pinch verwenden separat Zoom zum Mauszeiger",
"properties.title": "Eigenschaften: {name}",
"recovery.days_ago": "vor {count} Tagen",
"recovery.detail": "{file} · {features} Merkmale · automatisch gespeichert {when}",
@@ -2752,6 +2812,7 @@
"settings.display_quality_note": "Feinere Stufen zeigen gekrümmte Flächen und Kanten glatter und brauchen länger zum Vernetzen. Nur die Ansicht ändert sich, nie das Design oder Exporte.",
"settings.double_click_components": "Doppelklick auf eine Komponente aktiviert sie oder bearbeitet eine verknüpfte an Ort und Stelle",
"settings.frame_rate_note": "Zeigt die Bilder pro Sekunde unten rechts in der Ansicht.",
"settings.insert_lists_open_designs": "Konstruktion einfügen zeigt zuerst die in anderen Tabs geöffneten Konstruktionen",
"settings.measure_axis_distances": "X/Y/Z-Maße anzeigen",
"settings.measure_axis_distances_help": "Bei geöffneter Messung die Abstände entlang der Weltachsen zusätzlich zum Gesamtabstand anzeigen.",
"settings.msaa_applies_on_restart": "Die Kantenglättung wird beim nächsten Start von SolidCraft übernommen",
@@ -2772,6 +2833,10 @@
"shortcut.unpin": "Loslösen",
"sketch is missing": "die Skizze fehlt",
"sketch.degrees_of_freedom": "{count} Freiheitsgrade",
"sketch.dimension_added_driven": "Die Skizze legt dieses Maß bereits fest, daher wurde es als abhängiges Maß (Referenzmaß) hinzugefügt",
"sketch.dimension_applied": "Maß angewendet",
"sketch.dimension_applied_defined": "Maß angewendet; Parameter `{name}` definiert",
"sketch.dimension_applied_renamed": "Maß angewendet; der Parameter heißt jetzt `{name}`",
"sketch.dof_left": "{count} Freiheitsgrade übrig",
"sketch.dof_left_one": "{count} Freiheitsgrad übrig",
"sketch.draw_curves": "Als {linetype} zeichnen",
@@ -2801,6 +2866,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",
@@ -2827,7 +2894,9 @@
"thread.iso_metric": "ISO metrisch",
"thread.select_size": "Größe wählen",
"timeline.delete_folders_with_features_tip": "Löscht die Ordner mit allen Features darin und ihren Unterordnern",
"timeline.edited_rolled_back": "{name} geändert, liegt aber hinter der Verlaufsmarke und wird daher nicht erzeugt: Marke dahinter ziehen, um das Ergebnis zu sehen",
"timeline.feature_hint": "Doppelklicken zum Bearbeiten · Ziehen zum Neuordnen · Rechtsklicken für weitere Optionen",
"timeline.feature_rolled_back": "Zurückgesetzt: hinter der Verlaufsmarke, daher nicht erzeugt",
"timeline.group": "Neuen Merkmalsordner anlegen",
"timeline.move_many": "Zeitleistenelemente verschieben",
"timeline.move_to_group": "Merkmale in Ordner verschieben",
@@ -2908,6 +2977,10 @@
"ui.3d_mouse_offer": "Die Kalibrierung Ihrer 3D-Maus behebt vertauschte oder umgekehrte Achsen und stellt Geschwindigkeit und Gefühl ein.",
"ui.3d_mouse_offer_never": "Nicht mehr anzeigen",
"ui.3d_mouse_offer_swap": "Zu Hidraw wechseln (direkter Treiber)",
"ui.3d_mouse_orbit": "Orbit",
"ui.3d_mouse_orbit_classic": "Klassisch",
"ui.3d_mouse_orbit_tip": "Verfolgt: Die 3D-Maus dreht die Ansicht um den Teil des Modells, der zu Beginn der Drehung in der Ansichtsmitte liegt, markiert durch eine blasse Kugel. Klassisch: um die Ansichtsmitte",
"ui.3d_mouse_orbit_traced": "Verfolgt",
"ui.3d_mouse_reading": "Werte",
"ui.3d_mouse_reset_axes": "Achsenzuordnung zurücksetzen",
"ui.3d_mouse_reset_view": "Ansicht zurücksetzen",
@@ -3333,6 +3406,14 @@
"ui.end_scale": "Endskalierung",
"ui.entities": "Elemente",
"ui.equal": "Gleich",
"ui.equation_end": "Ende",
"ui.equation_explicit": "Explizit",
"ui.equation_form": "Form",
"ui.equation_note": "Die Variable ist eine Zahl ohne Einheit: sin(t) liest sie im Bogenmaß. Ein Ergebnis ohne Einheit ist in der Einheit der Konstruktion.",
"ui.equation_parametric": "Parametrisch",
"ui.equation_x_of_t": "x(t)",
"ui.equation_y_of_t": "y(t)",
"ui.equation_y_of_x": "y(x)",
"ui.equivalent_radius": "Äquivalenter Radius",
"ui.expand": "Aufklappen",
"ui.exportDxf": "Als DXF exportieren…",
@@ -3395,6 +3476,7 @@
"ui.heading_design": "DESIGN",
"ui.heading_draft": "ABZIEHEN",
"ui.heading_edit": "BEARBEITEN",
"ui.heading_equation_curve": "GLEICHUNGSKURVE",
"ui.heading_extrude": "EXTRUDIEREN",
"ui.heading_fillet": "VOLLRUNDUNG",
"ui.heading_finish_editing": "BEARBEITUNG BEENDEN",
@@ -3475,6 +3557,10 @@
"ui.import_ellipsis": "Importieren…",
"ui.infinite": "Unendlich",
"ui.insert": "Einfügen",
"ui.insert_design.browse": "Durchsuchen…",
"ui.insert_design.open_designs": "Geöffnete Konstruktionen",
"ui.insert_design.saved_file": "{name} hat ungespeicherte Änderungen, daher wurde die gespeicherte Datei eingefügt. Speichern Sie sie und wählen Sie dann Verknüpfungen aktualisieren, um die Änderungen zu übernehmen.",
"ui.insert_design.unsaved": "Ungespeicherte Änderungen",
"ui.inspect": "Prüfen",
"ui.intersect": "Schnitt",
"ui.isolate": "Isolieren",
@@ -3569,6 +3655,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 +3696,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",
@@ -3667,6 +3766,7 @@
"ui.select": "Auswählen",
"ui.selectOther": "Andere auswählen…",
"ui.selectTangentChain": "Tangentialkette auswählen",
"ui.select_all": "Alles auswählen",
"ui.selection": "Auswahl",
"ui.selection_modifiers": "Auswahlmodifikatoren",
"ui.selection_shift_add_only": "Umschalt+Klick fügt hinzu, ohne zu entfernen",
@@ -3699,7 +3799,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 +3839,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 +3859,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 +3904,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 +3952,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)",
+138
View File
@@ -206,6 +206,7 @@
"Edges": "Edges",
"Edit": "Edit",
"Edit Profile Sketch": "Edit Profile Sketch",
"Edit shortcuts": "Edit shortcuts",
"Enable rule": "Enable rule",
"Enabled": "Enabled",
"End Radius": "End Radius",
@@ -468,6 +469,7 @@
"Radius": "Radius",
"Radius Fit": "Radius Fit",
"Ratio": "Ratio",
"Read-only list: shortcuts can't be changed here.": "Read-only list: shortcuts can't be changed here.",
"Reattach selected face": "Reattach selected face",
"Recover": "Recover",
"Recover unsaved design?": "Recover unsaved design?",
@@ -652,6 +654,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",
@@ -821,6 +824,7 @@
"cmd.browser_move": "Move Browser Items",
"cmd.browser_order": "Order Browser Items",
"cmd.browser_rename_group": "Rename Group",
"cmd.browser_reparent": "Move to Component",
"cmd.browser_ungroup": "Ungroup",
"cmd.browser_visibility": "Show/Hide",
"cmd.canvas_calibrate": "Calibrate Canvas",
@@ -847,7 +851,9 @@
"cmd.component_metadata": "Part Properties",
"cmd.component_metadata_set": "Change Part Properties",
"cmd.component_move_bodies": "Move Bodies to Component",
"cmd.component_move_features": "Move Features to Component",
"cmd.component_move_sketches": "Move Sketches to Component",
"cmd.component_paste_from_design": "Paste from Another Design",
"cmd.component_paste_new": "Paste New",
"cmd.component_properties": "Component Physical Properties",
"cmd.component_relink": "Relink Source",
@@ -984,6 +990,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",
@@ -1074,6 +1081,13 @@
"cmd.render_start": "Render Image",
"cmd.render_status": "Render Progress",
"cmd.select_clear": "Clear Selection",
"cmd.settings_profile_apply": "Apply Settings Profile",
"cmd.settings_profile_delete": "Delete Settings Profile",
"cmd.settings_profile_export": "Export Settings Profile",
"cmd.settings_profile_import": "Import Settings Profile",
"cmd.settings_profile_list": "Settings Profiles",
"cmd.settings_profile_rename": "Rename Settings Profile",
"cmd.settings_profile_save": "Save Settings Profile",
"cmd.sheet_convert": "Convert to Sheet Metal",
"cmd.sheet_export_bend_table": "Export Bend Table",
"cmd.sheet_export_dxf": "Export Flat Pattern as DXF",
@@ -1119,6 +1133,8 @@
"cmd.sketch_drawing_bounds": "Drawing Size",
"cmd.sketch_edit": "Edit Sketch",
"cmd.sketch_edit_text": "Edit Text",
"cmd.sketch_equation_curve": "Equation Curve",
"cmd.sketch_equation_curve_edit": "Edit Equation Curve",
"cmd.sketch_export_dxf": "Save As DXF",
"cmd.sketch_finish_auto_constrain": "Finish with AutoConstrain",
"cmd.sketch_fit_curves_to_section": "Fit Curves to Mesh Section",
@@ -1215,6 +1231,7 @@
"cmd.view.render_browser": "Render Browser",
"cmd.view.render_dock": "Move Render Dock",
"cmd.view.render_focus": "Render Focus View",
"cmd.view.status_items": "Viewport Status Items",
"cmd.view_ambient_occlusion": "Ambient Occlusion",
"cmd.view_angle_readout": "View Angles",
"cmd.view_apply": "Restore Named View",
@@ -1369,8 +1386,10 @@
"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.sketch.equation_curve": "Draws a curve from formulas: x(t) and y(t), or y = f(x), over a range. The formulas can use parameters and the curve follows them: use it for a sine wave, a spiral or a gear tooth's involute.",
"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.",
"help.solid.chamfer": "Cuts the selected edges with a flat bevel of the distance you set. Use it to break sharp edges or to ease the entry of a pin or screw.",
"help.solid.combine": "Joins bodies, cuts them out of a target body, or keeps only the volume they share, and can keep the tool bodies. Use it to merge parts, to cut one shape with another or to find where two bodies overlap.",
@@ -1499,6 +1518,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 +1627,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 +1650,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",
@@ -1720,6 +1759,7 @@
"parts.dimension.thickness": "Thickness",
"parts.dimension.width": "Width",
"parts.family.bearing": "Deep groove ball bearing (envelope)",
"parts.family.button_head_cap_screw": "Button head cap screw",
"parts.family.dowel_pin": "Parallel pin (dowel)",
"parts.family.hex_bolt": "Hex head bolt / screw",
"parts.family.hex_nut": "Hex nut",
@@ -1778,6 +1818,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",
@@ -1848,6 +1891,8 @@
"prefs.layout_note": "Compact docks panels to the window edges. Browser translucency is available in Floating. Change the Assistant side on the Assistant page.",
"prefs.left": "Left",
"prefs.left_click": "Left click",
"prefs.line_drag_draws_one": "Press and drag draws one line",
"prefs.line_drag_draws_one_note": "In the Line tool, pressing, dragging and letting go draws one line from the press to the release, and it ends there. Clicks still draw a chain. Off: a drag places a point where it is let go, as a click does.",
"prefs.linked_designs": "Linked designs",
"prefs.live_input": "Live input",
"prefs.live_preview": "Live preview",
@@ -1880,14 +1925,36 @@
"prefs.origin_plane_names": "Origin plane names",
"prefs.origin_plane_names_note": "Names shown in the browser, dialogs and lists. {top} is XY, {front} is XZ and {right} is YZ, with Z up. Commands, scripts and saved designs keep XY, XZ and YZ.",
"prefs.overall_speed": "Overall speed",
"prefs.perspective_fov": "Field of view",
"prefs.placement": "Placement",
"prefs.prefer_free_sketch_points": "Prefer movable sketch points when points overlap",
"prefs.preview_choices": "Preview of your choices",
"prefs.profile_active": "Active profile",
"prefs.profile_applied": "Profile applied.",
"prefs.profile_changed": "Settings changed since the profile was saved. Save keeps them in it.",
"prefs.profile_deleted": "Profile deleted.",
"prefs.profile_export": "Export…",
"prefs.profile_exported": "Profile exported.",
"prefs.profile_imported": "Profile imported. Choose it under Active profile to switch to it.",
"prefs.profile_name_hint": "Profile name, e.g. Laptop",
"prefs.profile_rename": "Rename",
"prefs.profile_renamed": "Profile renamed.",
"prefs.profile_save": "Save",
"prefs.profile_saved": "Current settings saved to the profile.",
"prefs.profile_skipped": "Profile applied; {n} setting(s) kept their current value (see the log).",
"prefs.profiles": "Settings profiles",
"prefs.profiles_note": "Shortcuts, the toolbar, navigation, the radial menu and interface settings, kept together under a name and switched at once.",
"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.ribbon_reveal": "Show search results in the ribbon",
"prefs.ribbon_reveal_note": "Hovering a result in Search commands shows where the command sits in the ribbon: its tab, its button or its row in a panel's drop-down. A command started from the search blinks there, and a running command from a drop-down marks its panel.",
"prefs.right_click": "Right click",
"prefs.saving": "Autosave and recovery",
"prefs.scale_note": "Also Ctrl + / Ctrl − in the window. Buttons are 28 px at 100%.",
@@ -1901,6 +1968,10 @@
"prefs.shift_add_ctrl_toggle": "Shift adds only; Ctrl / Cmd toggles",
"prefs.shift_add_note": "On: Shift adds, Ctrl / Cmd toggles. Off (default): both toggle.",
"prefs.shortcuts": "Shortcuts",
"prefs.single_surface_analysis": "Use a single surface analysis",
"prefs.single_surface_analysis_help": "When off, surface shading and Continuity can stay on together. Turning this on keeps the last analysis started or changed.",
"prefs.sketch_align_points": "Line up with other sketch points while drawing",
"prefs.sketch_align_points_note": "While drawing, a point lines up horizontally with one sketch point and vertically with another at the same time, with a dotted guide, and the click keeps both alignments. Off: only with the previous point, one axis at a time.",
"prefs.sketch_conflict_colour": "Conflicting geometry in the conflict color",
"prefs.sketch_conflict_colour_note": "While a sketch's constraints conflict, the curves and points a failing constraint holds are drawn in the conflict color too, not only its glyph or dimension. Off: they keep their usual colors.",
"prefs.sketch_exit_view": "When a sketch ends",
@@ -1952,7 +2023,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,9 +2040,14 @@
"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",
"prefs.zoom_stop_at_model": "Stop zoom at the model",
"prefs.zoom_stop_at_model_tip": "The 3D mouse and device app zoom toward the surface in the middle of the view; drag-zoom holds the point where the drag began. Off: zoom can pass through the model. Wheel and pinch use Zoom to the cursor separately",
"prefs.zoom_toward_cursor": "Zoom, toward the cursor",
"properties.title": "Properties: {name}",
"recovery.days_ago": "{count} days ago",
@@ -3296,6 +3379,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",
@@ -3314,6 +3398,7 @@
"settings.double_click_components": "Double-click a component to activate it, or to edit a linked one in place",
"settings.frame_rate_details_note": "Adds a graph of the last frames' times with the triangles drawn over it, a histogram of frame times and what the GPU drew. Click the frame rate in the view to show or hide them.",
"settings.frame_rate_note": "Shows frames per second in the view's bottom-right corner.",
"settings.insert_lists_open_designs": "Insert Design lists the designs open in other tabs first",
"settings.measure_axis_distances": "Show X/Y/Z distances",
"settings.measure_axis_distances_help": "Show world-axis component dimensions alongside the total distance while Measure is open.",
"settings.msaa_applies_on_restart": "Anti-aliasing applies the next time SolidCraft starts",
@@ -3338,6 +3423,10 @@
"shortcut.unpin": "Unpin",
"sketch is missing": "sketch is missing",
"sketch.degrees_of_freedom": "{count} degrees of freedom",
"sketch.dimension_added_driven": "The sketch already determines that dimension, so it was added as a driven (reference) dimension",
"sketch.dimension_applied": "Dimension applied",
"sketch.dimension_applied_defined": "Dimension applied; parameter `{name}` defined",
"sketch.dimension_applied_renamed": "Dimension applied; parameter named `{name}`",
"sketch.dof_left": "{count} degrees of freedom left",
"sketch.dof_left_one": "{count} degree of freedom left",
"sketch.draw_curves": "Draw as {linetype}",
@@ -3372,6 +3461,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",
@@ -3405,8 +3496,10 @@
"timeline.delete_folders_tip": "Keep their features and nested folders in the history",
"timeline.delete_folders_with_features_tip": "Delete the folders with every feature in them and their nested folders",
"timeline.delete_many": "Delete {count} Selected Features",
"timeline.edited_rolled_back": "{name} changed, but it is after the history marker, so it is not built: move the marker past it to see the result",
"timeline.feature_error": "Error: {message}",
"timeline.feature_hint": "Double-click to edit · drag to reorder · right-click for more",
"timeline.feature_rolled_back": "Rolled back: after the history marker, so not built",
"timeline.feature_warning": "Warning: {message}",
"timeline.group": "New Feature Folder",
"timeline.move_many": "Move Timeline Items",
@@ -3488,6 +3581,10 @@
"ui.3d_mouse_offer": "Calibrating your 3D mouse fixes swapped or reversed axes and sets its speed and feel.",
"ui.3d_mouse_offer_never": "Never show again",
"ui.3d_mouse_offer_swap": "Swap to Hidraw (Direct Driver)",
"ui.3d_mouse_orbit": "Orbit",
"ui.3d_mouse_orbit_classic": "Classic",
"ui.3d_mouse_orbit_tip": "Traced: the 3D mouse turns the view around the part of the model in the middle of the view when the turn begins, marked by a faint ball. Classic: around the view's centre",
"ui.3d_mouse_orbit_traced": "Traced",
"ui.3d_mouse_reading": "Reading",
"ui.3d_mouse_reset_axes": "Reset axis assignment",
"ui.3d_mouse_reset_view": "Reset view",
@@ -3965,6 +4062,14 @@
"ui.end_scale": "End Scale",
"ui.entities": "Entities",
"ui.equal": "Equal",
"ui.equation_end": "End",
"ui.equation_explicit": "Explicit",
"ui.equation_form": "Form",
"ui.equation_note": "The variable is a plain number: sin(t) takes it in radians. A result without a unit is in the design's unit.",
"ui.equation_parametric": "Parametric",
"ui.equation_x_of_t": "x(t)",
"ui.equation_y_of_t": "y(t)",
"ui.equation_y_of_x": "y(x)",
"ui.equivalent_radius": "Equivalent Radius",
"ui.expand": "Expand",
"ui.exportDxf": "Export DXF…",
@@ -4030,6 +4135,7 @@
"ui.heading_design": "DESIGN",
"ui.heading_draft": "DRAFT",
"ui.heading_edit": "EDIT",
"ui.heading_equation_curve": "EQUATION CURVE",
"ui.heading_extrude": "EXTRUDE",
"ui.heading_fillet": "FILLET",
"ui.heading_finish_editing": "FINISH EDITING",
@@ -4121,6 +4227,10 @@
"ui.import_ellipsis": "Import…",
"ui.infinite": "Infinite",
"ui.insert": "Insert",
"ui.insert_design.browse": "Browse…",
"ui.insert_design.open_designs": "Open designs",
"ui.insert_design.saved_file": "{name} has unsaved changes, so its saved file was inserted. Save it, then Update Links to bring the changes in.",
"ui.insert_design.unsaved": "Unsaved changes",
"ui.inspect": "Inspect",
"ui.intersect": "Intersect",
"ui.isolate": "Isolate",
@@ -4220,6 +4330,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 +4380,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",
@@ -4331,6 +4454,7 @@
"ui.select": "Select",
"ui.selectOther": "Select Other…",
"ui.selectTangentChain": "Select Tangent Chain",
"ui.select_all": "Select All",
"ui.selection": "Selection",
"ui.selection_modifiers": "Selection modifiers",
"ui.selection_shift_add_only": "Shift+click adds without removing",
@@ -4367,7 +4491,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 +4542,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 +4562,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 +4609,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 +4663,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",
+17
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",
@@ -3437,6 +3449,7 @@
"ui.select": "Seleccionar",
"ui.selectOther": "Seleccionar otro…",
"ui.selectTangentChain": "Seleccionar cadena tangente",
"ui.select_all": "Seleccionar todo",
"ui.selection": "Selección",
"ui.selection_modifiers": "Modificadores de selección",
"ui.selection_shift_add_only": "Mayús+clic añade sin quitar",
@@ -3520,6 +3533,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",
+35
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",
@@ -3139,8 +3166,10 @@
"timeline.delete_folders_tip": "Garder leurs fonctions et leurs dossiers imbriqués dans l'historique",
"timeline.delete_folders_with_features_tip": "Supprimer les dossiers avec toutes leurs fonctions et leurs dossiers imbriqués",
"timeline.delete_many": "Supprimer {count} fonctions sélectionnées",
"timeline.edited_rolled_back": "{name} modifié, mais il est après le marqueur d'historique et n'est donc pas construit : déplacez le marqueur au-delà pour voir le résultat",
"timeline.feature_error": "Erreur : {message}",
"timeline.feature_hint": "Double-clic pour modifier · glisser pour réordonner · clic droit pour plus",
"timeline.feature_rolled_back": "En retrait : après le marqueur d'historique, donc non construit",
"timeline.feature_warning": "Avertissement : {message}",
"timeline.group": "Nouveau dossier de fonctions",
"timeline.move_many": "Déplacer des éléments de la chronologie",
@@ -3950,6 +3979,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",
@@ -4061,6 +4091,7 @@
"ui.select": "Sélectionner",
"ui.selectOther": "Sélectionner un autre…",
"ui.selectTangentChain": "Sélectionner la chaîne tangente",
"ui.select_all": "Tout sélectionner",
"ui.selection": "Sélection",
"ui.selection_modifiers": "Modificateurs de sélection",
"ui.selection_shift_add_only": "Maj+clic ajoute sans retirer",
@@ -4144,6 +4175,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",
+138
View File
@@ -206,6 +206,7 @@
"Edges": "מקצועות",
"Edit": "עריכה",
"Edit Profile Sketch": "עריכת סקיצת הפרופיל",
"Edit shortcuts": "עריכת קיצורי דרך",
"Enable rule": "הפעלת הכלל",
"Enabled": "מופעל",
"End Radius": "רדיוס סיום",
@@ -468,6 +469,7 @@
"Radius": "רדיוס",
"Radius Fit": "התאמת רדיוס",
"Ratio": "יחס",
"Read-only list: shortcuts can't be changed here.": "רשימה לקריאה בלבד: לא ניתן לשנות כאן קיצורי דרך.",
"Reattach selected face": "הצמדה מחדש לפאה שנבחרה",
"Recover": "שחזור",
"Recover unsaved design?": "לשחזר תכן שלא נשמר?",
@@ -652,6 +654,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": "עומק הברגה",
@@ -821,6 +824,7 @@
"cmd.browser_move": "הזזת פריטים בעץ",
"cmd.browser_order": "סידור פריטים בעץ",
"cmd.browser_rename_group": "שינוי שם קבוצה",
"cmd.browser_reparent": "העברה לרכיב",
"cmd.browser_ungroup": "פירוק קבוצה",
"cmd.browser_visibility": "הצגה/הסתרה",
"cmd.canvas_calibrate": "כיול קנבס",
@@ -847,7 +851,9 @@
"cmd.component_metadata": "מאפייני חלק",
"cmd.component_metadata_set": "שינוי מאפייני חלק",
"cmd.component_move_bodies": "העברת גופים לרכיב",
"cmd.component_move_features": "העברת פיצ'רים לרכיב",
"cmd.component_move_sketches": "העברת סקיצות לרכיב",
"cmd.component_paste_from_design": "הדבק מעיצוב אחר",
"cmd.component_paste_new": "הדבקה כחדש",
"cmd.component_properties": "תכונות פיזיקליות של רכיב",
"cmd.component_relink": "קישור מחדש למקור",
@@ -984,6 +990,7 @@
"cmd.motion_trace": "עקבת תנועה",
"cmd.motion_trace_edit": "עריכת עקבת תנועה",
"cmd.motion_trace_keep": "שמירת עקבת תנועה",
"cmd.occurrence_align": "יישור מופע",
"cmd.occurrence_copy": "הדבקה (מופע)",
"cmd.occurrence_drag": "גרירת מופע",
"cmd.occurrence_ground": "עיגון",
@@ -1074,6 +1081,13 @@
"cmd.render_start": "יצירת תמונת הדמיה",
"cmd.render_status": "התקדמות ההדמיה",
"cmd.select_clear": "ניקוי הבחירה",
"cmd.settings_profile_apply": "החלת פרופיל הגדרות",
"cmd.settings_profile_delete": "מחיקת פרופיל הגדרות",
"cmd.settings_profile_export": "ייצוא פרופיל הגדרות",
"cmd.settings_profile_import": "ייבוא פרופיל הגדרות",
"cmd.settings_profile_list": "פרופילי הגדרות",
"cmd.settings_profile_rename": "שינוי שם פרופיל הגדרות",
"cmd.settings_profile_save": "שמירת פרופיל הגדרות",
"cmd.sheet_convert": "המרה לפח",
"cmd.sheet_export_bend_table": "ייצוא טבלת כיפופים",
"cmd.sheet_export_dxf": "ייצוא פריסה כ־DXF",
@@ -1119,6 +1133,8 @@
"cmd.sketch_drawing_bounds": "גודל הציור",
"cmd.sketch_edit": "עריכת סקיצה",
"cmd.sketch_edit_text": "עריכת טקסט",
"cmd.sketch_equation_curve": "עקומת משוואה",
"cmd.sketch_equation_curve_edit": "עריכת עקומת משוואה",
"cmd.sketch_export_dxf": "שמירה כ־DXF",
"cmd.sketch_finish_auto_constrain": "סיום עם אילוץ אוטומטי",
"cmd.sketch_fit_curves_to_section": "התאמת עקומות לחתך רשת משולשים",
@@ -1215,6 +1231,7 @@
"cmd.view.render_browser": "דפדפן רינדור",
"cmd.view.render_dock": "הזז את עגינת הרינדור",
"cmd.view.render_focus": "תצוגת מיקוד רינדור",
"cmd.view.status_items": "פריטי מצב תצוגה",
"cmd.view_ambient_occlusion": "הצללה סביבתית",
"cmd.view_angle_readout": "זוויות תצוגה",
"cmd.view_apply": "שחזור תצוגה שמורה",
@@ -1369,8 +1386,10 @@
"help.material.assign": "קביעת החומר שממנו עשויים הגופים שנבחרו, למשל פלדה, אלומיניום או ABS. כך המסה והמשקל תואמים את החלק האמיתי.",
"help.mit": "רישיון MIT",
"help.notice": "NOTICE",
"help.occurrence.move": "מזיז חלק בהרכבה רק בכיוונים שהמפרקים שלו מאפשרים. בחרו פאה של החלק, ואז גררו חץ או קשת זמינים.",
"help.parameters.change": "פתיחת הטבלה של ערכים בעלי שם וביטויים שקובעים את מידות התכנון. כך משנים מידה במקום אחד וכל הפיצ'רים שמשתמשים בה מתעדכנים.",
"help.pointcloud.insert": "הוספת סריקה בפורמט PLY, XYZ או PTS כענן נקודות לצד המודל. משמש כעזר למידול על סמך נתוני מדידה.",
"help.sketch.equation_curve": "משרטט עקומה מנוסחאות על פני טווח: x(t) ו־y(t), או y = f(x). הנוסחאות יכולות להשתמש בפרמטרים והעקומה עוקבת אחריהם: לגל סינוס, לספירלה או לאבולבנטה של שן גלגל שיניים.",
"help.solid.align": "הזזת הגופים שנבחרו כך שנקודה, פאה או מישור שלהם יפגשו נקודה, פאה או מישור במקום אחר. מתאים להצמדת חלק לחלק אחר בלי למדוד את המרחק.",
"help.solid.chamfer": "חיתוך המקצועות שנבחרו בשיפוע ישר במרחק שנקבע. כך שוברים מקצועות חדים או מקלים על הכנסה של פין או בורג.",
"help.solid.combine": "איחוד גופים, חיסורם מגוף יעד או השארת הנפח המשותף להם בלבד, עם אפשרות לשמור את גופי הכלי. מתאים למיזוג חלקים, לחיתוך צורה אחת בעזרת אחרת או למציאת החפיפה בין שני גופים.",
@@ -1499,6 +1518,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 +1627,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 +1650,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": "מוסתר בתכן",
@@ -1720,6 +1759,7 @@
"parts.dimension.thickness": "עובי",
"parts.dimension.width": "רוחב",
"parts.family.bearing": "מסב כדורי חריץ עמוק (מעטפת)",
"parts.family.button_head_cap_screw": "בורג ראש כפתור עם שקע משושה",
"parts.family.dowel_pin": "פין גלילי",
"parts.family.hex_bolt": "בורג ראש משושה",
"parts.family.hex_nut": "אום משושה",
@@ -1778,6 +1818,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": "גרירה אמצעית",
@@ -1848,6 +1891,8 @@
"prefs.layout_note": "הפריסה הדחוסה מצמידה חלוניות לשולי החלון. שקיפות עץ התכן זמינה בפריסה הצפה. את צד העוזר אפשר לשנות בעמוד העוזר.",
"prefs.left": "שמאל",
"prefs.left_click": "לחיצה שמאלית",
"prefs.line_drag_draws_one": "לחיצה וגרירה משרטטות קו אחד",
"prefs.line_drag_draws_one_note": "בכלי הקו, לחיצה, גרירה ושחרור משרטטים קו אחד מנקודת הלחיצה עד נקודת השחרור, והוא מסתיים שם. לחיצות עדיין משרטטות שרשרת. כבוי: גרירה מציבה נקודה היכן שמשחררים, כמו לחיצה.",
"prefs.linked_designs": "תכנים מקושרים",
"prefs.live_input": "קלט חי",
"prefs.live_preview": "תצוגה מקדימה חיה",
@@ -1880,14 +1925,36 @@
"prefs.origin_plane_names": "שמות מישורי הראשית",
"prefs.origin_plane_names_note": "השמות המוצגים בדפדפן, בתיבות הדו־שיח וברשימות. {top} הוא XY, {front} הוא XZ ו־{right} הוא YZ, כש־Z למעלה. פקודות, סקריפטים ותכנונים שמורים שומרים על XY, XZ ו־YZ.",
"prefs.overall_speed": "מהירות כללית",
"prefs.perspective_fov": "שדה ראייה",
"prefs.placement": "מיקום",
"prefs.prefer_free_sketch_points": "העדפת נקודות סקיצה ניתנות להזזה כשנקודות חופפות",
"prefs.preview_choices": "תצוגה מקדימה של הבחירות שלך",
"prefs.profile_active": "פרופיל פעיל",
"prefs.profile_applied": "הפרופיל הוחל.",
"prefs.profile_changed": "ההגדרות השתנו מאז שהפרופיל נשמר. „שמירה“ מכניסה אותן לפרופיל.",
"prefs.profile_deleted": "הפרופיל נמחק.",
"prefs.profile_export": "ייצוא…",
"prefs.profile_exported": "הפרופיל יוצא.",
"prefs.profile_imported": "הפרופיל יובא. בחרו בו תחת „פרופיל פעיל“ כדי לעבור אליו.",
"prefs.profile_name_hint": "שם הפרופיל, למשל מחשב נייד",
"prefs.profile_rename": "שינוי שם",
"prefs.profile_renamed": "שם הפרופיל שונה.",
"prefs.profile_save": "שמירה",
"prefs.profile_saved": "ההגדרות הנוכחיות נשמרו בפרופיל.",
"prefs.profile_skipped": "הפרופיל הוחל; {n} הגדרות שמרו על ערכן הנוכחי (ראו ביומן).",
"prefs.profiles": "פרופילי הגדרות",
"prefs.profiles_note": "קיצורי מקלדת, סרגל הכלים, ניווט, התפריט המעגלי והגדרות הממשק, נשמרים יחד תחת שם אחד ומוחלפים בבת אחת.",
"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.ribbon_reveal": "הצגת תוצאות חיפוש בסרט",
"prefs.ribbon_reveal_note": "מעבר עם הסמן מעל תוצאה בחיפוש פקודות מראה היכן הפקודה נמצאת בסרט: הלשונית, הכפתור או השורה שלה בתפריט הנפתח של לוח. פקודה שהופעלה מהחיפוש מהבהבת שם, ופקודה פעילה מתפריט נפתח מסמנת את הלוח שלה.",
"prefs.right_click": "לחיצה ימנית",
"prefs.saving": "שמירה אוטומטית ושחזור",
"prefs.scale_note": "גם Ctrl + / Ctrl − בחלון. הכפתורים בגודל 28 px ב־100%.",
@@ -1901,6 +1968,10 @@
"prefs.shift_add_ctrl_toggle": "Shift מוסיף בלבד; Ctrl / Cmd מחליף מצב",
"prefs.shift_add_note": "כשפועל: Shift מוסיף, Ctrl / Cmd מחליף מצב. כשכבוי (ברירת מחדל): שניהם מחליפים מצב.",
"prefs.shortcuts": "קיצורים",
"prefs.single_surface_analysis": "שימוש בניתוח משטח יחיד",
"prefs.single_surface_analysis_help": "כשהאפשרות כבויה, הצללת המשטח וניתוח הרציפות יכולים להישאר פעילים יחד. הפעלתה משאירה רק את הניתוח האחרון שהופעל או שונה.",
"prefs.sketch_align_points": "יישור לנקודות סקיצה אחרות בזמן השרטוט",
"prefs.sketch_align_points_note": "בזמן השרטוט, נקודה מתיישרת אופקית עם נקודת סקיצה אחת ואנכית עם אחרת בו-זמנית, עם קו עזר מנוקד, והלחיצה שומרת את שני היישורים. כבוי: רק עם הנקודה הקודמת, ציר אחד בכל פעם.",
"prefs.sketch_conflict_colour": "גאומטריה סותרת בצבע הסתירה",
"prefs.sketch_conflict_colour_note": "כשאילוצי סקיצה סותרים, העקומות והנקודות שאילוץ כושל מחזיק מצוירות גם הן בצבע הסתירה, לא רק הסמל או המידה שלו. כבוי: הן שומרות על צבעיהן הרגילים.",
"prefs.sketch_exit_view": "בסיום סקיצה",
@@ -1952,7 +2023,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,9 +2040,14 @@
"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": "סביבת עבודה",
"prefs.zoom_stop_at_model": "עצירת הזום במודל",
"prefs.zoom_stop_at_model_tip": "העכבר התלת־ממדי ויישום ההתקן מקרבים את המבט אל המשטח שבמרכז התצוגה; זום בגרירה שומר על נקודת תחילת הגרירה. כשהאפשרות כבויה, אפשר לעבור דרך המודל. הגלגלת ומחוות הצביטה משתמשות בנפרד בהגדרת הזום אל הסמן",
"prefs.zoom_toward_cursor": "זום, לכיוון הסמן",
"properties.title": "מאפיינים: {name}",
"recovery.days_ago": "לפני {count} ימים",
@@ -3296,6 +3379,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": "יש לבחור תחילה מקצועות של הפח",
@@ -3314,6 +3398,7 @@
"settings.double_click_components": "לחיצה כפולה על רכיב מפעילה אותו, או עורכת רכיב מקושר במקומו",
"settings.frame_rate_details_note": "מוסיף גרף של זמני התמונות האחרונות עם המשולשים שצוירו מעליו, היסטוגרמה של זמני התמונות ואת מה שה־GPU צייר. לחיצה על קצב התמונות בתצוגה מציגה או מסתירה אותם.",
"settings.frame_rate_note": "הצגת מספר הפריימים לשנייה בפינה הימנית התחתונה של התצוגה. כל עוד האפשרות פעילה, התצוגה מצוירת מחדש ברציפות.",
"settings.insert_lists_open_designs": "הוספת תכן מציגה קודם את התכנים הפתוחים בלשוניות אחרות",
"settings.measure_axis_distances": "הצגת מרחקי X/Y/Z",
"settings.measure_axis_distances_help": "הצגת המרחקים לאורך צירי העולם לצד המרחק הכולל כשכלי המדידה פתוח.",
"settings.msaa_applies_on_restart": "החלקת הקצוות תחול בהפעלה הבאה של SolidCraft",
@@ -3338,6 +3423,10 @@
"shortcut.unpin": "ביטול הצמדה",
"sketch is missing": "הסקיצה חסרה",
"sketch.degrees_of_freedom": "{count} דרגות חופש",
"sketch.dimension_added_driven": "הסקיצה כבר קובעת את המידה הזו, ולכן היא נוספה כמידה מונעת (מידת ייחוס)",
"sketch.dimension_applied": "המידה הוחלה",
"sketch.dimension_applied_defined": "המידה הוחלה; הפרמטר `{name}` הוגדר",
"sketch.dimension_applied_renamed": "המידה הוחלה; שם הפרמטר הוא כעת `{name}`",
"sketch.dof_left": "נותרו {count} דרגות חופש",
"sketch.dof_left_one": "נותרה {count} דרגת חופש",
"sketch.draw_curves": "שרטוט כ־{linetype}",
@@ -3372,6 +3461,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": "יחידות התכנון. לחצו כדי לשנות אותן",
@@ -3405,8 +3496,10 @@
"timeline.delete_folders_tip": "שמירת הפיצ'רים והתיקיות המקוננות שלהן בציר הזמן",
"timeline.delete_folders_with_features_tip": "מחיקת התיקיות עם כל הפיצ'רים שבהן והתיקיות המקוננות בהן",
"timeline.delete_many": "מחיקת {count} הפיצ'רים שנבחרו",
"timeline.edited_rolled_back": "{name} שונה, אך הוא אחרי סמן ההיסטוריה ולכן לא נבנה: הזיזו את הסמן אל מעבר אליו כדי לראות את התוצאה",
"timeline.feature_error": "שגיאה: {message}",
"timeline.feature_hint": "לחיצה כפולה לעריכה · גרירה לשינוי הסדר · לחיצה ימנית לעוד",
"timeline.feature_rolled_back": "בוטל: אחרי סמן ההיסטוריה, ולכן לא נבנה",
"timeline.feature_warning": "אזהרה: {message}",
"timeline.group": "תיקיית פיצ'רים חדשה",
"timeline.move_many": "הזזת פריטים בציר הזמן",
@@ -3488,6 +3581,10 @@
"ui.3d_mouse_offer": "כיול העכבר התלת־ממדי מתקן צירים מוחלפים או הפוכים וקובע את המהירות והתחושה שלו.",
"ui.3d_mouse_offer_never": "לא להציג שוב",
"ui.3d_mouse_offer_swap": "מעבר ל־Hidraw (מנהל ישיר)",
"ui.3d_mouse_orbit": "סיבוב",
"ui.3d_mouse_orbit_classic": "קלאסי",
"ui.3d_mouse_orbit_tip": "במעקב: העכבר התלת־ממדי מסובב את המבט סביב החלק של המודל שבמרכז המבט כשהסיבוב מתחיל, ומסמן אותו בכדור חיוור. קלאסי: סביב מרכז המבט",
"ui.3d_mouse_orbit_traced": "במעקב",
"ui.3d_mouse_reading": "קריאה",
"ui.3d_mouse_reset_axes": "איפוס הקצאת הצירים",
"ui.3d_mouse_reset_view": "איפוס המבט",
@@ -3965,6 +4062,14 @@
"ui.end_scale": "קנה מידה בסיום",
"ui.entities": "ישויות",
"ui.equal": "שווה",
"ui.equation_end": "סוף",
"ui.equation_explicit": "מפורשת",
"ui.equation_form": "צורה",
"ui.equation_note": "המשתנה הוא מספר ללא יחידה: sin(t) מקבלת אותו ברדיאנים. תוצאה ללא יחידה היא ביחידה של התכנון.",
"ui.equation_parametric": "פרמטרית",
"ui.equation_x_of_t": "x(t)",
"ui.equation_y_of_t": "y(t)",
"ui.equation_y_of_x": "y(x)",
"ui.equivalent_radius": "רדיוס שקול",
"ui.expand": "הרחבה",
"ui.exportDxf": "ייצוא DXF…",
@@ -4030,6 +4135,7 @@
"ui.heading_design": "תכן",
"ui.heading_draft": "שיפוע יציקה",
"ui.heading_edit": "עריכה",
"ui.heading_equation_curve": "עקומת משוואה",
"ui.heading_extrude": "שליפה",
"ui.heading_fillet": "עיגול פינה",
"ui.heading_finish_editing": "סיום עריכה",
@@ -4121,6 +4227,10 @@
"ui.import_ellipsis": "ייבוא…",
"ui.infinite": "אינסופי",
"ui.insert": "הוספה",
"ui.insert_design.browse": "עיון…",
"ui.insert_design.open_designs": "תכנים פתוחים",
"ui.insert_design.saved_file": "ב-{name} יש שינויים שלא נשמרו, ולכן נוסף הקובץ השמור שלו. שמרו אותו ואז בחרו עדכון קישורים כדי להכניס את השינויים.",
"ui.insert_design.unsaved": "שינויים שלא נשמרו",
"ui.inspect": "בדיקה",
"ui.intersect": "חיתוך משותף",
"ui.isolate": "בידוד",
@@ -4220,6 +4330,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 +4380,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": "כמות",
@@ -4331,6 +4454,7 @@
"ui.select": "בחירה",
"ui.selectOther": "בחירת אחר…",
"ui.selectTangentChain": "בחירת שרשרת משיקה",
"ui.select_all": "בחירת הכול",
"ui.selection": "בחירה",
"ui.selection_modifiers": "מקשי שינוי לבחירה",
"ui.selection_shift_add_only": "Shift+לחיצה מוסיף בלי להסיר",
@@ -4367,7 +4491,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 +4542,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 +4562,7 @@
"ui.tip_angle": "זווית חוד",
"ui.to_object": "עד אובייקט",
"ui.tool_direction": "כיוון הכלי",
"ui.toolbar_tabs_hidden": "כל לשוניות סרגל הכלים מוסתרות. לחצו על + או לחצו כאן לחיצה ימנית כדי להציג לשונית.",
"ui.top": "עליון",
"ui.top_diameter": "קוטר עליון",
"ui.top_radius": "רדיוס עליון",
@@ -4475,6 +4609,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 +4663,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",
+14
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",
@@ -4061,6 +4070,7 @@
"ui.select": "Seleziona",
"ui.selectOther": "Seleziona altro…",
"ui.selectTangentChain": "Seleziona catena tangente",
"ui.select_all": "Seleziona tutto",
"ui.selection": "Selezione",
"ui.selection_modifiers": "Modificatori di selezione",
"ui.selection_shift_add_only": "Maiusc+clic aggiunge senza rimuovere",
@@ -4144,6 +4154,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",
+138
View File
@@ -206,6 +206,7 @@
"Edges": "エッジ",
"Edit": "編集",
"Edit Profile Sketch": "プロファイルスケッチを編集",
"Edit shortcuts": "ショートカットを編集",
"Enable rule": "ルールを有効化",
"Enabled": "有効",
"End Radius": "終了半径",
@@ -468,6 +469,7 @@
"Radius": "半径",
"Radius Fit": "半径フィット",
"Ratio": "比率",
"Read-only list: shortcuts can't be changed here.": "読み取り専用の一覧です。ここではショートカットを変更できません。",
"Reattach selected face": "選択した面を再アタッチ",
"Recover": "復元",
"Recover unsaved design?": "未保存のデザインを復元しますか?",
@@ -652,6 +654,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": "ねじ深さ",
@@ -821,6 +824,7 @@
"cmd.browser_move": "ブラウザ項目を移動",
"cmd.browser_order": "ブラウザ項目の並び順を変更",
"cmd.browser_rename_group": "グループの名前を変更",
"cmd.browser_reparent": "コンポーネントへ移動",
"cmd.browser_ungroup": "グループ解除",
"cmd.browser_visibility": "表示/非表示",
"cmd.canvas_calibrate": "キャンバスの尺度を設定",
@@ -847,7 +851,9 @@
"cmd.component_metadata": "部品プロパティ",
"cmd.component_metadata_set": "部品プロパティを変更",
"cmd.component_move_bodies": "ボディをコンポーネントに移動",
"cmd.component_move_features": "フィーチャーをコンポーネントに移動",
"cmd.component_move_sketches": "スケッチをコンポーネントに移動",
"cmd.component_paste_from_design": "別のデザインから貼り付け",
"cmd.component_paste_new": "新規として貼り付け",
"cmd.component_properties": "コンポーネントの物理的プロパティ",
"cmd.component_relink": "参照元を再リンク",
@@ -984,6 +990,7 @@
"cmd.motion_trace": "モーション トレース",
"cmd.motion_trace_edit": "モーション トレースを編集",
"cmd.motion_trace_keep": "モーション トレースを保持",
"cmd.occurrence_align": "オカレンスを整列",
"cmd.occurrence_copy": "貼り付け(インスタンス)",
"cmd.occurrence_drag": "オカレンスをドラッグ",
"cmd.occurrence_ground": "空間固定",
@@ -1074,6 +1081,13 @@
"cmd.render_start": "画像をレンダリング",
"cmd.render_status": "レンダリングの進行状況",
"cmd.select_clear": "選択をクリア",
"cmd.settings_profile_apply": "設定プロファイルを適用",
"cmd.settings_profile_delete": "設定プロファイルを削除",
"cmd.settings_profile_export": "設定プロファイルをエクスポート",
"cmd.settings_profile_import": "設定プロファイルをインポート",
"cmd.settings_profile_list": "設定プロファイル",
"cmd.settings_profile_rename": "設定プロファイルの名前を変更",
"cmd.settings_profile_save": "設定プロファイルを保存",
"cmd.sheet_convert": "板金に変換",
"cmd.sheet_export_bend_table": "曲げテーブルをエクスポート",
"cmd.sheet_export_dxf": "展開図を DXF としてエクスポート",
@@ -1119,6 +1133,8 @@
"cmd.sketch_drawing_bounds": "図面のサイズ",
"cmd.sketch_edit": "スケッチを編集",
"cmd.sketch_edit_text": "テキストを編集",
"cmd.sketch_equation_curve": "方程式曲線",
"cmd.sketch_equation_curve_edit": "方程式曲線を編集",
"cmd.sketch_export_dxf": "DXF として保存",
"cmd.sketch_finish_auto_constrain": "自動拘束で終了",
"cmd.sketch_fit_curves_to_section": "メッシュ断面に曲線をフィット",
@@ -1215,6 +1231,7 @@
"cmd.view.render_browser": "Renderブラウザー",
"cmd.view.render_dock": "レンダードックを移動",
"cmd.view.render_focus": "Renderフォーカス表示",
"cmd.view.status_items": "ビューポートの状態項目",
"cmd.view_ambient_occlusion": "アンビエントオクルージョン",
"cmd.view_angle_readout": "ビュー角度",
"cmd.view_apply": "名前付きビューを復元",
@@ -1369,8 +1386,10 @@
"help.material.assign": "選択した立体の材料を、鋼、アルミニウム、ABS などに設定します。質量と重さを実際の部品に合わせるときに使います。",
"help.mit": "MITライセンス",
"help.notice": "NOTICE",
"help.occurrence.move": "アセンブリ部品をジョイントが許す自由度だけで動かします。部品の面を選択してから、操作可能な矢印または円弧をドラッグしてください。",
"help.parameters.change": "デザインの寸法を決める名前付きの値と式の表を開きます。1か所で寸法を変え、それを使うすべてのフィーチャに反映させるときに使います。",
"help.pointcloud.insert": "PLY・XYZ・PTS形式のスキャンを点群としてモデルと一緒に挿入します。計測データを参考にモデリングするときに使います。",
"help.sketch.equation_curve": "x(t) と y(t)、または y = f(x) の式から、範囲を指定して曲線を描きます。式にはパラメーターを使え、曲線はその変更に追従します。正弦波、らせん、歯車のインボリュートなどに使います。",
"help.solid.align": "選択した立体を動かし、その点、面、平面を別の場所の点、面、平面に合わせます。距離を測らずに部品を別の部品に当てて配置するときに使います。",
"help.solid.chamfer": "選択した辺を、指定した距離で平らに斜めに削ります。鋭い角を落とすときや、ピンやねじを入れやすくするときに使います。",
"help.solid.combine": "複数の立体を結合したり、対象の立体から切り取ったり、共通する体積だけを残したりでき、工具の立体を残すこともできます。部品の結合、ある形での切り抜き、2つの立体が重なる範囲の確認に使います。",
@@ -1499,6 +1518,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 +1627,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 +1650,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": "デザインでは非表示",
@@ -1720,6 +1759,7 @@
"parts.dimension.thickness": "厚さ",
"parts.dimension.width": "幅",
"parts.family.bearing": "深溝玉軸受(外形)",
"parts.family.button_head_cap_screw": "ボタンヘッド六角穴付きボルト",
"parts.family.dowel_pin": "平行ピン",
"parts.family.hex_bolt": "六角ボルト",
"parts.family.hex_nut": "六角ナット",
@@ -1778,6 +1818,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": "中央ドラッグ",
@@ -1848,6 +1891,8 @@
"prefs.layout_note": "コンパクトではパネルをウィンドウの端に固定します。ブラウザーの半透明表示はフローティングで使用できます。アシスタントの配置はアシスタントページで変更します。",
"prefs.left": "左",
"prefs.left_click": "左クリック",
"prefs.line_drag_draws_one": "押してドラッグすると線を 1 本描く",
"prefs.line_drag_draws_one_note": "線ツールで押してドラッグし、離すと、押した位置から離した位置まで線を 1 本描いてそこで終わります。クリックでは従来どおり連続した線を描きます。オフ: ドラッグはクリックと同じく、離した位置に点を置きます。",
"prefs.linked_designs": "リンクされたデザイン",
"prefs.live_input": "現在の入力",
"prefs.live_preview": "ライブプレビュー",
@@ -1880,14 +1925,36 @@
"prefs.origin_plane_names": "原点平面の名前",
"prefs.origin_plane_names_note": "ブラウザー、ダイアログ、一覧に表示される名前です。{top} は XY、{front} は XZ、{right} は YZ です (Z が上)。コマンド、スクリプト、保存されたデザインでは XY、XZ、YZ のままです。",
"prefs.overall_speed": "全体の速度",
"prefs.perspective_fov": "画角",
"prefs.placement": "配置",
"prefs.prefer_free_sketch_points": "スケッチ点が重なったときは移動可能な点を優先する",
"prefs.preview_choices": "選択した設定のプレビュー",
"prefs.profile_active": "使用中のプロファイル",
"prefs.profile_applied": "プロファイルを適用しました。",
"prefs.profile_changed": "プロファイルの保存後に設定が変更されています。「保存」でプロファイルに反映されます。",
"prefs.profile_deleted": "プロファイルを削除しました。",
"prefs.profile_export": "エクスポート…",
"prefs.profile_exported": "プロファイルをエクスポートしました。",
"prefs.profile_imported": "プロファイルをインポートしました。切り替えるには「使用中のプロファイル」で選択してください。",
"prefs.profile_name_hint": "プロファイル名(例: ノートPC)",
"prefs.profile_rename": "名前を変更",
"prefs.profile_renamed": "プロファイルの名前を変更しました。",
"prefs.profile_save": "保存",
"prefs.profile_saved": "現在の設定をプロファイルに保存しました。",
"prefs.profile_skipped": "プロファイルを適用しました。{n} 件の設定は現在の値のままです(ログを参照)。",
"prefs.profiles": "設定プロファイル",
"prefs.profiles_note": "ショートカット、ツールバー、ナビゲーション、ラジアルメニュー、インターフェース設定を名前を付けてまとめて保存し、一度に切り替えます。",
"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.ribbon_reveal": "検索結果をリボンに表示",
"prefs.ribbon_reveal_note": "コマンド検索で結果にポインターを合わせると、そのコマンドがリボンのどこにあるか(タブ、ボタン、またはパネルのドロップダウン内の行)を表示します。検索から開始したコマンドはそこで点滅し、ドロップダウンから実行中のコマンドはそのパネルに印が付きます。",
"prefs.right_click": "右クリック",
"prefs.saving": "自動保存と復元",
"prefs.scale_note": "ウィンドウ内では Ctrl + / Ctrl − でも変更できます。100% のボタンは 28 px です。",
@@ -1901,6 +1968,10 @@
"prefs.shift_add_ctrl_toggle": "Shift は追加のみ、Ctrl / Cmd は選択を切り替え",
"prefs.shift_add_note": "オン:Shift は追加、Ctrl / Cmd は選択を切り替えます。オフ(既定):どちらも選択を切り替えます。",
"prefs.shortcuts": "ショートカット",
"prefs.single_surface_analysis": "サーフェス解析を1つだけ使用",
"prefs.single_surface_analysis_help": "オフの場合、サーフェスのシェーディングと連続性解析を同時に有効にできます。オンにすると、最後に開始または変更した解析が残ります。",
"prefs.sketch_align_points": "描画中にほかのスケッチ点に揃える",
"prefs.sketch_align_points_note": "描画中、点が一つのスケッチ点と水平に、別の点と垂直に同時に揃い、点線のガイドが表示されます。クリックすると両方の整列が保持されます。オフ: 直前の点にのみ、一度に一方向だけ揃えます。",
"prefs.sketch_conflict_colour": "競合するジオメトリを競合色で表示",
"prefs.sketch_conflict_colour_note": "スケッチの拘束が競合している間、満たされない拘束のグリフや寸法だけでなく、その拘束が保持する曲線と点も競合色で描画します。オフ: 通常の色のままです。",
"prefs.sketch_exit_view": "スケッチ終了時のビュー",
@@ -1952,7 +2023,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,9 +2040,14 @@
"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": "ワークスペース",
"prefs.zoom_stop_at_model": "モデルでズームを止める",
"prefs.zoom_stop_at_model_tip": "3Dマウスとデバイスアプリはビュー中央の表面に向かってズームします。ドラッグズームはドラッグ開始点を固定します。オフの場合はモデルを通り抜けられます。ホイールとピンチは別の「カーソルへズーム」設定を使います",
"prefs.zoom_toward_cursor": "カーソルに向かってズーム",
"properties.title": "プロパティ: {name}",
"recovery.days_ago": "{count}日前",
@@ -3296,6 +3379,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": "板金のエッジを先に選択してください",
@@ -3314,6 +3398,7 @@
"settings.double_click_components": "コンポーネントをダブルクリックしてアクティブにする、またはリンクされたものをその場で編集する",
"settings.frame_rate_details_note": "直近のフレーム時間のグラフ(描画した三角形数を重ねて表示)、フレーム時間のヒストグラム、GPU が描画した内容を追加します。ビューのフレームレートをクリックすると表示/非表示を切り替えます。",
"settings.frame_rate_note": "ビューの右下に 1 秒あたりのフレーム数を表示します。",
"settings.insert_lists_open_designs": "デザインを挿入で、ほかのタブで開いているデザインを先に表示する",
"settings.measure_axis_distances": "計測のX・Y・Z距離を表示",
"settings.measure_axis_distances_help": "計測ダイアログを開いている間、2点間距離に加えてワールドX・Y・Z軸方向の寸法線と距離を表示します。",
"settings.msaa_applies_on_restart": "アンチエイリアスは次回 SolidCraft を起動したときに適用されます",
@@ -3338,6 +3423,10 @@
"shortcut.unpin": "ピン留めを解除",
"sketch is missing": "スケッチが見つかりません",
"sketch.degrees_of_freedom": "自由度 {count}",
"sketch.dimension_added_driven": "スケッチがすでにその寸法を決めているため、従動寸法(参照寸法)として追加しました",
"sketch.dimension_applied": "寸法を適用しました",
"sketch.dimension_applied_defined": "寸法を適用しました。パラメータ `{name}` を定義しました",
"sketch.dimension_applied_renamed": "寸法を適用しました。パラメータ名を `{name}` にしました",
"sketch.dof_left": "残り自由度 {count}",
"sketch.dof_left_one": "残り自由度 {count}",
"sketch.draw_curves": "{linetype}として描く",
@@ -3372,6 +3461,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": "デザインの単位。クリックして変更",
@@ -3405,8 +3496,10 @@
"timeline.delete_folders_tip": "フィーチャーと入れ子のフォルダーを履歴に残します",
"timeline.delete_folders_with_features_tip": "フォルダーを、中のすべてのフィーチャーと入れ子のフォルダーごと削除します",
"timeline.delete_many": "選択した {count} 個のフィーチャーを削除",
"timeline.edited_rolled_back": "{name} を変更しましたが、履歴マーカーより後ろにあるため構築されません。結果を見るにはマーカーをその先へ移動してください",
"timeline.feature_error": "エラー: {message}",
"timeline.feature_hint": "ダブルクリックで編集 · ドラッグで並べ替え · 右クリックでその他の操作",
"timeline.feature_rolled_back": "ロールバック中: 履歴マーカーより後ろのため構築されません",
"timeline.feature_warning": "警告: {message}",
"timeline.group": "フィーチャーフォルダを新規作成",
"timeline.move_many": "タイムライン項目を移動",
@@ -3488,6 +3581,10 @@
"ui.3d_mouse_offer": "3Dマウスをキャリブレーションすると、入れ替わった軸や反転した軸が直り、速度と操作感も整います。",
"ui.3d_mouse_offer_never": "今後表示しない",
"ui.3d_mouse_offer_swap": "Hidraw(直接ドライバー)に切り替える",
"ui.3d_mouse_orbit": "回転",
"ui.3d_mouse_orbit_classic": "クラシック",
"ui.3d_mouse_orbit_tip": "トレース:3Dマウスで回転を始めたときにビューの中央にあるモデルの部分を中心にビューを回転し、その点を淡い球で示します。クラシック:ビューの中心を基準に回転します。",
"ui.3d_mouse_orbit_traced": "トレース",
"ui.3d_mouse_reading": "読み取り値",
"ui.3d_mouse_reset_axes": "軸の割り当てをリセット",
"ui.3d_mouse_reset_view": "表示をリセット",
@@ -3965,6 +4062,14 @@
"ui.end_scale": "終端スケール",
"ui.entities": "エンティティ",
"ui.equal": "等しい",
"ui.equation_end": "終了",
"ui.equation_explicit": "陽関数",
"ui.equation_form": "形式",
"ui.equation_note": "変数は単位のない数値です。sin(t) はラジアンとして扱います。単位のない結果はデザインの単位になります。",
"ui.equation_parametric": "パラメトリック",
"ui.equation_x_of_t": "x(t)",
"ui.equation_y_of_t": "y(t)",
"ui.equation_y_of_x": "y(x)",
"ui.equivalent_radius": "等価半径",
"ui.expand": "展開",
"ui.exportDxf": "DXF をエクスポート…",
@@ -4030,6 +4135,7 @@
"ui.heading_design": "デザイン",
"ui.heading_draft": "勾配",
"ui.heading_edit": "編集",
"ui.heading_equation_curve": "方程式曲線",
"ui.heading_extrude": "押し出し",
"ui.heading_fillet": "フィレット",
"ui.heading_finish_editing": "編集を終了",
@@ -4121,6 +4227,10 @@
"ui.import_ellipsis": "インポート…",
"ui.infinite": "無限",
"ui.insert": "挿入",
"ui.insert_design.browse": "参照…",
"ui.insert_design.open_designs": "開いているデザイン",
"ui.insert_design.saved_file": "{name} に保存されていない変更があるため、保存済みのファイルを挿入しました。保存してから、リンクを更新で変更を取り込んでください。",
"ui.insert_design.unsaved": "未保存の変更",
"ui.inspect": "検査",
"ui.intersect": "交差",
"ui.isolate": "分離表示",
@@ -4220,6 +4330,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 +4380,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": "数量",
@@ -4331,6 +4454,7 @@
"ui.select": "選択",
"ui.selectOther": "その他を選択…",
"ui.selectTangentChain": "接線チェーンを選択",
"ui.select_all": "すべて選択",
"ui.selection": "選択",
"ui.selection_modifiers": "選択修飾キー",
"ui.selection_shift_add_only": "Shift+クリックで追加のみ(解除なし)",
@@ -4367,7 +4491,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 +4542,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 +4562,7 @@
"ui.tip_angle": "先端角度",
"ui.to_object": "オブジェクトまで",
"ui.tool_direction": "工具方向",
"ui.toolbar_tabs_hidden": "ツールバーのタブはすべて非表示です。+ をクリックするか、ここを右クリックして表示します。",
"ui.top": "上面",
"ui.top_diameter": "上面の直径",
"ui.top_radius": "上面の半径",
@@ -4475,6 +4609,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 +4663,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": "後で",
+75
View File
@@ -716,6 +716,7 @@
"cmd.browser_move": "브라우저 항목 이동",
"cmd.browser_order": "브라우저 항목 정렬",
"cmd.browser_rename_group": "그룹 이름 변경",
"cmd.browser_reparent": "구성 요소로 이동",
"cmd.browser_ungroup": "그룹 해제",
"cmd.browser_visibility": "표시/숨기기",
"cmd.canvas_calibrate": "캔버스 보정",
@@ -738,6 +739,7 @@
"cmd.component_metadata": "부품 속성",
"cmd.component_metadata_set": "부품 속성 변경",
"cmd.component_move_bodies": "바디를 구성 요소로 이동",
"cmd.component_move_features": "특성을 구성 요소로 이동",
"cmd.component_move_sketches": "스케치를 구성 요소로 이동",
"cmd.component_paste_new": "새로 붙여넣기",
"cmd.component_properties": "구성 요소 물리 속성",
@@ -985,6 +987,8 @@
"cmd.sketch_draw_dimensioned": "치수가 있는 스케치 개체 그리기",
"cmd.sketch_edit": "스케치 편집",
"cmd.sketch_edit_text": "텍스트 편집",
"cmd.sketch_equation_curve": "방정식 곡선",
"cmd.sketch_equation_curve_edit": "방정식 곡선 편집",
"cmd.sketch_export_dxf": "DXF로 저장",
"cmd.sketch_finish_auto_constrain": "자동 구속으로 완료",
"cmd.sketch_fit_curves_to_section": "메시 단면에 곡선 맞추기",
@@ -1079,6 +1083,7 @@
"cmd.view.render_browser": "렌더 브라우저",
"cmd.view.render_dock": "렌더 도크 이동",
"cmd.view.render_focus": "렌더 집중 보기",
"cmd.view.status_items": "뷰포트 상태 항목",
"cmd.view_ambient_occlusion": "앰비언트 오클루전",
"cmd.view_angle_readout": "뷰 각도",
"cmd.view_apply": "이름 있는 보기 복원",
@@ -1194,6 +1199,7 @@
"help.mit": "MIT 라이선스",
"help.notice": "NOTICE",
"help.pointcloud.insert": "PLY, XYZ 또는 PTS 스캔을 모델 옆에 포인트 클라우드로 삽입합니다. 측정 데이터를 참고하여 모델링할 때 사용합니다.",
"help.sketch.equation_curve": "범위에 걸쳐 수식으로 곡선을 그립니다: x(t)와 y(t), 또는 y = f(x). 수식에 매개변수를 쓸 수 있으며 곡선이 그 값을 따라갑니다. 사인파, 나선, 기어 이의 인벌류트에 사용합니다.",
"help.solid.chamfer": "선택한 모서리를 지정한 거리만큼 평평하게 깎습니다. 날카로운 모서리를 다듬거나 핀 또는 나사가 쉽게 들어가도록 할 때 사용합니다.",
"help.solid.extrude": "스케치 프로파일을 지정한 거리만큼 3D로 밀어내 재료를 더하거나 제거합니다. 평면 윤곽을 블록, 보스 또는 포켓으로 만들 때 사용합니다.",
"help.solid.fillet": "선택한 모서리를 지정한 반지름의 매끄러운 원호로 둥글게 만듭니다. 강도, 편의성 또는 외관을 위해 날카로운 모서리를 부드럽게 할 때 사용합니다.",
@@ -1330,6 +1336,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을 누른 채 움직이면 회전합니다. 마우스 버튼은 필요 없습니다. 세로 스크롤로 확대/축소합니다.",
@@ -1558,6 +1566,8 @@
"prefs.layout_note": "컴팩트는 패널을 창 가장자리에 고정합니다. 브라우저 반투명은 플로팅에서 사용할 수 있습니다. 어시스턴트 위치는 어시스턴트 페이지에서 바꿀 수 있습니다.",
"prefs.left": "왼쪽",
"prefs.left_click": "왼쪽 클릭",
"prefs.line_drag_draws_one": "누른 채 드래그하면 선 하나 그리기",
"prefs.line_drag_draws_one_note": "선 도구에서 누르고 드래그한 뒤 놓으면 누른 곳에서 놓은 곳까지 선 하나를 그리고 거기서 끝납니다. 클릭하면 이전처럼 이어지는 선을 그립니다. 끄면: 드래그는 클릭처럼 놓은 곳에 점을 둡니다.",
"prefs.live_input": "실시간 입력",
"prefs.live_preview": "실시간 미리보기",
"prefs.max_file_size": "열 수 있는 최대 파일 크기",
@@ -1582,7 +1592,10 @@
"prefs.origin_plane_names": "원점 평면 이름",
"prefs.origin_plane_names_note": "브라우저, 대화 상자, 목록에 표시되는 이름입니다. {top}은 XY, {front}는 XZ, {right}는 YZ이며 Z가 위쪽입니다. 명령, 스크립트, 저장된 디자인은 XY, XZ, YZ를 유지합니다.",
"prefs.overall_speed": "전체 속도",
"prefs.perspective_fov": "시야각",
"prefs.preview_choices": "선택 항목 미리보기",
"prefs.project_options_dialog": "Project 옵션 대화상자",
"prefs.project_options_dialog_note": "켜기: 투영 전에 지오메트리 또는 바디와 투영 링크를 선택합니다. 끄기: 기존 직접 선택 작업 흐름을 유지합니다.",
"prefs.quality_default": "기본값",
"prefs.quality_fastest": "가장 빠름",
"prefs.quality_slowest": "가장 느림",
@@ -1601,6 +1614,10 @@
"prefs.shift_add_ctrl_toggle": "Shift는 추가만, Ctrl / Cmd는 토글",
"prefs.shift_add_note": "켬: Shift는 추가, Ctrl / Cmd는 토글. 끔(기본값): 둘 다 토글.",
"prefs.shortcuts": "단축키",
"prefs.single_surface_analysis": "표면 분석 하나만 사용",
"prefs.single_surface_analysis_help": "끄면 표면 음영 분석과 연속성 분석을 함께 유지할 수 있습니다. 켜면 마지막으로 시작하거나 변경한 분석만 유지됩니다.",
"prefs.sketch_align_points": "그리는 동안 다른 스케치 점에 맞추기",
"prefs.sketch_align_points_note": "그리는 동안 점이 한 스케치 점과 수평으로, 다른 점과 수직으로 동시에 맞춰지고 점선 안내선이 표시됩니다. 클릭하면 두 정렬이 모두 유지됩니다. 끄기: 이전 점에만, 한 번에 한 축씩 맞춥니다.",
"prefs.sketch_conflict_colour": "충돌하는 기하를 충돌 색상으로 표시",
"prefs.sketch_conflict_colour_note": "스케치의 구속 조건이 충돌하는 동안 실패한 구속 조건의 글리프나 치수뿐 아니라 그 구속 조건이 잡고 있는 곡선과 점도 충돌 색상으로 그립니다. 끄기: 평소 색상을 유지합니다.",
"prefs.sketch_selection": "스케치 및 선택",
@@ -1638,7 +1655,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": "커서 아래",
@@ -1651,6 +1675,8 @@
"prefs.wheel": "휠",
"prefs.wheel_trackpad": "휠 및 트랙패드",
"prefs.workspace": "작업 공간",
"prefs.zoom_stop_at_model": "모델에서 확대/축소 멈추기",
"prefs.zoom_stop_at_model_tip": "3D 마우스와 장치 앱은 뷰 중앙의 표면을 향해 확대/축소하고, 드래그 확대/축소는 드래그 시작점을 고정합니다. 끄면 모델을 통과할 수 있습니다. 휠과 핀치는 별도의 커서로 확대/축소 설정을 사용합니다",
"prefs.zoom_toward_cursor": "확대/축소, 커서 방향으로",
"properties.title": "속성: {name}",
"recovery.days_ago": "{count}일 전",
@@ -2545,6 +2571,7 @@
"settings.display_quality_note": "세밀할수록 곡면과 모서리가 더 매끄럽게 표시되지만 메시 생성 시간이 늘어납니다. 뷰포트만 바뀌며 설계나 내보내기는 바뀌지 않습니다.",
"settings.double_click_components": "구성 요소를 두 번 클릭하여 활성화하거나 연결된 구성 요소를 제자리에서 편집",
"settings.frame_rate_note": "뷰의 오른쪽 아래에 초당 프레임 수를 표시합니다.",
"settings.insert_lists_open_designs": "설계 삽입에서 다른 탭에 열린 설계를 먼저 표시",
"settings.measure_axis_distances": "X/Y/Z 거리 표시",
"settings.measure_axis_distances_help": "측정 대화상자가 열려 있는 동안 전체 거리와 함께 월드 축 방향 치수와 거리를 표시합니다.",
"settings.msaa_applies_on_restart": "안티앨리어싱은 다음에 SolidCraft를 시작할 때 적용됩니다",
@@ -2564,6 +2591,10 @@
"shortcut.unpin": "고정 해제",
"sketch is missing": "스케치가 없습니다",
"sketch.degrees_of_freedom": "자유도 {count}개",
"sketch.dimension_added_driven": "스케치가 이미 이 치수를 결정하므로 종속(참조) 치수로 추가했습니다",
"sketch.dimension_applied": "치수를 적용했습니다",
"sketch.dimension_applied_defined": "치수를 적용했습니다. 매개변수 `{name}`을(를) 정의했습니다",
"sketch.dimension_applied_renamed": "치수를 적용했습니다. 매개변수 이름을 `{name}`(으)로 지정했습니다",
"sketch.dof_left": "남은 자유도 {count}개",
"sketch.dof_left_one": "남은 자유도 {count}개",
"sketch.draw_curves": "{linetype}(으)로 그리기",
@@ -2698,6 +2729,10 @@
"ui.3d_mouse_offer": "3D 마우스를 보정하면 바뀌거나 반전된 축이 바로잡히고 속도와 조작감이 맞춰집니다.",
"ui.3d_mouse_offer_never": "다시 표시하지 않음",
"ui.3d_mouse_offer_swap": "Hidraw(직접 드라이버)로 전환",
"ui.3d_mouse_orbit": "궤도 회전",
"ui.3d_mouse_orbit_classic": "클래식",
"ui.3d_mouse_orbit_tip": "추적: 회전을 시작할 때 뷰 가운데에 있는 모델 부분을 중심으로 3D 마우스가 뷰를 회전하며, 그 점을 희미한 구로 표시합니다. 클래식: 뷰의 중심을 기준으로",
"ui.3d_mouse_orbit_traced": "추적",
"ui.3d_mouse_reading": "읽은 값",
"ui.3d_mouse_reset_axes": "축 지정 초기화",
"ui.3d_mouse_reset_view": "뷰 초기화",
@@ -3119,6 +3154,14 @@
"ui.end_scale": "끝 배율",
"ui.entities": "요소",
"ui.equal": "동일",
"ui.equation_end": "끝",
"ui.equation_explicit": "명시적",
"ui.equation_form": "형식",
"ui.equation_note": "변수는 단위가 없는 수입니다. sin(t)는 라디안으로 읽습니다. 단위가 없는 결과는 디자인 단위입니다.",
"ui.equation_parametric": "매개변수식",
"ui.equation_x_of_t": "x(t)",
"ui.equation_y_of_t": "y(t)",
"ui.equation_y_of_x": "y(x)",
"ui.equivalent_radius": "등가 반지름",
"ui.expand": "펼치기",
"ui.exportDxf": "DXF 내보내기…",
@@ -3177,6 +3220,7 @@
"ui.heading_design": "디자인",
"ui.heading_draft": "구배",
"ui.heading_edit": "편집",
"ui.heading_equation_curve": "방정식 곡선",
"ui.heading_extrude": "돌출",
"ui.heading_fillet": "필렛",
"ui.heading_finish_editing": "편집 완료",
@@ -3264,6 +3308,10 @@
"ui.import_ellipsis": "가져오기…",
"ui.infinite": "무한",
"ui.insert": "삽입",
"ui.insert_design.browse": "찾아보기…",
"ui.insert_design.open_designs": "열린 설계",
"ui.insert_design.saved_file": "{name}에 저장되지 않은 변경 사항이 있어 저장된 파일을 삽입했습니다. 저장한 다음 링크 업데이트로 변경 사항을 가져오세요.",
"ui.insert_design.unsaved": "저장되지 않은 변경 사항",
"ui.inspect": "검사",
"ui.intersect": "교차",
"ui.isolate": "분리 표시",
@@ -3354,6 +3402,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 +3443,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": "수량",
@@ -3451,6 +3512,7 @@
"ui.select": "선택",
"ui.selectOther": "기타 선택…",
"ui.selectTangentChain": "접선 체인 선택",
"ui.select_all": "모두 선택",
"ui.selection": "선택",
"ui.selection_modifiers": "선택 보조 키",
"ui.selection_shift_add_only": "Shift+클릭은 추가만 함",
@@ -3483,7 +3545,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 +3585,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 +3605,7 @@
"ui.tip_angle": "선단 각도",
"ui.to_object": "객체까지",
"ui.tool_direction": "공구 방향",
"ui.toolbar_tabs_hidden": "모든 도구 모음 탭이 숨겨져 있습니다. +를 클릭하거나 여기를 마우스 오른쪽 버튼으로 클릭하여 표시하세요.",
"ui.top": "상면",
"ui.top_diameter": "윗면 지름",
"ui.top_radius": "윗면 반지름",
@@ -3625,6 +3697,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",
+9 -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",
@@ -1823,6 +1823,8 @@
"prefs.layout_note": "Compact plaatst panelen vast aan de vensterranden. Doorschijnendheid van de browser is beschikbaar in Zwevend. Wijzig de kant van de Assistent op de pagina Assistent.",
"prefs.left": "Links",
"prefs.left_click": "Linksklik",
"prefs.line_drag_draws_one": "Indrukken en slepen tekent één lijn",
"prefs.line_drag_draws_one_note": "In het lijngereedschap tekent indrukken, slepen en loslaten één lijn van het indrukpunt tot het loslaatpunt, en daar eindigt de lijn. Klikken tekent nog steeds een keten. Uit: slepen zet, net als een klik, een punt waar losgelaten wordt.",
"prefs.linked_designs": "Gekoppelde ontwerpen",
"prefs.live_input": "Live invoer",
"prefs.live_preview": "Live voorbeeld",
@@ -3954,6 +3956,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",
@@ -4065,6 +4068,7 @@
"ui.select": "Selecteren",
"ui.selectOther": "Overige selecteren…",
"ui.selectTangentChain": "Tangentiële keten selecteren",
"ui.select_all": "Alles selecteren",
"ui.selection": "Selectie",
"ui.selection_modifiers": "Selectiemodificatoren",
"ui.selection_shift_add_only": "Shift+klik voegt toe zonder te verwijderen",
@@ -4148,6 +4152,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",
+10
View File
@@ -1300,6 +1300,10 @@
"shortcut.pin": "Przypnij na górze pola S",
"shortcut.unpin": "Odepnij",
"sketch.degrees_of_freedom": "Stopnie swobody: {count}",
"sketch.dimension_added_driven": "Szkic już określa ten wymiar, więc dodano go jako wymiar sterowany (referencyjny)",
"sketch.dimension_applied": "Wymiar zastosowany",
"sketch.dimension_applied_defined": "Wymiar zastosowany; zdefiniowano parametr `{name}`",
"sketch.dimension_applied_renamed": "Wymiar zastosowany; parametr nazywa się teraz `{name}`",
"sketch.dof_left": "Pozostałe stopnie swobody: {count}",
"sketch.dof_left_one": "Pozostał {count} stopień swobody",
"sketch.draw_curves": "Rysuj jako {linetype}",
@@ -2030,6 +2034,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",
@@ -2113,6 +2118,7 @@
"ui.select": "Zaznacz",
"ui.selectOther": "Wybierz inny…",
"ui.selectTangentChain": "Zaznacz łańcuch styczny",
"ui.select_all": "Zaznacz wszystko",
"ui.selection": "Zaznaczenie",
"ui.selection_modifiers": "Modyfikatory zaznaczania",
"ui.selection_shift_add_only": "Shift+kliknięcie tylko dodaje",
@@ -2174,6 +2180,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",
+6
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",
@@ -2243,6 +2244,7 @@
"ui.select": "Selecionar",
"ui.selectOther": "Selecionar outro…",
"ui.selectTangentChain": "Selecionar cadeia tangente",
"ui.select_all": "Selecionar tudo",
"ui.selection": "Seleção",
"ui.selection_modifiers": "Modificadores de seleção",
"ui.selection_shift_add_only": "Shift+clique adiciona sem remover",
@@ -2304,6 +2306,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",
+6
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": "Траектория",
@@ -3802,6 +3803,7 @@
"ui.select": "Выбрать",
"ui.selectOther": "Выбрать другое…",
"ui.selectTangentChain": "Выбрать касательную цепочку",
"ui.select_all": "Выделить всё",
"ui.selection": "Выделение",
"ui.selection_modifiers": "Модификаторы выбора",
"ui.selection_shift_add_only": "Shift+щелчок добавляет без удаления",
@@ -3885,6 +3887,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",
+22
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",
@@ -4064,6 +4081,7 @@
"ui.select": "Seç",
"ui.selectOther": "Diğerini seç…",
"ui.selectTangentChain": "Teğet zinciri seç",
"ui.select_all": "Tümünü seç",
"ui.selection": "Seçim",
"ui.selection_modifiers": "Seçim değiştiricileri",
"ui.selection_shift_add_only": "Shift+tıklama kaldırmadan ekler",
@@ -4147,6 +4165,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",
+6
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": "Траєкторія",
@@ -4061,6 +4062,7 @@
"ui.select": "Вибрати",
"ui.selectOther": "Вибрати інше…",
"ui.selectTangentChain": "Вибрати дотичний ланцюг",
"ui.select_all": "Виділити все",
"ui.selection": "Виділення",
"ui.selection_modifiers": "Модифікатори вибору",
"ui.selection_shift_add_only": "Shift+клацання додає без вилучення",
@@ -4144,6 +4146,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": "З різьбою",
+138
View File
@@ -206,6 +206,7 @@
"Edges": "Các cạnh",
"Edit": "Sửa",
"Edit Profile Sketch": "Chỉnh sửa phác thảo biên dạng",
"Edit shortcuts": "Sửa phím tắt",
"Enable rule": "Bật quy tắc",
"Enabled": "Đã kích hoạt",
"End Radius": "Bán kính kết thúc",
@@ -468,6 +469,7 @@
"Radius": "Bán kính",
"Radius Fit": "Bán kính vừa vặn",
"Ratio": "Tỷ lệ",
"Read-only list: shortcuts can't be changed here.": "Danh sách chỉ đọc: không thể thay đổi phím tắt ở đây.",
"Reattach selected face": "Gắn lại bề mặt đã chọn",
"Recover": "Khôi phục",
"Recover unsaved design?": "Khôi phục thiết kế chưa lưu?",
@@ -652,6 +654,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",
@@ -821,6 +824,7 @@
"cmd.browser_move": "Di chuyển mục trong cây đối tượng",
"cmd.browser_order": "Sắp xếp các mục trong cây đối tượng",
"cmd.browser_rename_group": "Đổi tên nhóm",
"cmd.browser_reparent": "Chuyển sang Cụm chi tiết",
"cmd.browser_ungroup": "Hủy nhóm",
"cmd.browser_visibility": "Hiển thị/Ẩn",
"cmd.canvas_calibrate": "Hiệu chuẩn hình ảnh Canvas",
@@ -847,7 +851,9 @@
"cmd.component_metadata": "Thuộc tính chi tiết",
"cmd.component_metadata_set": "Thay đổi thuộc tính chi tiết",
"cmd.component_move_bodies": "Di chuyển các thân khối vào cụm chi tiết",
"cmd.component_move_features": "Di chuyển các tính năng vào cụm chi tiết",
"cmd.component_move_sketches": "Di chuyển các phác thảo vào cụm chi tiết",
"cmd.component_paste_from_design": "Dán từ thiết kế khác",
"cmd.component_paste_new": "Dán Tạo mới",
"cmd.component_properties": "Thuộc tính cơ lý của cụm chi tiết",
"cmd.component_relink": "Liên kết lại nguồn",
@@ -984,6 +990,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",
@@ -1074,6 +1081,13 @@
"cmd.render_start": "Bắt đầu kết xuất hình ảnh",
"cmd.render_status": "Tiến độ kết xuất hình ảnh",
"cmd.select_clear": "Hủy chọn tất cả",
"cmd.settings_profile_apply": "Áp dụng hồ sơ cài đặt",
"cmd.settings_profile_delete": "Xóa hồ sơ cài đặt",
"cmd.settings_profile_export": "Xuất hồ sơ cài đặt",
"cmd.settings_profile_import": "Nhập hồ sơ cài đặt",
"cmd.settings_profile_list": "Hồ sơ cài đặt",
"cmd.settings_profile_rename": "Đổi tên hồ sơ cài đặt",
"cmd.settings_profile_save": "Lưu hồ sơ cài đặt",
"cmd.sheet_convert": "Chuyển thành kim loại tấm",
"cmd.sheet_export_bend_table": "Xuất bảng thông số chấn uốn (Bend Table)",
"cmd.sheet_export_dxf": "Xuất bản trải phẳng dạng DXF",
@@ -1119,6 +1133,8 @@
"cmd.sketch_drawing_bounds": "Kích thước bản vẽ",
"cmd.sketch_edit": "Chỉnh sửa phác thảo",
"cmd.sketch_edit_text": "Chỉnh sửa văn bản",
"cmd.sketch_equation_curve": "Đường cong phương trình",
"cmd.sketch_equation_curve_edit": "Sửa đường cong phương trình",
"cmd.sketch_export_dxf": "Lưu dưới dạng DXF",
"cmd.sketch_finish_auto_constrain": "Hoàn tất với tự động ràng buộc",
"cmd.sketch_fit_curves_to_section": "Khớp đường cong vào mặt cắt lưới",
@@ -1215,6 +1231,7 @@
"cmd.view.render_browser": "Trình duyệt kết xuất",
"cmd.view.render_dock": "Di chuyển neo kết xuất",
"cmd.view.render_focus": "Chế độ xem tập trung kết xuất",
"cmd.view.status_items": "Các mục trạng thái khung nhìn",
"cmd.view_ambient_occlusion": "Đổ bóng che khuất môi trường (AO)",
"cmd.view_angle_readout": "Góc hướng nhìn",
"cmd.view_apply": "Áp dụng góc nhìn đã lưu",
@@ -1369,8 +1386,10 @@
"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.sketch.equation_curve": "Vẽ một đường cong từ công thức trên một khoảng: x(t) và y(t), hoặc y = f(x). Công thức có thể dùng tham số và đường cong thay đổi theo: dùng cho sóng sin, đường xoắn ốc hay đường thân khai của răng bánh răng.",
"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.",
"help.solid.chamfer": "Vát các cạnh đã chọn theo khoảng cách bạn thiết lập. Dùng để triệt tiêu cạnh sắc hoặc giúp lắp chốt, bu lông dễ dàng hơn.",
"help.solid.combine": "Hợp nhất các khối, cắt trừ khỏi khối đích, hoặc chỉ giữ lại phần thể tích giao nhau, đồng thời có thể giữ lại các khối công cụ. Dùng để gộp chi tiết, dùng hình này cắt hình kia hoặc tìm vị trí hai khối giao nhau.",
@@ -1499,6 +1518,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 +1627,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 +1650,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ế",
@@ -1720,6 +1759,7 @@
"parts.dimension.thickness": "Độ dày",
"parts.dimension.width": "Bề rộng",
"parts.family.bearing": "Ổ bi cầu rãnh sâu (bao ngoài)",
"parts.family.button_head_cap_screw": "Vít đầu tròn lục giác chìm",
"parts.family.dowel_pin": "Chốt trụ",
"parts.family.hex_bolt": "Bu lông đầu lục giác",
"parts.family.hex_nut": "Đai ốc lục giác",
@@ -1778,6 +1818,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",
@@ -1848,6 +1891,8 @@
"prefs.layout_note": "Chế độ Gọn (Compact) gắn các bảng vào mép cửa sổ. Độ trong suốt của Cây thư mục chỉ khả dụng ở chế độ Nổi (Floating). Thay đổi vị trí của Trợ lý trong trang Trợ lý.",
"prefs.left": "Bên trái",
"prefs.left_click": "Nhấp chuột trái",
"prefs.line_drag_draws_one": "Nhấn và kéo vẽ một đường",
"prefs.line_drag_draws_one_note": "Trong công cụ Đường, nhấn, kéo rồi thả sẽ vẽ một đường từ điểm nhấn đến điểm thả, và kết thúc ở đó. Nhấp vẫn vẽ một chuỗi. Tắt: kéo đặt một điểm tại nơi thả, như một lần nhấp.",
"prefs.linked_designs": "Thiết kế liên kết",
"prefs.live_input": "Nhập liệu trực tiếp",
"prefs.live_preview": "Xem trước trực tiếp",
@@ -1880,14 +1925,36 @@
"prefs.origin_plane_names": "Tên các mặt phẳng gốc",
"prefs.origin_plane_names_note": "Tên hiển thị trong trình duyệt, hộp thoại và danh sách. {top} là XY, {front} là XZ và {right} là YZ, với Z hướng lên. Lệnh, script và thiết kế đã lưu vẫn dùng XY, XZ và YZ.",
"prefs.overall_speed": "Tốc độ tổng thể",
"prefs.perspective_fov": "Trường nhìn",
"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.profile_active": "Hồ sơ đang dùng",
"prefs.profile_applied": "Đã áp dụng hồ sơ.",
"prefs.profile_changed": "Cài đặt đã thay đổi kể từ khi lưu hồ sơ. “Lưu” sẽ ghi chúng vào hồ sơ.",
"prefs.profile_deleted": "Đã xóa hồ sơ.",
"prefs.profile_export": "Xuất tệp…",
"prefs.profile_exported": "Đã xuất hồ sơ.",
"prefs.profile_imported": "Đã nhập hồ sơ. Chọn hồ sơ trong “Hồ sơ đang dùng” để chuyển sang.",
"prefs.profile_name_hint": "Tên hồ sơ, ví dụ Laptop",
"prefs.profile_rename": "Đổi tên",
"prefs.profile_renamed": "Đã đổi tên hồ sơ.",
"prefs.profile_save": "Lưu",
"prefs.profile_saved": "Đã lưu cài đặt hiện tại vào hồ sơ.",
"prefs.profile_skipped": "Đã áp dụng hồ sơ; {n} cài đặt giữ nguyên giá trị hiện tại (xem nhật ký).",
"prefs.profiles": "Hồ sơ cài đặt",
"prefs.profiles_note": "Phím tắt, thanh công cụ, điều hướng, menu dạng vòng và cài đặt giao diện, được lưu cùng nhau dưới một tên và chuyển đổi cùng lúc.",
"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.ribbon_reveal": "Hiện kết quả tìm kiếm trên ribbon",
"prefs.ribbon_reveal_note": "Khi rê chuột lên một kết quả trong Tìm lệnh, ribbon cho biết lệnh nằm ở đâu: tab, nút hoặc dòng của nó trong menu thả xuống của một bảng. Lệnh bắt đầu từ tìm kiếm sẽ nhấp nháy ở đó, và lệnh đang chạy từ menu thả xuống sẽ đánh dấu bảng của nó.",
"prefs.right_click": "Nhấp chuột phải",
"prefs.saving": "Tự động lưu và khôi phục",
"prefs.scale_note": "Cũng có thể dùng Ctrl + / Ctrl − trong cửa sổ. Các nút có kích thước 28 px ở mức 100%.",
@@ -1901,6 +1968,10 @@
"prefs.shift_add_ctrl_toggle": "Shift chỉ thêm; Ctrl / Cmd đảo trạng thái chọn",
"prefs.shift_add_note": "Bật: Shift để thêm vào vùng chọn, Ctrl / Cmd để đảo chọn. Tắt (mặc định): cả hai đều đảo trạng thái chọn.",
"prefs.shortcuts": "Phím tắt",
"prefs.single_surface_analysis": "Chỉ dùng một phân tích bề mặt",
"prefs.single_surface_analysis_help": "Khi tắt, phân tích tô bóng bề mặt và phân tích tính liên tục có thể cùng hoạt động. Khi bật, chỉ giữ phân tích được bắt đầu hoặc thay đổi gần nhất.",
"prefs.sketch_align_points": "Căn theo các điểm phác thảo khác khi vẽ",
"prefs.sketch_align_points_note": "Khi vẽ, một điểm được căn ngang theo một điểm phác thảo và căn dọc theo điểm khác cùng lúc, kèm đường dẫn chấm; cú nhấp giữ cả hai ràng buộc căn. Tắt: chỉ căn theo điểm trước, mỗi lần một trục.",
"prefs.sketch_conflict_colour": "Hình học xung đột theo màu xung đột",
"prefs.sketch_conflict_colour_note": "Khi các ràng buộc của bản phác xung đột, các đường cong và điểm mà một ràng buộc lỗi giữ cũng được vẽ bằng màu xung đột, không chỉ ký hiệu hay kích thước của nó. Tắt: chúng giữ màu thường.",
"prefs.sketch_exit_view": "Khi kết thúc phác thảo",
@@ -1952,7 +2023,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,9 +2040,14 @@
"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",
"prefs.zoom_stop_at_model": "Dừng thu phóng tại mô hình",
"prefs.zoom_stop_at_model_tip": "Chuột 3D và ứng dụng thiết bị thu phóng về phía bề mặt ở giữa khung nhìn; thu phóng bằng cách kéo giữ nguyên điểm bắt đầu kéo. Khi tắt, có thể thu phóng xuyên qua mô hình. Con lăn và cử chỉ chụm dùng riêng tùy chọn Thu phóng về con trỏ",
"prefs.zoom_toward_cursor": "Thu phóng về phía con trỏ chuột",
"properties.title": "Thuộc tính: {name}",
"recovery.days_ago": "{count} ngày trước",
@@ -3296,6 +3379,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",
@@ -3314,6 +3398,7 @@
"settings.double_click_components": "Nhấp đúp vào một thành phần để kích hoạt nó, hoặc chỉnh sửa tại chỗ một thành phần liên kết",
"settings.frame_rate_details_note": "Bổ sung biểu đồ thời gian các khung hình gần nhất xếp chồng số tam giác đã vẽ, biểu đồ phân bố thời gian khung hình và nội dung GPU đã vẽ. Bấm vào tốc độ khung hình trên khung nhìn để ẩn/hiện.",
"settings.frame_rate_note": "Hiển thị số khung hình trên giây (FPS) ở góc dưới bên phải khung nhìn.",
"settings.insert_lists_open_designs": "Chèn thiết kế vào cụm liệt kê trước các thiết kế đang mở ở các thẻ khác",
"settings.measure_axis_distances": "Hiển thị khoảng cách theo trục X/Y/Z",
"settings.measure_axis_distances_help": "Hiển thị kích thước thành phần theo các trục tọa độ cùng với khoảng cách tổng thể khi mở công cụ Đo lường.",
"settings.msaa_applies_on_restart": "Khử răng cưa sẽ áp dụng vào lần khởi động tiếp theo của SolidCraft",
@@ -3338,6 +3423,10 @@
"shortcut.unpin": "Bỏ ghim",
"sketch is missing": "thiếu phác thảo",
"sketch.degrees_of_freedom": "{count} bậc tự do",
"sketch.dimension_added_driven": "Bản phác thảo đã xác định kích thước đó, nên nó được thêm vào dưới dạng kích thước tham chiếu",
"sketch.dimension_applied": "Đã áp dụng kích thước",
"sketch.dimension_applied_defined": "Đã áp dụng kích thước; đã định nghĩa tham số `{name}`",
"sketch.dimension_applied_renamed": "Đã áp dụng kích thước; tham số được đặt tên là `{name}`",
"sketch.dof_left": "Còn {count} bậc tự do",
"sketch.dof_left_one": "Còn {count} bậc tự do",
"sketch.draw_curves": "Vẽ dưới dạng {linetype}",
@@ -3372,6 +3461,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",
@@ -3405,8 +3496,10 @@
"timeline.delete_folders_tip": "Giữ lại các tính năng và thư mục lồng nhau trong lịch sử",
"timeline.delete_folders_with_features_tip": "Xóa các thư mục cùng mọi tính năng trong đó và các thư mục lồng bên trong",
"timeline.delete_many": "Xóa {count} tính năng đã chọn",
"timeline.edited_rolled_back": "{name} đã thay đổi, nhưng nó nằm sau dấu mốc lịch sử nên chưa được dựng: kéo dấu mốc qua nó để xem kết quả",
"timeline.feature_error": "Lỗi: {message}",
"timeline.feature_hint": "Bấm đúp để sửa · kéo rê để đổi thứ tự · bấm chuột phải để xem thêm",
"timeline.feature_rolled_back": "Đã lùi lại: nằm sau dấu mốc lịch sử nên chưa được dựng",
"timeline.feature_warning": "Cảnh báo: {message}",
"timeline.group": "Thư mục tính năng mới",
"timeline.move_many": "Di chuyển các mục trong lịch sử thiết kế",
@@ -3488,6 +3581,10 @@
"ui.3d_mouse_offer": "Hiệu chuẩn chuột 3D sẽ sửa các trục bị hoán đổi hoặc đảo ngược và chỉnh tốc độ cũng như cảm giác.",
"ui.3d_mouse_offer_never": "Không hiện lại",
"ui.3d_mouse_offer_swap": "Chuyển sang Hidraw (điều khiển trực tiếp)",
"ui.3d_mouse_orbit": "Quỹ đạo",
"ui.3d_mouse_orbit_classic": "Cổ điển",
"ui.3d_mouse_orbit_tip": "Theo dấu: chuột 3D xoay khung nhìn quanh phần mô hình ở giữa khung nhìn lúc bắt đầu xoay, được đánh dấu bằng một quả cầu mờ. Cổ điển: quanh tâm khung nhìn",
"ui.3d_mouse_orbit_traced": "Theo dấu",
"ui.3d_mouse_reading": "Giá trị đọc",
"ui.3d_mouse_reset_axes": "Đặt lại phép gán trục",
"ui.3d_mouse_reset_view": "Đặt lại khung nhìn",
@@ -3965,6 +4062,14 @@
"ui.end_scale": "Tỷ lệ đầu cuối",
"ui.entities": "Các thực thể",
"ui.equal": "Bằng nhau",
"ui.equation_end": "Kết thúc",
"ui.equation_explicit": "Tường minh",
"ui.equation_form": "Dạng",
"ui.equation_note": "Biến là một số không có đơn vị: sin(t) hiểu nó theo radian. Kết quả không có đơn vị dùng đơn vị của thiết kế.",
"ui.equation_parametric": "Tham số",
"ui.equation_x_of_t": "x(t)",
"ui.equation_y_of_t": "y(t)",
"ui.equation_y_of_x": "y(x)",
"ui.equivalent_radius": "Bán kính tương đương",
"ui.expand": "Mở rộng",
"ui.exportDxf": "Xuất tệp DXF…",
@@ -4030,6 +4135,7 @@
"ui.heading_design": "THIẾT KẾ",
"ui.heading_draft": "ĐỘ DỐC THOÁT KHUÔN",
"ui.heading_edit": "CHỈNH SỬA",
"ui.heading_equation_curve": "ĐƯỜNG CONG PHƯƠNG TRÌNH",
"ui.heading_extrude": "ĐÙN KHỐI",
"ui.heading_fillet": "BO TRÒN",
"ui.heading_finish_editing": "HOÀN TẤT CHỈNH SỬA",
@@ -4121,6 +4227,10 @@
"ui.import_ellipsis": "Nhập tệp…",
"ui.infinite": "Vô hạn",
"ui.insert": "Chèn",
"ui.insert_design.browse": "Duyệt…",
"ui.insert_design.open_designs": "Thiết kế đang mở",
"ui.insert_design.saved_file": "{name} có thay đổi chưa lưu, nên tệp đã lưu của nó đã được chèn. Hãy lưu nó, rồi dùng Cập nhật liên kết để đưa các thay đổi vào.",
"ui.insert_design.unsaved": "Thay đổi chưa lưu",
"ui.inspect": "Kiểm tra",
"ui.intersect": "Giao nhau",
"ui.isolate": "Cô lập đối tượng",
@@ -4220,6 +4330,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 +4380,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",
@@ -4331,6 +4454,7 @@
"ui.select": "Chọn",
"ui.selectOther": "Chọn đối tượng khác…",
"ui.selectTangentChain": "Chọn chuỗi tiếp tuyến",
"ui.select_all": "Chọn tất cả",
"ui.selection": "Vùng chọn",
"ui.selection_modifiers": "Phím bổ trợ chọn",
"ui.selection_shift_add_only": "Shift+nhấp chỉ thêm, không bỏ chọn",
@@ -4367,7 +4491,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 +4542,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 +4562,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 +4609,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 +4663,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",
+6
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": "路徑",
@@ -4061,6 +4062,7 @@
"ui.select": "選擇",
"ui.selectOther": "選擇其他…",
"ui.selectTangentChain": "選擇相切鏈",
"ui.select_all": "全選",
"ui.selection": "選擇",
"ui.selection_modifiers": "選擇修飾鍵",
"ui.selection_shift_add_only": "Shift+點擊僅新增、不取消選擇",
@@ -4144,6 +4146,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": "攻牙",
+138
View File
@@ -206,6 +206,7 @@
"Edges": "边",
"Edit": "编辑",
"Edit Profile Sketch": "编辑轮廓草图",
"Edit shortcuts": "编辑快捷键",
"Enable rule": "启用规则",
"Enabled": "已启用",
"End Radius": "结束半径",
@@ -468,6 +469,7 @@
"Radius": "半径",
"Radius Fit": "半径拟合",
"Ratio": "比率",
"Read-only list: shortcuts can't be changed here.": "只读列表:此处无法更改快捷键。",
"Reattach selected face": "重新附着所选面",
"Recover": "恢复",
"Recover unsaved design?": "恢复未保存的设计?",
@@ -652,6 +654,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": "螺纹深度",
@@ -821,6 +824,7 @@
"cmd.browser_move": "移动浏览器项目",
"cmd.browser_order": "排列浏览器项目",
"cmd.browser_rename_group": "重命名组",
"cmd.browser_reparent": "移动到组件",
"cmd.browser_ungroup": "取消编组",
"cmd.browser_visibility": "显示/隐藏",
"cmd.canvas_calibrate": "校准画布",
@@ -847,7 +851,9 @@
"cmd.component_metadata": "零件属性",
"cmd.component_metadata_set": "更改零件属性",
"cmd.component_move_bodies": "将实体移至组件",
"cmd.component_move_features": "将特征移至组件",
"cmd.component_move_sketches": "将草图移至组件",
"cmd.component_paste_from_design": "从其他设计粘贴",
"cmd.component_paste_new": "粘贴为新对象",
"cmd.component_properties": "组件物理属性",
"cmd.component_relink": "重新链接源文件",
@@ -984,6 +990,7 @@
"cmd.motion_trace": "运动轨迹",
"cmd.motion_trace_edit": "编辑运动轨迹",
"cmd.motion_trace_keep": "保留运动轨迹",
"cmd.occurrence_align": "对齐实例",
"cmd.occurrence_copy": "粘贴(实例)",
"cmd.occurrence_drag": "拖动实例",
"cmd.occurrence_ground": "固定",
@@ -1074,6 +1081,13 @@
"cmd.render_start": "渲染图像",
"cmd.render_status": "渲染进度",
"cmd.select_clear": "清除选择",
"cmd.settings_profile_apply": "应用设置配置文件",
"cmd.settings_profile_delete": "删除设置配置文件",
"cmd.settings_profile_export": "导出设置配置文件",
"cmd.settings_profile_import": "导入设置配置文件",
"cmd.settings_profile_list": "设置配置文件",
"cmd.settings_profile_rename": "重命名设置配置文件",
"cmd.settings_profile_save": "保存设置配置文件",
"cmd.sheet_convert": "转换为钣金",
"cmd.sheet_export_bend_table": "导出折弯表",
"cmd.sheet_export_dxf": "将展开图导出为 DXF",
@@ -1119,6 +1133,8 @@
"cmd.sketch_drawing_bounds": "图纸尺寸",
"cmd.sketch_edit": "编辑草图",
"cmd.sketch_edit_text": "编辑文字",
"cmd.sketch_equation_curve": "方程曲线",
"cmd.sketch_equation_curve_edit": "编辑方程曲线",
"cmd.sketch_export_dxf": "另存为 DXF",
"cmd.sketch_finish_auto_constrain": "使用自动约束完成",
"cmd.sketch_fit_curves_to_section": "将曲线拟合到网格截面",
@@ -1215,6 +1231,7 @@
"cmd.view.render_browser": "渲染浏览器",
"cmd.view.render_dock": "移动渲染停靠面板",
"cmd.view.render_focus": "渲染专注视图",
"cmd.view.status_items": "视口状态项",
"cmd.view_ambient_occlusion": "环境光遮蔽",
"cmd.view_angle_readout": "视图角度",
"cmd.view_apply": "还原命名视图",
@@ -1369,8 +1386,10 @@
"help.material.assign": "设置所选实体的材料,例如钢、铝或 ABS。用于让质量和重量与实际零件一致。",
"help.mit": "MIT 许可证",
"help.notice": "NOTICE",
"help.occurrence.move": "仅沿关节允许的自由度移动装配零部件。选中零部件的面后,拖动可用的箭头或圆弧。",
"help.parameters.change": "打开驱动设计尺寸的命名数值和表达式表。用于在一处修改尺寸,让所有引用它的特征随之更新。",
"help.pointcloud.insert": "将 PLY、XYZ 或 PTS 扫描作为点云插入到模型旁。根据测量数据建模时,可将其用作参考。",
"help.sketch.equation_curve": "在一个范围内按公式绘制曲线:x(t) 和 y(t),或 y = f(x)。公式可以使用参数,曲线随参数变化。适用于正弦波、螺旋线或齿轮的渐开线。",
"help.solid.align": "移动所选实体,使其上的点、面或平面与别处的点、面或平面对齐。用于把一个零件贴靠到另一个零件上,而无需测量距离。",
"help.solid.chamfer": "用指定的距离在所选边上切出一个平直的斜面。用于去除锐边,或使销钉、螺钉更容易插入。",
"help.solid.combine": "将多个实体合并、从目标实体中切除,或只保留它们共有的体积,还可以保留工具实体。用于合并零件、用一个形体切割另一个形体,或找出两个实体重叠的位置。",
@@ -1499,6 +1518,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 +1627,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 +1650,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": "在设计中隐藏",
@@ -1720,6 +1759,7 @@
"parts.dimension.thickness": "厚度",
"parts.dimension.width": "宽度",
"parts.family.bearing": "深沟球轴承(外形)",
"parts.family.button_head_cap_screw": "圆头内六角螺钉",
"parts.family.dowel_pin": "圆柱销",
"parts.family.hex_bolt": "六角头螺栓",
"parts.family.hex_nut": "六角螺母",
@@ -1778,6 +1818,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": "中键拖动",
@@ -1848,6 +1891,8 @@
"prefs.layout_note": "紧凑模式将面板停靠在窗口边缘。浏览器半透明仅适用于浮动模式。助手面板的位置在助手页面中设置。",
"prefs.left": "左侧",
"prefs.left_click": "左键单击",
"prefs.line_drag_draws_one": "按下并拖动绘制一条直线",
"prefs.line_drag_draws_one_note": "在直线工具中,按下、拖动再松开,会从按下处到松开处绘制一条直线并在此结束。单击仍会绘制连续的直线。关闭时:拖动与单击一样,在松开处放置一个点。",
"prefs.linked_designs": "链接设计",
"prefs.live_input": "实时输入",
"prefs.live_preview": "实时预览",
@@ -1880,14 +1925,36 @@
"prefs.origin_plane_names": "原点平面名称",
"prefs.origin_plane_names_note": "浏览器、对话框和列表中显示的名称。{top} 即 XY,{front} 即 XZ,{right} 即 YZ(Z 轴朝上)。命令、脚本和已保存的设计仍使用 XY、XZ 和 YZ。",
"prefs.overall_speed": "整体速度",
"prefs.perspective_fov": "视场角",
"prefs.placement": "放置",
"prefs.prefer_free_sketch_points": "草图点重合时优先选择可移动的点",
"prefs.preview_choices": "所选设置预览",
"prefs.profile_active": "当前配置文件",
"prefs.profile_applied": "已应用配置文件。",
"prefs.profile_changed": "保存配置文件后设置已更改。“保存”会将其写入配置文件。",
"prefs.profile_deleted": "已删除配置文件。",
"prefs.profile_export": "导出…",
"prefs.profile_exported": "已导出配置文件。",
"prefs.profile_imported": "已导入配置文件。在“当前配置文件”中选择它即可切换。",
"prefs.profile_name_hint": "配置文件名称,例如 笔记本",
"prefs.profile_rename": "重命名",
"prefs.profile_renamed": "已重命名配置文件。",
"prefs.profile_save": "保存",
"prefs.profile_saved": "已将当前设置保存到配置文件。",
"prefs.profile_skipped": "已应用配置文件;{n} 项设置保留了当前值(见日志)。",
"prefs.profiles": "设置配置文件",
"prefs.profiles_note": "将快捷键、工具栏、导航、径向菜单和界面设置一起以名称保存,并一次切换。",
"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.ribbon_reveal": "在功能区中显示搜索结果",
"prefs.ribbon_reveal_note": "将指针悬停在“搜索命令”的结果上时,会显示该命令在功能区中的位置:其选项卡、按钮或面板下拉菜单中的行。从搜索启动的命令会在那里闪烁,从下拉菜单运行的命令会标记其面板。",
"prefs.right_click": "右键单击",
"prefs.saving": "自动保存和恢复",
"prefs.scale_note": "也可在窗口中使用 Ctrl + / Ctrl −。100% 时按钮高度为 28 像素。",
@@ -1901,6 +1968,10 @@
"prefs.shift_add_ctrl_toggle": "Shift 仅添加;Ctrl / Cmd 切换选择",
"prefs.shift_add_note": "开启:Shift 添加,Ctrl / Cmd 切换选择。关闭(默认):两者均切换选择。",
"prefs.shortcuts": "快捷键",
"prefs.single_surface_analysis": "仅使用一个曲面分析",
"prefs.single_surface_analysis_help": "关闭时,曲面着色分析和连续性分析可以同时保持启用。开启时,仅保留最后启动或修改的分析。",
"prefs.sketch_align_points": "绘制时与其他草图点对齐",
"prefs.sketch_align_points_note": "绘制时,点可同时与一个草图点水平对齐、与另一个草图点竖直对齐,并显示虚线参考线;单击后保留两个对齐约束。关闭:仅与上一点对齐,每次一个方向。",
"prefs.sketch_conflict_colour": "以冲突颜色显示冲突的几何图形",
"prefs.sketch_conflict_colour_note": "草图约束冲突时,不仅失败约束的符号或尺寸,该约束所约束的曲线和点也以冲突颜色绘制。关闭:它们保持平常的颜色。",
"prefs.sketch_exit_view": "草图结束时的视图",
@@ -1952,7 +2023,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,9 +2040,14 @@
"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": "工作区",
"prefs.zoom_stop_at_model": "缩放到模型表面时停止",
"prefs.zoom_stop_at_model_tip": "3D 鼠标和设备应用向视图中央的表面缩放;拖动缩放固定拖动开始时的点。关闭后可穿过模型。滚轮和捏合手势单独使用向光标缩放设置",
"prefs.zoom_toward_cursor": "朝光标缩放",
"properties.title": "属性:{name}",
"recovery.days_ago": "{count} 天前",
@@ -3296,6 +3379,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": "请先选择钣金边",
@@ -3314,6 +3398,7 @@
"settings.double_click_components": "双击组件以激活它,或在原位编辑链接组件",
"settings.frame_rate_details_note": "添加最近各帧耗时的图表(叠加绘制的三角形数)、帧耗时直方图以及 GPU 绘制的内容。点击视图中的帧率可显示或隐藏。",
"settings.frame_rate_note": "在视图右下角显示每秒帧数。",
"settings.insert_lists_open_designs": "插入设计时先列出其他标签页中打开的设计",
"settings.measure_axis_distances": "显示 X/Y/Z 距离",
"settings.measure_axis_distances_help": "测量对话框打开时,在总距离之外显示沿世界坐标轴方向的尺寸线和距离。",
"settings.msaa_applies_on_restart": "抗锯齿将在下次启动 SolidCraft 时生效",
@@ -3338,6 +3423,10 @@
"shortcut.unpin": "取消固定",
"sketch is missing": "草图不存在",
"sketch.degrees_of_freedom": "{count} 个自由度",
"sketch.dimension_added_driven": "草图已确定该尺寸,因此已将其添加为被驱动(参考)尺寸",
"sketch.dimension_applied": "已应用尺寸",
"sketch.dimension_applied_defined": "已应用尺寸;已定义参数 `{name}`",
"sketch.dimension_applied_renamed": "已应用尺寸;参数已命名为 `{name}`",
"sketch.dof_left": "剩余 {count} 个自由度",
"sketch.dof_left_one": "剩余 {count} 个自由度",
"sketch.draw_curves": "绘制为 {linetype}",
@@ -3372,6 +3461,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": "设计的单位。单击可更改",
@@ -3405,8 +3496,10 @@
"timeline.delete_folders_tip": "在历史记录中保留其中的特征和嵌套文件夹",
"timeline.delete_folders_with_features_tip": "删除文件夹及其中的所有特征和嵌套文件夹",
"timeline.delete_many": "删除所选的 {count} 个特征",
"timeline.edited_rolled_back": "{name} 已更改,但它位于历史标记之后,因此未构建:将标记移到其后即可查看结果",
"timeline.feature_error": "错误:{message}",
"timeline.feature_hint": "双击编辑 · 拖动以重新排序 · 右键查看更多操作",
"timeline.feature_rolled_back": "已回退:位于历史标记之后,因此未构建",
"timeline.feature_warning": "警告:{message}",
"timeline.group": "新建特征文件夹",
"timeline.move_many": "移动时间线项目",
@@ -3488,6 +3581,10 @@
"ui.3d_mouse_offer": "校准 3D 鼠标可以修正互换或反向的轴,并调整它的速度和手感。",
"ui.3d_mouse_offer_never": "不再显示",
"ui.3d_mouse_offer_swap": "切换到 Hidraw(直接驱动)",
"ui.3d_mouse_orbit": "环绕",
"ui.3d_mouse_orbit_classic": "经典",
"ui.3d_mouse_orbit_tip": "追踪:3D 鼠标围绕开始旋转时视图中间的模型部分旋转视图,并用一个淡色小球标出该点。经典:围绕视图中心旋转",
"ui.3d_mouse_orbit_traced": "追踪",
"ui.3d_mouse_reading": "读数",
"ui.3d_mouse_reset_axes": "重置轴分配",
"ui.3d_mouse_reset_view": "重置视图",
@@ -3965,6 +4062,14 @@
"ui.end_scale": "末端缩放",
"ui.entities": "实体",
"ui.equal": "相等",
"ui.equation_end": "结束",
"ui.equation_explicit": "显式",
"ui.equation_form": "形式",
"ui.equation_note": "变量是不带单位的数:sin(t) 按弧度计算。不带单位的结果采用设计的单位。",
"ui.equation_parametric": "参数式",
"ui.equation_x_of_t": "x(t)",
"ui.equation_y_of_t": "y(t)",
"ui.equation_y_of_x": "y(x)",
"ui.equivalent_radius": "等效半径",
"ui.expand": "展开",
"ui.exportDxf": "导出 DXF…",
@@ -4030,6 +4135,7 @@
"ui.heading_design": "设计",
"ui.heading_draft": "拔模",
"ui.heading_edit": "编辑",
"ui.heading_equation_curve": "方程曲线",
"ui.heading_extrude": "拉伸",
"ui.heading_fillet": "圆角",
"ui.heading_finish_editing": "完成编辑",
@@ -4121,6 +4227,10 @@
"ui.import_ellipsis": "导入…",
"ui.infinite": "无限",
"ui.insert": "插入",
"ui.insert_design.browse": "浏览…",
"ui.insert_design.open_designs": "已打开的设计",
"ui.insert_design.saved_file": "{name} 有未保存的更改,因此插入的是其已保存的文件。请先保存,再使用“更新链接”导入这些更改。",
"ui.insert_design.unsaved": "未保存的更改",
"ui.inspect": "检查",
"ui.intersect": "相交",
"ui.isolate": "隔离",
@@ -4220,6 +4330,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 +4380,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": "数量",
@@ -4331,6 +4454,7 @@
"ui.select": "选择",
"ui.selectOther": "选择其他…",
"ui.selectTangentChain": "选择相切链",
"ui.select_all": "全选",
"ui.selection": "选择",
"ui.selection_modifiers": "选择修饰键",
"ui.selection_shift_add_only": "Shift+点击仅添加、不取消选择",
@@ -4367,7 +4491,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 +4542,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 +4562,7 @@
"ui.tip_angle": "尖端角度",
"ui.to_object": "到对象",
"ui.tool_direction": "刀具方向",
"ui.toolbar_tabs_hidden": "所有工具栏选项卡均已隐藏。点击 + 或在此处右键单击即可显示。",
"ui.top": "上视",
"ui.top_diameter": "顶部直径",
"ui.top_radius": "顶部半径",
@@ -4475,6 +4609,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 +4663,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",
+6
View File
@@ -253,6 +253,12 @@ impl crate::Feature {
for e in sketch.text_exprs_mut() {
*e = clone_expression(e, parameters);
}
// An equation curve's variable is its own: a parameter of that name is not renamed in it.
for (variable, e) in sketch.equation_exprs_mut() {
let others: BTreeMap<String, String> =
parameters.iter().filter(|(old, _)| old.as_str() != variable).map(|(o, n)| (o.clone(), n.clone())).collect();
*e = clone_expression(e, &others);
}
for link in &mut sketch.links {
remap_link(&mut link.source, ids, names, bodies, 0);
for n in link.face_name.iter_mut().chain(&mut link.edge_name) {
+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));
+8 -4
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
@@ -3702,7 +3705,7 @@ impl Document {
}
/// Add or change a parameter. Fails if the result would not evaluate. A new parameter
/// without a unit takes the unit of its value (mm, deg or none).
/// without a unit takes the unit of its value (a length in the design's units, deg or none).
pub fn set_param(&mut self, name: &str, expr_s: &str, unit: Option<&str>, comment: Option<&str>) -> Result<()> {
// A text parameter: one with the text unit, or an existing one when no unit is given.
match (unit.map(crate::text_params::is_text_unit), self.text_param(name).is_some()) {
@@ -3886,10 +3889,11 @@ impl Document {
}
}
/// Apply parameter values to every dimension in a sketch, and to its points placed by
/// parameters (in place).
/// Apply parameter values to every dimension in a sketch, to its points placed by parameters
/// and to its equation curves (in place).
pub fn apply_dimension_values(&self, vals: &std::collections::BTreeMap<String, expr::Value>, sk: &mut Sketch) -> Result<()> {
self.apply_point_values(vals, sk)?;
self.apply_equation_values(vals, sk)?;
self.apply_pattern_values(vals, sk)?;
for c in &mut sk.constraints {
if let Some(p) = &c.param {
+29 -2
View File
@@ -2523,13 +2523,40 @@ fn point(vals: &BTreeMap<String, Value>, p: &crate::PointExpr) -> Result<Vec3> {
pub(crate) fn val(vals: &BTreeMap<String, Value>, e: &str, k: Kind) -> Result<f64> {
let scale = vals.get(DESIGN_LEN).map(|x| x.v).unwrap_or(1.0);
// Non-literal lengths (a parameter ± a bare number) must read that bare number in the design's
// units during the expression, not only when the whole result is unit-less (#9015).
if k == Kind::Length && scale != 1.0 && !expr::is_literal(e) {
let v = expr::eval_with(e, &|n| vals.get(n).copied().ok_or_else(|| DocError::Expr(format!("unknown parameter `{n}`"))))?;
return v.to_kind_in(k, expr::Defaults { len: scale, ..expr::Defaults::default() });
let defaults = expr::Defaults { len: scale, ..expr::Defaults::default() };
let look = |n: &str| vals.get(n).copied().ok_or_else(|| DocError::Expr(format!("unknown parameter `{n}`")));
return expr::eval_with_defaults(e, &look, defaults)?.to_kind_in(k, defaults);
}
Document::eval_in(vals, e, k)
}
#[cfg(test)]
mod design_unit_val_tests {
use super::*;
use expr::Value;
/// #9015: with inches as the design unit, `baseLength - 1` subtracts one inch (not one mm).
#[test]
fn bare_offsets_beside_a_length_follow_the_design_units() {
let mut vals = BTreeMap::new();
vals.insert(DESIGN_LEN.into(), Value::num(25.4));
vals.insert("baseLength".into(), Value::length(76.0 * 25.4));
let got = val(&vals, "baseLength - 1", Kind::Length).unwrap();
assert!((got - 75.0 * 25.4).abs() < 1e-9, "got {got} mm, want 75 in");
let got = val(&vals, "baseLength - 1.5", Kind::Length).unwrap();
assert!((got - 74.5 * 25.4).abs() < 1e-9, "got {got} mm, want 74.5 in");
// An explicit millimetre still wins.
let got = val(&vals, "baseLength - 1 mm", Kind::Length).unwrap();
assert!((got - (76.0 * 25.4 - 1.0)).abs() < 1e-9, "got {got}");
// A bare literal stays millimetres (feature inputs write the design unit in when typed).
let got = val(&vals, "2", Kind::Length).unwrap();
assert!((got - 2.0).abs() < 1e-12, "got {got}");
}
}
/// Collapsing the outline and its holes onto one apex cannot make a manifold solid.
fn refuse_loft_holes(r: &Region2) -> Result<()> {
if r.holes.is_empty() {
+11
View File
@@ -46,6 +46,17 @@ impl FeatureKind {
found
}
/// Bodies the feature works on (by name).
pub fn input_bodies(&self) -> Vec<String> {
let mut v = Vec::new();
self.walk_refs(&mut |r| {
if let Ref::Body(name) = r {
v.push(name.as_ref().to_string());
}
});
v
}
/// Point references to sketches and features (both by id) at their copies, and references
/// that are still keyed by name at a feature's new name (`timeline.rename`).
pub fn remap_refs(&mut self, ids: &BTreeMap<u64, u64>, names: &BTreeMap<String, String>) {
+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,5 @@
//! A sketch saves its equation curves (#8352) in `equations`: for each, the fit point spline it
//! places, its formulas (x(t) and y(t), or y = f(x) without `x`), the range of the variable and
//! the tolerance its points were chosen for. An older design has none, and its sketches mean
//! what they meant. The last release refuses a design that has them (it asks for an update)
//! instead of opening the splines as plain fixed splines that no longer follow their formulas.
@@ -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.
+5 -1
View File
@@ -191,7 +191,11 @@ impl Document {
}
let mut out = Vec::with_capacity(found.defs.len());
for d in found.defs {
next.add_user_param(&d.name, &d.expression, d.unit.as_deref(), None)
// A value of no fixed kind (a joint's) is evaluated without the design's units, so
// its bare number stays a millimetre length whatever they are (#8865): `w = 5` is
// what `5` is there.
let unit = d.unit.unwrap_or_else(|| Self::infer_unit_in(&next.param_values().0, &d.expression, "mm"));
next.add_user_param(&d.name, &d.expression, Some(&unit), None)
.map_err(|e| DocError::Invalid(format!("`{} = {}`: {e}", d.name, d.expression)))?;
let unit = next.param(&d.name).map(|p| p.unit.clone()).unwrap_or_default();
out.push(Defined { name: d.name, expression: d.expression, unit });
+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 })
}
+1
View File
@@ -71,6 +71,7 @@ pub mod render_settings;
pub mod render_surface;
pub use project3d::iso_grid;
pub mod sheet;
pub mod sketch_equation;
pub mod text_params;
pub use assembly::{
+20 -7
View File
@@ -268,7 +268,7 @@ impl Document {
.filter(|(_, _, k)| *k == Kind::Length)
.map(|(_, e, _)| {
let d = if expr::is_literal(&e) { expr::Defaults::default() } else { expr::Defaults { len: scale, ..expr::Defaults::default() } };
expr::eval_with(&e, &look).and_then(|v| v.to_kind_in(Kind::Length, d)).ok()
expr::eval_with_defaults(&e, &look, d).and_then(|v| v.to_kind_in(Kind::Length, d)).ok()
})
.collect()
}
@@ -585,7 +585,7 @@ impl Document {
}
let unit = match unit {
Some(u) => u.trim().to_string(),
None => Self::infer_unit_in(vals, expr_s),
None => Self::infer_unit_in(vals, expr_s, &self.units),
};
self.params.push(crate::Param {
name: name.into(),
@@ -696,18 +696,19 @@ impl Document {
}
/// The unit for a new parameter with this expression: that of its value; a bare number is
/// a length (mm).
/// a length in the design's units, as a typed length reads it (#8865).
pub(crate) fn infer_unit(&self, e: &str) -> String {
Self::infer_unit_in(&self.param_values().0, e)
Self::infer_unit_in(&self.param_values().0, e, &self.units)
}
/// [`Document::infer_unit`] against parameter values already worked out.
fn infer_unit_in(vals: &BTreeMap<String, Value>, e: &str) -> String {
/// [`Document::infer_unit`] against parameter values already worked out, a length taking
/// `units` (the design's, or `mm` where a bare number reads as millimetres).
pub(crate) fn infer_unit_in(vals: &BTreeMap<String, Value>, e: &str, units: &str) -> String {
let v = expr::eval_with(e, &|n| vals.get(n).copied().ok_or_else(|| DocError::Expr(format!("unknown parameter `{n}`"))));
match v.ok().and_then(|v| v.kind()) {
Some(Kind::Unitless) if !expr::references(e).is_empty() => String::new(),
Some(Kind::Angle) => "deg".into(),
_ => "mm".into(),
_ => expr::length_unit_or_mm(units).to_string(),
}
}
@@ -896,6 +897,18 @@ impl Document {
n += 1;
}
}
// An equation curve's variable hides a parameter of its name: such a parameter is
// not what its formulas refer to.
for (variable, e) in sketch.equation_exprs_mut() {
if variable == old {
continue;
}
let r = expr::rename_reference(e, old, new);
if r != *e {
*e = r;
n += 1;
}
}
for e in sketch.text_word_exprs_mut() {
let r = crate::text_params::rename_reference(e, old, new);
if r != *e {
+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
+363
View File
@@ -0,0 +1,363 @@
//! Equation curves (#8352): the points of a sketch curve worked out from its formulas over the
//! design's parameters. The formulas are evaluated at evenly spaced values of the curve's
//! variable; the sketch draws a cubic fit spline through those points. How many points: as many
//! as it takes for the spline to stay within the curve's tolerance of the formulas, checked
//! between the points, up to the most a spline takes. When the parameters change, the same
//! number of points is placed again (`Document::apply_equation_values`), before every solve.
use std::collections::BTreeMap;
use solidcraft_geom::Vec2;
use solidcraft_sketch::{EquationCurve, MAX_COORD, MAX_EQUATION_TEXT, MAX_SPLINE_POINTS, Sketch, fit_beziers_mode};
use crate::expr::{self, Defaults, Kind, Value};
use crate::{DocError, Result};
/// The tolerance a new equation curve gets (mm).
pub const DEFAULT_TOLERANCE: f64 = 0.01;
/// The tightest and loosest tolerances (mm).
pub const MIN_TOLERANCE: f64 = 1e-4;
pub const MAX_TOLERANCE: f64 = 10.0;
/// The fewest points a fit starts from.
const FIRST_COUNT: usize = 9;
/// The widest range of the variable.
const MAX_RANGE: f64 = 1e6;
/// How many times the point count is raised before the fit gives up.
const MAX_ROUNDS: usize = 12;
/// An equation curve's points and how well the spline through them follows the formulas.
#[derive(Clone, Debug, PartialEq)]
pub struct EquationFit {
/// The spline's points in order; a closed curve's last point is its first one again.
pub points: Vec<Vec2>,
/// The curve ends where it starts: the spline closes smoothly there.
pub closed: bool,
/// The largest distance (mm) found between the spline and the formulas.
pub deviation: f64,
}
/// How bare numbers read in the formulas: angles in radians (the variable is a plain number,
/// `sin(t)` its sine), lengths in the design's unit.
fn defaults(vals: &BTreeMap<String, Value>) -> Defaults {
let len = vals.get(crate::eval::DESIGN_LEN).map(|v| v.v).filter(|v| v.is_finite() && *v > 0.0).unwrap_or(1.0);
Defaults { len, ang: 1.0 }
}
fn checked_text<'a>(what: &str, e: &'a str) -> Result<&'a str> {
let e = e.trim();
if e.is_empty() {
return Err(DocError::Invalid(format!("{what} is empty")));
}
if e.chars().count() > MAX_EQUATION_TEXT {
return Err(DocError::Invalid(format!("{what} is longer than {MAX_EQUATION_TEXT} characters")));
}
Ok(e)
}
/// A formula's value (mm) where the variable is `at`. The variable hides a parameter of its name.
fn formula(vals: &BTreeMap<String, Value>, e: &str, variable: &str, at: f64, d: Defaults) -> Result<f64> {
let lookup = |n: &str| {
if n == variable { Ok(Value::num(at)) } else { vals.get(n).copied().ok_or_else(|| DocError::Expr(format!("unknown parameter `{n}`"))) }
};
let v = expr::eval_with_defaults(e, &lookup, d)?.to_kind_in(Kind::Length, d)?;
if v.is_finite() && v.abs() < MAX_COORD { Ok(v) } else { Err(DocError::Expr("the curve goes out of range".into())) }
}
/// Where the variable starts or ends, as the plain number the formulas see: a number, an angle
/// (in radians) or a length (in the design's unit).
fn range_end(vals: &BTreeMap<String, Value>, e: &str, d: Defaults) -> Result<f64> {
let v = expr::eval_with_defaults(e, &|n| vals.get(n).copied().ok_or_else(|| DocError::Expr(format!("unknown parameter `{n}`"))), d)?;
let out = match v.kind() {
Some(Kind::Unitless | Kind::Angle) => v.v,
Some(Kind::Length) => v.v / d.len,
None => return Err(DocError::Expr(format!("expected a number, an angle or a length, got {}", v.describe()))),
};
if out.is_finite() { Ok(out) } else { Err(DocError::Expr("not a finite number".into())) }
}
/// The variable's range, checked.
fn range(vals: &BTreeMap<String, Value>, eq: &EquationCurve, d: Defaults) -> Result<(f64, f64)> {
let a = range_end(vals, checked_text("the start", &eq.start)?, d).map_err(|e| DocError::Expr(format!("start: {e}")))?;
let b = range_end(vals, checked_text("the end", &eq.end)?, d).map_err(|e| DocError::Expr(format!("end: {e}")))?;
if (b - a).abs() <= 1e-12 * a.abs().max(b.abs()).max(1.0) {
return Err(DocError::Invalid("the start and the end of the range must differ".into()));
}
if (b - a).abs() > MAX_RANGE {
return Err(DocError::Invalid(format!("the range is wider than {MAX_RANGE}")));
}
Ok((a, b))
}
/// The curve's point where the variable is `at`.
fn point_at(vals: &BTreeMap<String, Value>, eq: &EquationCurve, at: f64, d: Defaults) -> Result<Vec2> {
let variable = eq.variable();
let x = match &eq.x {
Some(e) => formula(vals, e, variable, at, d).map_err(|e| DocError::Expr(format!("x: {e}")))?,
// The explicit form: x is the variable, in the design's unit.
None => {
let x = at * d.len;
if !(x.is_finite() && x.abs() < MAX_COORD) {
return Err(DocError::Expr("the curve goes out of range".into()));
}
x
}
};
let y = formula(vals, &eq.y, variable, at, d).map_err(|e| DocError::Expr(format!("y: {e}")))?;
Ok(Vec2::new(x, y))
}
/// The formulas' points at `count` evenly spaced values of the variable, from start to end.
fn samples(vals: &BTreeMap<String, Value>, eq: &EquationCurve, (a, b): (f64, f64), count: usize, d: Defaults) -> Result<Vec<Vec2>> {
let last = count.saturating_sub(1).max(1) as f64;
(0..count)
.map(|i| {
let at = a + (b - a) * (i as f64 / last);
point_at(vals, eq, at, d).map_err(|e| DocError::Expr(format!("at {} = {}: {e}", eq.variable(), short(at))))
})
.collect()
}
/// A number short enough for a message.
fn short(v: f64) -> String {
let s = format!("{v:.6}");
s.trim_end_matches('0').trim_end_matches('.').to_string()
}
/// The size of the curve: the larger side of its box (for closing tolerances).
fn extent(points: &[Vec2]) -> f64 {
let (mut lo, mut hi) = (Vec2::new(f64::INFINITY, f64::INFINITY), Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY));
for p in points {
lo = Vec2::new(lo.x.min(p.x), lo.y.min(p.y));
hi = Vec2::new(hi.x.max(p.x), hi.y.max(p.y));
}
(hi.x - lo.x).max(hi.y - lo.y).max(0.0)
}
/// Does a curve through these samples end where it starts?
fn closes(points: &[Vec2]) -> bool {
match (points.first(), points.last()) {
(Some(a), Some(b)) => points.len() >= 4 && a.dist(*b) <= 1e-7 * extent(points).max(1.0),
_ => false,
}
}
/// Refuse samples a spline cannot pass through: two in a row on the same spot, or all of them.
fn check_samples(points: &[Vec2], closed: bool) -> Result<()> {
let size = extent(points);
if size <= 1e-9 {
return Err(DocError::Invalid("the formulas give the same point all along the range".into()));
}
let spans = if closed { points.len().saturating_sub(1) } else { points.len() };
for w in points.windows(2).take(spans) {
if let [p, q] = w
&& p.dist(*q) <= 1e-9 * size.max(1.0)
{
return Err(DocError::Invalid("the curve stands still between two of its points: narrow the range or use fewer points".into()));
}
}
Ok(())
}
/// Distance from `q` to the cubic Bézier `b`, sampled finely.
fn bezier_distance(b: &[Vec2; 4], q: Vec2) -> f64 {
const STEPS: usize = 32;
let at = |t: f64| {
let s = 1.0 - t;
b[0] * (s * s * s) + b[1] * (3.0 * t * s * s) + b[2] * (3.0 * t * t * s) + b[3] * (t * t * t)
};
let mut best = f64::INFINITY;
let mut prev = at(0.0);
for i in 1..=STEPS {
let next = at(i as f64 / STEPS as f64);
let d = next - prev;
let l2 = d.len2();
let t = if l2 > 0.0 { ((q - prev).dot(d) / l2).clamp(0.0, 1.0) } else { 0.0 };
best = best.min(q.dist(prev + d * t));
prev = next;
}
best
}
/// How far the spline through `points` strays from the formulas: the largest distance from the
/// formulas' points a quarter, a half and three quarters of the way between two samples to the
/// spline's piece between them.
fn deviation(vals: &BTreeMap<String, Value>, eq: &EquationCurve, (a, b): (f64, f64), points: &[Vec2], closed: bool, d: Defaults) -> Result<f64> {
let pieces = fit_beziers_mode(points, closed).map_err(|e| DocError::Invalid(e.to_string()))?;
let step = (b - a) / points.len().saturating_sub(1).max(1) as f64;
let mut worst: f64 = 0.0;
for (i, piece) in pieces.iter().enumerate() {
for k in [0.25, 0.5, 0.75] {
let q = point_at(vals, eq, a + step * (i as f64 + k), d)?;
worst = worst.max(bezier_distance(piece, q));
}
}
Ok(worst)
}
/// Check an equation's text and tolerance before any evaluation.
pub fn check(eq: &EquationCurve) -> Result<()> {
if let Some(x) = &eq.x {
checked_text("x(t)", x)?;
}
checked_text(if eq.x.is_some() { "y(t)" } else { "y(x)" }, &eq.y)?;
checked_text("the start", &eq.start)?;
checked_text("the end", &eq.end)?;
if !(eq.tolerance.is_finite() && (MIN_TOLERANCE..=MAX_TOLERANCE).contains(&eq.tolerance)) {
return Err(DocError::Invalid(format!("the tolerance must be from {MIN_TOLERANCE} mm to {MAX_TOLERANCE} mm")));
}
Ok(())
}
/// The points of `count` samples (a closed curve's last one is its first), whether it closes,
/// and how far the spline through them strays.
pub fn sample(vals: &BTreeMap<String, Value>, eq: &EquationCurve, count: usize, closed: Option<bool>) -> Result<EquationFit> {
check(eq)?;
if !(2..=MAX_SPLINE_POINTS).contains(&count) {
return Err(DocError::Invalid(format!("an equation curve takes 2 to {MAX_SPLINE_POINTS} points")));
}
let d = defaults(vals);
let span = range(vals, eq, d)?;
let mut points = samples(vals, eq, span, count, d)?;
let closed = closed.unwrap_or_else(|| closes(&points)) && count >= 4;
if closed
&& let Some(first) = points.first().copied()
&& let Some(last) = points.last_mut()
{
*last = first;
}
check_samples(&points, closed)?;
let deviation = deviation(vals, eq, span, &points, closed, d)?;
Ok(EquationFit { points, closed, deviation })
}
/// The points of an equation curve, as few as keep the spline within its tolerance of the
/// formulas. Refused when even the most points a spline takes are not close enough.
pub fn fit(vals: &BTreeMap<String, Value>, eq: &EquationCurve) -> Result<EquationFit> {
let mut count = FIRST_COUNT;
for _ in 0..MAX_ROUNDS {
let fit = sample(vals, eq, count, None)?;
if fit.deviation <= eq.tolerance {
return Ok(fit);
}
if count >= MAX_SPLINE_POINTS {
return Err(DocError::Invalid(format!(
"even {MAX_SPLINE_POINTS} points stray {} mm from the formulas, more than the tolerance of {} mm: raise the tolerance or narrow the range",
short(fit.deviation),
short(eq.tolerance)
)));
}
// A cubic fit's error falls with the fourth power of the spacing: aim a little past
// what that asks for, at least half as many points again.
let ratio = (fit.deviation / eq.tolerance).powf(0.25) * 1.2;
let next = (count as f64 * ratio.max(1.5)).ceil();
count = if next.is_finite() && next < MAX_SPLINE_POINTS as f64 { next as usize } else { MAX_SPLINE_POINTS };
}
Err(DocError::Invalid("the equation curve's points could not be fitted".into()))
}
impl crate::Document {
/// Place the points of the sketch's equation curves where their formulas put them now (in
/// place), with the number of points each has. Their points are fixed, so the solve keeps
/// them there. A curve that is gone or is no longer a fit point spline is left as it is.
pub fn apply_equation_values(&self, vals: &BTreeMap<String, Value>, sk: &mut Sketch) -> Result<()> {
for k in 0..sk.equations.len() {
let Some(eq) = sk.equations.get(k).cloned() else { continue };
let Some(ci) = sk.curve_index(&eq.curve) else { continue };
let Some(solidcraft_sketch::CurveKind::Spline { pts, control: false, periodic, .. }) = sk.curves.get(ci).map(|c| &c.kind) else {
continue;
};
let (count, closed) = (pts.len(), *periodic);
let fit = sample(vals, &eq, count, Some(closed)).map_err(|e| DocError::Expr(format!("equation curve {}: {e}", eq.curve)))?;
sk.place_equation_points(ci, &fit.points)?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn eq(x: Option<&str>, y: &str, start: &str, end: &str) -> EquationCurve {
EquationCurve {
curve: String::new(),
x: x.map(str::to_string),
y: y.into(),
start: start.into(),
end: end.into(),
tolerance: DEFAULT_TOLERANCE,
}
}
#[test]
fn a_circle_closes_and_stays_within_the_tolerance() {
let vals = BTreeMap::new();
let fit = fit(&vals, &eq(Some("10 * cos(t)"), "10 * sin(t)", "0", "2 * pi")).unwrap();
assert!(fit.closed);
assert!(fit.deviation <= DEFAULT_TOLERANCE, "{}", fit.deviation);
for p in &fit.points {
assert!((p.len() - 10.0).abs() < 1e-9, "{p:?}");
}
assert_eq!(fit.points.first(), fit.points.last());
}
#[test]
fn the_variable_hides_a_parameter_and_parameters_drive_the_curve() {
let mut vals = BTreeMap::new();
vals.insert("t".to_string(), Value::length(1000.0));
vals.insert("amp".to_string(), Value::length(5.0));
let fit = fit(&vals, &eq(None, "amp * sin(x / 10)", "0 mm", "100 mm")).unwrap();
assert!(!fit.closed);
let (first, last) = (fit.points[0], *fit.points.last().unwrap());
assert!((first - Vec2::new(0.0, 0.0)).len() < 1e-9, "{first:?}");
assert!((last.x - 100.0).abs() < 1e-9 && (last.y - 5.0 * 10f64.sin()).abs() < 1e-9, "{last:?}");
assert!(fit.points.iter().all(|p| p.y.abs() <= 5.0 + 1e-9));
}
#[test]
fn a_design_in_inches_reads_bare_numbers_in_inches() {
let mut vals = BTreeMap::new();
vals.insert(crate::eval::DESIGN_LEN.to_string(), Value::num(25.4));
let fit = fit(&vals, &eq(Some("t"), "2", "0", "1")).unwrap();
let last = *fit.points.last().unwrap();
assert!((last - Vec2::new(25.4, 50.8)).len() < 1e-9, "{last:?}");
}
#[test]
fn more_points_where_the_tolerance_is_tighter() {
let vals = BTreeMap::new();
let mut e = eq(Some("t * cos(t)"), "t * sin(t)", "0", "6 * pi");
e.tolerance = 0.1;
let loose = fit(&vals, &e).unwrap();
e.tolerance = 0.005;
let tight = fit(&vals, &e).unwrap();
assert!(tight.points.len() > loose.points.len(), "{} vs {}", tight.points.len(), loose.points.len());
assert!(tight.deviation <= 0.005 && loose.deviation <= 0.1);
}
#[test]
fn hostile_formulas_and_ranges_are_refused() {
let vals = BTreeMap::new();
for (x, y, a, b, tol) in [
(Some("t"), "sqrt(t)", "-1", "1", DEFAULT_TOLERANCE),
(Some("t"), "1 / t", "-1", "1", DEFAULT_TOLERANCE),
(Some("t"), "t", "0", "0", DEFAULT_TOLERANCE),
(Some("t"), "t", "0", "1e300", DEFAULT_TOLERANCE),
(Some("t"), "t", "0", "nan", DEFAULT_TOLERANCE),
(Some("t * 1 deg"), "t", "0", "1", DEFAULT_TOLERANCE),
(Some("1e300 * t"), "t", "0", "1", DEFAULT_TOLERANCE),
(Some("5"), "5", "0", "1", DEFAULT_TOLERANCE),
(Some(""), "t", "0", "1", DEFAULT_TOLERANCE),
(Some("t"), "t", "0", "1", f64::NAN),
(Some("t"), "t", "0", "1", 0.0),
(Some("t"), "unknown_name * t", "0", "1", DEFAULT_TOLERANCE),
(Some("t"), "sin(1e5 * t)", "0", "1000", 1e-4),
] {
let mut e = eq(x, y, a, b);
e.tolerance = tol;
assert!(fit(&vals, &e).is_err(), "{x:?} {y} {a}..{b} {tol}");
}
let long = "t + ".repeat(400) + "t";
assert!(fit(&vals, &eq(Some(&long), "t", "0", "1")).is_err());
}
}
+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);
}
+2 -2
View File
@@ -23,9 +23,9 @@ pub struct TextParam {
pub comment: String,
}
/// Is `unit` the text unit?
/// Is `unit` the text unit, ignoring ASCII case and surrounding whitespace?
pub fn is_text_unit(unit: &str) -> bool {
unit.trim() == TEXT_UNIT
unit.trim().eq_ignore_ascii_case(TEXT_UNIT)
}
/// A text as typed for a text parameter: inside matching quotes, what they hold (a quote doubled
+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 }))
}
+1 -1
View File
@@ -39,7 +39,7 @@ fn copy(s: &mut Session, p: &Value) -> Result<Value> {
}
/// Origin planes become explicit planes so a translation can move them.
fn explicit_planes(p: &mut PlaneRef) {
pub(in crate::cmd) fn explicit_planes(p: &mut PlaneRef) {
match p {
PlaneRef::Origin { name } => {
if let Some(plane) = solidcraft_geom::Plane::named(name) {
+62 -4
View File
@@ -11,23 +11,36 @@ 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, MoveFeaturesParams, MoveFeaturesResult, OccurrenceAlignParams,
OccurrenceAlignResult, ReparentItemsParams, ReparentItemsResult,
};
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();
const MOVE_FEATURES_CONTRACT: TypedContract<MoveFeaturesParams> = TypedContract::new();
const REPARENT_ITEMS_CONTRACT: TypedContract<ReparentItemsParams> = TypedContract::new();
#[path = "component_align.rs"]
mod align;
#[path = "component_drag.rs"]
mod drag;
#[path = "component_foreign.rs"]
mod foreign;
#[path = "component_matrix.rs"]
pub(super) mod matrix;
#[path = "component_move_features.rs"]
mod move_features;
#[path = "component_placing.rs"]
pub(super) mod placing;
#[path = "component_properties.rs"]
pub(super) mod properties;
#[path = "component_reparent.rs"]
mod reparent;
#[path = "component_reparent_items.rs"]
mod reparent_items;
#[path = "component_transfer.rs"]
mod transfer;
@@ -78,10 +91,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(
@@ -99,7 +112,37 @@ pub static COMMANDS: &[CommandSpec] = &[
by (default: its only one, else the active one; a component placed more than once gets them in every instance). Each \
copy stays where its body is in the world; the copies' names come back as `bodies`",
),
CommandSpec::new("component.move_sketches", "Move Sketches to Component", move_sketches).icon("move").params("sketches: [ids or names], component"),
CommandSpec::new("component.paste_from_design", "Paste from Another Design", foreign::paste_from_design).icon("paste").params(
"component?: where it goes (default: the active one); component_path?: its placement (default: the active one, else its \
only one). Pastes what was copied in another design, at the same world coordinates: its bodies as imported bodies \
(no history) in one Import feature, or its components as independent copies with their history, appearances, \
joints and motion (as `component.paste_new`); the user parameters they use come along, renamed `w_2` when taken. \
A component that uses what isn't in it (another body, sketch or parameter) is refused, saying what. Returns \
`bodies` and `feature`, or the pasted components' `paths`",
),
CommandSpec::new("component.move_sketches", "Move Sketches to Component", move_sketches)
.icon("move")
.params("sketches: [ids or names], component; the sketches keep their own coordinates (component.move_features keeps them where they are in the world)"),
CommandSpec::new("component.move_features", "Move Features to Component", crate::checked!("component.move_features", move_features::move_features))
.icon("move")
.contract(&MOVE_FEATURES_CONTRACT)
.output(output_schema_for::<MoveFeaturesResult>)
.params(
"features: [ids or names] (sketches too); component?: where they go (default: the active one) → moved, component, bodies. \
Ids, names and timeline order stay; the geometry stays where it is in the world; the bodies the features made move with \
them. Refused while the timeline is rolled back, for a component placed more than once, and for a move that would break \
a feature (nothing changes)",
),
CommandSpec::new("browser.reparent", "Move to Component", crate::checked!("browser.reparent", reparent_items::reparent_items))
.icon("move")
.contract(&REPARENT_ITEMS_CONTRACT)
.output(output_schema_for::<ReparentItemsResult>)
.params(
"Moves placements, features and bodies into a component. occurrences?: [ids or paths], features?: [ids or names], bodies?: [names], component?: where they go (default: the active \
one), component_path?: its placement → component, occurrences, features, bodies (what moved). One undo step, all or \
nothing: placements move first (taking what they hold), then features (taking the bodies they made), then the bodies \
still elsewhere (a body transfer, which leaves its features behind)",
),
CommandSpec::new("occurrence.copy", "Paste (Instance)", copy_occurrence).icon("copy")
.params("component (or occurrence): what to place again; path?: validates selected occurrence; parent?: component (default: the same parent); translate?: [x,y,z]; in_place?: bool (default false: the selected transform within the parent; true: where the selected placement is in the world, translate in world axes; a parent placed more than once is placed by parent_path?, else the active placement, and every instance gets the copy). Copies the selected effective transform, including pending moves"),
CommandSpec::new("component.paste_new", "Paste New", paste_new).icon("paste").params("component: independently copy its nested definition graph and history, materials and styles; occurrence?, path?: selected source placement; parent?: component the copy goes in (default: the source placement's; not the component or one inside it); in_place?, parent_path?: as occurrence.copy; translate?: [x,y,z]. Reassigned external bodies become base geometry; retained external links and omitted boundary joints are reported"),
@@ -110,6 +153,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 +173,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 +421,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)
}
+211 -56
View File
@@ -83,8 +83,10 @@ fn cloned_path(source: &Document, path: &[u64], root: u64, destination: &[u64],
Ok(mapped)
}
fn unique(base: &str, used: &mut BTreeSet<String>) -> String {
let name = solidcraft_doc::copy_name(base, |n| used.contains(n));
/// A name for a copy of `base` that isn't in `used` (and then is): numbered, or with `keep`
/// (pasting into another design, where the original isn't) `base` itself while it is free.
fn unique(base: &str, used: &mut BTreeSet<String>, keep: bool) -> String {
let name = if keep && !used.contains(base.trim()) { base.trim().to_string() } else { solidcraft_doc::copy_name(base, |n| used.contains(n)) };
used.insert(name.clone());
name
}
@@ -182,6 +184,32 @@ fn graph(doc: &Document, root: u64) -> Result<Vec<u64>> {
Ok(sorted)
}
/// The design Paste New copies from: its document, its evaluated model and its placements moved
/// but not yet applied. The session pasted into is the target; they are the same design for a
/// paste within one design.
pub(super) struct Source<'a> {
pub doc: &'a Document,
pub model: &'a solidcraft_doc::Model,
pub pending_moves: &'a BTreeMap<u64, Mat>,
/// Another design than the target: the copy can't point at anything outside it (it is
/// refused, saying what), and the user parameters it uses come along.
pub other_design: bool,
}
impl<'a> Source<'a> {
/// The session's own design.
pub fn of(s: &'a Session) -> Source<'a> {
Source { doc: &s.doc, model: &s.model, pending_moves: &s.pending_moves, other_design: false }
}
/// What was copied in another design.
pub fn copied(copy: &'a crate::ForeignCopy) -> Source<'a> {
Source { doc: &copy.doc, model: &copy.model, pending_moves: &copy.pending_moves, other_design: true }
}
}
/// An independent copy of `component` (and what it holds), from the session's own design, into
/// `parent`.
pub(super) fn paste(
s: &mut Session,
component: u64,
@@ -190,7 +218,26 @@ pub(super) fn paste(
parent: u64,
params: &Value,
) -> Result<Value> {
let source = s.doc.clone();
let (scratch, out) = build(s, &Source::of(s), component, source_occurrence, transform, parent, None, params)?;
s.accept_computed(scratch);
Ok(out)
}
/// [`paste`] from `origin` into `s`: the computed session to accept, and what the paste made.
/// `parent_path`: the placement of `parent` the copy is placed by (default: the source
/// placement's parent's).
#[allow(clippy::too_many_arguments)]
pub(super) fn build(
s: &Session,
origin: &Source<'_>,
component: u64,
source_occurrence: Option<Occurrence>,
transform: Mat,
parent: u64,
parent_path: Option<Vec<u64>>,
params: &Value,
) -> Result<(Session, Value)> {
let source = origin.doc;
let source_parameters: BTreeMap<_, _> = source.params.iter().map(|p| (p.name.as_str(), p)).collect();
let generated_parameters: BTreeSet<_> = source
.params
@@ -199,22 +246,25 @@ pub(super) fn paste(
.map(|p| p.name.as_str())
.chain(source.features.iter().flat_map(|f| f.param_names.iter().map(String::as_str)))
.collect();
let definitions = graph(&source, component)?;
let definitions = graph(source, component)?;
let definition_records: BTreeMap<_, _> = source.components.iter().map(|c| (c.id, c)).collect();
let selected: BTreeSet<_> = definitions.iter().copied().collect();
if source.marker.is_some_and(|m| m < source.features.len()) {
return Err(bad(CMD, "move the timeline marker to the end before Paste New"));
}
if origin.other_design && s.doc.marker.is_some_and(|m| m < s.doc.features.len()) {
return Err(bad(CMD, "move the timeline marker to the end in this design before pasting"));
}
let feats: Vec<Feature> = source.features.iter().filter(|f| selected.contains(&f.component)).cloned().collect();
if feats.len() + source.features.len() > MAX_WORK {
return Err(bad(CMD, "too many features to copy"));
}
for f in &feats {
if s.model.result(f.id).is_some_and(|r| r.error.is_some()) {
if origin.model.result(f.id).is_some_and(|r| r.error.is_some()) {
return Err(bad(CMD, format!("source feature {} has an evaluation error", f.name)));
}
}
let state = s.model.state();
let state = origin.model.state();
if state.bodies.len() > MAX_WORK {
return Err(bad(CMD, "body set exceeds copy work limit"));
}
@@ -228,11 +278,12 @@ pub(super) fn paste(
let mut names = BTreeMap::new();
let mut bodies = BTreeMap::new();
let mut parameters = BTreeMap::new();
// Names are numbered among their own kind: a component named after its body takes the same
// number as the body's copy, and neither skips one the other took.
let mut used_components: BTreeSet<String> = source.components.iter().map(|c| c.name.clone()).collect();
let mut used_features: BTreeSet<String> = source.features.iter().map(|f| f.name.clone()).collect();
let mut used_bodies: BTreeSet<String> = state.bodies.iter().map(|b| b.name.clone()).collect();
// Names are numbered among their own kind, against the names the target has: a component
// named after its body takes the same number as the body's copy, and neither skips one the
// other took.
let mut used_components: BTreeSet<String> = s.doc.components.iter().map(|c| c.name.clone()).collect();
let mut used_features: BTreeSet<String> = s.doc.features.iter().map(|f| f.name.clone()).collect();
let mut used_bodies: BTreeSet<String> = s.model.state().bodies.iter().map(|b| b.name.clone()).collect();
let mut new_ids = Vec::new();
let mut frozen = Vec::new();
// Allocate definitions, discarding their automatic placeholder occurrence except root.
@@ -243,7 +294,7 @@ pub(super) fn paste(
} else {
components.get(&c.parent).copied().unwrap_or(*components.get(&component).ok_or_else(|| bad(CMD, "missing clone root"))?)
};
let name = unique(&c.name, &mut used_components);
let name = unique(&c.name, &mut used_components, origin.other_design);
let new = scratch.doc_mut().add_component(Some(&name), mapped_parent)?;
// A copy keeps the definition's part properties.
if let Some(added) = scratch.doc_mut().components.iter_mut().find(|x| x.id == new) {
@@ -258,12 +309,14 @@ pub(super) fn paste(
}
let root = *components.get(&component).ok_or_else(|| bad(CMD, "missing clone root"))?;
let root_occurrence = scratch.doc.occurrence_of(root).map(|o| o.id).ok_or_else(|| bad(CMD, "missing cloned placement"))?;
let mut clone_path = if let Some(o) = &source_occurrence {
let mut clone_path = if let Some(path) = parent_path {
path
} else if let Some(o) = &source_occurrence {
let mut path = super::component::occurrence_path(s, params, o.id, CMD)?;
path.pop();
path
} else {
source.first_path(parent)
s.doc.first_path(parent)
};
clone_path.push(root_occurrence);
if let Some(o) = scratch.doc_mut().occurrences.iter_mut().find(|o| o.id == root_occurrence) {
@@ -277,7 +330,7 @@ pub(super) fn paste(
for old in source.occurrences.iter().filter(|o| selected.contains(&o.parent)) {
let child = *components.get(&old.component).ok_or_else(|| bad(CMD, "missing child definition"))?;
let owner = *components.get(&old.parent).ok_or_else(|| bad(CMD, "missing parent definition"))?;
let pose = s.pending_moves.get(&old.id).copied().unwrap_or(old.transform);
let pose = origin.pending_moves.get(&old.id).copied().unwrap_or(old.transform);
if !super::component::valid_position(&pose) {
return Err(bad(CMD, "invalid internal pending occurrence transform"));
}
@@ -292,10 +345,10 @@ pub(super) fn paste(
// The names the bodies of a feature have in the model, where they are its stored names, numbered.
let mut shown: BTreeMap<u64, Vec<String>> = BTreeMap::new();
for f in &feats {
if let Some(result) = s.model.result(f.id) {
if let Some(result) = origin.model.result(f.id) {
let created: Vec<_> = result.state.bodies.iter().filter(|b| b.feature == f.id).map(|b| b.name.clone()).collect();
for name in &created {
bodies.entry(name.clone()).or_insert_with(|| unique(name, &mut used_bodies));
bodies.entry(name.clone()).or_insert_with(|| unique(name, &mut used_bodies, origin.other_design));
}
if named_by(&f.body_names, &created) {
shown.insert(f.id, created.clone());
@@ -307,7 +360,7 @@ pub(super) fn paste(
if !shown.contains_key(&f.id) {
for name in &f.body_names {
if !name.is_empty() {
bodies.entry(name.clone()).or_insert_with(|| unique(name, &mut used_bodies));
bodies.entry(name.clone()).or_insert_with(|| unique(name, &mut used_bodies, origin.other_design));
}
}
}
@@ -328,23 +381,32 @@ pub(super) fn paste(
!f.suppressed
&& matches!(&f.kind, FeatureKind::BodyTransfer { bodies, from, to, .. }
if !selected.contains(from) && bodies.iter().any(|b| *b == body.name)
&& s.model.result(f.id).is_some_and(|result| !result.skipped && result.error.is_none()
&& origin.model.result(f.id).is_some_and(|result| !result.skipped && result.error.is_none()
&& result.state.body(&body.name).is_some_and(|entered| source.model_body_component(entered) == *to)))
});
let before = feats.iter().find_map(|f| {
let before = s.model.state_before(f.id);
let before = origin.model.state_before(f.id);
let prior = before.body(&body.name)?;
let after = s.model.result(f.id)?.state.body(&body.name);
let after = origin.model.result(f.id)?.state.body(&body.name);
if after.is_none_or(|after| !prior.body.same(&after.body)) { Some(before) } else { None }
});
let entered = entry.and_then(|f| s.model.result(f.id)).map(|r| r.state.clone());
let entered = entry.and_then(|f| origin.model.result(f.id)).map(|r| r.state.clone());
let base = entered.as_ref().and_then(|st| st.body(&body.name)).or_else(|| before.as_ref().and_then(|st| st.body(&body.name))).unwrap_or(body);
let baseline_component = source.model_body_component(base);
let geometry = solidcraft_kernel::transform_matrix(&base.body, offset)?;
let step = solidcraft_kernel::step_export(&[&geometry], "SolidCraft independent reassigned body")?;
let new_name = unique(&body.name, &mut used_bodies);
let new_name = unique(&body.name, &mut used_bodies, origin.other_design);
bodies.insert(body.name.clone(), new_name.clone());
let feature_name = unique(&format!("{} Base", body.name), &mut used_features);
// From another design it is named as what it is there, an imported body, numbered as
// the timeline numbers its features ("BodyImport1").
let feature_name = if origin.other_design {
let name =
(1..=MAX_WORK + 1).map(|n| format!("BodyImport{n}")).find(|n| !used_features.contains(n)).unwrap_or_else(|| "BodyImport".into());
used_features.insert(name.clone());
name
} else {
unique(&format!("{} Base", body.name), &mut used_features, false)
};
let id = scratch.doc_mut().add_feature(
FeatureKind::Import { file: format!("{} independent base", body.name), step: step.into(), components: Vec::new(), skip: Vec::new() },
Some(&feature_name),
@@ -358,8 +420,12 @@ pub(super) fn paste(
}
// Allocate all history IDs before rewriting any forward reference.
for f in &feats {
let name = unique(&f.name, &mut used_features);
let id = scratch.doc_mut().add_feature(f.kind.clone(), Some(&name))?;
let name = unique(&f.name, &mut used_features, origin.other_design);
// A transfer is checked as it is added: it names the copy's components (another design
// has none of the original ones).
let mut kind = f.kind.clone();
transfer_into_copy(&mut kind, &components)?;
let id = scratch.doc_mut().add_feature(kind, Some(&name))?;
if let Some(new) = scratch.doc.feature(id) {
for (old, pn) in f.param_names.iter().zip(&new.param_names) {
parameters.insert(old.clone(), pn.clone());
@@ -387,6 +453,11 @@ pub(super) fn paste(
}
}
}
// From another design, the user parameters the copy uses come along (and the ones they use),
// renamed `w_2` where this design has a `w` already.
if origin.other_design {
copy_user_parameters(&mut scratch, source, &feats, &mut parameters)?;
}
for (old, new) in &parameters {
if let Some(comment) = source.param_comments.get(old) {
scratch.doc_mut().param_comments.insert(new.clone(), comment.clone());
@@ -414,26 +485,18 @@ pub(super) fn paste(
f.body_names = outputs.get(&old.id).cloned().unwrap_or_default();
}
f.remap_clone(&features, &names, &bodies, &parameters)?;
if let FeatureKind::BodyTransfer { from, to, transform, .. } = &mut f.kind {
*to = *components.get(to).ok_or_else(|| bad(CMD, "transfer destination is outside the copied graph"))?;
if let Some(mapped) = components.get(from) {
*from = *mapped;
} else {
*from = *to;
*transform = solidcraft_doc::IDENTITY;
}
}
transfer_into_copy(&mut f.kind, &components)?;
if let FeatureKind::Emboss { face: Some(face), .. } = &mut f.kind
&& !face.path.is_empty()
&& let FeatureKind::Emboss { face: Some(old_face), .. } = &old.kind
&& bodies.contains_key(old_face.body.as_ref())
{
face.path = cloned_path(&source, &old_face.path, component, &clone_path, &occurrences)?;
face.path = cloned_path(source, &old_face.path, component, &clone_path, &occurrences)?;
}
if let FeatureKind::Emboss { face: Some(face), .. } = &mut f.kind
&& !face.author_path.is_empty()
{
face.author_path = cloned_path(&source, &face.author_path, component, &clone_path, &occurrences)?;
face.author_path = cloned_path(source, &face.author_path, component, &clone_path, &occurrences)?;
}
if let (FeatureKind::Sketch { plane, .. }, FeatureKind::Sketch { plane: old_plane, .. }) = (&mut f.kind, &old.kind)
&& let (Some(anchor), Some(original)) = (super::sketch::face::anchor_mut(plane), super::sketch::face::anchor(old_plane))
@@ -441,9 +504,9 @@ pub(super) fn paste(
if bodies.contains_key(&original.target.body) {
anchor.component =
*components.get(&original.component).ok_or_else(|| bad(CMD, "anchored source component is outside copied graph"))?;
anchor.path = cloned_path(&source, &original.path, component, &clone_path, &occurrences)?;
anchor.path = cloned_path(source, &original.path, component, &clone_path, &occurrences)?;
}
anchor.author_path = cloned_path(&source, &original.author_path, component, &clone_path, &occurrences)?;
anchor.author_path = cloned_path(source, &original.author_path, component, &clone_path, &occurrences)?;
}
if let Some(slot) = scratch.doc_mut().feature_mut(f.id) {
*slot = f;
@@ -482,7 +545,7 @@ pub(super) fn paste(
.filter_map(|b| bodies.get(&b.name).cloned())
.collect();
if !moved_out.is_empty() {
let name = unique("Assigned-out bodies", &mut used_features);
let name = unique("Assigned-out bodies", &mut used_features, origin.other_design);
let id = scratch.doc_mut().add_feature(FeatureKind::Remove { bodies: moved_out.into_iter().map(BodyName).collect() }, Some(&name))?;
if let Some(f) = scratch.doc_mut().feature_mut(id) {
f.component = root;
@@ -514,14 +577,20 @@ pub(super) fn paste(
scratch.doc_mut().appearances.faces.push(face);
}
}
// Typed configuration and assembly helpers preserve original targets.
scratch.doc_mut().configs.clone_targets(&solidcraft_doc::config::ConfigCloneMaps {
features: features.clone(),
components: components.clone(),
bodies: bodies.clone(),
parameters: parameters.clone(),
})?;
let mut notices = super::component_clone_assembly::clone_assembly(scratch.doc_mut(), &source, &occurrences, &features, &bodies)?;
// Typed configuration and assembly helpers preserve original targets (another design's
// configurations stay with it: this design's table has its own targets).
if !origin.other_design {
scratch.doc_mut().configs.clone_targets(&solidcraft_doc::config::ConfigCloneMaps {
features: features.clone(),
components: components.clone(),
bodies: bodies.clone(),
parameters: parameters.clone(),
})?;
}
let mut notices = super::component_clone_assembly::clone_assembly(scratch.doc_mut(), source, &occurrences, &features, &bodies)?;
// Joints and motion to the outside are left behind (those notices); what follows is what
// the copied features point at outside it.
let assembly_notices = notices.len();
let mut reference_work = 0usize;
for feature in &feats {
if let FeatureKind::Emboss { face: Some(face), .. } = &feature.kind
@@ -605,12 +674,22 @@ pub(super) fn paste(
}
}
}
if origin.other_design {
// What the copy points at outside itself isn't in this design.
let mut outside = outside_references(notices.get(assembly_notices..).unwrap_or_default(), &feats, &bodies);
if !outside.is_empty() {
outside.sort();
outside.dedup();
let name = definition_records.get(&component).map(|c| c.name.clone()).unwrap_or_default();
return Err(bad(CMD, format!("{name} uses what isn't in it, so it can't be pasted into another design: {}", outside.join("; "))));
}
}
notices.sort();
notices.dedup();
scratch.refresh();
let original_errors: BTreeMap<_, _> = s.model.results.iter().map(|result| (result.id, result.error.as_ref())).collect();
let refreshed_errors: BTreeMap<_, _> = scratch.model.results.iter().map(|result| (result.id, result.error.as_ref())).collect();
if source.features.iter().any(|feature| original_errors.get(&feature.id) != refreshed_errors.get(&feature.id)) {
if s.doc.features.iter().any(|feature| original_errors.get(&feature.id) != refreshed_errors.get(&feature.id)) {
return Err(bad(CMD, "copy would change an original feature's error state"));
}
// Base imports acquire new face identities. Resolve copied face overrides against the
@@ -640,14 +719,14 @@ pub(super) fn paste(
let copied_bodies: BTreeMap<_, _> = copied.bodies.iter().map(|body| (body.name.as_str(), body)).collect();
// Adding placements can invalidate cached source evaluation. Compare clone effects
// against that same evaluation's source prefix, not older shared B-rep pointers.
if scratch.doc.features.get(..source.features.len()) != Some(source.features.as_slice())
|| scratch.doc.params.get(..source.params.len()) != Some(source.params.as_slice())
if scratch.doc.features.get(..s.doc.features.len()) != Some(s.doc.features.as_slice())
|| scratch.doc.params.get(..s.doc.params.len()) != Some(s.doc.params.as_slice())
{
return Err(bad(CMD, "copy would change original feature history or parameters"));
}
let original_state = new_ids.first().map(|id| scratch.model.state_before(*id)).unwrap_or_else(|| copied.clone());
let all_columns = std::mem::take(&mut scratch.doc_mut().configs.columns);
scratch.doc_mut().configs.columns = all_columns.iter().skip(source.configs.columns.len()).cloned().collect();
scratch.doc_mut().configs.columns = all_columns.iter().skip(s.doc.configs.columns.len()).cloned().collect();
let style_validation = scratch.doc.configuration_style_status(&copied);
scratch.doc_mut().configs.columns = all_columns;
style_validation?;
@@ -714,10 +793,86 @@ pub(super) fn paste(
}
}
scratch.selection = vec![Sel::Occurrence { path: clone_path }];
s.accept_computed(scratch);
Ok(
json!({"component":root,"occurrence":root_occurrence,"features":new_ids,"components":components.len(),"bodies":expected_count,"frozen_bodies":frozen,"notices":notices}),
)
let out = json!({"component":root,"occurrence":root_occurrence,"features":new_ids,"components":components.len(),"bodies":expected_count,"frozen_bodies":frozen,"notices":notices});
Ok((scratch, out))
}
/// A copied body transfer moves its bodies between the copies of its components; one from
/// outside the copy is where the copy starts (it stays in place, in its destination).
fn transfer_into_copy(kind: &mut FeatureKind, components: &BTreeMap<u64, u64>) -> Result<()> {
if let FeatureKind::BodyTransfer { from, to, transform, .. } = kind {
*to = *components.get(to).ok_or_else(|| bad(CMD, "transfer destination is outside the copied graph"))?;
if let Some(mapped) = components.get(from) {
*from = *mapped;
} else {
*from = *to;
*transform = solidcraft_doc::IDENTITY;
}
}
Ok(())
}
/// What copied features point at outside the copy, in words: the external references Paste New
/// keeps within one design (`notices`), and the bodies they work on that aren't copied.
fn outside_references(notices: &[String], feats: &[Feature], bodies: &BTreeMap<String, String>) -> Vec<String> {
let mut out: Vec<String> =
notices.iter().map(|n| n.replace(" retains an external ", " uses a ").replace(" retains external ", " uses ")).collect();
for f in feats {
for body in f.kind.input_bodies() {
if !bodies.contains_key(&body) {
out.push(format!("{} uses body {body}", f.name));
}
}
}
out
}
/// The user parameters copied features use (their expressions and their dimensions'), and the
/// ones those use, copied from `source` into `scratch`: named as they were where that name is
/// free, else `w_2`, `w_3`…; `parameters` maps each to its copy.
fn copy_user_parameters(scratch: &mut Session, source: &Document, feats: &[Feature], parameters: &mut BTreeMap<String, String>) -> Result<()> {
let user: BTreeMap<&str, &solidcraft_doc::Param> = source.params.iter().filter(|p| !p.model).map(|p| (p.name.as_str(), p)).collect();
let mut pending: Vec<String> = feats.iter().flat_map(|f| f.kind.expressions()).map(str::to_string).collect();
pending.extend(parameters.keys().filter_map(|old| source.params.iter().find(|p| p.model && p.name == *old)).map(|p| p.expr.clone()));
let mut used: BTreeSet<&str> = BTreeSet::new();
let mut work = 0usize;
while let Some(expr) = pending.pop() {
for name in solidcraft_doc::expr::references(&expr) {
work += 1;
if work > 100_000 {
return Err(bad(CMD, "parameter references exceed copy work limit"));
}
if let Some((key, p)) = user.get_key_value(name.as_str())
&& used.insert(key)
{
pending.push(p.expr.clone());
}
}
}
let copied: Vec<&solidcraft_doc::Param> = source.params.iter().filter(|p| used.contains(p.name.as_str())).collect();
if scratch.doc.params.len() + copied.len() > solidcraft_doc::MAX_PARAMS {
return Err(bad(CMD, "too many copied parameters"));
}
let mut taken: BTreeSet<String> = scratch.doc.params.iter().map(|p| p.name.clone()).collect();
for p in &copied {
let mut name = p.name.clone();
let mut n = 2;
while taken.contains(&name) || Document::param_name_problem(&name).is_some() {
name = format!("{}_{n}", p.name);
n += 1;
if n > 10_000 {
return Err(bad(CMD, format!("no free name for parameter {}", p.name)));
}
}
taken.insert(name.clone());
parameters.insert(p.name.clone(), name);
}
for p in copied {
let name = parameters.get(&p.name).cloned().unwrap_or_default();
let expr = solidcraft_doc::clone_expression(&p.expr, parameters);
scratch.doc_mut().params.push(solidcraft_doc::Param { name, expr, unit: p.unit.clone(), comment: p.comment.clone(), model: false });
}
Ok(())
}
#[cfg(test)]
@@ -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);
+107
View File
@@ -0,0 +1,107 @@
//! Pasting what was copied in another design (`Session::foreign_copy`). Bodies come as an
//! imported body each, without their history (that stays with the design they were made in), at
//! the same world coordinates as in their design, in one Import base feature in the target
//! component. Components come as a Paste New does: an independent copy of each with what it
//! holds, its history, appearances, joints and motion, where it is in the world.
use super::*;
pub(super) fn paste_from_design(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "component.paste_from_design";
let copy = s.foreign_copy.clone().ok_or_else(|| bad(cmd, "nothing was copied in another design"))?;
if copy.bodies.is_empty() && copy.placed.is_empty() {
return Err(bad(cmd, "nothing was copied in another design"));
}
let to = if p.get("component").is_some() { component(s, p, "component", cmd)? } else { s.active_component };
let given = match p.get("component_path") {
None => None,
Some(v) => Some(
v.as_array()
.filter(|a| a.len() <= 64)
.and_then(|a| a.iter().map(Value::as_u64).collect::<Option<Vec<_>>>())
.ok_or_else(|| bad(cmd, "`component_path` must list 0..64 occurrence ids"))?,
),
};
let path = match given {
Some(path) => path,
None if to == s.active_component => s.active_occurrence_path.clone().unwrap_or_else(|| s.doc.first_path(to)),
None => placing::placement_of(s, &s.placed_document(), to, None, "component_path", cmd)?.0,
};
let frame = s.placed_document().path_transform(to, &path).ok_or_else(|| bad(cmd, "that path is not a placement of the target"))?;
let into_target = solidcraft_doc::mat_inverse(&frame).ok_or_else(|| bad(cmd, "the target's frame is not invertible"))?;
if copy.bodies.is_empty() {
return paste_components(s, &copy, to, path, into_target, p);
}
// Each body at its world coordinates in its design, in the target's frame, named as in its
// component (a body seen in another placement without that placement) where that name is
// free here, else numbered.
let state = s.model.state();
let mut names: Vec<String> = Vec::new();
let mut shapes = Vec::new();
for (i, seen) in copy.bodies.iter().enumerate() {
let body = copy.world.body(seen).ok_or_else(|| bad(cmd, format!("no body `{seen}` in {}", copy.design)))?;
let name = copy.names.get(i).unwrap_or(seen);
let taken = |n: &str| state.body(n).is_some() || names.iter().any(|x| x == n);
let new = if taken(name) { solidcraft_doc::copy_name(name, taken) } else { name.clone() };
shapes.push(solidcraft_kernel::transform_matrix(&body.body, into_target)?);
names.push(new);
}
drop(state);
let list: Vec<solidcraft_kernel::ExportBody> =
names.iter().zip(&shapes).map(|(name, body)| solidcraft_kernel::ExportBody { name: name.clone(), body, color: body.color() }).collect();
let header = solidcraft_kernel::StepHeader { organization: "SolidCraft".into(), ..Default::default() };
let step = solidcraft_kernel::step_export_bodies(&list, &header)?;
let mut scratch = s.scratch();
let kind = FeatureKind::Import { file: format!("{} (pasted)", copy.design), step: step.into(), components: Vec::new(), skip: Vec::new() };
// Named as any import is ("Import1"); the design they came from stays on it as its file.
let id = scratch.doc_mut().add_feature(kind, None)?;
let feature = scratch.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default();
if let Some(f) = scratch.doc_mut().feature_mut(id) {
f.component = to;
f.body_names = names.clone();
}
scratch.refresh();
if let Some(error) = scratch.model.result(id).and_then(|r| r.error.as_ref()) {
return Err(bad(cmd, format!("the pasted bodies didn't import: {error}")));
}
scratch.selection = names.iter().map(|name| crate::Sel::Body { name: name.clone() }).collect();
s.accept_computed(scratch);
Ok(json!({"bodies": names, "feature": feature, "component": to}))
}
/// Independent copies of the components copied in another design, each with what it holds, in
/// `to` (placed by `path`; `into_target` takes world coordinates into it), where each is in the
/// world; one step to undo. They are selected.
fn paste_components(
s: &mut Session,
copy: &crate::ForeignCopy,
to: u64,
path: Vec<u64>,
into_target: solidcraft_doc::Mat,
p: &Value,
) -> Result<Value> {
let origin = crate::cmd::component_clone::Source::copied(copy);
let mut computed: Option<Session> = None;
let mut made = Vec::new();
for (component, occurrence, world) in &copy.placed {
let source_occurrence = copy.doc.occurrences.iter().find(|o| o.id == *occurrence).cloned();
let transform = solidcraft_doc::mat_mul(&into_target, world);
if !super::valid_position(&transform) {
return Err(bad("component.paste_from_design", "copy transform exceeds supported numeric range"));
}
let target = computed.as_ref().unwrap_or(s);
let (next, out) = crate::cmd::component_clone::build(target, &origin, *component, source_occurrence, transform, to, Some(path.clone()), p)?;
if let Some(id) = out.get("occurrence").and_then(Value::as_u64) {
made.push(path.iter().copied().chain([id]).collect::<Vec<u64>>());
}
computed = Some(next);
}
let Some(mut scratch) = computed else { return Err(bad("component.paste_from_design", "the copied components are gone")) };
scratch.selection = made.iter().map(|path| crate::Sel::Occurrence { path: path.clone() }).collect();
s.accept_computed(scratch);
Ok(json!({"paths": made, "component": to}))
}
#[cfg(test)]
#[path = "component_foreign_tests.rs"]
mod tests;
@@ -0,0 +1,134 @@
#![allow(clippy::unwrap_used)]
use crate::{ForeignCopy, Session};
use serde_json::{Value, json};
use std::sync::Arc;
fn run(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap()
}
fn created(s: &mut Session, name: &str) -> (u64, u64) {
let c = run(s, "component.create", json!({"name": name}));
(c["component"].as_u64().unwrap(), c["occurrence"].as_u64().unwrap())
}
fn volume(s: &mut Session, id: u64) -> f64 {
run(s, "component.properties", json!({"component": id}))["total"]["volume_mm3"].as_f64().unwrap()
}
fn design(name: &str) -> Session {
let mut s = Session::default();
s.doc_mut().name = name.into();
s
}
/// `s` copies its placement `path` for another design.
fn copied(s: &Session, path: Vec<u64>) -> Option<Arc<ForeignCopy>> {
Some(Arc::new(ForeignCopy::of(s, Vec::new(), vec![path])))
}
/// A component copied in another design pastes as an independent copy of it and what it holds:
/// its history, its appearances, a part placed twice in it still one component placed twice,
/// where it is in the world, named "(2)" where the name is taken, as one step to undo.
#[test]
fn a_component_from_another_design_pastes_with_its_history() {
let mut source = design("Bracket");
let (assembly, at) = created(&mut source, "Assembly");
let (child, _) = created(&mut source, "Child");
run(&mut source, "solid.box", json!({"length": 2, "width": 2, "height": 2, "body_name": "Cube"}));
run(&mut source, "appearance.assign", json!({"bodies": ["Cube"], "color": "#abcdef"}));
run(&mut source, "occurrence.copy", json!({"component": child}));
run(&mut source, "component.activate", json!({"component": 0}));
run(&mut source, "occurrence.ground", json!({"occurrence": at, "grounded": false}));
run(&mut source, "occurrence.move", json!({"occurrence": at, "translate": [0, 30, 0]}));
let was = source.world_state().body("Cube").unwrap().mesh().bounds();
let box_features = source.doc.features.len();
let mut s = design("Frame");
created(&mut s, "Assembly");
run(&mut s, "component.activate", json!({"component": 0}));
s.foreign_copy = copied(&source, vec![at]);
let undo = s.undo.len();
let out = run(&mut s, "component.paste_from_design", json!({}));
assert_eq!(s.undo.len(), undo + 1);
let new = s.doc.components.iter().find(|c| c.name == "Assembly (2)").expect("named (2)").id;
assert_eq!(out["paths"], json!([[s.doc.occurrence_of(new).unwrap().id]]));
let kids: Vec<_> = s.doc.occurrences.iter().filter(|o| o.parent == new).collect();
assert_eq!(kids.len(), 2);
assert_eq!(kids[0].component, kids[1].component, "still one component placed twice");
assert_eq!(s.doc.features.len(), box_features, "the box's history came along");
assert!((volume(&mut s, new) - 16.0).abs() < 1e-6);
assert_eq!(s.doc.appearances.bodies["Cube"].color, [0xab, 0xcd, 0xef]);
let is = s.world_state().body("Cube").unwrap().mesh().bounds();
assert!(was.min.dist(is.min) < 1e-6 && was.max.dist(is.max) < 1e-6, "{was:?} vs {is:?}");
// Its history is its own: changing it here leaves the original as it was.
let copy = s.doc.features.iter().find(|f| matches!(f.kind, solidcraft_doc::FeatureKind::Box { .. })).unwrap();
let parameter = copy.param_names[0].clone();
run(&mut s, "parameters.change", json!({"name": parameter, "expression": "3 mm"}));
assert!((volume(&mut s, new) - 24.0).abs() < 1e-6);
assert!((volume(&mut source, assembly) - 16.0).abs() < 1e-6);
let reopened = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
assert_eq!(reopened.occurrences.iter().filter(|o| o.parent == new).count(), 2);
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
assert!(s.doc.components.iter().all(|c| c.name != "Assembly (2)"));
}
/// The user parameters a pasted component uses come along: as they were where the name is
/// free, else renamed `w_2`, its features following; this design's own `w` stays as it is.
#[test]
fn user_parameters_come_along_renamed_where_taken() {
let mut source = design("Bracket");
run(&mut source, "parameters.add", json!({"name": "w", "expression": "4 mm"}));
let (_, at) = created(&mut source, "Part");
let feature = run(&mut source, "solid.box", json!({"length": 2, "width": 2, "height": 2, "body_name": "Cube"}))["feature"].as_u64().unwrap();
let length = source.doc.feature(feature).unwrap().param_names[0].clone();
run(&mut source, "parameters.change", json!({"name": length, "expression": "w"}));
let mut s = design("Frame");
run(&mut s, "parameters.add", json!({"name": "w", "expression": "10 mm"}));
s.foreign_copy = copied(&source, vec![at]);
run(&mut s, "component.paste_from_design", json!({}));
let w2 = s.doc.params.iter().find(|p| p.name == "w_2").expect("w came along as w_2");
assert_eq!(w2.expr, "4 mm");
assert_eq!(s.doc.params.iter().find(|p| p.name == "w").unwrap().expr, "10 mm");
let part = s.doc.components.iter().find(|c| c.name == "Part").unwrap().id;
assert!((volume(&mut s, part) - 16.0).abs() < 1e-6);
run(&mut s, "parameters.change", json!({"name": "w_2", "expression": "1 mm"}));
assert!((volume(&mut s, part) - 4.0).abs() < 1e-6, "the copy follows w_2");
}
/// A component whose features use what isn't in it (here a body outside it) isn't pasted into
/// another design: it is refused, saying what, and the design is left as it was.
#[test]
fn a_component_using_what_is_outside_it_is_refused_saying_what() {
let mut source = design("Bracket");
run(&mut source, "solid.box", json!({"length": 2, "width": 2, "height": 2, "body_name": "Cube"}));
let (_, at) = created(&mut source, "Part");
run(&mut source, "solid.box", json!({"length": 1, "width": 1, "height": 1, "body_name": "Pin"}));
run(&mut source, "solid.move", json!({"bodies": ["Cube"], "translate": [10, 0, 0]}));
let mut s = design("Frame");
s.foreign_copy = copied(&source, vec![at]);
let saved = s.doc.to_json();
let error = s.execute("component.paste_from_design", &json!({})).unwrap_err().to_string();
assert!(error.contains("uses body Cube"), "{error}");
assert_eq!(s.doc.to_json(), saved);
}
/// A component that bodies were moved into (a body transfer, as Create Components from Bodies
/// makes) pastes into another design, which has none of the components the transfer names.
#[test]
fn a_component_bodies_were_moved_into_pastes_into_another_design() {
let mut source = design("Bracket");
run(&mut source, "solid.box", json!({"length": 2, "width": 2, "height": 2, "body_name": "Cube"}));
let (part, at) = created(&mut source, "Part");
run(&mut source, "component.move_bodies", json!({"bodies": ["Cube"], "component": part}));
run(&mut source, "solid.box", json!({"length": 1, "width": 1, "height": 1, "body_name": "Pin"}));
let mut s = design("Frame");
s.foreign_copy = copied(&source, vec![at]);
run(&mut s, "component.paste_from_design", json!({}));
let new = s.doc.components.iter().find(|c| c.name == "Part").unwrap().id;
let state = s.model.state();
assert_eq!(s.doc.model_body_component(state.body("Cube").unwrap()), new);
assert_eq!(s.doc.model_body_component(state.body("Pin").unwrap()), new);
// The body moved in from outside comes as an imported body.
assert!(s.doc.find_feature("BodyImport1").is_some_and(|f| f.component == new));
}
@@ -0,0 +1,143 @@
//! Moving timeline features (sketches included) into another component.
//!
//! It is an organisational change: ids, names and timeline positions stay, nothing is copied, and
//! the geometry stays where it is in the world (a moved feature's picks, planes and directions are
//! re-expressed in the new component's frame). Bodies follow the feature that made them. The move
//! is made on a scratch copy and the whole timeline is evaluated; the result is adopted only when
//! no feature, moved or left behind, gained an error. Otherwise nothing changes.
use std::collections::{BTreeMap, BTreeSet};
use solidcraft_doc::{Feature, FeatureKind, is_identity, mat_inverse, mat_mul};
use super::super::clipboard::explicit_planes;
use super::*;
use crate::params::name_or_id_list;
pub(super) fn move_features(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "component.move_features";
let dest = if p.get("component").is_some() { component(s, p, "component", cmd)? } else { s.active_component };
let keys = name_or_id_list(cmd, p, "features")?;
if keys.is_empty() || keys.len() > 1000 {
return Err(bad(cmd, "`features` must list 1…1000 features"));
}
if s.doc.marker.is_some() {
return Err(bad(cmd, "finish timeline rollback before moving features"));
}
if !s.pending_moves.is_empty() {
return Err(bad(cmd, "finish the component move in progress first"));
}
let mut ids = BTreeSet::new();
for k in &keys {
ids.insert(s.doc.find_feature(k).map(|f| f.id).ok_or_else(|| bad(cmd, format!("no feature `{k}`")))?);
}
let moving: BTreeSet<u64> = ids.into_iter().filter(|id| s.doc.feature(*id).is_some_and(|f| f.component != dest)).collect();
if moving.is_empty() {
return Err(bad(cmd, "already in that component"));
}
let moved: Vec<Feature> = s.doc.features.iter().filter(|f| moving.contains(&f.id)).cloned().collect();
// One placement each: a component placed twice has no single frame to keep the geometry in.
let placements = |c: u64| s.doc.occurrences.iter().filter(|o| o.component == c).count();
let sources: BTreeSet<u64> = moved.iter().map(|f| f.component).collect();
if sources.iter().chain([&dest]).any(|c| *c != 0 && placements(*c) > 1) {
return Err(bad(cmd, "a component placed more than once can't take part in this move"));
}
if moved.iter().any(|f| matches!(f.kind, FeatureKind::BodyTransfer { .. })) {
return Err(bad(cmd, "a recorded body transfer can't be moved"));
}
// The bodies that move with the features: those they are the origin of.
let moved_bodies: BTreeSet<String> = s.body_origins().into_iter().filter(|(_, f)| moving.contains(f)).map(|(body, _)| body).collect();
if moved_bodies.iter().any(|b| s.doc.body_components.contains_key(b) || s.doc.body_offsets.contains_key(b)) {
return Err(bad(cmd, "a body that was transferred between components can't be moved with its features"));
}
// A join or boolean whose inputs would end up in two components.
for f in &s.doc.features {
let mut inputs: BTreeSet<String> = f.kind.input_bodies().into_iter().collect();
inputs.extend(f.participants.iter().cloned());
let (inside, outside) =
(inputs.iter().filter(|b| moved_bodies.contains(*b)).count(), inputs.iter().filter(|b| !moved_bodies.contains(*b)).count());
if inside > 0 && outside > 0 && inputs.len() > 1 {
return Err(bad(cmd, format!("{} works on bodies that would be split between components", f.name)));
}
}
let to_dest = mat_inverse(&s.doc.component_transform(dest)).ok_or_else(|| bad(cmd, "the target component's frame is not invertible"))?;
let dest_path = s.doc.first_path(dest);
let before_errors: BTreeMap<u64, bool> = s.doc.features.iter().map(|f| (f.id, s.model.result(f.id).is_some_and(|r| r.error.is_some()))).collect();
let mut scratch = s.scratch();
let doc = scratch.doc_mut();
for old in &moved {
let delta = mat_mul(&to_dest, &s.doc.component_transform(old.component));
let Some(f) = doc.feature_mut(old.id) else { continue };
f.component = dest;
if !is_identity(&delta) {
if let FeatureKind::Sketch { plane, .. } | FeatureKind::ConstructionPlane { plane } = &mut f.kind {
explicit_planes(plane);
}
f.kind.transform_geometry(&delta);
}
}
// Attached sketches and embossed faces carry the placement of the body they sit on and of
// the component they were authored in: those that moved are rewritten, nothing else changes.
for f in &mut doc.features {
let authored_here = moving.contains(&f.id);
let rewrite = |body: &str, path: &mut Vec<u64>, author_path: &mut Vec<u64>| {
if moved_bodies.contains(body) {
*path = dest_path.clone();
}
if authored_here {
*author_path = dest_path.clone();
}
};
match &mut f.kind {
FeatureKind::Sketch { plane, .. } => {
if let Some(anchor) = crate::cmd::sketch::face::anchor_mut(plane) {
let moved_body = moved_bodies.contains(&anchor.target.body);
rewrite(&anchor.target.body, &mut anchor.path, &mut anchor.author_path);
if moved_body {
anchor.component = dest;
}
}
}
FeatureKind::Emboss { face: Some(face), .. } => {
let body = face.body.as_ref().to_string();
rewrite(&body, &mut face.path, &mut face.author_path);
}
_ => {}
}
}
scratch.refresh();
// Any feature that gains an error, moved or left behind, stops the move.
let broken = scratch
.doc
.features
.iter()
.filter(|f| !before_errors.get(&f.id).copied().unwrap_or(false))
.find_map(|f| scratch.model.result(f.id).and_then(|r| r.error.clone()).map(|e| (f.name.clone(), e)));
if let Some((name, error)) = broken {
return Err(bad(cmd, format!("moving would break {name}: {error}")));
}
s.accept_computed(scratch);
let names: Vec<&str> = moved.iter().map(|f| f.name.as_str()).collect();
Ok(json!({"moved": names, "component": dest, "bodies": moved_bodies}))
}
impl Session {
/// The feature each body depends on first, by body name: the earliest feature whose own result
/// holds a body of that name (a converted mesh keeps its import), else the creator the body
/// carries (the pieces of a split). Moving that feature moves the body.
pub fn body_origins(&self) -> BTreeMap<String, u64> {
let mut origins = BTreeMap::new();
for f in &self.doc.features {
if let Some(r) = self.model.result(f.id) {
for b in r.state.bodies.iter().filter(|b| b.feature == f.id) {
origins.entry(b.name.clone()).or_insert(f.id);
}
}
}
for b in &self.model.state().bodies {
origins.entry(b.name.clone()).or_insert(b.feature);
}
origins
}
}
+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]
+9 -3
View File
@@ -92,16 +92,22 @@ pub(super) fn reparent(s: &mut Session, p: &Value) -> Result<Value> {
}
}
s.accept_computed(scratch);
follow_visibility(s, &old_path, &new_path);
Ok(json!({"occurrence": id, "parent": to, "path": new_path, "transform": transform}))
}
/// Hidden placements and folders under `old_path` follow it to `new_path`. `accept_computed` does
/// not carry them, so whoever accepts a computed move into a session does this after.
pub(super) fn follow_visibility(s: &mut Session, old_path: &[u64], new_path: &[u64]) {
for hidden in &mut s.visibility.hidden_occurrences {
if let Some(moved) = rebased(hidden, &old_path, &new_path) {
if let Some(moved) = rebased(hidden, old_path, new_path) {
*hidden = moved;
}
}
let (old_scope, new_scope) = (Session::folder_scope(&old_path), Session::folder_scope(&new_path));
let (old_scope, new_scope) = (Session::folder_scope(old_path), Session::folder_scope(new_path));
for key in &mut s.visibility.hidden_folders {
if let Some(rest) = key.strip_prefix(&old_scope).filter(|rest| rest.starts_with('/')) {
*key = format!("{new_scope}{rest}");
}
}
Ok(json!({"occurrence": id, "parent": to, "path": new_path, "transform": transform}))
}
@@ -0,0 +1,151 @@
//! Moving placements, timeline features and bodies into a component in one undo step
//! (`browser.reparent`: the owner of each changes, whatever its kind).
//!
//! What Paste does after Cut. Each kind moves by its own command (`occurrence.reparent`,
//! `component.move_features`, `component.move_bodies`), in that order, on a scratch copy, so what
//! an earlier step carried along is left alone by a later one: a moved component takes its
//! features and bodies with it, moved features take the bodies they made. The result is adopted
//! only when every step succeeds, as one undo step; otherwise nothing changes.
use std::collections::BTreeSet;
use super::move_features::move_features;
use super::reparent::{follow_visibility, reparent};
use super::transfer;
use super::*;
use crate::params::{name_or_id_list, string_list};
/// Most items of each kind in one move.
const MAX_ITEMS: usize = 1000;
/// A placement named by its occurrence id, or by its path from the top of the design.
fn placements(s: &Session, p: &Value, cmd: &str) -> Result<Vec<(u64, Option<Vec<u64>>)>> {
let list = match p.get("occurrences") {
None | Some(Value::Null) => return Ok(Vec::new()),
Some(Value::Array(a)) if a.len() <= MAX_ITEMS => a,
Some(_) => return Err(bad(cmd, format!("`occurrences` must list at most {MAX_ITEMS} placements"))),
};
let mut out: Vec<(u64, Option<Vec<u64>>)> = Vec::new();
for entry in list {
let found = match entry {
Value::Number(n) => n.as_u64().map(|id| (id, None)),
Value::Array(a) if !a.is_empty() && a.len() <= 64 => {
a.iter().map(Value::as_u64).collect::<Option<Vec<_>>>().and_then(|path| path.last().copied().map(|id| (id, Some(path))))
}
_ => None,
};
let (id, path) = found.ok_or_else(|| bad(cmd, "`occurrences` must list occurrence ids or paths of 1..64 occurrence ids"))?;
if !s.doc.occurrences.iter().any(|o| o.id == id) {
return Err(bad(cmd, format!("no occurrence {id}")));
}
if !out.iter().any(|(seen, _)| *seen == id) {
out.push((id, path));
}
}
Ok(out)
}
pub(super) fn reparent_items(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "browser.reparent";
let dest = if p.get("component").is_some() { component(s, p, "component", cmd)? } else { s.active_component };
let dest_path = p.get("component_path").cloned();
let occurrences = placements(s, p, cmd)?;
let features = name_or_id_list(cmd, p, "features")?;
let bodies = string_list(cmd, p, "bodies")?;
if features.len() > MAX_ITEMS || bodies.len() > MAX_ITEMS {
return Err(bad(cmd, format!("`features` and `bodies` must list at most {MAX_ITEMS} items each")));
}
if occurrences.is_empty() && features.is_empty() && bodies.is_empty() {
return Err(bad(cmd, "give `occurrences`, `features` or `bodies` to move"));
}
if s.doc.marker.is_some() {
return Err(bad(cmd, "finish timeline rollback before moving"));
}
let mut feature_ids = BTreeSet::new();
for k in &features {
feature_ids.insert(s.doc.find_feature(k).map(|f| f.id).ok_or_else(|| bad(cmd, format!("no feature `{k}`")))?);
}
{
let state = s.model.state();
if let Some(missing) = bodies.iter().find(|b| state.body(b).is_none()) {
return Err(bad(cmd, format!("no body `{missing}`")));
}
}
let mut sc = s.scratch();
sc.active_component = s.active_component;
sc.active_occurrence_path = s.active_occurrence_path.clone();
// Components first: each takes everything it holds.
let mut moved_placements = Vec::new();
let mut moved_components = Vec::new();
let mut followed: Vec<(Vec<u64>, Vec<u64>)> = Vec::new();
for (id, path) in &occurrences {
let Some(o) = sc.doc.occurrences.iter().find(|o| o.id == *id).cloned() else { continue };
if o.parent == dest {
continue;
}
let mut params = json!({"occurrence": id, "parent": dest});
if let Some(path) = path {
params["path"] = json!(path);
}
if let Some(parent_path) = &dest_path {
params["parent_path"] = parent_path.clone();
}
let old_path = occurrence_path(&sc, &params, *id, cmd)?;
let out = reparent(&mut sc, &params)?;
let new_path = out.get("path").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).collect::<Vec<_>>());
followed.extend(new_path.map(|new| (old_path, new)));
moved_placements.push(*id);
moved_components.push(o.component);
}
// What a moved component holds travels with it.
let travels = |sc: &Session, component: u64| moved_components.iter().any(|c| sc.doc.inside_component(component, *c));
// Then features, which take the bodies they made.
let features_to_move: Vec<u64> =
feature_ids.iter().copied().filter(|id| sc.doc.feature(*id).is_some_and(|f| f.component != dest && !travels(&sc, f.component))).collect();
let mut moved_features = Vec::new();
if !features_to_move.is_empty() {
let mut params = json!({"features": features_to_move, "component": dest});
if let Some(path) = &dest_path {
params["component_path"] = path.clone();
}
let out = move_features(&mut sc, &params)?;
moved_features =
out.get("moved").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default();
}
// Last the bodies still elsewhere: one a moved component or feature carried is already there.
let bodies_to_move: Vec<String> = {
let state = sc.model.state();
bodies
.iter()
.filter(|b| {
state.body(b).is_some_and(|body| {
let at = sc.doc.model_body_component(body);
at != dest && !travels(&sc, at)
})
})
.cloned()
.collect()
};
if !bodies_to_move.is_empty() {
let mut params = json!({"bodies": bodies_to_move, "component": dest});
if let Some(path) = &dest_path {
params["component_path"] = path.clone();
}
transfer::move_bodies(&mut sc, &params)?;
}
if moved_placements.is_empty() && moved_features.is_empty() && bodies_to_move.is_empty() {
return Err(bad(cmd, "already in that component"));
}
// The steps above are this command's one undo step.
sc.undo.clear();
s.accept_computed(sc);
for (old, new) in &followed {
follow_visibility(s, old, new);
}
Ok(json!({"component": dest, "occurrences": moved_placements, "features": moved_features, "bodies": bodies_to_move}))
}
@@ -290,3 +290,205 @@ fn edited_transfer_destination_updates_history_owner_copy_and_reopen() {
assert_eq!(reopened.model.state().body("Block").unwrap().component, b);
assert_eq!(reopened.model.state().body("Block (2)").unwrap().component, copied);
}
/// A 10 × 10 × 5 block (sketch `S`, extrude `Extrude1`, body `Block`) in the design with a sketch
/// `Top` on its top face, and a component `Part` placed 100 mm along X. The design is active.
fn block_and_part() -> (Session, u64) {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "S", "distance": 5, "body_name": "Block"}));
run(&mut s, "sketch.create", json!({"plane": {"face": [5, 5, 5]}, "name": "Top"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [2, 2], "p1": [4, 4]}));
run(&mut s, "sketch.finish", json!({}));
let part = run(&mut s, "component.create", json!({"name": "Part"}))["component"].as_u64().unwrap();
let first = s.doc.occurrence_of(part).map(|o| o.id).unwrap();
run(&mut s, "occurrence.move", json!({"occurrence": first, "translate": [100, 0, 0], "capture": true, "move_grounded": true}));
run(&mut s, "component.activate", json!({"component": 0}));
(s, part)
}
/// The lowest corner of every body in the world, rounded.
fn world_corners(s: &Session) -> Vec<Vec<f64>> {
s.world_state()
.bodies
.iter()
.map(|b| {
let min = b.mesh().bounds().min;
vec![min.x.round(), min.y.round(), min.z.round()]
})
.collect()
}
#[test]
fn move_features_keeps_the_world_and_the_attachment_in_the_active_component() {
let (mut s, part) = block_and_part();
let ids: Vec<u64> = s.doc.features.iter().map(|f| f.id).collect();
let names: Vec<String> = s.doc.features.iter().map(|f| f.name.clone()).collect();
let corners = world_corners(&s);
// No `component`: the features go to the active one.
run(&mut s, "component.activate", json!({"component": part}));
let r = run(&mut s, "component.move_features", json!({"features": ["S", "Extrude1"]}));
assert_eq!(r["moved"], json!(["S", "Extrude1"]), "{r}");
// Nothing was added or renamed.
assert_eq!(s.doc.features.iter().map(|f| f.id).collect::<Vec<_>>(), ids);
assert_eq!(s.doc.features.iter().map(|f| f.name.clone()).collect::<Vec<_>>(), names);
for name in ["S", "Extrude1"] {
assert_eq!(s.doc.find_feature(name).map(|f| f.component), Some(part));
}
assert_eq!(s.doc.find_feature("Top").map(|f| f.component), Some(0));
assert_eq!(world_corners(&s), corners, "the block is where it was in the world");
// The sketch left behind follows the body into its new component and still evaluates.
let top = s.doc.find_feature("Top").unwrap();
assert!(s.model.result(top.id).is_some_and(|r| r.error.is_none()));
let anchor = match top.kind.clone() {
FeatureKind::Sketch { mut plane, .. } => crate::cmd::sketch::face::anchor_mut(&mut plane).cloned(),
_ => None,
};
assert_eq!(anchor.map(|a| (a.component, a.path)), Some((part, s.doc.first_path(part))));
}
/// Moved without its body, or with the body moved from under it, a feature would break: the move
/// is refused and nothing changes.
#[test]
fn move_features_refuses_a_move_that_would_break_a_feature() {
for moves in [["S2", "Cut1"], ["S1", "Extrude1"]] {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S1"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 20]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "S1", "distance": 10, "body_name": "Block"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S2"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [5, 5], "p1": [10, 10]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "S2", "distance": 4, "operation": "cut", "name": "Cut1"}));
let part = run(&mut s, "component.create", json!({"name": "Part", "activate": false}))["component"].as_u64().unwrap();
let (before, steps) = (s.doc.to_json(), s.undo.len());
let e = s.execute("component.move_features", &json!({"features": moves, "component": part})).unwrap_err().to_string();
assert!(e.contains("moving would"), "{moves:?}: {e}");
assert_eq!((s.doc.to_json(), s.undo.len()), (before, steps), "{moves:?}");
}
}
#[test]
fn move_features_refusals_change_nothing() {
let (mut s, part) = block_and_part();
let refused = |s: &mut Session, params: Value, why: &str| {
let (before, steps) = (s.doc.to_json(), s.undo.len());
let e = s.execute("component.move_features", &params).unwrap_err().to_string();
assert!(e.contains(why), "{e}");
assert_eq!((s.doc.to_json(), s.undo.len()), (before, steps));
};
refused(&mut s, json!({"features": ["S"], "component": 0}), "already in that component");
refused(&mut s, json!({"features": ["Nope"], "component": part}), "no feature");
refused(&mut s, json!({"features": [], "component": part}), "1…1000");
refused(&mut s, json!({"features": ["S"], "component": "Nowhere"}), "no component");
// A component placed twice.
run(&mut s, "occurrence.copy", json!({"component": part}));
refused(&mut s, json!({"features": ["S", "Extrude1"], "component": part}), "more than once");
// Rolled back.
run(&mut s, "timeline.roll_to", json!({"position": 1}));
refused(&mut s, json!({"features": ["S"], "component": part}), "rollback");
}
#[test]
fn move_features_refuses_a_boolean_whose_inputs_would_be_split() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "A", "name": "BoxA"}));
run(
&mut s,
"solid.box",
json!({"length": 10, "width": 10, "height": 10, "corner": [10, 0, 0], "operation": "new_body", "body_name": "B", "name": "BoxB"}),
);
run(&mut s, "solid.combine", json!({"target": "A", "tools": ["B"], "operation": "join", "name": "Joined"}));
let part = run(&mut s, "component.create", json!({"name": "Part", "activate": false}))["component"].as_u64().unwrap();
let before = s.doc.to_json();
let e = s.execute("component.move_features", &json!({"features": ["BoxA"], "component": part})).unwrap_err().to_string();
assert!(e.contains("split between components"), "{e}");
assert_eq!(s.doc.to_json(), before);
}
/// Each kind moves by its own step, all or nothing, as one undo step: a block's features without
/// the cut that works on it break, so the first call changes nothing; with it, the second moves
/// the placement, the features and the body that is not made by them, and skips the block they carry.
#[test]
fn reparent_items_moves_each_kind_all_or_nothing() {
let (mut s, part) = block_and_part();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S2"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [5, 5], "p1": [10, 10]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "S2", "distance": 4, "operation": "cut", "name": "Cut1"}));
run(
&mut s,
"solid.box",
json!({"length": 10, "width": 10, "height": 10, "corner": [50, 0, 0], "operation": "new_body", "body_name": "Plate", "name": "PlateBox"}),
);
let sub = run(&mut s, "component.create", json!({"name": "Sub", "activate": false}));
let sub_occurrence = sub["occurrence"].as_u64().unwrap();
let (before, steps, corners) = (s.doc.to_json(), s.undo.len(), world_corners(&s));
let moves = |features: Value| json!({"occurrences": [sub_occurrence], "features": features, "bodies": ["Block", "Plate"], "component": part});
// Cut1 would be left without its body: nothing moves, the placement included.
let e = s.execute("browser.reparent", &moves(json!(["S", "Extrude1"]))).unwrap_err().to_string();
assert!(e.contains("moving would"), "{e}");
assert_eq!((s.doc.to_json(), s.undo.len()), (before, steps));
let r = run(&mut s, "browser.reparent", moves(json!(["S", "Extrude1", "S2", "Cut1"])));
assert_eq!(r["occurrences"], json!([sub_occurrence]), "{r}");
assert_eq!(r["features"], json!(["S", "Extrude1", "S2", "Cut1"]), "{r}");
assert_eq!(r["bodies"], json!(["Plate"]), "Block went with its features: {r}");
assert_eq!(s.undo.len(), steps + 1, "one undo step");
assert_eq!(s.doc.occurrences.iter().find(|o| o.id == sub_occurrence).map(|o| o.parent), Some(part));
assert_eq!(world_corners(&s), corners, "everything is where it was in the world");
}
#[test]
fn reparent_items_leaves_the_contents_of_a_moved_component_alone() {
let (mut s, part) = block_and_part();
let dest = run(&mut s, "component.create", json!({"name": "Dest", "activate": false}))["component"].as_u64().unwrap();
let occurrence = s.doc.occurrence_of(part).map(|o| o.id).unwrap();
// A feature inside Part is named as well as Part: it travels with Part, not on its own.
run(&mut s, "component.activate", json!({"component": part}));
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "body_name": "Inner", "name": "InnerBox"}));
run(&mut s, "component.activate", json!({"component": 0}));
let r = run(&mut s, "browser.reparent", json!({"occurrences": [occurrence], "features": ["InnerBox"], "bodies": ["Inner"], "component": dest}));
assert_eq!(r["features"], json!([]), "{r}");
assert_eq!(r["bodies"], json!([]), "{r}");
assert_eq!(s.doc.find_feature("InnerBox").map(|f| f.component), Some(part));
}
#[test]
fn reparent_items_refusals_change_nothing() {
let (mut s, part) = block_and_part();
let refused = |s: &mut Session, params: Value, why: &str| {
let (before, steps) = (s.doc.to_json(), s.undo.len());
let e = s.execute("browser.reparent", &params).unwrap_err().to_string();
assert!(e.contains(why), "{e}");
assert_eq!((s.doc.to_json(), s.undo.len()), (before, steps));
};
refused(&mut s, json!({"component": part}), "give `occurrences`");
refused(&mut s, json!({"features": ["S"], "component": 0}), "already in that component");
refused(&mut s, json!({"bodies": ["Nope"], "component": part}), "no body");
refused(&mut s, json!({"occurrences": [9999], "component": part}), "no occurrence");
refused(&mut s, json!({"occurrences": ["x"], "component": part}), "occurrence ids");
let many: Vec<u64> = (0..1001).collect();
refused(&mut s, json!({"occurrences": many, "component": part}), "at most 1000");
run(&mut s, "timeline.roll_to", json!({"position": 1}));
refused(&mut s, json!({"features": ["S"], "component": part}), "rollback");
}
/// Placements hidden inside a moved component are still hidden under their new path.
#[test]
fn reparent_items_keeps_hidden_placements_hidden() {
let (mut s, part) = block_and_part();
let made = run(&mut s, "component.create", json!({"name": "Dest", "activate": false}));
let (dest, dest_occurrence) = (made["component"].as_u64().unwrap(), made["occurrence"].as_u64().unwrap());
let outer = run(&mut s, "component.create", json!({"name": "Outer", "activate": false}));
let (outer_component, outer_occurrence) = (outer["component"].as_u64().unwrap(), outer["occurrence"].as_u64().unwrap());
let inner =
run(&mut s, "component.create", json!({"name": "Inner", "parent": outer_component, "activate": false}))["occurrence"].as_u64().unwrap();
s.visibility.hidden_occurrences.push(vec![outer_occurrence, inner]);
run(&mut s, "browser.reparent", json!({"occurrences": [outer_occurrence], "component": dest}));
assert_eq!(s.visibility.hidden_occurrences, vec![vec![dest_occurrence, outer_occurrence, inner]]);
assert_eq!(s.doc.occurrences.iter().find(|o| o.id == outer_occurrence).map(|o| o.parent), Some(dest));
let _ = part;
}
+79 -22
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",
),
@@ -67,14 +67,14 @@ pub static COMMANDS: &[CommandSpec] = &[
"features: [id|name], position: insertion slot in the current history; selected features keep their relative order; active?: bool (as timeline.reorder's); refused when a feature that worked would fail, unless allow_errors?: bool (default false): then made, and broken: [{feature, error}] lists the features it broke",
),
CommandSpec::new("timeline.redefine", "Edit Feature", redefine).icon("edit")
.params("feature: id|name, command: the feature's command id, params: its parameters (the feature is rebuilt in place)"),
.params("feature: id|name, command: the feature's command id, params: its parameters (the feature is rebuilt in place) → feature, recomputed, restored, rolled_back: bool (the feature is after the history marker, so the edit is not built yet; `note` then says so), warning?"),
CommandSpec::new("timeline.dependents", "Feature Dependents", dependents).icon("graph")
.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()))?;
@@ -1730,9 +1773,23 @@ fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(e) = s.model.result(id).and_then(|r| r.error.clone()) {
return Err(EngineError::Other(e));
}
// A feature behind the history marker is not built, so the edit changes nothing in the model
// yet: the reply says so, or the edit would look as if it had silently failed (#8100). Its
// warning is the one the constructor met building it in the trial, at its place.
let rolled_back = s.doc.marker.is_some_and(|marker| s.doc.feature_index(id).is_some_and(|index| index >= marker));
let warning =
if rolled_back { r.get("warning").and_then(Value::as_str).map(str::to_string) } else { s.model.result(id).and_then(|r| r.warning.clone()) };
// Report the trial's rebuild, plus anything the session still had to compute itself (a kept
// result its budget dropped); `s.model`'s own counters would only report the reuse (#5694).
Ok(json!({"feature": id, "recomputed": rebuilt + s.model.last_recomputed, "restored": reused}))
let mut reply = json!({"feature": id, "recomputed": rebuilt + s.model.last_recomputed, "restored": reused, "rolled_back": rolled_back});
if let Some(warning) = warning {
reply["warning"] = json!(warning);
}
if rolled_back {
let name = s.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default();
reply["note"] = json!(format!("{name} is after the history marker, so it is not built: move the marker past it to build it"));
}
Ok(reply)
}
fn dependents(s: &mut Session, p: &Value) -> Result<Value> {
+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 {
+8
View File
@@ -469,6 +469,14 @@ impl Session {
super::component_link::resolve_design_links(&mut doc, path);
let mut session = Self::opened(doc, saved);
session.path = path.map(str::to_owned);
// A design saved before body names were pinned (#6403) is pinned now, as its first
// command would pin it: that command pinned them as part of itself, so undoing it went
// back to the unpinned design, whose fingerprints no evaluated result matched, and the
// first undo after opening evaluated the whole timeline again (82-93 s in a
// 5,484-feature design). The pinned names are the ones the file's bodies have, so
// opening is no change: the pinned design is the saved one.
session.pin_body_names();
session.saved = session.doc.clone();
session
}
+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);
@@ -188,6 +188,22 @@ fn a_screw_advances_its_pitch_per_turn() {
assert!(close_to(shown(&mut s, "Lift"), 10.0), "{}", shown(&mut s, "Lift"));
}
/// A coupling's length reads a bare number as millimetres in any design, so a parameter defined
/// in it (`lead = 1.5`) keeps that millimetre, while `parameters.add` without a unit takes the
/// design's units (#8865): the screw advances 1.5 mm a turn in an inch design too.
#[test]
fn a_pitch_defined_in_an_inch_design_reads_as_the_screw_does() {
let mut s = Session::default();
run(&mut s, "document.units", json!({"units": "in"}));
base(&mut s);
part(&mut s, "Nut", [-1.0, -1.0, 0.0]);
pin(&mut s, "cylindrical", "Thread", "Base", "Nut", Vec3::ZERO, Vec3::Z);
run(&mut s, "joint.screw", json!({"joint": "Thread", "pitch": "lead = 1.5"}));
assert_eq!(s.doc.param("lead").map(|p| (p.expr.as_str(), p.unit.as_str())), Some(("1.5", "mm")));
let r = run(&mut s, "joint.drive", json!({"joint": "Thread", "values": [720]}));
assert!(close_to(r["values"][1]["value"].as_f64().unwrap_or(f64::NAN), 3.0), "{r}");
}
/// A motor gear turns the four-bar's crank: half as fast, the other way round, and the loop
/// follows.
#[test]
+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());
}
}
+4
View File
@@ -67,6 +67,7 @@ mod parts;
mod point_cloud;
mod script;
mod section;
pub mod settings;
pub(crate) use measure_sel::{measure_items, shape_of};
pub(crate) use measure_shape::Shape;
#[cfg(test)]
@@ -94,6 +95,7 @@ pub mod sketch_clipboard;
mod sketch_constraints;
mod sketch_create;
mod sketch_ends;
mod sketch_equation;
mod sketch_freeform;
mod sketch_import;
mod sketch_inspect;
@@ -286,6 +288,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
let mut v: Vec<&'static CommandSpec> = Vec::new();
v.extend(sketch::COMMANDS.iter());
v.extend(sketch_create::COMMANDS.iter());
v.extend(sketch_equation::COMMANDS.iter());
v.extend(sketch_constraints::COMMANDS.iter());
v.extend(sketch_import::COMMANDS.iter());
v.extend(sketch_inspect::COMMANDS.iter());
@@ -334,6 +337,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
v.extend(section::COMMANDS.iter());
v.extend(view_navigation::COMMANDS.iter());
v.extend(view_dxf::COMMANDS.iter());
v.extend(settings::COMMANDS.iter());
v.extend(diagnostics::COMMANDS.iter());
v.extend(render::COMMANDS.iter());
v.extend(render_ui::COMMANDS.iter());
+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;
+187
View File
@@ -1507,3 +1507,190 @@ 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}");
}
/// A body copied in another design pastes here as an imported body: at the world coordinates it
/// had there, into the component asked for (placed elsewhere), named as it was or numbered when
/// that name is taken, from one Import feature named as any import is, as one step to undo.
#[test]
fn a_body_copied_in_another_design_pastes_as_an_imported_body() {
let mut source = Session::default();
source.doc_mut().name = "Bracket".into();
let (_, holder) = created(&mut source, "Holder");
run(&mut source, "occurrence.move", json!({"occurrence": holder, "translate": [0,50,0]}));
run(&mut source, "solid.box", json!({"length": 2, "width": 2, "height": 2, "body_name": "Cube"}));
let was = source.world_state().body("Cube").unwrap().mesh().bounds();
let copy = crate::ForeignCopy::of(&source, vec!["Cube".into()], Vec::new());
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 1, "width": 1, "height": 1, "body_name": "Cube"}));
let (a, a_at) = created(&mut s, "A");
run(&mut s, "occurrence.move", json!({"occurrence": a_at, "translate": [100,0,0]}));
s.foreign_copy = Some(std::sync::Arc::new(copy));
let undo = s.undo.len();
let out = run(&mut s, "component.paste_from_design", json!({"component": a, "component_path": [a_at]}));
assert_eq!(out["bodies"], json!(["Cube (2)"]));
assert_eq!(out["feature"], "Import1");
assert_eq!(s.undo.len(), undo + 1);
let is = s.world_state().body("Cube (2)").unwrap().mesh().bounds();
assert!(was.min.dist(is.min) < 1e-6 && was.max.dist(is.max) < 1e-6, "{was:?} vs {is:?}");
let state = s.model.state();
assert_eq!(s.doc.model_body_component(state.body("Cube (2)").unwrap()), a);
assert!(matches!(&s.doc.find_feature("Import1").unwrap().kind, solidcraft_doc::FeatureKind::Import { file, .. } if file == "Bracket (pasted)"));
run(&mut s, "edit.undo", json!({}));
assert!(s.model.state().body("Cube (2)").is_none());
}
/// A body seen in another placement of its component ("Cube (Holder:2)") pastes into another
/// design under its own name, at that placement's position.
#[test]
fn a_body_seen_in_another_placement_pastes_into_another_design_under_its_own_name() {
let mut source = Session::default();
source.doc_mut().name = "Bracket".into();
let (holder, first) = created(&mut source, "Holder");
run(&mut source, "solid.box", json!({"length": 2, "width": 2, "height": 2, "body_name": "Cube"}));
run(&mut source, "component.activate", json!({"component": 0}));
let second = run(&mut source, "occurrence.copy", json!({"occurrence": first}))["occurrence"].as_u64().unwrap();
run(&mut source, "occurrence.move", json!({"occurrence": second, "translate": [0,40,0]}));
let seen = source.placed_document().world_body_name(&source.model.state(), "Cube", holder, &[second]);
assert_ne!(seen, "Cube");
let was = source.world_state().body(&seen).unwrap().mesh().bounds();
let copy = crate::ForeignCopy::of(&source, vec![seen], Vec::new());
assert_eq!(copy.names, vec!["Cube".to_string()]);
let mut s = Session { foreign_copy: Some(std::sync::Arc::new(copy)), ..Default::default() };
let out = run(&mut s, "component.paste_from_design", json!({}));
assert_eq!(out["bodies"], json!(["Cube"]));
let is = s.world_state().body("Cube").unwrap().mesh().bounds();
assert!(was.min.dist(is.min) < 1e-6 && was.max.dist(is.max) < 1e-6, "{was:?} vs {is:?}");
}
+4 -2
View File
@@ -13,7 +13,7 @@ use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("parameters.change", "Change Parameters", change_param).at("SOLID", "MODIFY").icon("params").params(
"name, expression (number or text), unit?: mm|cm|m|in|ft|deg|rad|\"\"|text (new parameters default to their value's unit; \
"name, expression (number or text), unit?: mm|cm|m|in|ft|deg|rad|\"\"|text (new parameters default to their value's unit, a length the design's units; \
a text parameter's expression is its text, quotes round it optional: sketch texts' words are built from it), comment?, \
favorite?: bool, new_name?: rename (references follow; an expression `new = value` renames to `new` and sets value, \
or sets `new` when the expression is just `new`); or delete: name; or changes: {name: expression, …} (existing \
@@ -467,7 +467,9 @@ fn evaluate(s: &mut Session, p: &Value) -> Result<Value> {
(None, None) => None,
};
let (vals, _) = s.doc.param_values();
let defaults = expr::Defaults::for_unit(unit.unwrap_or(""), 1.0);
// Bare numbers follow the asked-for unit when given, else the design's length unit (#9015).
let doc_len = expr::unit_info(s.doc.units.trim()).filter(|(k, _)| *k == Kind::Length).map(|(_, s)| s).unwrap_or(1.0);
let defaults = expr::Defaults::for_unit(unit.unwrap_or(""), doc_len);
let refs = expr::references(&e);
let lookup = |n: &str| vals.get(n).copied().ok_or_else(|| solidcraft_doc::DocError::Expr(format!("unknown parameter `{n}`")));
let r = expr::eval_with_defaults(&e, &lookup, defaults).and_then(|v| match kind {
+150
View File
@@ -326,6 +326,81 @@ fn csv_and_json_round_trip() {
let _ = std::fs::remove_dir_all(&dir);
}
/// #8960: the text marker accepts ASCII case variants without changing numeric units.
#[test]
fn text_parameter_imports_accept_mixed_case_units() {
for unit in ["text", "Text", "TEXT", "tExT", " Text "] {
let csv = format!("name,unit,expression,comment\nwidth,in,1,numeric control\nverID,{unit},\"\"\"REV-A\"\"\",quoted label\n");
let json_text = json!([
{"name": "width", "unit": "in", "expression": "1", "comment": "numeric control"},
{"name": "verID", "unit": unit, "expression": "\"REV-A\"", "comment": "quoted label"}
])
.to_string();
for text in [csv, json_text] {
let mut s = Session::default();
// Check both adding and updating a text parameter through the import command.
for expected in ["REV-A", "REV-B"] {
let r = run(&mut s, "parameters.import", json!({"text": text.replace("REV-A", expected)}));
assert_eq!(r["imported"], json!(["width", "verID"]));
assert_eq!(r["errors"], json!([]));
let list = run(&mut s, "parameters.list", json!({}));
assert_eq!(list["count"], 2);
let label = row(&list, "verID");
assert_eq!(label["unit"], "text");
assert_eq!(label["text"], expected);
assert_eq!(label["expression"], expected);
assert_eq!(label["comment"], "quoted label");
assert!(label["value"].is_null());
assert_eq!(row(&list, "width")["unit"], "in");
assert_eq!(row(&list, "width")["value"], 1.0);
assert_eq!(s.doc.param_values().0["width"].v, 25.4);
}
// Export still uses the canonical marker and round-trips the unquoted text.
let exported = run(&mut s, "parameters.export", json!({"format": "csv"}));
assert!(exported["text"].as_str().unwrap().contains("verID,text,REV-B,,quoted label,"), "{exported}");
let mut reopened = Session::default();
run(&mut reopened, "parameters.import", json!({"text": exported["text"]}));
assert_eq!(reopened.doc.text_param("verID").unwrap().text, "REV-B");
}
}
}
#[test]
fn text_parameter_imports_keep_unknown_units_atomic() {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "verID", "unit": "text", "expression": "original"}));
run(&mut s, "parameters.add", json!({"name": "width", "unit": "mm", "expression": "20"}));
let before = s.doc.to_json();
for unit in ["Textual", "MM"] {
let csv = format!("name,unit,expression\nverID,Text,replacement\nwidth,mm,30\nbad,{unit},1\n");
let json_text = json!([
{"name": "verID", "unit": "Text", "expression": "replacement"},
{"name": "width", "unit": "mm", "expression": "30"},
{"name": "bad", "unit": unit, "expression": "1"}
])
.to_string();
for text in [csv, json_text] {
let why = err(&mut s, "parameters.import", json!({"text": text}));
assert!(why.contains(&format!("unknown unit `{unit}`")), "{why}");
assert_eq!(s.doc.to_json(), before, "a rejected row must roll back the entire import");
}
}
}
#[test]
fn text_parameter_commands_accept_mixed_case_units() {
let mut s = Session::default();
let r = run(&mut s, "parameters.add", json!({"name": "Label", "unit": "Text", "expression": "\"TAG-A\""}));
assert_eq!(r["unit"], "text");
assert_eq!(r["text"], "TAG-A");
run(&mut s, "parameters.change", json!({"name": "Label", "unit": "TEXT", "expression": "'TAG-B'"}));
assert_eq!(s.doc.text_param("Label").unwrap().text, "TAG-B");
let r = run(&mut s, "expr.evaluate", json!({"unit": " tExT ", "expression": "\"TAG-C\""}));
assert_eq!(r["ok"], true);
assert_eq!(r["kind"], "text");
assert_eq!(r["text"], "TAG-C");
}
/// Two triangles joined by three links (a b c, d e f; links a–d, b–e, c–f), a on the origin,
/// a–b horizontal, every distance a dimension: the construction leaves it to the iteration.
/// Returns the session, the sketch feature and the nine dimensions' parameters.
@@ -812,6 +887,69 @@ fn a_defined_length_takes_the_design_unit() {
assert!(rel(volume(&mut s), 50.8 * 25.4 * 25.4) < 1e-9);
}
/// A parameter added without a unit takes the unit of its value, a length in the design's units
/// as a typed length would be (#8865): `40/2` in an inch design is 20 in, as the Parameters
/// dialog makes it. A unit given, an angle and a unit-less value keep their own.
#[test]
fn a_parameter_added_without_a_unit_takes_the_design_unit() {
let added = |s: &mut Session, p: Value| {
let r = run(s, "parameters.add", p);
(r["unit"].as_str().unwrap_or("?").to_string(), r["value"].as_f64().unwrap_or(f64::NAN), r["display_value"].as_f64().unwrap_or(f64::NAN))
};
let close = |got: (String, f64, f64), unit: &str, mm: f64, shown: f64| {
assert!(got.0 == unit && rel(got.1, mm) < 1e-9 && rel(got.2, shown) < 1e-9, "{got:?}, expected {unit} {mm} mm ({shown})");
};
// A millimetre design is as it was.
let mut s = Session::default();
close(added(&mut s, json!({"name": "v", "expression": "40/2"})), "mm", 20.0, 20.0);
close(added(&mut s, json!({"name": "w", "expression": "v + 1"})), "mm", 21.0, 21.0);
let mut s = Session::default();
run(&mut s, "document.units", json!({"units": "in"}));
close(added(&mut s, json!({"name": "v", "expression": "40/2"})), "in", 508.0, 20.0);
close(added(&mut s, json!({"name": "given", "expression": "40/2", "unit": "in"})), "in", 508.0, 20.0);
close(added(&mut s, json!({"name": "metric", "expression": "40/2", "unit": "mm"})), "mm", 20.0, 20.0);
// A value with a unit keeps its size and shows in the design's units.
close(added(&mut s, json!({"name": "plate", "expression": "25.4 mm"})), "in", 25.4, 1.0);
// One that refers to another parameter: its bare number is an inch too.
close(added(&mut s, json!({"name": "more", "expression": "v + 1"})), "in", 533.4, 21.0);
close(added(&mut s, json!({"name": "half", "expression": "v / 2"})), "in", 254.0, 10.0);
// An angle stays in degrees, a unit-less count stays unit-less.
close(added(&mut s, json!({"name": "turn", "expression": "30 deg"})), "deg", 30f64.to_radians(), 30.0);
close(added(&mut s, json!({"name": "n", "expression": "4", "unit": ""})), "", 4.0, 4.0);
close(added(&mut s, json!({"name": "twice", "expression": "n * 2"})), "", 8.0, 8.0);
// parameters.change and parameters.import make a new parameter the same way.
run(&mut s, "parameters.change", json!({"name": "c", "expression": "3"}));
assert_eq!(s.doc.param("c").map(|p| p.unit.as_str()), Some("in"));
run(&mut s, "parameters.import", json!({"text": "name,expression\nimported,2\n"}));
assert_eq!(s.doc.param("imported").map(|p| p.unit.as_str()), Some("in"));
let (vals, _) = s.doc.param_values();
assert!(vals.get("imported").is_some_and(|v| rel(v.v, 50.8) < 1e-9), "{:?}", vals.get("imported"));
// An existing parameter changed without a unit keeps its own.
run(&mut s, "parameters.change", json!({"name": "metric", "expression": "30"}));
assert_eq!(s.doc.param("metric").map(|p| p.unit.as_str()), Some("mm"));
// A centimetre design works the same way.
let mut s = Session::default();
run(&mut s, "document.units", json!({"units": "cm"}));
close(added(&mut s, json!({"name": "v", "expression": "3"})), "cm", 30.0, 3.0);
}
/// Hostile parameters.add input in an inch design is refused with a reason and adds nothing.
#[test]
fn a_hostile_parameter_in_an_inch_design_is_refused() {
let mut s = Session::default();
run(&mut s, "document.units", json!({"units": "in"}));
// 1e308 inches is past any finite millimetre value.
for (name, e) in [("huge", "1e308"), ("inf", "1e308 * 1e308"), ("nan", "0/0"), ("bad", "2 furlong"), ("empty", " "), ("self_ref", "self_ref + 1")]
{
let why = err(&mut s, "parameters.add", json!({"name": name, "expression": e}));
assert!(!why.is_empty() && s.doc.param(name).is_none(), "{name}: {why}");
}
let why = err(&mut s, "parameters.add", json!({"name": "u", "expression": "1", "unit": "furlong"}));
assert!(why.contains("unknown unit"), "{why}");
assert!(s.doc.params.is_empty());
}
/// A definition that cannot be made fails the command and leaves the design as it was: a taken,
/// reserved or malformed name, no value, a value that names itself, two different values for one
/// name, and a parameter's or configuration cell's own expression.
@@ -895,6 +1033,18 @@ fn expr_of_now(s: &Session, name: &str) -> String {
s.doc.param(name).map(|p| p.expr.clone()).unwrap_or_default()
}
/// #9015: `expr.evaluate` of a length with a bare offset uses the design's units.
#[test]
fn expr_evaluate_bare_offsets_follow_design_units() {
let mut s = Session::default();
run(&mut s, "document.units", json!({"units": "in"}));
run(&mut s, "parameters.add", json!({"name": "baseLength", "expression": "76"}));
let r = run(&mut s, "expr.evaluate", json!({"expression": "baseLength - 1", "kind": "length"}));
assert_eq!(r["ok"], true, "{r}");
// 75 in in millimetres (without the fix this was 76 in − 1 mm).
assert!((r["value"].as_f64().unwrap_or(0.0) - 75.0 * 25.4).abs() < 1e-9, "{r}");
}
/// A dimension made as `w = 20` shows `w`; typing `w = 30` over it sets `w` rather than being
/// refused as a taken name (#4071).
#[test]
+128 -8
View File
@@ -22,7 +22,7 @@ pub static COMMANDS: &[CommandSpec] = &[
"the Insert Part dialog's command (same as parts.insert): family, size, length?, at?|point?+direction?, name?",
),
CommandSpec::new("parts.insert", "Insert Part", insert).icon("fastener").params(
"family (socket_head_cap_screw | hex_bolt | hex_nut | washer | set_screw | dowel_pin | bearing); size (\"M6\", or \"608\" for bearings); \
"family (socket_head_cap_screw | button_head_cap_screw | hex_bolt | hex_nut | washer | set_screw | dowel_pin | bearing); size (\"M6\", or \"608\" for bearings); \
length? (mm; screws, bolts, set screws, pins; default a standard length near 2.5 d); \
at?: [x,y,z] on a hole's rim or wall (the part seats on the hole's face with a rigid joint) | point?: [x,y,z] with direction?: [x,y,z] (no joint); name?",
),
@@ -33,6 +33,7 @@ pub static COMMANDS: &[CommandSpec] = &[
/// A family: (id, name, standard).
const FAMILIES: &[(&str, &str, &str)] = &[
("socket_head_cap_screw", "Socket head cap screw", "ISO 4762"),
("button_head_cap_screw", "Button head cap screw", "ISO 7380-1"),
("hex_bolt", "Hex head bolt / screw", "ISO 4014 / ISO 4017"),
("hex_nut", "Hex nut", "ISO 4032"),
("washer", "Plain washer", "ISO 7089"),
@@ -77,6 +78,33 @@ const SHCS: [(&str, f64, f64, f64, f64); 14] = [
("M30", 45.0, 30.0, 22.0, 15.5),
];
/// ISO 7380-1: (size, head diameter dk, head height k, socket s, socket depth t, the last being
/// the key engagement depth, which is 0.59 to 0.63 k throughout). The standard runs M3 to M16:
/// the M2.5 and M14 some suppliers list are not in it.
const BHCS: [(&str, f64, f64, f64, f64); 8] = [
("M3", 5.7, 1.65, 2.0, 1.04),
("M4", 7.6, 2.2, 2.5, 1.3),
("M5", 9.5, 2.75, 3.0, 1.56),
("M6", 10.5, 3.3, 4.0, 2.08),
("M8", 14.0, 4.4, 5.0, 2.6),
("M10", 17.5, 5.5, 6.0, 3.12),
("M12", 21.0, 6.6, 8.0, 4.16),
("M16", 28.0, 8.8, 10.0, 5.2),
];
/// A button head's cylindrical flank, as a fraction of the head height k, and the flat top it
/// keeps around its socket, as a fraction of the socket's across-flats s. The flat clears the
/// socket's corners (0.577 s) at every size.
///
/// ISO 7380-1 draws neither, and a real button head has no flat at all: its crown curves on and
/// the socket's corners break through it. The flat is ours, so that the socket is a plain cut on
/// a plane. It costs us the standard's crown radius r1, which the head's own dk and k then fix:
/// the result is inside r1's tolerance at six of the eight sizes, 0.08 under it at M3 and 0.12
/// over it at M12. No one pair of fractions is inside it at all eight, and taking r1 as given
/// instead leaves no flat clear of the socket below M10, so dk, k, s and t are what we keep exact.
const BUTTON_FLANK: f64 = 0.15;
const BUTTON_FLAT: f64 = 0.7;
/// ISO 4014/4017 hex head: (size, across flats s, head height k).
const HEX_BOLT: [(&str, f64, f64); 14] = [
("M2", 4.0, 1.4),
@@ -170,7 +198,7 @@ fn default_length(d: f64) -> f64 {
/// One part's shape, in its own frame.
#[derive(Clone, Debug, PartialEq)]
enum Shape {
/// A head (round or hex), a shank down −z, an optional hex socket in the head's top.
/// A head (round, domed or hex), a shank down −z, an optional hex socket in the head's top.
Screw { d: f64, pitch: f64, len: f64, head: Head, socket: Option<(f64, f64)> },
/// A hex prism with a through hole (nut), z 0…m.
Nut { d: f64, pitch: f64, s: f64, m: f64 },
@@ -182,8 +210,21 @@ enum Shape {
#[derive(Clone, Copy, Debug, PartialEq)]
enum Head {
Round { dk: f64, k: f64 },
Hex { s: f64, k: f64 },
Round {
dk: f64,
k: f64,
},
/// A button head: a short cylindrical flank, a crown domed over it, and a flat top of
/// radius `flat` around the socket, at z = k.
Dome {
dk: f64,
k: f64,
flat: f64,
},
Hex {
s: f64,
k: f64,
},
}
fn shape(family: &str, size: &str, length: Option<f64>) -> std::result::Result<Shape, String> {
@@ -195,6 +236,11 @@ fn shape(family: &str, size: &str, length: Option<f64>) -> std::result::Result<S
let (_, dk, k, s, t) = SHCS.iter().find(|r| r.0.eq_ignore_ascii_case(size)).copied().ok_or_else(|| need("ISO 4762"))?;
Shape::Screw { d, pitch, len: len(d), head: Head::Round { dk, k }, socket: Some((s, t)) }
}
"button_head_cap_screw" => {
let (d, pitch) = metric(size).ok_or_else(|| need("metric"))?;
let (_, dk, k, s, t) = BHCS.iter().find(|r| r.0.eq_ignore_ascii_case(size)).copied().ok_or_else(|| need("ISO 7380-1"))?;
Shape::Screw { d, pitch, len: len(d), head: Head::Dome { dk, k, flat: BUTTON_FLAT * s }, socket: Some((s, t)) }
}
"hex_bolt" => {
let (d, pitch) = metric(size).ok_or_else(|| need("metric"))?;
let (_, s, k) = HEX_BOLT.iter().find(|r| r.0.eq_ignore_ascii_case(size)).copied().ok_or_else(|| need("ISO 4014"))?;
@@ -260,15 +306,41 @@ fn cyl(s: &mut Session, d: f64, z0: f64, z1: f64, op: &str) -> Result<()> {
/// A solid of revolution about z from a closed (r, z) outline, as one body or joined.
fn revolve(s: &mut Session, pts: &[(f64, f64)], op: &str) -> Result<()> {
revolve_outline(s, pts, None, op)
}
/// The same, with the outline closed from its last point back to its first by an arc through
/// `through`: a button head's domed crown.
fn revolve_domed(s: &mut Session, pts: &[(f64, f64)], through: (f64, f64), op: &str) -> Result<()> {
revolve_outline(s, pts, Some(through), op)
}
fn revolve_outline(s: &mut Session, pts: &[(f64, f64)], arc: Option<(f64, f64)>, op: &str) -> Result<()> {
run(s, "sketch.create", json!({"plane": "XZ"}))?;
let points: Vec<[f64; 2]> = pts.iter().map(|(r, z)| [*r, -*z]).collect();
run(s, "sketch.line", json!({"points": points, "closed": true}))?;
let xz = |(r, z): &(f64, f64)| [*r, -*z];
let points: Vec<[f64; 2]> = pts.iter().map(xz).collect();
run(s, "sketch.line", json!({"points": points, "closed": arc.is_none()}))?;
if let (Some(through), Some(first), Some(last)) = (arc, pts.first(), pts.last()) {
run(s, "sketch.arc.three_point", json!({"start": xz(last), "through": xz(&through), "end": xz(first)}))?;
}
fix_all(s)?;
run(s, "sketch.finish", json!({}))?;
run(s, "solid.revolve", json!({"axis": "y", "operation": op}))?;
Ok(())
}
/// A point halfway along a button head's crown: on the circle centred on the axis that runs
/// from the top of the flank (a, c) to the rim of the flat top (b, k).
fn crown(a: f64, c: f64, b: f64, k: f64) -> (f64, f64) {
// The centre on the axis, equidistant from both, and the radius from it.
let zc = (b * b + k * k - a * a - c * c) / (2.0 * (k - c));
let r = (b * b + (k - zc) * (k - zc)).sqrt();
// Halfway between the directions to the two ends.
let (x, z) = ((a + b) / r, (c - zc + k - zc) / r);
let n = x.hypot(z);
if n.is_finite() && n > 0.0 { (r * x / n, zc + r * z / n) } else { ((a + b) / 2.0, (c + k) / 2.0) }
}
/// Fix every point and curve of the sketch being drawn: a standard part's outline is its size, so
/// its sketches are fully defined and a check of the design finds nothing left free in them.
fn fix_all(s: &mut Session) -> Result<()> {
@@ -297,6 +369,14 @@ fn build(s: &mut Session, sh: &Shape) -> Result<()> {
revolve(s, &[(0.0, -len), (d / 2.0, -len), (d / 2.0, 0.0), (dk / 2.0, 0.0), (dk / 2.0, k), (0.0, k)], "new")?;
k
}
// The same outline up to the head's rim, then a short flank, and the arc of the
// crown closing it back to the flat top.
Head::Dome { dk, k, flat } => {
let (a, c) = (dk / 2.0, BUTTON_FLANK * k);
let outline = [(flat, k), (0.0, k), (0.0, -len), (d / 2.0, -len), (d / 2.0, 0.0), (a, 0.0), (a, c)];
revolve_domed(s, &outline, crown(a, c, flat, k), "new")?;
k
}
// A hex head joined over a shank that runs up into it.
Head::Hex { s: af, k } => {
hex(s, af, 0.0, k, "new")?;
@@ -334,6 +414,7 @@ fn library(_s: &mut Session, p: &Value) -> Result<Value> {
let fam = |id: &str| -> Value {
let mut sizes: Vec<Value> = match id {
"socket_head_cap_screw" => SHCS.iter().map(|r| json!({"size": r.0, "d": metric(r.0).map(|m| m.0), "pitch": metric(r.0).map(|m| m.1), "head_diameter": r.1, "head_height": r.2, "socket": r.3, "socket_depth": r.4})).collect(),
"button_head_cap_screw" => BHCS.iter().map(|r| json!({"size": r.0, "d": metric(r.0).map(|m| m.0), "pitch": metric(r.0).map(|m| m.1), "head_diameter": r.1, "head_height": r.2, "socket": r.3, "socket_depth": r.4})).collect(),
"hex_bolt" => HEX_BOLT.iter().map(|r| json!({"size": r.0, "d": metric(r.0).map(|m| m.0), "pitch": metric(r.0).map(|m| m.1), "across_flats": r.1, "head_height": r.2})).collect(),
"hex_nut" => HEX_NUT.iter().map(|r| json!({"size": r.0, "d": metric(r.0).map(|m| m.0), "pitch": metric(r.0).map(|m| m.1), "across_flats": r.1, "height": r.2})).collect(),
"washer" => WASHER.iter().map(|r| json!({"size": r.0, "inner": r.1, "outer": r.2, "thickness": r.3})).collect(),
@@ -343,7 +424,8 @@ fn library(_s: &mut Session, p: &Value) -> Result<Value> {
_ => Vec::new(),
};
let (_, name, standard) = FAMILIES.iter().find(|f| f.0 == id).copied().unwrap_or(("", "", ""));
let lengths = matches!(id, "socket_head_cap_screw" | "hex_bolt" | "set_screw" | "dowel_pin").then_some(LENGTHS.to_vec());
let lengths =
matches!(id, "socket_head_cap_screw" | "button_head_cap_screw" | "hex_bolt" | "set_screw" | "dowel_pin").then_some(LENGTHS.to_vec());
if lengths.is_some() {
for row in &mut sizes {
let Some(size) = row.get("size").and_then(Value::as_str).map(str::to_string) else { continue };
@@ -378,6 +460,7 @@ fn identity(family: &str, size: &str, sh: &Shape) -> (String, String) {
};
let standard = match family {
"socket_head_cap_screw" => "ISO 4762",
"button_head_cap_screw" => "ISO 7380-1",
"hex_bolt" => "ISO 4017",
"hex_nut" => "ISO 4032",
"washer" => "ISO 7089",
@@ -411,6 +494,7 @@ fn set_identity(s: &mut Session, comp: u64, now: (String, String), was: Option<(
fn label(family: &str) -> &'static str {
match family {
"socket_head_cap_screw" => "SHCS",
"button_head_cap_screw" => "BHCS",
"hex_bolt" => "Hex Bolt",
"hex_nut" => "Hex Nut",
"washer" => "Washer",
@@ -633,6 +717,40 @@ mod tests {
}
}
/// #7692: a button head cap screw (ISO 7380-1) takes the same parameters as a socket head
/// cap screw, with a domed crown in place of the cylindrical head.
#[test]
fn a_button_head_is_domed_over_the_shank() {
let l = run(&mut Session::default(), "parts.library", json!({"family": "button_head_cap_screw"}));
let m6 = l["sizes"].as_array().and_then(|a| a.iter().find(|x| x["size"] == "M6")).cloned().unwrap_or_default();
assert_eq!((m6["head_diameter"].as_f64(), m6["head_height"].as_f64(), m6["socket"].as_f64()), (Some(10.5), Some(3.3), Some(4.0)));
// ISO 7380-1 has no M2, M2.5, M14 or larger than M16; the lengths are a screw's.
let sizes: Vec<&str> = l["sizes"].as_array().map(|a| a.iter().filter_map(|x| x["size"].as_str()).collect()).unwrap_or_default();
assert_eq!(sizes, ["M3", "M4", "M5", "M6", "M8", "M10", "M12", "M16"]);
assert!(l["lengths"].as_array().is_some_and(|a| !a.is_empty()), "{l}");
// An M6 x 20 button head: head Ø10.5, 3.3 high over the seating plane, shank 20 long.
let mut s = Session::default();
run(&mut s, "parts.insert", json!({"family": "button_head_cap_screw", "size": "M6", "length": 20}));
let (lo, hi) = bbox(&s, "BHCS M6x20");
assert!((hi.x - lo.x - 10.5).abs() < 1e-3 && (lo.z + 20.0).abs() < 1e-6 && (hi.z - 3.3).abs() < 1e-3, "{lo:?} {hi:?}");
assert!(s.model.state().threads.iter().any(|t| t.designation.starts_with("M6")), "{:?}", s.model.state().threads);
// The profile: full width at the rim and up the flank (0.15 k = 0.50 high), drawing in
// over the crown (r = 4.62 halfway up the head), and a flat top of 0.7 s = 2.8, which
// leaves a ring of material around the socket's corners at r = 2.31.
let st = s.world_state();
let widest = |above: f64| {
st.bodies.iter().flat_map(|b| b.mesh().positions.clone()).filter(|p| p.z > above).map(|p| p.x.hypot(p.y)).fold(0.0, f64::max)
};
assert!((5.2..5.26).contains(&widest(-0.1)), "the rim: {}", widest(-0.1));
assert!((5.2..5.26).contains(&widest(0.3)), "the flank: {}", widest(0.3));
assert!((3.5..4.7).contains(&widest(1.65)), "the crown halfway up: {}", widest(1.65));
assert!((2.6..2.9).contains(&widest(3.29)), "the flat top: {}", widest(3.29));
// Its part number is the standard and size, as the other families' are.
let comp = s.doc.components.iter().find(|c| c.name.starts_with("BHCS")).map(|c| c.id).unwrap_or(0);
let number = s.doc.components.iter().find(|c| c.id == comp).and_then(|c| c.metadata.text(solidcraft_doc::metadata::PART_NUMBER));
assert_eq!(number, Some("ISO 7380-1 M6x20"));
}
/// #2091: the second and larger ISO sizes, M2, M2.5, M14, M24 and M30, are in the screw, bolt,
/// nut and washer tables (they stopped at M3 and M20).
#[test]
@@ -686,6 +804,7 @@ mod tests {
fn every_sketch_of_a_standard_part_is_fully_defined() {
for (family, size, resized) in [
("socket_head_cap_screw", "M6", "M8"),
("button_head_cap_screw", "M6", "M8"),
("hex_bolt", "M6", "M8"),
("hex_nut", "M6", "M8"),
("washer", "M6", "M8"),
@@ -710,6 +829,7 @@ mod tests {
fn resizing_placed_parts_uses_local_geometry_and_millimetres_in_every_design_unit() {
for (family, initial, resized, prefix) in [
("socket_head_cap_screw", "M6", "M8", "SHCS"),
("button_head_cap_screw", "M6", "M8", "BHCS"),
("hex_bolt", "M6", "M8", "Hex Bolt"),
("hex_nut", "M6", "M8", "Hex Nut"),
("washer", "M6", "M8", "Washer"),
@@ -726,7 +846,7 @@ mod tests {
assert!(s.model.results.iter().all(|r| r.error.is_none()), "{family} {units}");
let b = bbox(&s, prefix);
assert!(b.0.x < b.1.x && b.0.y < b.1.y && b.0.z < b.1.z, "{family} {units}: {b:?}");
if matches!(family, "socket_head_cap_screw" | "hex_bolt" | "hex_nut" | "set_screw") {
if matches!(family, "socket_head_cap_screw" | "button_head_cap_screw" | "hex_bolt" | "hex_nut" | "set_screw") {
assert!(s.model.state().threads.iter().any(|t| t.designation == "M8" && (t.pitch - 1.25).abs() < 1e-9), "{family} {units}");
}
if family == "dowel_pin" {
+54
View File
@@ -1006,6 +1006,22 @@ fn pattern_count_units_explain_how_to_use_a_parameter() -> Result<(), Box<dyn st
Ok(())
}
/// #9015: in an inch design, pattern spacing `baseLength - 1` subtracts one inch, not one mm.
#[test]
fn pattern_spacing_bare_offsets_follow_design_units() {
let mut s = Session::default();
run(&mut s, "document.units", json!({"units": "in"}));
run(&mut s, "parameters.add", json!({"name": "baseLength", "expression": "4"}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Seed"}));
run(&mut s, "solid.pattern.rectangular", json!({"bodies": ["Seed"], "dir1": [1, 0, 0], "count1": 2, "spacing1": "baseLength - 1"}));
assert_eq!(s.model.state().bodies.len(), 2);
// Spacing is 3 in = 76.2 mm (not 3 mm): the seed is 10 in, so the copy starts at x = 76.2.
let m = run(&mut s, "inspect.measure", json!({}));
let bodies = m["bodies"].as_array().expect("bodies");
let xmin = |b: &Value| b["bbox"]["min"][0].as_f64().unwrap_or(0.0);
assert!(bodies.iter().any(|b| (xmin(b) - 76.2).abs() < 1e-6), "expected a copy spaced 3 in apart, got {m}");
}
#[test]
fn pattern_count_fractions_are_refused() -> Result<(), Box<dyn std::error::Error>> {
let mut s = Session::default();
@@ -1049,3 +1065,41 @@ fn pattern_count_integer_parameters_survive_save_and_reject_fractional_edits() -
assert!(invalid.model.results.iter().any(|r| r.error.as_ref().is_some_and(|e| e.contains("a count must be a whole number;"))));
Ok(())
}
/// Editing a pattern behind the history marker (double-clicking its greyed-out item and pressing
/// OK) changes nothing in the model: the reply says so, where the edit used to look as if it had
/// silently failed, and it still carries what building the copies met (#8100).
#[test]
fn editing_a_rolled_back_pattern_says_it_is_not_built() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length":40,"width":30,"height":10,"corner":[0,0,0],"body_name":"Plate"}));
run(&mut s, "solid.cylinder", json!({"radius":2,"height":20,"base":[10,15,-5],"operation":"cut","targets":["Plate"],"name":"Cut"}));
let pattern = |count: u32, origin: [f64; 3]| json!({"features":["Cut"],"axis":{"origin":origin,"dir":[0,0,1]},"count":count});
let redefine = |count, origin| json!({"feature":"Pattern","command":"solid.pattern.circular","params":pattern(count, origin)});
let centre = [20.0, 15.0, 0.0];
let hole = PI * 4.0 * 10.0;
let mut first = pattern(2, centre);
first["name"] = json!("Pattern");
let id = run(&mut s, "solid.pattern.circular", first)["feature"].as_u64().unwrap();
assert!(rel(volume(&mut s), 12000.0 - 2.0 * hole) < 1e-4);
// A pattern that is built: the edit is built at once, with nothing to say.
let reply = run(&mut s, "timeline.redefine", redefine(3, centre));
assert_eq!(reply["rolled_back"], json!(false), "{reply}");
assert!(reply.get("note").is_none(), "{reply}");
assert!(rel(volume(&mut s), 12000.0 - 3.0 * hole) < 1e-4);
// Behind the marker: the edit is kept but not built, and the reply says why nothing changed.
run(&mut s, "timeline.roll_to", json!({"position": 2}));
let reply = run(&mut s, "timeline.redefine", redefine(4, centre));
assert_eq!(reply["rolled_back"], json!(true), "{reply}");
assert_eq!(reply["note"], "Pattern is after the history marker, so it is not built: move the marker past it to build it");
assert!(reply.get("warning").is_none(), "{reply}");
assert!(rel(volume(&mut s), 12000.0 - hole) < 1e-4);
assert!(s.model.result(id).is_some_and(|r| r.skipped && r.error.is_none()));
run(&mut s, "timeline.roll_to", json!({}));
assert!(rel(volume(&mut s), 12000.0 - 4.0 * hole) < 1e-4);
// What building the rolled-back copies met still reaches the reply: here a copy that misses.
run(&mut s, "timeline.roll_to", json!({"position": 2}));
let reply = run(&mut s, "timeline.redefine", redefine(2, [0.0, 0.0, 0.0]));
assert_eq!(reply["rolled_back"], json!(true), "{reply}");
assert_eq!(reply["warning"], "1 of 1 cut copies remove no material", "{reply}");
}

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