Compare commits

...

104 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
bherbruck 9df8837d43 Release 0.26.1 2026-10-29 06:33:09 -04:00
bherbruck 409ab1ed45 Release 0.26.0 (zip 9.1.3) 2026-10-28 19:41:38 -04:00
bherbruck 5e03fe2f4b Release 0.26.0 2026-10-28 19:32:17 -04:00
bherbruck 37a586dd61 Release 0.25.1 2026-10-27 20:58:30 -04:00
bherbruck a79f40a711 Release 0.25.0 2026-10-27 18:05:39 -04:00
bherbruck b5728f284b Release 0.24.1 2026-10-27 08:34:27 -04:00
bherbruck 5965439a15 Release 0.24.0 2026-10-26 19:23:21 -04:00
bherbruck 586a358ed4 Release 0.23.1 2026-10-26 07:07:21 -04:00
bherbruck 7770f8982d Release 0.23.0 2026-10-25 18:18:39 -04:00
bherbruck 72ad6383ef Release 0.22.1 2026-10-25 10:38:39 -04:00
bherbruck 0537623a75 Release 0.22.0 2026-10-24 18:53:43 -04:00
bherbruck 65d288227e Release 0.21.0 (rc.2's code, version only)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 20:51:08 -04:00
bherbruck fedb3bcdb1 Release 0.21.0-rc.2 2026-10-23 17:14:38 -04:00
bherbruck 097b8b7550 Release 0.21.0-rc.1
The tree is main at f120285c9 exactly; the previous release commit is kept as a parent so release only moves forward.
2026-10-23 13:48:05 -04:00
bherbruck af7ca3a8a2 Release 0.21.0-rc.1
The tree is main at b31fddb98 exactly; the previous release commit is kept as a parent so release only moves forward.
2026-10-23 13:37:43 -04:00
bherbruck 27ddb725c7 Release 0.21.0-rc.1
The tree is main at 2bf3d4e11 exactly; the previous release commit is kept as a parent so release only moves forward.
2026-10-23 12:10:02 -04:00
bherbruck a06bdf5cfc Release 0.20.0
The tree is main at 030187452 exactly; the previous release commit is kept as a parent so release only moves forward.
2026-10-22 18:34:40 -04:00
bherbruck 33fdcdb3a0 Release 0.19.0
The tree is main at 5899e5b6c exactly; the previous release commit is kept as a parent so release only moves forward.
2026-10-21 21:13:33 -04:00
bherbruck c07ab5f8f9 Release 0.18.4
The tree is main at 728f753b8 exactly; the previous release commit is kept as a parent so release only moves forward.
2026-10-21 12:26:34 -04:00
bherbruck fea32f8aac Release 0.18.3
The tree is main at 034aa60d4 exactly; the previous release commit is kept as a parent so release only moves forward.
2026-10-21 08:46:37 -04:00
bherbruck 4927214976 Release 0.18.2
The tree is main at 57bcc4083 exactly; the previous release commit is kept as a parent so release only moves forward.
2026-10-20 22:09:18 -04:00
bherbruck b621819c0c Release 0.18.1
The tree is main at 522ae7755 exactly; the previous release commit is kept as a parent so release only moves forward.
2026-10-20 17:39:11 -04:00
bherbruck ec552a5300 Release 0.18.0
The tree is main at c3151fb7f exactly; the previous release commit is kept as a parent so release only moves forward.
2026-10-20 16:28:59 -04:00
bherbruck e0cc740cc3 Release 0.17.0
The tree is main at 511faedcf exactly; the 0.16.x release commits are kept as a parent so release only moves forward.
2026-10-20 11:41:48 -04:00
bherbruck 5de48e916d Release 0.16.2 2026-10-19 00:39:30 +00:00
bherbruck 80af7bbe3e Release 0.16.2
Version 0.16.2: 0.16.1 with the rigid orbit fix (#2753). Same design format (27).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 00:38:53 +00:00
bherbruck 191e983423 Fix orbit about the picked point (regression in 0.16.0) (#2753)
Orbiting about the point under the cursor (or the origin) turned the camera
about its target and then panned to put the pivot back. The pan works at the
target's depth with the parallel-view scale, so in a perspective view the
pivot slid off its pixel and the model swung about the camera.

The camera now turns rigidly about the pivot: Camera::orbit_about keeps the
pivot's coordinates in the camera's frame and rebuilds the target from the
new orientation, so the pivot stays exactly on its pixel in both projections.
The Shift snap spring and view.navigate's pivot orbit use the same.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit e88779218)
2026-10-19 00:38:52 +00:00
bherbruck 2b9445b049 Release 0.16.1 2026-10-18 20:16:21 +00:00
bherbruck 7ddf48ca16 Release 0.16.1
Version 0.16.1: 0.16.0 with the orbit fix (reverts #2142). Same design format (27).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:12:04 +00:00
bherbruck d2f6848377 Revert #2142: orbit about the picked point like 0.15.0
#2142 made orbiting about the point under the cursor turn the camera in place instead of turning the part about that point. Restore the 0.15.0 behaviour.

This reverts commit cac4bccea8.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:12:00 +00:00
bherbruck 0b6d6fecd8 Release 0.16.0 2026-10-18 18:16:11 +00:00
bherbruck fa691707c2 Release 0.15.0 2026-10-17 13:54:24 +00:00
bherbruck b9a55c74e2 Release 0.15.0
Version 0.15.0; the pending design-format changes are folded into format 26.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:53:44 +00:00
bherbruck 2bb9c81069 Release 0.14.0 2026-10-16 09:19:14 +00:00
bherbruck cedb51b8ea Release 0.14.0
Version 0.14.0; the pending design-format changes are folded into format 25.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 09:19:10 +00:00
bherbruck 66d13059dd Release 0.13.1 2026-10-15 21:56:43 +00:00
bherbruck 800ea40c0d Release 0.13.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 21:56:35 +00:00
bherbruck 20442eecd3 Agent chat on Windows: start npm agents through their .cmd shim
npm puts a Unix shell script with the bare name (codex-acp,
claude-code-acp, gemini) beside the .cmd shim. The lookup tried the bare
name first, so every npm-installed agent failed with "not a valid Win32
application (os error 193)". Windows now only considers .exe/.cmd/.bat,
and a command saved with the bare path runs the shim beside it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 60ea8c69d2)
2026-10-15 21:51:01 +00:00
bherbruck 22d1cd7032 Release 0.13.0 2026-10-15 20:34:13 +00:00
bherbruck 6a0dc18655 Fix the Windows 32-bit and macOS release builds
On 32-bit Windows SetWindowLongPtrW is SetWindowLongW, which takes and
returns i32, so the window-procedure hook didn't compile for i686.
The macOS app bundle now signs the bundled solidcraft-cli before the
bundle itself; codesign refuses a bundle with an unsigned helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 94e76cd97e)
2026-10-15 20:34:12 +00:00
bherbruck e006dbf1af Release 0.13.0
The tree is the 0.13.0 version commit (8241a750fa) on main exactly; the previous release is kept as a parent so release only moves forward.
2026-10-15 19:59:50 +00:00
bherbruck 8241a750fa Release 0.13.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:59:42 +00:00
bherbruck 41779cf7bb Release 0.12.0: release branch follows main again after the 0.11.1 patch
The tree is main at c36a6f9f exactly; the 0.11.1 patch commit is kept as a parent so release only moves forward.
2026-10-15 13:32:01 +00:00
bherbruck b01b9689c8 Release 0.11.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:41:41 +00:00
bherbruck 4426da6f5d Windows: start Direct3D 12 first, and Vulkan only without a D3D12 GPU (#1190)
* Windows: start Direct3D 12 first, and Vulkan only without a D3D12 GPU

wgpu started Vulkan, D3D12 and OpenGL together and preferred Vulkan, so
every Windows start loaded all installed Vulkan drivers. An old Intel
Vulkan driver (igvk64.dll) crashed 0.10.1 at startup inside the driver.

The app now probes D3D12, then Vulkan, then OpenGL, one backend at a
time, and starts only the first with a hardware adapter (WARP when none
has one). WGPU_BACKEND still wins; graphics.json's "backend" sets the
order by hand; the software renderer (safe mode, the retry) uses D3D12
alone. A new Windows CI job starts the real app under several settings.

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

* CI: a test filter YAML parses

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

* CI: the startup smoke test writes graphics.json where the app reads it

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

* CI: run the WARP shader tests one at a time

WARP's shader JIT (d3d10warp.dll) crashed twice in 124 runs of the gpu
tests with four test threads, with a null read inside its own optimizer
while several devices compiled compute pipelines at once. Serial runs
did not crash.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 95ea05e7c8)
2026-10-14 20:41:37 +00:00
bherbruck 5ead82e627 Release 0.11.0: release branch follows main again after the 0.10.1 patch
The tree is main at cccbe125 exactly; c29b0d2f (0.10.1) is kept as a parent so release only moves forward.
2026-10-14 20:07:41 +00:00
bherbruck c29b0d2f13 Release 0.10.1 2026-10-14 14:41:19 +00:00
bherbruck 480fd8d517 Fix the startup crash on D3D12 with FXC: CPU fallback for the tessellation pipeline, FXC-friendly shader (#1098) (#1099)
* GPU tessellation: a pipeline the device cannot build falls back to CPU meshing instead of crashing (#1098)

Build the tessellation compute pipeline inside error scopes; TessGpu::new returns an error,
and the viewport logs it and meshes bodies on the CPU. A Windows CI job builds the pipeline
with D3D12's FXC on the WARP adapter.

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

* Tessellation shader: basis functions in one-dimensional local arrays, so D3D12's FXC compiles it (#1098)

FXC has to unroll loops that write an array inside a struct (Basis.n0/n1) or a 2-D array
(ndu) by a counter, and cannot with run-time trip counts (X3511). A2.2 basis functions in a
local 1-D array returned whole; derivatives from the degree p-1 functions; the pole step
offsets computed rather than indexed.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:41:16 +00:00
bherbruck 4c09fa5355 Hovered and selected faces light solidly again over GPU-drawn bodies (#1064)
Since the model's mesh went back to the reference mesher (#940), the GPU viewport drew
opaque bodies from their display plans while hover and selection lit faces with the
reference mesh's triangles. Two triangulations of the same face at equal depth fought, so
a hovered face shimmered in wedges instead of lighting whole.

Faces lit over a body drawn from its plan now use the plan's own triangles
(ModelBody::plan_mesh, evaluated once on the CPU), and the highlight pass gets a small
depth bias toward the eye so f32 rounding between the GPU and CPU evaluations of the same
triangles cannot speckle it either.

Test: every face of a box with a pocket and a round boss, hovered and rendered offscreen
through the viewport's real pipelines on the GPU path, lights the same pixels as on the
CPU path (skipped without a GPU adapter).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:56:47 +00:00
169 changed files with 9908 additions and 573 deletions
+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
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())?;
+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");
}
}
+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
@@ -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

+76 -8
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?": "استعادة التصميم غير المحفوظ؟",
@@ -730,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": "عناصر تحكم المساعد",
@@ -822,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": "معايرة اللوحة",
@@ -848,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": "إعادة ربط المصدر",
@@ -1076,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",
@@ -1121,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": "ملاءمة منحنيات لمقطع الشبكة",
@@ -1217,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": "استعادة عرض مسمّى",
@@ -1374,6 +1389,7 @@
"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": "يضم الأجسام، أو يقتطعها من جسم هدف، أو يُبقي الحجم المشترك بينها فقط، ويمكنه الاحتفاظ بأجسام الأداة. استخدمه لدمج القطع، أو لقطع شكل بشكل آخر، أو لمعرفة موضع تداخل جسمين.",
@@ -1467,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": "أنشئ تصميمًا، أو افتح ملفًا حديثًا، أو استكشف مثالًا.",
@@ -1642,7 +1658,7 @@
"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": "الأيقونات",
@@ -1743,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": "صامولة سداسية",
@@ -1801,8 +1818,8 @@
"prefs.bottom_centre": "أسفل الوسط",
"prefs.bottom_right": "أسفل اليمين",
"prefs.browser": "المستعرض",
"prefs.browser_reveal_on_hover": "إظهار عند التمرير",
"prefs.browser_show_note": "«ظاهر» يُبقي المستعرض على الشاشة. «إظهار عند التمرير» يُخفيه حتى يمر المؤشر فوق مكانه أو فوق الشريط الرفيع عند حافته اليسرى، ويبقى مفتوحًا ما دام المؤشر داخله أو كانت قائمة منه مفتوحة.",
"prefs.browser_reveal_on_hover": "إظهار عند مرور المؤشر",
"prefs.browser_show_note": "«ظاهر» يُبقي المستعرض على الشاشة. «إظهار عند مرور المؤشر» يُخفيه حتى يمر المؤشر فوق مكانه أو فوق الشريط الرفيع عند حافته اليسرى، ويبقى مفتوحًا ما دام المؤشر داخله أو كانت قائمة منه مفتوحة.",
"prefs.browser_shown": "ظاهر",
"prefs.built_in": "مدمج",
"prefs.button_left": "سحب بالزر الأيسر",
@@ -1874,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": "المعاينة المباشرة",
@@ -1906,10 +1925,26 @@
"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.project_options_dialog": "مربع حوار خيارات Project",
"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": "الأسرع",
@@ -1918,6 +1953,8 @@
"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%.",
@@ -1931,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": "عند انتهاء الرسم",
@@ -1988,7 +2029,7 @@
"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.help": "اضغط مع الاستمرار على هذا المفتاح وحده للتكبير عند المؤشر بالتمرير العمودي. عند الإيقاف يبقى سلوك التمرير كما هو.",
"prefs.trackpad_zoom_modifier.off": "إيقاف",
"prefs.translucent": "شفاف",
"prefs.under_browser": "تحت المستعرض",
@@ -2005,6 +2046,8 @@
"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} يوم",
@@ -2702,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": "مركز المنتج",
@@ -3355,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",
@@ -3379,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}",
@@ -3448,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": "نقل عناصر الخط الزمني",
@@ -3531,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": "إعادة تعيين العرض",
@@ -4008,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…",
@@ -4073,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": "إنهاء التحرير",
@@ -4164,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": "عزل",
@@ -4387,6 +4454,7 @@
"ui.select": "تحديد",
"ui.selectOther": "تحديد آخر…",
"ui.selectTangentChain": "تحديد سلسلة مماسية",
"ui.select_all": "تحديد الكل",
"ui.selection": "التحديد",
"ui.selection_modifiers": "مُعدِّلات التحديد",
"ui.selection_shift_add_only": "Shift مع النقر يضيف دون إزالة",
+65
View File
@@ -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.",
@@ -1697,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",
@@ -1826,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",
@@ -1837,10 +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",
@@ -1877,6 +1915,8 @@
"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}",
@@ -2772,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",
@@ -2792,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",
@@ -2849,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",
@@ -2930,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",
@@ -3355,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…",
@@ -3417,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",
@@ -3497,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",
@@ -3702,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",
+68
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?",
@@ -822,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",
@@ -848,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",
@@ -1076,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",
@@ -1121,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",
@@ -1217,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",
@@ -1374,6 +1389,7 @@
"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.",
@@ -1743,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",
@@ -1874,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",
@@ -1906,9 +1925,25 @@
"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",
@@ -1918,6 +1953,8 @@
"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%.",
@@ -1931,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",
@@ -2005,6 +2046,8 @@
"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",
@@ -3355,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",
@@ -3379,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}",
@@ -3448,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",
@@ -3531,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",
@@ -4008,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…",
@@ -4073,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",
@@ -4164,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",
@@ -4387,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",
+1
View File
@@ -3449,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",
+3
View File
@@ -3166,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",
@@ -4089,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",
+68
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?": "לשחזר תכן שלא נשמר?",
@@ -822,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": "כיול קנבס",
@@ -848,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": "קישור מחדש למקור",
@@ -1076,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",
@@ -1121,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": "התאמת עקומות לחתך רשת משולשים",
@@ -1217,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": "שחזור תצוגה שמורה",
@@ -1374,6 +1389,7 @@
"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": "איחוד גופים, חיסורם מגוף יעד או השארת הנפח המשותף להם בלבד, עם אפשרות לשמור את גופי הכלי. מתאים למיזוג חלקים, לחיתוך צורה אחת בעזרת אחרת או למציאת החפיפה בין שני גופים.",
@@ -1743,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": "אום משושה",
@@ -1874,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": "תצוגה מקדימה חיה",
@@ -1906,9 +1925,25 @@
"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": "ברירת מחדל",
@@ -1918,6 +1953,8 @@
"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%.",
@@ -1931,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": "בסיום סקיצה",
@@ -2005,6 +2046,8 @@
"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} ימים",
@@ -3355,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",
@@ -3379,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}",
@@ -3448,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": "הזזת פריטים בציר הזמן",
@@ -3531,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": "איפוס המבט",
@@ -4008,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…",
@@ -4073,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": "סיום עריכה",
@@ -4164,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": "בידוד",
@@ -4387,6 +4454,7 @@
"ui.select": "בחירה",
"ui.selectOther": "בחירת אחר…",
"ui.selectTangentChain": "בחירת שרשרת משיקה",
"ui.select_all": "בחירת הכול",
"ui.selection": "בחירה",
"ui.selection_modifiers": "מקשי שינוי לבחירה",
"ui.selection_shift_add_only": "Shift+לחיצה מוסיף בלי להסיר",
+1
View File
@@ -4070,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",
+68
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?": "未保存のデザインを復元しますか?",
@@ -822,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": "キャンバスの尺度を設定",
@@ -848,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": "参照元を再リンク",
@@ -1076,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 としてエクスポート",
@@ -1121,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": "メッシュ断面に曲線をフィット",
@@ -1217,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": "名前付きビューを復元",
@@ -1374,6 +1389,7 @@
"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つの立体が重なる範囲の確認に使います。",
@@ -1743,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": "六角ナット",
@@ -1874,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": "ライブプレビュー",
@@ -1906,9 +1925,25 @@
"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": "既定",
@@ -1918,6 +1953,8 @@
"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 です。",
@@ -1931,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": "スケッチ終了時のビュー",
@@ -2005,6 +2046,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}日前",
@@ -3355,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 を起動したときに適用されます",
@@ -3379,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}として描く",
@@ -3448,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": "タイムライン項目を移動",
@@ -3531,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": "表示をリセット",
@@ -4008,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 をエクスポート…",
@@ -4073,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": "編集を終了",
@@ -4164,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": "分離表示",
@@ -4387,6 +4454,7 @@
"ui.select": "選択",
"ui.selectOther": "その他を選択…",
"ui.selectTangentChain": "接線チェーンを選択",
"ui.select_all": "すべて選択",
"ui.selection": "選択",
"ui.selection_modifiers": "選択修飾キー",
"ui.selection_shift_add_only": "Shift+クリックで追加のみ(解除なし)",
+38
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": "선택한 모서리를 지정한 반지름의 매끄러운 원호로 둥글게 만듭니다. 강도, 편의성 또는 외관을 위해 날카로운 모서리를 부드럽게 할 때 사용합니다.",
@@ -1560,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": "열 수 있는 최대 파일 크기",
@@ -1584,6 +1592,7 @@
"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": "켜기: 투영 전에 지오메트리 또는 바디와 투영 링크를 선택합니다. 끄기: 기존 직접 선택 작업 흐름을 유지합니다.",
@@ -1605,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": "스케치 및 선택",
@@ -1662,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}일 전",
@@ -2556,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를 시작할 때 적용됩니다",
@@ -2575,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}(으)로 그리기",
@@ -2709,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": "뷰 초기화",
@@ -3130,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 내보내기…",
@@ -3188,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": "편집 완료",
@@ -3275,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": "분리 표시",
@@ -3475,6 +3512,7 @@
"ui.select": "선택",
"ui.selectOther": "기타 선택…",
"ui.selectTangentChain": "접선 체인 선택",
"ui.select_all": "모두 선택",
"ui.selection": "선택",
"ui.selection_modifiers": "선택 보조 키",
"ui.selection_shift_add_only": "Shift+클릭은 추가만 함",
+3
View File
@@ -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",
@@ -4066,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",
+5
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}",
@@ -2114,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",
+1
View File
@@ -2244,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",
+1
View File
@@ -3803,6 +3803,7 @@
"ui.select": "Выбрать",
"ui.selectOther": "Выбрать другое…",
"ui.selectTangentChain": "Выбрать касательную цепочку",
"ui.select_all": "Выделить всё",
"ui.selection": "Выделение",
"ui.selection_modifiers": "Модификаторы выбора",
"ui.selection_shift_add_only": "Shift+щелчок добавляет без удаления",
+1
View File
@@ -4081,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",
+1
View File
@@ -4062,6 +4062,7 @@
"ui.select": "Вибрати",
"ui.selectOther": "Вибрати інше…",
"ui.selectTangentChain": "Вибрати дотичний ланцюг",
"ui.select_all": "Виділити все",
"ui.selection": "Виділення",
"ui.selection_modifiers": "Модифікатори вибору",
"ui.selection_shift_add_only": "Shift+клацання додає без вилучення",
+68
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?",
@@ -822,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",
@@ -848,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",
@@ -1076,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",
@@ -1121,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",
@@ -1217,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",
@@ -1374,6 +1389,7 @@
"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.",
@@ -1743,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",
@@ -1874,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",
@@ -1906,9 +1925,25 @@
"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",
@@ -1918,6 +1953,8 @@
"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%.",
@@ -1931,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",
@@ -2005,6 +2046,8 @@
"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",
@@ -3355,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",
@@ -3379,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}",
@@ -3448,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ế",
@@ -3531,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",
@@ -4008,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…",
@@ -4073,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",
@@ -4164,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",
@@ -4387,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",
+1
View File
@@ -4062,6 +4062,7 @@
"ui.select": "選擇",
"ui.selectOther": "選擇其他…",
"ui.selectTangentChain": "選擇相切鏈",
"ui.select_all": "全選",
"ui.selection": "選擇",
"ui.selection_modifiers": "選擇修飾鍵",
"ui.selection_shift_add_only": "Shift+點擊僅新增、不取消選擇",
+68
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?": "恢复未保存的设计?",
@@ -822,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": "校准画布",
@@ -848,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": "重新链接源文件",
@@ -1076,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",
@@ -1121,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": "将曲线拟合到网格截面",
@@ -1217,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": "还原命名视图",
@@ -1374,6 +1389,7 @@
"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": "将多个实体合并、从目标实体中切除,或只保留它们共有的体积,还可以保留工具实体。用于合并零件、用一个形体切割另一个形体,或找出两个实体重叠的位置。",
@@ -1743,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": "六角螺母",
@@ -1874,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": "实时预览",
@@ -1906,9 +1925,25 @@
"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": "默认",
@@ -1918,6 +1953,8 @@
"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 像素。",
@@ -1931,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": "草图结束时的视图",
@@ -2005,6 +2046,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} 天前",
@@ -3355,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 时生效",
@@ -3379,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}",
@@ -3448,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": "移动时间线项目",
@@ -3531,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": "重置视图",
@@ -4008,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…",
@@ -4073,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": "完成编辑",
@@ -4164,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": "隔离",
@@ -4387,6 +4454,7 @@
"ui.select": "选择",
"ui.selectOther": "选择其他…",
"ui.selectTangentChain": "选择相切链",
"ui.select_all": "全选",
"ui.selection": "选择",
"ui.selection_modifiers": "选择修饰键",
"ui.selection_shift_add_only": "Shift+点击仅添加、不取消选择",
+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) {
+4 -3
View File
@@ -3705,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()) {
@@ -3889,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>) {
@@ -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.
+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 });
+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 {
+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());
}
}
+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
+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) {
+41 -2
View File
@@ -12,26 +12,35 @@ use solidcraft_geom::Vec3;
use super::CommandSpec;
use crate::params::{bad, bool_, str_, string_list, vec3};
use crate::schema::component::{
EditInPlaceParams, EditInPlaceResult, FinishEditParams, FinishEditResult, OccurrenceAlignParams, OccurrenceAlignResult,
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;
@@ -103,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"),
+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)]
+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
}
}
+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;
}
+16 -2
View File
@@ -67,7 +67,7 @@ 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"),
@@ -1773,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> {
+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
}
@@ -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]
+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());
+56
View File
@@ -1638,3 +1638,59 @@ fn occurrence_align_keeps_every_pick_and_reports_shared_definitions() {
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}");
}
@@ -625,7 +625,7 @@ fn embedded_hdr_capture_rejects_parameters_scope_and_cpu_primaries_without_mutat
assert_eq!(state, (s.revision, s.undo.len(), s.redo.len()));
}
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let dir = std::env::temp_dir().join(format!("solidcraft-hdr-scope-{}-{nonce}", std::process::id()));
let dir = solidcraft_io::vfs::temp_dir().join(format!("solidcraft-hdr-scope-{}-{nonce}", std::process::id()));
std::fs::create_dir(&dir).unwrap();
let allowed = dir.join("allowed");
std::fs::create_dir(&allowed).unwrap();
+2
View File
@@ -19,6 +19,7 @@ fn section(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "inspect.section";
if bool_(p, "clear").unwrap_or(false) {
s.section = None;
s.status_slot(crate::status_items::StatusId::Section, false);
s.revision += 1;
return Ok(json!({"section": null}));
}
@@ -75,6 +76,7 @@ fn section(s: &mut Session, p: &Value) -> Result<Value> {
let origin = origin + normal * offset * if bool_(p, "flip").unwrap_or(false) { -1.0 } else { 1.0 };
s.section = Some((origin, normal));
s.section_hatch = bool_(p, "hatch").unwrap_or(true);
s.status_slot(crate::status_items::StatusId::Section, true);
s.revision += 1;
let mut section = json!({"origin": origin, "normal": normal, "hatch": s.section_hatch});
if let Some(i) = axis {
+166
View File
@@ -0,0 +1,166 @@
//! Settings profiles (#6237): named sets of the input and interface preferences, kept by the
//! app. The engine holds no preferences, so these commands only check and echo their
//! parameters; the desktop and web apps act on the echo (`settings_profiles` in the UI crate).
//! Run without an app (CLI, scripts), they do nothing else.
use serde_json::{Map, Value, json};
use super::CommandSpec;
use crate::params::{bad, str_};
use crate::{Result, Session};
/// Most characters in a profile's name.
pub const MAX_PROFILE_NAME: usize = 64;
/// Most bytes in a path given to Export or Import.
const MAX_PATH: usize = 4096;
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("settings.profile.list", "Settings Profiles", list)
.icon("list")
.noundo()
.doc_only()
.params("→ profiles: [name], active: name|null; handled by the app"),
CommandSpec::new("settings.profile.save", "Save Settings Profile", save)
.icon("save")
.noundo()
.doc_only()
.params("name: 1..64 characters → name; keeps the current input and interface settings under `name` (replacing a profile of that name) and makes it the active one"),
CommandSpec::new("settings.profile.apply", "Apply Settings Profile", apply)
.icon("settings")
.noundo()
.doc_only()
.params("name → name, applied, skipped: [preference]; switches every setting the profile holds at once"),
CommandSpec::new("settings.profile.rename", "Rename Settings Profile", rename)
.icon("rename")
.noundo()
.doc_only()
.params("name, to: 1..64 characters → name"),
CommandSpec::new("settings.profile.delete", "Delete Settings Profile", delete)
.icon("delete")
.noundo()
.doc_only()
.params("name → deleted"),
CommandSpec::new("settings.profile.export", "Export Settings Profile", export)
.icon("export")
.noundo()
.doc_only()
.params("name, path?: file → profile (a `solidcraft-profile/1` document), written to `path` when given; never opens a dialog"),
CommandSpec::new("settings.profile.import", "Import Settings Profile", import)
.icon("import")
.noundo()
.doc_only()
.params("path: file | profile: `solidcraft-profile/1` document, name?: 1..64 characters → name; adds the profile without switching to it"),
];
/// A profile name as given: trimmed, 1 to [`MAX_PROFILE_NAME`] characters, no control characters.
pub fn profile_name(name: &str) -> Option<&str> {
let name = name.trim();
(!name.is_empty() && name.chars().count() <= MAX_PROFILE_NAME && !name.chars().any(char::is_control)).then_some(name)
}
fn name_param<'a>(cmd: &str, p: &'a Value, key: &str) -> Result<&'a str> {
str_(p, key)
.and_then(profile_name)
.ok_or_else(|| bad(cmd, format!("`{key}` must be 1 to {MAX_PROFILE_NAME} characters, without control characters")))
}
fn path_param<'a>(cmd: &str, p: &'a Value) -> Result<Option<&'a str>> {
match p.get("path") {
None | Some(Value::Null) => Ok(None),
Some(Value::String(path)) if !path.trim().is_empty() && path.len() <= MAX_PATH => Ok(Some(path)),
Some(_) => Err(bad(cmd, format!("`path` must be a file path of at most {MAX_PATH} bytes"))),
}
}
fn list(_s: &mut Session, _p: &Value) -> Result<Value> {
Ok(json!({}))
}
fn save(_s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "name": name_param("settings.profile.save", p, "name")? }))
}
fn apply(_s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "name": name_param("settings.profile.apply", p, "name")? }))
}
fn rename(_s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "settings.profile.rename";
Ok(json!({ "name": name_param(cmd, p, "name")?, "to": name_param(cmd, p, "to")? }))
}
fn delete(_s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "name": name_param("settings.profile.delete", p, "name")? }))
}
fn export(_s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "settings.profile.export";
let mut out = Map::new();
out.insert("name".into(), json!(name_param(cmd, p, "name")?));
if let Some(path) = path_param(cmd, p)? {
out.insert("path".into(), json!(path));
}
Ok(Value::Object(out))
}
fn import(_s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "settings.profile.import";
let mut out = Map::new();
let (key, value) = match (path_param(cmd, p)?, p.get("profile")) {
(Some(path), None) => ("path", json!(path)),
(None, Some(profile)) if profile.is_object() => ("profile", profile.clone()),
_ => return Err(bad(cmd, "give either `path` or `profile` (a `solidcraft-profile/1` document)")),
};
out.insert(key.into(), value);
if p.get("name").is_some_and(|name| !name.is_null()) {
out.insert("name".into(), json!(name_param(cmd, p, "name")?));
}
Ok(Value::Object(out))
}
#[cfg(test)]
mod tests {
use super::*;
fn run(id: &str, p: Value) -> std::result::Result<Value, String> {
let mut s = Session::default();
s.execute(id, &p).map_err(|e| e.to_string())
}
#[test]
fn names_are_trimmed_and_bounded() {
assert_eq!(run("settings.profile.save", json!({"name":" Laptop "})).unwrap(), json!({"name":"Laptop"}));
for name in [json!(""), json!(" "), json!("a\nb"), json!("x".repeat(65)), json!(3), Value::Null] {
assert!(run("settings.profile.save", json!({ "name": name })).is_err(), "{name}");
}
assert!(run("settings.profile.save", json!({"name":"é".repeat(64)})).is_ok());
assert!(run("settings.profile.rename", json!({"name":"A"})).is_err());
}
#[test]
fn import_takes_a_path_or_a_document_but_not_both() {
assert_eq!(run("settings.profile.import", json!({"path":"/tmp/p.json"})).unwrap(), json!({"path":"/tmp/p.json"}));
let doc = json!({"format":"solidcraft-profile/1"});
assert_eq!(run("settings.profile.import", json!({"profile":doc, "name":"B"})).unwrap(), json!({"profile":doc, "name":"B"}));
assert!(run("settings.profile.import", json!({"path":"/tmp/p.json", "profile":doc})).is_err());
assert!(run("settings.profile.import", json!({"profile":"text"})).is_err());
assert!(run("settings.profile.import", json!({})).is_err());
assert!(run("settings.profile.export", json!({"name":"A", "path":"x".repeat(5000)})).is_err());
}
#[test]
fn profile_commands_leave_the_design_and_its_history_alone() {
let mut s = Session::default();
let doc = s.doc.to_json();
for (id, p) in [
("settings.profile.save", json!({"name":"A"})),
("settings.profile.apply", json!({"name":"A"})),
("settings.profile.list", json!({})),
("settings.profile.delete", json!({"name":"A"})),
] {
s.execute(id, &p).unwrap();
}
assert_eq!(s.doc.to_json(), doc);
assert!(s.undo.is_empty());
}
}
+64 -3
View File
@@ -128,7 +128,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.params(concat!("p0, p1: overall ends, width", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.point", "Point", draw_point).at("SKETCH", "CREATE").icon("point").enabled(in_sketch).params(concat!("point: [x,y] (each a number or an expression over the design's parameters: the point is then fixed where it comes to and follows them) or a snap: \"mid:<line>\" (held at the line's midpoint), \"on:<curve>:x,y\" (on the curve near x,y), {vertex: [x,y,z]} or {on_edge: [x,y,z]} of the model; id?", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.dimension", "Sketch Dimension", dimension).at("SKETCH", "CREATE").icon("dimension").key("D").enabled(in_sketch).params(
"entities: [refs] (a line midpoint is \"mid:line_id\"), type?: auto|distance|horizontal|distance_x|vertical|distance_y|length|radius|diameter|angle, value?: number or expression (default: current; a bare number is in the design's units, e.g. 2 means 2 in in an inch design; give a unit such as \"50 mm\" to override), or `name = expression` (e.g. \"width = 40\"; also `#name = expression`), which adds the user parameter `name` with that expression and dimensions with `name` (refused when the name is taken or reserved; the reply's `defined` names it); text_at?: [x, y]. Two points, two midpoints or a line off the axes: with auto, text_at inside the box they span (as opposite corners) or in its outer corners is the distance between them, above or below the box horizontal, beside it vertical. Two lines: the angle between them (acute, or 180° − it when the value is nearer that) unless text_at picks a sector. A line and a circle or arc: the distance from the line to its edge (the gap, or how far the edge reaches past a line that cuts it; 0 is tangent); dimension the centre point for the distance to the centre. A point and a circle or arc: the distance from the point to its edge (or how far within it a point inside lies). Two circles or arcs: the gap between their edges (or how far they overlap when they cross, or the narrowest gap between them when one lies inside the other), or the centre-to-centre distance when they are the two caps of a slot. An arc and one of its end points: the arc's length (type arc_length does it from the arc alone). A dimension the sketch already determines is refused (naming what determines it); if_redundant?: \"driven\" places it as a driven (reference) dimension instead. scale_sketch?: bool (the dimension tool sends true): when this is the sketch's first length dimension and nothing in the sketch fixes a size (no other driving length dimension, nothing fixed or projected), the whole sketch scales about its origin to the value instead of distorting. Returns measured in unit (mm or deg), and scaled: the factor when the sketch was scaled",
"entities: [refs] (a line midpoint is \"mid:line_id\"; a model edge or vertex, {edge: [x,y,z], body?} or {vertex: [x,y,z], body?}, is projected into the sketch first and measured there: a point against the projected edge measures the distance to it), type?: auto|distance|horizontal|distance_x|vertical|distance_y|length|radius|diameter|angle, value?: number or expression (default: current; a bare number is in the design's units, e.g. 2 means 2 in in an inch design; give a unit such as \"50 mm\" to override), or `name = expression` (e.g. \"width = 40\"; also `#name = expression`), which adds the user parameter `name` with that expression and dimensions with `name` (refused when the name is taken or reserved; the reply's `defined` names it); text_at?: [x, y]. Two points, two midpoints or a line off the axes: with auto, text_at inside the box they span (as opposite corners) or in its outer corners is the distance between them, above or below the box horizontal, beside it vertical. Two lines: the angle between them (acute, or 180° − it when the value is nearer that) unless text_at picks a sector. A line and a circle or arc: the distance from the line to its edge (the gap, or how far the edge reaches past a line that cuts it; 0 is tangent); dimension the centre point for the distance to the centre. A point and a circle or arc: the distance from the point to its edge (or how far within it a point inside lies). Two circles or arcs: the gap between their edges (or how far they overlap when they cross, or the narrowest gap between them when one lies inside the other), or the centre-to-centre distance when they are the two caps of a slot. An arc and one of its end points: the arc's length (type arc_length does it from the arc alone). A dimension the sketch already determines is refused (naming what determines it); if_redundant?: \"driven\" places it as a driven (reference) dimension instead. scale_sketch?: bool (the dimension tool sends true): when this is the sketch's first length dimension and nothing in the sketch fixes a size (no other driving length dimension, nothing fixed or projected), the whole sketch scales about its origin to the value instead of distorting. Returns measured in unit (mm or deg), and scaled: the factor when the sketch was scaled",
),
CommandSpec::new("sketch.constraint.horizontal_vertical", "Horizontal/Vertical", c_horizontal_vertical)
.at("SKETCH", "CONSTRAINTS")
@@ -333,12 +333,63 @@ fn settle(strict: bool, rep: SolveReport, sk: &mut Sketch, unsolved: Sketch) ->
if strict && !rep.keeps_orientation() {
return Err(EngineError::Other(rep.failure(|f| format!("that would over-constrain the sketch (conflicts with {f})"))));
}
if !rep.keeps_orientation() {
// A solve that throws points far beyond the sketch found a far solution of its constraints,
// not the one the edit means (a line squeezed to a point under a direction constraint, an
// angle between sides that can't take it): it counts as one that fails.
let far = flung(&unsolved, sk);
if strict && far {
return Err(EngineError::Other(
"that would throw points of the sketch far away: its constraints have no solution near the sketch as it is".into(),
));
}
// A solve that shrinks a line or circle to a point met its constraints only by squeezing
// that curve away (a corner made the midpoint of the opposite side of its rectangle).
let gone = shrunk(&unsolved, sk);
if strict && let Some(curve) = &gone {
return Err(EngineError::Other(format!("that would shrink {curve} to a point: its constraints can only be met without it")));
}
if !rep.keeps_orientation() || far || gone.is_some() {
*sk = unsolved;
}
Ok(rep)
}
/// The first line or circle that solving `before` into `after` shrank to a point: longer (or
/// wider) than 1e-3 before and shorter than 1e-5 after. A length a dimension asks for is never
/// that short. 1e-6 let a side squeezed to 1.2e-6 by one solve through, and the next solve
/// took it the rest of the way.
fn shrunk(before: &Sketch, after: &Sketch) -> Option<String> {
let size = |sk: &Sketch, i: usize| match sk.curves.get(i).map(|c| &c.kind) {
Some(CurveKind::Line { .. }) => line_pts(sk, i).map(|(a, b)| a.dist(b)),
Some(CurveKind::Arc { .. } | CurveKind::Circle { .. }) => sk.radius(i),
_ => None,
};
(0..before.curves.len().min(after.curves.len()))
.find(|&i| matches!((size(before, i), size(after, i)), (Some(b), Some(a)) if b > 1e-3 && a < 1e-5))
.and_then(|i| after.curves.get(i).map(|c| c.id.clone()))
}
/// A solve moving some point further than this many times the sketch's size (and its largest
/// dimension value) has thrown the geometry to a far solution.
const FLUNG: f64 = 1000.0;
/// Did solving `before` into `after` throw some point further than [`FLUNG`] times the sketch's
/// size, or to no number at all?
fn flung(before: &Sketch, after: &Sketch) -> bool {
let (lo, hi) = before
.points
.iter()
.map(|p| p.pos)
.filter(|p| p.x.is_finite() && p.y.is_finite())
.fold((Vec2::new(f64::INFINITY, f64::INFINITY), Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY)), |(lo, hi), p| {
(Vec2::new(lo.x.min(p.x), lo.y.min(p.y)), Vec2::new(hi.x.max(p.x), hi.y.max(p.y)))
});
let size = if lo.x <= hi.x { lo.dist(hi) } else { 0.0 };
let longest = after.driving_values().values().map(|v| v.abs()).filter(|v| v.is_finite()).fold(0.0, f64::max);
let reach = FLUNG * (size + longest).max(1.0);
after.points.iter().zip(&before.points).any(|(a, b)| !(a.pos.dist(b.pos) <= reach))
}
/// The sketch solves of `edit` and drags, counted in tests (see [`solve_with`]).
pub(super) fn counted_solve(
sk: &mut Sketch,
@@ -1736,7 +1787,7 @@ pub(super) fn apply_in_order(sk: &mut Sketch, rep: &SolveReport, first: &[Ent],
}));
let ok = solve_with(&mut trial, &h, None, Analysis::StatusOnly, None).ok();
trial.constraints.retain(|c| c.id != TEMP);
if ok && !collapsed(sk, &trial) {
if ok && !collapsed(sk, &trial) && !flung(sk, &trial) {
*sk = trial;
return Ok(());
}
@@ -1748,6 +1799,12 @@ pub(super) fn apply_in_order(sk: &mut Sketch, rep: &SolveReport, first: &[Ent],
if first_movable && !second_locked {
let mut trial = sk.clone();
if solve_with(&mut trial, &Hold::default(), None, Analysis::StatusOnly, None).ok() {
if flung(sk, &trial) {
return Err(bad(cmd, "that would throw points of the sketch far away: its constraints have no solution near the sketch as it is"));
}
if let Some(curve) = shrunk(sk, &trial) {
return Err(bad(cmd, format!("that would shrink {curve} to a point: its constraints can only be met without it")));
}
*sk = trial;
return Ok(());
}
@@ -2435,6 +2492,10 @@ pub(super) fn line_pts(sk: &Sketch, l: usize) -> Option<(Vec2, Vec2)> {
fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.dimension";
// A dimension may measure model geometry (#7912): an `{edge: [x,y,z], body?}` or a
// `{vertex: [x,y,z], body?}` among its entities is projected into the sketch first, as
// `sketch.project` does, and stands for the projected curve (or point) there.
let p = &super::sketch_project::project_entities(s, p, cmd)?;
let ents = string_list(cmd, p, "entities")?;
if ents.is_empty() || ents.len() > 2 {
return Err(bad(cmd, "`entities` must list one or two sketch entities"));
@@ -1232,6 +1232,94 @@ fn coincident_refuses_a_lines_two_ends() {
run(&mut s, "sketch.constraint.coincident", json!({"a":"l1.end","b":"l2.end"}));
}
/// A constraint whose solve finds only a far solution is not allowed to throw points far away:
/// the held solve is tried without it, or the constraint is refused. Here a centre that is
/// already the midpoint of two lines is made the midpoint of a third, and the solve threw two
/// line ends to x = -2.2e8 mm while the sketch said solved (found by a local sketch fuzzer).
#[test]
fn a_constraint_never_throws_the_sketch_far_away() {
let mut s = Session::default();
for (id, p) in [
("sketch.create", json!({"plane": "XY"})),
("sketch.arc.three_point", json!({"end": [-15, 6], "start": [-24, -21], "through": [-12, -1]})),
("sketch.arc.three_point", json!({"end": [-18, -27], "start": [3, 13], "through": [22, 21]})),
("sketch.dimension", json!({"entities": ["a1.start", "a2.center"], "value": 29})),
("sketch.dimension", json!({"entities": ["a2.end", "a1.start"], "value": 38})),
("sketch.circle.center", json!({"center": [-23, -10], "radius": 7})),
("sketch.constraint.coincident", json!({"a": "a2.start", "b": "a1.end"})),
("sketch.rectangle.two_point", json!({"p0": [22, -9], "p1": [19, -10]})),
("sketch.line", json!({"ids": ["l16"], "points": [[-14, -29], [-5, 5]]})),
("sketch.circle.center", json!({"center": [-14, 19], "radius": 14})),
("sketch.constraint.fix", json!({"entity": "c1.center"})),
("sketch.rectangle.two_point", json!({"p0": [-10, 23], "p1": [16, 13]})),
("sketch.line", json!({"ids": ["l22"], "points": [[-8, 21], [25, 14]]})),
("sketch.circle.center", json!({"center": [6, 10], "radius": 10})),
("sketch.circle.center", json!({"center": [8, -12], "radius": 11})),
("sketch.rectangle.two_point", json!({"p0": [-4, -7], "p1": [-5, -27]})),
("sketch.constraint.concentric", json!({"a": "a1", "b": "c4"})),
("sketch.rectangle.two_point", json!({"p0": [-12, -14], "p1": [-1, -17]})),
("sketch.constraint.midpoint", json!({"line": "l12", "point": "c4.center"})),
("sketch.constraint.midpoint", json!({"line": "l14", "point": "c4.center"})),
("sketch.rectangle.two_point", json!({"p0": [23, 22], "p1": [-28, 8]})),
("sketch.constraint.fix", json!({"entity": "p15"})),
("sketch.dimension", json!({"entities": ["p17", "a1.start"], "value": 35})),
("sketch.constraint.coincident", json!({"a": "p19", "b": "p12"})),
("sketch.constraint.tangent", json!({"a": "c1", "b": "l11"})),
("sketch.line", json!({"closed": true, "points": [[8, -29], [-5, 24], [-9, -6], [26, -8]]})),
("sketch.constraint.perpendicular", json!({"a": "l15", "b": "l17"})),
("sketch.dimension", json!({"entities": ["l12"], "value": 13})),
("sketch.dimension", json!({"entities": ["l26", "l17"], "type": "angle", "value": 14})),
("sketch.constraint.fix", json!({"entity": "p18"})),
("sketch.constraint.tangent", json!({"a": "l23", "b": "c4"})),
("sketch.constraint.coincident", json!({"a": "origin", "b": "l23.start"})),
("sketch.constraint.midpoint", json!({"line": "l18", "point": "c4.center"})),
] {
let _ = s.execute(id, &p);
}
let sk = sketch(&s);
let far = sk.points.iter().map(|p| p.pos.len()).fold(0.0_f64, f64::max);
assert!(far < 1e4, "a point went {far} mm away");
}
/// A constraint that can only be met by shrinking curves to a point is refused, as one that
/// throws the sketch far away is: making a rectangle's corner the midpoint of the opposite side,
/// or two opposite corners coincident, squeezed the whole rectangle to a point while the sketch
/// said solved (found by a local sketch fuzzer).
#[test]
fn a_constraint_that_would_shrink_a_rectangle_to_a_point_is_refused() {
for (id, p) in
[("sketch.constraint.midpoint", json!({"line": "l4", "point": "p2"})), ("sketch.constraint.coincident", json!({"a": "p1", "b": "p3"}))]
{
let mut s = new_sketch();
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-30, 4], "p1": [11, 27]}));
let before = sketch(&s);
let e = s.execute(id, &p).unwrap_err().to_string();
assert!(e.contains("shrink") && e.contains("to a point"), "{id} {p}: {e}");
assert_eq!(sketch(&s), before, "{id} {p}");
}
}
/// Two rectangles, a corner of one made coincident with a corner of the other and then a second
/// pair of corners made coincident: one solve squeezed a 49 mm side to about 1.2e-6 mm, just
/// longer than the check took for a point, and the next took it the rest of the way, so the
/// constraint was taken (found by a local sketch fuzzer).
#[test]
fn a_constraint_that_squeezes_a_side_to_a_hair_is_refused() {
let mut s = new_sketch();
for (id, p) in [
("sketch.rectangle.two_point", json!({"p0": [27, 29], "p1": [1, 19]})),
("sketch.rectangle.two_point", json!({"p0": [23, -8], "p1": [-21, -20]})),
("sketch.constraint.perpendicular", json!({"a": "l5", "b": "l4"})),
("sketch.constraint.coincident", json!({"a": "p5", "b": "p1"})),
] {
run(&mut s, id, p);
}
let before = sketch(&s);
let e = s.execute("sketch.constraint.coincident", &json!({"a": "p6", "b": "p3"})).unwrap_err().to_string();
assert!(e.contains("shrink") && e.contains("to a point"), "{e}");
assert_eq!(sketch(&s), before);
}
#[test]
fn coincident_circles_lie_on_each_other() {
let mut s = new_sketch();
+159
View File
@@ -0,0 +1,159 @@
//! Equation curves (#8352): a sketch curve drawn from formulas, x(t) and y(t) over a range of t
//! or y = f(x) over a range of x, in the design's expression language and parameters. The curve
//! is a fit point spline through points worked out from the formulas, as many as keep it within
//! a tolerance of them; it keeps its formulas, so it follows the parameters and can be edited.
use serde_json::{Value, json};
use solidcraft_doc::sketch_equation::{self, DEFAULT_TOLERANCE, EquationFit};
use solidcraft_sketch::{EquationCurve, Sketch};
use super::sketch::{edit, mark_construction};
use super::{CommandSpec, in_sketch};
use crate::params::bad;
use crate::schema::sketch_equation::{EquationCurveParams, EquationCurveResult, EquationEditParams, EquationEditResult};
use crate::schema::{TypedContract, output_schema_for};
use crate::{Result, Session};
#[cfg(test)]
#[path = "sketch_equation_tests.rs"]
mod tests;
const CREATE_CONTRACT: TypedContract<EquationCurveParams> = TypedContract::new();
const EDIT_CONTRACT: TypedContract<EquationEditParams> = TypedContract::new();
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.equation_curve", "Equation Curve", crate::checked!("sketch.equation_curve", create))
.at("SKETCH", "CREATE")
.icon("equation_curve")
.enabled(in_sketch)
.contract(&CREATE_CONTRACT)
.output(output_schema_for::<EquationCurveResult>)
.params(concat!(
"x: x(t), y: y(t): formulas of t in the design's expression language and parameters (\"r * (cos(t) + t * sin(t))\"); or explicit: true and y: f(x) alone (no x); \
start, end: the range of t (or of x): a number or an expression (\"2 * pi\", \"360 deg\" is 2·pi, a length is in the design's unit); \
tolerance?: mm (default 0.01): how far the curve may stray from the formulas (0.0001 to 10); construction?: bool. \
The variable is a plain number: sin(t) reads it in radians; a result without a unit is in the design's length unit. \
Makes a fit point spline through fixed points, as many as the tolerance needs (up to 500), closed smoothly when it ends where it starts; \
the points are placed again from the formulas whenever the parameters change. Returns curve, points (how many), closed, deviation (mm)",
created_doc!()
)),
CommandSpec::new("sketch.equation_curve.edit", "Edit Equation Curve", crate::checked!("sketch.equation_curve.edit", edit_curve))
.icon("equation_curve")
.enabled(in_sketch)
.contract(&EDIT_CONTRACT)
.output(output_schema_for::<EquationEditResult>)
.params(
"curve: an equation curve's id; x?, y?, explicit?, start?, end?, tolerance?: as in sketch.equation_curve, each kept when left out (explicit: true drops x; false needs x). \
The points are chosen again for the new formulas; the curve's start point (and the end point of an open curve) keeps its id, so geometry joined there stays joined. Returns curve, points, closed, deviation",
),
];
/// A formula or range parameter: a string (an expression) or a number.
fn text(p: &Value, cmd: &str, key: &str) -> Result<Option<String>> {
match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(Value::String(s)) if !s.trim().is_empty() => Ok(Some(s.trim().to_string())),
Some(Value::Number(n)) => n
.as_f64()
.and_then(crate::params::finite)
.map(|v| Some(format!("{v}")))
.ok_or_else(|| bad(cmd, format!("`{key}` must be a finite number"))),
Some(_) => Err(bad(cmd, format!("`{key}` must be a formula (a string) or a number"))),
}
}
fn tolerance(p: &Value, cmd: &str) -> Result<Option<f64>> {
match p.get("tolerance") {
None | Some(Value::Null) => Ok(None),
Some(v) => v
.as_f64()
.filter(|t| t.is_finite() && (sketch_equation::MIN_TOLERANCE..=sketch_equation::MAX_TOLERANCE).contains(t))
.map(Some)
.ok_or_else(|| bad(cmd, "`tolerance` must be a number of mm from 0.0001 to 10")),
}
}
fn explicit(p: &Value, cmd: &str) -> Result<Option<bool>> {
match p.get("explicit") {
None | Some(Value::Null) => Ok(None),
Some(Value::Bool(b)) => Ok(Some(*b)),
Some(_) => Err(bad(cmd, "`explicit` must be true or false")),
}
}
/// The equation a command's parameters give, over `old` (each left out kept).
fn equation(p: &Value, cmd: &str, old: Option<&EquationCurve>) -> Result<EquationCurve> {
let x = text(p, cmd, "x")?;
let explicit = explicit(p, cmd)?;
let x = match (explicit, x) {
(Some(true), Some(_)) => return Err(bad(cmd, "an explicit curve takes y = f(x) alone: leave out `x`")),
(Some(true), None) => None,
(_, Some(x)) => Some(x),
(Some(false), None) => match old.and_then(|o| o.x.clone()) {
Some(x) => Some(x),
None => return Err(bad(cmd, "a parametric curve needs `x`, a formula of t")),
},
(None, None) => match old {
Some(o) => o.x.clone(),
None => return Err(bad(cmd, "needs `x` and `y` (formulas of t), or explicit: true and `y` (a formula of x)")),
},
};
let pick = |key: &str, kept: Option<&String>| -> Result<String> {
match text(p, cmd, key)? {
Some(v) => Ok(v),
None => kept.cloned().ok_or_else(|| bad(cmd, format!("missing `{key}`"))),
}
};
let eq = EquationCurve {
curve: old.map(|o| o.curve.clone()).unwrap_or_default(),
x,
y: pick("y", old.map(|o| &o.y))?,
start: pick("start", old.map(|o| &o.start))?,
end: pick("end", old.map(|o| &o.end))?,
tolerance: tolerance(p, cmd)?.or(old.map(|o| o.tolerance)).unwrap_or(DEFAULT_TOLERANCE),
};
sketch_equation::check(&eq).map_err(|e| bad(cmd, e.to_string()))?;
Ok(eq)
}
/// The points of an equation, over the design's parameters as features are evaluated with them.
fn fit(doc: &solidcraft_doc::Document, eq: &EquationCurve, cmd: &str) -> Result<EquationFit> {
sketch_equation::fit(&doc.evaluation_values(), eq).map_err(|e| bad(cmd, e.to_string()))
}
fn reply(curve: String, fit: &EquationFit, info: Value) -> Value {
// A closed curve's last point is its first one.
let points = fit.points.len().saturating_sub(usize::from(fit.closed));
json!({"curves": [curve.clone()], "curve": curve, "points": points, "closed": fit.closed, "deviation": fit.deviation, "sketch": info})
}
fn create(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.equation_curve";
let eq = equation(p, cmd, None)?;
let ((curve, fit), info) = edit(s, p, cmd, false, |sk: &mut Sketch, doc| {
let fit = fit(doc, &eq, cmd)?;
let c = sk.add_equation_curve(&fit.points, fit.closed, eq.clone()).map_err(|e| bad(cmd, e.to_string()))?;
mark_construction(sk, &[c], p);
let id = sk.curves.get(c).map(|c| c.id.clone()).ok_or_else(|| bad(cmd, "the new curve is missing"))?;
Ok((id, fit))
})?;
Ok(reply(curve, &fit, info))
}
fn edit_curve(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.equation_curve.edit";
let id = p.get("curve").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`curve` must be an equation curve's id"))?.to_string();
let (fit, info) = edit(s, p, cmd, false, |sk: &mut Sketch, doc| {
let c = sk.curve_index(&id).ok_or_else(|| bad(cmd, format!("unknown curve `{id}`")))?;
let old = sk.equation_of(&id).cloned().ok_or_else(|| bad(cmd, format!("`{id}` is not an equation curve")))?;
let eq = equation(p, cmd, Some(&old))?;
let fit = fit(doc, &eq, cmd)?;
sk.replace_equation_curve(c, &fit.points, fit.closed, eq).map_err(|e| bad(cmd, e.to_string()))?;
Ok(fit)
})?;
let mut out = reply(id, &fit, info);
if let Some(o) = out.as_object_mut() {
o.remove("curves");
}
Ok(out)
}
@@ -0,0 +1,256 @@
//! Equation curves (#8352): a fit point spline from formulas over the design's parameters.
use serde_json::{Value, json};
use solidcraft_geom::Vec2;
use solidcraft_sketch::CurveKind;
use crate::Session;
fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) {
Ok(v) => v,
Err(e) => panic!("{id} {p}: {e}"),
}
}
fn volume(s: &mut Session) -> f64 {
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
}
fn no_trouble(s: &Session) {
for r in &s.model.results {
assert!(r.error.is_none() && r.warning.is_none(), "{}: {:?} {:?}", r.name, r.error, r.warning);
}
}
fn near(got: f64, want: f64, rel: f64) {
assert!((got - want).abs() <= want.abs() * rel, "got {got}, want {want}");
}
/// The evaluated sketch `S`.
fn solved(s: &Session) -> solidcraft_sketch::Sketch {
let f = s.doc.find_feature("S").unwrap().id;
s.model.state().sketch(f).unwrap().sketch.clone()
}
/// The positions of curve `id`'s points in the evaluated sketch `S`.
fn curve_points(s: &Session, id: &str) -> Vec<Vec2> {
let sk = solved(s);
let c = sk.curves.iter().find(|c| c.id == id).unwrap();
c.kind.point_ids().iter().map(|i| sk.points[*i].pos).collect()
}
/// A wave over a strip: y = h + 5 sin(2 pi x / 100) over x from 0 to 100, closed by three
/// lines down to the x axis. Its area is 100 h (the sine adds as much as it takes away).
fn wave(s: &mut Session) -> String {
run(s, "parameters.add", json!({"name": "h", "expression": "10 mm"}));
run(s, "sketch.create", json!({"plane": "XY", "name": "S"}));
let r = run(s, "sketch.equation_curve", json!({"explicit": true, "y": "h + 5 * sin(2 * pi * x / 100)", "start": 0, "end": "100 mm"}));
let id = r["curve"].as_str().unwrap().to_string();
assert_eq!(r["closed"], false, "{r}");
assert!(r["deviation"].as_f64().unwrap() <= 0.01, "{r}");
run(s, "sketch.line", json!({"points": [format!("{id}.end"), [100, 0], [0, 0], format!("{id}.start")]}));
run(s, "sketch.finish", json!({}));
id
}
#[test]
fn a_wave_from_a_formula_extrudes_and_follows_its_parameter() {
let mut s = Session::default();
let id = wave(&mut s);
run(&mut s, "solid.extrude", json!({"sketch": "S", "distance": 10}));
no_trouble(&s);
near(volume(&mut s), 100.0 * 10.0 * 10.0, 1e-4);
let before = curve_points(&s, &id);
assert!(before.iter().all(|p| (p.y - (10.0 + 5.0 * (2.0 * std::f64::consts::PI * p.x / 100.0).sin())).abs() < 1e-9), "{before:?}");
// The curve follows the parameter: the strip is twice as tall.
run(&mut s, "parameters.change", json!({"name": "h", "expression": "20 mm"}));
no_trouble(&s);
near(volume(&mut s), 100.0 * 20.0 * 10.0, 1e-4);
let after = curve_points(&s, &id);
assert_eq!(before.len(), after.len());
assert!(before.iter().zip(&after).all(|(a, b)| (b.y - a.y - 10.0).abs() < 1e-9 && (a.x - b.x).abs() < 1e-9));
// The parameter knows the curve uses it, and a rename reaches the formula.
let users = run(&mut s, "parameters.users", json!({"name": "h"}));
assert!(users.to_string().contains("Curve Y"), "{users}");
run(&mut s, "parameters.rename", json!({"name": "h", "new_name": "height"}));
let f = s.doc.find_feature("S").unwrap().id;
assert_eq!(s.doc.sketch(f).unwrap().equation_of(&id).unwrap().y, "height + 5 * sin(2 * pi * x / 100)");
no_trouble(&s);
near(volume(&mut s), 100.0 * 20.0 * 10.0, 1e-4);
// The design file keeps the formulas, and a reopened design follows them.
assert!(s.doc.to_json().contains("\"equations\""));
let mut r = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
no_trouble(&r);
near(volume(&mut r), 100.0 * 20.0 * 10.0, 1e-4);
}
#[test]
fn the_variable_hides_a_parameter_of_its_name_and_a_rename_leaves_it_alone() {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "t", "expression": "1000 mm"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
let r = run(&mut s, "sketch.equation_curve", json!({"x": "t", "y": "t * t", "start": -2, "end": 2}));
let id = r["curve"].as_str().unwrap().to_string();
run(&mut s, "sketch.finish", json!({}));
no_trouble(&s);
let pts = curve_points(&s, &id);
assert!(pts.iter().all(|p| (p.y - p.x * p.x).abs() < 1e-9 && p.x.abs() <= 2.0 + 1e-9), "{pts:?}");
run(&mut s, "parameters.rename", json!({"name": "t", "new_name": "u"}));
let f = s.doc.find_feature("S").unwrap().id;
let eq = s.doc.sketch(f).unwrap().equation_of(&id).unwrap().clone();
assert_eq!((eq.x.as_deref(), eq.y.as_str()), (Some("t"), "t * t"));
}
#[test]
fn a_full_turn_closes_smoothly_and_extrudes_as_a_disc() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
let r = run(&mut s, "sketch.equation_curve", json!({"x": "20 * cos(t)", "y": "20 * sin(t)", "start": 0, "end": "360 deg"}));
assert_eq!(r["closed"], true, "{r}");
let id = r["curve"].as_str().unwrap().to_string();
run(&mut s, "sketch.finish", json!({}));
let f = s.doc.find_feature("S").unwrap().id;
let sk = s.doc.sketch(f).unwrap();
let Some(CurveKind::Spline { pts, periodic: true, control: false, .. }) = sk.curves.iter().find(|c| c.id == id).map(|c| &c.kind) else {
panic!("not a closed fit spline: {:?}", sk.curves)
};
assert_eq!(pts.first(), pts.last());
assert!(pts.iter().all(|p| sk.points[*p].fixed));
run(&mut s, "solid.extrude", json!({"sketch": "S", "distance": 5}));
no_trouble(&s);
near(volume(&mut s), std::f64::consts::PI * 400.0 * 5.0, 1e-3);
}
#[test]
fn a_profile_under_a_curve_revolves() {
// y = 5 + 2 sin(x / 5) over x from 0 to 30, revolved about the x axis:
// V = pi * integral of (5 + 2 sin(x/5))^2 = pi * (25 * 30 + 20 * 5 (1 - cos 6) + 4 (15 - 5/4 sin 12)).
let want = std::f64::consts::PI * (750.0 + 100.0 * (1.0 - 6f64.cos()) + 4.0 * (15.0 - 1.25 * 12f64.sin()));
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
let r = run(&mut s, "sketch.equation_curve", json!({"explicit": true, "y": "5 + 2 * sin(x / 5)", "start": 0, "end": 30}));
let id = r["curve"].as_str().unwrap().to_string();
run(&mut s, "sketch.line", json!({"points": [format!("{id}.end"), [30, 0], [0, 0], format!("{id}.start")]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.revolve", json!({"sketch": "S", "axis": "x"}));
no_trouble(&s);
near(volume(&mut s), want, 2e-3);
}
#[test]
fn an_edit_changes_the_formulas_and_keeps_the_joined_ends() {
let mut s = Session::default();
let id = wave(&mut s);
run(&mut s, "sketch.edit", json!({"sketch": "S"}));
let f = s.doc.find_feature("S").unwrap().id;
let before = s.doc.sketch(f).unwrap().clone();
let ends = |sk: &solidcraft_sketch::Sketch| {
let c = sk.curves.iter().find(|c| c.id == id).unwrap();
let (a, b) = c.kind.ends().unwrap();
(sk.points[a].id.clone(), sk.points[b].id.clone())
};
// A tighter tolerance and a bigger wave: more points, the same ends.
let r = run(&mut s, "sketch.equation_curve.edit", json!({"curve": id, "y": "h + 8 * sin(2 * pi * x / 100)", "tolerance": 0.002}));
assert!(r["deviation"].as_f64().unwrap() <= 0.002, "{r}");
let after = s.doc.sketch(f).unwrap().clone();
assert_eq!(ends(&before), ends(&after));
let count = |sk: &solidcraft_sketch::Sketch| sk.curves.iter().find(|c| c.id == id).unwrap().kind.point_ids().len();
assert!(count(&after) > count(&before), "{} vs {}", count(&after), count(&before));
assert_eq!(r["points"].as_u64(), Some(count(&after) as u64));
let eq = after.equation_of(&id).unwrap();
assert_eq!((eq.y.as_str(), eq.start.as_str(), eq.tolerance), ("h + 8 * sin(2 * pi * x / 100)", "0", 0.002));
// No points left behind: the old in-between points went.
assert_eq!(after.points.len(), before.points.len() - count(&before) + count(&after));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "S", "distance": 10}));
no_trouble(&s);
near(volume(&mut s), 100.0 * 10.0 * 10.0, 1e-4);
// Undo puts the old formulas back.
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.sketch(f).unwrap().equation_of(&id).unwrap().y, "h + 5 * sin(2 * pi * x / 100)");
}
#[test]
fn an_edit_can_switch_between_the_two_forms() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
let r = run(&mut s, "sketch.equation_curve", json!({"explicit": true, "y": "x / 2", "start": 0, "end": 10}));
let id = r["curve"].as_str().unwrap().to_string();
// To a closed parametric curve: the old end point goes, the start stays.
let r = run(&mut s, "sketch.equation_curve.edit", json!({"curve": id, "x": "10 * cos(t)", "y": "10 * sin(t)", "start": 0, "end": "2 * pi"}));
assert_eq!(r["closed"], true, "{r}");
let f = s.doc.find_feature("S").unwrap().id;
let sk = s.doc.sketch(f).unwrap();
assert_eq!(sk.points.len(), 1 + r["points"].as_u64().unwrap() as usize, "the origin and the curve's own points");
// Back to the explicit form, open again.
let r = run(&mut s, "sketch.equation_curve.edit", json!({"curve": id, "explicit": true, "y": "x * x / 10", "start": -10, "end": 10}));
assert_eq!(r["closed"], false, "{r}");
let sk = s.doc.sketch(f).unwrap();
assert!(sk.equation_of(&id).unwrap().x.is_none());
let (a, b) = sk.curves.iter().find(|c| c.id == id).unwrap().kind.ends().unwrap();
assert_eq!((sk.points[a].pos, sk.points[b].pos), (Vec2::new(-10.0, 10.0), Vec2::new(10.0, 10.0)));
}
#[test]
fn deleting_the_curve_forgets_its_formulas() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
let r = run(&mut s, "sketch.equation_curve", json!({"x": "t", "y": "sin(t)", "start": 0, "end": 6}));
let id = r["curve"].as_str().unwrap().to_string();
run(&mut s, "sketch.delete", json!({"entities": [id]}));
let f = s.doc.find_feature("S").unwrap().id;
let sk = s.doc.sketch(f).unwrap();
assert!(sk.equations.is_empty() && sk.curves.is_empty() && sk.points.len() == 1, "{sk:?}");
}
#[test]
fn bad_formulas_ranges_and_values_are_refused_and_change_nothing() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
let before = s.doc.to_json();
for p in [
json!({}),
json!({"y": "t", "start": 0, "end": 1}),
json!({"x": "t", "y": "t", "start": 0}),
json!({"x": "t", "explicit": true, "y": "x", "start": 0, "end": 1}),
json!({"x": "t", "y": "sqrt(t)", "start": -1, "end": 1}),
json!({"x": "t", "y": "1 / t", "start": -1, "end": 1}),
json!({"x": "t", "y": "t", "start": 1, "end": 1}),
json!({"x": "t", "y": "t", "start": 0, "end": "1e12"}),
json!({"x": "t * 1 deg", "y": "t", "start": 0, "end": 1}),
json!({"x": "nope", "y": "t", "start": 0, "end": 1}),
json!({"x": "t", "y": "t", "start": 0, "end": 1, "tolerance": 0}),
json!({"x": "t", "y": "t", "start": 0, "end": 1, "tolerance": "big"}),
json!({"x": 5, "y": 5, "start": 0, "end": 1}),
json!({"x": ["t"], "y": "t", "start": 0, "end": 1}),
json!({"x": "t", "y": "sin(1e5 * t)", "start": 0, "end": 1000, "tolerance": 0.0001}),
json!({"x": "1e300 * t", "y": "t", "start": 0, "end": 1}),
] {
assert!(s.execute("sketch.equation_curve", &p).is_err(), "{p} should be refused");
assert_eq!(s.doc.to_json(), before, "{p} changed the design");
}
for p in [json!({"curve": "nope", "y": "t"}), json!({"y": "t"}), json!({"curve": 7})] {
assert!(s.execute("sketch.equation_curve.edit", &p).is_err(), "{p} should be refused");
}
let line = run(&mut s, "sketch.line", json!({"points": [[0, 0], [5, 5]]}))["curves"][0].as_str().unwrap().to_string();
assert!(s.execute("sketch.equation_curve.edit", &json!({"curve": line, "y": "t"})).is_err());
}
#[test]
fn a_curve_its_parameters_break_is_an_error_not_a_crash() {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "k", "expression": "1 mm"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
run(&mut s, "sketch.equation_curve", json!({"x": "t", "y": "sqrt(k / 1 mm - t)", "start": 0, "end": 0.5}));
run(&mut s, "sketch.finish", json!({}));
no_trouble(&s);
// Now the square root goes negative inside the range: the sketch reports it.
run(&mut s, "parameters.change", json!({"name": "k", "expression": "0.25 mm"}));
let r = s.model.results.iter().find(|r| r.name == "S").unwrap();
assert!(r.error.as_deref().is_some_and(|e| e.contains("equation curve")), "{:?}", r.error);
run(&mut s, "parameters.change", json!({"name": "k", "expression": "1 mm"}));
no_trouble(&s);
}
+12 -5
View File
@@ -321,8 +321,14 @@ fn bracket(sk: &Sketch, ci: usize, tq: f64) -> (Vec<(f64, Vec2, usize)>, bool) {
let (t0, t1) = sh.range();
let full = sh.is_full();
let span = t1 - t0;
let mut cuts: Vec<(f64, Vec2, usize)> =
cut_points(sk, ci).into_iter().filter(|(t, _, _)| full || (*t > t0 + 1e-9 * span && *t < t1 - 1e-9 * span)).collect();
// A cut within rounding of the curve's own end is that end. A curve ending where another one
// ends crosses it there only within the intersection's tolerance, and a cut a hair inside
// left a piece of no length. Cuts closer than that to each other are already one (`add_cut`).
let ends = if full { Vec::new() } else { vec![sh.point(t0), sh.point(t1)] };
let mut cuts: Vec<(f64, Vec2, usize)> = cut_points(sk, ci)
.into_iter()
.filter(|(t, _, _)| full || (*t > t0 + 1e-9 * span && *t < t1 - 1e-9 * span && ends.iter().all(|e| e.dist(sh.point(*t)) >= TOL * 10.0)))
.collect();
cuts.sort_by(|a, b| a.0.total_cmp(&b.0));
if full {
if cuts.len() < 2 {
@@ -861,9 +867,10 @@ fn arc_aim(sk: &Sketch, ci: usize, at: Vec2, cmd: &str) -> Result<Option<ArcAim>
for piece in &pieces {
for q in support::hits(&circle, piece, &mut budget)? {
let travel = if at_b { (q - centre).angle() - end_angle } else { end_angle - (q - centre).angle() }.rem_euclid(TAU);
// The growing end itself, or past the arc's far end (where the extension would
// run over the arc).
if travel <= angle_tol || travel > reach + far_tol {
// The growing end itself (within the intersection's tolerance: a line ending there
// that touches the circle is found a hair beyond it), or past the arc's far end
// (where the extension would run over the arc).
if travel <= far_tol || travel > reach + far_tol {
continue;
}
keep_stop(&mut best, travel, q, oi, angle_tol);
@@ -171,6 +171,57 @@ fn trim_extend_break() {
assert!(sketch(&s).curve_index(&lone).is_none());
}
/// A line that ends where an arc ends crosses it there only within the intersection's
/// tolerance: trimmed from its other end, it was cut a hair inside its own end and left a piece
/// about 1e-7 mm long (found by a local trim fuzzer). Nothing else crosses it, so it trims away.
#[test]
fn trimming_a_line_that_ends_on_an_arc_leaves_no_piece_of_no_length() {
let mut s = new_sketch();
let l =
ids(&run(&mut s, "sketch.line", json!({"points": [[30, 20], [-6.10565359059288, -8.884522872474289]], "infer": false}))["curves"])[0].clone();
run(
&mut s,
"sketch.arc.three_point",
json!({"start": [-8.972171771153333, -9.973571767012182], "end": format!("{l}.end"), "through": [-7.4533649085502365, -9.654220170934645]}),
);
run(&mut s, "sketch.trim", json!({"curve": l, "at": [30.001, 19.999]}));
let sk = sketch(&s);
for c in &sk.curves {
if let Some((a, b)) = c.kind.ends().and_then(|(a, b)| sk.point(a).zip(sk.point(b))) {
assert!(a.dist(b) > 1e-6, "{} is left {} mm long: {:?}", c.id, a.dist(b), c.kind);
}
}
}
/// An arc rounded into a corner, whose end is also where a line ends that touches the arc's
/// circle there: extended from that end, it stopped on the touch at its own end and added an arc
/// from that point to itself (found by a local trim fuzzer). The touch is the growing end, not a
/// stop.
#[test]
fn an_arc_extended_from_an_end_a_tangent_line_ends_on_adds_no_arc_of_no_length() {
let mut s = new_sketch();
for (id, p) in [
("sketch.line", json!({"infer": false, "points": [[-20, -20], [-10, 20]]})),
("sketch.arc.three_point", json!({"end": [0, 30], "start": [10, 20], "through": [-7, 7]})),
("sketch.line", json!({"infer": false, "points": [[-10, -30], [10, 30]]})),
("sketch.rectangle.two_point", json!({"p0": [-10, 30], "p1": [20, 0]})),
("sketch.arc.three_point", json!({"end": [0, 10], "start": [20, -30], "through": [3, 27]})),
("sketch.trim", json!({"at": [2.829281375022314, 26.51870311540471], "curve": "a2"})),
("sketch.trim", json!({"at": [-9.999, 29.999], "curve": "l6"})),
("sketch.fillet", json!({"point": "p1", "radius": 5})),
] {
run(&mut s, id, p);
}
let _ = s.execute("sketch.extend", &json!({"at": [-8.670612547178429, 1.6049962723352929], "curve": "a4"}));
let sk = sketch(&s);
for c in &sk.curves {
if let CurveKind::Arc { a: p, b: q, .. } = c.kind {
let (pa, pb) = (sk.point(p).unwrap(), sk.point(q).unwrap());
assert!(p != q && pa.dist(pb) > 1e-6, "{} runs from {pa:?} to {pb:?}: {:?}", c.id, c.kind);
}
}
}
#[test]
fn trim_circle_makes_an_arc_and_a_profile() {
let mut s = new_sketch();
+58 -12
View File
@@ -382,8 +382,7 @@ fn include_cmd(s: &mut Session, p: &Value) -> Result<Value> {
/// Any body's edges work, a mirrored or patterned copy's too; the link keeps the edge's
/// persistent name, so the constraint follows the edge through rebuilds.
pub(super) fn project_operands(s: &mut Session, p: &Value, cmd: &str, keys: &[&str]) -> Result<Value> {
let model = |v: &Value| v.get("edge").is_some() || v.get("vertex").is_some();
if !keys.iter().any(|k| p.get(*k).is_some_and(model)) {
if !keys.iter().any(|k| p.get(*k).is_some_and(model_ref)) {
return Ok(p.clone());
}
let id = sketch_store::target(s, p, cmd)?;
@@ -392,22 +391,69 @@ pub(super) fn project_operands(s: &mut Session, p: &Value, cmd: &str, keys: &[&s
refresh(s, &doc, id, &mut sk);
let mut out = p.clone();
for key in keys {
let Some(v) = p.get(*key).filter(|v| model(v)) else { continue };
let src = source(s, v, cmd)?;
let what = src.describe();
let link = add_link(s, &doc, id, &mut sk, LinkKind::Project, src).map_err(|e| bad(cmd, format!("{what}: {e}")))?;
// An edge stands for its curve (a point when it is seen end on), a vertex for its point.
let curve = if v.get("edge").is_some() { sk.link_curves(&link).first().and_then(|c| sk.curves.get(*c)).map(|c| c.id.clone()) } else { None };
let entity = curve
.or_else(|| sk.link_points(&link).first().and_then(|q| sk.points.get(*q)).map(|q| q.id.clone()))
.ok_or_else(|| bad(cmd, format!("{what} projects to nothing on this sketch")))?;
out[*key] = json!(entity);
let Some(v) = p.get(*key).filter(|v| model_ref(v)) else { continue };
out[*key] = json!(project_ref(s, &doc, id, &mut sk, v, cmd)?);
}
sketch_store::write(&mut doc, id, sk)?;
*s.doc_mut() = doc;
Ok(out)
}
/// A model's reference among a command's operands (#6348): an `{edge: [x,y,z], body?}` or a
/// `{vertex: [x,y,z], body?}`.
fn model_ref(v: &Value) -> bool {
v.get("edge").is_some() || v.get("vertex").is_some()
}
/// Project one model reference into the sketch and report the entity it stands for: an edge's
/// projected curve (a point when it is seen end on) or a vertex's projected point.
fn project_ref(s: &mut Session, doc: &Document, id: u64, sk: &mut Sketch, v: &Value, cmd: &str) -> Result<String> {
let src = source(s, v, cmd)?;
let what = src.describe();
let link = add_link(s, doc, id, sk, LinkKind::Project, src).map_err(|e| bad(cmd, format!("{what}: {e}")))?;
// An edge stands for its curve (a point when it is seen end on), a vertex for its point.
let curve = if v.get("edge").is_some() { sk.link_curves(&link).first().and_then(|c| sk.curves.get(*c)).map(|c| c.id.clone()) } else { None };
curve
.or_else(|| sk.link_points(&link).first().and_then(|q| sk.points.get(*q)).map(|q| q.id.clone()))
.ok_or_else(|| bad(cmd, format!("{what} projects to nothing on this sketch")))
}
/// Most model references one call may project into a sketch: each one is a link and a resolve,
/// so the count is capped before any of them is projected.
const MAX_PROJECTED_ENTITIES: usize = 1_000;
/// A dimension's entities may be model geometry (#7912): an `{edge: [x,y,z], body?}` or
/// `{vertex: [x,y,z], body?}` among them is projected into the sketch first, as Project does,
/// and stands for the projected curve (or point) in the entities returned.
pub(super) fn project_entities(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
let Some(Value::Array(entities)) = p.get("entities") else { return Ok(p.clone()) };
// A long list is refused before anything is projected, so it costs nothing.
if entities.len() > MAX_PROJECTED_ENTITIES {
return Err(bad(cmd, format!("`entities` may list at most {MAX_PROJECTED_ENTITIES} entities")));
}
if !entities.iter().any(model_ref) {
return Ok(p.clone());
}
let id = sketch_store::target(s, p, cmd)?;
let mut doc = (*s.doc).clone();
let mut sk = sketch_store::read(&doc, id)?.clone();
refresh(s, &doc, id, &mut sk);
let mut out = entities.clone();
for entity in out.iter_mut().take(MAX_PROJECTED_ENTITIES) {
if !model_ref(entity) {
continue;
}
*entity = json!(project_ref(s, &doc, id, &mut sk, entity, cmd)?);
}
let mut params = p.clone();
if let Some(o) = params.as_object_mut() {
o.insert("entities".into(), Value::Array(out));
}
sketch_store::write(&mut doc, id, sk)?;
*s.doc_mut() = doc;
Ok(params)
}
/// Add one link from `src` to the active sketch and report it.
fn one_link(s: &mut Session, cmd: &str, kind: LinkKind, src: LinkSource) -> Result<Value> {
let id = sketch_store::active(s).ok_or_else(|| EngineError::Other("no sketch is being edited".into()))?;
+47
View File
@@ -1983,3 +1983,50 @@ fn a_changed_length_dimension_keeps_its_first_point() {
run(&mut s, "parameters.change", json!({"name": width, "expression": "60 mm"}));
assert!((at(&s, "p1").x + 30.0).abs() < 1e-6 && (at(&s, "p2").x - 30.0).abs() < 1e-6, "{:?} {:?}", at(&s, "p1"), at(&s, "p2"));
}
/// #7912: a dimension can measure a model edge: the edge is projected into the sketch and the
/// dimension measures the distance from the sketch's point to it.
#[test]
fn a_dimension_measures_a_model_edge() {
let mut s = Session::default();
// A sheet-metal base flange: a 100 x 50 plate whose front edge runs along y = 0.
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 50]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sheet.flange", json!({"sketch": "Base", "body_name": "Flange"}));
// A sketch with a point 25 above the flange's front edge.
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Near"}));
let point = run(&mut s, "sketch.point", json!({"point": [50, 25]}))["point"].as_str().unwrap().to_string();
let out = run(&mut s, "sketch.dimension", json!({"entities": [point, {"edge": [50, 0, 0], "body": "Flange"}]}));
assert!((out["measured"].as_f64().unwrap() - 25.0).abs() < 1e-6, "the distance to the edge: {out}");
// The edge is projected into the sketch, and the dimension measures that projection.
let sk = sketch(&s);
assert_eq!(sk.links.len(), 1, "{:?}", sk.links);
}
/// #7912: a dimension refuses a long list of entities before projecting any of them, so one
/// request cannot make the engine resolve thousands of model edges.
#[test]
fn a_dimension_refuses_a_long_list_of_entities() {
// The most entities one call may project (the engine's `MAX_PROJECTED_ENTITIES`).
const MAX_PROJECTED: usize = 1_000;
let mut s = Session::default();
// A sheet-metal base flange: a 100 x 50 plate whose front edge runs along y = 0.
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 50]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sheet.flange", json!({"sketch": "Base", "body_name": "Flange"}));
// A sketch with a point 25 above the flange's front edge.
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Near"}));
let point = run(&mut s, "sketch.point", json!({"point": [50, 25]}))["point"].as_str().unwrap().to_string();
// More entities than a call may project, every one of them a model edge to project.
let mut entities = vec![json!(point)];
for _ in 0..MAX_PROJECTED {
entities.push(json!({"edge": [50, 0, 0], "body": "Flange"}));
}
let before = serde_json::to_value(&s.doc).unwrap();
let error = s.execute("sketch.dimension", &json!({"entities": entities})).unwrap_err();
assert!(error.to_string().contains("at most"), "{error}");
// The refused request leaves the design as it was.
assert_eq!(serde_json::to_value(&s.doc).unwrap(), before);
}
+325 -1
View File
@@ -62,6 +62,7 @@ fn set(s: &mut Session, p: &Value, a: SurfaceAnalysis) -> Result<Value> {
} else {
s.analysis = Some(a);
}
s.status_analysis_changed((!bool_(p, "clear").unwrap_or(false)).then_some(crate::status_items::StatusId::SurfaceAnalysis));
s.revision += 1;
Ok(json!({ "analysis": s.analysis }))
}
@@ -117,6 +118,7 @@ fn continuity(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "inspect.continuity";
if bool_(p, "clear").unwrap_or(false) {
s.continuity.clear();
s.status_analysis_changed(None);
s.revision += 1;
return Ok(json!({ "analysis": "continuity", "cleared": true }));
}
@@ -205,6 +207,7 @@ fn continuity(s: &mut Session, p: &Value) -> Result<Value> {
} else {
s.continuity = seams;
}
s.status_analysis_changed(Some(crate::status_items::StatusId::Continuity));
s.revision += 1;
Ok(json!({
"analysis": "continuity",
@@ -313,6 +316,327 @@ mod tests {
s.execute("solid.box", &json!({"length": size, "width": size, "height": size})).unwrap();
}
fn status(s: &mut Session) -> Vec<crate::status_items::StatusItem> {
serde_json::from_value::<crate::status_items::StatusItems>(s.execute("view.status_items", &json!({})).unwrap()).unwrap().items
}
#[test]
fn status_items_zero_seam_continuity_never_leaves_a_phantom_winner() {
use crate::status_items::StatusId;
for single in [false, true] {
let mut empty = Session::default();
empty.set_single_surface_analysis(single);
empty.execute("inspect.zebra", &json!({"stripes":14.5})).unwrap();
let before = (status(&mut empty), empty.latest_analysis, empty.revision);
assert!(empty.execute("inspect.continuity", &json!({})).is_err());
assert_eq!((status(&mut empty), empty.latest_analysis, empty.revision), before, "empty-model failure is atomic");
for had_continuity in [false, true] {
let mut s = Session::default();
s.set_single_surface_analysis(single);
boxed(&mut s, 10.0);
for (command, params) in [
("sketch.create", json!({"plane":"XY","name":"Boundary"})),
("sketch.rectangle.two_point", json!({"p0":[20,20],"p1":[30,30]})),
("sketch.finish", json!({})),
("surface.patch", json!({"sketch":"Boundary","body_name":"Sheet"})),
("inspect.zebra", json!({"stripes":14.5})),
("inspect.section", json!({"plane":"XY"})),
] {
s.execute(command, &params).unwrap();
}
if had_continuity {
s.execute("inspect.continuity", &json!({"body":"Body1","edges":[[5,0,0]],"samples":5})).unwrap();
assert_eq!(s.continuity.len(), 1);
if single {
s.execute("inspect.zebra", &json!({"stripes":14.5})).unwrap();
}
}
s.mark_saved();
let doc = s.doc.clone();
let undo = s.undo.len();
let result = s.execute("inspect.continuity", &json!({"body":"Sheet","samples":5})).unwrap();
assert_eq!(result["summary"]["edges"], 0);
assert_eq!(result["summary"]["skipped"], 4, "an actual one-face sheet has four open boundaries");
assert!(s.continuity.is_empty());
assert_eq!(s.latest_analysis, if single { None } else { Some(StatusId::SurfaceAnalysis) });
if single {
assert_eq!(s.analysis, None, "single-mode successful zero-result replacement retains its existing clear semantics");
} else {
assert_eq!(s.analysis, Some(SurfaceAnalysis::Zebra { stripes: 14.5 }), "legacy zero result preserves shading");
}
let expected = if single { vec![StatusId::Section] } else { vec![StatusId::SurfaceAnalysis, StatusId::Section] };
for policy in [true, false, true] {
s.set_single_surface_analysis(policy);
assert_eq!(status(&mut s).iter().map(|item| item.id).collect::<Vec<_>>(), expected);
}
assert!(std::sync::Arc::ptr_eq(&doc, &s.doc));
assert!(!s.is_dirty());
assert_eq!(s.undo.len(), undo);
}
}
}
#[test]
fn status_items_legacy_policy_keeps_independent_effects_and_clears_in_both_orders() {
use crate::status_items::StatusId::{Continuity, Section, SurfaceAnalysis};
for continuity_first in [false, true] {
let mut s = Session::default();
boxed(&mut s, 10.0);
s.mark_saved();
let doc = s.doc.clone();
let undo = s.undo.len();
s.set_single_surface_analysis(false);
let shading = ("inspect.zebra", json!({"stripes": 14.5}));
let continuity = ("inspect.continuity", json!({"edges": [[5, 0, 0]], "samples": 5}));
for (command, params) in if continuity_first { [continuity, shading] } else { [shading, continuity] } {
s.execute(command, &params).unwrap();
}
s.execute("inspect.section", &json!({"plane": "XY"})).unwrap();
let items = status(&mut s);
let expected = if continuity_first { vec![Continuity, SurfaceAnalysis, Section] } else { vec![SurfaceAnalysis, Continuity, Section] };
assert_eq!(items.iter().map(|item| item.id).collect::<Vec<_>>(), expected);
assert_eq!(s.analysis, Some(crate::SurfaceAnalysis::Zebra { stripes: 14.5 }));
assert_eq!(s.continuity.len(), 1);
let before = (items.clone(), s.status_order.clone(), s.latest_analysis, s.revision, s.continuity.clone());
for (command, params) in [
("inspect.continuity", json!({"body": "missing"})),
("inspect.draft", json!({"pull": [0, 0, 0]})),
("inspect.zebra", json!({"stripes": -2})),
] {
assert!(s.execute(command, &params).is_err());
assert_eq!((status(&mut s), s.status_order.clone(), s.latest_analysis, s.revision, s.continuity.clone()), before);
}
// The legacy generic shading clear still ends Zebra, never Continuity or Section.
s.execute("inspect.curvature_map", &json!({"clear": true})).unwrap();
assert_eq!(status(&mut s).iter().map(|item| item.id).collect::<Vec<_>>(), vec![Continuity, Section]);
let clear = items.iter().find(|item| item.id == Continuity).unwrap().clear.clone();
for _ in 0..2 {
s.execute(&clear.command, &serde_json::to_value(&clear.params).unwrap()).unwrap();
assert_eq!(status(&mut s).iter().map(|item| item.id).collect::<Vec<_>>(), vec![Section]);
}
// Clear in the opposite direction leaves the shading active too.
s.execute("inspect.zebra", &json!({})).unwrap();
s.execute("inspect.continuity", &json!({"edges": [[5, 0, 0]]})).unwrap();
s.execute("inspect.continuity", &json!({"clear": true})).unwrap();
assert!(s.analysis.is_some());
assert!(s.section.is_some());
assert!(std::sync::Arc::ptr_eq(&doc, &s.doc));
assert!(!s.is_dirty());
assert_eq!(s.undo.len(), undo);
}
}
#[test]
fn status_items_policy_switch_keeps_latest_replacement_at_oldest_group_position() {
use crate::status_items::{StatusId, StatusSetting};
for continuity_first in [false, true] {
for latest_continuity in [false, true] {
let mut s = Session::default();
boxed(&mut s, 10.0);
s.mark_saved();
let doc = s.doc.clone();
let undo = s.undo.len();
s.set_single_surface_analysis(false);
let shading = ("inspect.zebra", json!({}));
let continuity = ("inspect.continuity", json!({"edges": [[5, 0, 0]], "samples": 5}));
let (first, last) = if continuity_first { (continuity, shading) } else { (shading, continuity) };
s.execute(first.0, &first.1).unwrap();
s.execute("inspect.section", &json!({"plane": "XY"})).unwrap();
s.execute(last.0, &last.1).unwrap();
// Replacement does not move the slot, but determines the policy-switch winner.
if latest_continuity {
s.execute("inspect.continuity", &json!({"edges": [[0, 5, 0]], "add": true})).unwrap();
} else {
s.execute("inspect.draft", &json!({"angle": 7.5})).unwrap();
}
let before = status(&mut s);
for _ in 0..3 {
assert_eq!(status(&mut s), before);
}
s.set_single_surface_analysis(true);
let items = status(&mut s);
assert_eq!(items.iter().map(|item| item.id).collect::<Vec<_>>(), vec![StatusId::SurfaceAnalysis, StatusId::Section]);
assert_eq!(items[0].command, if latest_continuity { "inspect.continuity" } else { "inspect.draft" });
if latest_continuity {
assert_eq!(s.analysis, None);
assert_eq!(items[0].setting, StatusSetting::Continuity { edge_count: 2 });
} else {
assert!(s.continuity.is_empty());
assert!(matches!(s.analysis, Some(SurfaceAnalysis::Draft { angle, .. }) if angle == 7.5));
}
let revision = s.revision;
s.set_single_surface_analysis(true);
assert_eq!(s.revision, revision, "idempotent policy application");
s.set_single_surface_analysis(false);
assert_eq!(status(&mut s)[0].id, if latest_continuity { StatusId::Continuity } else { StatusId::SurfaceAnalysis });
assert!(std::sync::Arc::ptr_eq(&doc, &s.doc));
assert!(!s.is_dirty());
assert_eq!(s.undo.len(), undo);
assert!(!s.scratch().single_surface_analysis());
s.execute("file.new", &json!({})).unwrap();
assert!(!s.single_surface_analysis(), "new/open retain SDK session configuration");
assert!(status(&mut s).is_empty());
}
}
}
#[test]
fn status_items_describe_every_effect_and_execute_their_own_clear() {
use crate::status_items::{StatusId, StatusKind, StatusSetting};
let mut s = Session::default();
assert!(status(&mut s).is_empty());
for (command, params, setting, has_key) in [
("inspect.zebra", json!({"stripes": 12.5}), StatusSetting::Zebra { stripes: 12.5 }, false),
("inspect.draft", json!({"pull": [0, -2, 0], "angle": 3.25}), StatusSetting::Draft { pull: [0.0, -1.0, 0.0], angle_deg: 3.25 }, true),
("inspect.environment_map", json!({}), StatusSetting::Environment, false),
("inspect.accessibility", json!({"direction": [-3, 0, 0]}), StatusSetting::Accessibility { direction: [-1.0, 0.0, 0.0] }, true),
("inspect.curvature_map", json!({"radius": 2.75}), StatusSetting::Curvature { radius_mm: 2.75 }, true),
] {
s.execute(command, &params).unwrap();
let items = status(&mut s);
assert_eq!(items.len(), 1);
let item = &items[0];
assert_eq!(item.id, StatusId::SurfaceAnalysis);
assert_eq!(item.kind, StatusKind::Mode);
assert_eq!(item.command, command);
assert_eq!(item.setting, setting);
assert_eq!(item.has_key, has_key);
assert_eq!(item.clear.command, command);
let params = serde_json::to_value(&item.clear.params).unwrap();
for _ in 0..2 {
s.execute(&item.clear.command, &params).unwrap();
assert!(status(&mut s).is_empty());
}
}
for (params, origin, normal, hatch) in [
(json!({"plane": "XY", "at": 20, "hatch": false}), [0.0, 0.0, 20.0], [0.0, 0.0, 1.0], false),
(json!({"plane": {"origin": [1, 2, 3], "normal": [0, 3, 4]}}), [1.0, 2.0, 3.0], [0.0, 0.6, 0.8], true),
] {
s.execute("inspect.section", &params).unwrap();
let item = status(&mut s).remove(0);
assert_eq!(item.id, StatusId::Section);
assert_eq!(item.kind, StatusKind::Mode);
assert_eq!(item.command, "inspect.section");
assert_eq!(item.setting, StatusSetting::Section { origin_mm: origin, normal, hatch });
assert!(!item.has_key);
s.execute(&item.clear.command, &serde_json::to_value(item.clear.params).unwrap()).unwrap();
assert!(status(&mut s).is_empty());
}
}
#[test]
fn status_items_order_slots_by_activation_and_retain_replacement_position() {
use crate::status_items::StatusId::{Section, SurfaceAnalysis};
for section_first in [false, true] {
let mut s = Session::default();
let section = ("inspect.section", json!({"plane": "XY"}));
let zebra = ("inspect.zebra", json!({}));
for (command, params) in if section_first { [section, zebra] } else { [zebra, section] } {
s.execute(command, &params).unwrap();
}
let expected = if section_first { vec![Section, SurfaceAnalysis] } else { vec![SurfaceAnalysis, Section] };
assert_eq!(status(&mut s).iter().map(|item| item.id).collect::<Vec<_>>(), expected);
s.execute("inspect.draft", &json!({"angle": 7})).unwrap();
s.execute("inspect.section", &json!({"plane": "YZ", "at": 9})).unwrap();
assert_eq!(
status(&mut s).iter().map(|item| item.id).collect::<Vec<_>>(),
expected,
"analysis and Section replacements retain their slots"
);
s.execute("inspect.draft", &json!({"clear": true})).unwrap();
assert_eq!(status(&mut s).iter().map(|item| item.id).collect::<Vec<_>>(), vec![Section]);
s.execute("inspect.zebra", &json!({})).unwrap();
assert_eq!(status(&mut s).iter().map(|item| item.id).collect::<Vec<_>>(), vec![Section, SurfaceAnalysis]);
s.execute("inspect.section", &json!({"clear": true})).unwrap();
assert_eq!(status(&mut s).len(), 1);
s.execute("inspect.section", &json!({"plane": "XZ"})).unwrap();
assert_eq!(status(&mut s).iter().map(|item| item.id).collect::<Vec<_>>(), vec![SurfaceAnalysis, Section]);
}
}
#[test]
fn status_items_continuity_shares_the_analysis_slot_and_failures_keep_effects() {
use crate::status_items::{StatusId, StatusSetting};
let mut s = Session::default();
boxed(&mut s, 10.0);
s.execute("inspect.zebra", &json!({})).unwrap();
s.execute("inspect.section", &json!({"plane": "XY"})).unwrap();
s.execute("inspect.continuity", &json!({"edges": [[5, 0, 0]]})).unwrap();
assert_eq!(s.analysis, None);
let continuity = status(&mut s)[0].clone();
assert_eq!(continuity.id, StatusId::SurfaceAnalysis);
assert_eq!(continuity.command, "inspect.continuity");
assert_eq!(continuity.setting, StatusSetting::Continuity { edge_count: 1 });
assert!(!continuity.has_key);
s.execute("inspect.continuity", &json!({"edges": [[0, 5, 0]], "add": true})).unwrap();
assert_eq!(status(&mut s)[0].setting, StatusSetting::Continuity { edge_count: 2 });
let before = status(&mut s);
let order = s.status_order.clone();
let effects = (s.analysis, s.continuity.clone(), s.section, s.section_hatch, s.revision);
for (command, params) in [
("inspect.continuity", json!({"body": "missing"})),
("inspect.continuity", json!({"samples": 2})),
("inspect.draft", json!({"pull": [0, 0, 0]})),
("inspect.zebra", json!({"stripes": -1})),
("inspect.section", json!({"plane": {"normal": [0, 0, 0]}})),
] {
assert!(s.execute(command, &params).is_err());
assert_eq!(status(&mut s), before);
assert_eq!(s.status_order, order);
assert_eq!((s.analysis, s.continuity.clone(), s.section, s.section_hatch, s.revision), effects);
}
s.execute("inspect.draft", &json!({})).unwrap();
assert!(s.continuity.is_empty());
let before = status(&mut s);
assert!(s.execute("inspect.continuity", &json!({"body": "missing"})).is_err());
assert_eq!(status(&mut s), before, "failed Continuity activation keeps surface shading and ordering");
s.execute(&continuity.clear.command, &serde_json::to_value(continuity.clear.params).unwrap()).unwrap();
assert_eq!(status(&mut s)[0].command, "inspect.draft", "stale continuity clear keeps replacement");
s.execute("inspect.continuity", &json!({"edges": [[5, 0, 0]]})).unwrap();
let item = status(&mut s).remove(0);
s.execute(&item.clear.command, &serde_json::to_value(item.clear.params).unwrap()).unwrap();
assert_eq!(status(&mut s).len(), 1);
assert_eq!(status(&mut s)[0].id, StatusId::Section);
}
#[test]
fn status_items_queries_are_read_only_even_during_a_stale_live_drag() {
use std::sync::Arc;
let mut s = Session::default();
for (command, params) in [
("sketch.create", json!({"plane":"XY"})),
("sketch.rectangle.two_point", json!({"p0":[5,5],"p1":[25,20]})),
("sketch.dimension", json!({"entities":["l1"],"value":20})),
("sketch.dimension", json!({"entities":["l2"],"value":15})),
("sketch.finish", json!({})),
("solid.extrude", json!({"distance":5})),
("inspect.zebra", json!({"stripes": 10.5})),
("inspect.section", json!({"plane":"XY", "at": 2})),
("sketch.edit", json!({"sketch":"Sketch1"})),
] {
s.execute(command, &params).unwrap();
}
let effects = status(&mut s);
s.execute("sketch.drag", &json!({"entity":"l1.start","from":[5,5],"to":[4,5],"live":true})).unwrap();
assert!(s.model.is_stale());
let doc = s.doc.clone();
let model = s.model.state();
let selection = s.selection.clone();
let before = (s.revision, s.undo.len(), s.redo.len(), s.is_dirty(), s.evaluations, s.status_order.clone());
for _ in 0..3 {
assert_eq!(status(&mut s), effects);
}
assert!(Arc::ptr_eq(&doc, &s.doc));
assert!(Arc::ptr_eq(&model, &s.model.state()));
assert!(s.model.is_stale());
assert_eq!(s.selection, selection);
assert_eq!((s.revision, s.undo.len(), s.redo.len(), s.is_dirty(), s.evaluations, s.status_order.clone()), before);
assert!(s.scratch().status_items().is_empty(), "scratch sessions have no persistent view effects");
s.execute("file.new", &json!({})).unwrap();
assert!(status(&mut s).is_empty());
assert!(s.status_order.is_empty(), "new design resets transient chronology");
}
/// The edges of the first body of a continuity reply.
fn edges(v: &Value) -> &Vec<Value> {
v["bodies"][0]["edges"].as_array().unwrap_or_else(|| panic!("no edges in {v}"))
@@ -376,7 +700,7 @@ mod tests {
assert_eq!(v["summary"]["worst"], json!("sharp"));
assert_eq!(s.continuity.len(), 12, "the seams are kept for the viewport");
assert_eq!(s.undo.len(), undo, "no undo step");
assert_eq!(s.analysis, None, "the other analyses are left alone");
assert_eq!(s.analysis, None, "only the Continuity analysis is active");
}
/// A rolling-ball fillet is tangent to the faces it joins but not curvature continuous: its
+14
View File
@@ -32,8 +32,13 @@ struct RenderBrowserControls {
}
const RENDER_SELECT: TypedContract<RenderSelect> = TypedContract::new();
const STATUS_ITEMS_CONTRACT: TypedContract<crate::schema::view_navigation::StatusItemsParams> = TypedContract::new();
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("view.status_items", "Viewport Status Items", crate::checked!("view.status_items", status_items))
.icon("list").noundo().doc_only()
.contract(&STATUS_ITEMS_CONTRACT).output(output_schema_for::<crate::status_items::StatusItems>)
.params("no parameters → {items}; persistent viewport effects, oldest slot first, with structured settings and executable clear requests; read-only, no model evaluation"),
CommandSpec::new("view.fit", "Fit", fit)
.icon("fit").key("F6").noundo().doc_only().params("no parameters → frame the active canvas or selection; display only"),
CommandSpec::new("view.home", "Home view", home)
@@ -270,6 +275,15 @@ fn navigation_bar(_s: &mut Session, p: &Value) -> Result<Value> {
view_toggle(p, "view.navigation_bar", "visible")
}
fn status_items(s: &mut Session, p: &Value) -> Result<Value> {
if !p.is_object() {
return Err(bad("view.status_items", "parameters must be an empty object"));
}
let _: crate::schema::view_navigation::StatusItemsParams =
serde_json::from_value(p.clone()).map_err(|e| bad("view.status_items", e.to_string()))?;
Ok(json!(crate::status_items::StatusItems { items: s.status_items() }))
}
fn render_focus(_s: &mut Session, p: &Value) -> Result<Value> {
let request: RenderFocus = serde_json::from_value(p.clone()).map_err(|e| bad("view.render_focus", e.to_string()))?;
Ok(json!(request))
+2
View File
@@ -57,6 +57,7 @@ pub fn command_reads_only(id: &str) -> bool {
|| id.ends_with(".inspect")
|| id.ends_with(".list")
|| id == "document.revision"
|| id == "view.status_items"
|| matches!(id, "document.inspect" | "engine.commands" | "render.status" | "render.backends" | "render.settings.get" | "file.slicers")
}
@@ -120,6 +121,7 @@ mod tests {
("ui.screenshot", json!({})),
("document.inspect", json!({"measure": true})),
("engine.execute", json!({"command": "sketch.inspect"})),
("engine.execute", json!({"command": "view.status_items", "params": {}})),
("engine.execute", json!({"command": "inspect.measure"})),
("engine.execute", json!({"command": "model.edges", "params": {"body": "Body1"}})),
("engine.execute", json!({"command": "parameters.export", "params": {"format": "csv"}})),
+73 -6
View File
@@ -33,6 +33,7 @@ pub mod slicer_files;
mod slicer_icon;
pub mod slicers;
pub mod snap;
pub mod status_items;
pub mod timing;
pub mod view;
@@ -43,6 +44,7 @@ use serde_json::Value;
use solidcraft_doc::{Document, Model, ModelState};
use solidcraft_geom::Vec3;
pub use cmd::settings::{MAX_PROFILE_NAME, profile_name};
pub use cmd::{
Addition, CommandInfo, CommandSpec, addition, auto_operation, command_specs, dimension_roles, extrude_reach, find_command, resolve_design_links,
shown_scope, standard_view,
@@ -183,6 +185,52 @@ struct BodyRename {
names_only: bool,
}
/// Bodies or component placements copied in one design, for pasting into another
/// (`component.paste_from_design`): that design's placed model as it was when they were copied,
/// so the copy is what was copied, whatever happens to that design (or its tab) after.
#[derive(Clone, Debug)]
pub struct ForeignCopy {
/// The design's name (it names what the paste adds).
pub design: String,
/// Its placed model: what its Browser and view showed.
pub world: Arc<solidcraft_doc::ModelState>,
/// The bodies copied, as its Browser names them (a body seen in another placement under the
/// name it has there).
pub bodies: Vec<String>,
/// The name each of `bodies` has in its component (without the placement a body seen in
/// another placement is named after): the name it pastes as.
pub names: Vec<String>,
/// The component placements copied, by path.
pub placements: Vec<Vec<u64>>,
/// Each of `placements`: the component placed, the occurrence and where it is in the world.
pub placed: Vec<(u64, u64, solidcraft_doc::Mat)>,
/// The design, its evaluated model and its placements moved but not yet applied: what a
/// pasted component's history is copied from.
pub doc: Arc<Document>,
pub model: Model,
pub pending_moves: std::collections::BTreeMap<u64, solidcraft_doc::Mat>,
}
impl ForeignCopy {
/// What `session` copies when `bodies` and `placements` are copied in it now.
pub fn of(session: &Session, bodies: Vec<String>, placements: Vec<Vec<u64>>) -> ForeignCopy {
let frames = session.placed_document().body_frames_with_source(&session.model.state());
let names = bodies.iter().map(|b| frames.iter().find(|f| f.0 == *b).map_or_else(|| b.clone(), |f| f.1.clone())).collect();
let placed = placements.iter().filter_map(|p| session.occurrence_placement(p).ok()).map(|p| (p.component, p.occurrence, p.world)).collect();
ForeignCopy {
design: session.doc.name.clone(),
world: session.world_state(),
bodies,
names,
placements,
placed,
doc: session.doc.clone(),
model: session.model.clone(),
pending_moves: session.pending_moves.clone(),
}
}
}
/// One undo step.
#[derive(Clone, Debug)]
pub struct Snapshot {
@@ -410,6 +458,9 @@ pub struct Session {
pub sketch_clipboard: Option<cmd::sketch_clipboard::SketchClip>,
/// Resolved appearance copied by appearance.copy; transient, outside design/history.
pub appearance_clipboard: Option<solidcraft_doc::appearance::Look>,
/// What was copied in another design, to paste here (`component.paste_from_design`). The
/// host puts it here; each design's own copies stay in its own clipboard.
pub foreign_copy: Option<Arc<ForeignCopy>>,
/// Section Analysis: a view cut by a plane (origin, unit normal; the normal side is hidden).
pub section: Option<(Vec3, Vec3)>,
/// Whether the section's cut faces are hatched (`inspect.section`'s `hatch`; on by default).
@@ -421,6 +472,10 @@ pub struct Session {
/// The seams `inspect.continuity` last measured, for the viewport to shade. View state: not
/// part of the design and not an undo step.
pub continuity: Vec<ContinuitySeam>,
/// Transient activation order only; effect settings remain in their existing fields.
status_order: Vec<status_items::StatusId>,
single_surface_analysis: bool,
latest_analysis: Option<status_items::StatusId>,
/// What is hidden or shown in the view (`browser.visibility`): saved with the design and an
/// undo step of its own ([`Snapshot::visibility`]). A design brings its saved visibility when
/// it opens (`Session::new`).
@@ -594,11 +649,15 @@ impl Session {
clipboard: Vec::new(),
sketch_clipboard: None,
appearance_clipboard: None,
foreign_copy: None,
section: None,
section_hatch: true,
colored_components: None,
analysis: None,
continuity: Vec::new(),
status_order: Vec::new(),
single_surface_analysis: true,
latest_analysis: None,
checkpoints: Vec::new(),
next_checkpoint: 1,
replacements: 0,
@@ -622,6 +681,7 @@ impl Session {
/// checkpoint keeps, not the replaced session's file path (docs/mcp.md).
pub fn replace_design(&mut self, fresh: Session) {
let auto_project = self.auto_project;
let single_surface_analysis = self.single_surface_analysis;
let param_check = self.param_check;
let easyprint_upload = self.easyprint_upload.take();
let render_viewport = self.render_viewport.clone();
@@ -635,6 +695,7 @@ impl Session {
*self = fresh;
self.crash_journal = crash_journal;
self.auto_project = auto_project;
self.set_single_surface_analysis(single_surface_analysis);
self.param_check = param_check;
self.easyprint_upload = easyprint_upload;
self.render_viewport = render_viewport;
@@ -937,9 +998,10 @@ impl Session {
/// Pin the name of every body the features made, as if the body had been renamed to it, and
/// reserve the `Body<N>` names, so that no later command renames a body or hands its number
/// to another. It is part of the command that made the bodies. A design saved before names
/// were pinned is pinned by its first command that changes it, with the names it has after
/// that command: when that command is itself a delete, reorder, suppress or rollback, the
/// later counted bodies are renumbered once, as they always were.
/// were pinned is pinned as it opens ([`Self::opened_at`]), with the names it was saved
/// with; one made in memory ([`Self::new`]) by its first command that changes it, with the
/// names it has after that command (a delete, reorder, suppress or rollback renumbers the
/// later counted bodies once).
fn pin_body_names(&mut self) {
if self.model.is_stale() {
return;
@@ -1437,11 +1499,15 @@ impl Session {
clipboard: self.clipboard.clone(),
sketch_clipboard: self.sketch_clipboard.clone(),
appearance_clipboard: self.appearance_clipboard.clone(),
foreign_copy: self.foreign_copy.clone(),
section: None,
section_hatch: true,
colored_components: None,
analysis: None,
continuity: Vec::new(),
status_order: Vec::new(),
single_surface_analysis: self.single_surface_analysis,
latest_analysis: None,
checkpoints: Vec::new(),
next_checkpoint: 1,
replacements: 0,
@@ -1744,9 +1810,10 @@ impl Session {
/// [`Session::run_script`], timing each command (#1833): the results and
/// `{"timings": [{"index", "command", "ms", "profile"?}], "total_ms"}`, with `profile` the
/// kernel's share of each command ([`timing`]).
pub fn run_script_timed(&mut self, script: &Value, profile: bool) -> Result<(Vec<Value>, Value)> {
let (results, timings) = self.run_script_with(script, Some(profile), &mut |_, _| {})?;
/// kernel's share of each command ([`timing`]). `each` is called as in
/// [`Session::run_script_each`].
pub fn run_script_timed(&mut self, script: &Value, profile: bool, each: &mut dyn FnMut(usize, &Value)) -> Result<(Vec<Value>, Value)> {
let (results, timings) = self.run_script_with(script, Some(profile), each)?;
Ok((results, timings.unwrap_or(Value::Null)))
}
+76
View File
@@ -349,6 +349,82 @@ pub struct OccurrenceAlignResult {
pub param_warnings: Vec<String>,
}
/// Move timeline features (sketches too) into a component. Their ids, names and timeline order
/// stay, the geometry stays where it is in the world, and the bodies they made move with them.
/// Nothing changes when the move would break a feature, while the timeline is rolled back, or
/// for a component placed more than once.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct MoveFeaturesParams {
/// The features to move, by id or name.
pub features: Vec<IdOrName>,
/// The component they go to, by id or name. Default: the active component.
#[serde(default, deserialize_with = "present")]
pub component: Option<IdOrName>,
}
/// What `component.move_features` returns.
#[derive(Debug, Default, Serialize, JsonSchema)]
pub struct MoveFeaturesResult {
/// The names of the features that moved.
pub moved: Vec<String>,
/// The component they went to.
pub component: u64,
/// The bodies that moved with them.
pub bodies: Vec<String>,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
/// A placement: an occurrence id, or its path of occurrence ids from the top of the design.
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(untagged)]
pub enum PlacementRef {
Id(u64),
Path(Vec<u64>),
}
/// Move placements, timeline features and bodies into a component in one undo step, all or
/// nothing. Placements move first and take what they hold, then features, which take the bodies
/// they made, then the bodies still elsewhere (a body transfer, which leaves its features
/// behind). Anything already in the component is left alone.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct ReparentItemsParams {
/// The placements to move.
#[serde(default, deserialize_with = "present")]
pub occurrences: Option<Vec<PlacementRef>>,
/// The features to move, by id or name.
#[serde(default, deserialize_with = "present")]
pub features: Option<Vec<IdOrName>>,
/// The bodies to move, by name.
#[serde(default, deserialize_with = "present")]
pub bodies: Option<Vec<String>>,
/// The component they go to, by id or name. Default: the active component.
#[serde(default, deserialize_with = "present")]
pub component: Option<IdOrName>,
/// The placement of that component to place them by, as occurrence ids from the top.
#[serde(default, deserialize_with = "present")]
pub component_path: Option<Vec<u64>>,
}
/// What `browser.reparent` returns.
#[derive(Debug, Default, Serialize, JsonSchema)]
pub struct ReparentItemsResult {
/// The component they went to.
pub component: u64,
/// The occurrence ids that moved.
pub occurrences: Vec<u64>,
/// The names of the features that moved.
pub features: Vec<String>,
/// The bodies that were transferred on their own.
pub bodies: Vec<String>,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
#[cfg(test)]
#[path = "component_tests.rs"]
mod tests;
+3 -1
View File
@@ -2,7 +2,7 @@ use std::sync::Arc;
use serde_json::{Value, json};
use super::{EditInPlaceParams, FinishEditParams, OccurrenceAlignParams, OccurrenceAlignResult};
use super::{EditInPlaceParams, FinishEditParams, MoveFeaturesParams, OccurrenceAlignParams, OccurrenceAlignResult, ReparentItemsParams};
use crate::Session;
use crate::schema::tests::{assert_keys, conforms};
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
@@ -29,6 +29,8 @@ fn schemas_and_the_key_check_agree() {
"to_plane",
],
);
assert_keys(input_schema_for::<MoveFeaturesParams>(), &["component", "features"]);
assert_keys(input_schema_for::<ReparentItemsParams>(), &["bodies", "component", "component_path", "features", "occurrences"]);
}
#[test]
+1
View File
@@ -64,6 +64,7 @@ pub mod render_ui;
pub mod sheet;
pub mod sketch_constraints;
pub mod sketch_create;
pub mod sketch_equation;
pub mod view_navigation;
pub use check::{Contract, Issue, ParamCheck, TypedContract, input_schema_for, output_schema_for, run_checked, run_checked_as};
+202
View File
@@ -0,0 +1,202 @@
//! Contracts and result types for the commands in `cmd/sketch_equation.rs`.
use std::borrow::Cow;
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
use serde::{Deserialize, Deserializer, Serialize, de::Error as _};
use serde_json::Value;
use super::types::{SketchRef, present};
/// A formula or a range end: an expression (text) or a number.
#[derive(Clone, Debug, PartialEq)]
pub struct Formula(pub String);
impl<'de> Deserialize<'de> for Formula {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
match Value::deserialize(d)? {
Value::String(s) if !s.trim().is_empty() => Ok(Formula(s)),
Value::Number(n) => match n.as_f64().and_then(crate::params::finite) {
Some(v) => Ok(Formula(format!("{v}"))),
None => Err(D::Error::custom("must be a finite number")),
},
_ => Err(D::Error::custom("must be a formula (a string) or a number")),
}
}
}
impl JsonSchema for Formula {
fn schema_name() -> Cow<'static, str> {
"Formula".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "An expression in the design's expression language and parameters, such as \"r * cos(t)\" or \"2 * pi\", or a number.",
"anyOf": [
{"type": "number", "exclusiveMinimum": -1e9, "exclusiveMaximum": 1e9},
{"type": "string", "minLength": 1, "maxLength": 1024, "pattern": "\\S"}
]
})
}
}
/// How far (mm) the curve may stray from its formulas.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Tolerance(pub f64);
impl<'de> Deserialize<'de> for Tolerance {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
Value::deserialize(d)?
.as_f64()
.filter(|t| t.is_finite() && (1e-4..=10.0).contains(t))
.map(Tolerance)
.ok_or_else(|| D::Error::custom("must be a number of mm from 0.0001 to 10"))
}
}
impl JsonSchema for Tolerance {
fn schema_name() -> Cow<'static, str> {
"EquationTolerance".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "How far (mm) the curve may stray from its formulas: 0.0001 to 10. Default 0.01.",
"type": "number",
"minimum": 0.0001,
"maximum": 10.0
})
}
}
/// An equation curve's id.
#[derive(Clone, Debug, PartialEq)]
pub struct CurveId(pub String);
impl<'de> Deserialize<'de> for CurveId {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
match Value::deserialize(d)? {
Value::String(s) => Ok(CurveId(s)),
_ => Err(D::Error::custom("must be an equation curve's id")),
}
}
}
impl JsonSchema for CurveId {
fn schema_name() -> Cow<'static, str> {
"EquationCurveId".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({"description": "An equation curve's id (a curve id of the sketch).", "type": "string"})
}
}
/// Draw a curve from formulas: x(t) and y(t) over a range of t, or y = f(x) over a range of x.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct EquationCurveParams {
/// x(t), a formula of t. Leave it out with `explicit: true`.
#[serde(default)]
pub x: Option<Formula>,
/// y(t), a formula of t; or f(x), a formula of x, with `explicit: true`. Required.
#[serde(default)]
pub y: Option<Formula>,
/// The explicit form, y = f(x): `y` is a formula of x and `x` is left out. Default false.
#[serde(default)]
pub explicit: Option<bool>,
/// Where the variable starts: a number, an angle ("360 deg" is 2·pi) or a length (in the
/// design's unit). Required.
#[serde(default)]
pub start: Option<Formula>,
/// Where the variable ends, as `start`. Required.
#[serde(default)]
pub end: Option<Formula>,
/// How far (mm) the curve may stray from its formulas. Default 0.01.
#[serde(default)]
pub tolerance: Option<Tolerance>,
/// Make it construction geometry. Default false.
#[serde(default)]
pub construction: Option<bool>,
/// The sketch (default: the one being edited).
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "SketchRef")]
pub sketch: Option<SketchRef>,
}
/// Change an equation curve's formulas, range or tolerance.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct EquationEditParams {
/// The equation curve. Required.
#[serde(default)]
pub curve: Option<CurveId>,
/// x(t). Kept when left out; `explicit: true` drops it.
#[serde(default)]
pub x: Option<Formula>,
/// y(t), or f(x) for an explicit curve. Kept when left out.
#[serde(default)]
pub y: Option<Formula>,
/// The explicit form, y = f(x). Kept when left out; false needs `x` (given or kept).
#[serde(default)]
pub explicit: Option<bool>,
/// Where the variable starts. Kept when left out.
#[serde(default)]
pub start: Option<Formula>,
/// Where the variable ends. Kept when left out.
#[serde(default)]
pub end: Option<Formula>,
/// How far (mm) the curve may stray from its formulas. Kept when left out.
#[serde(default)]
pub tolerance: Option<Tolerance>,
/// The sketch (default: the one being edited).
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "SketchRef")]
pub sketch: Option<SketchRef>,
}
/// What `sketch.equation_curve` returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct EquationCurveResult {
/// The new curve's id, as a list (as other drawing commands return it).
pub curves: Vec<String>,
/// The new curve's id.
pub curve: String,
/// How many points the spline goes through (a closed curve's start counted once).
pub points: usize,
/// The curve ends where it starts and closes smoothly there.
pub closed: bool,
/// The largest distance (mm) found between the spline and the formulas.
pub deviation: f64,
/// The sketch's solve: `{solved, dof}`.
pub sketch: Value,
/// What it added: `{points, curves, constraints, dimensions}`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created: Option<Value>,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
/// What `sketch.equation_curve.edit` returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct EquationEditResult {
/// The curve's id (unchanged).
pub curve: String,
/// How many points the spline now goes through (a closed curve's start counted once).
pub points: usize,
/// The curve ends where it starts and closes smoothly there.
pub closed: bool,
/// The largest distance (mm) found between the spline and the formulas.
pub deviation: f64,
/// The sketch's solve: `{solved, dof}`.
pub sketch: Value,
/// What the edit added (the new points in between): `{points, curves, constraints, dimensions}`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created: Option<Value>,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
#[cfg(test)]
#[path = "sketch_equation_tests.rs"]
mod tests;
@@ -0,0 +1,46 @@
use serde_json::json;
use super::{EquationCurveParams, EquationCurveResult, EquationEditParams, EquationEditResult};
use crate::Session;
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
#[test]
fn schemas_and_the_key_check_agree() {
assert_keys(input_schema_for::<EquationCurveParams>(), &["construction", "end", "explicit", "sketch", "start", "tolerance", "x", "y"]);
assert_keys(input_schema_for::<EquationEditParams>(), &["curve", "end", "explicit", "sketch", "start", "tolerance", "x", "y"]);
}
#[test]
fn strict_keeps_todays_errors() {
assert_strict_keeps_todays_errors(&[
("sketch.equation_curve", json!({"x": ["t"], "y": "t", "start": 0, "end": 1})),
("sketch.equation_curve", json!({"x": "t", "y": " ", "start": 0, "end": 1})),
("sketch.equation_curve", json!({"x": "t", "y": "t", "start": 0, "end": 1, "tolerance": 0})),
("sketch.equation_curve", json!({"x": "t", "y": "t", "start": 0, "end": 1, "tolerance": "big"})),
("sketch.equation_curve", json!({"x": "t", "y": "t", "start": 0, "end": 1, "explicit": 1})),
("sketch.equation_curve", json!({"x": "t", "y": "t", "start": 0, "end": 1e12})),
("sketch.equation_curve", json!({"y": "t", "start": 0, "end": 1})),
("sketch.equation_curve.edit", json!({"curve": 7})),
("sketch.equation_curve.edit", json!({"curve": "nope", "y": "t"})),
]);
}
#[test]
fn replies_match_their_schemas() {
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
let run = |s: &mut Session, id: &str, p: serde_json::Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
let created = output_schema_for::<EquationCurveResult>();
let created = created.as_value();
let r = run(&mut s, "sketch.equation_curve", json!({"x": "t * cos(t)", "y": "t * sin(t)", "start": 0, "end": "4 * pi"}));
assert!(conforms(created, created, &r), "{r} doesn't match {created}");
let id = r["curve"].as_str().unwrap_or_else(|| panic!("{r}")).to_string();
let r = run(&mut s, "sketch.equation_curve", json!({"explicit": true, "y": "x", "start": 0, "end": 1, "unknown": 1}));
assert!(conforms(created, created, &r), "{r} doesn't match {created}");
assert!(r["param_warnings"].as_array().is_some_and(|w| !w.is_empty()), "{r}");
let edited = output_schema_for::<EquationEditResult>();
let edited = edited.as_value();
let r = run(&mut s, "sketch.equation_curve.edit", json!({"curve": id, "end": "6 * pi"}));
assert!(conforms(edited, edited, &r), "{r} doesn't match {edited}");
}
@@ -39,6 +39,11 @@ pub struct RenderBrowser {
pub open: bool,
}
/// Empty input for the read-only viewport effect query.
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct StatusItemsParams {}
/// Show or hide the View angles readout under the orientation gizmo (#6032): the view's pitch, yaw
/// and roll in degrees (the angles of `view.apply`'s camera, which takes them in radians) and Ortho
/// or Persp for the projection. A display setting the app keeps: not saved with the design, not an
@@ -10,6 +10,54 @@ fn schemas_and_the_key_check_agree() {
assert_keys(input_schema_for::<AngleReadoutParams>(), &["enabled"]);
}
#[test]
fn status_items_has_strict_empty_input_and_a_typed_output_schema() {
use crate::status_items::StatusItems;
assert_keys(input_schema_for::<super::StatusItemsParams>(), &[]);
let schema = output_schema_for::<StatusItems>();
let schema = schema.as_value();
let mut s = Session::default();
let described = s.execute("engine.describe", &json!({"command": "view.status_items"})).unwrap();
assert_eq!(described["input"]["type"], "object");
assert_eq!(described["input"]["additionalProperties"], false);
assert_eq!(described["output"]["properties"]["items"]["type"], "array");
let spec = crate::find_command("view.status_items").unwrap();
assert!(!spec.undoable && !spec.reads_model);
for mode in [ParamCheck::Off, ParamCheck::Warn, ParamCheck::Strict] {
s.param_check = mode;
for input in [json!({}), Value::Null] {
let reply = s.execute("view.status_items", &input).unwrap();
assert!(conforms(schema, schema, &reply), "{reply} doesn't match {schema}");
}
for input in [json!({"extra": true}), json!({"clear": true}), json!([]), json!(false), json!(42), json!("status")] {
assert!(s.execute("view.status_items", &input).is_err(), "accepted {input} in {mode:?}");
}
}
for (command, params) in [
("inspect.zebra", json!({"stripes": 13.5})),
("inspect.draft", json!({"angle": 4.5})),
("inspect.environment_map", json!({})),
("inspect.accessibility", json!({})),
("inspect.curvature_map", json!({"radius": 2})),
("inspect.section", json!({"plane": "XY", "hatch": false})),
] {
s.execute(command, &params).unwrap();
let reply = s.execute("view.status_items", &json!({})).unwrap();
assert!(conforms(schema, schema, &reply), "{reply} doesn't match {schema}");
let typed: StatusItems = serde_json::from_value(reply.clone()).unwrap();
assert_eq!(serde_json::to_value(typed).unwrap(), reply);
}
s.set_single_surface_analysis(false);
s.execute("solid.box", &json!({"length":10,"width":10,"height":10})).unwrap();
s.execute("inspect.continuity", &json!({"edges":[[5,0,0]],"samples":5})).unwrap();
let reply = s.execute("view.status_items", &json!({})).unwrap();
assert!(conforms(schema, schema, &reply), "legacy reply {reply} doesn't match {schema}");
assert_eq!(reply["items"].as_array().unwrap().len(), 3);
assert!(reply["items"].as_array().unwrap().iter().any(|item| item["id"] == "continuity"));
let typed: StatusItems = serde_json::from_value(reply.clone()).unwrap();
assert_eq!(serde_json::to_value(typed).unwrap(), reply);
}
#[test]
fn strict_keeps_todays_errors() {
assert_strict_keeps_todays_errors(&[
+233
View File
@@ -0,0 +1,233 @@
//! A read-only declaration of persistent viewport effects. Settings remain in the session's
//! existing effect fields; only activation order is recorded separately, never in a design.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::{Session, SurfaceAnalysis};
/// Stable identity of an effect slot, retained when its analysis is replaced.
#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum StatusId {
SurfaceAnalysis,
/// Independent Continuity slot when the compatibility policy is enabled.
Continuity,
Section,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum StatusKind {
Mode,
Result,
Attention,
}
/// Structured settings, without unit formatting or translated text.
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
pub enum StatusSetting {
Zebra {
stripes: f64,
},
Draft {
/// Normalised pull direction.
pull: [f64; 3],
/// The stored Draft threshold is in degrees.
angle_deg: f64,
},
Environment,
Accessibility {
/// Normalised direction from which the tool approaches.
direction: [f64; 3],
},
Curvature {
radius_mm: f64,
},
Continuity {
/// Number of persistent seam records currently shaded.
edge_count: usize,
},
Section {
origin_mm: [f64; 3],
/// Normalised world-space plane normal.
normal: [f64; 3],
hatch: bool,
},
}
/// An executable request that ends this effect, through the same command as other clients.
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct StatusClear {
pub command: String,
pub params: StatusClearParams,
}
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct StatusClearParams {
pub clear: bool,
}
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct StatusItem {
pub id: StatusId,
pub kind: StatusKind,
/// Source command ID, whose catalog label consumers can translate.
pub command: String,
pub setting: StatusSetting,
pub has_key: bool,
pub clear: StatusClear,
}
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct StatusItems {
/// Oldest active slot first; replacing an analysis retains its position.
pub items: Vec<StatusItem>,
}
impl Session {
/// Whether shading and Continuity share one slot (the default). This is session
/// configuration, never design data; SDK clients may choose the legacy independent policy.
pub fn single_surface_analysis(&self) -> bool {
self.single_surface_analysis
}
/// Change the analysis policy without changing the design or undo history. Enabling a
/// single slot keeps the latest successful analysis activation/replacement and retains the
/// oldest analysis-group position relative to Section. Queries never perform this transition.
pub fn set_single_surface_analysis(&mut self, single: bool) {
if self.single_surface_analysis == single {
return;
}
let before = self.status_items();
if single && self.analysis.is_some() && !self.continuity.is_empty() {
if self.latest_analysis == Some(StatusId::Continuity) {
self.analysis = None;
} else {
self.continuity.clear();
}
}
self.single_surface_analysis = single;
self.status_order.clear();
for item in &before {
let id = match item.id {
StatusId::SurfaceAnalysis | StatusId::Continuity if single => StatusId::SurfaceAnalysis,
StatusId::SurfaceAnalysis if self.analysis.is_none() => StatusId::Continuity,
id => id,
};
let active = match id {
StatusId::SurfaceAnalysis => self.analysis.is_some() || (single && !self.continuity.is_empty()),
StatusId::Continuity => !self.continuity.is_empty(),
StatusId::Section => self.section.is_some(),
};
self.status_slot(id, active);
}
if self.status_items() != before {
self.revision += 1;
}
}
/// After a successful activation, or a clear (`None`), reconcile only chronology and the
/// explicitly configured sharing policy. No duplicate effect settings are kept here.
pub(crate) fn status_analysis_changed(&mut self, activated: Option<StatusId>) {
if let Some(source) = activated
&& self.single_surface_analysis
{
match source {
StatusId::SurfaceAnalysis => self.continuity.clear(),
StatusId::Continuity => self.analysis = None,
StatusId::Section => {}
}
}
// A successful Continuity measurement may find only open boundaries and no seams.
// An ended/empty effect cannot become the winning source on the next policy change.
let shading = self.analysis.is_some();
let continuity = !self.continuity.is_empty();
let active = |source: &StatusId| match source {
StatusId::SurfaceAnalysis => shading,
StatusId::Continuity => continuity,
StatusId::Section => false,
};
let remaining = if shading {
Some(StatusId::SurfaceAnalysis)
} else if continuity {
Some(StatusId::Continuity)
} else {
None
};
self.latest_analysis = activated.filter(active).or(self.latest_analysis.filter(active)).or(remaining);
self.status_slot(StatusId::SurfaceAnalysis, self.analysis.is_some() || (self.single_surface_analysis && !self.continuity.is_empty()));
self.status_slot(StatusId::Continuity, !self.single_surface_analysis && !self.continuity.is_empty());
}
/// Current effects, derived without evaluating the model or changing any session state.
pub fn status_items(&self) -> Vec<StatusItem> {
let analysis = self
.analysis
.map(|a| match a {
SurfaceAnalysis::Zebra { stripes } => ("inspect.zebra", StatusSetting::Zebra { stripes }, false),
SurfaceAnalysis::Draft { pull, angle } => ("inspect.draft", StatusSetting::Draft { pull: pull.into(), angle_deg: angle }, true),
SurfaceAnalysis::Environment => ("inspect.environment_map", StatusSetting::Environment, false),
SurfaceAnalysis::Access { dir } => ("inspect.accessibility", StatusSetting::Accessibility { direction: dir.into() }, true),
SurfaceAnalysis::Curvature { radius } => ("inspect.curvature_map", StatusSetting::Curvature { radius_mm: radius }, true),
})
.or_else(|| {
(self.single_surface_analysis && !self.continuity.is_empty()).then_some((
"inspect.continuity",
StatusSetting::Continuity { edge_count: self.continuity.len() },
false,
))
});
let analysis = analysis.map(|(command, setting, has_key)| item(StatusId::SurfaceAnalysis, command, setting, has_key));
let continuity = (!self.single_surface_analysis && !self.continuity.is_empty())
.then(|| item(StatusId::Continuity, "inspect.continuity", StatusSetting::Continuity { edge_count: self.continuity.len() }, false));
let section = self.section.map(|(origin, normal)| {
item(
StatusId::Section,
"inspect.section",
StatusSetting::Section { origin_mm: origin.into(), normal: normal.into(), hatch: self.section_hatch },
false,
)
});
// Public effect fields can also be populated by embedders without command chronology.
// Include those effects after recorded slots, without inventing or storing timestamps.
let mut items = Vec::new();
for id in self.status_order.iter().chain([StatusId::SurfaceAnalysis, StatusId::Continuity, StatusId::Section].iter()) {
let effect = match id {
StatusId::SurfaceAnalysis => &analysis,
StatusId::Continuity => &continuity,
StatusId::Section => &section,
};
if let Some(effect) = effect
&& !items.iter().any(|item: &StatusItem| item.id == *id)
{
items.push(effect.clone());
}
}
items
}
pub(crate) fn status_slot(&mut self, id: StatusId, active: bool) {
if !active {
self.status_order.retain(|slot| *slot != id);
} else if !self.status_order.contains(&id) {
self.status_order.push(id);
}
}
}
fn item(id: StatusId, command: &str, setting: StatusSetting, has_key: bool) -> StatusItem {
StatusItem {
id,
kind: StatusKind::Mode,
command: command.into(),
setting,
has_key,
clear: StatusClear { command: command.into(), params: StatusClearParams { clear: true } },
}
}
+3 -3
View File
@@ -87,7 +87,7 @@ mod tests {
{"command": "solid.box", "params": {"length": 4, "width": 4, "height": 30, "operation": "join"}},
]});
let mut s = crate::Session::default();
let (results, t) = s.run_script_timed(&script, true).unwrap();
let (results, t) = s.run_script_timed(&script, true, &mut |_, _| {}).unwrap();
assert_eq!(results.len(), 2);
let rows = t["timings"].as_array().unwrap();
assert_eq!(rows.len(), 2);
@@ -100,12 +100,12 @@ mod tests {
let total = t["total_ms"].as_f64().unwrap();
assert!(total + 0.01 >= rows.iter().map(|r| r["ms"].as_f64().unwrap()).sum::<f64>(), "{t}");
// Without profile: the same rows, no kernel split; the plain run is unchanged.
let (_, t) = crate::Session::default().run_script_timed(&script, false).unwrap();
let (_, t) = crate::Session::default().run_script_timed(&script, false, &mut |_, _| {}).unwrap();
assert!(t["timings"][0].get("profile").is_none());
assert_eq!(crate::Session::default().run_script(&script).unwrap().len(), 2);
// A failing step still fails the run, with its step number.
let bad = json!([{"command": "solid.box", "params": {"length": -1}}]);
let e = crate::Session::default().run_script_timed(&bad, true).unwrap_err().to_string();
let e = crate::Session::default().run_script_timed(&bad, true, &mut |_, _| {}).unwrap_err().to_string();
assert!(e.contains("step 1"), "{e}");
}
}
+36
View File
@@ -29,6 +29,42 @@ fn angular_drag_keeps_the_horizontal_construction_line() {
}
}
/// A line held at its midpoint, with an angle to a horizontal line from that point (#8944):
/// dragging one of its ends off that angle slides the end along the line as far as it can go,
/// and the horizontal line keeps its length. That line's far end is free only along it, which
/// can't bring the cursor closer, yet the cursor correction moved it a whole step at a time on
/// rounding: the line shrank, for the first target to nothing.
#[test]
fn a_midpoint_line_dragged_off_its_angle_leaves_the_horizontal_line_alone() {
for live in [false, true] {
for (end, to, reached) in
[("l1.end", [14.0, 3.0], [11.617647, 6.970588]), ("l1.end", [5.0, 12.0], [8.970588, 5.382353]), ("l1.start", [-3.0, -12.0], [-7.5, -4.5])]
{
let mut s = solidcraft_engine::Session::default();
for (id, params) in [
("sketch.create", json!({"plane":"XY"})),
("sketch.line", json!({"points":[[-10,-6],[10,6]],"infer":false})),
("sketch.constraint.midpoint", json!({"line":"l1","point":"origin"})),
("sketch.line", json!({"points":["origin",[15,0]],"infer":false})),
("sketch.constraint.horizontal_vertical", json!({"line":"l2"})),
("sketch.dimension", json!({"entities":["l1","l2"],"type":"angle"})),
] {
s.execute(id, &params).unwrap();
}
let from = four_bar::point(&s, end).unwrap();
s.execute("sketch.drag", &json!({"entity":end,"from":[from.x,from.y],"to":to,"live":live})).unwrap();
if live {
s.execute("sketch.drag_end", &json!({})).unwrap();
}
let point = |id| four_bar::point(&s, id).unwrap();
let at = point(end);
assert!(at.dist(Vec2::new(reached[0], reached[1])) < 1e-4, "{end} to {to:?} (live {live}) went to {at:?}");
let far = point("l2.end");
assert!(far.dist(Vec2::new(15.0, 0.0)) < 1e-6, "{end} to {to:?} (live {live}) moved l2's end to {far:?}");
}
}
}
fn drag(s: &mut solidcraft_engine::Session, target: Vec2) {
let from = four_bar::point(s, "crank.end").unwrap();
s.execute("sketch.drag", &json!({"entity":"crank.end","from":[from.x,from.y],"to":[target.x,target.y],"live":true})).unwrap();
+5 -1
View File
@@ -231,7 +231,11 @@ fn move_cursor_towards(
}
let next = position(&trial);
let cost = (target - next).dot(target - next);
if !cost.is_finite() || cost >= old_cost - 1e-14 * old_cost.max(1.0) {
// The cursor itself must move. Retracting onto the constraints moves it by
// rounding even along freedom that can't reach it (the far end of a line held only
// by its direction): taking such a step for the rounding moved that end a whole
// step, and step after step it walked away or collapsed its line to a point (#8944).
if !cost.is_finite() || cost >= old_cost - 1e-14 * old_cost.max(1.0) || next.dist(at) < trial_tol {
continue;
}
if !within_budget(&mut budget, setup_work.saturating_mul(4)) {
+188
View File
@@ -0,0 +1,188 @@
//! Equation curves (#8352): a fit point spline whose points come from formulas. The sketch keeps
//! the formulas beside the spline; the design's parameters are worked out in the document layer
//! (`solidcraft_doc::sketch_equation`), which places the points again before every solve. The
//! points are fixed for the solver, so dragging never bends the curve, while other geometry can
//! still share its end points.
use serde::{Deserialize, Serialize};
use solidcraft_geom::Vec2;
use crate::model::{CurveKind, Result, Sketch, SketchError};
/// Longest formula or range expression an equation curve takes (characters).
pub const MAX_EQUATION_TEXT: usize = 1024;
/// A curve drawn from formulas: x(t) and y(t) over t from `start` to `end`, or (with `x` left
/// out) the explicit y = f(x) over x from `start` to `end`. The formulas use the design's
/// expression language and parameters; the variable is a plain number, so `sin(t)` reads it in
/// radians, and a result without a unit is in the design's length unit.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct EquationCurve {
/// The fit point spline it places (a curve id of the sketch).
pub curve: String,
/// x(t). `None`: the explicit form, where the variable is x itself.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub x: Option<String>,
/// y(t), or f(x) in the explicit form.
pub y: String,
/// Where the variable starts and ends.
pub start: String,
pub end: String,
/// How far (mm) the spline may stray from the formulas: the number of points was chosen for
/// it when the curve was made or last edited.
pub tolerance: f64,
}
impl EquationCurve {
/// The name the formulas use for the curve's variable.
pub fn variable(&self) -> &'static str {
if self.x.is_some() { "t" } else { "x" }
}
/// The formulas, each with what it sets (for the parameters a sketch uses).
pub fn list(&self) -> Vec<(&'static str, &String)> {
let mut out = Vec::with_capacity(4);
if let Some(x) = &self.x {
out.push(("Curve X", x));
}
out.extend([("Curve Y", &self.y), ("Curve Start", &self.start), ("Curve End", &self.end)]);
out
}
/// Mutable view of [`EquationCurve::list`], for renaming the parameters they refer to.
pub fn list_mut(&mut self) -> Vec<&mut String> {
let mut out: Vec<&mut String> = self.x.iter_mut().collect();
out.extend([&mut self.y, &mut self.start, &mut self.end]);
out
}
}
impl Sketch {
/// The equation behind a curve, when it is an equation curve.
pub fn equation_of(&self, curve: &str) -> Option<&EquationCurve> {
self.equations.iter().find(|e| e.curve == curve)
}
/// Add an equation curve: a cubic fit spline through `at` (fixed points), closed smoothly
/// when `closed` (then the last position is the first one again and is not repeated), with
/// `equation` kept beside it. Returns the curve's index.
pub fn add_equation_curve(&mut self, at: &[Vec2], closed: bool, mut equation: EquationCurve) -> Result<usize> {
let pts = self.equation_points(at, closed, None)?;
let curve = self.add_curve(CurveKind::Spline { pts, control: false, degree: 3, periodic: closed }, None)?;
equation.curve = self.curves.get(curve).map(|c| c.id.clone()).ok_or_else(|| SketchError::Invalid("the new curve is missing".into()))?;
self.equations.push(equation);
Ok(curve)
}
/// Give an equation curve new points (its formulas were edited): the start point (and the
/// end point of an open curve) keep their ids, so geometry joined there stays joined; the
/// points in between are made again, and the old ones go with whatever referred to them.
pub fn replace_equation_curve(&mut self, curve: usize, at: &[Vec2], closed: bool, equation: EquationCurve) -> Result<()> {
let Some(CurveKind::Spline { pts: old, control: false, .. }) = self.curves.get(curve).map(|c| c.kind.clone()) else {
return Err(SketchError::Invalid("an equation curve must be a fit point spline".into()));
};
let (Some(first), Some(last)) = (old.first().copied(), old.last().copied()) else {
return Err(SketchError::Invalid("the equation curve has no points".into()));
};
let id = self.curves.get(curve).map(|c| c.id.clone()).ok_or_else(|| SketchError::Invalid("unknown curve".into()))?;
let keep_end = (last != first).then_some(last);
let pts = self.equation_points(at, closed, Some((first, keep_end)))?;
let unused: Vec<usize> = old.iter().copied().filter(|p| !pts.contains(p)).collect();
if let Some(c) = self.curves.get_mut(curve) {
c.kind = CurveKind::Spline { pts, control: false, degree: 3, periodic: closed };
}
// The old points no other curve uses (the in-between points, and an end a closed curve
// no longer has) go, with the constraints on them.
let mut gone: Vec<usize> = unused.into_iter().filter(|p| *p != 0 && !self.curves.iter().any(|c| c.kind.uses(*p))).collect();
gone.sort_unstable();
gone.dedup();
self.remove_points(&gone);
match self.equations.iter_mut().find(|e| e.curve == id) {
Some(e) => *e = EquationCurve { curve: id, ..equation },
None => self.equations.push(EquationCurve { curve: id, ..equation }),
}
Ok(())
}
/// The point indices of an equation curve through `at`: new fixed points, or (`reuse`) the
/// given start point and, for an open curve, end point moved to where the curve now starts
/// and ends.
fn equation_points(&mut self, at: &[Vec2], closed: bool, reuse: Option<(usize, Option<usize>)>) -> Result<Vec<usize>> {
let wanted = if closed { 3 } else { 2 };
if at.len() < wanted || at.len() > crate::curves::MAX_SPLINE_POINTS {
return Err(SketchError::Invalid(format!("an equation curve needs {wanted} to {} points", crate::curves::MAX_SPLINE_POINTS)));
}
if self.points.len().saturating_add(at.len()) > crate::model::MAX_POINTS {
return Err(SketchError::TooLarge);
}
let place = |sk: &mut Sketch, existing: Option<usize>, p: Vec2| -> Result<usize> {
let i = match existing {
Some(i) => {
let q = sk.points.get_mut(i).ok_or_else(|| SketchError::Invalid("missing equation curve point".into()))?;
q.pos = Sketch::check_pos(p)?;
i
}
None => sk.add_point(p, None)?,
};
if let Some(q) = sk.points.get_mut(i) {
q.fixed = true;
}
Ok(i)
};
let (first_at, rest) = at.split_first().ok_or_else(|| SketchError::Invalid("no points".into()))?;
let first = place(self, reuse.map(|r| r.0), *first_at)?;
let mut pts = vec![first];
let (middle, end_at) = if closed {
// The last position is the first one again: the curve closes on its first point.
(rest.split_last().map(|(_, m)| m).unwrap_or(&[]), None)
} else {
let (end_at, middle) = rest.split_last().ok_or_else(|| SketchError::Invalid("no end point".into()))?;
(middle, Some(*end_at))
};
for p in middle {
pts.push(place(self, None, *p)?);
}
match end_at {
Some(p) => pts.push(place(self, reuse.and_then(|r| r.1), p)?),
None => pts.push(first),
}
Ok(pts)
}
/// Move an equation curve's points to `at` (one position per point of the spline, in order;
/// a closed curve's last point is its first one, and its position is skipped). Every point
/// is fixed. Used each time the formulas are worked out again.
pub fn place_equation_points(&mut self, curve: usize, at: &[Vec2]) -> Result<()> {
let Some(CurveKind::Spline { pts, control: false, .. }) = self.curves.get(curve).map(|c| &c.kind) else {
return Err(SketchError::Invalid("an equation curve must be a fit point spline".into()));
};
if pts.len() != at.len() {
return Err(SketchError::Invalid("the equation curve's point count changed".into()));
}
let moves: Vec<(usize, Vec2)> = pts.iter().copied().zip(at.iter().copied()).collect();
for (i, p) in moves {
let p = Self::check_pos(p)?;
let q = self.points.get_mut(i).ok_or_else(|| SketchError::Invalid("missing equation curve point".into()))?;
q.pos = p;
q.fixed = true;
}
Ok(())
}
/// The equation curves' formulas, each with the curve's variable: a parameter of that name
/// is hidden from the formulas by it, so a rename of it must not touch them.
pub fn equation_exprs_mut(&mut self) -> Vec<(&'static str, &mut String)> {
self.equations
.iter_mut()
.flat_map(|e| {
let variable = e.variable();
e.list_mut().into_iter().map(move |s| (variable, s))
})
.collect()
}
/// Forget the equations of removed curves.
pub(crate) fn prune_equations(&mut self, removed: &std::collections::BTreeSet<String>) {
self.equations.retain(|e| !removed.contains(&e.curve));
}
}
+2
View File
@@ -21,6 +21,7 @@ mod curves;
mod decompose;
mod dims;
mod ends;
mod equation;
mod fonts;
mod freeform_offset;
pub mod linalg;
@@ -51,6 +52,7 @@ pub use dims::{
DimFrame, DimLayout, chain_centres, default_text, default_text_offset, dim_frame, dim_layout, dim_layout_reaching, dim_reach, encode_text,
};
pub use ends::{END_TOL, EndReport, EndTouch, UntiedEnd, loose_ends};
pub use equation::{EquationCurve, MAX_EQUATION_TEXT};
pub use fonts::{DEFAULT_FONT_FAMILY, FontData, families_in, find_style_in, font_data, font_data_styled, installed_families, set_font_file_check};
pub use freeform_offset::{FreeformOffset, FreeformOffsetOutput, OFFSET_TOLERANCE};
pub use link::{Link, LinkGeom, LinkKind, LinkSource, MAX_LINK_ENTITIES, SKETCH_AXIS_HALF, TextExprs, sketch_axis_geom};
+7 -3
View File
@@ -551,8 +551,9 @@ impl Sketch {
self.links.iter().find(|l| l.id == id)
}
/// The expressions of the sketch's texts and of its points placed by parameters, each with
/// what it sets (`Text height`, `Point X`…).
/// The expressions of the sketch's texts, of its points placed by parameters and of its
/// equation curves, each with what it sets (`Text height`, `Point X`, `Curve Y`…). An equation
/// curve's own variable (`t` or `x`) is among the names its formulas refer to.
pub fn text_exprs(&self) -> Vec<(&'static str, &String)> {
self.links
.iter()
@@ -562,6 +563,7 @@ impl Sketch {
})
.flatten()
.chain(self.points.iter().filter_map(|p| p.exprs.as_deref()).flat_map(crate::PointExprs::list))
.chain(self.equations.iter().flat_map(crate::EquationCurve::list))
.collect()
}
@@ -587,7 +589,9 @@ impl Sketch {
.collect()
}
/// Mutable view of [`Sketch::text_exprs`], for renaming the parameters they refer to.
/// Mutable view of [`Sketch::text_exprs`], for renaming the parameters they refer to. An
/// equation curve's formulas are not in it: [`Sketch::equation_exprs_mut`] gives them with the
/// variable a rename must leave alone.
pub fn text_exprs_mut(&mut self) -> Vec<&mut String> {
self.links
.iter_mut()
+7 -1
View File
@@ -730,6 +730,9 @@ pub struct Sketch {
/// 3D curves (world coordinates) carried by the sketch.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub wires: Vec<crate::wire::Wire>,
/// Curves drawn from formulas (#8352): the fit point splines they place, with their formulas.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub equations: Vec<crate::equation::EquationCurve>,
/// Next number per id prefix (`l` → l1, l2…).
#[serde(default)]
counters: std::collections::BTreeMap<String, u64>,
@@ -759,6 +762,7 @@ impl Sketch {
offsets: Vec::new(),
freeform_offsets: Vec::new(),
wires: Vec::new(),
equations: Vec::new(),
counters: Default::default(),
view: SketchView::default(),
}
@@ -832,7 +836,7 @@ impl Sketch {
self.points.get(i).map(|p| p.pos)
}
fn check_pos(p: Vec2) -> Result<Vec2> {
pub(crate) fn check_pos(p: Vec2) -> Result<Vec2> {
if in_range(p) { Ok(p) } else { Err(SketchError::Invalid(format!("point {:?}", [p.x, p.y]))) }
}
@@ -1724,6 +1728,8 @@ impl Sketch {
/// Remove curves only (points stay), remapping constraints.
fn drop_curves(&mut self, which: &[usize]) {
self.prune_offsets_for_removed_curves(which);
let removed: std::collections::BTreeSet<String> = which.iter().filter_map(|i| self.curves.get(*i).map(|c| c.id.clone())).collect();
self.prune_equations(&removed);
let mut cmap = vec![None; self.curves.len()];
let mut n = 0;
for (i, slot) in cmap.iter_mut().enumerate() {
+33 -1
View File
@@ -428,6 +428,38 @@ fn close_graph_joints(mut segs: Vec<Seg2>, edge_nodes: &[(usize, usize)], edge_a
segs
}
/// Close the joints a line leaves open: the graph joins ends within `tol` into one node, but a
/// free-form curve's polyline pieces keep their own ends, so two near-coincident curves can leave
/// a loop open by more than the closing tolerance. A line meeting a line moves both ends to their
/// middle; a line meeting another segment moves its end onto that segment's.
fn close_line_joints(mut segs: Vec<Seg2>, tol: f64) -> Vec<Seg2> {
let n = segs.len();
for i in 0..n {
let j = (i + 1) % n;
let (Some(a), Some(b)) = (segs.get(i).copied(), segs.get(j).copied()) else { continue };
let gap = a.end().dist(b.start());
if gap <= 1e-12 || gap > tol {
continue;
}
let (a, b) = match (a, b) {
(Seg2::Line { .. }, Seg2::Line { .. }) => {
let p = (a.end() + b.start()) * 0.5;
(with_end(a, p), set_start(b, p))
}
(Seg2::Line { .. }, _) => (with_end(a, b.start()), b),
(_, Seg2::Line { .. }) => (a, set_start(b, a.end())),
_ => continue,
};
if let Some(slot) = segs.get_mut(i) {
*slot = a;
}
if let Some(slot) = segs.get_mut(j) {
*slot = b;
}
}
segs
}
fn merge_nearby_endpoints(sk: &Sketch, endpoints: &[(usize, usize)], groups: &mut [usize], max_edges: usize) {
let tol = ENDPOINT_JOIN_TOL;
let cell = |p: Vec2| ((p.x / tol).floor() as i64, (p.y / tol).floor() as i64);
@@ -1178,7 +1210,7 @@ fn find_profiles_bounded(sk: &Sketch, max_edges: usize) -> Vec<Profile> {
continue;
}
let segs = close_graph_joints(segs, &edge_nodes, &edge_analytic, ENDPOINT_JOIN_TOL);
let lp = Loop2 { segs: restore_exact(segs, &ptags, &exact) };
let lp = Loop2 { segs: close_line_joints(restore_exact(segs, &ptags, &exact), ENDPOINT_JOIN_TOL) };
if lp.signed_area() > 1e-9 {
let c = edges.get(start / 2).map(|e| find(&mut comp, e.u)).unwrap_or(0);
loops.push((lp, ids, c, whole_graph_complete.then(|| complete_loop(&coverage_tags, &expected, sk)).flatten()));
+40
View File
@@ -1398,3 +1398,43 @@ fn a_fillet_folded_through_flat_is_refused_like_a_fold() {
}
assert!(refused.is_some_and(|s| (7..=9).contains(&s)), "refused at {refused:?}");
}
/// #8639: two rounded rectangles drawn as closed control-point splines with the same straight
/// sides and different corners (as a DXF can carry an outline twice). Where the sides lie on each
/// other the two curves' sampled pieces end a few millionths apart; every profile must still close
/// within the 1e-6 a solid needs, or the region around them can't be extruded or shown.
#[test]
fn rounded_rectangles_sharing_their_sides_leave_every_profile_closed() {
// Control points of a rounded rectangle 17.6 by 17.6, corner `r`, sides split in thirds.
fn rounded(sk: &mut Sketch, r: f64) {
let (lo, hi) = (0.0, 17.6);
let k = r * 0.448;
let mut pts = Vec::new();
for (cx, cy, dx, dy) in [(hi, hi, 1.0, 1.0), (hi, lo, 1.0, -1.0), (lo, lo, -1.0, -1.0), (lo, hi, -1.0, 1.0)] {
// Into the corner along the side, round it, out along the next side (clockwise).
let (a, b) = if dx * dy > 0.0 { ((cx - dx * r, cy), (cx, cy - dy * r)) } else { ((cx, cy - dy * r), (cx - dx * r, cy)) };
let (ka, kb) = if dx * dy > 0.0 { ((cx - dx * k, cy), (cx, cy - dy * k)) } else { ((cx, cy - dy * k), (cx - dx * k, cy)) };
pts.extend([v(a.0, a.1), v(ka.0, ka.1), v(kb.0, kb.1), v(b.0, b.1)]);
// Two more points along the next side, so it is straight.
let (nx, ny) = if dx * dy > 0.0 { (cx, cy - dy * (hi - r)) } else { (cx - dx * (hi - r), cy) };
let t = |f: f64| v(b.0 + (nx - b.0) * f, b.1 + (ny - b.1) * f);
pts.extend([t(1.0 / 3.0), t(2.0 / 3.0)]);
}
let mut ids: Vec<usize> = pts.iter().map(|p| sk.add_point(*p, None).unwrap()).collect();
ids.push(ids[0]);
sk.add_curve(CurveKind::Spline { pts: ids, control: true, degree: 3, periodic: false }, None).unwrap();
}
let mut sk = Sketch::new();
rounded(&mut sk, 0.55);
rounded(&mut sk, 0.8);
sk.add_circle(v(8.8, 8.8), 10.0, None, None).unwrap();
let ps = find_profiles(&sk);
assert!(!ps.is_empty());
for p in &ps {
for lp in std::iter::once(&p.region.outer).chain(&p.region.holes) {
let n = lp.segs.len();
let gap = (0..n).map(|i| lp.segs[i].end().dist(lp.segs[(i + 1) % n].start())).fold(0.0, f64::max);
assert!(lp.is_closed(1e-6), "a loop of {:?} is open by {gap:.3e}", crate::distinct_curves(&p.outer_curves));
}
}
}
+2 -2
View File
@@ -214,7 +214,7 @@ fn a_messages_changes_undo_as_one_step() {
#[test]
fn file_actions_wait_for_the_user() {
let (mut app, tx) = app_in_a_turn();
let dir = std::env::temp_dir().join(format!("sc-agent-chat-approve-{}", std::process::id()));
let dir = solidcraft_engine::io::vfs::temp_dir().join(format!("sc-agent-chat-approve-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("a.solidcraft");
let save = json!({"command": "file.save", "params": {"path": path.to_string_lossy()}, "allow_paths": [dir.to_string_lossy()]});
@@ -466,7 +466,7 @@ fn permission_modes_decide_what_asks() {
decline_all(&mut app, "test");
// Allow everything, set for this agent only.
app.preferences.agent_permissions.insert("codex".into(), agents::Mode::All);
let dir = std::env::temp_dir().join(format!("sc-agent-chat-all-{}", std::process::id()));
let dir = solidcraft_engine::io::vfs::temp_dir().join(format!("sc-agent-chat-all-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("b.solidcraft");
let save = ask(
+53 -5
View File
@@ -71,11 +71,12 @@ pub(crate) fn reveal_in_history(app: &mut SolveApp, id: u64) {
mod model_tests;
use std::collections::BTreeSet;
use std::sync::{Arc, Weak};
use egui::{Color32, FontId, Pos2, Rect, Sense, Stroke, pos2, vec2};
use serde_json::{Value, json};
use solidcraft_engine::Sel;
use solidcraft_engine::doc::FeatureKind;
use solidcraft_engine::doc::{Document, FeatureKind};
use crate::context_menu::{Item, Target};
use crate::theme::Tokens;
@@ -120,6 +121,8 @@ pub struct TreeState {
pub redefine_draft: Option<crate::sketch_attachment_ui::Draft>,
/// Components picked in the tree (they have no viewport selection).
pub picked_components: Vec<u64>,
/// The last folder label clicked, for V; independent of the geometry selection.
picked_folder: Option<FolderPick>,
/// Canvases picked in the tree.
pub picked_canvases: Vec<u64>,
/// Joints picked in the tree (a hidden one shows highlighted while it is picked).
@@ -165,7 +168,23 @@ pub struct TreeState {
cache: Option<cache::Cache>,
}
struct FolderPick {
key: String,
session: u64,
doc: Weak<Document>,
}
impl FolderPick {
fn current(&self, app: &SolveApp) -> bool {
self.session == app.session.uid && std::ptr::eq(self.doc.as_ptr(), Arc::as_ptr(&app.session.doc))
}
}
impl TreeState {
pub(crate) fn clear_folder_pick(&mut self) {
self.picked_folder = None;
}
fn remember(&mut self, key: &str) {
if let Some(slot) = self.order.get_mut(self.order_len) {
if slot != key {
@@ -406,6 +425,27 @@ pub fn toggle_folder(app: &mut SolveApp, key: &str) {
let _ = app.run("browser.visibility", json!({ "tree_folders": [key], "visible": app.session.folder_hidden(key) }));
}
/// A folder shortcut target lasts until another click, Escape, or a change of design/model.
/// Visibility changes keep the document snapshot, so V can toggle the same folder again.
pub(crate) fn update_folder_pick(app: &mut SolveApp, ctx: &egui::Context) {
if ctx.input(|i| i.pointer.any_pressed() || i.key_pressed(egui::Key::Escape))
|| app.tree.picked_folder.as_ref().is_some_and(|pick| !pick.current(app))
{
app.tree.clear_folder_pick();
}
}
fn pick_folder(app: &mut SolveApp, key: &str) {
app.tree.picked_folder = Some(FolderPick { key: key.into(), session: app.session.uid, doc: Arc::downgrade(&app.session.doc) });
}
pub(crate) fn toggle_picked_folder(app: &mut SolveApp) -> bool {
let Some(pick) = app.tree.picked_folder.as_ref().filter(|pick| pick.current(app)) else { return false };
let key = pick.key.clone();
toggle_folder(app, &key);
true
}
/// Selected browser items of the folder of a body or sketch, when more than one (for Group
/// Selected in their menus).
pub fn selection_group(app: &SolveApp, target: &Target) -> Option<Value> {
@@ -1509,6 +1549,8 @@ fn occurrence_rows_inner(
let own_visible = !on_screen || !app.session.occurrence_hidden(path);
let selected = on_screen && app.session.selection.iter().any(|s| matches!(s, Sel::Occurrence { path: selected } if selected == path));
let faded = app.session.active_occurrence_path.as_ref().is_some_and(|p| !path.starts_with(p) && !p.starts_with(path));
// A cut component has a dashed outline until Paste moves it.
let cut = app.cut.placements.iter().any(|p| p.as_slice() == path);
let badges: &[&str] = if placement.grounded { &["pin"] } else { &[] };
app.tree.remember(key);
let appearance = if crate::render_workspace::active(app) {
@@ -1531,8 +1573,8 @@ fn occurrence_rows_inner(
count: &appearance.count,
name: true,
selected,
// A cut component is faded until Paste moves it.
dim: faded || !visible || app.cut_placements.iter().any(|p| p.as_slice() == path),
dim: faded || !visible,
cut,
radio: Some(active),
badges,
mark: acts.lit.placement(&app.session.doc, path, placement.component),
@@ -2438,6 +2480,7 @@ fn folder_rows_at(
// still folds it. With no such command, the label folds, as it always has.
let names: Vec<String> = data.entries().flat_map(|e| e.bodies.clone()).collect();
if !offer_bodies(app, &names, Take::Toggle) {
pick_folder(app, key);
toggle(app, key, default_open);
}
}
@@ -2504,6 +2547,7 @@ fn folder_rows_at(
} else if r.clicked {
let names: Vec<String> = items.iter().flat_map(|e| e.bodies.clone()).collect();
if !offer_bodies(app, &names, Take::Toggle) {
pick_folder(app, gkey);
toggle(app, gkey, true);
}
}
@@ -2605,6 +2649,10 @@ fn entry_row(
Some(Sel::Body { name }) => app.session.visibility.locked_bodies.contains(name),
_ => e.locked,
};
// What is cut (a body, a sketch, or a body made by a cut feature) has a dashed
// outline until Paste moves it.
let cut = (folder == "bodies" && crate::edit_clipboard::body_is_cut(app, &e.key))
|| matches!(&e.sel, Some(Sel::Feature { id }) if app.cut.features.contains(id));
let badges: &[&str] = if locked { &["lock"] } else { &[] };
// Cached with the evaluated model; the preference is read as the row is drawn.
let sketch_state = e.sketch_state.filter(|_| app.preferences.sketch_state_icons);
@@ -2621,8 +2669,8 @@ fn entry_row(
label: &e.label,
name: true,
selected,
// A cut body is faded until Paste moves it.
dim: !e.visible || parent_hidden || (folder == "bodies" && app.cut_bodies.contains(&e.key)),
dim: !e.visible || parent_hidden,
cut,
color: e.color,
swatch: appearance.swatch,
secondary: &appearance.name,
+3
View File
@@ -225,6 +225,8 @@ fn feature_row(
let eye = (is_sketch || !bodies.is_empty()).then_some(visible);
let status = crate::timeline::feature_status(app, id);
let tip = status.and_then(|_| crate::timeline::feature_message(app, id));
// A cut feature has a dashed outline until Paste moves it.
let cut = app.cut.features.contains(&id);
let r = draw_row(
ui,
ui.id().with(("browser_feature", id)),
@@ -236,6 +238,7 @@ fn feature_row(
label: &label,
selected,
dim: dim || (!is_sketch && eye == Some(false)),
cut,
action,
mark,
status,
+99
View File
@@ -2,6 +2,105 @@ use super::*;
use crate::scenario::{Harness, handle_at, rect_of};
use crate::shell::{LayoutMode, SHELL_GAP};
#[test]
fn clicked_browser_folders_toggle_visibility_with_v() {
for mode in [LayoutMode::Floating, LayoutMode::Docked] {
let mut h = Harness::new();
h.app.ui.layout = mode;
for (name, x) in [("One", 0), ("Two", 20)] {
h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"corner":[x,0,0],"operation":"new_body","body_name":name})).unwrap();
}
let group =
h.app.run("browser.group", json!({"component":0,"folder":"bodies","items":["One"],"name":"Group"})).unwrap()["group"].as_u64().unwrap();
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
h.app.run("sketch.circle.center", json!({"center":[0,0],"radius":5})).unwrap();
h.app.run("sketch.finish", json!({})).unwrap();
h.app.run("construct.plane.offset", json!({"base":"XY","offset":20})).unwrap();
h.app.run("browser.visibility", json!({"items":["Two"],"visible":false})).unwrap();
h.settle();
for (label, key) in [
("Bodies", "c0/bodies".into()),
("Sketches", "c0/sketches".into()),
("Construction", "c0/construction".into()),
("Group (1)", format!("c0/g{group}")),
] {
h.app.run("select.set", json!({"items":[Sel::Body {name:"One".into()}]})).unwrap();
let before = h.app.session.visibility.clone();
let mut hidden = before.clone();
hidden.hidden_folders.push(key.clone());
h.step(&json!({"click":{"handle":format!("row:{label}")}})).unwrap();
h.step(&json!({"key":"V"})).unwrap();
assert_eq!(h.app.session.visibility, hidden, "{mode:?}: V hides {label}, leaving individual items alone");
h.step(&json!({"key":"V"})).unwrap();
assert_eq!(h.app.session.visibility, before, "V shows the same folder again");
h.app.run("edit.undo", json!({})).unwrap();
assert_eq!(h.app.session.visibility, hidden);
h.app.run("edit.redo", json!({})).unwrap();
assert_eq!(h.app.session.visibility, before);
// Restore expansion so the later group row is still reachable.
h.step(&json!({"click":{"handle":format!("row:{label}")}})).unwrap();
}
}
}
#[test]
fn folder_visibility_target_clears_when_the_user_moves_on() {
for action in ["body click", "selection command", "empty click", "escape", "design tab", "model edit", "tool"] {
let mut h = Harness::new();
h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"One"})).unwrap();
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
h.app.run("sketch.finish", json!({})).unwrap();
h.app.run("select.set", json!({"items":[Sel::Body {name:"One".into()}]})).unwrap();
h.settle();
h.step(&json!({"click":{"handle":"row:Sketches"}})).unwrap();
match action {
"body click" => h.step(&json!({"click":{"handle":"row:One"}})).unwrap(),
"selection command" => {
// Explicitly picking the same body must also leave the folder target.
h.app.run("select.set", json!({"items":[Sel::Body {name:"One".into()}]})).unwrap();
}
"empty click" => h.step(&json!({"click":[950,650]})).unwrap(),
"escape" => h.step(&json!({"key":"Escape"})).unwrap(),
"design tab" => {
crate::documents::new_tab(&mut h.app);
h.settle();
crate::documents::switch(&mut h.app, 0);
h.settle();
}
"model edit" => {
h.app.run("sketch.create", json!({"plane":"XZ"})).unwrap();
h.app.run("sketch.finish", json!({})).unwrap();
}
"tool" => {
h.app.start("file.save_mesh");
h.settle();
}
_ => panic!("unknown test action: {action}"),
}
h.step(&json!({"key":"V"})).unwrap();
assert!(h.app.session.visibility.hidden_folders.is_empty(), "{action}: V must no longer target Sketches");
if matches!(action, "body click" | "selection command") {
assert!(!h.app.session.body_visible("One"), "{action}: normal selection shortcuts still work");
}
}
}
#[test]
fn folder_visibility_shortcut_does_not_take_dialog_body_picks_or_chevrons() {
let mut h = Harness::new();
h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"One"})).unwrap();
h.settle();
h.step(&json!({"click":{"chevron":"Bodies"}})).unwrap();
h.step(&json!({"key":"V"})).unwrap();
assert!(!h.app.session.folder_hidden("c0/bodies"), "the fold arrow does not pick a visibility target");
h.app.start("file.save_mesh");
h.settle();
h.step(&json!({"click":{"handle":"row:Bodies"}})).unwrap();
assert_eq!(h.app.dialog.as_ref().unwrap().inputs[0].items, vec![Sel::Body { name: "One".into() }]);
h.step(&json!({"key":"V"})).unwrap();
assert!(!h.app.session.folder_hidden("c0/bodies"), "a body input consumes the folder click");
}
#[test]
fn save_mesh_add_all_and_add_visible_bodies_fill_the_bodies_input() {
let mut h = Harness::new();
+31 -4
View File
@@ -857,7 +857,9 @@ fn value_box_full(
at
};
let mut field = None;
Container::new(ui.ctx(), Surface::Canvas).area(area).fixed_pos(at).pivot(pivot).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
// Background: under Preferences, Report a bug and other system windows (#9016). The box is
// already kept clear of the command window (#6608), which floats at Middle with Features.
Container::new(ui.ctx(), Surface::Canvas).area(area).fixed_pos(at).pivot(pivot).order(egui::Order::Background).show(ui.ctx(), |ui| {
Container::popup_frame(ui.style()).inner_margin(egui::Margin::symmetric(6, 3)).show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(egui::RichText::new(crate::i18n::tr_label(label)).color(t.text_dim).size(crate::theme::Tokens::get().font_caption));
@@ -1600,7 +1602,7 @@ fn draw_revolve(
let area = Container::new(ui.ctx(), Surface::Canvas)
.area(egui::Id::new("sc_canvas_area"))
.fixed_pos(box_at)
.order(egui::Order::Foreground)
.order(egui::Order::Background)
.interactable(false);
area.show(ui.ctx(), |ui| {
Container::popup_frame(ui.style()).inner_margin(egui::Margin::symmetric(6, 3)).show(ui, |ui| {
@@ -1852,8 +1854,8 @@ mod tests {
}
/// #6608: a value box that has to move out of the command window does not cover the
/// manipulator's handles: the box is a foreground area, so it took the press and the drag
/// on a ring never reached the model.
/// manipulator's handles: when it covered a handle it took the press and the drag on a ring
/// never reached the model.
#[test]
fn a_value_box_keeps_clear_of_the_handles() {
let mut h = crate::scenario::Harness::new();
@@ -1896,6 +1898,31 @@ mod tests {
assert_eq!(clear_of_dialog(clear, place), clear, "a box that misses the window stays");
}
/// Canvas value boxes sit under Preferences and Report a bug (#9016).
#[test]
fn a_value_box_draws_under_system_windows() {
let mut h = crate::scenario::Harness::new();
h.app.run("solid.box", serde_json::json!({"length": 40, "width": 30, "height": 20})).unwrap();
h.app.session.selection = vec![Sel::Body { name: "Body1".into() }];
h.step(&serde_json::json!({"start": "solid.move"})).unwrap();
h.frames(6);
let order = |h: &crate::scenario::Harness| {
h.ctx.memory(|m| m.areas().visible_layer_ids().into_iter().find(|l| l.id == egui::Id::new("sc_canvas_area")).map(|l| l.order))
};
assert_eq!(order(&h), Some(egui::Order::Background), "the value box belongs under system windows");
h.call("ui.prefs", serde_json::json!({"open": "General"}));
h.frames(3);
let window = h.ctx.memory(|m| m.area_rect(egui::Id::new("sc_prefs"))).expect("Preferences");
let box_rect = h.ctx.memory(|m| m.area_rect(egui::Id::new("sc_canvas_area"))).expect("the value box");
assert!(order(&h) < Some(egui::Order::Middle), "Preferences draws over the value box");
if window.intersects(box_rect) {
assert!(
h.ctx.memory(|m| m.layer_id_at(box_rect.center())).is_some_and(|l| l.id == egui::Id::new("sc_prefs")),
"Preferences covers the Distance box where they overlap"
);
}
}
/// A box that would cover a handle moves off it, and one that cannot clear both the window
/// and the handles keeps off the handles: the handle takes the press (#6608).
#[test]
+1
View File
@@ -15,6 +15,7 @@ pub const DOCUMENTED: &[&str] = &[
"occurrence.move",
"parameters.change",
"pointcloud.insert",
"sketch.equation_curve",
"solid.align",
"solid.chamfer",
"solid.combine",
+57
View File
@@ -63,6 +63,8 @@ pub enum Target {
Origin,
/// Document Settings › Units.
Units,
/// Insert › Insert Design: the designs open in other tabs, and Browse… (#8570).
InsertDesign,
/// The mouse mappings (Preferences › Navigation), from the status bar (#7023).
Navigation,
/// An assembly joint.
@@ -428,6 +430,7 @@ pub fn items(app: &SolveApp, target: &Target) -> Vec<Item> {
})
.collect(),
Target::NamedView { name } => crate::browser::view_items(app, name),
Target::InsertDesign => insert_design_items(app),
};
if !crate::render_workspace::active(app) {
v.extend(crate::browser::folded_folder_items(app, target));
@@ -562,6 +565,8 @@ fn feature_items(app: &SolveApp, id: u64) -> Vec<Item> {
.with(json!({"feature":id, "suppressed": !suppressed}))
.on(exists),
act("timeline.roll_to", "Roll Back Here", "rollback").with(json!({"feature":id})).on(exists),
act("ui.cut", "Cut", "cut").key("Ctrl+X").on(exists && crate::edit_clipboard::can_cut(app)),
act("ui.copy", "Copy", "copy").key("Ctrl+C").on(exists && crate::edit_clipboard::something_to_copy(app)),
act("ui.delete", "Delete", "delete").key("Del").with(json!({"items": [{"type": "feature", "id": id}]})).on(exists),
Item::sep(),
act("ui.findTimeline", "Find in Timeline", "rollback").with(json!({"feature":id})).on(exists),
@@ -764,6 +769,7 @@ fn sketch_items(app: &SolveApp, id: u64) -> Vec<Item> {
act("ui.redefineSketch", "Redefine Sketch Plane", "plane").with(json!({ "sketch": id })),
act("ui.exportDxf", "Export DXF…", "export").with(json!({ "sketch": id })),
Item::sep(),
act("ui.cut", "Cut", "cut").key("Ctrl+X").on(crate::edit_clipboard::can_cut(app)),
act("ui.copy", "Copy", "copy").key("Ctrl+C").on(crate::edit_clipboard::something_to_copy(app)),
act("ui.paste", "Paste", "paste").key("Ctrl+V").on(crate::edit_clipboard::can_paste(app)),
act("ui.delete", "Delete", "delete").key("Del").with(json!({ "items": [{"type": "feature", "id": id}] })),
@@ -1125,6 +1131,9 @@ fn sketch_entity_items(app: &SolveApp) -> Vec<Item> {
v.push(cmd(app, "sketch.edit_text", "Edit Text"));
v.extend(explode_text(app));
}
if let Some(curve) = crate::sketch_modify_ui::selected_equation(app) {
v.push(act("ui.editEquationCurve", "Edit Equation Curve", "equation_curve").with(json!({ "curve": curve })));
}
v.push(cmd(app, "sketch.move", "Move/Copy").key("M"));
v.push(cmd(app, "sketch.transform", "Transform").key("G"));
if !curves.is_empty() {
@@ -1243,6 +1252,30 @@ fn menu_sketch_point(app: &SolveApp, plane: &solidcraft_engine::geom::Plane) ->
Some(plane.to_local(plane.intersect_ray(o, d)?))
}
/// Insert Design's list (#8570): each design open in another tab, inserted with one click (its
/// file name beside it; one not saved yet greyed out, as there is no file to link), then
/// Browse… for a file, as the button did before.
fn insert_design_items(app: &SolveApp) -> Vec<Item> {
let mut v = vec![Item::heading("Open designs")];
for d in crate::documents::other_designs(app) {
let file = d.path.as_deref().map(|p| std::path::Path::new(p).file_name().map_or(p.to_string(), |f| f.to_string_lossy().into_owned()));
let note = match (&file, d.unsaved) {
(None, _) => "Not saved yet".to_string(),
(Some(_), true) => "Unsaved changes".to_string(),
(Some(file), false) => file.clone(),
};
v.push(
act("ui.insertOpenDesign", &d.name, "component")
.key(&note)
.on(d.path.is_some())
.with(json!({"path": d.path, "name": d.name, "unsaved": d.unsaved})),
);
}
v.push(Item::sep());
v.push(act("ui.insertBrowse", "Browse…", "open"));
v
}
// ---------------------------------------------------------------------------------------------
// Running an item
// ---------------------------------------------------------------------------------------------
@@ -1326,6 +1359,11 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
None => drop(crate::edit_clipboard::paste_into(app, Some(at), item.id == "ui.pasteNew", into)),
}
}
"ui.editEquationCurve" => {
if let Some(id) = p.get("curve").and_then(Value::as_str) {
crate::sketch_modify_ui::edit_equation(app, id);
}
}
"ui.editSketchPattern" => {
if let Some(id) = p.get("pattern").and_then(Value::as_str) {
crate::sketch_modify_ui::edit_pattern(app, id);
@@ -1401,6 +1439,21 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
crate::design_properties::open_object(app, command, &params, fallback);
}
"ui.designProperties" => app.start("document.metadata"),
"ui.insertOpenDesign" => {
let (Some(path), name) = (p.get("path").and_then(Value::as_str), p.get("name").and_then(Value::as_str).unwrap_or_default()) else {
return;
};
// The design is open here, so its file's place is already the user's choice.
let hosts: Vec<String> = solidcraft_engine::io::vfs::network_host(path).into_iter().collect();
let inserted = solidcraft_engine::io::vfs::allowing_hosts(&hosts, || app.run("component.insert_design", json!({ "path": path })));
if inserted.is_ok() {
app.fit_view();
if p.get("unsaved").and_then(Value::as_bool) == Some(true) {
app.set_status(crate::i18n::format("ui.insert_design.saved_file", &[("name", name)]), false);
}
}
}
"ui.insertBrowse" => app.pick_design_to_insert(),
"ui.export" => {
let name = bodies.first().cloned().unwrap_or_else(|| app.session.doc.name.clone());
app.pick_save_command(&format!("{name}.step"), &["step", "stp", "igs", "stl", "3mf", "obj"], "file.export", json!({"bodies": bodies}));
@@ -1712,8 +1765,12 @@ pub fn press_shortcut(app: &mut SolveApp, key: &str) -> bool {
/// Show/Hide (the V key): what is selected in the viewport or picked in the browser flips its
/// visibility (bodies, faces' and edges' bodies, sketches, construction and origin planes and
/// axes, components' bodies, canvases, joints' glyphs). When some are shown and some hidden, all are hidden.
/// A clicked folder takes precedence, and toggles its own visibility without changing its items.
pub fn toggle_visibility(app: &mut SolveApp) {
use solidcraft_engine::doc::FeatureKind;
if crate::browser::toggle_picked_folder(app) {
return;
}
let paths: Vec<Vec<u64>> =
app.session.selection.iter().filter_map(|s| if let Sel::Occurrence { path } = s { Some(path.clone()) } else { None }).collect();
if !paths.is_empty() {
+40 -9
View File
@@ -48,7 +48,7 @@ use std::time::Duration;
use serde_json::{Value, json};
use solidcraft_engine::io::vfs;
use solidcraft_engine::render::StandardView;
use solidcraft_engine::render::{Camera, StandardView};
use solidcraft_engine::{batch_params, timing};
use crate::SolveApp;
@@ -251,6 +251,7 @@ fn execute(app: &mut SolveApp, id: &str, params: Value) -> Result<Value, String>
| "browser.expand_all"
| "browser.collapse_all"
)
&& !id.starts_with("settings.")
&& app.session.active_sketch.is_none()
&& app.session.world_state().bodies.len() == 1
&& app.session.undo.len() <= 2
@@ -1135,7 +1136,7 @@ fn dispatch(app: &mut SolveApp, ctx: &egui::Context, method: &str, p: &Value) ->
/// `ui.camera`: the viewport's orbit camera (`target`, `yaw`, `pitch`, `roll` in radians,
/// `distance` in mm, `fov`, `up`), or a new one. A field left out keeps its value, so a reply
/// passed back sets the same view again. `fit`: frame the listed occurrences (ids or names;
/// passed back sets the same view again. `fov` is the angle the window draws with (see [`drawn`]). `fit`: frame the listed occurrences (ids or names;
/// `root`: the bodies outside every component), or everything (`true`), keeping the direction;
/// `false` frames nothing.
/// Nothing changes when a field is refused.
@@ -1149,10 +1150,10 @@ fn camera(app: &mut SolveApp, p: &Value) -> Result<Value, String> {
if let Some(k) = o.keys().find(|k| !FIELDS.contains(&k.as_str())) {
return Err(format!("unknown camera field `{k}` (it takes {})", FIELDS.join(", ")));
}
let mut cam = app.cam;
let mut cam = drawn(app);
let sets = o.keys().any(|k| k != "fit");
if sets {
let mut v = serde_json::to_value(app.cam).map_err(|e| e.to_string())?;
let mut v = serde_json::to_value(cam).map_err(|e| e.to_string())?;
for (k, x) in o.iter().filter(|(k, _)| *k != "fit") {
v[k] = x.clone();
}
@@ -1164,10 +1165,11 @@ fn camera(app: &mut SolveApp, p: &Value) -> Result<Value, String> {
// Straight up or down keeps a heading, as the standard views do.
let lim = std::f64::consts::FRAC_PI_2 - 1e-6;
cam.pitch = cam.pitch.clamp(-lim, lim);
// Perspective and the up axis are settings the camera follows, not its own.
if cam.fov != app.cam.fov {
return Err("`fov` follows the perspective setting (ui.set {perspective})".into());
// Perspective, its angle and the up axis are settings the camera follows, not its own.
if cam.fov != drawn(app).fov {
return Err("`fov` follows Perspective view (ui.set {perspective}) and its angle (ui.prefs {perspective_fov})".into());
}
cam.fov = app.cam.fov;
if cam.up != app.cam.up {
return Err("`up` follows the up axis preference (ui.prefs {up_axis})".into());
}
@@ -1190,7 +1192,7 @@ fn camera(app: &mut SolveApp, p: &Value) -> Result<Value, String> {
};
// A read leaves a view change that is animating alone.
if !sets && fit.is_none() {
return serde_json::to_value(app.cam).map_err(|e| e.to_string());
return serde_json::to_value(drawn(app)).map_err(|e| e.to_string());
}
app.cancel_view_animation();
app.viewport.orbit_free = None;
@@ -1200,7 +1202,17 @@ fn camera(app: &mut SolveApp, p: &Value) -> Result<Value, String> {
Some(Some(b)) => app.fit_bounds(b),
None => {}
}
serde_json::to_value(app.cam).map_err(|e| e.to_string())
serde_json::to_value(drawn(app)).map_err(|e| e.to_string())
}
/// The camera as the window draws it: with the view's angle ([`SolveApp::view_fov`]), or in Render
/// with the Render camera's own.
fn drawn(app: &SolveApp) -> Camera {
let mut cam = app.cam;
if !crate::render_workspace::active(app) {
cam.fov = app.view_fov();
}
cam
}
/// An occurrence by id or name (or a component's name: its first occurrence); 0 is the root.
@@ -2203,6 +2215,25 @@ mod tests {
assert!(r["ok"] == false && empty.app.cam == before, "{r}");
}
/// With Perspective view on, `ui.camera` reports the angle the window draws with (#5364) and
/// its reply replays; another angle is refused, naming the preference. A sketch draws parallel.
#[test]
fn ui_camera_reports_the_drawn_angle_and_its_reply_replays() {
let mut h = crate::scenario::Harness::new();
h.call("ui.set", json!({"perspective": true}));
h.call("ui.prefs", json!({"perspective_fov": 1.0}));
let read = h.call("ui.camera", Value::Null);
assert_eq!(read["result"]["fov"], json!(1.0), "{read}");
let stored = h.app.cam.fov;
let again = h.call("ui.camera", read["result"].clone());
assert_eq!(again["result"], read["result"], "the reply replays");
assert_eq!(h.app.cam.fov, stored, "the camera's own angle is left as it was");
let refused = h.call("ui.camera", json!({"fov": 0.6}));
assert!(refused["ok"] == false && refused["error"].as_str().unwrap().contains("ui.prefs {perspective_fov}"), "{refused}");
h.app.run("sketch.create", json!({"plane": "XY"})).unwrap();
assert_eq!(h.call("ui.camera", Value::Null)["result"]["fov"], json!(0.0), "a sketch draws parallel");
}
/// `ui.start` params set what a dialog opens on: Drive Joints on a joint (by name or id) at
/// the given values, driving nothing yet. A param it doesn't take is refused and closes it.
#[test]

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