Compare commits

...

128 Commits

Author SHA1 Message Date
Brennen Herbruck 4f19974d6a Tapped hole: thread every piece of a wall split by a pocket or slot (#13505)
A tapped hole drilled across a pocket or slot leaves its cylindrical wall
in separate faces along the axis. The thread was put on only the piece
nearest the entry, so cosmetic and modelled threads stopped at the first
gap. The hole now threads every piece of the wall within its depth, and a
thread depth runs on through the pieces from where the first one starts.

Fixes #13505

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 15:31:26 -05:00
Mek f4d9519bb9 Hole: a counterbore sized to the screw's head, from clearance and head (#12321) (#12322)
* claim: #12321

* Hole: a counterbore sized to the screw's head, from `clearance` and `head` (#12321)

`solid.hole` takes `head`: `ISO 4762` (socket head) or `ISO 7380-1` (button head),
or the family name `parts.insert` uses. With `clearance`, the hole gets a counterbore
the head sits in: the head's diameter dk plus `head_gap` each side, the head's height k
plus `head_gap` deep (default 0.5 mm). `type` defaults to counterbore with `head`; a
`cb_diameter` or `cb_depth` given keeps its value. A `head_gap` that names a parameter
is stored as an expression, so the recess follows it. The lookup, `cap_screw_head`,
lives beside the tables in parts.rs.

Refused with the value named: an unknown standard, a size the standard lacks, `head`
without `clearance`, `head` with another type (no countersink table yet), a gap outside
0 to dk, a `head` that is not text.

Tests: four holes against analytic volumes, a hole matrix case, nine refusal cases,
and the hostile-parameter sweep now tries `head` and `head_gap`.

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

* MCP: a test that a hole's head reaches solid.hole through execute (#12321)

`describe_command solid.hole` lists `head` and `head_gap` and has no typed
contract, so `execute` forwards the parameters to the command unchanged. The
test drills an ISO 4762 and an ISO 7380-1 counterbore through the tool with an
`expect` on the volume, and checks that a wrong head, a negative gap and a head
without a clearance are refused with the value named, leaving the part as it
was.

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

---------

Co-authored-by: Mek <mf@linescode.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-11-08 18:52:33 +00:00
Loohi 9890abae36 Sketch: input and output schemas for the slots (part of #2829) (#13300)
* claim: part of #2829, slot schemas

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

* Sketch: input and output schemas for the slots (part of #2829)

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

* Slots: pin the reply to the old shape (part of #2829)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 18:51:50 +00:00
ancient_astronaut_theorist 28cfed5e07 Copy and Paste New: an edge name beside a body face points at the copy on both sides (#13097) (#13157)
* claim: #13097

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

* Copy and Paste New: an edge name beside a body face maps both faces (#13097)

remap_topology read an edge name whose first face is a face of a body
without history (`Plate:face:3|F2:top`) as one body face: it renamed the
body and left `F2` naming the original feature. With the body face second
(`F2:top|Plate:face:3`) the body kept its old name. It now reads an edge
name with EdgeName::parse first and remaps each face on its own, keeping
the edge's `~k` number. A body whose own name holds a `|` is still one face.

Fixes #13097

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 18:51:20 +00:00
Loohi 01042ca81e Project: pick a point of another sketch (#13246) (#13281)
* claim: #13246

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

* Project: pick a point of another sketch (#13246)

An input that takes points of other sketches only (Project) was offered
only the active sketch's points. Offer the other sketches' points and
leave out the active sketch's own.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 18:49:36 +00:00
PeppaWutz 42cd362748 Agents and scripts: Save and Save As describe their exact parameters and replies (#13307) (#13352)
file.save and file.save_as now publish typed input and output schemas, so
agents and scripts can read which parameters they take and what they return,
and the warn and strict parameter checks cover them.

Each parameter is read the way the handler reads it today. Save As keeps its
"`path` is required" message, Save and Save As keep refusing an unknown format
or compression word themselves, and the reply is built from a typed value that
serializes to the same JSON as before. Their lines leave unconverted.txt.
Part of #2829.

Made with Claude Sonnet 5.5 (medium effort, fast mode off) through the Claude API, with Claude Opus 5.5 as advisor.
2026-11-08 18:49:20 +00:00
MarkusRe bfee9bcc19 Sketch Tangent: an end that already holds is redundant, as for two curves (#13356)
* Sketch Tangent: test that an end that already holds is redundant

Tangent with a curve's end point and a curve the end already touches
tangentially should move nothing, and with both fixed report the tangent
as redundant (or refuse it with `if_redundant: "refuse"`), as Tangent on
two curves does. Fails on main: the call is refused as a conflict.

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

* Sketch Tangent: an end that already holds is redundant, as for two curves

Tangent with a curve's end point skipped what Tangent on two curves does
through `constrain`. Now constraints that already hold move nothing, and
when they remove no degree of freedom they are reported as redundant with
what determines them, or refused with `if_redundant: "refuse"`. Before,
a line's end already tangent on a fixed circle was refused as a conflict.

Follow-up to #12377, from its CodeRabbit review.

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

* Sketch Tangent: test which constraints determine an end that already holds

Free curves whose own constraints decide the tangent at a line's end:
the reply names exactly the Concentric and the Tangent on the equal
circle. The equal radius is not named, because a tangent read at its
touch point says the line is square to the radius there.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 18:49:00 +00:00
PeppaWutz 1210ac5080 Agents and scripts: Sketch Dimension describes its exact parameters and reply (#13312) (#13355)
sketch.dimension now publishes typed input and output schemas. Each
parameter is read the way the handler reads it today and refused with the
handler's own message, and the reply is built from the same typed value the
output schema describes, so the JSON it returns does not change. A driven
dimension does not read value, scale_sketch or if_redundant, as the handler
does not. Its line leaves unconverted.txt.

Part of #2829.

Made with Claude Sonnet 5.5 (medium effort, fast mode off) through the Claude API, with Claude Opus 5.5 as advisor; review fixes made with Claude Opus 5.5 in Claude Code (Ultra plan, default effort, fast mode off, Ultracode off).
2026-11-08 18:48:48 +00:00
Sebastian Höpfl 6e27cfa7ff Tests: reuse one offscreen GPU device per test process (#13395) (#13396)
* claim: #13395

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

* Tests: reuse one offscreen GPU device per test process (#13395)

Every offscreen render or check in the ui-egui tests started its own wgpu instance, adapter and
device, and a fresh device compiled the viewport's pipelines again. `offscreen_device` now hands
out clones of one device per test process. In
`a_hovered_face_lights_whole_over_a_body_drawn_from_its_plan` (28 renders) that takes the test
from 55.1 s to 4.1 s alone: device setup was 9.4 s of it, pipeline compiles 44.3 s.

The tests that destroy their device, or replace its uncaptured-error handler, keep a device of
their own through `offscreen_own_device`: the three device-loss tests in `gpu.rs`,
`a_configure_that_meets_a_lost_device_is_not_taken` and `an_uncaught_gpu_error_is_not_a_panic`.
In wgpu 30 error scopes are per thread, and a new device-lost callback drops the old one without
calling it, so the other tests don't see each other on the shared device.

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

* Tests: retry the shared offscreen device after a failed start (#13395)

`offscreen_device` kept the first result, so one failed device start (a GPU briefly out of
memory) left `None` cached and every later offscreen test in the process skipped. It now keeps
only a device: after a failure the next test tries again, as each test did before the sharing.
Under `SOLIDCRAFT_GPU=cpu` or without an adapter every call still answers `None`.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 18:48:00 +00:00
0rdnungsamt 5c95fb027c Tests: the extrude free-drag test holds the platform's primary key, so it passes on macOS (#13274) (#13296)
* claim: #13274

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

* Tests: hold the platform's primary key for the extrude free drag (#13274)

The free-drag tests held physical Ctrl, which is not the primary key on
macOS, so the arrow still snapped to the face there. Set
Modifiers::command instead, and check for both platforms that Held::new
sees a primary key, so the mistake also shows on Windows and Linux.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 18:48:00 +00:00
ABDULLAH 1dcb1066e5 Arabic: focused-panel wording in the sash reset and panel toggle commands, shared panel layout help, construction-geometry projection note, Browser panel name in render strings (#13320)
* Arabic: focused-panel wording in the sash reset and panel toggle commands, shared panel layout help

* Arabic: command names and clearer wording in the construction-geometry projection note

* Arabic: the Browser panel is المستعرض in the render browser strings and the shared panel layout help
2026-11-08 18:46:59 +00:00
Janiz-zzz 94cb149856 Finnish interface: translate the 618 messages added since #12036, and five wording fixes (#13460)
* Finnish: translate the 618 messages added since #12036, and five wording fixes

- assets/locales/pending/finnish-catch-up-2026-11-08.json: 618 messages
  (renderer settings, interface, preferences, sketch, commands, dialog titles).
- fi.json, the CodeRabbit notes on #12036: puolisiin -> puoliin; the import
  report hint, double-click help, Fold items and Select Other wording.
- fi.json: cmd.sheet_unfold "Avaa" (open) -> "Levitä" (unfold), matching the
  new UNFOLD dialog title.

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

* i18n(fi): live preview shows the last rendered image, not a saved one

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

---------

Co-authored-by: Janiz-zzz <267742026+Janiz-zzz@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 18:45:21 +00:00
bherbruck 9e3e441a2d xtask layers: add the solidcore seam rule (#13498)
SolidCraft reaches solidcore only through the crates listed in
xtask/solidcore-public.txt (solidcore-ops, solidcore-tess, solidcore-record).
Any other solidcore crate, matched by package name or by where it comes from
(git or the vendored solidcore/ directory), fails `cargo xtask layers` unless
xtask/solidcore-seam.txt lists it. That list only shrinks.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 19:06:32 +00:00
Frank 3d78129af6 cargo xtask: check the git worktree it is run in, not the one that last built xtask (#13417)
* claim: cargo xtask uses the checkout it runs in

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

* cargo xtask: check the git worktree it is run in, not the one that last built xtask

root() now prefers the CARGO_MANIFEST_DIR that cargo run sets for the
process, and falls back to the compile-time value only when it is unset.
Worktrees that share one CARGO_TARGET_DIR share one xtask binary, so the
baked-in path named whichever worktree built xtask last.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 18:35:17 +00:00
bherbruck 3fb1040164 Mesher: flat faces mesh as their boundary alone, so a box exports as 12 triangles, not 124 (#13266)
* Mesher: flat faces are their boundary alone, not a grid capped at a fifth of the body

A planar face is exact for any triangulation of its boundary, so it needs no interior
cells. The planner capped every face, flat ones included, at a fifth of the body's
extent, so a 20 x 10 x 5 box came out as 124 triangles instead of 12.

A flat face (a plane, or a spline lying in one) now asks for no size. An edge between
flat faces is one segment; an edge beside a curved face still takes the curved face's
size, so both faces use the same nodes and the mesh stays watertight. Curved faces keep
the cap, the aspect limit and the grid limits.

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

* Mesh counts of a box follow the flat-face mesher: engine export, viewport box and edges

The box exports as 12 triangles (was 124), draws as 12 glass triangles (was 104) and
its imported-mesh outline is 12 segments a box (was 20). Also cargo fmt.

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

* Tests: side-wall sag checks pick the wall by face, the dense-cluster test keeps its intent

The flat caps of a cylinder are their rim alone now, so a triangle with all corners on the
rim is no proof of a wall triangle: pick the wall's triangles by their face. The hole
placement test asserted the body had twice the face's triangles; it asserts the body has
other faces than the one picked.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 18:25:08 +00:00
jyviko 746b43878e Control: the control channel's transport and protocol are shared crates, solidcraft-control and solidcraft-control-egui (#13407)
* Layers: a Shared crate class, and cargo xtask shared builds each one on its own (#12935)

A Shared crate depends only on other Shared crates, solidcore and external crates (UI
toolkits included); any layered or testkit crate may use one. The layer check reports a
Shared crate's dependency on an app crate, of any kind. cargo xtask shared checks each
Shared crate's manifest (workspace lints, licence, README) and that no include_str! or
include_bytes! reaches outside it, then builds and tests it with its Shared dependencies in
a scratch workspace of its own. cargo xtask ci runs it after the layer check.

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

* UI kit: the widget registry is a shared crate, ui-registry (#12936)

The handle, rect and count registry that UI tests, scenarios and the control channel read
moves out of scenario into solidcraft-ui-registry, the first Shared crate. The kit publishes
into it directly; scenario re-exports it, so scenarios, scripts and the rest of ui-egui are
unchanged and every handle keeps its name.

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

* Theme: the tab accent takes the tab, the sketch and the tool as data (#12938)

TabAccent::publish takes the open tab, whether a sketch is being edited and whether a tool
or dialog is active, instead of SolveApp; SolveApp::publish_tab_accent works the three out
in one place. scoped_panel_tokens is public, so a host can draw any component in another
palette or accent. The theme and its tests no longer name SolveApp, Services or workspace.
Accents are unchanged.

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

* UI kit: Preferences search is a kit module, settings_search (#13256)

prefs_search moves whole into ui_kit::settings_search: it filters any settings page the
kit draws, and SolidCraft's Preferences window is one use of it. It publishes its handles
into the widget registry and reads the same wall clock as before (web_time), so no kit
file calls the app for search any more. Every caller uses the new path; state ids, handle
names, matches and scores are unchanged.

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

* UI kit: the kit owns the menu entry and the list width (#13257)

Item (a command or ui.* action with its label, shortcut, icon, params, children, swatch
and tip) and LIST_W move from context_menu into ui_kit::menu, with the same serde form, so
the control channel's menu report is byte-identical. context_menu keeps building
SolidCraft's entries (cmd, and act over Item::action) and returns kit Items. No kit file
refers to context_menu any more.

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

* UI kit: number parsing and recent colours in the kit, expression support passed in by the host (#13258)

parse_number (decimal point or comma, Unicode minus) moves from prefs into the kit's
fields, and recent_colors moves whole into ui_kit::recent_colors beside the colour picker.
The kit no longer knows the expression language: a host installs an Expressions (is this
a literal, is this a unit, complete or step this text) per egui context, and fields read
it for label drags and completion. SolidCraft installs DocExpressions, built on doc::expr
and params_dialog::complete, when the app first draws into a context. Behaviour in the app
is unchanged.

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

* UI kit: navigation-bar values and the horizontal wheel in the kit (#13259)

The bar's edge, size, idle and button-kind values move beside the bar's drawing in
ui_kit::navigation as NavigationEdge, NavigationSize, NavigationIdle and NavigationKind (the
kit already has a container Edge). Serde names are unchanged, so saved preferences load as
before. take_horizontal_wheel moves into the theme picker, its only caller. SolidCraft's
slot table stays in navigation_bar.

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

* UI kit: layout mode and card geometry in the kit, input shield set by the host (#13260)

LayoutMode (with its per-context publish and current), the card gap and the panic-free
clamp move into the kit's containers and panels, and Cards into stages. Cards::new takes
the workspace width instead of reading the ribbon's constant. The flag that says a report
covers the 3D view is now the kit's (input_blocked, set_input_blocked); the app's CadInput
guard sets it, and issue_report's own copy is gone. Serde names are unchanged, so saved
preferences load as before.

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

* UI kit: continuity rows leave the kit, the navigator figure takes its axes as input (#13261)

continuity_row and its explained and tip variants are CAD controls over the kernel's
Continuity: they move beside their callers in dialogs_surface, still drawn with the kit's
choice_row. The Preferences navigator figure takes its axes (screen direction and depth)
as NavigatorAxis input; SolidCraft computes them from its default camera. dashed_line is
painter geometry and moves into ui_kit::dash with its test. No kit file uses the engine,
the record or the viewport outside tests.

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

* UI kit: length fields take unit scaling and length parsing from the host (#13270)

The kit's length field converted millimetres to and from the design's units through three
prefs helpers. Those are the host's unit rules, so Expressions gains unit_scale,
length_number and parse_length_mm, and length_drag reads them from the context.
DocExpressions implements them with the same prefs helpers, so the app is unchanged. With
nothing installed the field edits plain millimetres. No kit file calls prefs outside
tests.

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

* Colour: the sRGB rule and the analysis palette are a shared crate, solidcraft-color (#13291)

geom's srgb module (the one sRGB transfer function every encode and decode goes through)
moves whole into solidcraft-color with its tests, and so does AnalysisColors, the analysis
palette the theme fills and the renderer reads. render, doc, io, engine, gpu and ui-egui use
the crate; there is no re-export at the old paths. The theme's unused material_color wrapper
is removed, so no non-test theme code names solidcraft_engine. Values are unchanged.

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

* Material editor: the Remove buttons are translated (material.editor.remove)

The editor's two Remove buttons called tr("Remove"), which looks up an id, so they read
"Remove" in every language. They use a new id, material.editor.remove, whose text in each
locale is the one that locale already gives the plain Remove button
prefs.network_host_remove (33 of 35 catalogs have it; the other two show English, as now).
The label-literal guard found it once the file's import named tr directly.

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

* Shared UI crates: ui-text, the message catalogs' machinery as a shared crate (#13290)

bidi, Catalog, Locale, lookup, formatting, labels and language choice move from ui-egui's
i18n into solidcraft-ui-text, a Shared crate any front end can use. The host installs its
catalogs once; with none installed every id reads as itself. SolidCraft's 35 catalogs, the
build script that merges them, and the adapters that read engine types (diagnostic,
tr_engine_error, and the given-name and browser-label vocabulary as the LocaleNames trait)
stay in the app's i18n. The app installs its catalogs when it starts, when an app is built
and in the widget test context. Call sites use the crate's path, with no re-export at the
old one; messages, ids and fallbacks are unchanged.

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

* i18n tests: the painted-English sweeps install the catalogs themselves (#13290)

no_english_is_painted_where_the_language_has_a_translation and
the_sweep_reports_english_only_where_a_translation_exists read translations without installing the
app's catalogs, so they passed only when another test in the same process had installed them first
(run alone, every id read as itself).

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

* Theme: the icon palette leaves Tokens; the icon renderer owns it (#13304)

A theme file's icon palette was a field of Tokens, so the token set depended on the icon roles
ui-egui's build script generates. It is now the icon renderer's per-thread state
(icon_svg::set_theme_palette), set alongside the theme's palette by theme::set_file_palette, and
theme_files resolves it with resolve_icon_palette (the same overlay merge as the tokens) and
exports it as an argument. Rendered pixels, theme files and exports are unchanged.

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

* Shared UI crates: the token contract as a shared crate, solidcraft-theme (#13304)

Tokens with SolidCraft's light and dark values in code (palettes.rs moves in from
assets/styles/ui, with the eight primary strokes it shared with the icon artwork written as
icon_strokes), colour vision, TabAccent, contrast checks, apply/visuals and the per-thread and
per-context theme state move into crates/theme, class Shared. It holds no data files and does not
depend on theme files or icon roles; with nothing set, components draw with the built-in tokens.

The theme system stays in ui-egui's theme module: the custom-theme glue (prepared theme, tab
targets and their fade, blur intent), publish_tab (was TabAccent::publish, which now stores its
result through TabAccent::set_current and set_published), set_file_palette, ViewportTheme.
ui-egui's build script no longer generates the primary-stroke constants; an icon test holds the
crate's literals equal to palette.toml's. Call sites use solidcraft_theme paths with no
re-export (one rename pass). Tokens, theme files and rendered pixels are unchanged.

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

* Theme: the icon lint and the docs read palettes.rs at crates/theme (#13304)

lint_icons.py compared the icon review page with assets/styles/ui/palettes.rs, which moved into
solidcraft-theme; it reads crates/theme/src/palettes.rs now (cargo xtask icons passes again), and
the READMEs, docs and test comments name the new path. set_custom and scoped_tokens say they set
tokens only, so a host with an icon renderer sets its palette too; the field contrast test applies
each built-in theme with set_file_palette, as the app does.

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

* Theme: format the field contrast test (#13304)

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

* MCP: the server machinery is a shared crate, solidcraft-mcp-server (#12947)

JSON-RPC over stdio, the MCP lifecycle, tool and resource methods, tool
definitions with their annotations, the argument check and the text and
image results move to crates/mcp-server, which has no engine or GPU
dependency and is classed Shared. SolidCraft's tools, instructions,
resources and the agent's folders stay in crates/mcp and register into it
through the Tools trait; its public API, tools list and replies are unchanged.

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

* MCP: the shared server tests what it owns, on its own (#12947)

Batches (mixed, notifications only, the 1000 limit), an unasked response,
the protocol version fallback, null arguments, enum mismatches, the empty
default lists and the list-then-call order Tools::call relies on are tested
in solidcraft-mcp-server with a fake Tools; the protocol-only lines move out
of SolidCraft's wrapper tests.

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

* Docs: the architecture table lists the shared crates and the MCP split (#12947)

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

* Control: the token files and Effect are a shared crate, solidcraft-control (#12946)

The per-launch token, its files, the discovery file and the client's token
lookup move from the engine to crates/control, with no engine dependency.
Which app they belong to (name, data folder, environment, UTC format,
default port) is an `App` value; SolidCraft's is engine::control_auth::APP.
`Effect` moves with them; which requests do what stays in the engine's
control_pending. Callers switch paths; files and messages are unchanged.

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

* Control: the loopback control server is shared; SolidCraft gives it its Protocol (#12946)

The token-admitted JSON-lines server (framing and line limit, connection
cap, control.running/pending/capabilities, busy and timeout replies,
control.subscribe events), ControlRequest and the design watch move to
solidcraft-control as `server::Hub`. What a request means (its name in
messages, its effect, command id, step count, the batch wait, the
capabilities) is the app's `server::Protocol`; SolidCraft's is in
apps/solidcraft/src/control_server.rs with its listeners and token files.
The wire format is unchanged and the server's tests move with it.

Also from review: Effect::when_done and the timeout text take the app's
word for its document ("design" for SolidCraft), the Listen docs name no
SolidCraft preference, and bench_assistant.rs is formatted.

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

* Control: the shared server speaks the app's words and listens on loopback only (#12946)

The change event, the revision method, the docs path in the unauthorized
reply and the document noun come from the app's Protocol; SolidCraft's
replies are byte-identical. The design watch is the DocumentWatch.
Hub::listen refuses a listener off loopback, so no app can put the
channel on the network; SolidCraft binds 127.0.0.1 as before.
control_pending.rs is formatted.

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

* Control: ui.screenshot and ui.resize are shared over egui (#12946)

The window's screenshots in flight (the frame waits, the AO-settled
shot, the hidden-window and chat-turn refusals, the 8 s deadline) move
from SolveApp to solidcraft_control::screenshot::Shots, with the PNG
encoding; ui.resize and the hidden-window test go to window. SolidCraft
passes its AO settle, its issue report's frames and its vfs write, so a
second app can serve ui.screenshot with the shared crate alone. The issue
report and tests reach the queue as app.shots; replies are unchanged.

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

* Control: ui.screenshot and ui.resize are their own shared crate, solidcraft-control-egui (#12946)

d8d35d454 made egui and image dependencies of solidcraft-control, so the
headless engine, the CLI and the MCP bridge compiled egui. The screenshot
and window modules move unchanged to solidcraft-control-egui; ui-egui
uses them from there. cargo tree finds no egui under engine, mcp or control.

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

* Control: the app gives the screenshot reply for no frame (#12946)

The shared text named SolidCraft's ui.render. Shots::new takes the app's
words; SolidCraft passes its reply unchanged.

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

* Control: tests for the screenshot gate, a hidden window and frames nobody waits for (#12946)

Shots::issue takes a frame on the third frame and 150 ms after the request,
or 500 ms later for a view that keeps moving; a hidden window refuses a
request and answers one waiting; with nothing pending the frames that came
are dropped. Each fails on a one-line mutation of screenshot.rs.

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

---------

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 18:09:23 +00:00
austin62jake 674e72ee8d Agents and scripts: the surface edit commands Stitch, Unstitch, Reverse Normal, Thicken, Offset, Untrim and Fair describe their exact parameters and reply (#12627) (#12628)
* claim: #12627

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

* Surface edits: input and output schemas for Stitch, Unstitch, Reverse Normal, Thicken, Offset Surface, Untrim and Fair Surface (#12627)

Typed contracts for surface.stitch, surface.unstitch,
surface.reverse_normal, surface.thicken, surface.offset, surface.untrim
and surface.fair in a new schema/surface.rs, wired with checked!,
.contract and .output; the seven leave schema/unconverted.txt. Each reply
is the feature commands' FeatureResult.

Each key reads as its handler reads it, with its message: bodies and
targets through NameList, face points through FacePoints and
OffsetFaces, Untrim's face and holes through UntrimFace and UntrimHoles,
Fair's strength and iterations with their range messages, lengths as
LengthExpr, and the common feature keys through FeatureCommon. `bodies`,
`faces` (Offset) and `face` (Untrim) are optional in the types and
required in the schemas, so a call without them keeps the handler's
message. Thicken takes `touching`, as the other joining features do.

The contracts refuse keys of the wrong type that the handlers ignore
today: an item of `faces` that isn't a point (skipped today), a `faces`
that isn't a list, and flags, tolerances, strength and iterations of the
wrong type.

Tests in schema/surface_tests.rs: the keys, 46 calls that keep their
message in strict, 15 stricter keys, 22 calls checked against both schema
and handler, and each command's reply against FeatureResult.

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

* Surface edits: the contracts refuse a null or empty `bodies`, and a null `faces` or `face`

`bodies` (Stitch, Thicken), `faces` (Offset Surface) and `face` (Untrim)
are optional in the types so that a call without them gets the
handler's own message. A `null` or empty value also reached the handler,
so the check (warn, strict, MCP validation, batch preflight) passed
calls the handler then refused. The readers now refuse them with the
handler's message: `surface_bodies` maps `null` to no names, as
params::string_list does, and refuses an empty list; `faces` and `face`
take `present`, so a `null` goes to OffsetFaces and UntrimFace, which
refuse it. A key left out still goes on to the handler.

Raised by CodeRabbit on #12628.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-08 17:30:50 +00:00
austin62jake d0da8047a7 Agents and scripts: Plot Motion Study and Motion Interference describe their exact parameters and replies (#12853)
* Claim #12852: motion analysis schemas

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

* Schemas for Plot Motion Study and Motion Interference

The two motion analysis commands (motion.plot, motion.interference)
get input contracts and output schemas, so describe_command gives
their exact parameters and replies. The contracts live in
schema/motion_analysis.rs and read each key as the handlers read it,
with their messages. The handlers build their replies from the typed
structs, and the JSON doesn't change.

Fixes #12852, part of #2829.

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

* Motion Interference: read the part lists with the handler's messages

`occurrences` and `against` were `Vec<PartRef>`, so a value that isn't a
list got serde's "expected a sequence" in strict instead of the handler's
"`occurrences` must list occurrences", and the schema didn't say a list
holds at most 10000. They are now `PartList`, which reads a list as
`cmd/motion.rs`'s `occurrences` does, with its messages and its limit.
`OccurrenceKeys`, the contact sets' list, shares the same reader.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-08 17:30:11 +00:00
PaddleStroke 72a22b3338 Threads: a tap marking looks for its cylinder only in the bodies near it again, by exact boxes (#10227) (#12777)
* claim: tap markings search only nearby bodies again, by exact boxes

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

* Threads: a tap marking looks for its cylinder only in the bodies near it again

A tap marking (a Thread feature, or a tapped hole's marking) looked for the
cylinder at its point in every body of the design, and each search meshes
the body afresh. #10303 passed over bodies whose mesh box lay out of the
search's reach, but the SMLib merge (#11351) left that mesh-based version
out, and its two cost tests were listed as known failures until an exact
replacement came. This is it: a body is passed over when its exact box
(the one holes and cuts already use) lies farther from the point than the
search looks, a twentieth of the body's size plus twice its mesh's
tolerance. The two tests come off tests/KNOWN_FAILING_TESTS.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 17:21:46 +00:00
austin62jake f0f0b58a94 Agents and scripts: the render job commands describe their exact parameters and replies (#12837)
* Claim #12836: render result command schemas

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

* Agents and scripts: the render job commands describe their parameters and replies

Render Progress, Cancel Render, Retain Displayed Render Result, Regrade
Linear Master, Copy Render Image and Export Render Image get input and
output schemas (#12836, part of #2829), in the new schema/render_jobs.rs.

The handlers parse their parameters with serde; each key in the
contracts reads as the same Rust type and passes serde's message on
whole (`as_handler`), so strict keeps today's messages. A job is the
render crate's JobId (`Job`, with a hand-written schema), and Regrade's
presentation its Presentation. The replies are typed: a job's status
keeps its id, progress (phase as an enum), error and frame typed, and
the render crate's other sections as JSON objects; the handlers build
them through `retyped`, and the JSON doesn't change. The shot queue is
RenderQueueState, which now derives its schema, and Export's format
moves to the schema module. Five lines leave unconverted.txt; Copy
Render Image had no line but no schemas either.

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

* Render export: type the reply before writing the file

`render.export` converted the frame, linear master and presentation
information to their reply types after it had replaced the file. A
conversion that failed then returned an error with the file already
written. The conversions now run first, so a failure leaves no file.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-08 17:21:36 +00:00
austin62jake cfb0240869 Agents and scripts: Sweep Joint describes its exact parameters and reply (#12845)
* Claim #12844: Sweep Joint schemas

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

* Schemas for Sweep Joint (joint.sweep)

Sweep Joint gets an input contract and an output schema, so
describe_command gives its exact parameters and reply. The contract
lives in schema/joint_sweep.rs and reads each key as the handler
reads it, with the handler's messages. The handler builds its reply
from SweepResult, and the JSON doesn't change.

Fixes #12844, part of #2829.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-08 17:21:27 +00:00
austin62jake 16204d4c96 Agents and scripts: Chamfer describes its exact parameters and reply (#12884) (#12885)
* Claim #12884: Chamfer schemas

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

* Chamfer: input and output schemas for solid.chamfer (#12884)

Adds ChamferParams, the contract for solid.chamfer, and replies with the
shared FeatureResult. A misspelt key is now named in warn and refused in
strict; calls that work today get the same reply.

Two tests used Chamfer as their example of a command without a contract;
they now take the first such command from the registry instead.

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

* Chamfer schemas: format

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Brennen Herbruck <brennen.herbruck@gmail.com>
2026-11-08 17:21:06 +00:00
jyviko 419bbcda0e Shared UI crates: the token contract as a shared crate, solidcraft-theme (#13384)
* Layers: a Shared crate class, and cargo xtask shared builds each one on its own (#12935)

A Shared crate depends only on other Shared crates, solidcore and external crates (UI
toolkits included); any layered or testkit crate may use one. The layer check reports a
Shared crate's dependency on an app crate, of any kind. cargo xtask shared checks each
Shared crate's manifest (workspace lints, licence, README) and that no include_str! or
include_bytes! reaches outside it, then builds and tests it with its Shared dependencies in
a scratch workspace of its own. cargo xtask ci runs it after the layer check.

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

* UI kit: the widget registry is a shared crate, ui-registry (#12936)

The handle, rect and count registry that UI tests, scenarios and the control channel read
moves out of scenario into solidcraft-ui-registry, the first Shared crate. The kit publishes
into it directly; scenario re-exports it, so scenarios, scripts and the rest of ui-egui are
unchanged and every handle keeps its name.

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

* Theme: the tab accent takes the tab, the sketch and the tool as data (#12938)

TabAccent::publish takes the open tab, whether a sketch is being edited and whether a tool
or dialog is active, instead of SolveApp; SolveApp::publish_tab_accent works the three out
in one place. scoped_panel_tokens is public, so a host can draw any component in another
palette or accent. The theme and its tests no longer name SolveApp, Services or workspace.
Accents are unchanged.

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

* UI kit: Preferences search is a kit module, settings_search (#13256)

prefs_search moves whole into ui_kit::settings_search: it filters any settings page the
kit draws, and SolidCraft's Preferences window is one use of it. It publishes its handles
into the widget registry and reads the same wall clock as before (web_time), so no kit
file calls the app for search any more. Every caller uses the new path; state ids, handle
names, matches and scores are unchanged.

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

* UI kit: the kit owns the menu entry and the list width (#13257)

Item (a command or ui.* action with its label, shortcut, icon, params, children, swatch
and tip) and LIST_W move from context_menu into ui_kit::menu, with the same serde form, so
the control channel's menu report is byte-identical. context_menu keeps building
SolidCraft's entries (cmd, and act over Item::action) and returns kit Items. No kit file
refers to context_menu any more.

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

* UI kit: number parsing and recent colours in the kit, expression support passed in by the host (#13258)

parse_number (decimal point or comma, Unicode minus) moves from prefs into the kit's
fields, and recent_colors moves whole into ui_kit::recent_colors beside the colour picker.
The kit no longer knows the expression language: a host installs an Expressions (is this
a literal, is this a unit, complete or step this text) per egui context, and fields read
it for label drags and completion. SolidCraft installs DocExpressions, built on doc::expr
and params_dialog::complete, when the app first draws into a context. Behaviour in the app
is unchanged.

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

* UI kit: navigation-bar values and the horizontal wheel in the kit (#13259)

The bar's edge, size, idle and button-kind values move beside the bar's drawing in
ui_kit::navigation as NavigationEdge, NavigationSize, NavigationIdle and NavigationKind (the
kit already has a container Edge). Serde names are unchanged, so saved preferences load as
before. take_horizontal_wheel moves into the theme picker, its only caller. SolidCraft's
slot table stays in navigation_bar.

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

* UI kit: layout mode and card geometry in the kit, input shield set by the host (#13260)

LayoutMode (with its per-context publish and current), the card gap and the panic-free
clamp move into the kit's containers and panels, and Cards into stages. Cards::new takes
the workspace width instead of reading the ribbon's constant. The flag that says a report
covers the 3D view is now the kit's (input_blocked, set_input_blocked); the app's CadInput
guard sets it, and issue_report's own copy is gone. Serde names are unchanged, so saved
preferences load as before.

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

* UI kit: continuity rows leave the kit, the navigator figure takes its axes as input (#13261)

continuity_row and its explained and tip variants are CAD controls over the kernel's
Continuity: they move beside their callers in dialogs_surface, still drawn with the kit's
choice_row. The Preferences navigator figure takes its axes (screen direction and depth)
as NavigatorAxis input; SolidCraft computes them from its default camera. dashed_line is
painter geometry and moves into ui_kit::dash with its test. No kit file uses the engine,
the record or the viewport outside tests.

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

* UI kit: length fields take unit scaling and length parsing from the host (#13270)

The kit's length field converted millimetres to and from the design's units through three
prefs helpers. Those are the host's unit rules, so Expressions gains unit_scale,
length_number and parse_length_mm, and length_drag reads them from the context.
DocExpressions implements them with the same prefs helpers, so the app is unchanged. With
nothing installed the field edits plain millimetres. No kit file calls prefs outside
tests.

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

* Colour: the sRGB rule and the analysis palette are a shared crate, solidcraft-color (#13291)

geom's srgb module (the one sRGB transfer function every encode and decode goes through)
moves whole into solidcraft-color with its tests, and so does AnalysisColors, the analysis
palette the theme fills and the renderer reads. render, doc, io, engine, gpu and ui-egui use
the crate; there is no re-export at the old paths. The theme's unused material_color wrapper
is removed, so no non-test theme code names solidcraft_engine. Values are unchanged.

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

* Material editor: the Remove buttons are translated (material.editor.remove)

The editor's two Remove buttons called tr("Remove"), which looks up an id, so they read
"Remove" in every language. They use a new id, material.editor.remove, whose text in each
locale is the one that locale already gives the plain Remove button
prefs.network_host_remove (33 of 35 catalogs have it; the other two show English, as now).
The label-literal guard found it once the file's import named tr directly.

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

* Shared UI crates: ui-text, the message catalogs' machinery as a shared crate (#13290)

bidi, Catalog, Locale, lookup, formatting, labels and language choice move from ui-egui's
i18n into solidcraft-ui-text, a Shared crate any front end can use. The host installs its
catalogs once; with none installed every id reads as itself. SolidCraft's 35 catalogs, the
build script that merges them, and the adapters that read engine types (diagnostic,
tr_engine_error, and the given-name and browser-label vocabulary as the LocaleNames trait)
stay in the app's i18n. The app installs its catalogs when it starts, when an app is built
and in the widget test context. Call sites use the crate's path, with no re-export at the
old one; messages, ids and fallbacks are unchanged.

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

* i18n tests: the painted-English sweeps install the catalogs themselves (#13290)

no_english_is_painted_where_the_language_has_a_translation and
the_sweep_reports_english_only_where_a_translation_exists read translations without installing the
app's catalogs, so they passed only when another test in the same process had installed them first
(run alone, every id read as itself).

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

* Theme: the icon palette leaves Tokens; the icon renderer owns it (#13304)

A theme file's icon palette was a field of Tokens, so the token set depended on the icon roles
ui-egui's build script generates. It is now the icon renderer's per-thread state
(icon_svg::set_theme_palette), set alongside the theme's palette by theme::set_file_palette, and
theme_files resolves it with resolve_icon_palette (the same overlay merge as the tokens) and
exports it as an argument. Rendered pixels, theme files and exports are unchanged.

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

* Shared UI crates: the token contract as a shared crate, solidcraft-theme (#13304)

Tokens with SolidCraft's light and dark values in code (palettes.rs moves in from
assets/styles/ui, with the eight primary strokes it shared with the icon artwork written as
icon_strokes), colour vision, TabAccent, contrast checks, apply/visuals and the per-thread and
per-context theme state move into crates/theme, class Shared. It holds no data files and does not
depend on theme files or icon roles; with nothing set, components draw with the built-in tokens.

The theme system stays in ui-egui's theme module: the custom-theme glue (prepared theme, tab
targets and their fade, blur intent), publish_tab (was TabAccent::publish, which now stores its
result through TabAccent::set_current and set_published), set_file_palette, ViewportTheme.
ui-egui's build script no longer generates the primary-stroke constants; an icon test holds the
crate's literals equal to palette.toml's. Call sites use solidcraft_theme paths with no
re-export (one rename pass). Tokens, theme files and rendered pixels are unchanged.

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

* Theme: the icon lint and the docs read palettes.rs at crates/theme (#13304)

lint_icons.py compared the icon review page with assets/styles/ui/palettes.rs, which moved into
solidcraft-theme; it reads crates/theme/src/palettes.rs now (cargo xtask icons passes again), and
the READMEs, docs and test comments name the new path. set_custom and scoped_tokens say they set
tokens only, so a host with an icon renderer sets its palette too; the field contrast test applies
each built-in theme with set_file_palette, as the app does.

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

* Theme: format the field contrast test (#13304)

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

---------

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 17:01:03 +00:00
bherbruck 28efdb6681 CI: build without sccache when its download fails (#13368)
Since about 4:30 AM ET GitHub answers 504 for sccache's x86_64 release
asset, so every job that sets up the compiler cache fails before it builds,
including merge-queue runs. The setup step may now fail; the job then clears
RUSTC_WRAPPER and builds uncached, and the stats steps skip.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 16:37:48 +00:00
jyviko 8c197e4d18 MCP: the server machinery is a shared crate, solidcraft-mcp-server (#13338)
* Layers: a Shared crate class, and cargo xtask shared builds each one on its own (#12935)

A Shared crate depends only on other Shared crates, solidcore and external crates (UI
toolkits included); any layered or testkit crate may use one. The layer check reports a
Shared crate's dependency on an app crate, of any kind. cargo xtask shared checks each
Shared crate's manifest (workspace lints, licence, README) and that no include_str! or
include_bytes! reaches outside it, then builds and tests it with its Shared dependencies in
a scratch workspace of its own. cargo xtask ci runs it after the layer check.

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

* UI kit: the widget registry is a shared crate, ui-registry (#12936)

The handle, rect and count registry that UI tests, scenarios and the control channel read
moves out of scenario into solidcraft-ui-registry, the first Shared crate. The kit publishes
into it directly; scenario re-exports it, so scenarios, scripts and the rest of ui-egui are
unchanged and every handle keeps its name.

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

* Theme: the tab accent takes the tab, the sketch and the tool as data (#12938)

TabAccent::publish takes the open tab, whether a sketch is being edited and whether a tool
or dialog is active, instead of SolveApp; SolveApp::publish_tab_accent works the three out
in one place. scoped_panel_tokens is public, so a host can draw any component in another
palette or accent. The theme and its tests no longer name SolveApp, Services or workspace.
Accents are unchanged.

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

* UI kit: Preferences search is a kit module, settings_search (#13256)

prefs_search moves whole into ui_kit::settings_search: it filters any settings page the
kit draws, and SolidCraft's Preferences window is one use of it. It publishes its handles
into the widget registry and reads the same wall clock as before (web_time), so no kit
file calls the app for search any more. Every caller uses the new path; state ids, handle
names, matches and scores are unchanged.

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

* UI kit: the kit owns the menu entry and the list width (#13257)

Item (a command or ui.* action with its label, shortcut, icon, params, children, swatch
and tip) and LIST_W move from context_menu into ui_kit::menu, with the same serde form, so
the control channel's menu report is byte-identical. context_menu keeps building
SolidCraft's entries (cmd, and act over Item::action) and returns kit Items. No kit file
refers to context_menu any more.

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

* UI kit: number parsing and recent colours in the kit, expression support passed in by the host (#13258)

parse_number (decimal point or comma, Unicode minus) moves from prefs into the kit's
fields, and recent_colors moves whole into ui_kit::recent_colors beside the colour picker.
The kit no longer knows the expression language: a host installs an Expressions (is this
a literal, is this a unit, complete or step this text) per egui context, and fields read
it for label drags and completion. SolidCraft installs DocExpressions, built on doc::expr
and params_dialog::complete, when the app first draws into a context. Behaviour in the app
is unchanged.

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

* UI kit: navigation-bar values and the horizontal wheel in the kit (#13259)

The bar's edge, size, idle and button-kind values move beside the bar's drawing in
ui_kit::navigation as NavigationEdge, NavigationSize, NavigationIdle and NavigationKind (the
kit already has a container Edge). Serde names are unchanged, so saved preferences load as
before. take_horizontal_wheel moves into the theme picker, its only caller. SolidCraft's
slot table stays in navigation_bar.

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

* UI kit: layout mode and card geometry in the kit, input shield set by the host (#13260)

LayoutMode (with its per-context publish and current), the card gap and the panic-free
clamp move into the kit's containers and panels, and Cards into stages. Cards::new takes
the workspace width instead of reading the ribbon's constant. The flag that says a report
covers the 3D view is now the kit's (input_blocked, set_input_blocked); the app's CadInput
guard sets it, and issue_report's own copy is gone. Serde names are unchanged, so saved
preferences load as before.

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

* UI kit: continuity rows leave the kit, the navigator figure takes its axes as input (#13261)

continuity_row and its explained and tip variants are CAD controls over the kernel's
Continuity: they move beside their callers in dialogs_surface, still drawn with the kit's
choice_row. The Preferences navigator figure takes its axes (screen direction and depth)
as NavigatorAxis input; SolidCraft computes them from its default camera. dashed_line is
painter geometry and moves into ui_kit::dash with its test. No kit file uses the engine,
the record or the viewport outside tests.

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

* UI kit: length fields take unit scaling and length parsing from the host (#13270)

The kit's length field converted millimetres to and from the design's units through three
prefs helpers. Those are the host's unit rules, so Expressions gains unit_scale,
length_number and parse_length_mm, and length_drag reads them from the context.
DocExpressions implements them with the same prefs helpers, so the app is unchanged. With
nothing installed the field edits plain millimetres. No kit file calls prefs outside
tests.

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

* Colour: the sRGB rule and the analysis palette are a shared crate, solidcraft-color (#13291)

geom's srgb module (the one sRGB transfer function every encode and decode goes through)
moves whole into solidcraft-color with its tests, and so does AnalysisColors, the analysis
palette the theme fills and the renderer reads. render, doc, io, engine, gpu and ui-egui use
the crate; there is no re-export at the old paths. The theme's unused material_color wrapper
is removed, so no non-test theme code names solidcraft_engine. Values are unchanged.

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

* Material editor: the Remove buttons are translated (material.editor.remove)

The editor's two Remove buttons called tr("Remove"), which looks up an id, so they read
"Remove" in every language. They use a new id, material.editor.remove, whose text in each
locale is the one that locale already gives the plain Remove button
prefs.network_host_remove (33 of 35 catalogs have it; the other two show English, as now).
The label-literal guard found it once the file's import named tr directly.

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

* Shared UI crates: ui-text, the message catalogs' machinery as a shared crate (#13290)

bidi, Catalog, Locale, lookup, formatting, labels and language choice move from ui-egui's
i18n into solidcraft-ui-text, a Shared crate any front end can use. The host installs its
catalogs once; with none installed every id reads as itself. SolidCraft's 35 catalogs, the
build script that merges them, and the adapters that read engine types (diagnostic,
tr_engine_error, and the given-name and browser-label vocabulary as the LocaleNames trait)
stay in the app's i18n. The app installs its catalogs when it starts, when an app is built
and in the widget test context. Call sites use the crate's path, with no re-export at the
old one; messages, ids and fallbacks are unchanged.

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

* i18n tests: the painted-English sweeps install the catalogs themselves (#13290)

no_english_is_painted_where_the_language_has_a_translation and
the_sweep_reports_english_only_where_a_translation_exists read translations without installing the
app's catalogs, so they passed only when another test in the same process had installed them first
(run alone, every id read as itself).

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

* MCP: the server machinery is a shared crate, solidcraft-mcp-server (#12947)

JSON-RPC over stdio, the MCP lifecycle, tool and resource methods, tool
definitions with their annotations, the argument check and the text and
image results move to crates/mcp-server, which has no engine or GPU
dependency and is classed Shared. SolidCraft's tools, instructions,
resources and the agent's folders stay in crates/mcp and register into it
through the Tools trait; its public API, tools list and replies are unchanged.

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

* MCP: the shared server tests what it owns, on its own (#12947)

Batches (mixed, notifications only, the 1000 limit), an unasked response,
the protocol version fallback, null arguments, enum mismatches, the empty
default lists and the list-then-call order Tools::call relies on are tested
in solidcraft-mcp-server with a fake Tools; the protocol-only lines move out
of SolidCraft's wrapper tests.

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

* Docs: the architecture table lists the shared crates and the MCP split (#12947)

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

---------

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 16:01:11 +00:00
github-actions[bot] b76f681f01 Regenerate generated files and fold pending files [skip ci] 2026-11-08 15:52:32 +00:00
jyviko 82660d4ee2 Shared UI crates: ui-text, the message catalogs' machinery as a shared crate (#13316)
* Layers: a Shared crate class, and cargo xtask shared builds each one on its own (#12935)

A Shared crate depends only on other Shared crates, solidcore and external crates (UI
toolkits included); any layered or testkit crate may use one. The layer check reports a
Shared crate's dependency on an app crate, of any kind. cargo xtask shared checks each
Shared crate's manifest (workspace lints, licence, README) and that no include_str! or
include_bytes! reaches outside it, then builds and tests it with its Shared dependencies in
a scratch workspace of its own. cargo xtask ci runs it after the layer check.

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

* UI kit: the widget registry is a shared crate, ui-registry (#12936)

The handle, rect and count registry that UI tests, scenarios and the control channel read
moves out of scenario into solidcraft-ui-registry, the first Shared crate. The kit publishes
into it directly; scenario re-exports it, so scenarios, scripts and the rest of ui-egui are
unchanged and every handle keeps its name.

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

* Theme: the tab accent takes the tab, the sketch and the tool as data (#12938)

TabAccent::publish takes the open tab, whether a sketch is being edited and whether a tool
or dialog is active, instead of SolveApp; SolveApp::publish_tab_accent works the three out
in one place. scoped_panel_tokens is public, so a host can draw any component in another
palette or accent. The theme and its tests no longer name SolveApp, Services or workspace.
Accents are unchanged.

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

* UI kit: Preferences search is a kit module, settings_search (#13256)

prefs_search moves whole into ui_kit::settings_search: it filters any settings page the
kit draws, and SolidCraft's Preferences window is one use of it. It publishes its handles
into the widget registry and reads the same wall clock as before (web_time), so no kit
file calls the app for search any more. Every caller uses the new path; state ids, handle
names, matches and scores are unchanged.

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

* UI kit: the kit owns the menu entry and the list width (#13257)

Item (a command or ui.* action with its label, shortcut, icon, params, children, swatch
and tip) and LIST_W move from context_menu into ui_kit::menu, with the same serde form, so
the control channel's menu report is byte-identical. context_menu keeps building
SolidCraft's entries (cmd, and act over Item::action) and returns kit Items. No kit file
refers to context_menu any more.

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

* UI kit: number parsing and recent colours in the kit, expression support passed in by the host (#13258)

parse_number (decimal point or comma, Unicode minus) moves from prefs into the kit's
fields, and recent_colors moves whole into ui_kit::recent_colors beside the colour picker.
The kit no longer knows the expression language: a host installs an Expressions (is this
a literal, is this a unit, complete or step this text) per egui context, and fields read
it for label drags and completion. SolidCraft installs DocExpressions, built on doc::expr
and params_dialog::complete, when the app first draws into a context. Behaviour in the app
is unchanged.

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

* UI kit: navigation-bar values and the horizontal wheel in the kit (#13259)

The bar's edge, size, idle and button-kind values move beside the bar's drawing in
ui_kit::navigation as NavigationEdge, NavigationSize, NavigationIdle and NavigationKind (the
kit already has a container Edge). Serde names are unchanged, so saved preferences load as
before. take_horizontal_wheel moves into the theme picker, its only caller. SolidCraft's
slot table stays in navigation_bar.

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

* UI kit: layout mode and card geometry in the kit, input shield set by the host (#13260)

LayoutMode (with its per-context publish and current), the card gap and the panic-free
clamp move into the kit's containers and panels, and Cards into stages. Cards::new takes
the workspace width instead of reading the ribbon's constant. The flag that says a report
covers the 3D view is now the kit's (input_blocked, set_input_blocked); the app's CadInput
guard sets it, and issue_report's own copy is gone. Serde names are unchanged, so saved
preferences load as before.

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

* UI kit: continuity rows leave the kit, the navigator figure takes its axes as input (#13261)

continuity_row and its explained and tip variants are CAD controls over the kernel's
Continuity: they move beside their callers in dialogs_surface, still drawn with the kit's
choice_row. The Preferences navigator figure takes its axes (screen direction and depth)
as NavigatorAxis input; SolidCraft computes them from its default camera. dashed_line is
painter geometry and moves into ui_kit::dash with its test. No kit file uses the engine,
the record or the viewport outside tests.

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

* UI kit: length fields take unit scaling and length parsing from the host (#13270)

The kit's length field converted millimetres to and from the design's units through three
prefs helpers. Those are the host's unit rules, so Expressions gains unit_scale,
length_number and parse_length_mm, and length_drag reads them from the context.
DocExpressions implements them with the same prefs helpers, so the app is unchanged. With
nothing installed the field edits plain millimetres. No kit file calls prefs outside
tests.

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

* Colour: the sRGB rule and the analysis palette are a shared crate, solidcraft-color (#13291)

geom's srgb module (the one sRGB transfer function every encode and decode goes through)
moves whole into solidcraft-color with its tests, and so does AnalysisColors, the analysis
palette the theme fills and the renderer reads. render, doc, io, engine, gpu and ui-egui use
the crate; there is no re-export at the old paths. The theme's unused material_color wrapper
is removed, so no non-test theme code names solidcraft_engine. Values are unchanged.

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

* Material editor: the Remove buttons are translated (material.editor.remove)

The editor's two Remove buttons called tr("Remove"), which looks up an id, so they read
"Remove" in every language. They use a new id, material.editor.remove, whose text in each
locale is the one that locale already gives the plain Remove button
prefs.network_host_remove (33 of 35 catalogs have it; the other two show English, as now).
The label-literal guard found it once the file's import named tr directly.

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

* Shared UI crates: ui-text, the message catalogs' machinery as a shared crate (#13290)

bidi, Catalog, Locale, lookup, formatting, labels and language choice move from ui-egui's
i18n into solidcraft-ui-text, a Shared crate any front end can use. The host installs its
catalogs once; with none installed every id reads as itself. SolidCraft's 35 catalogs, the
build script that merges them, and the adapters that read engine types (diagnostic,
tr_engine_error, and the given-name and browser-label vocabulary as the LocaleNames trait)
stay in the app's i18n. The app installs its catalogs when it starts, when an app is built
and in the widget test context. Call sites use the crate's path, with no re-export at the
old one; messages, ids and fallbacks are unchanged.

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

* i18n tests: the painted-English sweeps install the catalogs themselves (#13290)

no_english_is_painted_where_the_language_has_a_translation and
the_sweep_reports_english_only_where_a_translation_exists read translations without installing the
app's catalogs, so they passed only when another test in the same process had installed them first
(run alone, every id read as itself).

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

* Model cache key: the colour crate is one of the crates a model is computed from (#13291)

render and doc build on solidcraft-color since the sRGB rule moved there, so its source decides
the saved model as much as theirs does. EVALUATION_CRATES in crates/io/build.rs lacked it, which
the_cache_key_covers_every_crate_the_model_is_computed_from caught: a change to the colour crate
would not have invalidated a cached model.

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

* ui-egui tests: each test that reads a translation installs the catalogs itself (#13290)

Lookups read only catalogs installed with i18n::install, which the app does at startup and
SolveApp::new and fonts::test_context do for the tests that use them. 55 tests read a message
before (or without) either of those, so they passed only when another test in the same process
had installed the catalogs first; run alone, as nextest runs each test, every id read as itself
("ui.agent_chat_model_no_tools", "Cam" where "Nocken" was expected). Each now calls
crate::i18n::install() first; a language scope entered before it would otherwise keep the
uninstalled English.

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

---------

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 15:12:10 +00:00
austin62jake fcd3186798 Convert Mesh: input and output schemas for mesh.convert (#13006) (#13007)
mesh.convert gets MeshConvertParams {body?, tolerance?, name?, participants?}
in a new schema/mesh.rs and replies with FeatureResult. body is text (blank
or null converts the selected body, as today); tolerance is a LengthExpr whose
range stays the kernel's to check; body_name and body_names are left out
because the solid keeps the mesh body's name.

The imported_box test helper in tests/mesh_convert.rs is now pub(crate) so the
schema tests convert the same box.

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-08 13:07:13 +00:00
MarkusRe 8706d623fb Agents and scripts: Flat Pattern, Export Bend Table and Path Flange describe their exact parameters and replies (#6581) (#12545)
* claim: #6581

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

* Sheet metal: schemas for flat pattern, bend table and path flange (#6581)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:28:02 +00:00
github-actions[bot] ae1d85e5d4 Regenerate generated files and fold pending files [skip ci] 2026-11-08 07:38:32 +00:00
github-actions[bot] 0d350e7b38 Regenerate generated files and fold pending files [skip ci] 2026-11-08 07:27:50 +00:00
PeppaWutz e69dfe126c Agents and scripts: Select and Clear Selection describe their exact parameters and reply (#13132) (#13174)
* Agents and scripts: Select and Clear Selection describe their exact parameters and reply (#13132)

select.set and select.clear now publish typed input and output schemas.
Each item of select.set is read as the handler reads it today, with the
same error messages, and both replies are built from the typed value and
serialize to the same JSON as before. Their lines leave unconverted.txt.

Made with Claude Sonnet 5.5 (medium effort, fast mode off) through the Claude API, with Claude Opus 5.5 as advisor.

* Select: the item test matches each item to the branch of its type

`conforms` doesn't check `const`, so every_selection_kind_is_taken now also
finds the SelectItem branch whose `type` const is the item's and checks the
item against it, and checks that every branch is tried.

Made with Claude Opus 5.5 in Claude Code (Ultra plan, default effort, fast mode off, Ultracode off).
2026-11-08 06:44:26 +00:00
0rdnungsamt 3c4b336080 Sketch: a preference to project references as construction geometry (#13142) (#13166)
* claim: #13142

* Sketch: a preference to project references as construction geometry (#13142)

Project, Intersect, Include 3D Geometry and the face edges a new sketch on a
face projects all came in as normal geometry, so they joined the sketch's
profiles even when they were only meant as references to draw against.

A new preference, "Project references as construction geometry" (Preferences >
Sketch, off by default), makes them start as construction geometry. They stay
linked to the model. It is the user's, like "Project face edges into new
sketches": it is kept when another design is opened or started, follows the
user across tabs and is saved with the preferences. sketch.project,
sketch.intersect and sketch.include_3d take an optional `construction`
boolean so scripts and agents can choose per call.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 06:34:21 +00:00
0rdnungsamt 1ec5eda955 Schemas: the rectangle and polygon sketch commands describe their parameters and replies (#13197)
* claim: #13196

* Schemas: the rectangle and polygon sketch commands describe their parameters and replies (#13196)

The seven commands sketch.rectangle.two_point, three_point, center, rounded
and sketch.polygon.inscribed, circumscribed, edge get a typed input contract
and an output schema; their replies are built from the reply types and their
lines leave schema/unconverted.txt. A test that passed the rectangle an `ids`
key it never read no longer does.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 06:33:30 +00:00
Sebastian 7bb016e5e4 Press Pull: the dialog the Q shortcut opens is titled "Press Pull", not "Extrude" (#11705) (#11712)
* claim: #11705

* Press Pull: the dialog the Q shortcut opens is titled PRESS PULL, not EXTRUDE (#11705)

Pressing Q (or picking Press Pull) with a planar face, a profile or
nothing selected opened the Extrude panel, so the shortcut and the
window disagreed. The panel now says PRESS PULL whenever it was opened
by Press Pull; a plain Extrude (E) still says EXTRUDE.

- lib.rs: the extrude dialog opened by solid.press_pull always carries
  the press_pull marker now (before, only with an empty selection)
- dialogs.rs: heading and issue-report title use PRESS PULL for it
- new ui.heading_press_pull message in assets/locales/pending/
- regression test in preselect_tests.rs + UI scenario pressing Q and E

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>

* Address review on #11712: Press Pull header, attribution row, precise test assertions

- dialogs.rs: the panel body header of a Press Pull extrude uses the
  solid.press_pull command (icon and label) instead of Extrude's
- attribution/pending/press-pull-dialog-title.md: row for the pending
  locale asset
- preselect_tests.rs: assert the drawn header by accessibility role and
  place in the dialog header band, for Q and for plain E
- scenario: check the press_pull flag and the painted header label

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>

* Fix f64/f32 in the header-label test assertion

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>

* Pin the header-label test to the dialog header band (no role filter)

The accesskit tree carries the painted title under its own node, not
Role::Label; the header band rect is the precise anchor, so a
same-named toolbar widget cannot satisfy the assertion.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>

* Press Pull title test: read the drawn header from the node value as well as its label

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

---------

Co-authored-by: fork <fork@foo.com>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
Co-authored-by: Brennen Herbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 06:04:14 +00:00
0rdnungsamt 98fdb6ef3c Tests: shader files keep LF line ends on Windows checkouts, so the GPU volume shader test passes there (#13138) (#13244)
* claim: #13138

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

* Shaders keep LF line ends on every checkout (#13138)

Tests embed .wgsl files with include_str! and match multi-line excerpts
written as Rust strings, which are always LF. On a Windows checkout with
core.autocrlf=true the shaders came out CRLF, so
gpu_volume_signed_primary_preserves_old_perspective_lens_none_and_zero_bytes
never matched. Mark *.wgsl as text with eol=lf, like requested_icons.txt.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 05:54:02 +00:00
github-actions[bot] 95fece4c7b Regenerate generated files and fold pending files [skip ci] 2026-11-08 06:20:59 +00:00
1993eg1 f959c0168d Add Hole command guide (machinist perspective) (#13125)
* Add Hole command guide (machinist perspective)

User guide for the Hole command: locating holes from two edges, UNC/UNF tapped holes, tap drill readout, blind tapped holes with a separate thread depth, and drill point angles. Tested in SolidCraft v0.30.1.

* Address review: reference edges, curved faces, multiple holes, presets

Updates from the CodeRabbit review, checked against the Hole dialog in SolidCraft v0.30.1:
- Reference edges must be straight and not parallel (no opposite sides).
- Single holes can go on curved faces; edge references need a flat face.
- Multiple placement: sketch points, set Placement to Multiple, select the points.
- Tapped holes carry the thread as a callout; the thread is not cut into the wall. Points to the Thread command for real thread geometry. No Modeled control appears in the v0.30.1 Hole dialog.
- Added a row for Personal Presets (Save new, Rename, Delete).
2026-11-08 05:42:38 +00:00
Kevin Ravensberg a2d42fdaec Render preview: near-black colours match the exported image (#5761) (#13219)
* Render preview: near-black colours decode as the exported image does

The product-lights branch of the viewport shader decoded the base colour
with `to_linear`, whose dark branch returns `c` instead of `c / 12.92`,
and on a linear attachment sent its encoded result back through
`out_color`, which decoded it the same way. Colours at or below about
#0a0a0a therefore lit far brighter in the Render preview than in the CPU
image (#050505: 38/255 against 5/255).

The branch now decodes with a piecewise `srgb_to_linear`, the transfer
`job::image` uses, returns linear light directly to a linear attachment
and encodes once otherwise. `to_linear` and the Design viewport's path
are unchanged.

The test-only offscreen renderer takes the preview lights, and a new test
renders a sphere under one uniform light for black, near-blacks on both
sides of the 0.04045 knee and mid-tones, on linear and sRGB attachments,
against the CPU image's decode, tone map and encode.

* Render preview: decode with the shader's existing srgb_decode

The product-lights branch now calls `srgb_decode`, the exact piecewise
decoder the sky fill already uses, instead of an identical new
`srgb_to_linear`.
2026-11-08 05:32:34 +00:00
Nathan Dane eaf8f27a7f Panels: show real Browser, History, Assistant and Render scene content (#11309) (#13199)
* claim: #11316

* Panels: support keyboard navigation and accessible controls (#11316)

* Panels: mount existing content in the shared layout

* Panels: fix validation imports and simplify host setup

* Panels: recover legacy sizes and preserve splitter controls

* Panels: confine legacy size recovery to shared import

* Assistant: keep the composer visible in short shared panels

* Assistant: reserve picker spacing above the composer

* Keep shared Assistant top controls within composer scroll budget
2026-11-08 05:28:07 +00:00
dudedagger42 8f1143b940 Naming: a piece name finds its face once the face is whole again (#11731) (#13147)
A reference picked while a face was split keeps the piece's qualifier
(F4:side:a1#[F4:start]). When that face is one face again, the whole face
still bounded by the faces the qualifier names now matches as the piece, so
the projected edge between it and F4:start is found. Numbered pieces stay
strict.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 05:22:09 +00:00
dudedagger42 778e7465e9 Circular Pattern of a cut works with another component active (#12996) (#13145)
* Add failing test: pattern of an untargeted cut with another component active (#12996)

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

* Pattern of an untargeted cut or intersect works on its source's component (#12996)

The copies took the bodies of the component active when the pattern was made,
so with another component active the pattern said there was no body to cut.

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

* Pattern: source-component scope only when the pattern's component has no body; also for misses and sheet cuts (#12996)

- The copies of an untargeted cut or intersect take the source's component only when the
  pattern's own component has no body (and no participants): a pattern that cut bodies of its own
  component before still does, so saved designs rebuild the same.
- That scope is chosen before the copy-miss count and the sheet-cut step, so all three use it.
- Test: pattern_of_an_untargeted_cut_still_cuts_its_own_components_body.

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 05:21:01 +00:00
PeppaWutz fa127b3e29 Agents and scripts: the feature folder commands, Reorder, Move Timeline Items and Edit Feature describe their exact parameters and reply (#13131) (#13180)
timeline.group, ungroup, move_to_group, rename_group, reorder, reorder_many,
move_many and redefine now publish typed input and output schemas, so agents
and scripts can read exactly which parameters they take and what they return,
and the warn and strict parameter checks cover them.

Each parameter is read the way its handler reads it today, with the same error
messages, and the replies are built from the typed values and serialize to the
same JSON as before. Their lines leave unconverted.txt. Part of #2829.

Made with Claude Sonnet 5.5 (medium effort, fast mode off) through the Claude API, with Claude Opus 5.5 as advisor.
2026-11-08 05:20:05 +00:00
CamWight 970eb715f3 Control channel: each step of engine.script gets the window's follow-up, like engine.batch (#12629) (#12630)
* claim: #12629

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

* Control channel: each step of engine.script runs as engine.execute runs it (#12629)

A script whose steps were all document commands ran them on the engine session alone, so the
window's part of each command was skipped: after `view.grid {"on": false}` the grid stayed on, and
the tool, the spin, the layout, preferences such as View Angles, and the first-body fit were missed
too. Now every step goes through `control::execute`, as each step of `engine.batch` does. The first
failing step still stops the script and the steps before it stay, with the same error words; a
script with a panel command answers as before.

The script's shape and its 100 000-step cap are read by one function, `script_steps`, for the
engine's own scripts and the window's.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 05:19:37 +00:00
Kai Evans a74e78fba8 Chamfer and fillet: an edge on a moved part is found where you picked it (#13239)
A chamfer or fillet without `body` on a moved part failed with
"[20, 0, 0] is where 2 edges meet". This happened when another part
had an edge at that point in its own frame. Take 2 parts that meet
in the world, but not in their own frames. A pick on the shared edge
cut the first part. edge_points looked for edges on every body in
that body's own frame. It ignored the world point of the pick.

- frames.rs remembers the world point of every pick, face or not.
  picked_world(local, nth) returns it. nth keeps 2 picks at one local
  point apart.
- edge_points maps the world point into each placed body's frame,
  hidden placements too. The world rows are built on first use. A
  body's box, grown by the reach, culls it. The polylines short-list
  edges. The exact curves decide (edge_foot: 33 samples, then a
  golden-section search). Bodies within 2 d + 1e-3 mm of the nearest
  refuse the pick. The reply names both: "[20, 0, 0] lies on edges of
  2 bodies, Base block and Cap block: name the one to chamfer with
  `body`". A hidden body adds "(X is hidden)".
- A seam at the pick, with no edge there, adds no second body.
- 2 placements of one body at one edge, or one local point, are 1 edge.
- With no placement to look through, the pick uses the bodies' own
  frames. That happens when every frame is singular. The pick is not
  passed unchecked.
- A pick's own `body` must exist and agree with `body`. Else the
  command refuses: "an edge names `Cap block`, but `body` is `Base
  block`: give one". Main finds the pick on Cap and cuts Base.
- The corner check runs on the body found. Corner and no-edge
  refusals print the world point.
- With 2 or more bodies, the feature keeps the body found as `body`.
  Picks on 2 or more bodies get 1 more check. If another
  body's frame has an edge at a pick's point, the command refuses. The
  reply asks for "each body in a command of its own". The ghost check
  skips edges whose polyline is too far before it runs edge_foot.
- solid.press_pull forwards `body` to the fillet. Its frames entry
  maps `body`, and its params name it.
- The fillet schema and the fillet and chamfer params describe the
  lookup.

Changes a default: no. Calls that cut the wrong part now refuse,
naming both parts. So do picks on the cut of a split body. A pick
whose own `body` disagrees with `body` refuses too. Dialogs already
send `body`.

Synthetic scripts ran against 6a1fe7468 (solidcraft-cli run,
--profile ci). 4 picks now cut the picked part, where main refused:
- Base's edge, with Cap moved 50 mm up.
- The same, with the root active and Cap moved 500 mm along x.
- Cap's edge at its world point.
- Base's edge with tangent_chain false.
Parts meeting in one frame or in the world are refused, naming
both. Press Pull there is refused too; main rounds Base block. A box
edge on a cylinder's seam is cut; main tries the seam and fails.

Tests: 19 in frames_tests.rs (16) and tests.rs (3). 16 fail on
6a1fe7468. 3 pass there: hostile picks, a singular placement, and 2
placements of one part. action_matrix
edge_round_actions_keep_the_picked_body expects the new refusal.

cargo xtask ci: 13948 passed, 32 failed, 67 ignored, 89 binaries.
- 22 failures are in tests/KNOWN_FAILING_TESTS.
- 7 fail alone on 6a1fe7468 too:
  3 pleat tests (FilletRearOuter, SMLib status 1001),
  smlib a_cylinder_side_section_is_its_circle,
  gpu gpu_thin_lens_focus_and_defocus_match_geometric_oracle,
  ui-egui the_extrude_arrow_passes_faces_beside_the_profile_and_in_a_free_drag,
  kernel boolean_props translation_invariant (a random 2-sphere case).
- 3 ui-egui agent_chat tests pass alone on both trees.
2026-11-08 05:19:04 +00:00
0rdnungsamt 2171393816 Section Analysis: the ground shadow stays soft instead of ending in a hard line at the section plane (#10708) (#12959)
* claim: #10708

* Section Analysis: the ground shadow is no longer cut by the section plane (#10708)

The ground shadow was drawn with the see-through pipeline, whose fragment
shader discards everything past the section plane, so a section ended the
soft shadow in a hard straight line. It now uses the pipeline reference
planes already use, the same blend and depth test without the cut.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 05:19:04 +00:00
PeppaWutz 3eacb3722e Agents and scripts: the occurrence commands, Capture and Revert Position, Move Bodies and Sketches to Component and Paste New describe their exact parameters and reply (#13186) (#13231)
* Agents and scripts: the occurrence commands, Capture and Revert Position, Move Bodies and Sketches to Component and Paste New describe their exact parameters and reply (#13186)

component.capture_position, component.revert_position, component.move_bodies,
component.move_sketches, component.paste_new, occurrence.copy, occurrence.drag,
occurrence.ground, occurrence.move and occurrence.properties now publish typed
input and output schemas. Each parameter is read the way its handler reads it
today and refused with the handler's own message, keys a handler reads only in
some cases (for example component with occurrence, tolerance with matrix) are
checked only in those cases, and the replies are built from the same types the
output schemas describe, so the JSON they return does not change. Their lines
leave unconverted.txt.

The base_transform check of occurrence.move is split out of the handler, and
move_vector and the matrix readers are made visible to the schema module, so
the contract uses the handler's own readers without changing what they accept.

Part of #2829.

Made with Claude Sonnet 5.5 (medium effort, fast mode off) through the Claude API, with Claude Opus 5.5 as advisor.

* Schema: base_transform is 4 columns of 4 numbers, as the handler reads it

Made with Claude Opus 5.5 in Claude Code (Ultra plan, default effort, fast mode off, Ultracode off).
2026-11-08 05:19:02 +00:00
PeppaWutz 2c82e702f5 Agents and scripts: Joint Origin, Rigid Group, Reach Point, Motion Link, Gear, Rack and Pinion, Screw and Cam describe their exact parameters and reply (#13185) (#13242)
joint.origin, joint.rigid_group, joint.reach, joint.motion_link, joint.gear,
joint.rack_pinion, joint.screw and joint.cam now publish typed input and
output schemas. Each parameter is read the way its handler reads it today and
refused with the handler's own message. Keys that a handler reads only in some
cases (for example a joint origin's end, or a screw's a and b) are read only in
those cases. The replies are built from the same types the output schemas
describe, so the JSON they return does not change. Their lines leave
unconverted.txt. Part of #2829.

Made with Claude Sonnet 5.5 (medium effort, fast mode off) through the Claude API, with Claude Opus 5.5 as advisor.
2026-11-08 05:18:21 +00:00
PeppaWutz f256bef34b Agents and scripts: Create Sketch, Normal/Construction, Delete, Drag, Move Point, Move Dimension Text, Sketch Options, Chain and Solve describe their exact parameters and reply (#13224) (#13273)
sketch.create, sketch.construction, sketch.delete, sketch.move_point,
sketch.drag, sketch.drag_end, sketch.dimension_text, sketch.options,
sketch.chain and sketch.solve now publish typed input and output schemas.
Each parameter is read the way its handler reads it today, in the cases it
reads it, and refused with the handler's own message. Each reply is built
from the typed value and serializes to the same JSON as before. Their lines
leave unconverted.txt.

Made with Claude Sonnet 5.5 (medium effort, fast mode off) through the Claude API, with Claude Opus 5.5 as advisor.
2026-11-08 05:18:14 +00:00
ABDULLAH 83cb509f81 Arabic: tanween in the canvas lock message and path tracing help, sketch term in the double Esc preference and Concentric help, timeline term in the Features under bodies note, Group holes preference wording (#13123)
* Arabic: tanween in the canvas lock message and path tracing help, sketch term in the double Esc preference and Concentric help

* Arabic: timeline term in the Features under bodies note

* Arabic: created-holes wording and collapsed-folder term in the Group holes preference
2026-11-08 05:17:41 +00:00
PeppaWutz c18aaf7cc3 Agents and scripts: Insert DXF, Insert SVG and Export DXF describe their exact parameters and reply (#12508) (#12601)
* Agents and scripts: Insert DXF, Insert SVG and Export DXF describe their exact parameters and reply (#12508)

sketch.insert_dxf, sketch.insert_svg and sketch.export_dxf now publish typed
input and output schemas. Each parameter is read the way its handler reads
it today and refused with the handler's own message, and the replies are
built from the same types the output schemas describe, so the JSON they
return does not change. Keys the handler reads only in some cases (a path
next to text, a height next to a width, a scale next to either) are read by
the check in the same cases only. Their lines leave unconverted.txt.

Part of #2829.

* Insert DXF and SVG: the schema refuses a blank or too-long path when it is read

* Insert DXF and SVG: the schema checks height, scale and path only when they are read

The published schema constrained `height` next to a `width`, `scale` next to
either, and `path` next to a `text`, which the handler never reads. Their shapes
now apply only in the cases the handler reads them, matching `leave_unread`.

Made with Claude Opus 5.5 in Claude Code (Ultra plan, default effort, fast mode off, Ultracode off).
2026-11-08 02:38:51 +00:00
bherbruck 1e6b1ec20f STEP import: a surface model of open shells, some closed, beside a turned sheet imports again (#13226)
A SolidWorks 2011 vendor part (a SHELL_BASED_SURFACE_MODEL of three OPEN_SHELLs, two of
them closed, the third using faces against their surfaces) came in as nothing on the SMLib
kernel: "STEP import: no bodies (... not supported yet: SMLib renumbered the solid's faces)".
SMLib's export numbers such a body's edges and vertices otherwise than the reader, so the
face map was refused even though the faces were in the reader's order. Where the full
numbering does not match, the faces are now taken in the record's order when each one is
checked to be the record's face (same surface kind, loops, and corners).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 01:55:43 +00:00
PeppaWutz 4d96c780e1 Agents and scripts: the sketch projection commands describe their exact parameters and reply (#12505) (#12598)
* Agents and scripts: the sketch projection commands describe their exact parameters and reply (#12505)

sketch.project, intersect, include_3d, project_to_surface,
intersection_curve, spun_profile, isoparametric_curve,
fit_curves_to_section, break_link and auto_project now publish typed input
and output schemas. Each parameter is read the way the handler reads it
today and refused with the handler's own message, and the replies are built
from the same types the output schemas describe, so the JSON they return
does not change. Their lines leave unconverted.txt.

The shape check of a reference is split out of the handler's reference
reader so the contract can use it without changing what it accepts.

Part of #2829.

* Sort the schema module list for rustfmt

Made with Claude Opus 5.5 in Claude Code (Ultra plan, default effort, fast mode off, Ultracode off).
2026-11-08 01:45:28 +00:00
github-actions[bot] 52afe212a8 Regenerate generated files and fold pending files [skip ci] 2026-11-08 01:37:34 +00:00
Daniel Dimitrov 0bb09d0b13 Hole: an option to put the holes one Hole command makes into a History folder (#12492) (#12493)
* Hole: an option to put the holes one Hole command makes into a folder (#12492)

Preferences > Tools > Hole > Group holes made together (hole_group, off by
default). With it on, when one Hole OK makes two or more holes, they go
into a feature folder named "Holes" in History, closed at first; opened,
each hole is edited on its own as before. A lone hole and an edit make no
folder, and holes added inside a folder get their folder there.

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

* Hole: a new Holes folder starts folded through the History's fold state

Since the History keeps each design's folds (#8886), fold the new folder
with timeline_folders::set_open instead of the browser's collapsed set.

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

* Hole: add several placements on one face

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 00:59:31 +00:00
PaddleStroke 11abdba1ba Export test: a box exports as its grid mesh (124 triangles), not 12 (#12781)
* Export test: count the box's grid mesh, not 12 triangles

each_format_has_an_export_command_of_its_own asserted a 20 x 10 x 5 box
exports as 12 triangles (an 684-byte STL). The mesher lays a planar face
out as a regular grid with sides at most a fifth of the body's diagonal
(kernel test a_box_is_a_regular_grid_on_every_face), so the box is 124
triangles and the STL 6284 bytes, on Linux and Windows alike. The test now
takes the count from the body's export mesh and checks the STL header,
the file size and the OBJ's faces against it; its line leaves
tests/KNOWN_FAILING_TESTS.

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

* Export test: expect the box's grid count, worked out from the mesher's rule

Review: a count taken from export_mesh would follow an export that drops
triangles. The test now expects 124 from the grid rule (cells at most a
fifth of the diagonal: 5 x 3 x 2 along 20 x 10 x 5).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 00:35:56 +00:00
PeppaWutz 7c9c1699de Agents and scripts: Inspect Document, Measure, Inspect Sketch, the command list and the material commands describe their exact parameters and reply (#12498) (#12591)
document.inspect, engine.commands, inspect.measure, material.assign,
material.list and sketch.inspect now publish typed input and output schemas.
Each parameter is read the way its handler reads it today, and is refused with
the handler's own message; inspect.measure reads only `items` when it is given
and `bodies` and `names_only` otherwise. The replies are built from the same
types the output schemas describe, so the JSON they return does not change.
Their lines leave unconverted.txt.

Part of #2829.
2026-11-08 00:04:33 +00:00
PeppaWutz cf9ae4d25e Agents and scripts: Auto Constrain, the constrainer, snapping and the curvature and polygon constraints describe their exact parameters and reply (#12506) (#12599)
* Agents and scripts: Auto Constrain, the constrainer, snapping and the curvature and polygon constraints describe their exact parameters and reply (#12506)

sketch.auto_constrain, sketch.auto_constrain_from_datum,
sketch.finish_auto_constrain, sketch.constrainer, sketch.constraint.curvature,
sketch.constraint.polygon, sketch.toggle_driven, sketch.glyphs and sketch.snap
now publish typed input and output schemas, so agents and scripts can read
exactly which parameters they take and what they return, and the warn and
strict parameter checks cover them.

Each parameter is read the way its handler reads it today, with the same error
messages. The replies are built from typed values and serialize to the same
JSON as before. Their lines leave unconverted.txt. Part of #2829.

* Drop constraint_result, which no command uses any more

Made with Claude Opus 5.5 in Claude Code (Ultra plan, default effort, fast mode off, Ultracode off).

* Check the coincident snap's point id in the reply test

Made with Claude Opus 5.5 in Claude Code (Ultra plan, default effort, fast mode off, Ultracode off).
2026-11-08 00:04:16 +00:00
PeppaWutz 1f29f432ee Agents and scripts: the 3D sketch commands describe their exact parameters and reply (#12507) (#12600)
sketch.line3d, sketch.spline3d, sketch.point3d and sketch.move3d now
publish typed input and output schemas. Each parameter is read the way
the handler reads it today and refused with the handler's own message,
and the replies are built from the same types the output schemas
describe, so the JSON they return does not change. Their lines leave
unconverted.txt.

Part of #2829.
2026-11-08 00:04:04 +00:00
PeppaWutz dc679cae34 Agents and scripts: the parameter commands and Evaluate Expression describe their exact parameters and reply (#12497) (#12590)
parameters.change, list, users, graph, export, import, complete and
expr.evaluate now publish typed input and output schemas. Each parameter is
read as its handler reads it today, with the same error messages; keys a
handler only reads in some forms (changes, delete, favorites, text, a text
unit) are checked only in those forms. Each reply is built from the typed value
and serializes to the same JSON as before. Their lines leave unconverted.txt.
2026-11-07 22:46:30 +00:00
PeppaWutz dbbfa437a5 Agents and scripts: the tangent arcs and circles, slots, splines, conics, ellipses and sketch text describe their exact parameters and reply (#12550) (#12604)
sketch.arc.tangent, sketch.blend_curve, sketch.centerline,
sketch.circle.two_tangent, sketch.circle.three_tangent, sketch.conic,
sketch.edit_text, sketch.ellipse, sketch.infinite, sketch.line.midpoint,
the three slot commands that were missing, the three spline commands
and sketch.text now publish typed input and output schemas.

Each parameter is read the way its handler reads it today and refused
with the handler's own message. Keys a handler reads only in some cases
(diameter without a radius, end without a sweep, minor without a minor
radius, the keys of one text outside edits) are checked only in those
cases. The replies are built from the same types the output schemas
describe, so the JSON they return does not change. Their lines leave
unconverted.txt.

Part of #2829.
2026-11-07 22:46:03 +00:00
bherbruck ba43d2798f Docs: RFCs 0001 (Craft bridge) and 0002 (plugins) (#13194)
* docs: RFC 0001, the Craft bridge between SolidCraft, CamCraft and PCBCraft

Starts docs/rfcs/ for cross-app proposals. RFC 0001 is a draft: one
.craftx exchange file with stable face names, an open-in launch, file
watching for live links, and a phased plan starting with Send to CamCraft.

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

* docs: RFC 0002, plugins

Turns the design in #1221 and docs/plugins.md into an RFC. docs/plugins.md
becomes a stub pointing to it; the two links to it are updated.

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

* docs: keep the RFC change docs-only

docs/plugins.md stays as a pointer to the RFC, so the code comment needs no edit.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 23:05:23 +00:00
MarkusRe 6a1fe74683 Sketch Tangent: a line's end point and a curve make a tangent joint (#12092) (#12377)
* claim: #12092

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

* Sketch Tangent: a line's end point and a curve make a tangent joint (#12092)

Tangent took two curves only and refused a picked end point with
"unknown sketch curve l1.end". It now takes a line's or an arc's end
point with a curve: the end lies on the curve (point on curve) and the
two curves are tangent there, in one undo step. Two end points make the
ends coincident and the curves tangent. Only the picked end moves, so
the line turns about its other end and gets longer or shorter; the
other curve stays.

Parallel, Perpendicular, Equal and Collinear take no point, so a click
on a line's end point now picks the line for them.

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

* Sketch Tangent: at a joint, add only the tangent (#12092)

When the picked end already lies on the other curve (it is that
curve's end, or a coincident joins them), Tangent added a redundant
point-on-curve, and a coincident given in the other order came back as
the existing constraint but was reported as new. Now such a constraint
counts as held and is not added. The end can't leave the other curve
at a joint, so the curve it ends stays and the other curve moves.

Raised in review by CodeRabbit.

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

* Sketch Tangent: x and y name the axes before a point does (#12092)

Tangent looks for an end point only when an operand names no curve and
no sketch axis. It checked for an existing axis line, so before that
line was created a point called x or y took the axis's place. Now x and
y always name the axes, as for the other constraints that take a line;
a curve with that id still wins.

Raised in review by CodeRabbit.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 22:08:10 +00:00
github-actions[bot] a9ff55ef27 Regenerate generated files and fold pending files [skip ci] 2026-11-07 22:31:45 +00:00
github-actions[bot] 3081630f92 Regenerate generated files and fold pending files [skip ci] 2026-11-07 22:17:47 +00:00
Daniel Dimitrov 323defa525 History: an option for a folder of one kind of feature to show that kind's icon (#12514) (#12515)
* History: an option for a folder of one kind of feature to show its icon (#12514)

Preferences > Layout > Folder icon from its features
(history_folder_kind_icon, off by default). With it on, a History folder
whose features (including those in folders inside it) all have the same
icon, and that holds no joints, shows that icon instead of the folder
icon, keeping its count; in both the History bar and the side History.

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

* Preferences: Folder icon from its features in a row of its own

One more box in the row of History and browser options widened the
Layout page past its edge at narrow windows, pushing the navigation
bar's segments outside it
(navigation_bar_appearance_segments_stay_inside_the_page_at_narrow_widths).
Also give the new messages in Korean.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 20:39:38 +00:00
PeppaWutz 5eaf8c4181 Agents and scripts: the named view commands and Redefine Sketch describe their exact parameters and reply (#12499) (#12592)
view.save, view.apply, view.rename, view.delete, view.list and
sketch.redefine now publish typed input and output schemas. Each
parameter is read the way the handler reads it today and refused with
the handler's own message, and the replies are built from the same
types the output schemas describe, so the JSON they return does not
change. view.apply only looks at the camera when no view name is given,
as the handler does. Their lines leave unconverted.txt.

The camera checks of view.save and view.apply, and the note that
sketch.redefine adds for a world plane in a moved component, are split
out of the handlers so the contracts use the same code.

Part of #2829.
2026-11-07 19:58:45 +00:00
PeppaWutz b464c48506 Agents and scripts: Frame Member and the profile library commands describe their exact parameters and reply (#12500) (#12593)
frame.member, frame.profile.insert, frame.profile.list and
frame.profile.properties now publish typed input and output schemas. Each
parameter is read the way its handler reads it today and refused with the
handler's own message, and frame.member reads the profile-sketch keys only
when the handler does. The replies are built from the same types the output
schemas describe, so the JSON they return does not change. Their lines leave
unconverted.txt.

Part of #2829.
2026-11-07 19:58:36 +00:00
PeppaWutz 7a2f2949a7 Agents and scripts: Copy and Paste Features, Add Recorded Feature and Export Command Script describe their exact parameters and reply (#12502) (#12595)
timeline.copy, timeline.paste, timeline.add and timeline.export_script now
publish typed input and output schemas, so agents and scripts can read
exactly which parameters they take and what they return, and the warn and
strict parameter checks cover them.

Each parameter is read the way the handler reads it today, with the same
error messages, and the replies are built from typed values that serialize
to the same JSON as before. Their lines leave unconverted.txt. Part of #2829.
2026-11-07 19:58:27 +00:00
PeppaWutz fcd4ccd09a Agents and scripts: the component commands describe their exact parameters and reply (#12504) (#12597)
component.activate, create, delete, from_bodies, list, properties, rename
and unlock now publish typed input and output schemas. Each parameter is
read the way its handler reads it today and refused with the handler's own
message, and each reply is built from the typed value and serializes to the
same JSON as before. Their lines leave unconverted.txt. Part of #2829.
2026-11-07 19:58:18 +00:00
PeppaWutz beaa43cb40 Agents and scripts: Create, Delete, List, Inspect, Limits, Drive, Solve and As-Built Joint describe their exact parameters and reply (#12503) (#12596)
* Agents and scripts: Create, Delete, List, Inspect, Limits, Drive, Solve and As-Built Joint describe their exact parameters and reply (#12503)

joint.as_built, joint.create, joint.delete, joint.drive, joint.inspect,
joint.limits, joint.list and joint.solve now publish typed input and output
schemas. Each parameter is read as its handler reads it today, with the same
error messages, in the same modes, and each reply is built from the typed
value and serializes to the same JSON as before. Their lines leave
unconverted.txt. Part of #2829.

* Joints: a joint origin's points take {x, y, z} in the schema too, as the handler reads them
2026-11-07 19:41:10 +00:00
PeppaWutz 6085423d0d Agents and scripts: the exploded view commands describe their exact parameters and reply (#12501) (#12594)
explode.create, explode.delete, explode.list and explode.show now publish
typed input and output schemas. Each parameter is read as its handler reads
it today, with the same error messages (scale is only read when moves is
not given, and show treats a missing or null view as "none"), and each reply
is built from the typed value and serializes to the same JSON as before.
Their lines leave unconverted.txt.
2026-11-07 19:23:14 +00:00
Loohi 4425b87d2b Typed schemas for Line, Point, Circle and Arc (#12953) (#12955)
* claim: #12953

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

* Typed schemas for Line, Point, Circle and Arc (#12953)

sketch.line, sketch.point, sketch.circle.center, sketch.circle.two_point,
sketch.circle.three_point, sketch.arc.three_point and sketch.arc.center_point
get typed input contracts and output schemas in schema/sketch_shapes.rs, and
leave unconverted.txt. The replies keep the same JSON.

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

* Blend test: give the chord line its id with `ids`

sketch.line takes `ids`; the `id` it was given was ignored, which the
parameter audit flags as a call strict would refuse.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 18:46:23 +00:00
filippecha 59d032659f Czech: translate the 748 missing UI strings, so the Czech interface has no English left (#12961)
* Czech: translate the 748 missing UI strings and fix 3 untranslated ones

The Czech catalog now covers every English message. New translations
go in assets/locales/pending/czech-complete.json; three messages that
were left in English in cs.json (Zoom, Polygon, Start) are translated
in place.

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

* Czech: name the render methods like their labels (Trasování paprsků CPU, Trasování cest GPU) and fix the direction of the graphics page subtitle

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 18:13:31 +00:00
Albert Shambazov 395679015f IO: sketch.export_dxf - units, layers and joined LWPOLYLINE loops (#12725) (#12726)
Opt-in options for sketch.export_dxf: units (scale + $INSUNITS), layer /
layers:"roles" (OUTLINE / CUT), join:true (closed LWPOLYLINE with arc
bulges, R2000). Default output stays byte-identical R12.

Co-authored-by: Albert Shambazov <319170918+shambazovalbert-art@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:48:25 +00:00
PaddleStroke 30be21a224 Browser: list the features that built each body under it, as an option (#11945) (#12709)
* claim: #11945

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

* wip: body features data, model.body_features and the Browser's Features under bodies (#11945)

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

* wip: fmt; engine body features test passes (#11945)

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

* kernel-cases: model.body_features is a listing with no kernel case

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

* Browser: a feature listed under two bodies is a Shift-click range end at each of its rows (#11945)

Each feature row under a body takes its own row-order key, <body row>/s:<id>, which still selects the feature, so a Shift-click on its second row no longer starts the range at its first row under another body.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:46:29 +00:00
bherbruck cd1685035a Release 0.40.0-rc.3 (#13144)
* Release 0.40.0-rc.3

Bumps the version to 0.40.0-rc.3 and folds the 15 pending format changes into format 51.

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

* Doc test: check the saved texture resource by its key, not the pending format name

Folding embedded-surface-textures into format 51 stops the design naming it.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 20:32:47 +00:00
bherbruck f472ee1a92 Start generated release notes at the newest published release (#13114)
* Start release notes at the newest published release

The nightly release job has failed since Nov 4 with HTTP 422 "body is too long": generated notes
started at the Latest stable release, v0.30.1, and grew past 125,000 characters. Start them at the
newest published release instead, pre-releases included. Let a refused Snap Store upload stop
failing the job, after the release is already staged.

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

* Pick the start tag by publication time; leave the Snap Store step as it was

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 20:03:17 +00:00
github-actions[bot] 0160e67ea1 Regenerate generated files and fold pending files [skip ci] 2026-11-07 20:15:34 +00:00
Steve Jason c29f396f72 Expressions: floor and ceil of 3 in / 1 in are 3, not 2 (#12489)
* Expressions: floor, ceil and trunc ignore float noise from unit conversion

* Expressions: snap only a few ulps to a whole number in floor and ceil

* Expressions: never snap a tiny value to zero in floor and ceil
2026-11-07 17:38:56 +00:00
Daniel Dimitrov 46edecca67 History: Edit Holes on a folder edits all its holes at once (#12520) (#12521)
A History folder that holds only holes (with the folders inside it) gets
Edit Holes in its right-click menu, in the History bar and the side
History, and over the control channel as ui.editFolder. It opens one
Hole dialog filled in from the first hole, with nothing to pick; OK
redefines each hole with the fields changed there, and each hole keeps
its own place and everything not changed.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:38:15 +00:00
Igor Bronovskyi a36c1743dc Ukrainian: translate 523 missing messages and four toolbar labels (#12566)
Fill the gaps in the Ukrainian catalog with a pending locale file, and translate the Appearance, Camera, Environment and Lighting toolbar labels.
2026-11-07 17:38:05 +00:00
PaddleStroke 7b3ca00054 Measure: two flat faces always show the distance between them (#11833) (#12708)
* claim: #11833

* wip: Measure: exact plane-to-plane distance with real ends (#11833)

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

* Measure: datum planes' meeting point from the cross product, exact near parallel

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

* Measure: the 1 and 2 end numbers never cover each other or the distance (#11833)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:37:16 +00:00
PaddleStroke 91a06a546e Sketch: replace a sketch's contents with another sketch, from this design or another file (#11125) (#12712)
* claim: #11125

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

* wip: sketch.replace command, tests and Browser menu entry (#11125)

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

* wip: menu test, formatting (#11125)

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

* kernel-cases: sketch.replace is 2D sketch only

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

* Replace Sketch: new ids never reuse the old contents' ids; a tool's picks on the replaced sketch are dropped

A reference to an old curve (a profile picked by its curves, a sweep path) now finds nothing and
the feature reports it, instead of quietly binding to a new curve that got the same id. Replacing
the sketch being edited clears the active tool's picks.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:36:45 +00:00
PaddleStroke fd02123b3e Sketch: a sketch that lost a projected edge opens for editing with its curves and the lost link marked (#12085) (#12714)
* claim: #12085

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

* Sketch: a sketch that lost a projected edge by name opens for editing with its curves and the lost link marked (#12085)

The sketch still fails and features built from it still fail; while it is
edited, the model holds it built with the lost named link marked lost, in the
final state only, so it can be repaired from inside.

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

* Sketch: a failed sketch held for its editor is built again on every evaluation (#12085)

When the sketch and every feature after it fail, the results end in the
same state as before an edit, so the held sketch kept its old geometry.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:36:35 +00:00
Mek 13ab728d07 Browser: Edit Feature from a component's Features folder gives the active component back (#12720) (#12723)
* claim: #12720

* Browser: Edit Feature from a component's Features folder gives the active component back (#12720)

A double-click on a feature row inside a placed component activates that
component and opens Edit Feature, so the edit happens in its placement.
OK and Cancel only closed the editor: the component stayed active, every
other body stayed faded and the History bar stayed filtered on it.

The double-click now remembers the component and placement active before
it, and the end of that feature's editor, however it ends, makes that one
active again, as leaving a sketch opened the same way does (#8127). A
component activated while the editor is open stays active.

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

---------

Co-authored-by: Mek <mf@linescode.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-07 17:36:25 +00:00
PaddleStroke 4a07865288 Brazilian Portuguese: translate the 3720 messages that still showed in English (#11760) (#12697)
* claim: #11760

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

* i18n(pt-BR): translate 1594 missing messages (labels, commands, ui)

Refs #11760

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

* i18n(pt-BR): translate 405 missing preference messages

Refs #11760

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

* i18n(pt-BR): translate 309 missing command help messages

Refs #11760

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

* wip: i18n(pt-BR): translate 600 of 1412 missing render messages

Refs #11760

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

* wip: i18n(pt-BR): translate 1000 of 1412 missing render messages

Refs #11760

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

* i18n(pt-BR): translate the remaining missing render messages

Refs #11760

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:27:34 +00:00
CamWight b73d6e6e6f Render: the crate's tests compile without the raytrace-cpu feature (#12535) (#12542)
The test helper mapped() in job/host_tests.rs reads a texture's encoded
bytes through TextureMap::source() and TextureSource::encoded_weak(), which
exist only with raytrace-cpu, but it wasn't gated, so the render crate's
tests failed to compile when it was checked on its own. The helper and the
two registered-executor tests that call it now take the cfg the helper's
other callers and its sibling cpu() already have. Workspace builds, which
turn the feature on, run all of them as before.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:27:26 +00:00
Cyryl Sochacki 3b2ca62c86 i18n(pl): translate the render workspace and 1290 missing UI messages (#12734)
* i18n(pl): translate the render workspace (1412 messages)

Every render.* message of en.json: output, lighting, environment, stage,
studios, appearance editor, lens effects, aging, shots and the render
browser. Added as one pending locale file.

* i18n(pl): translate 1290 missing UI messages

Everything else en.json gained since the last catch-up: Preferences,
tool help texts, panels, construction lines, fillet options, import
report, personal sheet metal rules, joint limits and recovery. With the
render file, pl covers every message of en.json.

* i18n(pl): "Of" picks a joint's motion, not an object

The nested Of row in the motion study and motion trace dialogs chooses
which of a joint's motions (turn or slide) the track drives.
2026-11-07 17:27:14 +00:00
ashxddaga 24c7bb7d70 fix(doc): permit circular edge centered on axis in axis_point plane construction (#12851) (#12882)
`cmd.construct.plane.axis_point` failed with "the point is on the axis"
when the user selected a circular edge whose center lies on the construction
axis (e.g. rim of a cylinder, revolve, or torus). This occurred because
`point()` resolved `GeoRef::Edge` to its center, which is collinear with
the axis of revolution.

Resolve the edge pick to the nearest point on the edge polyline, projecting
onto the exact circle geometry via `kernel::edge_circle` when available,
enabling unique planes through the axis at any picked azimuth around the rim.

Changes a default: no
Fixes #12851
2026-11-07 17:26:07 +00:00
bherbruck 670636ecd6 Update the solidcore kernel to main a445bdaac6 for rc.3 (#13015)
* Update the solidcore kernel to main 99ed57c4d4 for rc.3

The pin and its API changes, nine retired flags dropped, chamfers through
prepare_chamfers_along at the sizes asked, the whole-body section join, the
test and list updates this pin needs.

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

* Format crates/smlib/src/core/blend.rs

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

---------

Co-authored-by: bherbruck <bherbruck1@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 19:06:44 +00:00
github-actions[bot] 3623466acf Regenerate generated files and fold pending files [skip ci] 2026-11-07 19:19:41 +00:00
dudedagger42 ecbd05ef5e STEP import: an edge on a CURVE_REPLICA reads as its parent moved (#10755) (#12915)
* STEP import: tests for an edge or curve on a CURVE_REPLICA (#10755)

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

* STEP import: read a CURVE_REPLICA as its parent moved by the transformation (#10755)

An edge whose curve is a CURVE_REPLICA (FreeCAD writes one for a repeated
edge) was refused as an unsupported curve, leaving the body open. The reader
now keeps the parent and the CARTESIAN_TRANSFORMATION_OPERATOR_3D; edges use
the parent moved and scaled as the same kind of curve; the writer round-trips
it. A mirroring operator and a replica as a swept profile stay unsupported.

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

* STEP import: read CARTESIAN_TRANSFORMATION_OPERATOR_3D with its seven parameters (#10755)

The entity has a name and a description before its axes, origin and scale.

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 17:22:00 +00:00
MarkusRe c1bd94e937 Sketch Intersect: a cut through small rounds stays one closed profile (#12722)
* Sketch Intersect: test that a round refitted to the exact section still meets its neighbours

A failing test first. The section fit re-fits each arc to the exact crossings and moves only the
arc's own ends onto the new circle, so the lines beside it no longer meet it and the sketch finds
no closed profile (tangency-labs/solidcore#2908, point 1). `refine_rounds` wraps the per-curve
refit unchanged, so the test can call it.

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

* Sketch Intersect: a round refitted to the exact section keeps meeting the lines beside it

The section by a plane fits lines and arcs to the mesh crossings, then re-fits each arc to the
crossings that lie exactly on the surface. That refit moved only the arc's own ends onto its new
circle, so a wall beside an R2 round ended up to 1.2e-3 mm away from it, and the sketch, which joins
ends only within 1e-7, found no closed profile. A line meeting an arc's end now moves with it; an
arc that meets another curve there keeps its fit.

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

* Sketch Intersect: the refit test's crossings step by pi/16 with a constant that exists

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

* Sketch Intersect: the refit test names the distance a profile joins ends within (1e-4)

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

* Sketch Intersect: test that a round beside another arc is refitted when their joint stays put

A failing test first, for CodeRabbit's point on #12722: the fix kept the first fit of every arc
that meets another arc, even where the refit leaves their joint where it was. Before the fix such
a round was refitted, and that more exact result should stay.

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

* Sketch Intersect: a round beside another arc is refitted unless that tears their joint

The refit now runs for every arc. Lines that met its ends move with them, as before. Where an end
meets another arc, the refit stands when that end moves no further than the fit's join distance
(1e-7 of the size). Otherwise the arc keeps its first fit. So a round that was refitted before
tangency-labs/solidcore#2908 still is, unless the refit would open the outline.

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

* Sketch Intersect: test that a round beside another arc is refitted while its joint moves less than a profile joins across

A failing test first, for CodeRabbit's follow-up on #12722: with the joint moving 1e-5 to 9e-5 mm
the refit closed the profile before the fix, but the fix kept the first fit, because it guarded
the joint with the fit's join distance (1.4e-6 mm here) instead of the profile's 1e-4 mm.

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

* Sketch Intersect: a round beside another arc keeps its refit while their joint stays within a profile's join distance

The guard on a joint between two arcs now asks whether the refitted end stays within
ENDPOINT_JOIN_TOL (1e-4 mm, the distance a profile joins ends across, now public in
solidcraft-sketch) of where the other arc ends now, which may itself be refitted. Before, it asked
whether the end moved more than the fit's join distance (1e-7 of the size), which kept back refits
the profile would have closed across.

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

* Sketch Intersect: test that two arcs refitted the same way keep their joint

A failing test first, for CodeRabbit's next point on #12722: when a round and the arc beside it
both miss their circles by the same shift, both refits move their joint to the same point. Before
the fix both were refitted. bd7db2626 compares each refit with the other arc as it was fitted, so
past 1e-4 it keeps both first fits.

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

* Sketch Intersect: two arcs refitted the same way keep their refits and their joint

refine_rounds now refits every arc first, then takes refits back where two arcs met as fitted and
their ends, as they would now stand, are further apart than a profile joins ends across. It
repeats until no joint tears. So two arcs whose refits move their joint the same way both keep
them, and an arc is only kept as fitted where a refit would open the outline.

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

* Sketch: ENDPOINT_JOIN_TOL's comment says it bounds the search for joined ends, not that ends within it always join

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

* Sketch Intersect: the two-arc refit test checks that the whole outline stays closed

At e4c2545f8 the lines after an arc lost their move to the refitted arc's end, which the test
missed because it only looked at the joint between the arcs.

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

* Sketch Intersect: lines after a refitted arc keep their move to its ends

refine_rounds re-set every non-arc piece from its fit after the arcs were done, which undid the
move of each line that comes after the arc it meets. Only circles are refitted there now.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:19:22 +00:00
PaddleStroke 28fed4d6dd Sketch: a projected face follows its body when the body is resized (#12086) (#12732)
* claim: #12086

* wip: a projected face tells a thin arm's end corners apart by the sense of their lines (#12086)

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

* Sketch: lines leaving a projected face's corner match one to one (#12086 review)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:19:06 +00:00
PaddleStroke be648ef742 Sketch: a spline ending on another spline between its fit points closes a selectable profile (#12324) (#12774)
* claim: #12324

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

* Sketch: a curve ending on a spline between its samples closes the regions it bounds (#12324)

Free-form curves take part in profile detection as polylines with a 1e-3
chord tolerance, while curve ends are joined within 1e-5. A spline (or any
curve) ending on a spline between two samples, on the side away from the
chord, ended short of the polyline and was pruned as dangling, so the
regions it closes were not selectable. The polyline now passes through every
curve end lying on the exact segment.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:17:52 +00:00
DuBearte a8d775c12a Home: a tile keeps its title in view when its caption wraps (#7483) (#7716)
A tile on the Home page centres its title and a caption of up to two
rows. When the two are taller than the tile (a system font with tall
lines, such as the Chinese font on macOS), centring put the title's top
above the tile, where it was clipped. They now start at the top of the
text area instead, so the caption's end is clipped rather than the
title.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:17:38 +00:00
rlartot e02e1d40b5 Extrude: a profile still found after a sketch line cuts off a sliver or splits an offset curve (#9368) (#9563)
* claim: #9368

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

* Extrude: a profile still found after a sketch line cuts off a sliver or splits an offset curve (#9368)

A picked profile of lines, arcs and circles no longer fails when a line ending on
a circle gives it more arcs, and an analytic offset's loop that a line now cuts
falls back to its pieces (step 1 only). Free curves and free-form offsets keep
their exact meaning.

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 17:17:20 +00:00
PaddleStroke 136b62fa47 Dynamics: joint springs, motors and motion links (refs #9719) (#9768)
* claim: joint springs, motors and motion links (refs #9719)

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

* Dynamics: joint springs, motors and motion links (refs #9719)

Joint spring-dampers and motors act along joint values (revolute, slider, cylindrical and planar) as generalized forces; drives make a joint value follow a time profile (constant, linear, cycloidal ramp, harmonic, table) as a rheonomic constraint and report the effort it takes; motion links tie two values linearly (gear, rack and pinion, screw). Values they use are tracked in the state so angles count whole turns. The energy now carries the work put in (motors, drives, loads) and the energy dissipated, so total + dissipated - input stays constant. Integrators pass time to the stages (Dormand-Prince nodes).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:17:10 +00:00
phanoob a2c4a1267b Measure: the area of a flat face with holes comes from its exact edges (#12373)
* claim: #12372

* Measure: area of a flat face from its exact edges, less its holes

A flat face's area was taken from the display mesh. The mesh's polygon for a
round hole runs inside the circle, so the holes came out too small and the face
too big (2293.875 mm² instead of 2293.186 mm² for a 60 × 40 plate with holes of
radius 3 and 5).

The area of a flat face is now worked out along the exact edges of each loop
(Green's theorem): a line exactly, a circle or an arc in closed form, any other
curve with Gauss quadrature. The face's area is its outer loop's area less the
areas of its holes. When an edge does not lie in the face's plane, Measure falls
back to the mesh as before.

Fixes #12372

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

* Measure: a flat face's straight edges must lie in its plane; test an elliptic hole

A straight edge whose ends are off the face's plane now sends Measure back to
the mesh, as an off-plane curve already did.

A new test cuts an elliptic hole through a plate. Its edge is a spline, so it
goes through the Gauss quadrature path, on both faces. Without that path the
face read 2024.696 mm² from the mesh; it now reads πab within 10⁻² mm² (the
kernel's spline for the ellipse encloses 2·10⁻³ mm² more than πab).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:16:24 +00:00
PaddleStroke 735688dbc5 Render tests: two CPU raytracing tests skip when the crate is tested without raytrace-cpu (#12780)
* Render tests: skip two CPU raytracing tests when raytrace-cpu is not compiled in

cpu_atmosphere_pointer_fields_preserve_saved_camera_and_roundtrip_with_one_undo_each
and live_image_imported_mesh_keeps_selected_and_hovered_face_and_body_feedback
need CPU raytracing, which solidcraft-ui-egui only gets through feature
unification with the apps in a workspace build. Run on their own
(cargo test -p solidcraft-ui-egui) they failed. They now skip the same
way as their neighbours (live_cpu_fixture and the other CPU tests).

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

* Render tests: the CPU Atmosphere pointer-field test is native only, like its neighbours

On wasm the platform CPU host is the cooperative one, which never accepts
CPU raytracing, so the test could only ever skip there.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:16:16 +00:00
Daniel Dimitrov 0debd9759f Hole: an option to place several Single holes on one face (#12480) (#12481)
Preferences > Tools > Hole > Several holes on one face (hole_several, off
by default). With it on, in Single placement a pick elsewhere on a face
that already has a hole adds another hole, and a pick on a hole takes that
one out; each hole keeps its own drag handle, and OK makes one Hole
feature per hole. A round face split at its seam still gives one hole per
pick. Off, a face takes one Single hole as before.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:15:47 +00:00
austin62jake 2c6230eafc Agents and scripts: Render Settings and Save Render Settings describe their exact parameters and reply (#12783) (#12784)
* claim: #12783

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

* Render settings: input and output schemas for Render Settings and Save Render Settings (#12783)

Typed contracts and reply types for render.settings.get and
render.settings.set in a new schema/render_settings.rs, wired with
checked!, .contract and .output; the two leave schema/unconverted.txt.
The handlers build their replies from the new types through typed_reply;
the JSON is the same.

SETUP_FIELDS lists RenderSetup's 15 fields once. Save Render Settings'
schema and HDR capture's schema both publish it, and patched_settings
takes its bound from its length, so they can't drift: HDR capture's
schema had kept 13 as its bound and listed 14 fields without `coats`,
refusing patches the handler takes. A test checks the list against the
fields serde names for RenderSetup.

`name` reads with the setup name reader Apply Render Camera's contract
uses (now visible to the module beside it). Save Render Settings' `name`
and `set` are optional in the type and required in the schema, so a call
without them keeps serde's "missing field"; `null` and wrong types are
refused with the handler's message, and their published `null` default
is dropped (drop_default, also made visible).

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

* Save Render Settings: the check refuses a call without name or set

`name` and `set` were optional in SetParams, so the check passed a call
without them and the handler refused it with serde's "missing field".
They are required fields now: the check refuses the call itself, with
"`name` is required" or "`set` is required", and the published schema
lists both as required (no `default: null`, so drop_default is private
again in render_camera.rs).

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-07 17:15:36 +00:00
Homero Thompson 850509120b Thread: a modelled thread on one hole breaks the next hole (#11367) (#12491)
A modelled thread used to cut the whole body as facets: the Thread feature
swept the groove as a mesh and cut it with faceted_boolean at a tolerance of
a two-hundredth of the pitch, and faceted_boolean faceted the body operand
too. Every other cylinder in the body came out of that cut as a stack of
small planar faces, so the next hole's wall was no longer a cylinder and
thread_on refused it with "no cylindrical face there to thread".

The cut is exact now (the groove is an SMLib screw sweep cut by the exact
B-rep boolean), and faceted_boolean no longer facets the body. Both came
with the SMLib kernel merge, 34 minutes after the issue was filed, and the
reporter and the maintainer both reproduced on 0.30.1, which predates it.

This pins the behaviour down with tests, at the two layers the bug was
reported through: the model layer (the other hole's wall is still a cylinder
to thread) and the CLI (a modelled M6 thread in one hole of a block with two
holes, then a thread in the second, at gaps of 40, 20, 12 and 8 mm).

Fixes #11367

Co-authored-by: vdirienzo <vdirienzo@users.noreply.github.com>
2026-11-07 17:15:31 +00:00
PaddleStroke 72d2beff71 UI tests: clear each frame's texture changes so they pass with debug assertions (#12779)
Six ui-egui tests dropped an egui frame output without clearing its
texture changes. epaint debug-asserts that a dropped TexturesDelta is
empty, so a plain `cargo test -p solidcraft-ui-egui` (debug assertions
on) failed them; CI's `ci` profile turns debug assertions off and never
saw it. Each test now clears the texture changes right after the frame,
as the rest of the crate's tests do.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:15:24 +00:00
Homero Thompson 5395387dad Split Body: a body of two separate pieces splits through both (#6738) (#12511)
A body of two pieces joined apart could not be split by a plane through
both of them: the split stopped with "the tool does not split the body".

The split keeps only the non-empty halves the kernel's half-space cut
gives it, and refuses when fewer than two come back. For a body in two
pieces the half-space cut of one piece came back empty, so fewer than two
halves came back and the split was refused.

The behaviour is already right in the tree: the SMLib kernel merge
ea772a043 (#11351) brought it. This commit pins it down with tests, at the
two layers the bug was reported through, so it cannot come back:

* crates/doc/src/tests.rs - a_split_of_a_body_of_two_pieces_gives_the_halves_of_both
* crates/engine/src/tests.rs - split_body_of_a_body_of_two_pieces_splits_both

The engine test is the reporter's and the maintainer's scenario: one
sketch with two squares 20 mm across and 30 mm apart, both extruded 10 mm
as New Body, then Combine (join), then a plane through the middle of both
squares.

Fixes #6738

Changes a default: no
2026-11-07 17:15:11 +00:00
Daniel 0e83a0e494 Refusals: five windows drop a refused action's message once its inputs change (#12576)
* Parameters: a refused new parameter's message goes once the draft is corrected

In Parameters, adding a parameter whose name is taken, or whose expression does not evaluate, showed the reason in the window's banner. The banner stayed after the name or the expression was corrected, until the next Enter, beside an expression cell that already said the draft was fine.

The add row now keeps the draft it was refused with (name, unit, expression, comment), the design document the refusal was about and the message, as a command window does for a refused OK (#641). Each frame the window drops the message once the draft differs or the document is another one; a selection or display change keeps the document and so keeps the message. The refusal lives apart from the row edits' messages: the banner shows the last action's message if there is one, else the refusal. Discarding the row takes the message with it. The add row's bin button publishes the scenario handle param:new:delete.

Eight asserts read the banner through ParamsDialog::banner() instead of the error field. Regression tests: correcting_a_refused_new_parameter_clears_its_error and seven more in params_dialog.rs cover the corrected draft, a discarded row, a refusal on leaving the row, a design change, a row's own message beside the refusal, a selection, a unit or comment edit, and the banner's order.

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

* Design Properties: a refused OK's message goes once the fields change

In File, Design Properties, a value the design refuses (a part number with a control character, for example) keeps the window open with the reason. The message stayed after the field was corrected, until the next OK.

The window now keeps a snapshot of the fields at the refused OK, as a command dialog does for its inputs (#641). Each frame it compares the fields with the snapshot; once they differ, the message goes, also on an edit to another field, and the next OK checks again. The regression test the_window_edits_properties_in_one_undo_step_and_keeps_other_tools_data covers the corrected field, an edit to another field and the repeated refusal.

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

* BOM panel: a refused Apply's message goes once the row or its fields change

In the Bill of Materials window, edit a part number to one the design refuses and click Apply: the message shows. Correct the field, or pick another row: the message stayed until the next Apply. BomPanel::apply set the error on a refusal and nothing cleared it while the window was open.

The panel now keeps the design revision, the row and the fields Apply ran with and drops the message as soon as any of them differs, as a command dialog's refused OK does (#641); the next Apply checks again. The revision is part of the snapshot because the row index counts the design's rows, which an undo or a part added elsewhere can change under the message. The regression test a_refused_apply_keeps_its_message_until_the_row_or_its_fields_change covers the corrected field, another field, another row, an undo and the repeated refusal.

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

* Material editor: a refused Save's message goes once the draft changes

New material, name it Steel (a built-in), Save: refused with the reason. Rename it: the message stayed until the next Save. The editor set the error when save was refused and cleared it only on a successful save, Discard or a new draft.

The editor now keeps the design revision and the command Save ran with and drops the message as soon as the draft would run another, or the design changes under it, as a command dialog's refused OK does (#641); the next Save checks again. A Save refused while the draft fails its own validation (not reachable with a mouse, Save is disabled then) is kept the same way. The regression tests physical_material_refused_save_keeps_its_message_until_the_draft_changes and physical_material_refused_save_goes_when_the_design_changes in manage_dialogs_tests.rs cover the rename, an undo and the repeated refusal.

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

* Add agent: a refused Add's message goes once the fields change

Add agent, custom, click Add with the command empty: the dialog asks for a command. Type the command: the message stayed until Add was clicked again, the same for a missing model, a key without https or a bad URL. The dialog cleared the message only on a preset, kind or model change.

The dialog now keeps what the fields said when Add was refused and drops the message as soon as any of them changes, as a command dialog's refused OK does (#641); the next Add checks again. The snapshot holds whether a key is typed, not the key itself. The regression test a_refused_add_keeps_its_message_until_the_fields_change covers a program without a command and an endpoint without a model.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-07 17:14:18 +00:00
Daniel 9387a1b93c Web app: restore and recover a large design without freezing the page, with progress and Cancel (#12578)
* Web app: test that a staged restore counts only once its file is in

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

* Web app: restore and recover a large design in stages, with progress and Cancel

Restore and Open saved version in "Restore unsaved design?" and Recover in "Recover unsaved work?" opened their design with open_path, which reads and evaluates it on the page's only thread before returning. They now open through open_path_answered, a few features a frame, and what follows the open (the design gets back its file and name and is marked unsaved, the kept copy or the recovery goes) waits for the answer that it is in. Neither window comes back while the open runs.

SolveApp::open_path is now pub(crate), with a line saying it blocks; the one test outside the crate that used it opens with open_path_async and waits for the load.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:14:07 +00:00
Daniel 304aba2a49 Paste and Move Sketches: geometry pasted while an exploded view is shown stays put when the view closes (#12580)
* Tests: paste and move sketches while an exploded view is shown save what they save without it

Red first: the four commands work the saved geometry out from the exploded placements.

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

* Paste and Move Sketches: leave an exploded view shown out of the saved geometry

timeline.paste (within a design and from another design), component.paste_from_design and
component.move_sketches worked the pasted or moved geometry out from placed_document(), which
includes the placements an exploded view shows. Use design_document(), as component.transfer does.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:13:57 +00:00
Daniel b9270a6884 Section Analysis: a construction plane in a moved component cuts where it is drawn (#12582)
* Section Analysis: a construction plane in a moved component cuts where it is drawn

Make or insert a component away from the origin (or move it), activate it,
add a construction plane, then Section Analysis on that plane. The cut ran
where the plane would be if the component sat at the origin, while the
viewport drew the plane where the component stands, and the reply's origin
said the unplaced position. `inspect.section` read the plane from the
component frame; it now reads it from the placed model, like every other
world reply.

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

* Section Analysis: the placed-plane test turns the component too

A translation alone cannot tell a placed normal from an unplaced one.
The test now turns the component 90 degrees about X as well and takes
its expectation from the placed box as inspect.measure answers it: the
cut halves the box along Y and the normal lies along Y.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-07 17:13:46 +00:00
Daniel ea46e07300 Body names: a placed copy's name is taken by the rule, repair and configuration column commands (#12584)
`inspect.measure` lists a further placement's body as `KB (K:2)`, and
`appearance.assign` and `body.rename` take that name for the body the
placement shows. Three commands did not: `plastic.assign_rule`,
`body.repair` and a configuration column by body (`config.column` with
`material` or `appearance`) each answered "no body" for it. They now
resolve the name the same way (`own_body_name`), so an agent can act on
the name it was given. These commands take no picks, so the name is
mapped in the handler without placing the model.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-07 17:13:35 +00:00
Kai Evans eaf014eb54 Joint: a pick's snap is measured once, not every frame (#12864)
With a face picked, the Joint dialog's frames took 462 ms on a
1,956-face part. With both sides picked, they took 593 ms. snap_of
read a face's centre with Body::faces, which meshes the whole body.
A round face meshed it again in cylinder_face_at. The dialog's
commands and the picked glyphs called it every frame. The hover glyph
called it on each new hovered item.

- snap_of measures a face with ModelBody::joint_faces (now pub). Joint
  origins already use that cache, at the same tolerance.
- SolveApp keeps each pick's snap (picked_snap). It holds while the
  world state, design and open sketch keep the same Arcs. Any change
  starts a fresh memo. The dialog's commands, the picked glyphs and
  the hover glyph read it. It keeps the 16 latest, so hovering across
  a part stays bounded.
- A world rebuild that reuses an unchanged placement shares its face
  measurements too. Moving 1 part re-measures that part alone.

In the app, 10 s pointer sweeps give these median frames. With 1
pick: 462 ms before, 1.9 ms after. With 2 picks: 593 ms before,
1.9 ms after.

Test: a_picked_snap_is_measured_once_not_every_frame picks a hole wall
and a face. It then runs 3 frames plus the OK and preview commands.
Without the memo they tessellate for 26.7-27.2 ms in 4 runs. Without
the memo and the joint_faces read, 42.2-44.2 ms in 3 runs. With both,
0 ms. The memo matches a fresh measurement within 1e-9, and follows
a moved part.

cargo xtask ci: 13,644 passed, 36 failed, 67 ignored. 35 of the
failures also fail alone on upstream/main 871014f95. The other,
scale_invariant, is a random proptest case. It passes 3 of 3 runs
alone on both.
2026-11-07 17:13:26 +00:00
Kai Evans 1c03f2be06 Eval: To / From Object reads its face's part, not every part's placement (#12866)
Creating a joint rebuilt every feature after an Extrude From a face.
So did moving a part. That held even when the face's part stayed put.
placement_digest set reads.all for an Extrude with a From or To face.
It did the same for a Revolve To. Their cache key then took every
part's placement. Moving a third part changed that key. The chain
fingerprint then rebuilt every feature after it (#6183).

- placement_digest reads the bodies the stored face names find
  (PlacementReads::object_faces). Each comes with its own placement,
  as a combine reads its target. A join or cut also reads its
  targets, as other drawn tools do.
- It falls back to every placement when a name is empty or missing.
  It does the same when the name finds no body, or more than one. It
  also falls back when there is no input state. The face is then
  found by its point, on whatever body lies there.
- name_lookup::face_body finds the one body holding a stored name's
  face. It matches names exactly, as locate_face does.

No miss record is needed. with_named_face_points moves the point onto
the named face first. It fails the feature with "reselect it" when no
one face has the name. object_face's point search with a stored name
doesn't run.

Tests, in eval_cost_tests beside the cross-part cut test:
an_extrude_from_its_own_part_keeps_its_result_when_another_part_moves
and a_revolve_to_its_own_part_keeps_its_result_when_another_part_moves.
Moving the other part recomputes 1 feature on main, 0 here. Moving the
plate still recomputes 1, and the result follows it (#928).
an_extrude_from_a_face_without_a_name_extrudes_again_when_any_part_moves
holds the fallback.

The test part has 1,956 faces and 38 features after the extrude.
joint.create took 26.9 s before and takes 80-84 ms after.
occurrence.move of the third part took 26.7 s before, 0.1 ms after.

cargo xtask ci: fmt, clippy and the wasm check pass. 13,647 passed,
35 failed, 67 ignored. The 35 fail alone on upstream/main 871014f95 too.
2026-11-07 17:13:23 +00:00
austin62jake 3dc258c203 Agents and scripts: Interference describes its exact parameters and reply (#12875)
* Claim #12874: Interference schemas

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

* Interference: input and output schemas for inspect.interference

`InterferenceParams` gives the check its keys, `bodies` as a name list with
the handler's messages and `budget_s` bounded as the handler bounds it. The
reply is `InterferenceResult`, with the keys it had before. The command
leaves schema/unconverted.txt.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-07 17:12:50 +00:00
CaRLymx 565ba4a37e Catalan (ca): catch up with en.json (2026-11-07) (#12793)
* i18n(ca): catch-up amb en.json del 2026-11-07 (432 noves, 24 canviades)

Catch-up with `en.json` at `871014f95`.

* i18n(ca): el títol REST és Recolzament, com l'ordre i l'ajuda

---------

Co-authored-by: carlymx <24634598+carlymx@users.noreply.github.com>
2026-11-07 17:12:25 +00:00
Homero Thompson 1c1018aa6e Crash: a clean exit is reported as an unclean exit (#12842) (#12891)
* Crash: a clean exit is reported as an unclean exit

Task: https://github.com/tangency-labs/solidcraft/issues/12842

* Crash: do not report a clean exit as an unclean exit

Task: https://github.com/tangency-labs/solidcraft/issues/12842
2026-11-07 17:12:01 +00:00
PeppaWutz 1bd05a2c51 Agents and scripts: the timeline editing commands describe their exact parameters and reply (#12547) (#12602)
timeline.compute_all, delete, dependents, edit, inspect, rename, rollback,
roll_to and suppress now publish typed input and output schemas. Each
parameter is read as its handler reads it today, with the same error
messages, and a key a handler reads only in some cases (position when a
feature is given, feature when a group is given) is read only then. Each
reply is built from the typed value and serializes to the same JSON as
before. Their lines leave unconverted.txt.

Part of #2829.
2026-11-07 17:11:21 +00:00
ABDULLAH 5d8cd60f70 Arabic: tanween spelling in render help, sketch term in three preferences, verb form in the panel page reset hint (#12888) 2026-11-07 17:11:17 +00:00
PeppaWutz 046eba5ba3 Agents and scripts: the geometric sketch constraints describe their exact parameters and reply (#12549) (#12603)
sketch.constraint.coincident, collinear, concentric, equal, fix,
horizontal_vertical, midpoint, parallel, perpendicular, symmetry and tangent
now publish typed input and output schemas. Each key is read the way its
handler reads it today, in the same cases (for example `line` is not looked
at when Horizontal/Vertical gets `points`, and `a` and `b` are not looked at
when Equal gets `curves`), and is refused with the handler's own message. The
replies are built from the same types the output schemas describe, so the
JSON they return does not change. Their lines leave unconverted.txt.

Part of #2829.
2026-11-07 17:11:09 +00:00
austin62jake 96a6629ed1 Combine: input and output schemas (part of #2829) (#12918)
* Claim #12917: Combine schemas

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

* Combine: input and output schemas for solid.combine

solid.combine gets an input contract (CombineParams) and an output schema
(CombineResult: the feature result plus the new component's id). Each key
reads with the handler's messages, so strict mode keeps today's errors.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-07 17:10:56 +00:00
PeppaWutz 203ce2672b Agents and scripts: the sketch modify commands describe their exact parameters and reply (#12551) (#12605)
sketch.break, sketch.chamfer.distance_angle, sketch.chamfer.equal_distance,
sketch.chamfer.two_distance, sketch.extend, sketch.fillet, sketch.mirror,
sketch.move, sketch.offset, sketch.offset.break, sketch.pattern.circular,
sketch.pattern.rectangular, sketch.scale, sketch.trim and sketch.trim.path
now publish typed input and output schemas. Each parameter is read the way
the handler reads it today, with the same error messages, and keys a handler
only reads in some forms are only checked in those forms. The replies are
built from typed values and serialize to the same JSON as before. Their
lines leave unconverted.txt.

Part of #2829.
2026-11-07 17:10:48 +00:00
PeppaWutz 43c14bdbdf Agents and scripts: the render library apply and prepare commands describe their exact parameters and reply (#12552) (#12619)
The six render library commands (appearance, environment, physical,
surface and studio apply and prepare) now have typed input and output
schemas, so an agent can see exactly which parameters they take and what
they reply with. The commands behave as before; only what they report
about themselves is new.
2026-11-07 17:10:29 +00:00
austin62jake 6b5b49b285 Agents and scripts: the plastic features Boss, Lip, Snap Fit and Rest describe their exact parameters and reply (#12622) (#12623)
* claim: #12622

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

* Plastic features: input and output schemas for Boss, Lip, Snap Fit and Rest (#12622)

Typed contracts for plastic.boss, plastic.lip, plastic.snap_fit and
plastic.rest in schema/plastic.rs, wired with checked!, .contract and
.output; the four leave schema/unconverted.txt. Each reply is the
feature commands' FeatureResult.

Each key reads as its handler reads it, with its message: points and
directions through a small plastic_vector! type (a direction must not be
zero), sizes and angles as LengthExpr, AngleExpr and NumberExpr, and the
common feature keys through FeatureCommon. `touching` is declared because
the features join through the shared Boolean step. Lip's `type` and
`side` are new keyword_param! types in schema/types.rs.

The contracts refuse optional keys of the wrong type that the handlers
read as left out today (a size that isn't a number or text, a `body`
that isn't text, an `outside` or `new_body` that isn't true or false).

Tests in schema/plastic_tests.rs: the keys, 33 calls that keep their
message in strict (and an unknown `touching`, which strict gives after
the command's name), the 13 stricter keys, Boss's schema against its
handler, and each feature's reply against FeatureResult.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-07 17:10:20 +00:00
PeppaWutz 3d49d0cb0b Agents and scripts: Part Properties and Change Part Properties describe their exact parameters and reply (#12609) (#12634)
* Agents and scripts: Part Properties and Change Part Properties describe their exact parameters and reply (#12609)

component.metadata and component.metadata.set now publish typed input and
output schemas. Each parameter is read the way the handler reads it today and
refused with the handler's own message, and the replies are built from the
same types the output schemas describe, so the JSON they return does not
change. Their lines leave unconverted.txt.

Part of #2829.

Made with Claude Sonnet 5.5 (medium effort, fast mode off) through the Claude API, with Claude Opus 5.5 as advisor.

* Change Part Properties: say plainly how `values` reads each key

Made with Claude Opus 5.5 in Claude Code (Ultra plan, default effort, fast mode off, Ultracode off).
2026-11-07 17:10:05 +00:00
PeppaWutz 9ecf28834f Agents and scripts: Delete describes its exact parameters and reply (#12610) (#12635)
selection.delete now publishes typed input and output schemas. Each
parameter is read the way the handler reads it today and refused with the
handler's own message, and the reply is built from the same type the output
schema describes, so the JSON it returns does not change. Its line leaves
unconverted.txt.

Part of #2829.

Made with Claude Sonnet 5.5 (medium effort, fast mode off) through the Claude API, with Claude Opus 5.5 as advisor.
2026-11-07 17:09:55 +00:00
PeppaWutz 2e4b728fca Agents and scripts: Inspect Silhouette and Export View DXF describe their exact parameters and reply (#12611) (#12636)
* Agents and scripts: Inspect Silhouette and Export View DXF describe their exact parameters and reply (#12611)

inspect.silhouette and file.export_view_dxf now publish typed input and
output schemas. Each parameter is read the way the handler reads it today
and refused with the handler's own message, and the replies are built from
the same types the output schemas describe, so the JSON they return does
not change. Their lines leave unconverted.txt.

Part of #2829.

Made with Claude Sonnet 5.5 (medium effort, fast mode off) through the Claude API, with Claude Opus 5.5 as advisor.

* Export View DXF: read the triangle count through the meshes module

The typed reply read `meshes.triangles` in `view_dxf.rs`, which
`cargo xtask mesh-decisions` takes for a model decision on a mesh. The
count is only reported in the reply's stats, so `view_dxf_meshes.rs`,
the module that builds the drawing's display meshes, now hands it out.

Made with Claude Opus 5.5 in Claude Code (Ultra plan, default effort, fast mode off, Ultracode off).
2026-11-07 17:09:46 +00:00
thocsmith 5524224222 Motion Study: Play runs on by itself at 1× and slower speeds, without moving the mouse (#12620) (#12638)
* claim: #12620

* Motion Study: Play runs on by itself at every speed (#12620)

The Step slider was given the playhead itself. It is an integer slider,
and egui rounds such a slider's value every frame, so playback's gain of
0.4 of a step a frame at 1x went back to 0: Play only moved when a mouse
move made a long frame. The slider now gets a rounded copy and writes
back only when it is moved.

advance() also passes the end once the step shown would be after the
last one, so a loop no longer shows "11 / 10" before starting over.

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

---------

Co-authored-by: thocsmith <193096300+thocsmith@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:09:38 +00:00
austin62jake 2c4e3fe4d6 Assign Plastic Rule: the schema's bodies is a name or a non-empty list, not null (#12652)
The field is `Option<NameList>`, so its published schema allowed `null`
(from the `Option`) and an empty list (from `NameList`), both of which
the handler refuses ("`bodies` must list bodies"). It now has its own
schema: a name, or a list of 1 to 10 000 names. The reader is unchanged,
so strict still gives the handler's message for those calls. The schema
test adds both cases.

This is 9dfa1ade2 from #12314, pushed after review; the merge queue
merged #12314 from the commit before it, so main never got it.

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-07 17:09:27 +00:00
735 changed files with 74262 additions and 12539 deletions
+3
View File
@@ -1,6 +1,9 @@
# Preserve canonical source receipts and the runtime icon-discovery snapshot.
.gitattributes text eol=lf
/assets/icons/ui/design/requested_icons.txt text eol=lf
# Shaders reach the code through include_str!, and tests match multi-line WGSL excerpts
# written as Rust strings (always LF), so a CRLF checkout must not change them (#13138).
*.wgsl text eol=lf
# SolidCraft designs (.sc): a JSON document or a ZIP container, never Scala source, so they
# stay out of GitHub's language statistics. Git's own text/binary detection handles both kinds.
*.sc -linguist-detectable
+33 -3
View File
@@ -505,9 +505,15 @@ jobs:
key: native-tests
save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' }}
- name: Set up compiler cache
id: sccache
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
with:
version: v0.16.0
# A failed download of sccache (GitHub release outages answer 504) must not fail the build.
continue-on-error: true
- name: Build without the compiler cache if it could not be set up
if: steps.sccache.outcome == 'failure'
run: echo "RUSTC_WRAPPER=" >> "$GITHUB_ENV"
- uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28
with:
tool: cargo-nextest
@@ -528,7 +534,7 @@ jobs:
compression-level: 0
- name: Compiler cache statistics
if: always()
run: sccache --show-stats
run: if command -v sccache >/dev/null; then sccache --show-stats; else echo "sccache was not set up; built without it"; fi
tests:
name: Native Linux tests
@@ -576,9 +582,15 @@ jobs:
key: native-tests
save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' }}
- name: Set up compiler cache
id: sccache
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
with:
version: v0.16.0
# A failed download of sccache (GitHub release outages answer 504) must not fail the build.
continue-on-error: true
- name: Build without the compiler cache if it could not be set up
if: steps.sccache.outcome == 'failure'
run: echo "RUSTC_WRAPPER=" >> "$GITHUB_ENV"
- uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28
with:
tool: cargo-nextest
@@ -619,7 +631,7 @@ jobs:
cargo xtask known-failing --junit "$junit"
- name: Compiler cache statistics
if: always()
run: sccache --show-stats
run: if command -v sccache >/dev/null; then sccache --show-stats; else echo "sccache was not set up; built without it"; fi
- name: Upload test timings
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
@@ -692,9 +704,15 @@ jobs:
key: native-gpu
save-if: ${{ (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop') && matrix.shard == 1 }}
- name: Set up compiler cache
id: sccache
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
with:
version: v0.16.0
# A failed download of sccache (GitHub release outages answer 504) must not fail the build.
continue-on-error: true
- name: Build without the compiler cache if it could not be set up
if: steps.sccache.outcome == 'failure'
run: echo "RUSTC_WRAPPER=" >> "$GITHUB_ENV"
- uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28
with:
tool: cargo-nextest
@@ -715,7 +733,7 @@ jobs:
cargo xtask known-failing --junit "$junit"
- name: Compiler cache statistics
if: always()
run: sccache --show-stats
run: if command -v sccache >/dev/null; then sccache --show-stats; else echo "sccache was not set up; built without it"; fi
- name: Upload test timings
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
@@ -848,9 +866,15 @@ jobs:
key: doc-tests
save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' }}
- name: Set up compiler cache
id: sccache
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
with:
version: v0.16.0
# A failed download of sccache (GitHub release outages answer 504) must not fail the build.
continue-on-error: true
- name: Build without the compiler cache if it could not be set up
if: steps.sccache.outcome == 'failure'
run: echo "RUSTC_WRAPPER=" >> "$GITHUB_ENV"
# nextest does not run doc tests.
- name: Doc tests
env:
@@ -906,9 +930,15 @@ jobs:
key: oracle
save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' }}
- name: Set up compiler cache
id: sccache
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
with:
version: v0.16.0
# A failed download of sccache (GitHub release outages answer 504) must not fail the build.
continue-on-error: true
- name: Build without the compiler cache if it could not be set up
if: steps.sccache.outcome == 'failure'
run: echo "RUSTC_WRAPPER=" >> "$GITHUB_ENV"
- name: Build the command line
run: cargo build -p solidcraft-cli --profile ci --locked
- name: Replay tests/oracle and compare with expected.json
+6
View File
@@ -85,9 +85,15 @@ jobs:
key: fillet-rig
save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' }}
- name: Set up compiler cache
id: sccache
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
with:
version: v0.16.0
# A failed download of sccache (GitHub release outages answer 504) must not fail the build.
continue-on-error: true
- name: Build without the compiler cache if it could not be set up
if: steps.sccache.outcome == 'failure'
run: echo "RUSTC_WRAPPER=" >> "$GITHUB_ENV"
- name: Build the CLI (release)
run: cargo build --release --locked -p solidcraft-cli
- name: Run the fillet cases against the known-failing list
+7 -1
View File
@@ -149,9 +149,15 @@ jobs:
key: kernel-rig
save-if: ${{ github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop') }}
- name: Set up compiler cache
id: sccache
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
with:
version: v0.16.0
# A failed download of sccache (GitHub release outages answer 504) must not fail the build.
continue-on-error: true
- name: Build without the compiler cache if it could not be set up
if: steps.sccache.outcome == 'failure'
run: echo "RUSTC_WRAPPER=" >> "$GITHUB_ENV"
- name: Build the CLI (release) and the rig's runner
run: |
cargo build --release --locked -p solidcraft-cli
@@ -174,7 +180,7 @@ jobs:
retention-days: 1
- name: Compiler cache statistics
if: always()
run: sccache --show-stats
run: if command -v sccache >/dev/null; then sccache --show-stats; else echo "sccache was not set up; built without it"; fi
shard:
name: Kernel rig (${{ matrix.shard }})
+13 -2
View File
@@ -725,10 +725,16 @@ jobs:
run: |
tag="v$VERSION"
sha256sum --check SHA256SUMS.txt
# Generated notes start at the newest published release, pre-releases included. Left
# to GitHub they start at the Latest stable one, and the list outgrows the 125,000
# characters a release body may have.
start=()
prev="$(gh release list --exclude-drafts --limit 30 --json tagName,publishedAt --jq 'sort_by(.publishedAt) | last | .tagName // empty')"
if [ -n "$prev" ]; then start=(--notes-start-tag "$prev"); fi
# Stage all packages before publishing. Published tags are never replaced.
gh release create "$tag" --draft --prerelease --latest=false \
--title "SolidCraft v$VERSION (nightly)" --target "$GITHUB_SHA" \
--notes-file ../notes.md --generate-notes -- *
--notes-file ../notes.md --generate-notes "${start[@]}" -- *
gh release edit "$tag" --draft=false --prerelease --latest=false
echo "### Published nightly: $tag (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
- name: Create or update the draft release
@@ -760,8 +766,13 @@ jobs:
--target "$GITHUB_SHA" --prerelease="$PRERELEASE"
gh release upload "$tag" --clobber -- *
else
# As for nightlies: start the generated notes at the newest published release.
start=()
prev="$(gh release list --exclude-drafts --limit 30 --json tagName,publishedAt --jq 'sort_by(.publishedAt) | last | .tagName // empty')"
if [ -n "$prev" ]; then start=(--notes-start-tag "$prev"); fi
gh release create "$tag" --draft --title "SolidCraft v$VERSION" \
--target "$GITHUB_SHA" --notes-file ../notes.md --generate-notes "${pre[@]}" -- *
--target "$GITHUB_SHA" --notes-file ../notes.md --generate-notes "${start[@]}" \
"${pre[@]}" -- *
fi
{
echo "Draft **SolidCraft v$VERSION** at \`${GITHUB_SHA::9}\`:"
+1 -1
View File
@@ -13,7 +13,7 @@ Work: ROADMAP.md, "Where help is wanted most" in CONTRIBUTING.md, issues labelle
- Model decisions (what an operation does, which bodies it touches, which face or edge a point or name means, depths, nearness) use exact geometry; a mesh is for display, export, previews and mesh bodies (`xtask/mesh-decisions.txt` only shrinks): `cargo xtask mesh-decisions`.
- New kernel cases go to solidcore: `cargo xtask lists`.
- Everything is a command and programmatic calls never open dialogs: `skills/solidcraft-commands/SKILL.md`.
- Layering (geom, sketch/kernel/render, doc, io, engine, ui-egui, apps); only `crates/kernel` uses truck: `cargo xtask layers`.
- Layering; only `crates/kernel` uses truck; solidcore only via `solidcore-ops`/`-tess`/`-record` (`xtask/solidcore-seam.txt`): `cargo xtask layers`.
- One outer UI container, one camera path (`navigation::handle`), colours from theme tokens: `cargo xtask ui-containers`.
- Application code is Rust only and builds for wasm: `cargo xtask wasm`, `cargo xtask web-offline-test`.
- The product name is SolidCraft; the old name lives only in `docs/compatibility.md`.
+2 -1
View File
@@ -201,7 +201,6 @@ Editable drawing code, native projects and shipped exports are collected under `
| assets/platforms/windows/solidcraft.en-us.wxl | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
| assets/platforms/windows/solidcraft.wxs | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
| assets/shaders/viewport.wgsl | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/styles/ui/palettes.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/styles/ui/themes/gruvbox-dark.json | SolidCraft contributors | original SolidCraft theme file mapping SolidCraft's UI colours to the Gruvbox palette's colour values (Gruvbox by Pavel Pertsev, https://github.com/morhetz/gruvbox, MIT) | MIT OR Apache-2.0 |
| assets/styles/ui/themes/gruvbox-light.json | SolidCraft contributors | original SolidCraft theme file mapping SolidCraft's UI colours to the Gruvbox palette's colour values (Gruvbox by Pavel Pertsev, https://github.com/morhetz/gruvbox, MIT) | MIT OR Apache-2.0 |
| assets/styles/ui/themes/solidcraft-95.json | SolidCraft contributors | original SolidCraft theme file | MIT OR Apache-2.0 |
@@ -842,6 +841,8 @@ Original SolidCraft drawings, Open for owner review; no runtime assignments.
| crates/sketch/assets/fonts/liberation-sans/LiberationSans-Regular.ttf | Red Hat, Inc. (Liberation Fonts 2.x; digitized data Google) | https://github.com/liberationfonts (Debian fonts-liberation), unmodified; licence text in crates/sketch/assets/fonts/liberation-sans/OFL.txt; used for sketch Text outlines | SIL OFL 1.1 |
| crates/sketch/assets/fonts/liberation-sans/OFL.txt | Red Hat, Inc.; Liberation Fonts contributors | Unmodified accompanying font licence | SIL OFL 1.1 |
| assets/locales/pending/drag-to-zoom.json | SolidCraft contributors | Original drag-to-zoom labels and translations | MIT OR Apache-2.0 |
| `assets/locales/pending/hole-group-folder.json` | SolidCraft contributors | Original Hole grouping preference labels and translations | MIT OR Apache-2.0 |
| assets/locales/pending/press-pull-dialog-title.json | SolidCraft contributors | Original Press Pull dialog title translations for SolidCraft's English catalog; no external text used | MIT OR Apache-2.0 |
## Code adapted from sibling projects
Generated
+101 -27
View File
@@ -4051,15 +4051,16 @@ dependencies = [
[[package]]
name = "solidcore"
version = "1.0.1"
source = "git+https://github.com/tangency-labs/solidcore?rev=8ddce901671f1cebd9214a31d21895d725e886c8#8ddce901671f1cebd9214a31d21895d725e886c8"
source = "git+https://github.com/tangency-labs/solidcore?rev=a445bdaac60a980b0e9d1dba3ee28eb7ada44f50#a445bdaac60a980b0e9d1dba3ee28eb7ada44f50"
dependencies = [
"serde",
"thiserror 2.0.21",
]
[[package]]
name = "solidcore-record"
version = "0.1.0"
source = "git+https://github.com/tangency-labs/solidcore?rev=f5e8e9a29e0c9b7cf6c4040a5bc8878382b3c62f#f5e8e9a29e0c9b7cf6c4040a5bc8878382b3c62f"
source = "git+https://github.com/tangency-labs/solidcore?rev=a445bdaac60a980b0e9d1dba3ee28eb7ada44f50#a445bdaac60a980b0e9d1dba3ee28eb7ada44f50"
dependencies = [
"serde",
"serde_json",
@@ -4068,13 +4069,12 @@ dependencies = [
[[package]]
name = "solidcraft"
version = "0.40.0-rc.2"
version = "0.40.0-rc.3"
dependencies = [
"block2 0.6.2",
"eframe",
"egui",
"embed-resource",
"getrandom 0.3.4",
"hidapi",
"keyring",
"libc",
@@ -4092,10 +4092,13 @@ dependencies = [
"serde",
"serde_json",
"solidcraft-3dx-client",
"solidcraft-control",
"solidcraft-engine",
"solidcraft-gpu",
"solidcraft-space-mouse",
"solidcraft-theme",
"solidcraft-ui-egui",
"solidcraft-ui-text",
"ureq",
"windows-sys 0.61.2",
"winit",
@@ -4106,11 +4109,11 @@ dependencies = [
[[package]]
name = "solidcraft-3dx-client"
version = "0.40.0-rc.2"
version = "0.40.0-rc.3"
[[package]]
name = "solidcraft-cli"
version = "0.40.0-rc.2"
version = "0.40.0-rc.3"
dependencies = [
"image",
"log",
@@ -4122,9 +4125,32 @@ dependencies = [
"solidcraft-mcp",
]
[[package]]
name = "solidcraft-color"
version = "0.40.0-rc.3"
[[package]]
name = "solidcraft-control"
version = "0.40.0-rc.3"
dependencies = [
"getrandom 0.3.4",
"serde",
"serde_json",
]
[[package]]
name = "solidcraft-control-egui"
version = "0.40.0-rc.3"
dependencies = [
"egui",
"image",
"serde_json",
"solidcraft-control",
]
[[package]]
name = "solidcraft-derive"
version = "0.40.0-rc.2"
version = "0.40.0-rc.3"
dependencies = [
"proc-macro2",
"quote",
@@ -4133,13 +4159,14 @@ dependencies = [
[[package]]
name = "solidcraft-doc"
version = "0.40.0-rc.2"
version = "0.40.0-rc.3"
dependencies = [
"log",
"serde",
"serde_json",
"sha2 0.10.9",
"solidcore-record",
"solidcraft-color",
"solidcraft-derive",
"solidcraft-geom",
"solidcraft-kernel",
@@ -4152,7 +4179,7 @@ dependencies = [
[[package]]
name = "solidcraft-dynamics"
version = "0.40.0-rc.2"
version = "0.40.0-rc.3"
dependencies = [
"serde",
"serde_json",
@@ -4163,7 +4190,7 @@ dependencies = [
[[package]]
name = "solidcraft-engine"
version = "0.40.0-rc.2"
version = "0.40.0-rc.3"
dependencies = [
"image",
"log",
@@ -4172,6 +4199,8 @@ dependencies = [
"serde_json",
"serde_path_to_error",
"solidcore-record",
"solidcraft-color",
"solidcraft-control",
"solidcraft-doc",
"solidcraft-geom",
"solidcraft-io",
@@ -4186,7 +4215,7 @@ dependencies = [
[[package]]
name = "solidcraft-geom"
version = "0.40.0-rc.2"
version = "0.40.0-rc.3"
dependencies = [
"serde",
"serde_json",
@@ -4196,7 +4225,7 @@ dependencies = [
[[package]]
name = "solidcraft-gpu"
version = "0.40.0-rc.2"
version = "0.40.0-rc.3"
dependencies = [
"image",
"log",
@@ -4206,6 +4235,7 @@ dependencies = [
"serde",
"serde_json",
"solidcore-record",
"solidcraft-color",
"solidcraft-engine",
"solidcraft-geom",
"solidcraft-io",
@@ -4217,7 +4247,7 @@ dependencies = [
[[package]]
name = "solidcraft-io"
version = "0.40.0-rc.2"
version = "0.40.0-rc.3"
dependencies = [
"cadmpeg-core",
"cadmpeg-ir",
@@ -4233,6 +4263,7 @@ dependencies = [
"serde_json",
"sha2 0.10.9",
"solidcore-record",
"solidcraft-color",
"solidcraft-doc",
"solidcraft-geom",
"solidcraft-kernel",
@@ -4244,7 +4275,7 @@ dependencies = [
[[package]]
name = "solidcraft-kernel"
version = "0.40.0-rc.2"
version = "0.40.0-rc.3"
dependencies = [
"log",
"proptest",
@@ -4263,17 +4294,26 @@ dependencies = [
[[package]]
name = "solidcraft-mcp"
version = "0.40.0-rc.2"
version = "0.40.0-rc.3"
dependencies = [
"image",
"serde_json",
"solidcraft-control",
"solidcraft-engine",
"solidcraft-gpu",
"solidcraft-mcp-server",
]
[[package]]
name = "solidcraft-mcp-server"
version = "0.40.0-rc.3"
dependencies = [
"serde_json",
]
[[package]]
name = "solidcraft-profile"
version = "0.40.0-rc.2"
version = "0.40.0-rc.3"
dependencies = [
"serde_json",
"web-time",
@@ -4281,7 +4321,7 @@ dependencies = [
[[package]]
name = "solidcraft-render"
version = "0.40.0-rc.2"
version = "0.40.0-rc.3"
dependencies = [
"flate2",
"image",
@@ -4292,6 +4332,7 @@ dependencies = [
"serde_json",
"sha2 0.10.9",
"solidcore-record",
"solidcraft-color",
"solidcraft-geom",
"web-time",
"weezl",
@@ -4299,7 +4340,7 @@ dependencies = [
[[package]]
name = "solidcraft-sketch"
version = "0.40.0-rc.2"
version = "0.40.0-rc.3"
dependencies = [
"harfrust",
"serde",
@@ -4313,7 +4354,7 @@ dependencies = [
[[package]]
name = "solidcraft-smlib"
version = "0.40.0-rc.2"
version = "0.40.0-rc.3"
dependencies = [
"flate2",
"log",
@@ -4326,14 +4367,14 @@ dependencies = [
[[package]]
name = "solidcraft-space-mouse"
version = "0.40.0-rc.2"
version = "0.40.0-rc.3"
dependencies = [
"serde",
]
[[package]]
name = "solidcraft-step"
version = "0.40.0-rc.2"
version = "0.40.0-rc.3"
dependencies = [
"solidcore-record",
"solidcraft-geom",
@@ -4342,11 +4383,21 @@ dependencies = [
[[package]]
name = "solidcraft-testkit"
version = "0.40.0-rc.2"
version = "0.40.0-rc.3"
[[package]]
name = "solidcraft-theme"
version = "0.40.0-rc.3"
dependencies = [
"egui",
"serde",
"serde_json",
"solidcraft-color",
]
[[package]]
name = "solidcraft-ui-egui"
version = "0.40.0-rc.2"
version = "0.40.0-rc.3"
dependencies = [
"arboard",
"egui",
@@ -4362,23 +4413,44 @@ dependencies = [
"serde_json",
"sha2 0.10.9",
"solidcore-record",
"solidcraft-color",
"solidcraft-control",
"solidcraft-control-egui",
"solidcraft-engine",
"solidcraft-gpu",
"solidcraft-space-mouse",
"solidcraft-theme",
"solidcraft-ui-registry",
"solidcraft-ui-text",
"syn 2.0.119",
"sys-locale",
"tiny-skia",
"toml",
"ttf-parser",
"unicode-bidi",
"web-time",
"winreg",
"zip",
]
[[package]]
name = "solidcraft-ui-registry"
version = "0.40.0-rc.3"
dependencies = [
"egui",
]
[[package]]
name = "solidcraft-ui-text"
version = "0.40.0-rc.3"
dependencies = [
"log",
"serde_json",
"sys-locale",
"unicode-bidi",
]
[[package]]
name = "solidcraft-web"
version = "0.40.0-rc.2"
version = "0.40.0-rc.3"
dependencies = [
"eframe",
"egui",
@@ -4386,8 +4458,10 @@ dependencies = [
"log",
"percent-encoding",
"serde_json",
"solidcraft-control",
"solidcraft-engine",
"solidcraft-ui-egui",
"solidcraft-ui-text",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
@@ -5793,7 +5867,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.40.0-rc.2"
version = "0.40.0-rc.3"
dependencies = [
"flate2",
"proc-macro2",
+10 -3
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
exclude = ["vendor"]
[workspace.package]
version = "0.40.0-rc.2"
version = "0.40.0-rc.3"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -28,18 +28,25 @@ solidcraft-doc = { path = "crates/doc" }
solidcraft-io = { path = "crates/io" }
solidcraft-engine = { path = "crates/engine" }
solidcraft-ui-egui = { path = "crates/ui-egui" }
solidcraft-ui-registry = { path = "crates/ui-registry" }
solidcraft-color = { path = "crates/color" }
solidcraft-ui-text = { path = "crates/ui-text" }
solidcraft-theme = { path = "crates/theme" }
solidcraft-space-mouse = { path = "crates/space-mouse" }
solidcraft-mcp = { path = "crates/mcp" }
solidcraft-mcp-server = { path = "crates/mcp-server" }
solidcraft-control = { path = "crates/control" }
solidcraft-control-egui = { path = "crates/control-egui" }
solidcraft-derive = { path = "crates/derive" }
solidcraft-3dx-client = { path = "crates/threedx-client" }
# The Rust port of NVIDIA's SMLib (Apache-2.0), the B-rep kernel behind crates/smlib. Pinned;
# move the rev on its own commit. For a
# local checkout, add `[patch."https://github.com/tangency-labs/solidcore"] solidcore = { path =
# "../solidcore/crates/solidcore" }` to a `.cargo/config.toml` outside the repository.
solidcore = { git = "https://github.com/tangency-labs/solidcore", rev = "8ddce901671f1cebd9214a31d21895d725e886c8" }
solidcore = { git = "https://github.com/tangency-labs/solidcore", rev = "a445bdaac60a980b0e9d1dba3ee28eb7ada44f50" }
# Geometry as data (solidcore's layers §1, "Record"): the B-rep record, its structural validation
# and the native body file. Pinned like solidcore; for a local checkout, patch it as above.
solidcore-record = { git = "https://github.com/tangency-labs/solidcore", rev = "f5e8e9a29e0c9b7cf6c4040a5bc8878382b3c62f" }
solidcore-record = { git = "https://github.com/tangency-labs/solidcore", rev = "a445bdaac60a980b0e9d1dba3ee28eb7ada44f50" }
# test tooling: a dev-dependency only (xtask/src/layers.rs)
solidcraft-testkit = { path = "crates/testkit" }
+2 -1
View File
@@ -68,7 +68,8 @@ impl Control {
if !addr.ip().is_loopback() || addr.port() == 0 {
return Err("--connect requires a loopback address and a nonzero port (the app listens on 127.0.0.1)".into());
}
let token = solidcraft_engine::control_auth::client_token(addr.port(), token)
let token = solidcraft_engine::control_auth::APP
.client_token(addr.port(), token)
.map(|t| t.token)
.ok_or("no control token: pass --token, set SOLIDCRAFT_CONTROL_TOKEN, or run on the app's computer (its token file)")?;
if token.len() > 4096 {
+1 -1
View File
@@ -916,7 +916,7 @@ fn serve_mcp(
/// The port of the running desktop app: the one its discovery file names, if something listens
/// there (a file left by an app that crashed names a closed port).
fn discover_app() -> Option<u16> {
let port = solidcraft_engine::control_auth::read_current_port()?;
let port = solidcraft_engine::control_auth::APP.read_current_port()?;
let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port));
std::net::TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(500)).ok().map(|_| port)
}
+1 -1
View File
@@ -1308,7 +1308,7 @@ fn edge_round_actions_keep_the_picked_body() {
let mut unscoped = p.clone();
unscoped.as_object_mut().unwrap().remove("body");
let err = s.scratch().execute(id, &unscoped).unwrap_err().to_string();
assert!(err.contains("edges meet"), "{err}");
assert!(err.contains("[10, 0, 20] lies on edges of 2 bodies, Plate and Block"), "{err}");
perform(&mut s.scratch(), &a.act, &errors(&s)).unwrap();
tried += 1;
}
+2 -2
View File
@@ -318,7 +318,7 @@ fn a_model_saved_under_another_check_mode_is_evaluated_again() {
/// opened from its model or evaluated anew. Two edges of a 20 mm box rounded r2 (a mitre at the
/// corner (20, 0, 20)), saved; opened, the upright edge there rounded r3: the corner all three
/// rounded at once make (SMLib's setback corner, kernel `three_rounds_at_a_corner`), 15 faces and
/// 7928.142563938 mm³, the same body as the design evaluated anew.
/// 7927.72428251348 mm³, the same body as the design evaluated anew.
#[test]
fn a_later_round_after_opening_blends_the_saved_rounds_again() {
let mut built = Session::default();
@@ -336,7 +336,7 @@ fn a_later_round_after_opening_blends_the_saved_rounds_again() {
let b = &st.body("Box").unwrap().body;
let v = solidcraft_engine::kernel::measure(b).unwrap().volume;
assert_eq!(b.face_count(), 15);
assert!((v - 7928.142563938).abs() < 1e-6, "{v}");
assert!((v - 7927.72428251348).abs() < 1e-6, "{v}"); // solidcore#2382's setback corner
}
assert_eq!(model("later round", &cached), model("later round", &rebuilt));
let _ = std::fs::remove_file(&path);
+2
View File
@@ -22,7 +22,9 @@ profile = ["solidcraft-engine/profile"]
[dependencies]
solidcraft-engine = { workspace = true }
solidcraft-control = { workspace = true }
solidcraft-ui-egui = { workspace = true }
solidcraft-ui-text = { workspace = true }
egui = { workspace = true }
log = { workspace = true }
serde_json = { workspace = true }
+1 -1
View File
@@ -6,7 +6,7 @@ use std::sync::mpsc::{Receiver, Sender, TryRecvError};
use std::time::Duration;
use serde_json::{Value, json};
use solidcraft_ui_egui::ControlRequest;
use solidcraft_control::ControlRequest;
use wasm_bindgen::{JsValue, closure::Closure};
const MAX_PENDING: usize = 256;
+43
View File
@@ -173,6 +173,35 @@ pub fn is_open(open: Option<&str>, wanted: &str) -> bool {
open == Some(wanted)
}
/// What follows once a design restored from a kept copy, or recovered, is open: it opens in
/// stages, a few features a frame with progress and Cancel (#10198), so this waits for the answer.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AfterOpen {
/// A kept copy (#10220): it is its own design again (saving goes to `path`, it is named
/// `name`), with its changes unsaved; then the copy `id` goes when `drop` says so.
KeptCopy { opened: String, id: String, path: Option<String>, name: String, drop: bool },
/// The saved design offered beside a kept copy: the copy `id` goes when `drop` says so.
KeptSaved { opened: String, id: String, drop: bool },
/// Unsaved work from the page's last visit: it belongs to `path` again, named `name`, unsaved.
Recovery { opened: String, path: Option<String>, name: String },
}
impl AfterOpen {
/// The file the open reads.
pub fn opened(&self) -> &str {
match self {
AfterOpen::KeptCopy { opened, .. } | AfterOpen::KeptSaved { opened, .. } | AfterOpen::Recovery { opened, .. } => opened,
}
}
}
/// Whether the open of `wanted` answered with `reply` put it in: the answer says so, and it is
/// the design now open (`open`: the open design's path). A refusal, a Cancel or another file
/// leaves the copy or the recovered work offered.
pub fn opened(reply: &Value, open: Option<&str>, wanted: &str) -> bool {
reply["ok"].as_bool() == Some(true) && is_open(open, wanted)
}
/// Whether leaving the page asks through the browser's "leave site?" box: there is unsaved work,
/// and either the user still wants to be asked or it isn't all kept in the browser yet (with the
/// keep preference off it never is, so there the box always stays).
@@ -224,6 +253,20 @@ pub fn answers(entry: &Entry, saved_exists: bool) -> Vec<Answer> {
mod tests {
use super::*;
/// A restored or recovered design opens in stages (#10198): only an answer that it is in, for
/// the file asked for, counts; a refusal, a Cancel or another design open leaves it offered.
#[test]
fn a_staged_restore_counts_only_when_its_file_is_in() {
let copy = "/opfs/.kept/a.sc";
assert!(opened(&json!({"ok": true}), Some(copy), copy));
assert!(!opened(&json!({"ok": false, "error": "cancelled"}), Some(copy), copy));
assert!(!opened(&json!({"ok": false, "error": "not a design"}), None, copy));
assert!(!opened(&json!({"ok": true}), Some("/opfs/other.sc"), copy));
assert!(!opened(&json!({}), Some(copy), copy));
let after = AfterOpen::KeptCopy { opened: copy.into(), id: "a".into(), path: None, name: "Bracket".into(), drop: true };
assert_eq!(after.opened(), copy);
}
fn entry(id: &str, changed_at: f64, path: Option<&str>) -> Entry {
Entry { id: id.into(), name: "Bracket".into(), path: path.map(str::to_string), changed_at }
}
+4 -4
View File
@@ -48,15 +48,15 @@ impl Download {
}
if solidcraft_ui_egui::documents::any_dirty(app) {
let mut answer = None;
let closed = ui_kit::Window::new(egui::Id::unique("open_link"), "status_warning", solidcraft_ui_egui::i18n::tr("web.open_link"))
let closed = ui_kit::Window::new(egui::Id::unique("open_link"), "status_warning", solidcraft_ui_text::tr("web.open_link"))
.required()
.show_body(ctx, |ui| {
ui.label(solidcraft_ui_egui::i18n::tr("web.open_link_confirm"));
ui.label(solidcraft_ui_text::tr("web.open_link_confirm"));
ui.horizontal(|ui| {
if ui_kit::button(ui, solidcraft_ui_egui::i18n::tr("ui.open"), ui_kit::Button::Normal).clicked() {
if ui_kit::button(ui, solidcraft_ui_text::tr("ui.open"), ui_kit::Button::Normal).clicked() {
answer = Some(true);
}
if ui_kit::button(ui, solidcraft_ui_egui::i18n::tr("ui.cancel"), ui_kit::Button::Normal).clicked() {
if ui_kit::button(ui, solidcraft_ui_text::tr("ui.cancel"), ui_kit::Button::Normal).clicked() {
answer = Some(false);
}
});
+90 -35
View File
@@ -382,6 +382,9 @@ mod web {
keep_track: std::collections::HashMap<u64, Track>,
/// Every unsaved design's kept copy is current (and the preference is on).
all_kept: bool,
/// A kept copy or recovered work being opened, in stages with progress and Cancel
/// (#10198): the answer to come and what follows it ([`Shell::restored`]).
restoring: Option<(std::sync::mpsc::Receiver<serde_json::Value>, keep::AfterOpen)>,
}
impl Shell {
@@ -633,7 +636,7 @@ mod web {
/// (when it has a file) and Discard.
fn keep_prompt(&mut self, ctx: &egui::Context) {
let offered = self.files.keep_offered.borrow().clone();
if offered.is_empty() || !self.files.ready.get() {
if offered.is_empty() || !self.files.ready.get() || self.restoring.is_some() {
return;
}
let now = (js_sys::Date::now() / 1000.0) as u64;
@@ -673,24 +676,21 @@ mod web {
return;
}
};
self.app.open_path(&copy);
if self.app.session.path.as_deref() != Some(copy.as_str()) {
// Not opened (the status says why): it stays offered.
return;
}
// It is its own design again (saving goes to its file), with its changes unsaved.
self.app.session.path = entry.path.clone();
self.app.session.doc_mut().name = entry.name.clone();
self.app.session.mark_unsaved();
self.app.home.open = false;
// Finished in `restored` once it is in.
let after = keep::AfterOpen::KeptCopy {
opened: copy.clone(),
id: entry.id.clone(),
path: entry.path.clone(),
name: entry.name.clone(),
drop: plan.drop_copy,
};
self.restore(&copy, after);
return;
}
if let Some(path) = &plan.open_saved {
if vfs::exists(path) {
self.app.open_path(path);
if !keep::is_open(self.app.session.path.as_deref(), path) {
// Not opened (the status says why): the copy stays offered.
return;
}
self.restore(path, keep::AfterOpen::KeptSaved { opened: path.clone(), id: entry.id.clone(), drop: plan.drop_copy });
return;
} else {
// A file on the computer: the browser asks for access again, in this click.
// The copy goes once the file has opened, not before: if access is refused,
@@ -708,8 +708,62 @@ mod web {
}
}
if plan.drop_copy {
self.files.keep_offered.borrow_mut().retain(|e| e.id != entry.id);
files::remove_kept(&self.files, &entry.id);
self.drop_kept(&entry.id);
}
}
fn drop_kept(&self, id: &str) {
self.files.keep_offered.borrow_mut().retain(|e| e.id != id);
files::remove_kept(&self.files, id);
}
/// Open a kept copy, its saved design or recovered work as any design opens, a few
/// features a frame with progress and Cancel (#10198), so a large design doesn't freeze the
/// page; `after` follows once it is in ([`Self::restored`]).
fn restore(&mut self, path: &str, after: keep::AfterOpen) {
self.restoring = Some((self.app.open_path_answered(path), after));
self.restored();
}
/// What follows a restore that is in: the design gets back its file and name, and the
/// copy or the recovered work it came from goes. One that didn't open (refused or
/// cancelled; the status says why) stays offered.
fn restored(&mut self) {
let Some((rx, _)) = &self.restoring else { return };
let reply = match rx.try_recv() {
Ok(reply) => reply,
Err(std::sync::mpsc::TryRecvError::Empty) => return,
Err(std::sync::mpsc::TryRecvError::Disconnected) => serde_json::json!({"ok": false}),
};
let Some((_, after)) = self.restoring.take() else { return };
if !keep::opened(&reply, self.app.session.path.as_deref(), after.opened()) {
return;
}
match after {
keep::AfterOpen::KeptCopy { id, path, name, drop, .. } => {
// It is its own design again (saving goes to its file), with its changes unsaved.
self.app.session.path = path;
self.app.session.doc_mut().name = name;
self.app.session.mark_unsaved();
self.app.home.open = false;
if drop {
self.drop_kept(&id);
}
}
keep::AfterOpen::KeptSaved { id, drop, .. } => {
if drop {
self.drop_kept(&id);
}
}
keep::AfterOpen::Recovery { path, name, .. } => {
// It belongs to its own file again (saving goes there); it is unsaved.
self.app.session.path = path;
self.app.session.doc_mut().name = name;
self.app.session.mark_unsaved();
self.app.home.open = false;
*self.files.recovery.borrow_mut() = None;
files::autosave(&self.files, None);
}
}
}
@@ -756,7 +810,7 @@ mod web {
files::pick_from_disk(&self.files, Intent::Open);
}
if ui_kit::button(ui, "Insert from computer…", ui_kit::Button::Normal)
.backing_on_hover_text(solidcraft_ui_egui::i18n::tr("web.files.insert_drawings_hint"))
.backing_on_hover_text(solidcraft_ui_text::tr("web.files.insert_drawings_hint"))
.clicked()
{
files::pick_from_disk(&self.files, Intent::Insert);
@@ -810,7 +864,7 @@ mod web {
let design = solidcraft_engine::io::is_design_path(&e.name);
if !design
&& ui_kit::button(ui, "Insert", ui_kit::Button::Small)
.backing_on_hover_text(solidcraft_ui_egui::i18n::tr("web.files.insert_stored_drawings_hint"))
.backing_on_hover_text(solidcraft_ui_text::tr("web.files.insert_stored_drawings_hint"))
.clicked()
{
act = Some(("insert", e.name.clone()));
@@ -917,6 +971,9 @@ mod web {
/// Offer the autosave an earlier session left.
fn recovery_prompt(&mut self, ctx: &egui::Context) {
if self.restoring.is_some() {
return;
}
let Some((name, path)) = self.files.recovery.borrow().clone() else { return };
let mut answer = None;
let _ = solidcraft_ui_egui::ui_kit::Window::new(egui::Id::unique("sc_browser_recovery"), "restore", "Recover unsaved work?")
@@ -941,15 +998,10 @@ mod web {
return;
}
};
self.app.open_path(&recovery);
if self.app.session.path.as_deref() != Some(recovery.as_str()) {
return;
}
// It belongs to its own file again (saving goes there); it is unsaved.
self.app.session.path = path;
self.app.session.doc_mut().name = name;
self.app.session.mark_unsaved();
self.app.home.open = false;
// Finished in `restored` once it is in; until then the prompt stays away.
let after = keep::AfterOpen::Recovery { opened: recovery.clone(), path, name };
self.restore(&recovery, after);
return;
}
*self.files.recovery.borrow_mut() = None;
files::autosave(&self.files, None);
@@ -964,7 +1016,7 @@ mod web {
/// Whether the shell already has the on-demand face the live language needs.
fn script_font_ready(&self) -> bool {
match solidcraft_ui_egui::fonts::web_script_font_kind(solidcraft_ui_egui::i18n::effective_language(&self.app.preferences.language)) {
match solidcraft_ui_egui::fonts::web_script_font_kind(solidcraft_ui_text::effective_language(&self.app.preferences.language)) {
Some("zh") => self.chinese_bytes.is_some() && self.chinese_kind == Some("zh"),
Some("zh-TW") => self.chinese_bytes.is_some() && self.chinese_kind == Some("zh-TW"),
Some("ja") => self.cjk_bytes.is_some(),
@@ -1006,7 +1058,7 @@ mod web {
false
}
};
let effective = solidcraft_ui_egui::i18n::effective_language(&self.app.preferences.language);
let effective = solidcraft_ui_text::effective_language(&self.app.preferences.language);
if !installed && solidcraft_ui_egui::fonts::needs_cjk_font(effective) {
log::warn!("script font unavailable; showing English for this session");
}
@@ -1015,13 +1067,13 @@ mod web {
ctx.request_repaint();
}
let ready = self.script_font_ready();
let effective = solidcraft_ui_egui::i18n::effective_language(&self.app.preferences.language);
let effective = solidcraft_ui_text::effective_language(&self.app.preferences.language);
if self.font.tick(&mut self.app.preferences.language, ready) {
self.fetch_font(ctx, solidcraft_ui_egui::fonts::web_script_font_kind(effective).unwrap_or("ja"));
}
// The CJK face leads only a CJK UI: its Cyrillic glyphs are full-width, so switching
// to Ukrainian (or English) must put the Latin face back in front.
if solidcraft_ui_egui::i18n::effective_language(&self.app.preferences.language) != self.font_language {
if solidcraft_ui_text::effective_language(&self.app.preferences.language) != self.font_language {
self.apply_fonts(ctx);
}
}
@@ -1029,7 +1081,7 @@ mod web {
/// Set up the UI fonts for the live language, keeping fetched script fonts as fallbacks
/// (Chinese leads a Chinese UI; see [`solidcraft_ui_egui::fonts::install_web_script_fonts`]).
fn apply_fonts(&mut self, ctx: &egui::Context) {
let effective = solidcraft_ui_egui::i18n::effective_language(&self.app.preferences.language);
let effective = solidcraft_ui_text::effective_language(&self.app.preferences.language);
if self.cjk_bytes.is_none() && self.chinese_bytes.is_none() {
solidcraft_ui_egui::fonts::setup_fonts(ctx);
} else {
@@ -1093,10 +1145,11 @@ mod web {
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.linked.tick(&mut self.app, ui.ctx());
self.restored();
UNSAVED.with(|u| u.set(self.asks_before_unload()));
if self.launch.embed {
ui_kit::Container::new(ui.ctx(), ui_kit::Surface::Chrome).edge("embed_header", ui_kit::Edge::Top).show(ui, |ui| {
if ui_kit::button(ui, solidcraft_ui_egui::i18n::tr("web.open_full"), ui_kit::Button::Normal).clicked()
if ui_kit::button(ui, solidcraft_ui_text::tr("web.open_full"), ui_kit::Button::Normal).clicked()
&& let (Some(window), Some(url)) = (web_sys::window(), &self.full_url)
{
let _ = window.open_with_url_and_target_and_features(url, "_blank", "noopener");
@@ -1172,6 +1225,7 @@ mod web {
pub fn start() {
eframe::WebLogger::init(log::LevelFilter::Info).ok();
solidcraft_ui_egui::i18n::install();
wasm_bindgen_futures::spawn_local(async {
let Some(window) = web_sys::window() else { return };
let launch = crate::Launch::parse(&query());
@@ -1306,6 +1360,7 @@ mod web {
other_tab_prefs,
keep_track: Default::default(),
all_kept: false,
restoring: None,
}))
}),
)
+3 -1
View File
@@ -31,9 +31,12 @@ neg_cmp_op_on_partial_ord = "allow"
[dependencies]
solidcraft-engine = { workspace = true }
solidcraft-control = { workspace = true }
# The D3D12 shader compiler the window starts with (#6756).
solidcraft-gpu = { workspace = true }
solidcraft-ui-egui = { workspace = true }
solidcraft-ui-text = { workspace = true }
solidcraft-theme = { workspace = true }
solidcraft-space-mouse = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links"] }
egui = { workspace = true }
@@ -45,7 +48,6 @@ rfd = { workspace = true }
ron = "0.12"
# Waits for wgpu's adapter list when choosing the graphics backend before the window opens.
pollster = { workspace = true }
getrandom = "0.3"
# The update check (docs/updates.md): HTTPS with rustls, certificates from the operating system.
ureq = { version = "3.4", default-features = false, features = ["rustls-no-provider", "platform-verifier"] }
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] }
File diff suppressed because it is too large Load Diff
+4 -3
View File
@@ -468,7 +468,7 @@ fn case_variants(exts: &[&str]) -> Vec<String> {
fn content_ui(app: &mut SolveApp, ui: &mut egui::Ui) {
let content = ui.ctx().content_rect();
let above = egui::Rect::from_min_max(ui.max_rect().min, egui::pos2(ui.max_rect().right(), content.top()));
ui.painter().rect_filled(above, 0.0, solidcraft_ui_egui::theme::Tokens::get().app_bar);
ui.painter().rect_filled(above, 0.0, solidcraft_theme::Tokens::get().app_bar);
ui.scope_builder(egui::UiBuilder::new().max_rect(content), |ui| app.ui(ui));
}
@@ -518,7 +518,7 @@ fn services(cc: &eframe::CreationContext<'_>, folder: &DialogFolder) -> Services
// The groups come from the format registry, so a new format joins this dialog by itself.
let mut everything = io::formats::open_extensions(true);
everything.extend(["dxf", "svg"]);
dialog = dialog.add_filter(solidcraft_ui_egui::i18n::tr("file.open.drawing_filter"), &case_variants(&everything));
dialog = dialog.add_filter(solidcraft_ui_text::tr("file.open.drawing_filter"), &case_variants(&everything));
for (label, exts) in io::formats::import_filters(true) {
dialog = dialog.add_filter(label, &case_variants(&exts));
}
@@ -945,6 +945,7 @@ fn main() -> eframe::Result {
#[cfg(target_os = "macos")]
objc2_foundation::NSProcessInfo::processInfo().setProcessName(objc2_foundation::ns_string!("SolidCraft"));
mark("main");
solidcraft_ui_egui::i18n::install();
// On OCLP-patched Macs, wgpu picks a Metal Shading Language standard that the system's
// compiler refuses (`invalid value 'metal3.2'`), because the patched OS reports a newer
// macOS version than the GPU's Metal compiler supports. Re-launching with
@@ -1221,7 +1222,7 @@ fn main() -> eframe::Result {
mark("viewport pipelines");
// `--control`/the environment name a port; otherwise the preference (on by default)
// opens the default one (docs/control-protocol.md).
if let Some(listen_on) = solidcraft_engine::control_auth::plan(control_port, app.preferences.agent_control) {
if let Some(listen_on) = solidcraft_control::auth::plan(control_port, app.preferences.agent_control) {
let rx = control_server::start(listen_on, cc.egui_ctx.clone());
app = app.with_control(rx);
}
-1
View File
@@ -18,7 +18,6 @@ assets/
ui/source/ # Rust viewport glyphs, markers and navigation
branding/web/source/og.png # shared social preview card
branding/logo/ # print/web logo files; guide in branding/README.md
styles/ui/palettes.rs # light/dark defaults
styles/web/loading.css # web loading/canvas styles
appearances/library.rs # built-in visual presets
materials/library.rs # physical defaults and colours
+1 -1
View File
@@ -38,7 +38,7 @@ Other colours, all already used in the project:
| Colour | Hex | Where it comes from |
|---|---|---|
| Wordmark blue | `#15499e` | loading logo `--word` |
| Interface accent | `#2678da` | light theme `accent` token (`assets/styles/ui/palettes.rs`) |
| Interface accent | `#2678da` | light theme `accent` token (`crates/theme/src/palettes.rs`) |
| Light background | `#e8ebf0` | web loading screen (`assets/styles/web/loading.css`) |
| Dark background | `#1f232b` | dark theme `file_background` token |
| Black / white | `#000000` / `#ffffff` | one-colour versions only |
+1 -1
View File
@@ -81,7 +81,7 @@ for message in icon_palette.validation_errors(pal, names | set(al)):
err(d + '/palette.toml', message)
# the review page lies its icons on the real app surfaces: its colours must be the app's tokens
def app_tokens():
src = open(os.path.join(d, '..', '..', '..', 'styles', 'ui', 'palettes.rs'), encoding='utf-8').read()
src = open(os.path.join(d, '..', '..', '..', '..', 'crates', 'theme', 'src', 'palettes.rs'), encoding='utf-8').read()
lo, hi = src.index('pub const fn light'), src.index('pub const fn dark')
light_src, dark_src = src[lo:hi], src[hi:]
def pick(block, name, dark):
@@ -1,4 +1,4 @@
/* App surfaces (keep in sync with assets/styles/ui/palettes.rs; lint_icons.py checks): --card is the ribbon surface `toolbar` the icons lie on, --bg the page (`panel` in light, `viewport_top` in dark), --line `border`, --fg `text`, --mut `text_dim`. */
/* App surfaces (keep in sync with crates/theme/src/palettes.rs; lint_icons.py checks): --card is the ribbon surface `toolbar` the icons lie on, --bg the page (`panel` in light, `viewport_top` in dark), --line `border`, --fg `text`, --mut `text_dim`. */
:root{--bg:#fafbfc;--fg:#262a32;--card:#f6f7f9;--line:#d0d4dc;--mut:#6e7480;--sz:48px}
body.dark{--bg:#2e3440;--fg:#dee2e8;--card:#272b34;--line:#3f4552;--mut:#949ba8}
body{font:12px system-ui,sans-serif;margin:0;background:var(--bg);color:var(--fg)}
+2 -2
View File
@@ -110,7 +110,7 @@ pub(crate) fn breadcrumb(p: &egui::Painter, c: egui::Pos2, col: Color32) {
}
pub(crate) fn brand_mark(p: &egui::Painter, c: Pos2) {
brand_mark_styled(p, c, 20.0, crate::theme::Tokens::get().logo, true);
brand_mark_styled(p, c, 20.0, solidcraft_theme::Tokens::get().logo, true);
}
/// The same original mark as a single quiet silhouette, without face seams or outlines.
@@ -127,7 +127,7 @@ fn brand_mark_styled(p: &egui::Painter, c: Pos2, height: f32, colours: [Color32;
};
// The faces are separated by gaps that show the application bar, not by white lines.
let [light, mid, dark] = colours;
let edge_col = crate::theme::Tokens::get().app_bar;
let edge_col = solidcraft_theme::Tokens::get().app_bar;
face(light, &[(3.5, 105.9), (177.0, 5.8), (263.4, 55.6), (263.8, 156.0), (177.0, 406.4)]);
face(light, &[(177.0, 206.1), (263.8, 156.0), (350.5, 206.2), (350.5, 306.3), (177.0, 406.4)]);
face(mid, &[(3.5, 105.9), (177.0, 206.1), (177.0, 406.4)]);
+2 -2
View File
@@ -1,7 +1,7 @@
//! Constraint, assembly snap, hole placement and dimension marker artwork.
use crate::dialogs_assembly::SnapKind;
use crate::theme::Tokens;
use egui::{Color32, Pos2, Stroke, vec2};
use solidcraft_theme::Tokens;
pub(crate) fn snap(painter: &egui::Painter, c: Pos2, tip: Option<Pos2>, kind: SnapKind, col: Color32, tag: Option<&str>) {
// A dark halo under every stroke keeps the glyph readable on light and dark faces alike.
@@ -49,7 +49,7 @@ pub(crate) fn snap(painter: &egui::Painter, c: Pos2, tip: Option<Pos2>, kind: Sn
// What it snaps to (hovered), or which component it is (picked): a small label.
if let Some(tag) = tag {
let t = Tokens::get();
let font = egui::FontId::proportional(crate::theme::Tokens::get().font_caption);
let font = egui::FontId::proportional(solidcraft_theme::Tokens::get().font_caption);
let g = painter.layout_no_wrap(tag.to_string(), font, t.text);
let at = c + vec2(12.0, 10.0);
let r = egui::Rect::from_min_size(at, g.size()).expand2(vec2(5.0, 2.0));
+2 -2
View File
@@ -1,8 +1,8 @@
//! Navigation drawing definitions. Camera projection and picking remain in the UI widgets.
use crate::theme::Tokens;
use egui::{Color32, FontId, Pos2, Rect, Shape, Stroke, pos2, vec2};
use solidcore_record::Vec3;
use solidcraft_engine::geom::Ring;
use solidcraft_theme::Tokens;
pub(crate) struct Facet {
pub(crate) direction: Vec3,
pub(crate) points: Ring<Pos2>,
@@ -196,7 +196,7 @@ pub(crate) fn axis_tip_scaled(painter: &egui::Painter, c: Pos2, name: &str, d: &
// On a hovered (white) disc the letter takes the axis colour.
let letter = if hovered { col } else { t.on_accent };
let label = name.to_uppercase();
let galley = painter.layout_no_wrap(label, crate::fonts::bold(crate::theme::Tokens::get().font_compass * scale), letter);
let galley = painter.layout_no_wrap(label, crate::fonts::bold(solidcraft_theme::Tokens::get().font_compass * scale), letter);
let origin = at - galley.size() / 2.0;
let ppp = painter.ctx().pixels_per_point().max(0.5);
let origin = pos2((origin.x * ppp).round() / ppp, (origin.y * ppp).round() / ppp);
+3 -3
View File
@@ -2,16 +2,16 @@
use egui::{Color32, FontId, Pos2, Shape, Stroke, vec2};
/// The agent pointer's colour (a theme token, so a colour vision mode can reach it).
pub(crate) fn agent() -> Color32 {
crate::theme::Tokens::get().icons.agent
solidcraft_theme::Tokens::get().icons.agent
}
pub(crate) fn paint(painter: &egui::Painter, p: Pos2) {
let ic = crate::theme::Tokens::get().icons;
let ic = solidcraft_theme::Tokens::get().icons;
// An arrow pointer, tip at `p`.
let pts: Vec<Pos2> =
[(0.0, 0.0), (0.0, 17.0), (4.5, 13.0), (7.5, 20.0), (10.5, 18.8), (7.5, 12.0), (13.0, 12.0)].iter().map(|(x, y)| p + vec2(*x, *y)).collect();
painter.add(Shape::convex_polygon(pts.clone(), ic.agent, Stroke::NONE));
painter.add(Shape::closed_line(pts, Stroke::new(1.5, ic.highlight)));
let label = painter.layout_no_wrap("Agent".into(), FontId::proportional(crate::theme::Tokens::get().font_small), ic.highlight);
let label = painter.layout_no_wrap("Agent".into(), FontId::proportional(solidcraft_theme::Tokens::get().font_small), ic.highlight);
let r = egui::Rect::from_min_size(p + vec2(15.0, 18.0), label.size() + vec2(10.0, 4.0));
painter.rect_filled(r, 4.0, ic.agent);
painter.galley(r.min + vec2(5.0, 2.0), label, ic.highlight);
+48 -19
View File
@@ -265,6 +265,7 @@
"Edge": "حافة",
"Edges": "الحواف",
"Edit": "تعديل",
"Edit Holes": "تحرير الثقوب",
"Edit Profile Sketch": "تعديل رسم المقطع",
"Edit shortcuts": "تعديل الاختصارات",
"Enable rule": "تفعيل القاعدة",
@@ -976,7 +977,7 @@
"browser.calibrate_click_a": "انقر على الصورة لتحديد النقطة A، أو اكتب إحداثياتها.",
"browser.calibrate_click_b": "انقر على الصورة لتحديد النقطة B، أو اكتب إحداثياتها.",
"browser.calibrate_title": "معايرة: {name}",
"browser.canvas_locked": "اللوحة مقفلة. ألغِ قفلها أولاً.",
"browser.canvas_locked": "اللوحة مقفلة. ألغِ قفلها أولًا.",
"browser.capture_many": "تم تحريك {count} مكوّنات دون تثبيت مواضعها.",
"browser.capture_note": "التثبيت يحتفظ بالموضع الجديد، والإرجاع يعيده كما كان.",
"browser.capture_one": "تم تحريك مكوّن دون تثبيت موضعه.",
@@ -1206,6 +1207,7 @@
"cmd.material_properties_directional": "معامل المرونة الاتجاهي",
"cmd.material_properties_validate": "التحقق من بيانات المادة الفيزيائية",
"cmd.mesh_convert": "تحويل الشبكة",
"cmd.model_body_features": "سرد ميزات الجسم",
"cmd.model_edges": "قائمة الحواف",
"cmd.model_faces": "قائمة الأوجه",
"cmd.model_threads": "قائمة الأسنان اللولبية",
@@ -1234,8 +1236,11 @@
"cmd.panel.dismiss": "إغلاق العرض المؤقت للوحة",
"cmd.panel.float": "تعويم اللوحة",
"cmd.panel.focus": "تركيز اللوحة",
"cmd.panel.focus.next": "التركيز على اللوحة التالية",
"cmd.panel.focus.previous": "التركيز على اللوحة السابقة",
"cmd.panel.list": "سرد اللوحات",
"cmd.panel.move": "نقل اللوحة",
"cmd.panel.move.next_zone": "نقل اللوحة إلى المنطقة التالية",
"cmd.panel.native_close": "إعادة إرساء قسم اللوحة من نافذته المستقلة",
"cmd.panel.open": "فتح اللوحة",
"cmd.panel.peek": "عرض اللوحة المطوية مؤقتًا",
@@ -1243,6 +1248,12 @@
"cmd.panel.reset_pair": "إعادة ضبط فاصل اللوحة",
"cmd.panel.resize": "تغيير حجم قسم اللوحة",
"cmd.panel.resize_pair": "تحريك فاصل اللوحة",
"cmd.panel.sash.down": "تحريك فاصل اللوحة لأسفل",
"cmd.panel.sash.left": "تحريك فاصل اللوحة لليسار",
"cmd.panel.sash.reset": "إعادة ضبط فاصل اللوحة التي عليها التركيز",
"cmd.panel.sash.right": "تحريك فاصل اللوحة لليمين",
"cmd.panel.sash.up": "تحريك فاصل اللوحة لأعلى",
"cmd.panel.toggle": "طي اللوحة التي عليها التركيز أو توسيعها",
"cmd.parameters_add": "إضافة معامل مستخدم",
"cmd.parameters_change": "تغيير المعاملات",
"cmd.parameters_comment": "التعليق على المعامل",
@@ -1366,6 +1377,7 @@
"cmd.sketch.construction_line.tangent_external": "مماس يمر بنقطة خارجية",
"cmd.sketch.construction_line.tangent_two": "مماس مشترك",
"cmd.sketch.construction_line.vertical": "خط إنشائي رأسي",
"cmd.sketch.replace": "استبدال الرسم",
"cmd.sketch_arc3d": "قوس ثلاثي الأبعاد",
"cmd.sketch_arc_center_point": "قوس بنقطة مركز",
"cmd.sketch_arc_tangent": "قوس مماسي",
@@ -1498,7 +1510,7 @@
"cmd.timeline_suppress": "تعطيل الميزة",
"cmd.ui.assistant": "إظهار المساعد أو إخفاؤه",
"cmd.view.navigator": "متصفح العرض المخصص",
"cmd.view.render_browser": "متصفح التصيير",
"cmd.view.render_browser": "مستعرض التصيير",
"cmd.view.render_dock": "نقل لوحة التصيير",
"cmd.view.render_focus": "عرض تركيز التصيير",
"cmd.view.render_live": "إيقاف التصيير المباشر مؤقتًا أو استئنافه",
@@ -1862,7 +1874,7 @@
"help.sketch.constrainer": "يطبق القيد الذي يكاد منحنى أو منحنيان محددان يحققانه، مثل الأفقي أو التوازي أو التماس أو التساوي. استخدمه لتهذيب رسم يدوي علاقة بعد أخرى.",
"help.sketch.constraint.coincident": "يجعل نقطة تقع على نقطة أخرى أو على منحنى أو على محور الرسم. استخدمه لسد الفجوات في المقطع أو لوضع نقطة على خط.",
"help.sketch.constraint.collinear": "يجعل خطين يقعان على خط مستقيم واحد. استخدمه لمحاذاة الحواف المتقطعة، مثل جانبي حز.",
"help.sketch.constraint.concentric": "يجعل دائرتين أو قوسين يشتركان في المركز نفسه، أو يضع مركز دائرة أو قوس على نقطة في الرسم التخطيطي، بما في ذلك نقطة الأصل. اختر العنصرين بأي ترتيب.",
"help.sketch.constraint.concentric": "يجعل دائرتين أو قوسين يشتركان في المركز نفسه، أو يضع مركز دائرة أو قوس على نقطة في الرسم، بما في ذلك نقطة الأصل. اختر العنصرين بأي ترتيب.",
"help.sketch.constraint.curvature": "يجعل منحنيين يلتقيان عند نقطة نهاية يتابعان بالانحناء نفسه (G2). استخدمه حيث لا يكفي الوصل المماسي نعومة، مثلًا على الأسطح الظاهرة.",
"help.sketch.constraint.equal": "يجعل الخطوط بالطول نفسه، أو الدوائر والأقواس بنصف القطر نفسه. استخدمه ليتحكم بُعد واحد في عدة ميزات متماثلة.",
"help.sketch.constraint.fix": "يثبّت النقاط أو المنحنيات في مكانها، أو يفك تثبيتها. استخدمه للهندسة التي يجب ألا تتحرك عند سحب الرسم أو حله.",
@@ -2016,6 +2028,7 @@
"help.view.visual_style": "يبدّل طريقة رسم النموذج: مظلَّل مع الحواف أو بدونها، أو إطار سلكي، أو مع الحواف المخفية. استخدم الإطار السلكي لرؤية الداخل، والمظلَّل لمظهر نظيف.",
"history.sketch_under_constrained": "قيود ناقصة ({count} درجات حرية)",
"history.sketch_under_constrained_one": "قيود ناقصة ({count} درجة حرية)",
"hole_folder.applies_to": "تنطبق التغييرات على الثقوب الـ {count} في {folder}؛ ويحتفظ كل ثقب بموضعه.",
"hole_presets.confirm_delete": "تأكيد الحذف",
"hole_presets.delete": "حذف",
"hole_presets.delete_prompt": "هل تحذف هذا الإعداد المسبق الشخصي؟ الثقوب الموجودة لا تتغير.",
@@ -2205,6 +2218,7 @@
"material.editor.preserved_appearance": "المظهر المحفوظ",
"material.editor.public_reference": "مرجع عام",
"material.editor.reference": "مرجع",
"material.editor.remove": "إزالة",
"material.editor.save_or_discard": "احفظ مسودة المادة أو تجاهلها قبل تعيين مادة.",
"material.editor.saved": "حُفظت المادة. تعيينات الأجسام لم تتغير.",
"material.editor.scope": "الحفظ يخزّن هذه المادة دون تعيينها للأجسام. الخصائص والشروط الفارغة تبقى غير معروفة. كثافة البطاقة لا تغيّر الكثافة المعيّنة ولا الكتلة.",
@@ -2515,6 +2529,8 @@
"prefs.bottom_centre": "أسفل الوسط",
"prefs.bottom_right": "أسفل اليمين",
"prefs.browser": "المستعرض",
"prefs.browser_body_features": "الميزات تحت الأجسام",
"prefs.browser_body_features_note": "يمكن فتح كل جسم في المستعرض لعرض ميزات الخط الزمني التي بنته، بترتيب الخط الزمني.",
"prefs.browser_components_open": "المكوّنات مفتوحة من البداية",
"prefs.browser_components_open_note": "يبدأ كل مكوّن في المستعرض مفتوحًا كما كان من قبل، بدلًا من أن يبقى مغلقًا حتى تفتحه.",
"prefs.browser_eye_left": "أيقونة الإظهار بجانب سهم الطي",
@@ -2555,7 +2571,11 @@
"prefs.design_tools.degrees": "درجات",
"prefs.design_tools.double_click_help": "أو لتحرير مكوّن مرتبط في موضعه.",
"prefs.design_tools.ghost": "إظهار الموضع القديم للأجزاء المنقولة كشبح",
"prefs.design_tools.hole_group": "تجميع الثقوب المُنشأة معًا",
"prefs.design_tools.hole_group_help": "الثقوب المُنشأة دفعة واحدة تشترك في مجلد واحد في السجل يبدأ مطويًا؛ افتحه لتحرير كل ثقب على حدة.",
"prefs.design_tools.hole_moves_help": "اختيار الوجه مجددًا ينقل الثقب بدلًا من إزالته.",
"prefs.design_tools.hole_several": "عدة ثقوب على وجه واحد",
"prefs.design_tools.hole_several_help": "الاختيار في موضع آخر من الوجه يضيف ثقبًا آخر، واختيار ثقب يزيله.",
"prefs.design_tools.hole_snaps_help": "منتصف الوجه ومراكز حوافه الدائرية.",
"prefs.design_tools.imported_files": "الملفات المستوردة",
"prefs.design_tools.note": "ملاحظة",
@@ -2637,6 +2657,8 @@
"prefs.history": "السجل",
"prefs.history_bar_groups": "طي شريط السجل في مجموعات",
"prefs.history_bar_groups_note": "في سجل طويل، تُطوى سلسلة ميزات المكوّن الواحد في شريحة واحدة، وتبدأ مجلدات الميزات مطوية. تدخل مجموعة STEP المُدرجة في مجلد Group. عند الإيقاف تظهر كل ميزة، وتبدأ المجلدات مفتوحة، ولا يُنشأ أي مجلد.",
"prefs.history_folder_kind_icon": "أيقونة المجلد من ميزاته",
"prefs.history_folder_kind_icon_note": "مجلد السجل الذي تكون ميزاته كلها من نوع واحد يعرض أيقونتها.",
"prefs.history_group_joints": "تجميع المفاصل في السجل",
"prefs.history_group_joints_note": "يظهر مفصلان أو أكثر كزر واحد مع عددها، والنقر عليه يُظهر كل مفصل. عند الإيقاف يظهر كل مفصل على حدة.",
"prefs.history_origin_bodies": "إظهار الأصل والأجسام في الأعلى",
@@ -2828,8 +2850,10 @@
"prefs.select_hidden_edges_help": "يتيح تحديد الحواف عبر الأوجه عند تفعيل نمط «مظلل مع الحواف المخفية»، بما في ذلك عند استخدام التقويس. عند الإيقاف يبقى سلوك التحديد كما في v0.30.1. تظل الأجسام المخفية غير قابلة للتحديد.",
"prefs.select_one_item": "تحديد عنصر واحد",
"prefs.select_other_hidden": "تحديد العناصر الهندسية المخفية باستخدام «تحديد عنصر آخر»",
"prefs.select_other_hidden_help": "عند التفعيل: تضمين الأجسام والمخططات المخفية والحواف المحجوبة قرب المؤشر. عند التعطيل: استخدام القائمة الأصلية للعناصر الهندسية المرئية أسفل المؤشر.",
"prefs.select_other_hidden_help": "عند التفعيل: تضمين الأجسام والرسومات المخفية والحواف المحجوبة قرب المؤشر. عند التعطيل: استخدام القائمة الأصلية للعناصر الهندسية المرئية أسفل المؤشر.",
"prefs.selecting": "التحديد",
"prefs.shared_panel_layout": "استخدام تخطيط اللوحات المشترك",
"prefs.shared_panel_layout_help": "اعرض المستعرض والسجل والمساعد الحالية في لوحات قابلة للنقل. يسري التغيير عند تطبيق التفضيلات.",
"prefs.shift": "Shift",
"prefs.shift_add_ctrl_toggle": "Shift يضيف فقط، وCtrl / Cmd يبدّل",
"prefs.shift_add_note": "عند التشغيل: Shift يضيف، وCtrl / Cmd يبدّل. عند الإيقاف (الافتراضي): كلاهما يبدّل.",
@@ -2852,8 +2876,8 @@
"prefs.sketch.drawing": "الرسم",
"prefs.sketch.ellipse_axes": "رسم القطوع الناقصة مع محاورها",
"prefs.sketch.ellipse_axes_note": "المحوران الأكبر والأصغر كخطوط مساعدة، لتحديد أبعاد أنصاف المحاور والمحاور. عند الإيقاف: القطع الناقص وحده، ويعطي D عليه نصف قطره الأصغر أينما سُحبت القيمة، مرسومًا ومُعلَّمًا بـ R كما في السابق.",
"prefs.sketch.escape_twice_finishes": "الضغط المزدوج على Esc ينهي الرسم التخطيطي",
"prefs.sketch.escape_twice_finishes_note": "ضغطتان سريعتان على Esc تنهيان الرسم التخطيطي. عند الإيقاف: يلغي Esc الأداة أو التحديد فقط، وينهي أمر «إنهاء الرسم» الرسم التخطيطي.",
"prefs.sketch.escape_twice_finishes": "الضغط على Esc مرتين ينهي الرسم",
"prefs.sketch.escape_twice_finishes_note": "ضغطتان سريعتان على Esc تنهيان الرسم. عند الإيقاف: يلغي Esc الأداة أو التحديد فقط، وينهي أمر «إنهاء الرسم» الرسم.",
"prefs.sketch.exit_keep": "إبقاء",
"prefs.sketch.exit_previous": "العودة إلى العرض السابق",
"prefs.sketch.exit_unless_rotated": "إلا إذا تم التدوير",
@@ -3074,7 +3098,7 @@
"render.aging_metallic": "النسبة المعدنية للطبقة",
"render.aging_origin": "أصل الحقل · mm العالم",
"render.aging_patina": "زنجار نحاسي أخضر",
"render.aging_raytrace_help": "يُطبّق التعتيق في تتبع الأشعة على المعالج وتتبع المسارات على المعالج الرسومي. يبقى التصيير النقطي تخطيطياً.",
"render.aging_raytrace_help": "يُطبّق التعتيق في تتبع الأشعة على المعالج وتتبع المسارات على المعالج الرسومي. يبقى التصيير النقطي تخطيطيًا.",
"render.aging_refresh_targets": "فحص الأجسام الحالية",
"render.aging_roughness": "خشونة الطبقة",
"render.aging_rust": "حديد مؤكسد / صدأ",
@@ -3374,7 +3398,7 @@
"render.cpu_opaque_required": "يتطلب تتبع الأشعة على CPU خلفية معتمة. ألغِ تحديد «خلفية شفافة» للمتابعة.",
"render.cpu_options_pending": "لا تستطيع لوحة CPU هذه تحرير الانبعاث ولا تأثيرات السطح ولا الزجاج ولا تباين الخواص بعد. امسح خيارات API هذه قبل استخدامها.",
"render.cpu_path_depth": "عمق المسار",
"render.cpu_path_help": "تحسب الضوء المرتد بين الأسطح المعتمة والمار عبر أشكال الزجاج المدعومة. تتطلب التصيير التدريجي وظلال تتبع الأشعة. يحسب عمق المسار الأسطح المضاءة وحدود الزجاج وأحداث التشتت بدءاً من أول تقاطع (1–16). يقتصر العمق 1 على الإضاءة المباشرة للأسطح المعتمة؛ وتسمح القيم الأعلى بمزيد من الارتدادات أو عبور الزجاج وقد تستغرق وقتاً أطول. أوقف الإضاءة غير المباشرة لاستخدام الانعكاسات المخصصة أو التباين الاتجاهي.",
"render.cpu_path_help": "تضيف الضوء المرتد بين الأسطح المعتمة والمار عبر أشكال الزجاج المدعومة. تتطلب التصيير التدريجي وظلال تتبع الأشعة. يحسب عمق المسار الأسطح المضاءة وحدود الزجاج وأحداث التشتت بدءًا من أول تقاطع (1–16). يقتصر العمق 1 على الإضاءة المباشرة للأسطح المعتمة؛ وتسمح القيم الأعلى بمزيد من الارتدادات أو عبور الزجاج وقد تستغرق وقتًا أطول. أوقف الإضاءة غير المباشرة لاستخدام الانعكاسات المخصصة أو التباين الاتجاهي.",
"render.cpu_path_lighting": "إضاءة غير مباشرة",
"render.cpu_path_optics_reason": "تبقى إعداداتك كما هي. أوقف الإضاءة غير المباشرة لاستخدام الانعكاسات المخصصة أو التباين الاتجاهي.",
"render.cpu_point": "ضوء نقطي",
@@ -3409,8 +3433,8 @@
"render.cpu_sky_bounds": "يجب أن يكون دوران السماء محدودًا ضمن ±1,000,000 راديان، وأن تكون الشدة بين 0 و100.",
"render.cpu_studio_enable": "تفعيل الإضاءة غير المباشرة لهذا التوزيع",
"render.cpu_studio_enable_apply": "تفعيل الإضاءة غير المباشرة وتطبيقها",
"render.cpu_studio_indirect_help": "يحتاج توزيع الشمس هذا إلى إضاءة غير مباشرة بعمق 8 وظلال متتبعة وعينة كاميرا واحدة لكل مرور. إذا كان التصيير التدريجي معطلاً، يبدأ بـ 16 عينة لكل بكسل وبذرة 0. تُحفظ الإعدادات التدريجية الحالية وملف الموارد. تمنع الإعدادات غير المتوافقة التطبيق دون تغييرها.",
"render.cpu_studio_radiance_limit": "سطوع المصدر المساحي في هذا التوزيع هو {radiance}، ويتجاوز حد CPU البالغ 64. تُحفظ الإضاءة المؤلَّفة؛ اختر توزيعاً آخر أو GPU.",
"render.cpu_studio_indirect_help": "يحتاج توزيع الشمس هذا إلى إضاءة غير مباشرة بعمق 8 وظلال متتبعة وعينة كاميرا واحدة لكل مرور. إذا كان التصيير التدريجي معطلًا، يبدأ بـ 16 عينة لكل بكسل وبذرة 0. تُحفظ الإعدادات التدريجية الحالية وملف الموارد. تمنع الإعدادات غير المتوافقة التطبيق دون تغييرها.",
"render.cpu_studio_radiance_limit": "سطوع المصدر المساحي في هذا التوزيع هو {radiance}، ويتجاوز حد CPU البالغ 64. تُحفظ الإضاءة المؤلَّفة؛ اختر توزيعًا آخر أو GPU.",
"render.cpu_subsurface_denoise_reason": "يتطلب التشتت داخل الزجاج على CPU إيقاف إزالة الضجيج. تبقى إعداداتك محفوظة.",
"render.cpu_subsurface_help": "يغيّر التشتت اتجاه الضوء داخل الزجاج المغلق. تتطلب المعدلات الموجبة إضاءة غير مباشرة مع إيقاف إزالة الضجيج. المعدل هو عدد التصادمات لكل مم؛ لا يضيف الصفر أي تشتت. يفضّل انحياز الاتجاه الموجب الأمام، والسالب الخلف.",
"render.cpu_summary": "تتبع الأشعة على CPU · انعكاسات · HDR · أضواء اتجاهية ونقطية",
@@ -3430,7 +3454,7 @@
"render.diag_preflight_details": "تفاصيل الفحص المسبق",
"render.diag_request": "الطلب الحالي بالضبط",
"render.diag_triangles": "المثلثات",
"render.directional_sun_limit": "تدعم إضاءة GPU ثمانية مصادر ضوء اتجاهية إجمالاً، من بينها شمس واحدة بظلال ناعمة كحد أقصى. انسخ ضوءاً اتجاهياً بظلال حادة لإضافة مصدر بعيد آخر.",
"render.directional_sun_limit": "تدعم إضاءة GPU ثمانية مصادر ضوء اتجاهية إجمالًا، من بينها شمس واحدة بظلال ناعمة كحد أقصى. انسخ ضوءًا اتجاهيًا بظلال حادة لإضافة مصدر بعيد آخر.",
"render.discard_continue": "تجاهل المسودة والمتابعة",
"render.draft_confirm_help": "في مسودة التصيير الحالية تعديلات غير مطبّقة. احفظها قبل التبديل، أو تجاهلها صراحةً، أو تابع التحرير.",
"render.draft_confirm_title": "الحفاظ على إعدادات التصيير المعدّلة",
@@ -3590,7 +3614,7 @@
"render.flare_streak": "خط ضوئي بأسلوب أنامورفي",
"render.flare_strength": "شدة وهج الإضاءات العالية",
"render.flare_threshold": "عتبة الوهج الخطية للمشهد",
"render.fold_browser": "طيّ المتصفح",
"render.fold_browser": "طيّ المستعرض",
"render.frame_aspect": "النسبة",
"render.frame_centre_mark": "علامة المركز",
"render.frame_copy_limit": "لا يوجد اسم متاح للقطة مكررة",
@@ -3820,7 +3844,7 @@
"render.light_gpu_punctual": "الأضواء النقطية والكشّافة متاحة مع تتبّع الأشعة على المعالج.",
"render.light_hard_shadow": "لهذا الضوء ظلال حادة؛ نعومة المصدر غير متاحة.",
"render.light_intensity": "الشدة",
"render.light_missing": "هذا الضوء لم يعد موجودًا. اختر ضوءًا في المتصفح.",
"render.light_missing": "هذا الضوء لم يعد موجودًا. اختر ضوءًا في المستعرض.",
"render.light_place_highlight": "وضع بالتمييز",
"render.light_place_pending": "الوضع بالتمييز يتطلب اختيار الأضواء في منطقة العرض وهو غير مدعوم بعد.",
"render.light_position": "الموضع",
@@ -4014,7 +4038,7 @@
"render.output_lock_ratio": "قفل نسبة الأبعاد",
"render.output_master_unavailable": "لتصدير EXR، اختر صورة مكتملة تحتوي على بيانات EXR خام. فعّل EXR الخام قبل تصيير صور جديدة.",
"render.output_over_budget": "الصورة المطلوبة تتجاوز نطاق الموارد المختار. اختر حجمًا أو ملفًا متوافقًا صراحةً؛ لا يحدث تخفيض تلقائي.",
"render.output_presentation_limits": "تدعم مؤثرات الصور على CPU وإخراج EXR صوراً بعرض وارتفاع لا يتجاوزان 8192 بكسل، وبإجمالي لا يتجاوز 16,777,216 بكسل.",
"render.output_presentation_limits": "تدعم مؤثرات الصور على CPU وإخراج EXR صورًا بعرض وارتفاع لا يتجاوزان 8192 بكسل، وبإجمالي لا يتجاوز 16,777,216 بكسل.",
"render.output_presets_help": "الصيغة والجودة وقائمة الفحص في محرر المخرجات.",
"render.output_quality": "الجودة",
"render.output_quality_waiting": "بانتظار عقد الجودة",
@@ -4265,7 +4289,7 @@
"render.soft_focus": "تركيز ناعم",
"render.spot_axis": "اتجاه انبعاث الضوء",
"render.spot_cone": "مخروط الكشّاف",
"render.spot_gpu_help": "تُقاس الزوايا من اتجاه انبعاث الضوء. يتلاشى الضوء بين الزاويتين الداخلية والخارجية. تتطلب نطاقات التلاشي الضيقة جداً التصيير باستخدام CPU.",
"render.spot_gpu_help": "تُقاس الزوايا من اتجاه انبعاث الضوء. يتلاشى الضوء بين الزاويتين الداخلية والخارجية. تتطلب نطاقات التلاشي الضيقة جدًا التصيير باستخدام CPU.",
"render.spot_help": "تُقاس الزوايا من اتجاه انبعاث الضوء. يتلاشى الضوء بين الزاويتين الداخلية والخارجية.",
"render.spot_inner": "نصف الزاوية الداخلية",
"render.spot_outer": "نصف الزاوية الخارجية",
@@ -4433,7 +4457,7 @@
"render.transmission_strata_help": "القيمة 1 تحافظ على التدفق الأصلي. 2 أو 4 مسارات فرعية تقسّم أول اختيار انعكاس أو نفاذ في الزجاج إلى طبقات مع بقاء قانوني سنل والامتصاص دون تغيير. المزيد من المسارات يكلف عملًا أكثر؛ وهذا ليس إزالة تشويش ولا دعمًا للأوساط المتداخلة ولا وعدًا بتصيير أسرع.",
"render.tree_add_light": "إضافة ضوء",
"render.tree_add_shot": "إضافة لقطة",
"render.tree_browser": "متصفح التصيير",
"render.tree_browser": "مستعرض التصيير",
"render.tree_by_look": "حسب المظهر",
"render.tree_by_object": "حسب الكائن",
"render.tree_constant": "بيئة ثابتة",
@@ -5446,7 +5470,7 @@
"ui.finest": "الأدق",
"ui.finish_sketch": "إنهاء الرسم",
"ui.first_dimension_scales": "البُعد الأول يغيّر مقياس الرسم",
"ui.first_dimension_scales_tip": "تشغيل: أول بُعد طولي في المخطط يغيّر مقياس الرسم الذي يقيسه، فيبقى شكله كما هو. إيقاف: يغيّر البُعد حجم ما يقيسه فقط.",
"ui.first_dimension_scales_tip": "تشغيل: أول بُعد طولي في الرسم يغيّر مقياس الشكل الذي يقيسه، فيبقى شكله كما هو. إيقاف: يغيّر البُعد حجم ما يقيسه فقط.",
"ui.fit": "ملاءمة",
"ui.fix_unfix": "تثبيت/فك التثبيت",
"ui.flat": "مسطّح",
@@ -5520,6 +5544,7 @@
"ui.heading_plastic": "البلاستيك",
"ui.heading_position": "الموضع",
"ui.heading_prepare": "تحضير",
"ui.heading_press_pull": "ضغط وسحب",
"ui.heading_rectangular_pattern": "نمط مستطيلي",
"ui.heading_revolve": "دوران",
"ui.heading_right": "يمين",
@@ -5771,7 +5796,7 @@
"ui.panel_layout.custom_hint": "تم نقل اللوحات منذ {name}.",
"ui.panel_layout.details": "التفاصيل",
"ui.panel_layout.layout_workspace": "مساحة عمل التخطيط",
"ui.panel_layout.page_defaults_hint": "إعادة ضبط إعدادات هذه الصفحة. تُحفظ ترتيبات اللوحات والتخطيطات المحفوظة.",
"ui.panel_layout.page_defaults_hint": "يعيد ضبط إعدادات هذه الصفحة. تُحفظ ترتيبات اللوحات والتخطيطات المحفوظة.",
"ui.panel_layout.panel_layout": "تخطيط اللوحات",
"ui.panel_layout.panel_style": "نمط اللوحات",
"ui.panel_layout.replace": "استبدال التخطيط",
@@ -5796,7 +5821,7 @@
"ui.panel_layout.workspace.modelling": "النمذجة",
"ui.panel_layout.workspace.render": "التصيير",
"ui.panel_layout.workspace.review": "المراجعة",
"ui.panel_layout.workspace.sketch": "الرسم التخطيطي",
"ui.panel_layout.workspace.sketch": "الرسم",
"ui.panel_layout.your_layout": "تخطيطك",
"ui.parallel": "متوازي",
"ui.parameters": "المعاملات",
@@ -5863,6 +5888,8 @@
"ui.profiles": "المقاطع",
"ui.project": "إسقاط",
"ui.project_face_edges_sketch": "إسقاط حواف الوجه في رسومات جديدة",
"ui.project_references_construction": "إسقاط المراجع كهندسة مساعدة",
"ui.project_references_construction_note": "تصبح الهندسة التي تنشئها أوامر «إسقاط» و«تقاطع» و«تضمين هندسة ثلاثية الأبعاد»، وحواف الوجه في رسم جديد على وجه، هندسةً مساعدة: مراجع للرسم عليها لا تدخل في المقاطع. وتبقى مرتبطة بالنموذج.",
"ui.properties": "الخصائص",
"ui.properties.copy_matrix": "نسخ المصفوفة",
"ui.properties.first_moments": "العزوم الأولى",
@@ -5906,6 +5933,8 @@
"ui.renderer": "محرك التصيير",
"ui.repeat": "تكرار",
"ui.repeat_nothing": "لا يوجد ما يُكرَّر بعد: ابدأ أداة أو مربع حوار أولًا.",
"ui.replace_sketch.from_file": "من ملف تصميم…",
"ui.replace_sketch.no_sketches": "لا يحتوي هذا التصميم على رسم يمكن أخذه",
"ui.replace_with_link_done": "أصبحت النسخ الآن مواضع للتصميم المرتبط.",
"ui.report_a_bug": "الإبلاغ عن خطأ",
"ui.report_characters_left_one": "الأحرف المتبقية: {count}",
+1
View File
@@ -1395,6 +1395,7 @@
"material.editor.preserved_appearance": "Preserved appearance",
"material.editor.public_reference": "Public reference",
"material.editor.reference": "Reference",
"material.editor.remove": "Remove",
"material.editor.save_or_discard": "Save or discard the material draft before assigning a material.",
"material.editor.saved": "Material saved. Body assignments are unchanged.",
"material.editor.scope": "Save stores this material without assigning it to bodies. Blank properties and conditions remain unknown. Card density does not change assigned density or mass.",
+1
View File
@@ -1889,6 +1889,7 @@
"material.editor.preserved_appearance": "Sačuvani izgled",
"material.editor.public_reference": "Javna referenca",
"material.editor.reference": "Referenca",
"material.editor.remove": "Ukloni",
"material.editor.save_or_discard": "Sačuvajte ili odbacite nacrt materijala prije dodjele materijala.",
"material.editor.saved": "Materijal je sačuvan. Dodjele tijelima ostaju nepromijenjene.",
"material.editor.scope": "Sačuvaj sprema ovaj materijal bez dodjele tijelima. Prazna svojstva i uslovi ostaju nepoznati. Gustina s kartice ne mijenja dodijeljenu gustinu ni masu.",
+449 -16
View File
File diff suppressed because it is too large Load Diff
+752 -3
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -1889,6 +1889,7 @@
"material.editor.preserved_appearance": "Bevaret udseende",
"material.editor.public_reference": "Offentlig reference",
"material.editor.reference": "Reference",
"material.editor.remove": "Fjern",
"material.editor.save_or_discard": "Gem eller kassér materialeudkastet, før du tildeler et materiale.",
"material.editor.saved": "Materialet er gemt. Tildelinger til emner er uændrede.",
"material.editor.scope": "Gem gemmer materialet uden at tildele det til emner. Tomme egenskaber og betingelser forbliver ukendte. Kortets densitet ændrer ikke den tildelte densitet eller massen.",
+29
View File
@@ -206,6 +206,7 @@
"Edge": "Kante",
"Edges": "Kanten",
"Edit": "Bearbeiten",
"Edit Holes": "Bohrungen bearbeiten",
"Edit Profile Sketch": "Profile-Skizze bearbeiten",
"Edit shortcuts": "Tastenkürzel bearbeiten",
"Enable rule": "Regel aktivieren",
@@ -1017,6 +1018,7 @@
"cmd.material_properties_directional": "Richtungsabhängiger Elastizitätsmodul",
"cmd.material_properties_validate": "Physikalische Materialdaten prüfen",
"cmd.mesh_convert": "Netz konvertieren",
"cmd.model_body_features": "Körper-Features auflisten",
"cmd.model_edges": "Kanten auflisten",
"cmd.model_faces": "Flächen auflisten",
"cmd.model_threads": "Gewinde auflisten",
@@ -1045,8 +1047,11 @@
"cmd.panel.dismiss": "Panel-Vorschau schließen",
"cmd.panel.float": "Panel frei platzieren",
"cmd.panel.focus": "Panel fokussieren",
"cmd.panel.focus.next": "Nächstes Panel fokussieren",
"cmd.panel.focus.previous": "Vorheriges Panel fokussieren",
"cmd.panel.list": "Panels auflisten",
"cmd.panel.move": "Panel verschieben",
"cmd.panel.move.next_zone": "Panel in nächste Zone verschieben",
"cmd.panel.native_close": "Nativen Panel-Bereich wieder andocken",
"cmd.panel.open": "Panel öffnen",
"cmd.panel.peek": "Eingeklapptes Panel anzeigen",
@@ -1054,6 +1059,12 @@
"cmd.panel.reset_pair": "Panel-Trennlinie zurücksetzen",
"cmd.panel.resize": "Panel-Bereich skalieren",
"cmd.panel.resize_pair": "Panel-Trennlinie verschieben",
"cmd.panel.sash.down": "Panel-Trennlinie nach unten verschieben",
"cmd.panel.sash.left": "Panel-Trennlinie nach links verschieben",
"cmd.panel.sash.reset": "Trennlinie des fokussierten Panels zurücksetzen",
"cmd.panel.sash.right": "Panel-Trennlinie nach rechts verschieben",
"cmd.panel.sash.up": "Panel-Trennlinie nach oben verschieben",
"cmd.panel.toggle": "Fokussiertes Panel ein- oder ausklappen",
"cmd.parameters_add": "Benutzerparameter hinzufügen",
"cmd.parameters_change": "Parameter ändern",
"cmd.parameters_comment": "Parameter kommentieren",
@@ -1177,6 +1188,7 @@
"cmd.sketch.construction_line.tangent_external": "Tangente durch äußeren Punkt",
"cmd.sketch.construction_line.tangent_two": "Gemeinsame Tangente",
"cmd.sketch.construction_line.vertical": "Vertikale Konstruktionslinie",
"cmd.sketch.replace": "Skizze ersetzen",
"cmd.sketch_arc3d": "3D-Bogen",
"cmd.sketch_arc_center_point": "Mittelpunkt-Kreisbogen",
"cmd.sketch_arc_tangent": "Tangentialbogen",
@@ -1819,6 +1831,7 @@
"help.view.visual_style": "Schaltet um, wie das Modell gezeichnet wird: schattiert mit oder ohne Kanten, Drahtmodell oder mit verdeckten Kanten. Drahtmodell zum Hineinschauen, schattiert für ein sauberes Bild.",
"history.sketch_under_constrained": "Unterbestimmt ({count} Freiheitsgrade)",
"history.sketch_under_constrained_one": "Unterbestimmt ({count} Freiheitsgrad)",
"hole_folder.applies_to": "Änderungen gelten für die {count} Bohrungen in {folder}; jede behält ihre Position.",
"hole_presets.confirm_delete": "Löschen bestätigen",
"hole_presets.delete": "Löschen",
"hole_presets.delete_prompt": "Möchten Sie diese persönliche Vorlage wirklich löschen? Bereits vorhandene Bohrungen bleiben unverändert.",
@@ -1997,6 +2010,7 @@
"material.editor.preserved_appearance": "Beibehaltenes Erscheinungsbild",
"material.editor.public_reference": "Öffentliche Quelle",
"material.editor.reference": "Referenz",
"material.editor.remove": "Entfernen",
"material.editor.save_or_discard": "Speichern oder verwerfen Sie den Materialentwurf, bevor Sie ein Material zuweisen.",
"material.editor.saved": "Material gespeichert. Körperzuweisungen bleiben unverändert.",
"material.editor.scope": "Speichern legt dieses Material ab, ohne es Körpern zuzuweisen. Leere Eigenschaften und Bedingungen bleiben unbekannt. Die Dichte im Datenblatt ändert weder die zugewiesene Dichte noch die Masse.",
@@ -2302,6 +2316,8 @@
"prefs.bottom_centre": "Unten mittig",
"prefs.bottom_right": "Unten rechts",
"prefs.browser": "Browser",
"prefs.browser_body_features": "Features unter Körpern",
"prefs.browser_body_features_note": "Jeder Körper im Browser lässt sich aufklappen und zeigt die Features der Zeitleiste, die ihn erzeugt haben, in deren Reihenfolge.",
"prefs.browser_components_open": "Komponenten zu Beginn aufgeklappt",
"prefs.browser_components_open_note": "Jede Komponente im Browser ist wie bisher von Anfang an aufgeklappt, statt zugeklappt zu bleiben, bis du sie aufklappst.",
"prefs.browser_eye_left": "Auge neben dem Aufklapppfeil",
@@ -2342,7 +2358,11 @@
"prefs.design_tools.degrees": "Grad",
"prefs.design_tools.double_click_help": "Oder zum Bearbeiten einer verknüpften Komponente an Ort und Stelle.",
"prefs.design_tools.ghost": "Alte Position verschobener Teile als Geisterbild zeigen",
"prefs.design_tools.hole_group": "Gemeinsam erstellte Bohrungen gruppieren",
"prefs.design_tools.hole_group_help": "In einem Schritt erstellte Bohrungen teilen sich einen Ordner im Verlauf, zunächst geschlossen; öffnen Sie ihn, um eine Bohrung einzeln zu bearbeiten.",
"prefs.design_tools.hole_moves_help": "Erneutes Auswählen der Fläche verschiebt die Bohrung, statt sie zu entfernen.",
"prefs.design_tools.hole_several": "Mehrere Bohrungen auf einer Fläche",
"prefs.design_tools.hole_several_help": "Eine Auswahl an anderer Stelle der Fläche fügt eine weitere Bohrung hinzu; die Auswahl einer Bohrung entfernt sie.",
"prefs.design_tools.hole_snaps_help": "Die Mitte einer Fläche und die Mittelpunkte ihrer kreisförmigen Kanten.",
"prefs.design_tools.imported_files": "Importierte Dateien",
"prefs.design_tools.note": "Hinweis",
@@ -2424,6 +2444,8 @@
"prefs.history": "Verlauf",
"prefs.history_bar_groups": "Verlaufsleiste in Gruppen falten",
"prefs.history_bar_groups_note": "In einem langen Verlauf wird eine Folge von Features einer Komponente zu einem Chip gefaltet, und Merkmalsordner beginnen gefaltet. Eine eingefügte STEP-Baugruppe kommt in einen Group-Ordner. Aus zeigt jedes Feature, öffnet Ordner und legt keinen Ordner an.",
"prefs.history_folder_kind_icon": "Ordnersymbol aus seinen Features",
"prefs.history_folder_kind_icon_note": "Ein Ordner im Verlauf, dessen Features alle von einer Art sind, zeigt deren Symbol.",
"prefs.history_group_joints": "Gelenke im Verlauf gruppieren",
"prefs.history_group_joints_note": "Zwei oder mehr Gelenke erscheinen als eine Schaltfläche mit ihrer Anzahl; ein Klick zeigt jedes einzeln. Aus zeigt jedes Gelenk einzeln.",
"prefs.history_origin_bodies": "Ursprung und Körper oben zeigen",
@@ -2590,6 +2612,8 @@
"prefs.select_other_hidden": "Verborgene Geometrie mit „Andere auswählen“ auswählen",
"prefs.select_other_hidden_help": "Ein: Verborgene Körper, Skizzen und verdeckte Kanten nahe dem Zeiger einbeziehen. Aus: Die ursprüngliche Liste sichtbarer Geometrie direkt unter dem Zeiger verwenden.",
"prefs.selecting": "Auswahl",
"prefs.shared_panel_layout": "Gemeinsames Panellayout verwenden",
"prefs.shared_panel_layout_help": "Browser, Verlauf und Assistent in verschiebbaren Panels anzeigen. Die Änderung wird beim Anwenden der Einstellungen wirksam.",
"prefs.shift": "Umschalt",
"prefs.shift_add_ctrl_toggle": "Umschalt fügt nur hinzu; Strg / Cmd schaltet die Auswahl um",
"prefs.shift_add_note": "Aktiviert: Umschalt fügt Elemente zur Auswahl hinzu, Strg / Cmd schaltet ihre Auswahl um. Deaktiviert (Standard): Beide Tasten schalten die Auswahl um.",
@@ -5240,6 +5264,7 @@
"ui.heading_plastic": "PLASTIK",
"ui.heading_position": "POSITION",
"ui.heading_prepare": "VORBEREITEN",
"ui.heading_press_pull": "DRÜCKEN/ZIEHEN",
"ui.heading_rectangular_pattern": "RECHTECKMUSTER",
"ui.heading_revolve": "DREHEN",
"ui.heading_right": "RECHTS",
@@ -5579,6 +5604,8 @@
"ui.profiles": "Profile",
"ui.project": "Projizieren",
"ui.project_face_edges_sketch": "Flächenkanten in neue Skizzen projizieren",
"ui.project_references_construction": "Referenzen als Konstruktionsgeometrie projizieren",
"ui.project_references_construction_note": "Projizieren, Schneiden, 3D-Geometrie einbeziehen und die Flächenkanten einer neuen Skizze auf einer Fläche erzeugen Konstruktionsgeometrie: Bezüge zum Zeichnen, die nicht zu Profilen gehören. Sie bleiben mit dem Modell verknüpft.",
"ui.properties": "Eigenschaften",
"ui.properties.copy_matrix": "Matrix kopieren",
"ui.properties.first_moments": "Statische Momente",
@@ -5620,6 +5647,8 @@
"ui.renderer": "Renderer",
"ui.repeat": "Wiederholen",
"ui.repeat_nothing": "Noch nichts zu wiederholen: Starten Sie zuerst ein Werkzeug oder einen Dialog.",
"ui.replace_sketch.from_file": "Aus einer Konstruktionsdatei…",
"ui.replace_sketch.no_sketches": "Diese Konstruktion enthält keine Skizze",
"ui.replace_with_link_done": "Die Kopien sind jetzt Platzierungen des verknüpften Designs.",
"ui.report_a_bug": "Fehler melden",
"ui.report_characters_left_one": "{count} Zeichen übrig",
+29
View File
@@ -265,6 +265,7 @@
"Edge": "Edge",
"Edges": "Edges",
"Edit": "Edit",
"Edit Holes": "Edit Holes",
"Edit Profile Sketch": "Edit Profile Sketch",
"Edit shortcuts": "Edit shortcuts",
"Enable rule": "Enable rule",
@@ -1206,6 +1207,7 @@
"cmd.material_properties_directional": "Directional Elastic Modulus",
"cmd.material_properties_validate": "Validate Physical Material Data",
"cmd.mesh_convert": "Convert Mesh",
"cmd.model_body_features": "List Body Features",
"cmd.model_edges": "List Edges",
"cmd.model_faces": "List Faces",
"cmd.model_threads": "List Threads",
@@ -1234,8 +1236,11 @@
"cmd.panel.dismiss": "Dismiss Panel Peek",
"cmd.panel.float": "Float Panel",
"cmd.panel.focus": "Focus Panel",
"cmd.panel.focus.next": "Focus Next Panel",
"cmd.panel.focus.previous": "Focus Previous Panel",
"cmd.panel.list": "List Panels",
"cmd.panel.move": "Move Panel",
"cmd.panel.move.next_zone": "Move Panel to Next Zone",
"cmd.panel.native_close": "Redock Native Panel Section",
"cmd.panel.open": "Open Panel",
"cmd.panel.peek": "Peek Collapsed Panel",
@@ -1243,6 +1248,12 @@
"cmd.panel.reset_pair": "Reset Panel Sash",
"cmd.panel.resize": "Resize Panel Section",
"cmd.panel.resize_pair": "Resize Panel Sash",
"cmd.panel.sash.down": "Resize Panel Sash Down",
"cmd.panel.sash.left": "Resize Panel Sash Left",
"cmd.panel.sash.reset": "Reset Focused Panel Sash",
"cmd.panel.sash.right": "Resize Panel Sash Right",
"cmd.panel.sash.up": "Resize Panel Sash Up",
"cmd.panel.toggle": "Toggle Focused Panel",
"cmd.parameters_add": "Add User Parameter",
"cmd.parameters_change": "Change Parameters",
"cmd.parameters_comment": "Comment Parameter",
@@ -1366,6 +1377,7 @@
"cmd.sketch.construction_line.tangent_external": "Tangent through External Point",
"cmd.sketch.construction_line.tangent_two": "Common Tangent",
"cmd.sketch.construction_line.vertical": "Vertical Construction Line",
"cmd.sketch.replace": "Replace Sketch",
"cmd.sketch_arc3d": "3D Arc",
"cmd.sketch_arc_center_point": "Center Point Arc",
"cmd.sketch_arc_tangent": "Tangent Arc",
@@ -2016,6 +2028,7 @@
"help.view.visual_style": "Switches how the model is drawn: shaded with or without edges, wireframe, or with hidden edges. Use wireframe to see inside, shaded for a clean look.",
"history.sketch_under_constrained": "Under constrained ({count} degrees of freedom)",
"history.sketch_under_constrained_one": "Under constrained ({count} degree of freedom)",
"hole_folder.applies_to": "Changes apply to the {count} holes in {folder}; each keeps its place.",
"hole_presets.confirm_delete": "Confirm delete",
"hole_presets.delete": "Delete",
"hole_presets.delete_prompt": "Delete this personal preset? Existing holes are unchanged.",
@@ -2205,6 +2218,7 @@
"material.editor.preserved_appearance": "Preserved appearance",
"material.editor.public_reference": "Public reference",
"material.editor.reference": "Reference",
"material.editor.remove": "Remove",
"material.editor.save_or_discard": "Save or discard the material draft before assigning a material.",
"material.editor.saved": "Material saved. Body assignments are unchanged.",
"material.editor.scope": "Save stores this material without assigning it to bodies. Blank properties and conditions remain unknown. Card density does not change assigned density or mass.",
@@ -2515,6 +2529,8 @@
"prefs.bottom_centre": "Bottom center",
"prefs.bottom_right": "Bottom right",
"prefs.browser": "Browser",
"prefs.browser_body_features": "Features under bodies",
"prefs.browser_body_features_note": "Each body in the browser folds open on the timeline features that built it, in timeline order.",
"prefs.browser_components_open": "Components start open",
"prefs.browser_components_open_note": "Every component in the browser starts open, as before, instead of closed until you open it.",
"prefs.browser_eye_left": "Eye beside the fold arrow",
@@ -2555,7 +2571,11 @@
"prefs.design_tools.degrees": "Degrees",
"prefs.design_tools.double_click_help": "Or to edit a linked one in place.",
"prefs.design_tools.ghost": "Show moved parts' old place as a ghost",
"prefs.design_tools.hole_group": "Group holes made together",
"prefs.design_tools.hole_group_help": "Holes made in one go share a folder in History, closed at first; open it to edit a hole on its own.",
"prefs.design_tools.hole_moves_help": "Picking the face again moves the hole instead of removing it.",
"prefs.design_tools.hole_several": "Several holes on one face",
"prefs.design_tools.hole_several_help": "Picking elsewhere on a face adds another hole; picking a hole removes it.",
"prefs.design_tools.hole_snaps_help": "The middle of a face and the centers of its round edges.",
"prefs.design_tools.imported_files": "Imported files",
"prefs.design_tools.note": "Note",
@@ -2637,6 +2657,8 @@
"prefs.history": "History",
"prefs.history_bar_groups": "Fold the History bar into groups",
"prefs.history_bar_groups_note": "In a long history, a run of one component's features folds into one chip, and feature folders start folded. An inserted STEP assembly goes into a Group folder. Off shows each feature, starts folders open and makes no folder.",
"prefs.history_folder_kind_icon": "Folder icon from its features",
"prefs.history_folder_kind_icon_note": "A History folder whose features are all of one kind shows their icon.",
"prefs.history_group_joints": "Group joints in the History",
"prefs.history_group_joints_note": "Two or more joints show as one button with their count, and a click shows each one. Off shows each joint.",
"prefs.history_origin_bodies": "Show origin and bodies at the top",
@@ -2830,6 +2852,8 @@
"prefs.select_other_hidden": "Select hidden geometry with Select Other",
"prefs.select_other_hidden_help": "On: include hidden bodies, sketches and occluded edges near the pointer. Off: use the original list of visible geometry under the pointer.",
"prefs.selecting": "Selecting",
"prefs.shared_panel_layout": "Use shared panel layout",
"prefs.shared_panel_layout_help": "Show the existing Browser, History and Assistant in movable panels. The change takes effect when preferences are applied.",
"prefs.shift": "Shift",
"prefs.shift_add_ctrl_toggle": "Shift adds only; Ctrl / Cmd toggles",
"prefs.shift_add_note": "On: Shift adds, Ctrl / Cmd toggles. Off (default): both toggle.",
@@ -5520,6 +5544,7 @@
"ui.heading_plastic": "PLASTIC",
"ui.heading_position": "POSITION",
"ui.heading_prepare": "PREPARE",
"ui.heading_press_pull": "PRESS PULL",
"ui.heading_rectangular_pattern": "RECTANGULAR PATTERN",
"ui.heading_revolve": "REVOLVE",
"ui.heading_right": "RIGHT",
@@ -5863,6 +5888,8 @@
"ui.profiles": "Profiles",
"ui.project": "Project",
"ui.project_face_edges_sketch": "Project face edges into new sketches",
"ui.project_references_construction": "Project references as construction geometry",
"ui.project_references_construction_note": "Project, Intersect, Include 3D Geometry and the face edges of a new sketch on a face make construction geometry: references to draw against, left out of profiles. They stay linked to the model.",
"ui.properties": "Properties",
"ui.properties.copy_matrix": "Copy matrix",
"ui.properties.first_moments": "First moments",
@@ -5906,6 +5933,8 @@
"ui.renderer": "Renderer",
"ui.repeat": "Repeat",
"ui.repeat_nothing": "Nothing to repeat yet: start a tool or dialog first.",
"ui.replace_sketch.from_file": "From a Design File…",
"ui.replace_sketch.no_sketches": "That design has no sketch to take",
"ui.replace_with_link_done": "The copies are now placements of the linked design.",
"ui.report_a_bug": "Report a bug",
"ui.report_characters_left_one": "{count} character left",
+1
View File
@@ -2204,6 +2204,7 @@
"material.editor.preserved_appearance": "Apariencia conservada",
"material.editor.public_reference": "Referencia pública",
"material.editor.reference": "Referencia",
"material.editor.remove": "Eliminar",
"material.editor.save_or_discard": "Guarda o descarta el borrador antes de asignar un material.",
"material.editor.saved": "Material guardado. Las asignaciones a cuerpos no han cambiado.",
"material.editor.scope": "Guardar almacena este material sin asignarlo a cuerpos. Las propiedades y condiciones vacías siguen siendo desconocidas. La densidad de la ficha no cambia la densidad asignada ni la masa.",
+7 -6
View File
@@ -1155,7 +1155,7 @@
"cmd.sheet_perforate": "Rei'itä",
"cmd.sheet_refold": "Taita uudelleen",
"cmd.sheet_rules": "Luettele ohutlevysäännöt",
"cmd.sheet_unfold": "Avaa",
"cmd.sheet_unfold": "Levitä",
"cmd.sketch.construction_line": "Apuviiva",
"cmd.sketch.construction_line.angle": "Apuviiva kulmassa",
"cmd.sketch.construction_line.bisector": "Kulman puolittaja",
@@ -1692,7 +1692,7 @@
"help.sketch.move": "Siirtää tai kiertää luonnoskäyriä ja -pisteitä tai niiden kopiota. Käytä sitä geometrian uudelleensijoittamiseen niin, että sen muoto ja rajoitteet säilyvät.",
"help.sketch.offset": "Luo käyräketjusta kopion annetulle etäisyydelle toiselle tai molemmille puolille. Käytä sitä seinämiin, ääriviivoihin ja välyksiin; etäisyys säilyy muokattavana mittana.",
"help.sketch.paste": "Liittää kopioimasi tai leikkaamasi luonnosgeometrian aktiiviseen luonnokseen. Sijoita se pisteeseen tai siirrä sitä siirtymän verran.",
"help.sketch.pattern.circular": "Kopioi luonnosgeometriaa keskipisteen ympäri koko ympyrän tai annetun kulman matkalle. Käytä sitä pulttijakoympyröihin, puolisiin ja hampaisiin.",
"help.sketch.pattern.circular": "Kopioi luonnosgeometriaa keskipisteen ympäri koko ympyrän tai annetun kulman matkalle. Käytä sitä pulttijakoympyröihin, puoliin ja hampaisiin.",
"help.sketch.pattern.rectangular": "Kopioi luonnosgeometriaa riveihin ja sarakkeisiin annetuin välein. Käytä sitä reikien, urien tai tuuletusaukkojen ruudukkoihin.",
"help.sketch.point": "Sijoittaa luonnospisteen. Käytä sitä mittojen, rajoitteiden, reikien keskipisteiden tai myöhempien piirteiden viitteenä.",
"help.sketch.point3d": "Sijoittaa pisteen avaruuteen, kun 3D-luonnos on käytössä. Käytä sitä 3D-käyrien ja polkujen viitteenä.",
@@ -1869,7 +1869,7 @@
"import_report.details": "Tiedot:",
"import_report.file_one": "Tiedostossa oli 1 muu ongelma.",
"import_report.file_other": "Tiedostossa oli {count} muuta ongelmaa.",
"import_report.hint": "Näiden osien vieminen uudelleen ohjelmasta, jolla ne on tehty, voi korjata tämän. Kopioi varoitusilmoitus sisältää yksityiskohdat.",
"import_report.hint": "Näiden osien vieminen uudelleen ohjelmasta, jolla ne on tehty, voi korjata tämän. Näet yksityiskohdat toiminnolla Kopioi varoitusilmoitus.",
"import_report.left_out_one": "1 pintaa ei voitu korjata, ja se puuttuu.",
"import_report.left_out_other": "{count} pintaa ei voitu korjata, ja ne puuttuvat.",
"import_report.needs_repair_one": "1 osa on korjattava ennen kuin sitä voi mallintaa edelleen.",
@@ -1984,6 +1984,7 @@
"material.editor.preserved_appearance": "Säilytetty ulkoasu",
"material.editor.public_reference": "Julkinen lähde",
"material.editor.reference": "Viite",
"material.editor.remove": "Poista",
"material.editor.save_or_discard": "Tallenna tai hylkää materiaaliluonnos ennen materiaalin määrittämistä.",
"material.editor.saved": "Materiaali tallennettu. Kappaleiden määritykset eivät muuttuneet.",
"material.editor.scope": "Tallennus tallentaa tämän materiaalin määrittämättä sitä kappaleille. Tyhjät ominaisuudet ja olosuhteet jäävät tuntemattomiksi. Kortin tiheys ei muuta määritettyä tiheyttä tai massaa.",
@@ -2321,7 +2322,7 @@
"prefs.design_note": "Millä uudet mallit alkavat. Avoimet mallit säilyttävät omansa.",
"prefs.design_tools.ask": "Kysy",
"prefs.design_tools.degrees": "Astetta",
"prefs.design_tools.double_click_help": "Tai muokataksesi linkitettyä paikallaan.",
"prefs.design_tools.double_click_help": "Tai muokataksesi linkitettyä komponenttia paikallaan.",
"prefs.design_tools.ghost": "Näytä siirrettyjen osien vanha paikka haamukuvana",
"prefs.design_tools.hole_moves_help": "Pinnan valitseminen uudelleen siirtää reikää sen poistamisen sijaan.",
"prefs.design_tools.hole_snaps_help": "Pinnan keskikohta ja sen pyöreiden särmien keskipisteet.",
@@ -2380,7 +2381,7 @@
"prefs.graphics_note": "Vaihda nopeutta sulavuuteen. Kunkin valinnan vaikutus näkyy näytteessä.",
"prefs.graphics_page.current_view": "Nykyinen näkymä",
"prefs.graphics_page.fastest": "Nopein",
"prefs.graphics_page.fold_items": "Kokoa kohteet",
"prefs.graphics_page.fold_items": "Kutista kohteet",
"prefs.graphics_page.in_view": "Näkymässä",
"prefs.graphics_page.live_details": "Reaaliaikaiset tiedot",
"prefs.graphics_page.own_mix": "Oma yhdistelmä",
@@ -2588,7 +2589,7 @@
"prefs.sketch.prefer_free_points": "Suosi siirrettäviä pisteitä päällekkäisissä kohdissa",
"prefs.sketch.project_options": "Kysy projisoinnin asetukset ensin",
"prefs.sketch.recent_snap": "Tartu kohtaan, jossa kaksi ohjainta risteää",
"prefs.sketch.select_other_hidden": "Valitse muu ulottuu piilotettuun geometriaan",
"prefs.sketch.select_other_hidden": "Valitse muu -toiminto ulottuu piilotettuun geometriaan",
"prefs.sketch.shortcut_cycles_modes": "Pikanäppäin uudelleen vaihtaa tilaa",
"prefs.sketch.shortcut_cycles_modes_tip": "Pois: se käynnistää työkalun uudelleen.",
"prefs.sketch.snapping_guides": "Tartunta ja ohjaimet",
+7
View File
@@ -1000,6 +1000,7 @@
"cmd.material_properties_directional": "Module d'élasticité directionnel",
"cmd.material_properties_validate": "Valider les données du matériau physique",
"cmd.mesh_convert": "Convertir le maillage",
"cmd.model_body_features": "Lister les fonctions du corps",
"cmd.model_edges": "Lister les arêtes",
"cmd.model_faces": "Lister les faces",
"cmd.model_threads": "Lister les filetages",
@@ -1139,6 +1140,7 @@
"cmd.sketch.construction_line.tangent_external": "Tangente passant par un point extérieur",
"cmd.sketch.construction_line.tangent_two": "Tangente commune",
"cmd.sketch.construction_line.vertical": "Droite de construction verticale",
"cmd.sketch.replace": "Remplacer l’esquisse",
"cmd.sketch_arc3d": "Arc 3D",
"cmd.sketch_arc_center_point": "Arc par centre",
"cmd.sketch_arc_tangent": "Arc tangent",
@@ -1897,6 +1899,7 @@
"material.editor.preserved_appearance": "Apparence conservée",
"material.editor.public_reference": "Référence publique",
"material.editor.reference": "Référence",
"material.editor.remove": "Retirer",
"material.editor.save_or_discard": "Enregistrez ou abandonnez le brouillon de matériau avant d'affecter un matériau.",
"material.editor.saved": "Matériau enregistré. Les affectations des corps sont inchangées.",
"material.editor.scope": "Enregistrer stocke ce matériau sans l'affecter à des corps. Les propriétés et conditions vides restent inconnues. La masse volumique de la fiche ne modifie ni la masse volumique affectée ni la masse.",
@@ -2163,6 +2166,8 @@
"prefs.bottom_centre": "Bas centre",
"prefs.bottom_right": "Bas droite",
"prefs.browser": "Navigateur",
"prefs.browser_body_features": "Fonctions sous les corps",
"prefs.browser_body_features_note": "Chaque corps du navigateur se déplie sur les fonctions de l'historique qui l'ont construit, dans l'ordre de l'historique.",
"prefs.browser_components_open": "Composants dépliés au départ",
"prefs.browser_components_open_note": "Chaque composant du navigateur commence déplié, comme avant, au lieu de rester replié jusqu'à ce que vous le dépliiez.",
"prefs.browser_eye_left": "Œil à côté de la flèche de dépliage",
@@ -5015,6 +5020,8 @@
"ui.renderer": "Moteur de rendu",
"ui.repeat": "Répéter",
"ui.repeat_nothing": "Rien à répéter pour l'instant : lancer d'abord un outil ou une boîte de dialogue.",
"ui.replace_sketch.from_file": "Depuis un fichier de conception…",
"ui.replace_sketch.no_sketches": "Cette conception ne contient aucune esquisse",
"ui.replace_with_link_done": "Les copies sont maintenant des placements de la conception liée.",
"ui.report_a_bug": "Signaler un bug",
"ui.reset": "Réinitialiser",
+1
View File
@@ -1889,6 +1889,7 @@
"material.editor.preserved_appearance": "Aparencia conservada",
"material.editor.public_reference": "Referencia pública",
"material.editor.reference": "Referencia",
"material.editor.remove": "Quitar",
"material.editor.save_or_discard": "Garda ou descarta o borrador do material antes de asignar un material.",
"material.editor.saved": "Material gardado. As asignacións aos corpos non cambian.",
"material.editor.scope": "Gardar almacena este material sen asignalo a corpos. As propiedades e condicións en branco seguen sendo descoñecidas. A densidade da ficha non cambia a densidade asignada nin a masa.",
+29
View File
@@ -265,6 +265,7 @@
"Edge": "מקצוע",
"Edges": "מקצועות",
"Edit": "עריכה",
"Edit Holes": "עריכת חורים",
"Edit Profile Sketch": "עריכת סקיצת הפרופיל",
"Edit shortcuts": "עריכת קיצורי דרך",
"Enable rule": "הפעלת הכלל",
@@ -1206,6 +1207,7 @@
"cmd.material_properties_directional": "מודול אלסטיות כיווני",
"cmd.material_properties_validate": "אימות נתוני חומר פיזיקלי",
"cmd.mesh_convert": "המרת רשת משולשים",
"cmd.model_body_features": "הצגת פיצ'רים של גוף",
"cmd.model_edges": "רשימת מקצועות",
"cmd.model_faces": "רשימת פאות",
"cmd.model_threads": "רשימת הברגות",
@@ -1234,8 +1236,11 @@
"cmd.panel.dismiss": "סגירת תצוגה זמנית של חלונית",
"cmd.panel.float": "הצפת חלונית",
"cmd.panel.focus": "מיקוד חלונית",
"cmd.panel.focus.next": "מיקוד בלוח הבא",
"cmd.panel.focus.previous": "מיקוד בלוח הקודם",
"cmd.panel.list": "הצגת רשימת חלוניות",
"cmd.panel.move": "העברת חלונית",
"cmd.panel.move.next_zone": "העברת הלוח לאזור הבא",
"cmd.panel.native_close": "עיגון מחדש של מקטע חלונית מחלון נפרד",
"cmd.panel.open": "פתיחת חלונית",
"cmd.panel.peek": "הצגה זמנית של חלונית מכווצת",
@@ -1243,6 +1248,12 @@
"cmd.panel.reset_pair": "איפוס מפריד חלוניות",
"cmd.panel.resize": "שינוי גודל מקטע חלונית",
"cmd.panel.resize_pair": "הזזת מפריד חלוניות",
"cmd.panel.sash.down": "הזזת מפריד הלוח למטה",
"cmd.panel.sash.left": "הזזת מפריד הלוח שמאלה",
"cmd.panel.sash.reset": "איפוס מפריד הלוח שבמיקוד",
"cmd.panel.sash.right": "הזזת מפריד הלוח ימינה",
"cmd.panel.sash.up": "הזזת מפריד הלוח למעלה",
"cmd.panel.toggle": "שינוי מצב הכיווץ של הלוח שבמיקוד",
"cmd.parameters_add": "הוספת פרמטר משתמש",
"cmd.parameters_change": "שינוי פרמטרים",
"cmd.parameters_comment": "הערה לפרמטר",
@@ -1366,6 +1377,7 @@
"cmd.sketch.construction_line.tangent_external": "משיק דרך נקודה חיצונית",
"cmd.sketch.construction_line.tangent_two": "משיק משותף",
"cmd.sketch.construction_line.vertical": "ישר עזר אנכי",
"cmd.sketch.replace": "החלפת סקיצה",
"cmd.sketch_arc3d": "קשת תלת־ממדית",
"cmd.sketch_arc_center_point": "קשת מרכז ונקודה",
"cmd.sketch_arc_tangent": "קשת משיקה",
@@ -2016,6 +2028,7 @@
"help.view.visual_style": "החלפת אופן הציור של המודל: מוצלל עם מקצועות או בלעדיהם, מסגרת תיל, או עם מקצועות מוסתרים. מסגרת תיל מאפשרת לראות פנימה, ומוצלל נותן מראה נקי.",
"history.sketch_under_constrained": "אילוצים חסרים ({count} דרגות חופש)",
"history.sketch_under_constrained_one": "אילוצים חסרים (דרגת חופש {count})",
"hole_folder.applies_to": "השינויים חלים על {count} החורים ב-{folder}; כל חור שומר על מיקומו.",
"hole_presets.confirm_delete": "אישור מחיקה",
"hole_presets.delete": "מחיקה",
"hole_presets.delete_prompt": "למחוק את ההגדרה המוכנה האישית הזו? חורים קיימים לא ישתנו.",
@@ -2205,6 +2218,7 @@
"material.editor.preserved_appearance": "מראה שנשמר",
"material.editor.public_reference": "ייחוס ציבורי",
"material.editor.reference": "ייחוס",
"material.editor.remove": "הסרה",
"material.editor.save_or_discard": "יש לשמור או לבטל את טיוטת החומר לפני הקצאת חומר.",
"material.editor.saved": "החומר נשמר. הקצאות הגופים לא השתנו.",
"material.editor.scope": "השמירה מאחסנת את החומר הזה בלי להקצות אותו לגופים. תכונות ותנאים ריקים נשארים לא ידועים. צפיפות הכרטיס אינה משנה את הצפיפות המוקצית או את המסה.",
@@ -2515,6 +2529,8 @@
"prefs.bottom_centre": "למטה במרכז",
"prefs.bottom_right": "למטה מימין",
"prefs.browser": "עץ התכן",
"prefs.browser_body_features": "פיצ'רים מתחת לגופים",
"prefs.browser_body_features_note": "כל גוף בדפדפן נפתח ומציג את הפיצ'רים מציר הזמן שבנו אותו, לפי סדר ציר הזמן.",
"prefs.browser_components_open": "רכיבים פתוחים מההתחלה",
"prefs.browser_components_open_note": "כל רכיב בעץ התכן מתחיל פתוח, כמו קודם, במקום סגור עד שתפתח אותו.",
"prefs.browser_eye_left": "סמל העין ליד חץ הקיפול",
@@ -2555,7 +2571,11 @@
"prefs.design_tools.degrees": "מעלות",
"prefs.design_tools.double_click_help": "או לעריכת רכיב מקושר במקומו.",
"prefs.design_tools.ghost": "הצגת המיקום הקודם של חלקים שהוזזו כרפאים",
"prefs.design_tools.hole_group": "קיבוץ חורים שנוצרו יחד",
"prefs.design_tools.hole_group_help": "חורים שנוצרו בפעולה אחת חולקים תיקייה בהיסטוריה, סגורה בהתחלה; פתחו אותה כדי לערוך חור בנפרד.",
"prefs.design_tools.hole_moves_help": "בחירה חוזרת בפאה מזיזה את החור במקום להסירו.",
"prefs.design_tools.hole_several": "כמה חורים בפאה אחת",
"prefs.design_tools.hole_several_help": "בחירה במקום אחר בפאה מוסיפה חור נוסף; בחירה בחור מסירה אותו.",
"prefs.design_tools.hole_snaps_help": "אמצע הפאה ומרכזי הקצוות העגולים שלה.",
"prefs.design_tools.imported_files": "קבצים מיובאים",
"prefs.design_tools.note": "הערה",
@@ -2637,6 +2657,8 @@
"prefs.history": "ציר הזמן",
"prefs.history_bar_groups": "קיפול סרגל ציר הזמן לקבוצות",
"prefs.history_bar_groups_note": "בציר זמן ארוך, רצף פיצ'רים של רכיב אחד מתקפל לשבב אחד, ותיקיות פיצ'רים מתחילות מקופלות. מכלול STEP שהוכנס נכנס לתיקיית Group. כשהאפשרות כבויה, כל פיצ'ר מוצג, התיקיות מתחילות פתוחות ולא נוצרת תיקייה.",
"prefs.history_folder_kind_icon": "סמל התיקייה לפי התכונות שבה",
"prefs.history_folder_kind_icon_note": "תיקייה בהיסטוריה שכל התכונות בה מאותו סוג מציגה את הסמל שלהן.",
"prefs.history_group_joints": "קיבוץ מפרקים בציר הזמן",
"prefs.history_group_joints_note": "שני מפרקים או יותר מוצגים ככפתור אחד עם מספרם, ולחיצה מציגה כל אחד מהם. כשהאפשרות כבויה, כל מפרק מוצג בנפרד.",
"prefs.history_origin_bodies": "הצגת הראשית והגופים למעלה",
@@ -2830,6 +2852,8 @@
"prefs.select_other_hidden": "בחירת גאומטריה מוסתרת באמצעות בחירה אחרת",
"prefs.select_other_hidden_help": "מופעל: כולל גופים, סקיצות וקצוות מוסתרים בקרבת הסמן. כבוי: שימוש ברשימה המקורית של גאומטריה גלויה מתחת לסמן.",
"prefs.selecting": "בחירה",
"prefs.shared_panel_layout": "שימוש בפריסת הלוחות המשותפת",
"prefs.shared_panel_layout_help": "הצגת הדפדפן, ההיסטוריה והעוזר בלוחות ניתנים להזזה. השינוי ייכנס לתוקף לאחר החלת ההעדפות.",
"prefs.shift": "Shift",
"prefs.shift_add_ctrl_toggle": "Shift מוסיף בלבד; Ctrl / Cmd מחליף מצב",
"prefs.shift_add_note": "כשפועל: Shift מוסיף, Ctrl / Cmd מחליף מצב. כשכבוי (ברירת מחדל): שניהם מחליפים מצב.",
@@ -5520,6 +5544,7 @@
"ui.heading_plastic": "פלסטיק",
"ui.heading_position": "מיקום",
"ui.heading_prepare": "הכנה",
"ui.heading_press_pull": "דחיפה/משיכה",
"ui.heading_rectangular_pattern": "מערך מלבני",
"ui.heading_revolve": "סיבוב",
"ui.heading_right": "ימין",
@@ -5863,6 +5888,8 @@
"ui.profiles": "פרופילים",
"ui.project": "הטלה",
"ui.project_face_edges_sketch": "הטלת מקצועות הפאה לסקיצות חדשות",
"ui.project_references_construction": "הטלת הפניות כגאומטריית עזר",
"ui.project_references_construction_note": "הטלה, חיתוך, הכללת גאומטריה תלת־ממדית ומקצועות הפאה של סקיצה חדשה על פאה יוצרים גאומטריית עזר: הפניות לשרטוט שאינן נכללות בפרופילים. הן נשארות מקושרות למודל.",
"ui.properties": "מאפיינים",
"ui.properties.copy_matrix": "העתקת המטריצה",
"ui.properties.first_moments": "מומנטים ראשונים",
@@ -5906,6 +5933,8 @@
"ui.renderer": "מנוע גרפי",
"ui.repeat": "חזרה",
"ui.repeat_nothing": "אין עדיין מה לחזור עליו: התחל קודם כלי או תיבת דו־שיח.",
"ui.replace_sketch.from_file": "מקובץ תכנון…",
"ui.replace_sketch.no_sketches": "בתכנון הזה אין סקיצה לקחת",
"ui.replace_with_link_done": "העותקים הם עכשיו מיקומים של התכן המקושר.",
"ui.report_a_bug": "דיווח על באג",
"ui.report_characters_left_one": "תווים שנותרו: {count}",
+1
View File
@@ -1889,6 +1889,7 @@
"material.editor.preserved_appearance": "Očuvani izgled",
"material.editor.public_reference": "Javna referenca",
"material.editor.reference": "Referenca",
"material.editor.remove": "Ukloni",
"material.editor.save_or_discard": "Spremite ili odbacite nacrt materijala prije dodjele materijala.",
"material.editor.saved": "Materijal je spremljen. Dodjele tijelima ostaju nepromijenjene.",
"material.editor.scope": "Spremi pohranjuje ovaj materijal bez dodjele tijelima. Prazna svojstva i uvjeti ostaju nepoznati. Gustoća s kartice ne mijenja dodijeljenu gustoću ni masu.",
+1
View File
@@ -1889,6 +1889,7 @@
"material.editor.preserved_appearance": "Megőrzött megjelenés",
"material.editor.public_reference": "Nyilvános hivatkozás",
"material.editor.reference": "Hivatkozás",
"material.editor.remove": "Eltávolítás",
"material.editor.save_or_discard": "Anyag hozzárendelése előtt mentse vagy vesse el az anyagvázlatot.",
"material.editor.saved": "Az anyag mentve. A testekhez rendelt anyagok nem változtak.",
"material.editor.scope": "A Mentés az anyagot a testekhez való hozzárendelés nélkül tárolja. Az üres tulajdonságok és feltételek ismeretlenek maradnak. A kártyán szereplő sűrűség nem változtatja meg a hozzárendelt sűrűséget vagy a tömeget.",
+1
View File
@@ -1889,6 +1889,7 @@
"material.editor.preserved_appearance": "Tampilan yang dipertahankan",
"material.editor.public_reference": "Referensi publik",
"material.editor.reference": "Referensi",
"material.editor.remove": "Hapus",
"material.editor.save_or_discard": "Simpan atau buang draf material sebelum menetapkan material.",
"material.editor.saved": "Material disimpan. Penetapan ke bodi tidak berubah.",
"material.editor.scope": "Simpan menyimpan material ini tanpa menetapkannya ke bodi. Properti dan kondisi yang dikosongkan tetap dianggap tidak diketahui. Massa jenis pada kartu tidak mengubah massa jenis atau massa yang sudah ditetapkan.",
+1
View File
@@ -1896,6 +1896,7 @@
"material.editor.preserved_appearance": "Aspetto conservato",
"material.editor.public_reference": "Riferimento pubblico",
"material.editor.reference": "Riferimento",
"material.editor.remove": "Rimuovi",
"material.editor.save_or_discard": "Salva o scarta la bozza del materiale prima di assegnare un materiale.",
"material.editor.saved": "Materiale salvato. Le assegnazioni ai corpi non sono cambiate.",
"material.editor.scope": "Salva memorizza questo materiale senza assegnarlo ai corpi. Le proprietà e le condizioni lasciate vuote restano sconosciute. La densità della scheda non cambia la densità assegnata né la massa.",
+29
View File
@@ -265,6 +265,7 @@
"Edge": "エッジ",
"Edges": "エッジ",
"Edit": "編集",
"Edit Holes": "穴を編集",
"Edit Profile Sketch": "プロファイルスケッチを編集",
"Edit shortcuts": "ショートカットを編集",
"Enable rule": "ルールを有効化",
@@ -1206,6 +1207,7 @@
"cmd.material_properties_directional": "方向別弾性率",
"cmd.material_properties_validate": "物理材料データを検証",
"cmd.mesh_convert": "メッシュを変換",
"cmd.model_body_features": "ボディのフィーチャーを一覧表示",
"cmd.model_edges": "エッジを一覧表示",
"cmd.model_faces": "面を一覧表示",
"cmd.model_threads": "ねじを一覧表示",
@@ -1234,8 +1236,11 @@
"cmd.panel.dismiss": "パネルの一時表示を閉じる",
"cmd.panel.float": "パネルをフロート表示",
"cmd.panel.focus": "パネルにフォーカス",
"cmd.panel.focus.next": "次のパネルにフォーカス",
"cmd.panel.focus.previous": "前のパネルにフォーカス",
"cmd.panel.list": "パネルを一覧表示",
"cmd.panel.move": "パネルを移動",
"cmd.panel.move.next_zone": "パネルを次の領域に移動",
"cmd.panel.native_close": "別ウィンドウのパネルを再ドッキング",
"cmd.panel.open": "パネルを開く",
"cmd.panel.peek": "折りたたんだパネルを一時表示",
@@ -1243,6 +1248,12 @@
"cmd.panel.reset_pair": "パネルの区切りをリセット",
"cmd.panel.resize": "パネルのセクションのサイズを変更",
"cmd.panel.resize_pair": "パネルの区切りを移動",
"cmd.panel.sash.down": "パネルの区切りを下に移動",
"cmd.panel.sash.left": "パネルの区切りを左に移動",
"cmd.panel.sash.reset": "フォーカス中のパネルの区切りをリセット",
"cmd.panel.sash.right": "パネルの区切りを右に移動",
"cmd.panel.sash.up": "パネルの区切りを上に移動",
"cmd.panel.toggle": "フォーカス中のパネルの折りたたみを切り替え",
"cmd.parameters_add": "ユーザー パラメータを追加",
"cmd.parameters_change": "パラメータを変更",
"cmd.parameters_comment": "パラメータにコメントを付与",
@@ -1366,6 +1377,7 @@
"cmd.sketch.construction_line.tangent_external": "外部点を通る接線",
"cmd.sketch.construction_line.tangent_two": "共通接線",
"cmd.sketch.construction_line.vertical": "垂直補助直線",
"cmd.sketch.replace": "スケッチを置き換え",
"cmd.sketch_arc3d": "3D 円弧",
"cmd.sketch_arc_center_point": "中心点円弧",
"cmd.sketch_arc_tangent": "正接円弧",
@@ -2016,6 +2028,7 @@
"help.view.visual_style": "モデルの描画方法(エッジ付きまたはなしのシェード、ワイヤーフレーム、隠線付き)を切り替えます。内部を見るにはワイヤーフレーム、すっきり見せるにはシェードを使います。",
"history.sketch_under_constrained": "拘束不足(自由度 {count})",
"history.sketch_under_constrained_one": "拘束不足(自由度 {count})",
"hole_folder.applies_to": "変更は {folder} の {count} 個の穴に適用されます。各穴の位置はそのままです。",
"hole_presets.confirm_delete": "削除を確認",
"hole_presets.delete": "削除",
"hole_presets.delete_prompt": "この個人用プリセットを削除しますか?既存の穴は変更されません。",
@@ -2205,6 +2218,7 @@
"material.editor.preserved_appearance": "保持される外観",
"material.editor.public_reference": "公開資料",
"material.editor.reference": "参照",
"material.editor.remove": "削除",
"material.editor.save_or_discard": "材料を割り当てる前に下書きを保存または破棄してください。",
"material.editor.saved": "材料を保存しました。ボディへの割り当ては変わりません。",
"material.editor.scope": "保存してもボディには割り当てません。空欄の特性と条件は不明のままです。カードの密度は割り当て密度や質量を変更しません。",
@@ -2515,6 +2529,8 @@
"prefs.bottom_centre": "下中央",
"prefs.bottom_right": "右下",
"prefs.browser": "ブラウザー",
"prefs.browser_body_features": "ボディの下にフィーチャー",
"prefs.browser_body_features_note": "ブラウザーの各ボディを展開すると、そのボディを作ったタイムラインのフィーチャーがタイムライン順に表示されます。",
"prefs.browser_components_open": "コンポーネントを最初から展開",
"prefs.browser_components_open_note": "ブラウザーのすべてのコンポーネントを以前と同じく最初から展開して表示します。オフの場合は展開するまで折りたたまれています。",
"prefs.browser_eye_left": "折りたたみ矢印の隣の目のアイコン",
@@ -2555,7 +2571,11 @@
"prefs.design_tools.degrees": "度",
"prefs.design_tools.double_click_help": "リンクされたコンポーネントをその場で編集することもできます。",
"prefs.design_tools.ghost": "移動したパーツの元の位置をゴースト表示",
"prefs.design_tools.hole_group": "一緒に作成した穴をまとめる",
"prefs.design_tools.hole_group_help": "一度に作成した穴は履歴で1つのフォルダーにまとめられ、最初は閉じています。開くと穴を個別に編集できます。",
"prefs.design_tools.hole_moves_help": "面をもう一度選ぶと穴を削除せずに移動します。",
"prefs.design_tools.hole_several": "1つの面に複数の穴",
"prefs.design_tools.hole_several_help": "面の別の場所を選ぶと穴を追加し、穴を選ぶとその穴を削除します。",
"prefs.design_tools.hole_snaps_help": "面の中央と円形エッジの中心。",
"prefs.design_tools.imported_files": "インポートしたファイル",
"prefs.design_tools.note": "通知",
@@ -2637,6 +2657,8 @@
"prefs.history": "履歴",
"prefs.history_bar_groups": "履歴バーをグループにまとめる",
"prefs.history_bar_groups_note": "長い履歴では、1つのコンポーネントの連続したフィーチャーを1つのチップにまとめ、フィーチャーフォルダは閉じた状態で始まります。挿入したSTEPアセンブリはGroupフォルダに入ります。オフにすると、各フィーチャーを表示し、フォルダは開いた状態で始まり、フォルダは作成されません。",
"prefs.history_folder_kind_icon": "中のフィーチャーのアイコンをフォルダーに使う",
"prefs.history_folder_kind_icon_note": "中のフィーチャーがすべて同じ種類の履歴フォルダーは、そのアイコンで表示されます。",
"prefs.history_group_joints": "履歴でジョイントをまとめる",
"prefs.history_group_joints_note": "2つ以上のジョイントを、数を添えた1つのボタンにまとめます。クリックすると各ジョイントを表示します。オフにすると各ジョイントを個別に表示します。",
"prefs.history_origin_bodies": "原点とボディを先頭に表示",
@@ -2830,6 +2852,8 @@
"prefs.select_other_hidden": "「他を選択」で非表示のジオメトリを選択",
"prefs.select_other_hidden_help": "オン: ポインター付近の非表示のボディやスケッチ、遮られたエッジを含めます。オフ: ポインター直下の表示中のジオメトリだけを元のリストに表示します。",
"prefs.selecting": "選択",
"prefs.shared_panel_layout": "共通のパネルレイアウトを使用",
"prefs.shared_panel_layout_help": "既存のブラウザー、履歴、アシスタントを移動可能なパネルに表示します。設定を適用すると変更が有効になります。",
"prefs.shift": "Shift",
"prefs.shift_add_ctrl_toggle": "Shift は追加のみ、Ctrl / Cmd は選択を切り替え",
"prefs.shift_add_note": "オン:Shift は追加、Ctrl / Cmd は選択を切り替えます。オフ(既定):どちらも選択を切り替えます。",
@@ -5520,6 +5544,7 @@
"ui.heading_plastic": "プラスチック",
"ui.heading_position": "配置",
"ui.heading_prepare": "準備",
"ui.heading_press_pull": "プレス プル",
"ui.heading_rectangular_pattern": "矩形パターン",
"ui.heading_revolve": "回転",
"ui.heading_right": "右側面",
@@ -5863,6 +5888,8 @@
"ui.profiles": "プロファイル",
"ui.project": "投影",
"ui.project_face_edges_sketch": "新規スケッチに面のエッジを自動投影",
"ui.project_references_construction": "参照をコンストラクションジオメトリとして投影",
"ui.project_references_construction_note": "投影、交差、3D ジオメトリを含める、および面上の新規スケッチの面のエッジは、コンストラクションジオメトリになります。描画の参照用で、プロファイルには含まれません。モデルとのリンクは保たれます。",
"ui.properties": "プロパティ",
"ui.properties.copy_matrix": "行列をコピー",
"ui.properties.first_moments": "一次モーメント",
@@ -5906,6 +5933,8 @@
"ui.renderer": "レンダラー",
"ui.repeat": "繰り返し",
"ui.repeat_nothing": "まだ繰り返すものがありません。先にツールかダイアログを開始してください。",
"ui.replace_sketch.from_file": "デザインファイルから…",
"ui.replace_sketch.no_sketches": "そのデザインには取り込めるスケッチがありません",
"ui.replace_with_link_done": "コピーはリンクされたデザインの配置になりました。",
"ui.report_a_bug": "不具合を報告",
"ui.report_characters_left_one": "残り {count} 文字",
+1
View File
@@ -1939,6 +1939,7 @@
"material.editor.preserved_appearance": "შენარჩუნებული იერსახე",
"material.editor.public_reference": "საჯარო წყარო",
"material.editor.reference": "საცნობარო",
"material.editor.remove": "წაშლა",
"material.editor.save_or_discard": "მასალის მინიჭებამდე შეინახეთ ან გააუქმეთ მასალის მონახაზი.",
"material.editor.saved": "მასალა შენახულია. სხეულებზე მინიჭებები არ შეცვლილა.",
"material.editor.scope": "შენახვა ამ მასალას ინახავს სხეულებზე მინიჭების გარეშე. ცარიელი თვისებები და პირობები უცნობად რჩება. ბარათის სიმკვრივე არ ცვლის მინიჭებულ სიმკვრივეს ან მასას.",
+21
View File
@@ -992,8 +992,11 @@
"cmd.panel.dismiss": "패널 임시 보기 닫기",
"cmd.panel.float": "패널 띄우기",
"cmd.panel.focus": "패널에 초점 맞추기",
"cmd.panel.focus.next": "다음 패널에 초점 맞추기",
"cmd.panel.focus.previous": "이전 패널에 초점 맞추기",
"cmd.panel.list": "패널 목록",
"cmd.panel.move": "패널 이동",
"cmd.panel.move.next_zone": "패널을 다음 영역으로 이동",
"cmd.panel.native_close": "별도 창의 패널 구역 다시 도킹",
"cmd.panel.open": "패널 열기",
"cmd.panel.peek": "접힌 패널 임시 보기",
@@ -1001,6 +1004,12 @@
"cmd.panel.reset_pair": "패널 구분선 초기화",
"cmd.panel.resize": "패널 구역 크기 조정",
"cmd.panel.resize_pair": "패널 구분선 이동",
"cmd.panel.sash.down": "패널 구분선을 아래로 이동",
"cmd.panel.sash.left": "패널 구분선을 왼쪽으로 이동",
"cmd.panel.sash.reset": "초점이 있는 패널 구분선 초기화",
"cmd.panel.sash.right": "패널 구분선을 오른쪽으로 이동",
"cmd.panel.sash.up": "패널 구분선을 위로 이동",
"cmd.panel.toggle": "초점이 있는 패널 접기 전환",
"cmd.parameters_add": "사용자 매개 변수 추가",
"cmd.parameters_change": "매개 변수 변경",
"cmd.parameters_comment": "매개 변수에 설명 추가",
@@ -1117,6 +1126,7 @@
"cmd.sketch.construction_line.tangent_external": "외부 점을 지나는 접선",
"cmd.sketch.construction_line.tangent_two": "공통 접선",
"cmd.sketch.construction_line.vertical": "수직 보조 직선",
"cmd.sketch.replace": "스케치 바꾸기",
"cmd.sketch_arc_center_point": "중심점 호",
"cmd.sketch_arc_tangent": "접선 호",
"cmd.sketch_arc_three_point": "3점 호",
@@ -1928,6 +1938,8 @@
"prefs.design_tools.degrees": "도",
"prefs.design_tools.double_click_help": "연결된 구성요소를 제자리에서 편집할 수도 있습니다.",
"prefs.design_tools.ghost": "이동한 부품의 이전 위치를 고스트로 표시",
"prefs.design_tools.hole_group": "함께 만든 구멍 그룹화",
"prefs.design_tools.hole_group_help": "한 번에 만든 구멍은 기록에서 하나의 폴더를 공유하며, 처음에는 닫혀 있습니다. 폴더를 열어 각 구멍을 개별적으로 편집하세요.",
"prefs.design_tools.hole_moves_help": "면을 다시 선택하면 구멍을 제거하지 않고 이동합니다.",
"prefs.design_tools.hole_snaps_help": "면의 가운데와 원형 모서리의 중심입니다.",
"prefs.design_tools.imported_files": "가져온 파일",
@@ -2005,6 +2017,8 @@
"prefs.history": "이력",
"prefs.history_bar_groups": "이력 막대를 그룹으로 접기",
"prefs.history_bar_groups_note": "긴 이력에서는 한 컴포넌트의 연속된 특성을 하나의 칩으로 접고, 특성 폴더는 접힌 상태로 시작합니다. 삽입한 STEP 어셈블리는 Group 폴더에 들어갑니다. 끄면 각 특성을 표시하고, 폴더를 펼친 상태로 시작하며, 폴더를 만들지 않습니다.",
"prefs.history_folder_kind_icon": "폴더 아이콘을 안의 피처에서 가져오기",
"prefs.history_folder_kind_icon_note": "피처가 모두 같은 종류인 기록 폴더는 그 피처의 아이콘을 표시합니다.",
"prefs.history_group_joints": "이력에서 조인트 묶기",
"prefs.history_group_joints_note": "조인트가 두 개 이상이면 개수가 표시된 버튼 하나로 묶고, 클릭하면 각 조인트를 표시합니다. 끄면 각 조인트를 하나씩 표시합니다.",
"prefs.history_origin_bodies": "원점과 바디를 맨 위에 표시",
@@ -2132,6 +2146,8 @@
"prefs.select_other_hidden": "다른 항목 선택으로 숨겨진 형상 선택",
"prefs.select_other_hidden_help": "켜짐: 포인터 근처의 숨겨진 바디, 스케치 및 가려진 모서리를 포함합니다. 꺼짐: 포인터 바로 아래에 보이는 형상만 포함하는 기존 목록을 사용합니다.",
"prefs.selecting": "선택",
"prefs.shared_panel_layout": "공유 패널 레이아웃 사용",
"prefs.shared_panel_layout_help": "기존 브라우저, 기록 및 도우미를 이동 가능한 패널로 표시합니다. 환경설정을 적용하면 변경 사항이 반영됩니다.",
"prefs.shift": "Shift",
"prefs.shift_add_ctrl_toggle": "Shift는 추가만, Ctrl / Cmd는 토글",
"prefs.shift_add_note": "켬: Shift는 추가, Ctrl / Cmd는 토글. 끔(기본값): 둘 다 토글.",
@@ -4716,6 +4732,7 @@
"ui.heading_plastic": "플라스틱",
"ui.heading_position": "배치",
"ui.heading_prepare": "준비",
"ui.heading_press_pull": "밀기/당기기",
"ui.heading_rectangular_pattern": "직사각형 패턴",
"ui.heading_revolve": "회전",
"ui.heading_right": "우측면",
@@ -5037,6 +5054,8 @@
"ui.profiles": "프로파일",
"ui.project": "투영",
"ui.project_face_edges_sketch": "면 모서리를 새 스케치로 투영",
"ui.project_references_construction": "참조를 구성선으로 투영",
"ui.project_references_construction_note": "투영, 교차, 3D 형상 포함과 면 위 새 스케치의 면 모서리가 구성선으로 만들어집니다. 프로파일에 포함되지 않는 그리기용 참조이며, 모델과의 연결은 유지됩니다.",
"ui.properties": "속성",
"ui.properties.copy_matrix": "행렬 복사",
"ui.properties.first_moments": "1차 모멘트",
@@ -5078,6 +5097,8 @@
"ui.renderer": "렌더러",
"ui.repeat": "반복",
"ui.repeat_nothing": "아직 반복할 것이 없습니다. 먼저 도구나 대화 상자를 시작하세요.",
"ui.replace_sketch.from_file": "디자인 파일에서…",
"ui.replace_sketch.no_sketches": "그 디자인에는 가져올 스케치가 없습니다",
"ui.replace_with_link_done": "복사본이 이제 링크된 디자인의 배치가 되었습니다.",
"ui.report_a_bug": "버그 신고",
"ui.reset": "재설정",
+1
View File
@@ -1889,6 +1889,7 @@
"material.editor.preserved_appearance": "Penampilan dikekalkan",
"material.editor.public_reference": "Rujukan awam",
"material.editor.reference": "Rujukan",
"material.editor.remove": "Buang",
"material.editor.save_or_discard": "Simpan atau buang draf bahan sebelum menetapkan bahan.",
"material.editor.saved": "Bahan disimpan. Penetapan jasad tidak berubah.",
"material.editor.scope": "Simpan menyimpan bahan ini tanpa menetapkannya pada jasad. Sifat dan keadaan yang dibiarkan kosong kekal tidak diketahui. Ketumpatan kad tidak mengubah ketumpatan yang ditetapkan atau jisim.",
+1
View File
@@ -1889,6 +1889,7 @@
"material.editor.preserved_appearance": "Bevart utseende",
"material.editor.public_reference": "Offentlig referanse",
"material.editor.reference": "Referanse",
"material.editor.remove": "Fjern",
"material.editor.save_or_discard": "Lagre eller forkast materialutkastet før du tildeler et materiale.",
"material.editor.saved": "Materialet er lagret. Kroppstildelinger er uendret.",
"material.editor.scope": "Ved lagring lagres materialet uten at det tildeles kropper. Tomme egenskaper og betingelser forblir ukjente. Tettheten på kortet endrer ikke tildelt tetthet eller masse.",
+1
View File
@@ -1893,6 +1893,7 @@
"material.editor.preserved_appearance": "Behouden uiterlijk",
"material.editor.public_reference": "Openbare referentie",
"material.editor.reference": "Referentie",
"material.editor.remove": "Verwijderen",
"material.editor.save_or_discard": "Sla het materiaalconcept op of verwerp het voordat je een materiaal toewijst.",
"material.editor.saved": "Materiaal opgeslagen. Toewijzingen aan lichamen zijn ongewijzigd.",
"material.editor.scope": "Opslaan bewaart dit materiaal zonder het aan lichamen toe te wijzen. Lege eigenschappen en voorwaarden blijven onbekend. De dichtheid op de kaart wijzigt de toegewezen dichtheid of massa niet.",
@@ -0,0 +1,622 @@
{
"fi": {
" Keys": " Avainruudut",
" Maximum": " Enimmäisarvo",
" Minimum": " Vähimmäisarvo",
" Of": " Vapausaste",
" Value": " Arvo",
"ABS - Black": "ABS - musta",
"ABS - Grey": "ABS - harmaa",
"ABS - White": "ABS - valkoinen",
"ALIGN": "KOHDISTA",
"AS-BUILT JOINT": "NYKYASENNON MUKAINEN NIVEL",
"ASSIGN PLASTIC RULE": "MÄÄRITÄ MUOVISÄÄNTÖ",
"AXIS PERPENDICULAR TO FACE": "AKSELI KOHTISUORAAN PINTAAN",
"AXIS THROUGH CYLINDER": "AKSELI SYLINTERIN KAUTTA",
"AXIS THROUGH EDGE": "AKSELI SÄRMÄN KAUTTA",
"AXIS THROUGH TWO PLANES": "AKSELI KAHDEN TASON KAUTTA",
"AXIS THROUGH TWO POINTS": "AKSELI KAHDEN PISTEEN KAUTTA",
"Add shortcut": "Lisää pikanäppäin",
"Allowed values": "Sallitut arvot",
"Aluminum - Satin": "Alumiini - satiini",
"Anodized Black": "Anodisoitu musta",
"Any": "Mikä tahansa",
"BOSS": "TAPPI",
"BOUNDARY FILL": "RAJATÄYTTÖ",
"Brass": "Messinki",
"Built-in": "Sisäänrakennettu",
"By definition": "Määrittelyn mukaan",
"By part number": "Osanumeron mukaan",
"CAM": "CAM",
"CONTACT SET": "KOSKETUSJOUKKO",
"CONVERT MESH": "MUUNNA VERKKO",
"CONVERT TO SHEET METAL": "MUUNNA OHUTLEVYKSI",
"Chord length": "Jänteen pituus",
"Chrome": "Kromi",
"Close All Tabs": "Sulje kaikki välilehdet",
"Close Other Tabs": "Sulje muut välilehdet",
"Connect AI assistants…": "Yhdistä tekoälyavustajat…",
"Copper": "Kupari",
"Crash Reports…": "Kaatumisraportit…",
"Curvature continuous (G2)": "Kaarevuusjatkuva (G2)",
"DELETE FACE": "POISTA PINTA",
"DELETE FEATURE": "POISTA PIIRRE",
"DRIVE JOINTS": "OHJAA NIVELIÄ",
"Default material": "Oletusmateriaali",
"Design Properties…": "Mallin ominaisuudet…",
"Direct interaction": "Suora vuorovaikutus",
"Distance X": "Etäisyys X",
"Distance Y": "Etäisyys Y",
"Distance and Angle": "Etäisyys ja kulma",
"Download design": "Lataa malli",
"Drive parameter": "Ohjaa parametria",
"Drive suppression": "Ohjaa ohitusta",
"EDIT DIMENSION": "MUOKKAA MITTAA",
"EDIT JOINT": "MUOKKAA NIVELTÄ",
"EDIT MOTION STUDY": "MUOKKAA LIIKETUTKIMUSTA",
"EDIT TEXT": "MUOKKAA TEKSTIÄ",
"EMBOSS": "KOHOKUVIO",
"EXPLODED VIEW": "RÄJÄYTYSKUVA",
"EXTEND": "JATKA",
"Edit Holes": "Muokkaa reikiä",
"Exact": "Tarkka",
"Export 3MF…": "Vie 3MF…",
"Export Design as JSON…": "Vie malli JSON-muodossa…",
"Export OBJ…": "Vie OBJ…",
"Export STEP…": "Vie STEP…",
"Export STL…": "Vie STL…",
"Export URDF…": "Vie URDF…",
"FLANGE": "LAIPPA",
"FLAT PATTERN": "LEVITYSKUVA",
"FOLD": "TAITA",
"FRAME MEMBER": "RUNKOPALKKI",
"Faces on Block": "Lohkon pinnat",
"Faces on Ring": "Renkaan pinnat",
"Flipped": "Käännetty",
"Fold Features": "Kutista piirteet",
"Follow inherited appearance": "Käytä perittyä ulkoasua",
"Follow material": "Käytä materiaalin ulkoasua",
"From Object": "Kohteesta",
"GEAR": "HAMMASPYÖRÄ",
"GROOVE": "URA",
"Glass - Clear": "Lasi - kirkas",
"Glass - Smoked": "Lasi - savustettu",
"Groove": "Ura",
"Group Features": "Ryhmitä piirteet",
"HDR file changed since selection; inspect and select it again before saving": "HDR-tiedosto on muuttunut valinnan jälkeen; tarkastele ja valitse se uudelleen ennen tallennusta",
"Hold click": "Pidä painettuna",
"INSERT PART": "LISÄÄ OSA",
"INSERT SVG": "LISÄÄ SVG",
"Insert Mesh…": "Lisää verkko…",
"Insert STEP…": "Lisää STEP…",
"JOINT": "NIVEL",
"JOINT ORIGIN": "NIVELEN ORIGO",
"MANAGE PLASTIC RULES": "HALLITSE MUOVISÄÄNTÖJÄ",
"MIDPLANE": "KESKITASO",
"MOTION LINK": "LIIKELINKKI",
"MOTION STUDY": "LIIKETUTKIMUS",
"MOTION TRACE": "LIIKERATA",
"MOVE/COPY TEXT": "SIIRRÄ/KOPIOI TEKSTI",
"Mesh": "Verkko",
"NEW COMPONENT": "UUSI KOMPONENTTI",
"Not": "Ei",
"Nylon - Natural": "Nailon - luonnonvärinen",
"OFFSET FACE": "PINNAN SIIRTYMÄ",
"OFFSET SURFACE": "SIIRTYMÄPINTA",
"Oak": "Tammi",
"Offset X": "Siirtymä X",
"Offset Y": "Siirtymä Y",
"Offset Z": "Siirtymä Z",
"PATCH": "PAIKKAPINTA",
"PERPENDICULAR PLANE": "KOHTISUORA TASO",
"PHYSICAL MATERIAL": "FYSIKAALINEN MATERIAALI",
"PICK CONTEXT": "VALITSE KONTEKSTI",
"PLANE ALONG PATH": "TASO POLKUA PITKIN",
"PLANE THROUGH THREE POINTS": "TASO KOLMEN PISTEEN KAUTTA",
"PLANE THROUGH TWO EDGES": "TASO KAHDEN SÄRMÄN KAUTTA",
"POINT ALONG PATH": "PISTE POLKUA PITKIN",
"POINT AT CENTER": "PISTE KESKIPISTEESEEN",
"POINT AT EDGE AND PLANE": "PISTE SÄRMÄN JA TASON LEIKKAUKSEEN",
"POINT AT VERTEX": "PISTE KÄRKIPISTEESEEN",
"POINT THROUGH THREE PLANES": "PISTE KOLMEN TASON KAUTTA",
"POINT THROUGH TWO EDGES": "PISTE KAHDEN SÄRMÄN KAUTTA",
"Paint - Blue": "Maali - sininen",
"Paint - Green": "Maali - vihreä",
"Paint - Orange": "Maali - oranssi",
"Paint - Purple": "Maali - violetti",
"Paint - Red": "Maali - punainen",
"Paint - Yellow": "Maali - keltainen",
"Parts only": "Vain osat",
"Play": "Toista",
"Point to Point": "Pisteestä pisteeseen",
"Point to Position": "Pisteestä sijaintiin",
"Polycarbonate - Clear": "Polykarbonaatti - kirkas",
"Preferences…": "Asetukset…",
"RACK AND PINION": "HAMMASTANKO JA -PYÖRÄ",
"REFOLD": "TAITA UUDELLEEN",
"REMOVE": "POISTA",
"RENAME": "NIMEÄ UUDELLEEN",
"REPLACE FACE": "KORVAA PINTA",
"REST": "TUKI",
"REVERSE NORMAL": "KÄÄNNÄ NORMAALI",
"RIGID GROUP": "JÄYKKÄ RYHMÄ",
"Range": "Alue",
"Reduce to Fit": "Pienennä sopivaksi",
"Report an Issue…": "Ilmoita ongelmasta…",
"Rolling": "Vierivä",
"Rotate": "Kierrä",
"Rubber - Black": "Kumi - musta",
"Rules": "Säännöt",
"SCREW": "RUUVI",
"SHEET METAL RULES": "OHUTLEVYSÄÄNNÖT",
"SNAP FIT": "NAPSAUTUSLIITOS",
"SPLIT FACE": "JAA PINTA",
"STITCH": "OMPELE",
"STOCK": "AIHIO",
"SURFACE EXTRUDE": "PINTAPURSOTUS",
"SURFACE LOFT": "PINTALOFTTAUS",
"SURFACE REVOLVE": "PINTAPYÖRÄYTYS",
"SURFACE SWEEP": "PINTAPYYHKÄISY",
"Save As JSON…": "Tallenna JSON-muodossa…",
"Save As…": "Tallenna nimellä…",
"Screenshot captured locally. Review it before attaching it on GitHub.": "Kuvakaappaus otettiin paikallisesti. Tarkista se ennen kuin liität sen GitHubissa.",
"Scroll left": "Vieritä vasemmalle",
"Scroll right": "Vieritä oikealle",
"Select Features": "Valitse piirteet",
"Select the curves to divide first": "Valitse ensin jaettavat käyrät",
"Sheet Metal": "Ohutlevy",
"Show Each Feature": "Näytä jokainen piirre",
"Stainless Steel": "Ruostumaton teräs",
"Steel": "Teräs",
"Steel - Brushed": "Teräs - harjattu",
"Suppress Features": "Ohita piirteet",
"Suppression": "Ohitus",
"Surface or face": "Pintakappale tai pinta",
"Surface or faces": "Pintakappale tai pinnat",
"TANGENT PLANE": "TANGENTTITASO",
"THICKEN": "PAKSUNNA",
"TRANSFORM": "MUUNNA",
"Taper to Fit": "Kavenna sopivaksi",
"Target edges": "Kohdesärmät",
"The origin axis is perpendicular to the sketch plane, so it has no line in the plane": "Origon akseli on kohtisuorassa luonnostasoon nähden, joten sillä ei ole viivaa tasossa",
"Top level": "Ylin taso",
"Two Distances": "Kaksi etäisyyttä",
"UNFOLD": "LEVITÄ",
"UNSTITCH": "PURA OMMEL",
"Use axis direction only": "Käytä vain akselin suuntaa",
"Variable radius": "Muuttuva säde",
"View": "Näkymä",
"Walnut": "Pähkinäpuu",
"ai.http.allow": "Salli suojaamaton HTTP",
"ai.http.confirm": "Salli tälle päätepisteelle",
"ai.http.warning": "API-avain ja keskustelu lähetetään salaamattomina. Kuka tahansa, jolla on pääsy verkkoon, voi lukea tai muuttaa niitä. Käytä tätä vain verkossa, johon luotat.",
"ai.prefs.instructions": "Omat ohjeet",
"ai.prefs.instructions_note": "Lähetetään agentille jokaisen keskustelun alussa SolidCraftin omien ohjeiden jälkeen: miten haluat työskennellä ja mitä sääntöjä noudatetaan. Muutos tulee voimaan seuraavasta keskustelusta (Yhdistä tai Uusi keskustelu).",
"browser.canvas_locked": "Pohjakuva on lukittu. Avaa lukitus ensin.",
"cmd.inspect_colored_components": "Komponenttien värit",
"cmd.model_body_features": "Luettele kappaleen piirteet",
"cmd.panel.action": "Suorita paneelin otsikon toiminto",
"cmd.panel.focus.next": "Kohdista seuraavaan paneeliin",
"cmd.panel.focus.previous": "Kohdista edelliseen paneeliin",
"cmd.panel.move.next_zone": "Siirrä paneeli seuraavalle alueelle",
"cmd.panel.sash.down": "Siirrä paneelien jakajaa alas",
"cmd.panel.sash.left": "Siirrä paneelien jakajaa vasemmalle",
"cmd.panel.sash.reset": "Palauta kohdistetun paneelin jakaja",
"cmd.panel.sash.right": "Siirrä paneelien jakajaa oikealle",
"cmd.panel.sash.up": "Siirrä paneelien jakajaa ylös",
"cmd.panel.toggle": "Kohdistettu paneeli päälle/pois",
"cmd.pointcloud_reload": "Lataa pistepilvi uudelleen",
"cmd.render.coat.bind": "Tallenna kirkaslakka",
"cmd.render.coat.unbind": "Poista kirkaslakka",
"cmd.render.surface.import": "Tuo pintakartat",
"cmd.render.texture.list": "Luettele upotetut pintakuvat",
"cmd.render.texture.remove": "Poista upotettu pintakuva",
"cmd.sketch.replace": "Korvaa luonnos",
"cmd.sketch_move_points": "Siirrä luonnospisteitä",
"cmd.sketch_point_divide": "Jaa käyrä",
"cmd.view.navigator": "Mukautettu näkymänavigaattori",
"cmd.view.render_live": "Keskeytä tai jatka reaaliaikaista renderöintiä",
"cmd.view_ribbon_tabs_only": "Vain valintanauhan välilehdet",
"cmd.view_tool_zoom": "Zoomaa vetämällä",
"dialog.cylinder_face_tip": "Mallin pinta tai kärkipiste, jonka päällä sylinteri seisoo: napsauta tätä ja napsauta sitten sitä näkymässä. Jos tämä jätetään tyhjäksi, sijainnin määräävät X, Y ja Z.",
"dialog.primitive_point_tip": "Piste, joka täyttää X:n, Y:n ja Z:n: napsauta tätä ja napsauta sitten mallin kärkipistettä, särmää tai pintaa, luonnoksen pistettä tai origoa.",
"dialog.reference_edge_tip": "Valitun pinnan suora särmä, josta alla oleva etäisyys mitataan: napsauta tätä ja napsauta sitten särmää.",
"document.newer_file": "Tallennettu uudemmalla SolidCraftilla (muoto {format}, tämä versio lukee enintään muodon {reads}). Jos jotain puuttuu, päivitä.",
"document.newer_file_change": "Tallennettu uudemmalla SolidCraftilla (muoto {format}, jossa `{change}`, jota tämä versio ei tunne). Jos jotain puuttuu, päivitä.",
"document.newer_file_not_read": "Ei luettu: {what}. Tallennus säilyttää sen.",
"file.open.portal_fallback.no_portal_service": "Avautunut tiedostonvalitsin ei ole työpöydän oma: työpöydän portaalipalvelu ei ole käytettävissä.",
"file.open.portal_fallback.no_session_bus": "Avautunut tiedostonvalitsin ei ole työpöydän oma: D-Bus-istuntoväylää ei ole.",
"file.open.portal_fallback.xwayland": "Avautunut tiedostonvalitsin ei ole työpöydän oma: XWaylandissa portaali hylkää yläikkunan.",
"file_load.inserted": "Lisätty {name}",
"file_load.opened": "Avattu {name}",
"file_load.opened_in_tab": "{name} avattu uudessa välilehdessä",
"file_load.reloading_cloud": "Ladataan pistepilveä uudelleen",
"help.sketch.point.divide": "Sijoittaa pisteitä, jotka jakavat valitut käyrät yhtä pitkiin osiin. Pisteet pysyvät kullakin käyrällä ja säilyttävät välinsä, kun käyrä muuttuu.",
"help.view.ribbon_tabs_only": "Kutistaa valintanauhan välilehtiriviksi, jolloin näkymä saa paneelien viemän tilan. Napsauta välilehteä, niin sen paneelit näkyvät näkymän päällä; valitse tämä uudelleen, niin koko valintanauha pysyy näkyvissä.",
"hole_folder.applies_to": "Muutokset koskevat kansion {folder} {count} reikää; kukin pysyy paikallaan.",
"nav.drag_keeps_mode.help": "Keski- tai oikean painikkeen veto jatkaa kiertoa, panorointia tai zoomausta sellaisena kuin se alkoi, kunnes painike vapautetaan, riippumatta siitä, mitä näppäimiä sillä välin painetaan tai vapautetaan. Pois: painettuina olevat näppäimet ratkaisevat vedon joka hetkellä.",
"nav.drag_keeps_mode.label": "Veto säilyttää navigointitavan, jolla se alkoi",
"nav.orbit_cursor_wrap.help": "Kun kiertoveto saavuttaa näkymän reunan, kohdistin siirtyy vastakkaiseen reunaan ja kierto jatkuu. Pois: veto pysähtyy näytön reunaan. Ei vaikutusta Waylandissa eikä selaimessa.",
"nav.orbit_cursor_wrap.label": "Siirrä kohdistin vastakkaiseen reunaan kierrettäessä",
"navbar.zoom_hold_tip": "Zoomaa (pidä pikanäppäin painettuna)",
"prefs.background_choose": "Valitse kuva…",
"prefs.background_image": "Kuva",
"prefs.background_unreadable": "Taustakuvaa ei voitu lukea",
"prefs.browser_body_features": "Piirteet kappaleiden alla",
"prefs.browser_body_features_note": "Kunkin kappaleen alle selaimessa avautuvat aikajanan piirteet, joilla se on rakennettu, aikajanan järjestyksessä.",
"prefs.browser_rows_start_closed": "Selaimen rivit ovat aluksi suljettuina",
"prefs.browser_rows_start_closed_note": "Selaimen sijoitukset ja kansiot ovat aluksi suljettuina, joten mallista, jossa on paljon komponentteja, näkyvät ylimmän tason rivit. Kukin avautuu nuolestaan, Laajenna kaikki -toiminnolla tai Etsi selaimesta -toiminnolla, joka avaa vain rivit kappaleeseen asti ja sulkee taas ne, jotka edellinen haku avasi. 3D-näkymässä valittu kappale jättää selaimen ennalleen. Kun tämä on pois päältä, kansiot ovat aluksi auki, sijoitukset noudattavat Komponentit alkavat avoimina -asetusta, ja 3D-näkymässä tehty valinta avaa selaimen kappaleeseen asti kuten ennenkin.",
"prefs.command_dialog_values": "Komentoikkunat",
"prefs.design_tools.hole_group": "Ryhmitä yhdessä tehdyt reiät",
"prefs.design_tools.hole_group_help": "Kerralla tehdyt reiät ovat historiassa samassa kansiossa, joka on aluksi suljettu; avaa kansio, jos haluat muokata reikää erikseen.",
"prefs.design_tools.hole_several": "Useita reikiä yhdelle pinnalle",
"prefs.design_tools.hole_several_help": "Pinnan muun kohdan valitseminen lisää uuden reiän; reiän valitseminen poistaa sen.",
"prefs.extrude_arrow_face_stops": "Pursotusnuoli pysähtyy pintoihin",
"prefs.extrude_arrow_face_stops_note": "Pursotuksen etäisyysnuolen vetäminen pysähtyy profiilin suuntaisiin tasopintoihin, kuten leikattavan kappaleen vastakkaiseen pintaan, ja korostaa pinnan ääriviivan. Pois: nuoli liikkuu vain tasavälein.",
"prefs.ground_shadow_follows_model": "Maavarjo seuraa mallia",
"prefs.ground_shadow_follows_model_help": "Sijoita varjo alimman näkyvän kappaleen alle. Kun tämä on pois päältä, korotettujen mallien varjo pysyy origon maatasolla.",
"prefs.history_bar_groups": "Kokoa historiapalkki ryhmiksi",
"prefs.history_bar_groups_note": "Pitkässä historiassa yhden komponentin peräkkäiset piirteet kootaan yhdeksi merkiksi, ja piirrekansiot ovat aluksi kutistettuina. Lisätty STEP-kokoonpano sijoitetaan ryhmäkansioon. Pois: jokainen piirre näytetään, kansiot ovat aluksi auki eikä kansiota luoda.",
"prefs.history_folder_kind_icon": "Kansion kuvake sen piirteistä",
"prefs.history_folder_kind_icon_note": "Historiakansio, jonka kaikki piirteet ovat samaa lajia, näyttää niiden kuvakkeen.",
"prefs.history_group_joints": "Ryhmitä nivelet historiassa",
"prefs.history_group_joints_note": "Kaksi tai useampi nivel näkyy yhtenä painikkeena lukumäärineen, ja napsautus näyttää jokaisen. Pois: jokainen nivel näytetään.",
"prefs.keep_unsaved_also_asks": "Kysy silti, kun välilehti suljetaan",
"prefs.keep_unsaved_also_asks_tip": "Kun tämä on pois päältä, välilehti sulkeutuu ilman selaimen kyselyikkunaa, kun jokaisen tallentamattoman mallin kopio on ajan tasalla.",
"prefs.keep_unsaved_in_browser": "Säilytä tallentamaton työ tässä selaimessa",
"prefs.keep_unsaved_in_browser_tip": "Jokaisen avoimen mallin tallentamattomat muutokset pidetään ajan tasalla tämän selaimen yksityisessä tallennustilassa. Seuraavalla käynnillä niitä tarjotaan palautettaviksi.",
"prefs.layout_page.hover_panel_menus": "Vaihda paneelivalikkoa osoittimen ollessa päällä",
"prefs.layout_page.hover_panel_menus_help": "Avaa valintanauhan paneelin valikko napsauttamalla sen nimeä. Vaihda valikkoa viemällä osoitin toisen paneelin nimen päälle. Sulje valikko siirtämällä osoitin valintanauhan ja avoimen valikon ulkopuolelle.",
"prefs.move_drag_highlights": "Korosta osoittimen alla oleva kohde, kun Siirrä-toiminnon nuolilla vedetään komponenttia",
"prefs.move_drag_highlights_tip": "Pois: kun Siirrä-toiminnon nuolilla vedetään komponenttia, osoittimen alla olevaa kohdetta ei korosteta, mikä pitää suuret kokoonpanot sulavina. Kun leikkaus tai analyysi on näkyvissä, kohde korostetaan silti, eikä niveliään pitkin vedetty osa koskaan korosta sitä.",
"prefs.panel_layout.recovery": "Tallennettuja paneeliasetteluja ei voitu palauttaa. Tallennetut tiedot säilytetään; Perinteinen on edelleen käytettävissä.",
"prefs.picker.carousel": "Karuselli",
"prefs.picker.change": "Vaihda",
"prefs.picker.clear": "Tyhjennä suodattimet",
"prefs.picker.day": "Päiväteema",
"prefs.picker.empty": "Mikään teema ei vastaa näitä suodattimia.",
"prefs.picker.follow": "Seuraa järjestelmää",
"prefs.picker.grid": "Ruudukko",
"prefs.picker.high": "Suuri kontrasti",
"prefs.picker.high_note": "{source} · Suuri kontrasti",
"prefs.picker.hint": "Vaihtuu käyttöjärjestelmän mukana.",
"prefs.picker.import": "Tuo teematiedosto…",
"prefs.picker.imported": "Tuotu",
"prefs.picker.night": "Yöteema",
"prefs.picker.one": "Yksi teema",
"prefs.picker.pick": "Valitse alta",
"prefs.picker.standard": "Vakio",
"prefs.remember_dialog_values": "Muista komentoikkunoiden arvot",
"prefs.remember_dialog_values.help": "Oletuksena pois päältä. Kun tämä on käytössä, onnistuneen komennon turvalliset arvot ja asetukset otetaan uudelleen käyttöön, kun avaat saman komennon seuraavan kerran, kunnes SolidCraft suljetaan.",
"prefs.render_legacy_image_defaults": "Käytä aiempia renderöintikuvan oletuksia",
"prefs.render_legacy_image_defaults_help": "Säilytä aiempi manuaalinen työnkulku: uudet asetelmat käyttävät CPU-rasterointia, CPU-säteenjäljitys säilyttää kiinteän näytteistyksen ja avaat renderöidyt tulokset itse. Sovita näkymä uudelleen Renderöintiin siirryttäessä, vaihda kuvakulmia palauttamatta niiden kameroita ja käytä aiempaa rajausta ja navigointia. Poista automaattinen reaaliaikainen kuvapalaute käytöstä. Tallennetut asetelmat eivät muutu.",
"prefs.ribbon_tabs_only": "Näytä vain valintanauhan välilehdet",
"prefs.ribbon_tabs_only_note": "Valintanauha kutistuu välilehtiriviksi. Välilehden napsauttaminen näyttää sen paneelit näkymän päällä, kunnes komento alkaa tai osoitin poistuu niiden päältä. Näkymä-välilehden Vain valintanauhan välilehdet -komento tai sen pikanäppäin vaihtaa tämän.",
"prefs.select_hidden_edges": "Valitse piilosärmiä tilassa Varjostettu, piilosärmät näkyvissä",
"prefs.select_hidden_edges_help": "Valitse särmiä pintojen läpi, kun Varjostettu, piilosärmät näkyvissä on käytössä, myös pyöristyksessä. Pois: säilyttää version v0.30.1 valintatoiminnan. Piilotettuja kappaleita ei voi valita.",
"prefs.shared_panel_layout": "Käytä jaettua paneeliasettelua",
"prefs.shared_panel_layout_help": "Näytä nykyiset Selain, Historia ja Avustaja siirrettävissä paneeleissa. Muutos tulee voimaan, kun asetukset otetaan käyttöön.",
"prefs.sketch.ellipse_axes": "Piirrä ellipsit akseleineen",
"prefs.sketch.ellipse_axes_note": "Iso- ja pikkuakseli apuviivoina, jotta puoliakseleille ja akseleille voi lisätä mitat. Pois: pelkkä ellipsi, ja D sen päällä antaa sen pienemmän säteen riippumatta siitä, mistä arvo vedetään, piirrettynä ja R-merkittynä kuten ennenkin.",
"prefs.sketch.escape_twice_finishes": "Kaksi Esc-painallusta lopettaa luonnoksen",
"prefs.sketch.escape_twice_finishes_note": "Kaksi nopeaa Esc-painallusta lopettaa luonnoksen. Pois: Esc vain peruuttaa työkalun tai valinnan, ja Lopeta luonnos lopettaa luonnoksen.",
"prefs.sketch_mirror_auto_line": "Käytä ennalta valittua apuviivaa peilaukseen",
"prefs.tab_double_click_renames": "Nimeä mallin välilehti uudelleen kaksoisnapsauttamalla",
"prefs.tab_double_click_renames_note": "Kirjoita uusi nimi ja säilytä se painamalla Enter tai napsauttamalla muualla, tai peruuta painamalla Esc. Pois: välilehden kaksoisnapsautus suurentaa ikkunan kuten palkin tyhjän osan kaksoisnapsautus.",
"recovery.open_saved": "Avaa tallennettu versio",
"recovery.restore": "Palauta",
"recovery.restore_intro": "Muutokset, joita ei ollut tallennettu, kun tämä sivu viimeksi suljettiin, on säilytetty tässä selaimessa.",
"recovery.restore_row": "{name}, muutettu {when}",
"recovery.restore_title": "Palautetaanko tallentamattomat mallit?",
"recovery.restore_title_one": "Palautetaanko tallentamaton malli: {name}, muutettu {when}?",
"render.adaptive.pilot_progress": "Mukautuvat pilotit · {current}/{target} kierrosta",
"render.adaptive.preview_help": "Pilottikuvat ohjaavat jakoa, eikä niitä sisällytetä lopulliseen kuvaan. Tuotantoiteraatiot käyttävät erillistä budjettia kullekin pikselille; iteraatioiden määrä ei vastaa tasaista näytemäärää pikseliä kohden.",
"render.adaptive.production_progress": "Mukautuva tuotanto · {current}/{target} iteraatiota",
"render.aging_raytrace_help": "Vanhentaminen tehdään CPU-säteenjäljityksellä ja GPU-polunjäljityksellä. Rasterointi säilyttää kaavamaisen renderöinnin.",
"render.appearance_editor.discard_saved_edit": "Hylkää tallentamattomat optiset muokkaukset",
"render.batch_current_settings_help": "Käyttää nykyisiä kuva-asetuksia kunkin tallennetun kuvakulman kameran kanssa, yksi kuvakulma kerrallaan.",
"render.batch_owner_limit": "Enintään 32 asiakirjan kuvakulmajonoa voidaan säilyttää. Viimeistele tai peruuta ensin jokin olemassa oleva jono.",
"render.batch_preparing": "Valmistellaan seuraavaa kuvakulmaa",
"render.batch_waiting_worker": "Odotetaan renderöinnin valmistelua",
"render.caustics_depth": "Kaustiikkapolun syvyys",
"render.caustics_enable": "Lasin kaustiikka piste- ja suuntavaloista",
"render.caustics_help": "Fotonien määrä vaikuttaa kohinaan ja laskennan määrään. Keruusäde vaihtaa terävät yksityiskohdat tasaisempaan arvioon. CPU vaatii epäsuoran valaistuksen.",
"render.caustics_path_required": "CPU-kaustiikka vaatii epäsuoran valaistuksen.",
"render.caustics_photons": "Fotonit",
"render.caustics_radius": "Keruusäde",
"render.check_glass_cpu": "CPU tukee varmennettuja tasomaisia lasilevyjä ja akselien suuntaisia laatikoita. Yleiset suljetut verkot, sisäkkäisyys ja sisäinen sironta vaativat epäsuoran valaistuksen. Suljetun lasin läpi kulkevia varjoja ei edelleenkään tueta.",
"render.check_glass_gpu": "GPU tukee varmennettuja erillisiä lasikappaleita tai nimenomaista varmennettua sisäkkäisyyttä. Sisäkkäisyys vaatii kuperat ulommat kappaleet, kokonaan niiden sisällä olevat sisemmät kappaleet eikä toisiaan koskettavia rajapintoja.",
"render.check_media_remaining": "Tiheysruudukot, siirtymäkartoitus, täysi spektri-integrointi ja polarisaation eteneminen eivät edelleenkään ole käytettävissä.",
"render.check_media_scope": "Aktiivinen sisäinen sironta vaatii suljetun lasiväliaineen, suorien varjojen läpäisyn pois päältä ja kohinanpoiston pois päältä. GPU vaatii lisäksi yhden Fresnel-kerrostuman. Aktiivista ulkoista ilmakehää ei voi yhdistää lasiin eikä pintojen kohinanpoistoon.",
"render.coat_discard": "Hylkää lakan muokkaus",
"render.coat_pending_edit": "Tätä lakan muokkausta ei ole tallennettu. Tallenna se kuvakulman mukana tai hylkää se.",
"render.coat_remove": "Poista tallennettu lakka",
"render.coat_remove_note": "Poistaa tämän kohteen lakan kuvakulmasta. Geometria, ulkoasut ja fysikaaliset materiaalit pysyvät ennallaan.",
"render.coat_remove_title": "Poistetaanko tallennettu kirkaslakka?",
"render.coat_revalidate_saved": "Tallennettu lakka on muuttunut. Hylkää tämä muokkaus ja avaa kohde uudelleen ennen kuin muokkaat sitä taas.",
"render.coat_save": "Tallenna lakka kuvakulmaan",
"render.coat_saved_help": "Kirkaslakka lisää kiiltävän kerroksen läpinäkymättömille pinnoille. Tallenna se kuvakulman mukana. Lasi ei voi jakaa tätä kohdetta. Myöskään CPU:n mukautetut heijastus- ja anisotropiaohitukset eivät voi jakaa sitä.",
"render.cpu_celestial_adopt": "CPU-säteenjäljitys epäsuoralla valaistuksella · polun syvyys {depth} · {spp} SPP · jäljitetyt varjot · siemen {seed} · 1 ensisijainen näyte",
"render.cpu_celestial_adopt_label": "CPU-säteenjäljitys epäsuoralla valaistuksella",
"render.cpu_celestial_path_required": "Äärellinen aurinko ja liukuväritaivas käyttävät CPU:n epäsuoraa valaistusta.",
"render.cpu_celestial_switch": "Säilytä tämä aurinko tai taivas valitsemalla CPU-säteenjäljitys epäsuoralla valaistuksella.",
"render.cpu_celestial_unportable": "Säilytä GPU-polunjäljitys nykyisillä läpäisynäytteiden asetuksilla.",
"render.cpu_constant_direct_help": "Ilman epäsuoraa valaistusta tämä vakiolähde voi näkyä heijastuksissa ja lasin läpi. Se ei valaise läpinäkymättömiä pintoja. Ota epäsuora valaistus käyttöön, jos haluat käyttää sitä näkymän valaistukseen.",
"render.cpu_constant_enable_indirect_plan": "Polun syvyys {depth}; progressiivinen näytteistys {spp} näytettä/pikseli (siemen {seed}); yksi ensisijainen säde näytettä kohden; jäljitetyt varjot. Kuvan koko, CPU-budjetti ja muut asetukset säilyvät.",
"render.cpu_denoise_help": "Vähentää kohinaa jokaisessa kuvan päivityksessä. Se voi pehmentää hienoja yksityiskohtia ja syväterävyyden reunoja. Raaka-EXR pysyy muuttumattomana. Valmiin kuvan säätäminen käyttää sen raakakuvaa eikä tee kohinanpoistoa uudelleen.",
"render.cpu_fixed_retained": "Kiinteä näytteistys säilytetty",
"render.cpu_glass_enable_path": "Ota epäsuora valaistus käyttöön",
"render.cpu_indirect_method_required": "Valitse CPU-säteenjäljitys ottaaksesi tämän epäsuoran valaistuksen tilan käyttöön.",
"render.cpu_maps_saved_help": "CPU-renderöinnit voivat käyttää tallennettuja läpinäkymättömiä karttoja: valitse pinta tästä ja valitse sitten Tallenna kartat kuvakulmaan. Epäsuoralla valaistuksella lasi voi olla toisessa kappaleessa; kartoitettu kappale ei voi käyttää lasia. Valittujen heijastusten, anisotropian ja proseduraalisten pintatehosteiden on oltava pois päältä. Väliaikaiset karttatiedostopolut käyttävät GPU:ta.",
"render.cpu_material_certification": "CPU:n tarkistus varmentaa tuetun lasigeometrian ennen renderöintiä. Suljettu lasi käyttää geometrista etäisyyttä.",
"render.cpu_opaque_hint": "CPU-säteenjäljitys käyttää läpinäkymätöntä taustaa.",
"render.cpu_path_depth": "Polun syvyys",
"render.cpu_path_help": "Lisää valon heijastelun läpinäkymättömien pintojen välillä ja tuettujen lasimuotojen läpi. Vaatii progressiivisen renderöinnin ja jäljitetyt varjot. Polun syvyys laskee varjostetut pinnat, lasin rajapinnat ja sirontatapahtumat ensimmäisestä osumasta alkaen (1–16). Syvyys 1 antaa suoran valaistuksen läpinäkymättömille pinnoille; suuremmat arvot sallivat useampia heijastuksia tai lasin läpäisyjä ja voivat kestää kauemmin. Poista epäsuora valaistus käytöstä, jos haluat käyttää mukautettuja heijastuksia tai anisotropiaa.",
"render.cpu_path_lighting": "Epäsuora valaistus",
"render.cpu_path_optics_reason": "Asetuksesi säilytetään. Poista epäsuora valaistus käytöstä, jos haluat käyttää mukautettuja heijastuksia tai anisotropiaa.",
"render.cpu_progressive_adopt": "Käytä progressiivista näytteistystä",
"render.cpu_progressive_adopt_help": "Progressiivinen näytteistys käyttää yhtä kameranäytettä kierrosta kohden. Kuvan koko, valot ja heijastusasetukset pysyvät ennallaan.",
"render.cpu_progressive_always": "Progressiivinen näytteistys on aina käytössä",
"render.cpu_progressive_reflection_adopt": "Aseta valittujen heijastusten yrityskerroiksi 1 ennen progressiivisen näytteistyksen käyttöönottoa.",
"render.cpu_quality_budget_help": "Muuttaa koon, tuotantonäytteiden ylärajan, siemenen ja CPU-resurssiprofiilin. Kamera, objektiivi, valaistus, GI-syvyys ja tehosteet säilyvät.",
"render.cpu_quality_progressive_required": "Ota alla progressiivinen näytteistys käyttöön ennen CPU-budjetin valitsemista.",
"render.cpu_quality_summary": "{width} × {height} · näytteitä enintään {samples} · siemen {seed} · {profile}",
"render.cpu_sky_bounds": "Taivaan kierron on oltava äärellinen ja välillä ±1 000 000 radiaania; voimakkuuden on oltava 0–100.",
"render.cpu_studio_enable": "Ota epäsuora valaistus käyttöön tälle asetelmalle",
"render.cpu_studio_enable_apply": "Ota epäsuora valaistus käyttöön ja käytä",
"render.cpu_studio_indirect_help": "Tämä aurinkoasetelma vaatii epäsuoran valaistuksen syvyydellä 8, jäljitetyt varjot ja yhden kameranäytteen kierrosta kohden. Jos progressiivinen renderöinti on pois päältä, se aloitetaan 16 näytteellä pikseliä kohden siemenellä 0. Nykyiset progressiiviset asetukset ja resurssiprofiili säilyvät. Yhteensopimattomat asetukset keskeyttävät käyttöönoton muuttamatta niitä.",
"render.cpu_studio_radiance_limit": "Tämän asetelman pintasäteily on {radiance}, mikä ylittää CPU:n rajan 64. Laadittu valaistus säilytetään; valitse toinen asetelma tai GPU.",
"render.cpu_subsurface_denoise_reason": "CPU:n pinnanalainen sironta vaatii kohinanpoiston pois päältä. Asetuksesi säilytetään.",
"render.cpu_subsurface_help": "Sironta ohjaa valoa uudelleen suljetun lasin sisällä. Positiiviset tiheydet vaativat epäsuoran valaistuksen ja kohinanpoiston pois päältä. Tiheys ilmoitetaan törmäyksinä millimetriä kohden; 0 ei lisää sirontaa. Positiivinen vaihepainotus suosii eteenpäin suuntautuvia suuntia, negatiivinen taaksepäin suuntautuvia.",
"render.cpu_transparent_help": "Läpinäkyvä PNG poistaa taustan ja säilyttää reunojen peittävyyden. Kappaleiden ja pintojen peittävyyden on pysyttävä 100 %:ssa.",
"render.cpu_volume_enable_path": "Ota epäsuora valaistus käyttöön",
"render.cpu_volume_enable_path_plan": "CPU-säteenjäljitys · polun syvyys {depth} · {spp} näytettä/pikseli · siemen {seed} · jäljitetyt varjot. Laadittu ilmakehä ja kuvabudjetti säilyvät.",
"render.cpu_volume_help": "CPU:n ilmakehä käyttää epäsuoraa valaistusta. Nollaekstinktio säilyttää tallennetun laatikon ilman sumua.",
"render.cpu_volume_method_adopt": "CPU-säteenjäljitys epäsuoralla valaistuksella",
"render.cpu_volume_method_incompatible": "Näitä valo-, näytteistys- tai ilmakehäasetuksia ei voi siirtää tähän menetelmään. Asetuksesi säilytetään.",
"render.cpu_volume_path_required": "Ota epäsuora valaistus käyttöön, jos haluat käyttää CPU:n ilmakehää.",
"render.directional_sun_limit": "GPU-valaistus tukee yhteensä kahdeksaa suuntavalolähdettä, joista enintään yksi voi olla pehmeä aurinko. Lisää uusi kaukainen valonlähde monistamalla kova suuntavalo.",
"render.glass_abbe": "Abben luku",
"render.glass_dispersion": "Kromaattinen dispersio",
"render.glass_film": "Ohutkalvointerferenssi",
"render.glass_film_ior": "Kalvon IOR",
"render.glass_film_thickness": "Kalvon paksuus",
"render.glass_model_enable": "Edistynyt lasimalli",
"render.glass_model_help": "Lasin karheus sirottaa läpäiseviä säteitä. Dispersio ja kalvon väri käyttävät kolmea aallonpituuskaistaa. Karheat levyt vaativat suorien läpäisevien varjojen olevan pois päältä.",
"render.glass_roughness": "Lasin karheus",
"render.gpu_job_shader_wait": "Varjostimien valmistelu voi kestää minuutteja",
"render.gpu_path_crashed": "SolidCraft sulkeutui viimeksi, kun tätä valmisteltiin tällä näytönohjaimella, todennäköisesti näytönohjaimen ajurin sisällä, joten valmistelua ei käynnistetä uudelleen automaattisesti. Valitse sen sijaan CPU-säteenjäljitys tai yritä uudelleen (myös ajurin päivitys yrittää uudelleen).",
"render.gpu_path_not_started": "GPU-polunjäljittimen valmistelu ei ole alkanut.",
"render.gpu_path_retry": "Yritä uudelleen",
"render.light_space_world": "Maailmakoordinaatiston valot",
"render.light_space_world_help": "Valojen sijainnit ja suunnat käyttävät maailmakoordinaatteja. Kameran kiertäminen ei siirrä niitä.",
"render.live.adaptive": "Reaaliaikainen · {width} × {height} · {progress}",
"render.live.final_changed": "Renderöintipyyntö muuttui reaaliaikaisen renderöinnin pysähtyessä. Aloita lopullinen kuva uudelleen.",
"render.live.final_waiting": "Valmistellaan lopullista kuvaa · vapautetaan reaaliaikaisen renderöinnin resursseja",
"render.live.invalid_size": "Reaaliaikainen renderöinti vaatii positiivisen tulosteen leveyden ja korkeuden.",
"render.live.pause": "Keskeytä reaaliaikainen",
"render.live.paused": "Reaaliaikainen · Keskeytetty",
"render.live.paused_outdated": "Reaaliaikainen · Keskeytetty · Vanhentunut",
"render.live.quality_help": "Vuorovaikutteinen laatu: enintään 512 pikseliä pisimmällä sivulla. Käyttää valittua renderöijää ja sen tukemia tehosteita. Lopullinen tarkkuus, asetukset ja vienti pysyvät erillään.",
"render.live.refused": "Reaaliaikainen ei käytettävissä: {reason}",
"render.live.resume": "Jatka reaaliaikaista",
"render.live.samples": "Reaaliaikainen · {width} × {height} · {current}/{target} näytettä",
"render.live.source_current": "{method} · Reaaliaikainen",
"render.live.source_outdated": "{method} · Edellinen kuva",
"render.live.source_outdated_help": "Näyttää viimeksi muodostetun kuvan, kun reaaliaikainen renderöinti päivittyy tai ei ole käytettävissä. Se voi poiketa nykyisestä näkymästä tai kuva-asetuksista.",
"render.live.source_settings_help": "Avaa Tuloste muokataksesi renderöijän ja kuvan asetuksia.",
"render.live.stopped": "Reaaliaikainen renderöinti pysäytetty",
"render.live.updating": "Reaaliaikainen · Vuorovaikutteinen laatu (enintään 512 px) · Päivitetään",
"render.live.waiting_canvas": "Odotetaan renderöinnin piirtoaluetta",
"render.live.worker_stopped": "Reaaliaikaisen renderöinnin kaappausprosessi pysähtyi ennen tuloksen palauttamista",
"render.material_cpu_glass_only": "Väliaikaiset kuvatiedostokartat vaativat GPU:n. CPU voi käyttää tallennettuja sisäänrakennettuja pintakarttoja.",
"render.material_cpu_optics_limits": "Ota epäsuora valaistus käyttöön tuettuja suljettuja lasimuotoja, varmennettua sisäkkäisyyttä ja pinnanalaista sirontaa varten. Ohuet levyt voivat päästää varjot läpi. Muussa tapauksessa käytä tasomaista levyä tai akselien suuntaista laatikkoa. Toisiaan koskettavia muotoja ja suljetun lasin läpi kulkevia varjoja ei edelleenkään tueta. Arvosi säilytetään.",
"render.material_cpu_saved_runtime_conflict": "Tallennettua CPU-optiikkaa ei voi yhdistää vanhanmallisiin väliaikaisiin optisiin sidontoihin. Tallenna tarkistettu optiikka tai poista ristiriitainen väliaikainen sidonta.",
"render.material_raytrace_required": "Fysikaalinen lasi vaatii CPU-säteenjäljityksen tai GPU-polunjäljityksen.",
"render.material_saved_edit_pending": "Näitä optisia muokkauksia ei ole tallennettu. Tallenna tarkistetut arvot, poista tallennettu määritys tai hylkää muokkaukset. Vanhanmalliset väliaikaiset sidonnat ja tukemattomat tehosteet eivät voi ohittaa tallennettua kohdetta.",
"render.output_master_unavailable": "Valitse valmis kuva, jossa on raaka-EXR-data, viedäksesi EXR:n. Ota raaka-EXR käyttöön ennen uusien kuvien renderöintiä.",
"render.output_presentation_limits": "CPU:n kuvatehosteet ja EXR-tuloste tukevat kuvia, joiden leveys tai korkeus on enintään 8192 pikseliä ja joissa on yhteensä enintään 16 777 216 pikseliä.",
"render.output_raytrace_only": "Kuvatehosteet vaativat CPU-säteenjäljityksen tai GPU-polunjäljityksen.",
"render.preparation_cancelled": "Renderöinnin valmistelu peruutettu",
"render.preparation_checking": "Tarkistetaan renderöintiä",
"render.preparation_retirement_failed": "Renderöinnin valmistelun siivous epäonnistui. Tämä ikkuna ei voi valmistella uutta kuvaa.",
"render.preparation_retiring": "Viimeistellään renderöinnin valmistelua",
"render.preparation_unavailable": "Renderöinnin valmistelu ei ole käytettävissä",
"render.presentation_banner": "Esitystehosteet toimivat CPU- ja GPU-säteenjäljityksen kanssa. Tämän sivun avaaminen ei koskaan muuta menetelmää.",
"render.quality_budget_help": "Asettaa koon, näytteet, syvyyden, siemenen ja kohinanpoiston. Muut kuva-asetukset säilyvät.",
"render.quality_denoise_off": "Kohinanpoisto: Pois",
"render.quality_denoise_on": "Kohinanpoisto: Päällä",
"render.quality_summary": "{width} × {height} · {samples} näytettä · syvyys {depth} · siemen {seed} · {denoise}",
"render.shot_lens_cpu_progressive_reason": "Ota progressiivinen näytteistys käyttöön Näytteistys-kohdassa ennen CPU:n syväterävyyden käyttöönottoa.",
"render.shot_lens_raytrace_reason": "Syväterävyys on käytettävissä CPU-säteenjäljityksessä ja GPU-polunjäljityksessä.",
"render.spot_axis": "Säteilysuunta",
"render.spot_gpu_help": "Kulmat mitataan säteilysuunnasta. Valo himmenee sisemmän ja ulomman kulman välillä. Hyvin kapeat himmenemisalueet vaativat CPU-renderöinnin.",
"render.spot_help": "Kulmat mitataan säteilysuunnasta. Valo himmenee sisemmän ja ulomman kulman välillä.",
"render.spot_inner": "Sisempi puolikulma",
"render.spot_outer": "Ulompi puolikulma",
"render.stage_panels.grain_angle": "Syykulma",
"render.surface_import.author": "Tekijä",
"render.surface_import.busy": "Tuodaan kuvaa…",
"render.surface_import.complete": "Valitse kuva ja anna sen tekijä, lähde ja lisenssi.",
"render.surface_import.embedded": "Tuotu kuva",
"render.surface_import.failed": "Kuvan tuonti ei valmistunut.",
"render.surface_import.license": "Lisenssi / omistus",
"render.surface_import.raytrace_required": "Kuvat vaativat CPU- tai GPU-säteenjäljityksen. Valitse se kohdassa Tuloste.",
"render.surface_import.repeat_u": "U-toisto",
"render.surface_import.repeat_v": "V-toisto",
"render.surface_import.save": "Tallenna kuva kappaleeseen",
"render.surface_import.saved_cpu": "Tallenna kartat, jotta voit käyttää niitä CPU-renderöinnissä.",
"render.surface_import.source": "Lähde",
"render.surface_import.title": "Tuo kuva",
"render.volume_denoise_required": "Poista kohinanpoisto käytöstä, jos haluat käyttää ilmakehää.",
"render.volume_glass_required": "Ilmakehää ei voi yhdistää lasiin tässä asetelmassa.",
"render.volume_lighting_required": "Valitse ympäristö tai nimenomainen valoasetelma, jotta ilmakehä voidaan renderöidä.",
"render.volume_single_stratum_required": "Käytä yhtä läpäisynäytettä ilmakehän renderöintiin.",
"sketch.divide_hint": "Pisteet jakavat kunkin käyrän yhtä pitkiin osiin ja säilyttävät jaon, kun käyrä muuttuu.",
"sketch.error.closed_curve_two_crossings": "suljettu käyrä tarvitsee kaksi leikkauskohtaa katkaisua varten",
"sketch.error.nothing_to_break": "tässä ei ole katkaistavaa",
"sketch.error.offset_fillet_chamfer": "`{id}` on siirtymä, ja sen kulmat seuraavat sen lähdekäyriä: pyöristä tai viistä lähdekäyrien kulmat ennen siirtymää tai katkaise ensin siirtymän yhteys",
"sketch.error.offset_trim": "`{id}` on siirtymä, ja sen päät seuraavat sen lähdekäyriä: muokkaa sen sijaan lähdekäyriä tai katkaise ensin siirtymän yhteys",
"sketch.hint.arc.center_point": "Kaari: napsauta keskipistettä ja alkupistettä, kierrä kumpaan suuntaan tahansa ja napsauta loppupistettä tai kirjoita etumerkillinen kaarikulma",
"sketch.hint.arc.three_point": "Kaari: napsauta alkupistettä, loppupistettä ja sitten kaaren pistettä",
"sketch.hint.arc3d": "3D-kaari: napsauta alkupistettä, loppupistettä ja sitten kaaren pistettä (pisteet voivat olla luonnostason ulkopuolella)",
"sketch.hint.break": "Katko: napsauta käyrää kohdasta, josta se jaetaan",
"sketch.hint.circle.center": "Ympyrä: napsauta keskipistettä ja sitten ympyrän pistettä",
"sketch.hint.constraint.generic": "Rajoite: valitse luonnoksen entiteetit",
"sketch.hint.constraint.horizontal_vertical": "Vaakasuora/pystysuora: valitse viiva, kaksi pistettä tai piste ja viiva; valitse viivan keskipiste pitämällä Shift painettuna",
"sketch.hint.constraint.midpoint": "Keskipiste: valitse piste ja viiva tai kaksi viivaa, jolloin jälkimmäinen keskitetään ensimmäiseen",
"sketch.hint.dimension": "Mitta: valitse yksi tai kaksi entiteettiä ja kirjoita arvo (tai sijoita mitta ensin napsauttamalla); muokkaa mittaa napsauttamalla sitä; Enter käyttää",
"sketch.hint.extend": "Jatka: napsauta käyrää jatkettavan pään läheltä (Shift: napsauta kahta käyrää jatkaaksesi molempia niiden kohtaamispisteeseen)",
"sketch.hint.fallback": "Napsauta luonnoksessa",
"sketch.hint.line": "Viiva: napsauta pisteitä; lopeta hiiren kakkospainikkeella tai Esc-näppäimellä",
"sketch.hint.line3d": "3D-viiva: napsauta maailman pisteitä; lopeta hiiren kakkospainikkeella tai Esc-näppäimellä (pisteet voivat olla luonnostason ulkopuolella)",
"sketch.hint.open_done": "Avoin valmis",
"sketch.hint.point3d": "3D-piste: napsauta 3D-pisteen paikkaa",
"sketch.hint.rectangle.center": "Keskipistesuorakulmio: napsauta keskipistettä ja sitten kulmaa",
"sketch.hint.rectangle.rounded": "Pyöristetty suorakulmio: napsauta kahta vastakkaista kulmaa (siirry Tab-näppäimellä säderuutuun kirjoittaaksesi kulmasäteen)",
"sketch.hint.rectangle.three_point": "Kolmen pisteen suorakulmio: napsauta yhden sivun kahta pistettä ja sitten leveyttä",
"sketch.hint.rectangle.two_point": "Suorakulmio: napsauta kahta vastakkaista kulmaa",
"sketch.hint.slot": "Ura: napsauta molempia päitä ja sitten leveyttä",
"sketch.hint.spline3d": "3D-splini: napsauta sovituspisteitä 3D:ssä; lopeta hiiren kakkospainikkeella tai Esc-näppäimellä",
"sketch.hint.trim": "Katkaise: napsauta poistettavaa käyrän osaa tai vedä poistettavien osien yli",
"ui.addToQuickAccess": "Lisää pikakäyttöön",
"ui.close_tab": "Sulje välilehti",
"ui.fair_iterations": "Iteraatiot",
"ui.fair_keep_edges": "Säilytä särmät",
"ui.fair_strength": "Voimakkuus",
"ui.fair_surface_title": "SILOTA PINTA",
"ui.fair_tolerance_default": "0.001 kappaleen koosta",
"ui.first_dimension_scales": "Ensimmäinen mitta skaalaa piirustuksen",
"ui.first_dimension_scales_tip": "Päällä: luonnoksen ensimmäinen pituusmitta skaalaa mittaamaansa piirustusta, joten sen muoto pysyy samana. Pois: mitta muuttaa vain mittaamansa kohteen kokoa.",
"ui.heading_custom_view_navigator": "MUKAUTETTU NÄKYMÄNAVIGAATTORI",
"ui.heading_divide_curve": "JAA KÄYRÄ",
"ui.heading_press_pull": "TYÖNNÄ/VEDÄ",
"ui.history_scope_all": "Näytä koko mallin piirteet",
"ui.history_scope_all_shown": "Koko mallin piirteet",
"ui.history_scope_pick": "Muokkaa komponenttia tai valitse komponentti selaimessa, niin näet vain sen piirteet",
"ui.history_scope_show": "Näytä vain komponentin {name} ja sen sisältämien komponenttien piirteet",
"ui.history_scope_shown": "Komponentin {name} ja sen sisältämien komponenttien piirteet",
"ui.intersection_curve_second_pick": "Leikkauskäyrä: napsauta toista pintaa tai käyrää (aloita alusta hiiren kakkospainikkeella)",
"ui.match_surface_title": "SOVITA PINTA",
"ui.mcp_agents": "Agentit MCP:n kautta",
"ui.mcp_compact": "Tiivis tuloste",
"ui.mcp_compact_note": "MCP-palvelimen (solidcraft-cli mcp) kautta yhdistetyille agenteille: inspect_design tiivistää luonnokset, eikä työkalujen tuloksia muotoilla luettaviksi, joten ne käyttävät vähemmän tokeneita. Koskee muutoksen jälkeen aloitettuja agenttiistuntoja; myös --compact tai SOLIDCRAFT_MCP_COMPACT=1 ottaa sen käyttöön.",
"ui.mesh_import_origin": "Siirrä origoon",
"ui.mesh_import_origin_tip": "Keskitä verkko X- ja Y-suunnassa niin, että sen pohja on tasolla Z = 0. Kun asetus on pois päältä, verkko säilyttää tiedostoon tallennetut koordinaatit.",
"ui.organize": "Järjestä",
"ui.panel_fixture.apply": "Käytä",
"ui.panel_fixture.cancel": "Peruuta",
"ui.panel_fixture.caption": "Synteettinen paneelinäyte",
"ui.panel_fixture.draft": "Säilytetty luonnos",
"ui.panel_fixture.editor": "Näyte-editori",
"ui.panel_fixture.explanation": "Tämä synteettinen sisältö säilyttää luonnoksensa, kun se on piilotettu tai siirretty.",
"ui.panel_fixture.fill": "Täytä näyte",
"ui.panel_fixture.finish": "Valmis",
"ui.panel_fixture.finished": "Synteettinen tapahtuma päättyi",
"ui.panel_fixture.footer": "Synteettiset toiminnot eivät muuta mallia.",
"ui.panel_fixture.guard": "Estä peruutus, kun tapahtuma on käynnissä",
"ui.panel_fixture.inline": "Upotettu näyte",
"ui.panel_fixture.live": "Synteettinen tapahtuma käynnissä",
"ui.panel_fixture.log": "Näyteloki",
"ui.panel_fixture.pin": "Kiinnitetty näyte",
"ui.panel_fixture.title": "{title} · {id}",
"ui.panel_fixture.updates": "Palvelupäivitykset: {count}",
"ui.panel_host.attention": "Vaatii huomiota",
"ui.panel_host.bottom": "Alas",
"ui.panel_host.close": "Sulje paneeli",
"ui.panel_host.collapse": "Kutista paneeli",
"ui.panel_host.expand": "Laajenna paneeli",
"ui.panel_host.floating": "Kelluva",
"ui.panel_host.focus": "Kohdista paneeliin",
"ui.panel_host.focus_target": "Kohdista: {title}",
"ui.panel_host.header": "Paneelin otsikko",
"ui.panel_host.join": "Yhdistä välilehdet",
"ui.panel_host.join_target": "Yhdistä välilehdet: {title}",
"ui.panel_host.left": "Vasemmalle",
"ui.panel_host.menu": "Paneelin toiminnot",
"ui.panel_host.move": "Siirrä",
"ui.panel_host.overflow": "Lisää paneeleja",
"ui.panel_host.popout": "Avaa ikkunassa",
"ui.panel_host.resize": "Muuta paneelin kokoa",
"ui.panel_host.right": "Oikealle",
"ui.panel_host.rule.availability": "Tämä paneeli ei ole käytettävissä.",
"ui.panel_host.rule.bounds": "Paneelit eivät mahdu kokorajoihinsa.",
"ui.panel_host.rule.capability": "Tämä isäntä ei tue kyseistä toimintoa.",
"ui.panel_host.rule.collection_limit": "Paneeliasettelu on saavuttanut rajansa.",
"ui.panel_host.rule.duplicate_id": "Tämä paneelin tunniste on jo käytössä.",
"ui.panel_host.rule.exactly_once": "Jokaisen paneelin on oltava mukana kerran.",
"ui.panel_host.rule.feature_disabled": "Tämä toiminto on poistettu käytöstä tässä paneelissa.",
"ui.panel_host.rule.id": "Paneelin tunniste on virheellinen.",
"ui.panel_host.rule.incompatible_tabs": "Nämä paneelit eivät voi jakaa välilehtiryhmää.",
"ui.panel_host.rule.lifecycle": "Paneeli ei voinut suorittaa tätä toimintoa loppuun.",
"ui.panel_host.rule.missing_target": "Tämä paneelin kohde ei ole enää käytettävissä.",
"ui.panel_host.rule.no_space": "Tälle paneeliasettelulle ei ole tilaa.",
"ui.panel_host.rule.pin_conflict": "Toinen paneeli on jo kiinnitetty tähän reunaan.",
"ui.panel_host.rule.pin_order": "Kiinnitetyn paneelin on pysyttävä reunansa ensimmäisenä.",
"ui.panel_host.rule.placement": "Tätä paneelia ei voi siirtää kyseiseen paikkaan.",
"ui.panel_host.rule.revision": "Asettelu muuttui. Yritä toimintoa uudelleen.",
"ui.panel_host.rule.schema": "Paneelipyyntö on virheellinen.",
"ui.panel_host.rule.version": "Tätä paneeliasettelun versiota ei tueta.",
"ui.panel_host.sash": "Muuta viereisten paneelien kokoa",
"ui.panel_host.scroll_horizontal": "Vieritä paneelisarakkeita",
"ui.panel_host.scroll_vertical": "Vieritä paneelipinoja",
"ui.panel_host.surface": "Paneeli",
"ui.panel_host.tab": "Paneelin välilehti",
"ui.panel_host.tabs": "Kaikki välilehdet",
"ui.panel_layout.applied": "Asettelu {name} otettu käyttöön työtilassa {workspace}",
"ui.panel_layout.cancel": "Peruuta",
"ui.panel_layout.confirm_replace": "Korvataanko ”{name}”?",
"ui.panel_layout.copy": "Kopioi",
"ui.panel_layout.current": "Nykyinen asettelu",
"ui.panel_layout.custom": "Mukautettu",
"ui.panel_layout.custom_hint": "Paneeleja on siirretty asettelun {name} jälkeen.",
"ui.panel_layout.details": "Tiedot",
"ui.panel_layout.layout_workspace": "Asettelun työtila",
"ui.panel_layout.page_defaults_hint": "Palauttaa tämän sivun asetukset. Paneelien järjestys ja tallennetut asettelut säilyvät.",
"ui.panel_layout.panel_layout": "Paneeliasettelu",
"ui.panel_layout.panel_style": "Paneelityyli",
"ui.panel_layout.replace": "Korvaa asettelu",
"ui.panel_layout.replace_current": "Korvaa nykyisellä",
"ui.panel_layout.replace_short": "Korvaa",
"ui.panel_layout.reset": "Palauta työtila",
"ui.panel_layout.reset_hint": "Palauta työtilan {workspace} oletusjärjestys. Tallennetut asettelut säilyvät.",
"ui.panel_layout.reset_short": "Palauta",
"ui.panel_layout.restore_failed": "Asettelua ei voitu palauttaa",
"ui.panel_layout.save": "Tallenna asettelu",
"ui.panel_layout.save_as": "Tallenna nimellä",
"ui.panel_layout.save_as_tile": "Tallenna nimellä…",
"ui.panel_layout.selected": "valittu",
"ui.panel_layout.suggested_name": "Asettelu {n}",
"ui.panel_layout.try_again": "Yritä uudelleen",
"ui.panel_layout.validation.builtin": "Sisäänrakennettuja asetteluja ei voi muuttaa",
"ui.panel_layout.validation.duplicate": "Tämä nimi on jo käytössä",
"ui.panel_layout.validation.empty": "Anna nimi",
"ui.panel_layout.validation.full": "64/64 tallennettu. Valitse korvattava asettelu.",
"ui.panel_layout.validation.invalid": "Käytä 1–64 merkkiä, ei ohjausmerkkejä",
"ui.panel_layout.workspace.classic": "Klassinen",
"ui.panel_layout.workspace.modelling": "Mallinnus",
"ui.panel_layout.workspace.render": "Renderöinti",
"ui.panel_layout.workspace.review": "Tarkastelu",
"ui.panel_layout.workspace.sketch": "Luonnos",
"ui.panel_layout.your_layout": "oma asettelusi",
"ui.point_cloud_dots": "Pisteet",
"ui.point_cloud_reload": "Lataa uudelleen tiedostosta",
"ui.project_references_construction": "Projisoi viitteet apugeometriaksi",
"ui.project_references_construction_note": "Projisoi, Leikkaa, Sisällytä 3D-geometria ja pinnalle luodun uuden luonnoksen pinnan särmät tuottavat apugeometriaa: viitteitä, joita vasten piirretään ja jotka jätetään profiilien ulkopuolelle. Ne pysyvät linkitettyinä malliin.",
"ui.quick_access_full": "Pikakäyttöpalkkiin mahtuu {count} komentoa; poista yksi lisätäksesi toisen",
"ui.removeFromQuickAccess": "Poista pikakäytöstä",
"ui.replace_sketch.from_file": "Mallitiedostosta…",
"ui.replace_sketch.no_sketches": "Mallissa ei ole luonnosta, jonka voisi ottaa",
"ui.report_characters_left_one": "{count} merkki jäljellä",
"ui.report_characters_left_other": "{count} merkkiä jäljellä",
"ui.segments": "Segmentit",
"view_gizmo.arrows": "Kiertonuolet",
"view_gizmo.home": "Aloitusnäkymäpainike",
"view_gizmo.no_such_body": "Tässä mallissa ei ole sen nimistä kappaletta.",
"view_gizmo.note": "Valitse kappale näkymästä. Se tallennetaan asetuksiisi ja asetusprofiileihisi, ja se näytetään jokaisessa mallissa. Napsauta jotakin sen pinnoista katsoaksesi sitä suoraan.",
"view_gizmo.texture": "Tekstuuri",
"view_gizmo.themed": "Teeman mukainen",
"view_gizmo.too_detailed": "Liian yksityiskohtainen näkymänavigaattoriin (yli 10 000 kolmiota)",
"view_gizmo.too_thin": "Liian pitkä ja ohut näkymänavigaattoriin: sen ohuimman sivun on oltava vähintään viidesosa pisimmästä"
}
}
+2704 -1
View File
File diff suppressed because it is too large Load Diff
+3722 -1
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -1889,6 +1889,7 @@
"material.editor.preserved_appearance": "Aspect păstrat",
"material.editor.public_reference": "Referință publică",
"material.editor.reference": "Referință",
"material.editor.remove": "Elimină",
"material.editor.save_or_discard": "Salvați sau renunțați la ciorna materialului înainte de a atribui un material.",
"material.editor.saved": "Material salvat. Atribuirile pe corpuri rămân neschimbate.",
"material.editor.scope": "Salvarea stochează acest material fără a-l atribui corpurilor. Proprietățile și condițiile lăsate necompletate rămân necunoscute. Densitatea din fișă nu modifică densitatea atribuită și nici masa.",
+1
View File
@@ -1882,6 +1882,7 @@
"material.editor.preserved_appearance": "Сохранённый внешний вид",
"material.editor.public_reference": "Общедоступный справочник",
"material.editor.reference": "Ссылка",
"material.editor.remove": "Убрать",
"material.editor.save_or_discard": "Сохраните или отмените черновик материала, прежде чем назначать материал.",
"material.editor.saved": "Материал сохранён. Назначения телам не изменились.",
"material.editor.scope": "Сохранение записывает этот материал, не назначая его телам. Пустые свойства и условия остаются неизвестными. Плотность в карточке не меняет назначенную плотность и массу.",
+1
View File
@@ -1889,6 +1889,7 @@
"material.editor.preserved_appearance": "Сачувани изглед",
"material.editor.public_reference": "Јавна референца",
"material.editor.reference": "Референца",
"material.editor.remove": "Уклони",
"material.editor.save_or_discard": "Сачувајте или одбаците нацрт материјала пре доделе материјала.",
"material.editor.saved": "Материјал је сачуван. Доделе телима остају непромењене.",
"material.editor.scope": "Сачувај чува овај материјал без доделе телима. Празна својства и услови остају непознати. Густина са картице не мења додељену густину ни масу.",
+1
View File
@@ -1890,6 +1890,7 @@
"material.editor.preserved_appearance": "Bevarat utseende",
"material.editor.public_reference": "Offentlig referens",
"material.editor.reference": "Referens",
"material.editor.remove": "Ta bort",
"material.editor.save_or_discard": "Spara eller förkasta materialutkastet innan du tilldelar ett material.",
"material.editor.saved": "Materialet sparades. Kropparnas tilldelningar är oförändrade.",
"material.editor.scope": "Spara lagrar materialet utan att tilldela det till kroppar. Tomma egenskaper och förhållanden förblir okända. Kortets densitet ändrar inte tilldelad densitet eller massa.",
+1
View File
@@ -1889,6 +1889,7 @@
"material.editor.preserved_appearance": "Korunan görünüm",
"material.editor.public_reference": "Herkese açık referans",
"material.editor.reference": "Referans",
"material.editor.remove": "Kaldır",
"material.editor.save_or_discard": "Malzeme atamadan önce malzeme taslağını kaydedin veya atın.",
"material.editor.saved": "Malzeme kaydedildi. Gövde atamaları değişmedi.",
"material.editor.scope": "Kaydetme, bu malzemeyi gövdelere atamadan saklar. Boş özellikler ve koşullar bilinmez kalır. Kart yoğunluğu, atanan yoğunluğu veya kütleyi değiştirmez.",
+528 -4
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -265,6 +265,7 @@
"Edge": "Cạnh",
"Edges": "Các cạnh",
"Edit": "Sửa",
"Edit Holes": "Sửa các lỗ",
"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",
@@ -1206,6 +1207,7 @@
"cmd.material_properties_directional": "Mô đun đàn hồi theo hướng",
"cmd.material_properties_validate": "Xác thực dữ liệu thuộc tính vật liệu",
"cmd.mesh_convert": "Chuyển đổi lưới",
"cmd.model_body_features": "Liệt kê tính năng của thân",
"cmd.model_edges": "Danh sách các cạnh",
"cmd.model_faces": "Danh sách các bề mặt",
"cmd.model_threads": "Danh sách các đoạn ren",
@@ -1234,8 +1236,11 @@
"cmd.panel.dismiss": "Đóng chế độ xem tạm của bảng",
"cmd.panel.float": "Thả nổi bảng",
"cmd.panel.focus": "Đặt tiêu điểm vào bảng",
"cmd.panel.focus.next": "Đặt tiêu điểm vào bảng tiếp theo",
"cmd.panel.focus.previous": "Đặt tiêu điểm vào bảng trước",
"cmd.panel.list": "Liệt kê các bảng",
"cmd.panel.move": "Di chuyển bảng",
"cmd.panel.move.next_zone": "Di chuyển bảng đến vùng tiếp theo",
"cmd.panel.native_close": "Gắn lại vùng bảng từ cửa sổ riêng",
"cmd.panel.open": "Mở bảng",
"cmd.panel.peek": "Xem tạm bảng đã thu gọn",
@@ -1243,6 +1248,12 @@
"cmd.panel.reset_pair": "Đặt lại thanh chia bảng",
"cmd.panel.resize": "Đổi kích thước vùng bảng",
"cmd.panel.resize_pair": "Di chuyển thanh chia bảng",
"cmd.panel.sash.down": "Di chuyển thanh chia bảng xuống",
"cmd.panel.sash.left": "Di chuyển thanh chia bảng sang trái",
"cmd.panel.sash.reset": "Đặt lại thanh chia bảng đang có tiêu điểm",
"cmd.panel.sash.right": "Di chuyển thanh chia bảng sang phải",
"cmd.panel.sash.up": "Di chuyển thanh chia bảng lên",
"cmd.panel.toggle": "Bật/tắt thu gọn bảng đang có tiêu điểm",
"cmd.parameters_add": "Thêm tham số người dùng",
"cmd.parameters_change": "Thay đổi tham số",
"cmd.parameters_comment": "Thêm chú thích tham số",
@@ -1366,6 +1377,7 @@
"cmd.sketch.construction_line.tangent_external": "Tiếp tuyến qua điểm ngoài",
"cmd.sketch.construction_line.tangent_two": "Tiếp tuyến chung",
"cmd.sketch.construction_line.vertical": "Đường dựng hình thẳng đứng",
"cmd.sketch.replace": "Thay thế phác thảo",
"cmd.sketch_arc3d": "Cung tròn 3D",
"cmd.sketch_arc_center_point": "Cung tròn xác định bởi tâm và điểm",
"cmd.sketch_arc_tangent": "Cung tròn tiếp tuyến",
@@ -2016,6 +2028,7 @@
"help.view.visual_style": "Đổi cách vẽ mô hình: tô bóng có hoặc không có cạnh, khung dây, hoặc hiện cạnh khuất. Dùng khung dây để nhìn vào bên trong, tô bóng để có hình ảnh gọn gàng.",
"history.sketch_under_constrained": "Thiếu ràng buộc ({count} bậc tự do)",
"history.sketch_under_constrained_one": "Thiếu ràng buộc ({count} bậc tự do)",
"hole_folder.applies_to": "Thay đổi áp dụng cho {count} lỗ trong {folder}; mỗi lỗ giữ nguyên vị trí.",
"hole_presets.confirm_delete": "Xác nhận xóa",
"hole_presets.delete": "Xóa",
"hole_presets.delete_prompt": "Xóa mẫu thiết lập sẵn cá nhân này? Các lỗ ren/khoan hiện có sẽ không thay đổi.",
@@ -2205,6 +2218,7 @@
"material.editor.preserved_appearance": "Bề mặt hiển thị giữ lại",
"material.editor.public_reference": "Tài liệu tham khảo công khai",
"material.editor.reference": "Tham khảo",
"material.editor.remove": "Xóa",
"material.editor.save_or_discard": "Lưu hoặc hủy bản nháp vật liệu trước khi gán vật liệu.",
"material.editor.saved": "Đã lưu vật liệu. Gán vật liệu cho khối không đổi.",
"material.editor.scope": "Lưu sẽ lưu trữ vật liệu này mà không gán cho khối. Các thuộc tính và điều kiện để trống vẫn chưa xác định. Khối lượng riêng trên thẻ không làm thay đổi khối lượng riêng hay khối lượng đã gán.",
@@ -2515,6 +2529,8 @@
"prefs.bottom_centre": "Giữa phía dưới",
"prefs.bottom_right": "Góc dưới bên phải",
"prefs.browser": "Cây thư mục",
"prefs.browser_body_features": "Tính năng dưới thân",
"prefs.browser_body_features_note": "Mỗi thân trong trình duyệt có thể mở ra để hiện các tính năng trên dòng thời gian đã tạo nên nó, theo thứ tự dòng thời gian.",
"prefs.browser_components_open": "Thành phần mở sẵn",
"prefs.browser_components_open_note": "Mọi thành phần trong trình duyệt đều mở sẵn như trước, thay vì đóng cho đến khi bạn mở.",
"prefs.browser_eye_left": "Biểu tượng mắt cạnh mũi tên thu gọn",
@@ -2555,7 +2571,11 @@
"prefs.design_tools.degrees": "Độ",
"prefs.design_tools.double_click_help": "Hoặc để chỉnh sửa thành phần được liên kết tại chỗ.",
"prefs.design_tools.ghost": "Hiện vị trí cũ của chi tiết đã di chuyển dưới dạng bóng mờ",
"prefs.design_tools.hole_group": "Nhóm các lỗ được tạo cùng lúc",
"prefs.design_tools.hole_group_help": "Các lỗ được tạo trong một lần dùng chung một thư mục trong Lịch sử, ban đầu được đóng; mở thư mục để sửa từng lỗ.",
"prefs.design_tools.hole_moves_help": "Chọn lại mặt sẽ di chuyển lỗ thay vì xóa lỗ.",
"prefs.design_tools.hole_several": "Nhiều lỗ trên một mặt",
"prefs.design_tools.hole_several_help": "Chọn chỗ khác trên mặt sẽ thêm một lỗ; chọn một lỗ sẽ xóa lỗ đó.",
"prefs.design_tools.hole_snaps_help": "Điểm giữa của mặt và tâm các cạnh tròn của mặt đó.",
"prefs.design_tools.imported_files": "Tệp đã nhập",
"prefs.design_tools.note": "Thông báo",
@@ -2637,6 +2657,8 @@
"prefs.history": "Lịch sử",
"prefs.history_bar_groups": "Thu gọn thanh Lịch sử thành nhóm",
"prefs.history_bar_groups_note": "Trong lịch sử dài, một chuỗi tính năng của cùng một thành phần được thu gọn thành một thẻ, và thư mục tính năng bắt đầu ở dạng thu gọn. Cụm lắp ráp STEP được chèn vào sẽ nằm trong thư mục Group. Khi tắt, mỗi tính năng đều hiện, thư mục bắt đầu ở dạng mở và không tạo thư mục nào.",
"prefs.history_folder_kind_icon": "Biểu tượng thư mục theo các tính năng bên trong",
"prefs.history_folder_kind_icon_note": "Thư mục Lịch sử có các tính năng cùng một loại sẽ hiện biểu tượng của chúng.",
"prefs.history_group_joints": "Nhóm các khớp nối trong Lịch sử",
"prefs.history_group_joints_note": "Từ hai khớp nối trở lên hiện thành một nút kèm số lượng, nhấp vào để hiện từng khớp nối. Khi tắt, mỗi khớp nối hiện riêng.",
"prefs.history_origin_bodies": "Hiển thị gốc tọa độ và thân khối ở trên cùng",
@@ -2830,6 +2852,8 @@
"prefs.select_other_hidden": "Chọn hình học ẩn bằng Chọn đối tượng khác",
"prefs.select_other_hidden_help": "Bật: bao gồm các khối, bản phác thảo ẩn và cạnh bị che gần con trỏ. Tắt: dùng danh sách ban đầu chỉ gồm hình học đang hiển thị ngay dưới con trỏ.",
"prefs.selecting": "Đang chọn",
"prefs.shared_panel_layout": "Dùng bố cục bảng dùng chung",
"prefs.shared_panel_layout_help": "Hiển thị Trình duyệt, Lịch sử và Trợ lý hiện có trong các bảng có thể di chuyển. Thay đổi có hiệu lực khi áp dụng tùy chọn.",
"prefs.shift": "Shift",
"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.",
@@ -5520,6 +5544,7 @@
"ui.heading_plastic": "CHI TIẾT NHỰA",
"ui.heading_position": "VỊ TRÍ",
"ui.heading_prepare": "CHUẨN BỊ",
"ui.heading_press_pull": "KÉO/ĐẨY",
"ui.heading_rectangular_pattern": "SAO CHÉP HÌNH CHỮ NHẬT",
"ui.heading_revolve": "XOAY TRÒN TẠO KHỐI",
"ui.heading_right": "HÌNH CHIẾU CẠNH",
@@ -5863,6 +5888,8 @@
"ui.profiles": "Các biên dạng",
"ui.project": "Chiếu hình học",
"ui.project_face_edges_sketch": "Chiếu các cạnh bề mặt vào phác thảo mới",
"ui.project_references_construction": "Chiếu tham chiếu thành đường dựng hình",
"ui.project_references_construction_note": "Chiếu, Giao, Đưa vào hình học 3D và các cạnh bề mặt của phác thảo mới trên một bề mặt tạo ra đường dựng hình: tham chiếu để vẽ theo, không thuộc biên dạng. Chúng vẫn liên kết với mô hình.",
"ui.properties": "Thuộc tính cơ lý",
"ui.properties.copy_matrix": "Sao chép ma trận",
"ui.properties.first_moments": "Mômen bậc nhất",
@@ -5906,6 +5933,8 @@
"ui.renderer": "Trình kết xuất hình ảnh (Renderer)",
"ui.repeat": "Lặp lại",
"ui.repeat_nothing": "Chưa có gì để lặp lại: hãy bắt đầu một công cụ hoặc hộp thoại trước.",
"ui.replace_sketch.from_file": "Từ một tệp thiết kế…",
"ui.replace_sketch.no_sketches": "Thiết kế đó không có phác thảo nào để lấy",
"ui.replace_with_link_done": "Các bản sao hiện đã trở thành các vị trí đặt của thiết kế liên kết.",
"ui.report_a_bug": "Báo cáo lỗi",
"ui.report_characters_left_one": "Còn {count} ký tự",
+1
View File
@@ -1896,6 +1896,7 @@
"material.editor.preserved_appearance": "保留的外觀",
"material.editor.public_reference": "公開參考資料",
"material.editor.reference": "參考",
"material.editor.remove": "刪除",
"material.editor.save_or_discard": "指定材料前,請儲存或放棄材料草稿。",
"material.editor.saved": "材料已儲存。實體的材料指定未變。",
"material.editor.scope": "儲存材料不會將其指定給實體。空白屬性和條件仍為未知。屬性卡密度不會改變指定密度或質量。",
+29
View File
@@ -265,6 +265,7 @@
"Edge": "边",
"Edges": "边",
"Edit": "编辑",
"Edit Holes": "编辑孔",
"Edit Profile Sketch": "编辑轮廓草图",
"Edit shortcuts": "编辑快捷键",
"Enable rule": "启用规则",
@@ -1206,6 +1207,7 @@
"cmd.material_properties_directional": "方向弹性模量",
"cmd.material_properties_validate": "验证物理材料数据",
"cmd.mesh_convert": "转换网格",
"cmd.model_body_features": "列出实体特征",
"cmd.model_edges": "列出边",
"cmd.model_faces": "列出面",
"cmd.model_threads": "列出螺纹",
@@ -1234,8 +1236,11 @@
"cmd.panel.dismiss": "关闭面板临时预览",
"cmd.panel.float": "浮动面板",
"cmd.panel.focus": "聚焦面板",
"cmd.panel.focus.next": "聚焦下一个面板",
"cmd.panel.focus.previous": "聚焦上一个面板",
"cmd.panel.list": "列出面板",
"cmd.panel.move": "移动面板",
"cmd.panel.move.next_zone": "将面板移至下一个区域",
"cmd.panel.native_close": "重新停靠独立窗口中的面板分区",
"cmd.panel.open": "打开面板",
"cmd.panel.peek": "临时预览折叠面板",
@@ -1243,6 +1248,12 @@
"cmd.panel.reset_pair": "重置面板分隔条",
"cmd.panel.resize": "调整面板分区大小",
"cmd.panel.resize_pair": "移动面板分隔条",
"cmd.panel.sash.down": "向下移动面板分隔条",
"cmd.panel.sash.left": "向左移动面板分隔条",
"cmd.panel.sash.reset": "重置当前焦点面板的分隔条",
"cmd.panel.sash.right": "向右移动面板分隔条",
"cmd.panel.sash.up": "向上移动面板分隔条",
"cmd.panel.toggle": "切换当前焦点面板的折叠状态",
"cmd.parameters_add": "添加用户参数",
"cmd.parameters_change": "更改参数",
"cmd.parameters_comment": "注释参数",
@@ -1366,6 +1377,7 @@
"cmd.sketch.construction_line.tangent_external": "过外部点的切线",
"cmd.sketch.construction_line.tangent_two": "公切线",
"cmd.sketch.construction_line.vertical": "竖直构造直线",
"cmd.sketch.replace": "替换草图",
"cmd.sketch_arc3d": "三维圆弧",
"cmd.sketch_arc_center_point": "中心点圆弧",
"cmd.sketch_arc_tangent": "相切圆弧",
@@ -2016,6 +2028,7 @@
"help.view.visual_style": "切换模型的绘制方式:带边或不带边的着色、线框,或带隐藏边。用线框看内部,用着色得到干净的外观。",
"history.sketch_under_constrained": "欠约束({count} 个自由度)",
"history.sketch_under_constrained_one": "欠约束({count} 个自由度)",
"hole_folder.applies_to": "更改将应用于 {folder} 中的 {count} 个孔;每个孔保持原位置。",
"hole_presets.confirm_delete": "确认删除",
"hole_presets.delete": "删除",
"hole_presets.delete_prompt": "删除此个人预设?现有孔不会改变。",
@@ -2205,6 +2218,7 @@
"material.editor.preserved_appearance": "保留的外观",
"material.editor.public_reference": "公开参考资料",
"material.editor.reference": "参考",
"material.editor.remove": "删除",
"material.editor.save_or_discard": "指定材料前,请保存或放弃材料草稿。",
"material.editor.saved": "材料已保存。实体的材料指定未变。",
"material.editor.scope": "保存材料不会将其指定给实体。空白属性和条件仍为未知。属性卡密度不会改变指定密度或质量。",
@@ -2515,6 +2529,8 @@
"prefs.bottom_centre": "底部居中",
"prefs.bottom_right": "右下角",
"prefs.browser": "浏览器",
"prefs.browser_body_features": "实体下显示特征",
"prefs.browser_body_features_note": "浏览器中的每个实体都可展开,按时间线顺序显示构建它的时间线特征。",
"prefs.browser_components_open": "组件初始展开",
"prefs.browser_components_open_note": "浏览器中的每个组件都像以前一样初始展开,而不是在您展开前保持折叠。",
"prefs.browser_eye_left": "折叠箭头旁的眼睛图标",
@@ -2555,7 +2571,11 @@
"prefs.design_tools.degrees": "度",
"prefs.design_tools.double_click_help": "或就地编辑链接的组件。",
"prefs.design_tools.ghost": "以虚影显示已移动零件的原位置",
"prefs.design_tools.hole_group": "将一起创建的孔分组",
"prefs.design_tools.hole_group_help": "一次创建的孔在历史中共用一个文件夹,初始为折叠;展开后可单独编辑每个孔。",
"prefs.design_tools.hole_moves_help": "再次选择该面会移动孔而非移除孔。",
"prefs.design_tools.hole_several": "一个面上多个孔",
"prefs.design_tools.hole_several_help": "在面的其他位置选择会再添加一个孔;选择孔会移除该孔。",
"prefs.design_tools.hole_snaps_help": "面的中点及其圆形边的圆心。",
"prefs.design_tools.imported_files": "导入的文件",
"prefs.design_tools.note": "提示",
@@ -2637,6 +2657,8 @@
"prefs.history": "历史",
"prefs.history_bar_groups": "将历史栏折叠为分组",
"prefs.history_bar_groups_note": "在较长的历史中,同一组件的连续特征折叠为一个标签,特征文件夹开始时为折叠状态。插入的 STEP 装配体放入 Group 文件夹。关闭后显示每个特征,文件夹开始时为展开状态,且不创建文件夹。",
"prefs.history_folder_kind_icon": "文件夹使用其中特征的图标",
"prefs.history_folder_kind_icon_note": "历史中特征全部属于同一类型的文件夹显示该类型的图标。",
"prefs.history_group_joints": "在历史中将关节分组",
"prefs.history_group_joints_note": "两个或更多关节显示为一个带数量的按钮,单击即可显示每个关节。关闭后逐个显示每个关节。",
"prefs.history_origin_bodies": "在顶部显示原点和实体",
@@ -2830,6 +2852,8 @@
"prefs.select_other_hidden": "使用“选择其他”选择隐藏几何体",
"prefs.select_other_hidden_help": "开启:包括指针附近的隐藏实体、草图和被遮挡的边。关闭:恢复原来的列表,仅包含指针正下方的可见几何体。",
"prefs.selecting": "选择",
"prefs.shared_panel_layout": "使用共享面板布局",
"prefs.shared_panel_layout_help": "在可移动面板中显示现有的浏览器、历史记录和助手。应用首选项后更改生效。",
"prefs.shift": "Shift",
"prefs.shift_add_ctrl_toggle": "Shift 仅添加;Ctrl / Cmd 切换选择",
"prefs.shift_add_note": "开启:Shift 添加,Ctrl / Cmd 切换选择。关闭(默认):两者均切换选择。",
@@ -5520,6 +5544,7 @@
"ui.heading_plastic": "塑料",
"ui.heading_position": "位置",
"ui.heading_prepare": "准备",
"ui.heading_press_pull": "按压拖动",
"ui.heading_rectangular_pattern": "矩形阵列",
"ui.heading_revolve": "旋转",
"ui.heading_right": "右视",
@@ -5863,6 +5888,8 @@
"ui.profiles": "轮廓",
"ui.project": "投影",
"ui.project_face_edges_sketch": "将面的边投影到新草图",
"ui.project_references_construction": "将参考投影为构造几何",
"ui.project_references_construction_note": "投影、相交、包含三维几何以及在面上新建草图时投影的面边,都会生成构造几何:用于绘图参考,不计入轮廓。它们仍与模型保持关联。",
"ui.properties": "属性",
"ui.properties.copy_matrix": "复制矩阵",
"ui.properties.first_moments": "一阶矩",
@@ -5906,6 +5933,8 @@
"ui.renderer": "渲染器",
"ui.repeat": "重复",
"ui.repeat_nothing": "还没有可重复的操作:请先启动一个工具或对话框。",
"ui.replace_sketch.from_file": "从设计文件…",
"ui.replace_sketch.no_sketches": "该设计中没有可用的草图",
"ui.replace_with_link_done": "这些副本现在是链接设计的放置。",
"ui.report_a_bug": "报告缺陷",
"ui.report_characters_left_one": "剩余 {count} 个字符",
+9 -3
View File
@@ -716,14 +716,20 @@ fn lit_color(i: TOut, visibility: f32, base: vec4<f32>) -> vec4<f32> {
if (dot(n, v) < 0.0) { n = -n; }
if u.product_lights_meta.x>0.0 {
var radiance=vec3<f32>(0.0);
let albedo=srgb_decode(base.rgb);
for (var k=0u;k<min(u32(u.product_lights_meta.y),21u);k+=1u) {
if u.product_lights[k*4u].w == 4.0 {radiance+=to_linear(base.rgb)*u.product_lights[k*4u+1u].xyz/3.141592653589793;continue;}
if u.product_lights[k*4u].w == 4.0 {radiance+=albedo*u.product_lights[k*4u+1u].xyz/3.141592653589793;continue;}
let sample=product_light_sample(k,i.wp);
radiance+=to_linear(base.rgb)*sample[1]*(max(dot(n,sample[0]),0.0)/3.141592653589793);
radiance+=albedo*sample[1]*(max(dot(n,sample[0]),0.0)/3.141592653589793);
}
let mapped=radiance/(vec3<f32>(1.0)+radiance);
// A linear attachment takes linear light as it is; any other is encoded once, as the CPU
// image is (decoding the encoded value again through `out_color` would flatten the dark end).
if (u.back.w > 0.5) {
return vec4<f32>(mapped,base.a);
}
let encoded=select(mapped*12.92,1.055*pow(mapped,vec3<f32>(1.0/2.4))-vec3<f32>(0.055),mapped>vec3<f32>(0.0031308));
return out_color(vec4<f32>(encoded,base.a));
return vec4<f32>(encoded,base.a);
}
let key = normalize(v * 0.8 + vec3<f32>(-0.3, 0.2, 0.6));
let diff = max(dot(n, key), 0.0);
+9
View File
@@ -0,0 +1,9 @@
[package]
name = "solidcraft-color"
description = "Colour rules and data any crate may share: the sRGB transfer function and the surface analysis palette"
version.workspace = true
edition.workspace = true
license.workspace = true
[lints]
workspace = true
+20
View File
@@ -0,0 +1,20 @@
# solidcraft-color
Colour rules and data that any crate may share. It has no dependencies and holds no app types,
so the renderer, the document, the importers and the UI theme all use the same rule and the same
colours instead of keeping copies.
The sRGB transfer function (IEC 61966-2-1) lives here once: every encode and decode in Rust goes
through `srgb`, so a change to the rule is one change. Shaders can't call Rust and keep their own
copies.
## API
| Item | What it is |
|---|---|
| `srgb::encode(linear)` | Linear light (0..1) to an sRGB value (0..1) |
| `srgb::decode(encoded)` | An sRGB value (0..1) to linear light |
| `srgb::encode8(linear)` | Linear light to the nearest 8-bit sRGB code (out of range clamps, NaN reads as 0) |
| `srgb::decode8(code)` | An 8-bit sRGB code to linear light |
| `AnalysisColors` | The surface analysis palette (draft, accessibility, curvature, hatch), straight sRGB 0..1 as the shader takes it; the theme fills it and the renderer reads it |
| `AnalysisColors::DEFAULT` | The light theme's colours with no colour vision mode, as headless pictures use |
+32
View File
@@ -0,0 +1,32 @@
//! The colours a surface analysis is shaded in.
/// Surface analysis colours, straight sRGB 0..1 as the shader takes them.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct AnalysisColors {
/// Draft Analysis: at least the draft angle, between, at most minus the angle.
pub draft: [[f32; 3]; 3],
/// Accessibility Analysis: reachable, blocked.
pub access: [[f32; 3]; 2],
/// Curvature Map: flat, the middle of the scale, at or below the analysis radius. Dark blue
/// to light yellow in one straight blend, so the lightness rises the whole way and the scale
/// reads with hue lost too (#975).
pub curvature: [[f32; 3]; 3],
/// Stripe the negative draft and blocked faces too, so colour is not the only cue.
pub hatch: bool,
}
impl AnalysisColors {
/// The light theme's colours (no colour vision mode): what headless pictures use.
pub const DEFAULT: AnalysisColors = AnalysisColors {
draft: [[0.20, 0.75, 0.30], [0.95, 0.80, 0.15], [0.85, 0.22, 0.20]],
access: [[0.20, 0.75, 0.30], [0.85, 0.22, 0.20]],
curvature: [[0.08, 0.12, 0.45], [0.53, 0.51, 0.425], [0.98, 0.90, 0.40]],
hatch: false,
};
}
impl Default for AnalysisColors {
fn default() -> Self {
AnalysisColors::DEFAULT
}
}
+8
View File
@@ -0,0 +1,8 @@
//! Colour rules and data shared by every crate: the sRGB transfer function ([`srgb`]) and the surface analysis palette
//! ([`AnalysisColors`]). No app types and no dependencies, so the renderer, the document and the theme all read the same
//! rule and the same colours.
mod analysis;
pub mod srgb;
pub use analysis::AnalysisColors;
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "solidcraft-control-egui"
description = "The window's own control requests any egui app answers alike: ui.screenshot and ui.resize"
version.workspace = true
edition.workspace = true
license.workspace = true
[lints]
workspace = true
[dependencies]
solidcraft-control = { workspace = true }
serde_json = { workspace = true }
# The window's own requests (`ui.screenshot`, `ui.resize`) go through egui.
egui = { workspace = true }
# Screenshots are saved in the format their path names.
image = { workspace = true }
+14
View File
@@ -0,0 +1,14 @@
# solidcraft-control-egui
The window's own control requests (docs/control-protocol.md), which any egui app answers the same
way: `ui.screenshot` and `ui.resize`. The token files, the loopback server and the request types
are `solidcraft-control`, which has no UI toolkit, so a headless engine, CLI or MCP bridge uses the
control channel without compiling egui. No app types and no engine.
## API
| Item | What it does |
|---|---|
| `screenshot::Shots` | `ui.screenshot`: frames asked for, taken when the view settles, saved and answered, or refused when hidden, cancelled or late; the app gives the reply for no frame |
| `screenshot::encode` | A frame in the format its path names |
| `window::resize`, `window::hidden` | `ui.resize` over `egui::Context`; whether the window presents frames |
+8
View File
@@ -0,0 +1,8 @@
//! The window's own control requests (docs/control-protocol.md) any app answers alike over egui:
//! `ui.screenshot` ([`screenshot::Shots`]) and `ui.resize` ([`window::resize`]). The token, the
//! server and the requests themselves are `solidcraft-control`, which needs no UI toolkit.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
pub mod screenshot;
pub mod window;
+288
View File
@@ -0,0 +1,288 @@
//! `ui.screenshot`: a picture of the window as it is presented, for a control request. The
//! window asks egui for a frame a few frames after the request (a view that just moved settles
//! first), saves it where the request says and answers; a hidden window, a request whose agent
//! chat turn ended, or no frame within 8 s is answered at once with why.
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::sync::mpsc::{Receiver, Sender, channel};
use egui::ColorImage;
use serde_json::{Value, json};
use solidcraft_control::{cancelled_chat_response, chat_turn_cancelled};
use crate::window::hidden;
/// A frame asked for: its token, the time (ms) from which it may be taken, and the frames drawn
/// since it was asked for.
pub type Queued = (u64, f64, u32);
/// A request waiting for its frame: its token, where to save it, where to answer, and the time
/// (ms) after which no frame is coming.
pub type Pending = (u64, Option<String>, Sender<Value>, f64);
/// A frame egui handed back, by its token.
pub type Shot = (u64, Arc<ColorImage>);
/// The window's screenshots in flight. The app may queue frames of its own (an issue report's)
/// with tokens from [`Shots::token`]; [`Shots::collect`] hands those back.
pub struct Shots {
/// The error a request gets when the window presents no frame: the app's words, which may
/// name its other ways to a picture.
no_frame: String,
/// The last token given out.
pub token: u64,
/// Frames asked for and not yet requested from egui.
pub queued: Vec<Queued>,
/// Control requests waiting for their frame.
pub pending: Vec<Pending>,
/// The agent chat turn of each pending request that belongs to one.
turns: HashMap<u64, Arc<AtomicBool>>,
/// Frames egui hands back; egui calls back, [`Shots::collect`] reads them.
tx: Sender<Shot>,
rx: Receiver<Shot>,
}
impl Shots {
/// No screenshots in flight; a request the window presents no frame for is answered with the
/// error `no_frame`.
pub fn new(no_frame: impl Into<String>) -> Self {
let (tx, rx) = channel();
Shots { no_frame: no_frame.into(), token: 0, queued: Vec::new(), pending: Vec::new(), turns: HashMap::new(), tx, rx }
}
/// Take the window's picture for a request that answers on `reply`, saved at `path`; `turn`:
/// the agent chat turn it belongs to. `now` is in ms. A window that is minimised or covered
/// (another macOS Space) presents no frame: that is said at once rather than after the
/// deadline (#1677).
pub fn request(&mut self, ctx: &egui::Context, path: Option<String>, reply: Sender<Value>, turn: Option<Arc<AtomicBool>>, now: f64) {
if hidden(ctx) {
let _ = reply.send(json!({"ok": false, "hidden": true, "error": self.no_frame}));
return;
}
self.token += 1;
let token = self.token;
if let Some(turn) = turn {
self.turns.insert(token, turn);
}
self.queued.push((token, now + 150.0, 0));
self.pending.push((token, path, reply, now + 8000.0));
}
/// Each frame: ask egui for the frames that are due, three frames after their request at the
/// earliest. `settled`: the view is drawn at full quality; a view that keeps moving is taken
/// as it is half a second later (#7165).
pub fn issue(&mut self, ctx: &egui::Context, settled: bool, now: f64) {
let shot_tx = &self.tx;
self.queued.retain_mut(|(token, at, frames)| {
*frames += 1;
if now >= *at && *frames >= 3 && (settled || now >= *at + 500.0) {
let (token, tx, ctx2) = (*token, shot_tx.clone(), ctx.clone());
ctx.request_screenshot(move |image| {
let _ = tx.send((token, image));
ctx2.request_repaint();
});
false
} else {
true
}
});
if !self.queued.is_empty() || !self.pending.is_empty() {
ctx.request_repaint_after(std::time::Duration::from_millis(16));
}
}
/// Each frame: answer the requests whose frame came, saving it with `save` (the reply), and
/// those whose turn ended or that wait in vain. `others`: the app waits for frames of its own;
/// then every frame that came this frame is handed back, for the app to take its own from.
pub fn collect(&mut self, ctx: &egui::Context, now: f64, others: bool, save: impl Fn(&ColorImage, Option<&str>) -> Value) -> Vec<Shot> {
// A screenshot requested in one tab must never return or save a frame of another.
let turns = &mut self.turns;
let queued = &mut self.queued;
self.pending.retain(|(token, _, reply, _)| {
if chat_turn_cancelled(turns.get(token).map(Arc::as_ref)) {
turns.remove(token);
queued.retain(|(waiting, ..)| waiting != token);
let _ = reply.send(cancelled_chat_response());
false
} else {
true
}
});
// Drained every frame: a shot that comes after its request was cancelled or timed out
// is dropped here instead of waiting in the channel.
let shots: Vec<Shot> = self.rx.try_iter().collect();
if self.pending.is_empty() && !others {
return Vec::new();
}
for (token, image) in &shots {
if let Some(i) = self.pending.iter().position(|(t, ..)| t == token) {
let (_, path, reply, _) = self.pending.remove(i);
self.turns.remove(token);
let _ = reply.send(save(image, path.as_deref()));
}
}
// Hidden while waiting: no frame is coming.
let hidden = hidden(ctx);
let (turns, no_frame) = (&mut self.turns, &self.no_frame);
self.pending.retain(|(token, _, reply, deadline)| {
if now < *deadline && !hidden {
return true;
}
let _ = reply.send(json!({"ok": false, "error": no_frame}));
turns.remove(token);
false
});
shots
}
}
/// `image` in the format `path`'s extension names (a PNG for `.png`).
pub fn encode(image: &ColorImage, path: &str) -> Result<Vec<u8>, String> {
let [w, h] = image.size;
let mut buf = Vec::with_capacity(w * h * 4);
for c in &image.pixels {
buf.extend_from_slice(&c.to_array());
}
let img = image::RgbaImage::from_raw(w as u32, h as u32, buf).ok_or("bad image")?;
let format = image::ImageFormat::from_path(path).map_err(|e| e.to_string())?;
let mut bytes = std::io::Cursor::new(Vec::new());
img.write_to(&mut bytes, format).map_err(|e| e.to_string())?;
Ok(bytes.into_inner())
}
#[cfg(test)]
mod tests {
use super::*;
/// The app's reply for no frame.
const NO_FRAME: &str = "no frame here";
fn pixel_image() -> Arc<ColorImage> {
Arc::new(ColorImage::new([4, 3], vec![egui::Color32::from_rgb(10, 20, 30); 12]))
}
/// A request is answered with its frame once egui hands it back, saved as the app saves it;
/// a frame of the app's own is handed back to it; a request whose frame never comes is
/// answered after its deadline.
#[test]
fn a_request_is_answered_with_its_frame_or_its_deadline() {
let ctx = egui::Context::default();
let mut shots = Shots::new(NO_FRAME);
let (reply, answer) = channel();
shots.request(&ctx, Some("a.png".into()), reply, None, 0.0);
assert_eq!((shots.token, shots.queued.len(), shots.pending.len()), (1, 1, 1));
// The frame egui hands back, and one the app asked for itself.
shots.queued.clear();
let _ = shots.tx.send((1, pixel_image()));
let _ = shots.tx.send((2, pixel_image()));
let save = |image: &ColorImage, path: Option<&str>| json!({"ok": true, "result": {"path": path, "width": image.size[0]}});
let back = shots.collect(&ctx, 1.0, true, save);
assert_eq!(back.iter().map(|(t, _)| *t).collect::<Vec<_>>(), [1, 2]);
assert_eq!(answer.try_recv().ok(), Some(json!({"ok": true, "result": {"path": "a.png", "width": 4}})));
assert!(shots.pending.is_empty());
let (reply, late) = channel();
shots.request(&ctx, None, reply, None, 0.0);
assert!(shots.collect(&ctx, 7999.0, false, save).is_empty());
assert!(late.try_recv().is_err(), "still waiting");
shots.collect(&ctx, 8000.0, false, save);
assert_eq!(late.try_recv().ok(), Some(json!({"ok": false, "error": NO_FRAME})));
}
/// A request whose agent chat turn ended is answered so, and its frame is never taken.
#[test]
fn a_request_whose_turn_ended_is_refused() {
let ctx = egui::Context::default();
let mut shots = Shots::new(NO_FRAME);
let (reply, answer) = channel();
let turn = Arc::new(AtomicBool::new(true));
shots.request(&ctx, None, reply, Some(Arc::clone(&turn)), 0.0);
turn.store(false, std::sync::atomic::Ordering::Relaxed);
shots.collect(&ctx, 1.0, false, |_, _| Value::Null);
assert_eq!(answer.try_recv().ok(), Some(cancelled_chat_response()));
assert!(shots.queued.is_empty() && shots.pending.is_empty());
}
/// One frame of a window that is minimised (`hidden`) or shown, running `f`.
fn frame(ctx: &egui::Context, hidden: bool, f: impl FnMut(&egui::Context)) {
let mut raw = egui::RawInput::default();
let info = egui::ViewportInfo { minimized: Some(hidden), occluded: Some(false), ..Default::default() };
raw.viewports.insert(egui::ViewportId::ROOT, info);
// No painter takes the frame's textures here.
ctx.run_pass(raw, f).textures_delta.clear();
}
/// A frame is asked of egui 150 ms after its request at the earliest and on its third frame;
/// a view that keeps moving is taken as it is 500 ms after that.
#[test]
fn a_frame_is_taken_once_the_view_settles() {
let ctx = egui::Context::default();
let mut shots = Shots::new(NO_FRAME);
let (reply, _answer) = channel();
shots.request(&ctx, None, reply.clone(), None, 0.0);
shots.issue(&ctx, true, 200.0);
shots.issue(&ctx, true, 200.0);
assert_eq!(shots.queued.len(), 1, "two frames drawn");
shots.issue(&ctx, true, 200.0);
assert!(shots.queued.is_empty(), "the third frame is taken");
shots.request(&ctx, None, reply.clone(), None, 0.0);
for _ in 0..3 {
shots.issue(&ctx, true, 149.0);
}
assert_eq!(shots.queued.len(), 1, "too soon");
shots.issue(&ctx, true, 150.0);
assert!(shots.queued.is_empty());
shots.request(&ctx, None, reply, None, 0.0);
for _ in 0..3 {
shots.issue(&ctx, false, 649.0);
}
assert_eq!(shots.queued.len(), 1, "still moving");
shots.issue(&ctx, false, 650.0);
assert!(shots.queued.is_empty(), "taken as it is");
assert_eq!(shots.pending.len(), 3, "every request still waits for its frame");
}
/// A hidden window refuses a request at once, and answers one that waits when it hides.
#[test]
fn a_hidden_window_answers_at_once() {
let ctx = egui::Context::default();
let mut shots = Shots::new(NO_FRAME);
let (reply, answer) = channel();
frame(&ctx, true, |ctx| shots.request(ctx, None, reply.clone(), None, 0.0));
assert_eq!(answer.try_recv().ok(), Some(json!({"ok": false, "hidden": true, "error": NO_FRAME})));
assert_eq!((shots.token, shots.queued.len(), shots.pending.len()), (0, 0, 0));
frame(&ctx, false, |ctx| shots.request(ctx, None, reply.clone(), None, 0.0));
frame(&ctx, false, |ctx| {
shots.collect(ctx, 1.0, false, |_, _| Value::Null);
});
assert!(answer.try_recv().is_err(), "shown: still waiting");
frame(&ctx, true, |ctx| {
shots.collect(ctx, 2.0, false, |_, _| Value::Null);
});
assert_eq!(answer.try_recv().ok(), Some(json!({"ok": false, "error": NO_FRAME})));
assert!(shots.pending.is_empty());
}
/// With nothing waiting, the frames that came are dropped, not kept for a later request.
#[test]
fn frames_nobody_waits_for_are_dropped() {
let ctx = egui::Context::default();
let mut shots = Shots::new(NO_FRAME);
let _ = shots.tx.send((5, pixel_image()));
assert!(shots.collect(&ctx, 0.0, false, |_, _| Value::Null).is_empty());
assert!(shots.collect(&ctx, 1.0, true, |_, _| Value::Null).is_empty(), "the frame was dropped");
}
/// The saved bytes are the format the path names.
#[test]
fn a_png_path_gets_a_png() {
let bytes = encode(&pixel_image(), "shot.png").unwrap();
assert!(bytes.starts_with(b"\x89PNG"), "{:?}", bytes.get(..8));
assert!(encode(&pixel_image(), "shot.nope").is_err());
}
}
+19
View File
@@ -0,0 +1,19 @@
//! The window's own control requests over `egui::Context`, which any app answers the same way.
use serde_json::Value;
/// The window is minimised or fully covered (another macOS Space), so it presents no frames
/// (#1677). Unknown counts as shown: the screenshot deadline still ends a wait for a frame that
/// never comes.
pub fn hidden(ctx: &egui::Context) -> bool {
ctx.input(|i| i.viewport().visible()) == Some(false)
}
/// `ui.resize {width, height}`: the window's inner size in points, kept within 320×240 and
/// 8192×8192.
pub fn resize(ctx: &egui::Context, params: &Value) -> Result<Value, String> {
let f = |k: &str| params.get(k).and_then(Value::as_f64).filter(|x| x.is_finite());
let (Some(w), Some(h)) = (f("width"), f("height")) else { return Err("missing width/height".into()) };
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(w.clamp(320.0, 8192.0) as f32, h.clamp(240.0, 8192.0) as f32)));
Ok(Value::Null)
}
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "solidcraft-control"
description = "The control channel any app may share: token files, the loopback server, and its requests; no UI toolkit"
version.workspace = true
edition.workspace = true
license.workspace = true
[lints]
workspace = true
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
# The loopback server is for desktop apps; the web app is driven through the page instead.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
# The per-launch control token: 32 random bytes from the operating system (MIT OR Apache-2.0).
getrandom = "0.3"
+23
View File
@@ -0,0 +1,23 @@
# solidcraft-control
The control channel any app may share (docs/control-protocol.md). An app that listens for
control makes a random token per launch, writes it to a file only the user can read and refuses
every connection whose first request doesn't carry it; a client finds the token the same way.
Which app the files belong to, its environment, its name in messages and its default port come
from an `App` value the app defines. No app types and no engine.
## API
| Item | What it does |
|---|---|
| `auth::App` | An app's identity: `name`, `data_dir`, `env` (its prefixed variables), `utc`, `default_port` |
| `App::token_path(port)`, `App::current_port_path()` | `<data folder>/control/<port>.token` and the discovery file (`CONTROL_DIR` replaces the folder) |
| `App::write_token_file(path, token)` | Write a token readable only by the user, with its `<port>.pid` file beside it |
| `App::write_current_port`, `App::read_current_port`, `App::remove_current_port` | The discovery file of the most recently started app |
| `App::env_token()`, `App::client_token(port, explicit)` | The token a client sends: explicit, `CONTROL_TOKEN`, else the token file |
| `auth::ClientToken::explain_error(error)` | A refusal with the token file named and whether it is stale; never the token |
| `auth::plan`, `auth::Listen`, `auth::bind_with_fallback`, `auth::PORT_TRIES` | Where a launch listens; a loopback listener on the first free port |
| `auth::tokens_match`, `auth::remove_token_file`, `auth::TokenFile`, `auth::pid_path`, `auth::MIN_TOKEN_LEN` | Constant-time compare and token file housekeeping |
| `Effect` | What a request still running does when it finishes: reads only, writes a file, or changes; `when_done(document)` says it |
| `server::Hub`, `server::Protocol`, `server::Summary`, `server::Waits`, `server::new_token` | The loopback JSON-lines server behind the token (desktop only); the app says what its requests mean |
| `ControlRequest`, `ControlResponse`, `DocumentWatch`, `DOCUMENT_WATCH` | A request as the window gets it; the document id and revision subscribed connections wait on |
+665
View File
@@ -0,0 +1,665 @@
//! The control channel's per-launch token (docs/control-protocol.md). An app listening for
//! control makes a random token, writes it to a file only the user can read
//! ([`App::token_path`]) and refuses every connection whose first request doesn't carry it, so
//! other users on the same machine (loopback is shared, on Windows across remote-desktop sessions
//! too) can't drive the app. Clients find it with [`App::client_token`]. Next to it, `<port>.pid`
//! names the process that wrote it, so a client refused with a leftover token can say the file is
//! stale.
use std::ffi::OsString;
use std::io::{Error, ErrorKind, Read, Result, Write};
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
/// Shortest token accepted from the app's `CONTROL_TOKEN` variable (hex characters).
pub const MIN_TOKEN_LEN: usize = 32;
/// Ports tried from the app's default port upward when it is taken (a second window, another
/// program).
pub const PORT_TRIES: u16 = 16;
/// Which app a control channel belongs to: where its token files live, its environment and its
/// name in messages.
#[derive(Clone, Copy)]
pub struct App {
/// The app's name in messages ("restart SolidCraft").
pub name: &'static str,
/// The per-user data folder; the token files go in its `control` folder.
pub data_dir: fn() -> Option<PathBuf>,
/// The app's environment variable `name` (`CONTROL_DIR`, `CONTROL_TOKEN`), with the app's prefix.
pub env: fn(&str) -> Option<OsString>,
/// A time in seconds since the Unix epoch, as the app writes UTC times in messages.
pub utc: fn(u64) -> String,
/// The port the control channel tries first when the launch gives none. Below the ephemeral
/// ranges of Linux (32768+) and Windows/macOS (49152+), so the system never hands it to an
/// outgoing connection.
pub default_port: u16,
}
/// Where the control channel listens, from the launch's `--control`/environment port and the app's
/// allow-agents setting.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Listen {
/// Exactly this port (`--control PORT`, the app's `CONTROL_PORT`); no fallback.
Exact(u16),
/// The app's [`App::default_port`], or the next free one of [`PORT_TRIES`].
Default,
}
/// What a launch listens on. An explicit port wins over the app's allow-agents setting
/// (`enabled`), so a scripted launch works with the setting off; with the setting off and no port,
/// nothing listens.
pub fn plan(explicit: Option<u16>, enabled: bool) -> Option<Listen> {
match explicit {
Some(port) => Some(Listen::Exact(port)),
None if enabled => Some(Listen::Default),
None => None,
}
}
/// Bind a loopback-only listener on `first`, or on the next free port when it is taken, up to
/// `tries` ports in all.
pub fn bind_with_fallback(first: u16, tries: u16) -> Result<std::net::TcpListener> {
let mut last = Error::new(ErrorKind::AddrInUse, "no port to try");
for port in (first..=u16::MAX).take(usize::from(tries.max(1))) {
match std::net::TcpListener::bind(("127.0.0.1", port)) {
Ok(l) => return Ok(l),
// In use; Windows reports a port another program holds exclusively as denied.
Err(e) if matches!(e.kind(), ErrorKind::AddrInUse | ErrorKind::PermissionDenied) => last = e,
Err(e) => return Err(e),
}
}
Err(last)
}
impl App {
/// The folder of the token files: `<data folder>/control` (the app's `CONTROL_DIR` replaces it).
fn control_dir(&self) -> Option<PathBuf> {
match self.custom_control_dir() {
Some(d) => Some(PathBuf::from(d)),
None => Some((self.data_dir)()?.join("control")),
}
}
/// The folder the app's `CONTROL_DIR` names in place of the default one.
fn custom_control_dir(&self) -> Option<OsString> {
(self.env)("CONTROL_DIR").filter(|d| !d.is_empty())
}
/// The token file of the app listening on `port`: `<data folder>/control/<port>.token`
/// (the app's `CONTROL_DIR` replaces the folder).
pub fn token_path(&self, port: u16) -> Option<PathBuf> {
Some(self.control_dir()?.join(format!("{port}.token")))
}
/// The discovery file: the port of the most recently started app, so a client with no port
/// finds it. It holds only the port; the token stays in its own file.
pub fn current_port_path(&self) -> Option<PathBuf> {
Some(self.control_dir()?.join("current.port"))
}
/// Record `port` as the running app's, readable only by the user like the token files.
pub fn write_current_port(&self, port: u16) -> Result<PathBuf> {
let path = self.current_port_path().ok_or_else(|| Error::new(ErrorKind::NotFound, "no data folder"))?;
self.write_token_file(&path, &port.to_string())?;
Ok(path)
}
/// The port the discovery file names, if there is one.
pub fn read_current_port(&self) -> Option<u16> {
parse_port(&std::fs::read_to_string(self.current_port_path()?).ok().filter(|t| t.len() <= 64)?)
}
/// Remove the discovery file (and its process file) at exit, unless a later app has taken it
/// over: it names another port, or another launch now holds this port's token file. Under
/// Flatpak every instance has its own loopback, so two windows can listen on the same port
/// number and write the same file names; the one closing first must leave the other's (#8794).
/// Run it after [`remove_token_file`], which takes this launch's own token file away first.
pub fn remove_current_port(&self, port: u16, token: &str) {
if let (Some(current), Some(file)) = (self.current_port_path(), self.token_path(port)) {
remove_discovery(&current, &file, port, token);
}
}
/// A token set on purpose with the app's `CONTROL_TOKEN` (test harnesses, sandboxed packages
/// whose data folder the client can't see), if it is long enough.
pub fn env_token(&self) -> Option<String> {
(self.env)("CONTROL_TOKEN").and_then(|v| v.into_string().ok()).map(|t| t.trim().to_string()).filter(|t| t.len() >= MIN_TOKEN_LEN)
}
/// The token a client sends to the app on `port`: `explicit` (`--token`), else the app's
/// `CONTROL_TOKEN`, else the app's token file.
pub fn client_token(&self, port: u16, explicit: Option<&str>) -> Option<ClientToken> {
if let Some(t) = explicit.map(str::trim).filter(|t| !t.is_empty()) {
return Some(ClientToken { token: t.to_string(), file: None, app: *self });
}
if let Some(t) = self.env_token() {
return Some(ClientToken { token: t, file: None, app: *self });
}
// Read directly, not through any agent's file scope: the app's own file.
read_token_file(&self.token_path(port)?, *self)
}
/// Write `token` to `path`, readable and writable only by the user: the folder is made `0700`
/// and the file `0600` on Unix; on Windows the file's inherited permissions are replaced by one
/// entry for the user (`icacls`) before the token goes in; when that fails, the still empty file
/// is removed and no token is written. A file left by an earlier launch is replaced. The process
/// file beside it ([`pid_path`]) records this process, best effort.
pub fn write_token_file(&self, path: &Path, token: &str) -> Result<()> {
#[cfg(windows)]
let restrict = |p: &Path| restrict_to_user(p, self.custom_control_dir().is_some());
#[cfg(not(windows))]
let restrict = |_: &Path| Ok(());
write_token_file_with(path, token, restrict)
}
}
fn parse_port(text: &str) -> Option<u16> {
text.trim().parse::<u16>().ok().filter(|p| *p != 0)
}
/// [`App::remove_current_port`] on given files: `current` is the discovery file, `file` the token
/// file of `port`.
fn remove_discovery(current: &Path, file: &Path, port: u16, token: &str) {
let named = std::fs::read_to_string(current).ok().filter(|t| t.len() <= 64).and_then(|t| parse_port(&t));
if named != Some(port) || held_by_another(file, token) {
return;
}
let _ = std::fs::remove_file(current);
let _ = std::fs::remove_file(pid_path(current));
}
/// A client token and, when it came from a token file, where that file is.
pub struct ClientToken {
pub token: String,
pub file: Option<TokenSource>,
/// The app it is for, named in an explanation.
pub app: App,
}
/// The token file a client read: its path, when it was written and the process that wrote it.
pub struct TokenSource {
pub path: PathBuf,
pub modified: Option<SystemTime>,
/// From `<port>.pid`: the writer's process id and, on Linux, its start time.
pub writer: Option<(u32, Option<u64>)>,
}
impl ClientToken {
/// The app's `error`, and for a refused token read from a file, which file it was, when it
/// was written and whether the instance that wrote it has exited. Never the token itself.
pub fn explain_error(&self, error: &str) -> String {
let Some(src) = &self.file else { return error.to_string() };
if !error.starts_with("unauthorized") {
return error.to_string();
}
let written = src
.modified
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|t| (self.app.utc)(t.as_secs()))
.unwrap_or_else(|| "at an unknown time".into());
let age = src.modified.and_then(|t| t.elapsed().ok()).map(|d| format!(" ({} s ago)", d.as_secs())).unwrap_or_default();
let path = src.path.display();
let name = self.app.name;
match src.writer.filter(|(pid, start)| writer_exited(*pid, *start)) {
Some((pid, _)) => format!(
"{error}; stale token file from an instance that exited (process {pid}): {path}, written {written}{age}; delete that file or restart {name}"
),
None => format!("{error}; token from {path}, written {written}{age}; if {name} was force-quit, restart it or delete that file"),
}
}
}
/// Files bigger than this are not token files.
const MAX_TOKEN_FILE: u64 = 4096;
fn read_token_file(path: &Path, app: App) -> Option<ClientToken> {
let f = std::fs::File::open(path).ok()?;
let modified = f.metadata().ok().and_then(|m| m.modified().ok());
let mut text = String::new();
f.take(MAX_TOKEN_FILE + 1).read_to_string(&mut text).ok()?;
if text.len() as u64 > MAX_TOKEN_FILE {
return None;
}
let token = text.trim();
if token.is_empty() {
return None;
}
let path = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
let writer = read_pid_file(&pid_path(&path));
Some(ClientToken { token: token.to_string(), file: Some(TokenSource { path, modified, writer }), app })
}
/// The process file beside the token file `path`: `7878.token` → `7878.pid`.
pub fn pid_path(path: &Path) -> PathBuf {
path.with_extension("pid")
}
/// `<pid> [<start time>]`; anything else (or no file) is no information.
fn read_pid_file(path: &Path) -> Option<(u32, Option<u64>)> {
let mut text = String::new();
std::fs::File::open(path).ok()?.take(256).read_to_string(&mut text).ok()?;
let mut fields = text.split_whitespace();
let pid = fields.next()?.parse::<u32>().ok().filter(|&pid| pid > 0)?;
Some((pid, fields.next().and_then(|s| s.parse().ok())))
}
/// Whether the process that wrote a token file is known to be gone: not running, or its id now
/// belongs to a process that started at another time. Unknown counts as running.
fn writer_exited(pid: u32, start: Option<u64>) -> bool {
process_running(pid) == Some(false) || start.zip(process_start(pid)).is_some_and(|(saved, now)| saved != now)
}
/// Whether process `pid` runs; `None` when that can't be found out.
fn process_running(pid: u32) -> Option<bool> {
#[cfg(target_os = "linux")]
{
if !Path::new("/proc/self").exists() {
return None;
}
match std::fs::metadata(format!("/proc/{pid}")) {
Ok(_) => Some(true),
Err(e) if e.kind() == ErrorKind::NotFound => Some(false),
Err(_) => None,
}
}
#[cfg(all(unix, not(target_os = "linux")))]
{
let output = std::process::Command::new("ps").args(["-p", &pid.to_string(), "-o", "pid="]).output().ok()?;
if output.status.success() {
return Some(true);
}
(output.status.code() == Some(1) && output.stdout.iter().all(u8::is_ascii_whitespace)).then_some(false)
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
let output = std::process::Command::new("tasklist")
.args(["/FI", &format!("PID eq {pid}"), "/FO", "CSV", "/NH"])
.creation_flags(0x0800_0000)
.output()
.ok()?;
if !output.status.success() || output.stdout.is_empty() {
return None;
}
// `"image","pid",…` per process; with no match, a message line without a pid.
let text = String::from_utf8_lossy(&output.stdout);
Some(text.lines().any(|line| line.split("\",\"").nth(1).and_then(|s| s.trim_matches('"').parse::<u32>().ok()) == Some(pid)))
}
#[cfg(not(any(unix, windows)))]
{
let _ = pid;
None
}
}
/// Linux: the process's start time (clock ticks after boot), which tells a reused id apart.
fn process_start(pid: u32) -> Option<u64> {
#[cfg(target_os = "linux")]
{
let mut stat = String::new();
std::fs::File::open(format!("/proc/{pid}/stat")).ok()?.take(4096).read_to_string(&mut stat).ok()?;
stat.rsplit_once(')')?.1.split_whitespace().nth(19)?.parse().ok()
}
#[cfg(not(target_os = "linux"))]
{
let _ = pid;
None
}
}
/// `a == b`, taking the same time wherever they differ.
pub fn tokens_match(a: &str, b: &str) -> bool {
let (a, b) = (a.as_bytes(), b.as_bytes());
a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}
/// [`App::write_token_file`], with `restrict` limiting the new file to the user (Windows).
fn write_token_file_with(path: &Path, token: &str, restrict: impl FnOnce(&Path) -> Result<()>) -> Result<()> {
let dir = path.parent().ok_or_else(|| Error::new(ErrorKind::InvalidInput, "no folder"))?;
std::fs::create_dir_all(dir)?;
let _ = std::fs::remove_file(path);
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
opts.mode(0o600);
}
let opened = opts.open(path)?;
// On a refusal the file goes with the closure, so it is closed before it is removed.
let mut f = match restrict(path).map(|()| opened) {
Ok(f) => f,
Err(e) => {
let _ = std::fs::remove_file(path);
return Err(e);
}
};
f.write_all(token.as_bytes())?;
f.write_all(b"\n")?;
f.sync_all()?;
let pid = std::process::id();
let line = process_start(pid).map_or_else(|| format!("{pid}\n"), |start| format!("{pid} {start}\n"));
let _ = std::fs::write(pid_path(path), line);
Ok(())
}
/// Remove a token file this launch wrote, and its process file, while it still holds `token`.
/// Another launch may have written the same file since (a second window on the same port,
/// #8794); its file stays. The process id can't tell the two apart, as every Flatpak instance
/// has the same one, but each launch's token is its own.
pub fn remove_token_file(path: &Path, token: &str) {
if read_token(path).is_some_and(|held| tokens_match(&held, token.trim())) {
let _ = std::fs::remove_file(path);
let _ = std::fs::remove_file(pid_path(path));
}
}
/// The token a token file holds, trimmed; `None` when there is no readable token file.
fn read_token(path: &Path) -> Option<String> {
let mut text = String::new();
std::fs::File::open(path).ok()?.take(MAX_TOKEN_FILE + 1).read_to_string(&mut text).ok()?;
let token = text.trim();
(text.len() as u64 <= MAX_TOKEN_FILE && !token.is_empty()).then(|| token.to_string())
}
/// Whether the token file at `path` holds a token other than `token`: another launch's.
fn held_by_another(path: &Path, token: &str) -> bool {
read_token(path).is_some_and(|held| !tokens_match(&held, token.trim()))
}
/// How long `icacls` may take (#6145). It finishes at once; on some computers the new process
/// is left suspended (security software holding it), and waiting on it without a limit froze
/// the app.
#[cfg(windows)]
const ICACLS_LIMIT: std::time::Duration = std::time::Duration::from_secs(5);
/// How a run of `icacls` ended.
#[cfg(any(windows, test))]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum IcaclsEnd {
Succeeded,
Failed,
/// It didn't finish within [`ICACLS_LIMIT`] and was ended.
TimedOut,
}
/// Whether a token may go into a file after `icacls` ended this way. `custom_folder`: the file is
/// in the folder the app's `CONTROL_DIR` names. A timeout is accepted only in the default folder,
/// the user's own local app data, which other users can't read by default; nothing is known about
/// the permissions of any other folder.
#[cfg(any(windows, test))]
fn restriction_result(end: IcaclsEnd, custom_folder: bool, path: &Path) -> Result<()> {
match end {
IcaclsEnd::Succeeded => Ok(()),
IcaclsEnd::TimedOut if !custom_folder => Ok(()),
IcaclsEnd::TimedOut => Err(Error::new(ErrorKind::TimedOut, format!("icacls did not finish in time to restrict {}", path.display()))),
IcaclsEnd::Failed => Err(Error::other(format!("icacls failed to restrict {}", path.display()))),
}
}
/// Windows: drop inherited entries and grant only the current user full access. An `icacls`
/// that doesn't finish in time is ended, and [`restriction_result`] decides what that means.
#[cfg(windows)]
fn restrict_to_user(path: &Path, custom_folder: bool) -> Result<()> {
use std::os::windows::process::CommandExt;
let user = std::env::var("USERNAME").map_err(|_| Error::other("USERNAME is not set"))?;
let domain = std::env::var("USERDOMAIN").ok().filter(|d| !d.is_empty());
let who = domain.map_or(user.clone(), |d| format!("{d}\\{user}"));
let child = std::process::Command::new("icacls")
.arg(path)
.args(["/inheritance:r", "/grant:r", &format!("{who}:F")])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.creation_flags(0x0800_0000)
.spawn()?;
let end = match wait_within(child, ICACLS_LIMIT)? {
Some(status) if status.success() => IcaclsEnd::Succeeded,
Some(_) => IcaclsEnd::Failed,
None => IcaclsEnd::TimedOut,
};
restriction_result(end, custom_folder, path)
}
/// Wait for `child` at most `limit`: its exit status, or `None` (and the child ended) when it
/// hasn't finished by then.
#[cfg(any(windows, test))]
fn wait_within(mut child: std::process::Child, limit: std::time::Duration) -> Result<Option<std::process::ExitStatus>> {
let start = std::time::Instant::now();
loop {
if let Some(status) = child.try_wait()? {
return Ok(Some(status));
}
if start.elapsed() >= limit {
// Not waited for afterwards: a suspended process may not end at once.
let _ = child.kill();
return Ok(None);
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
}
/// Removes the token file (path, token) when the app ends, while it still holds that token.
pub struct TokenFile(pub PathBuf, pub String);
impl Drop for TokenFile {
fn drop(&mut self) {
remove_token_file(&self.0, &self.1);
}
}
#[cfg(test)]
mod tests {
use super::*;
/// An app with no data folder and no environment, whose times read `t<seconds>`.
const TEST: App = App { name: "TestCraft", data_dir: || None, env: |_| None, utc: |secs| format!("t{secs}"), default_port: 27878 };
/// #6145: a child that doesn't finish in time is given up on, not waited for forever.
#[cfg(unix)]
#[test]
fn a_slow_child_is_waited_for_only_so_long() {
use std::time::{Duration, Instant};
let start = Instant::now();
let slow = std::process::Command::new("sleep").arg("30").spawn().unwrap();
assert!(wait_within(slow, Duration::from_millis(200)).unwrap().is_none());
assert!(start.elapsed() < Duration::from_secs(5), "{:?}", start.elapsed());
let quick = std::process::Command::new("true").spawn().unwrap();
assert!(wait_within(quick, Duration::from_secs(5)).unwrap().is_some_and(|s| s.success()));
let failing = std::process::Command::new("false").spawn().unwrap();
assert!(wait_within(failing, Duration::from_secs(5)).unwrap().is_some_and(|s| !s.success()));
}
/// #6145's freeze fix stays in the default folder, the user's own; a folder picked with
/// the app's `CONTROL_DIR` gets no token unless the file was really restricted.
#[test]
fn only_the_default_folder_accepts_an_icacls_that_timed_out() {
let path = Path::new("control/7878.token");
for custom in [false, true] {
assert!(restriction_result(IcaclsEnd::Succeeded, custom, path).is_ok());
assert!(restriction_result(IcaclsEnd::Failed, custom, path).is_err());
}
assert!(restriction_result(IcaclsEnd::TimedOut, false, path).is_ok());
let e = restriction_result(IcaclsEnd::TimedOut, true, path).unwrap_err();
assert_eq!(e.kind(), ErrorKind::TimedOut);
assert!(e.to_string().contains("7878.token"), "{e}");
}
/// A file that couldn't be restricted is removed, empty and with no process file beside it, and
/// the error goes to the caller. A restricted one gets its token.
#[test]
fn a_token_file_that_could_not_be_restricted_is_removed() {
let dir = std::env::temp_dir().join(format!("solidcraft-control-restrict-{}", std::process::id()));
let path = dir.join("7879.token");
let e = write_token_file_with(&path, "secret-token", |p| {
assert_eq!(std::fs::read(p).unwrap(), b"", "restricted before the token goes in");
restriction_result(IcaclsEnd::TimedOut, true, p)
})
.unwrap_err();
assert_eq!(e.kind(), ErrorKind::TimedOut);
assert!(!path.exists() && !pid_path(&path).exists());
write_token_file_with(&path, "secret-token", |p| restriction_result(IcaclsEnd::TimedOut, false, p)).unwrap();
assert_eq!(read_token(&path).as_deref(), Some("secret-token"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn tokens_compare_whole() {
assert!(tokens_match("abc123", "abc123"));
assert!(!tokens_match("abc123", "abc124"));
assert!(!tokens_match("abc123", "abc12"));
assert!(!tokens_match("", "a"));
}
/// The preference starts the listener by default, off it doesn't, and a port given on the
/// command line or in the environment wins over it either way.
#[test]
fn the_preference_decides_unless_a_port_is_given() {
assert_eq!(plan(None, true), Some(Listen::Default));
assert_eq!(plan(None, false), None);
assert_eq!(plan(Some(7878), false), Some(Listen::Exact(7878)));
assert_eq!(plan(Some(7878), true), Some(Listen::Exact(7878)));
}
/// A taken port falls through to the next free one, on loopback only; a run of taken ports
/// is an error, not a hang.
#[test]
fn a_taken_port_falls_back_to_the_next_free_one() {
let first = bind_with_fallback(TEST.default_port, PORT_TRIES).unwrap();
let a = first.local_addr().unwrap();
assert!(a.ip().is_loopback() && (TEST.default_port..TEST.default_port + PORT_TRIES).contains(&a.port()), "{a}");
let second = bind_with_fallback(a.port(), PORT_TRIES).unwrap();
assert!(second.local_addr().unwrap().port() > a.port());
assert!(bind_with_fallback(a.port(), 1).is_err());
}
#[test]
fn the_discovery_file_holds_a_port() {
assert_eq!(parse_port("27879\n"), Some(27879));
assert_eq!(parse_port("0"), None);
assert_eq!(parse_port("70000"), None);
assert_eq!(parse_port("nope"), None);
let dir = std::env::temp_dir().join(format!("solidcraft-current-port-{}", std::process::id()));
let path = dir.join("current.port");
TEST.write_token_file(&path, "27881").unwrap();
assert_eq!(parse_port(&std::fs::read_to_string(&path).unwrap()), Some(27881));
let _ = std::fs::remove_dir_all(&dir);
}
/// A one-line token file (and one with no process file) still reads.
#[test]
fn a_token_file_reads_with_and_without_its_process_file() {
let dir = std::env::temp_dir().join(format!("solidcraft-control-read-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("7879.token");
std::fs::write(&path, "legacy-token\n").unwrap();
let client = read_token_file(&path, TEST).unwrap();
assert_eq!(client.token, "legacy-token");
let src = client.file.as_ref().unwrap();
assert_eq!(src.path, std::fs::canonicalize(&path).unwrap());
assert!(src.modified.is_some() && src.writer.is_none());
std::fs::write(pid_path(&path), "not a pid\n").unwrap();
assert!(read_token_file(&path, TEST).unwrap().file.unwrap().writer.is_none());
std::fs::write(&path, "x".repeat(4097)).unwrap();
assert!(read_token_file(&path, TEST).is_none());
let _ = std::fs::remove_dir_all(&dir);
}
/// A refused token's explanation names the app and writes the time as the app does.
#[test]
fn an_explanation_names_the_app() {
let file = Some(TokenSource { path: PathBuf::from("7878.token"), modified: Some(UNIX_EPOCH), writer: None });
let client = ClientToken { token: "secret-token".into(), file, app: TEST };
let error = client.explain_error("unauthorized");
assert!(error.contains("written t0 (") && error.contains("if TestCraft was force-quit"), "{error}");
assert!(!error.contains("secret-token"), "{error}");
let explicit = TEST.client_token(0, Some("explicit-token")).unwrap();
assert!(explicit.token == "explicit-token" && explicit.file.is_none());
assert_eq!(explicit.explain_error("unauthorized"), "unauthorized");
}
/// A process file naming a process that has exited marks the token file stale.
#[cfg(any(unix, windows))]
#[test]
fn a_token_file_from_an_exited_process_is_stale() {
let mut child = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", "auth::tests::tokens_compare_whole"])
.stdout(std::process::Stdio::null())
.spawn()
.unwrap();
let pid = child.id();
assert!(child.wait().unwrap().success());
let source = |writer| Some(TokenSource { path: PathBuf::from("7878.token"), modified: None, writer });
let stale = ClientToken { token: "secret-token".into(), file: source(Some((pid, None))), app: TEST };
let error = stale.explain_error("unauthorized");
assert!(error.contains(&format!("stale token file from an instance that exited (process {pid}): 7878.token")), "{error}");
assert!(!error.contains("secret-token"));
let live = ClientToken { token: "t".into(), file: source(Some((std::process::id(), process_start(std::process::id())))), app: TEST };
assert!(!live.explain_error("unauthorized").contains("stale"));
#[cfg(target_os = "linux")]
{
let reused = process_start(std::process::id()).map(|s| s + 1);
let reused = ClientToken { token: "t".into(), file: source(Some((std::process::id(), reused))), app: TEST };
assert!(reused.explain_error("unauthorized").contains("stale token file"));
}
}
/// The token file is the user's alone, and a client reads it back.
#[cfg(unix)]
#[test]
fn the_token_file_is_private_to_the_user() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("solidcraft-control-auth-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let path = dir.join("control/7878.token");
std::fs::create_dir_all(dir.join("control")).unwrap();
std::fs::write(&path, "stale").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
TEST.write_token_file(&path, "0123456789abcdef0123456789abcdef").unwrap();
let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
assert_eq!(mode(&path), 0o600);
assert_eq!(mode(path.parent().unwrap()), 0o700);
assert_eq!(std::fs::read_to_string(&path).unwrap().trim(), "0123456789abcdef0123456789abcdef");
let writer = read_token_file(&path, TEST).unwrap().file.unwrap().writer;
assert_eq!(writer.map(|(pid, _)| pid), Some(std::process::id()));
drop(TokenFile(path.clone(), "0123456789abcdef0123456789abcdef".into()));
assert!(!path.exists() && !pid_path(&path).exists());
let _ = std::fs::remove_dir_all(&dir);
}
/// #8794: under Flatpak two windows can listen on the same port number (each has its own
/// loopback) and write the same files. The one closing first leaves the other's token, process
/// and discovery files; the last one takes them all away, `current.pid` too.
#[test]
fn a_closing_launch_leaves_another_launchs_files() {
let dir = std::env::temp_dir().join(format!("solidcraft-control-two-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let (file, current) = (dir.join("27878.token"), dir.join("current.port"));
let (first, second) = ("a".repeat(64), "b".repeat(64));
// The first window, then the second on the same port, each writing both files.
for token in [&first, &second] {
TEST.write_token_file(&file, token).unwrap();
TEST.write_token_file(&current, "27878").unwrap();
}
// The first closes: the second's files stay.
remove_token_file(&file, &first);
remove_discovery(&current, &file, 27878, &first);
assert_eq!(read_token(&file).as_deref(), Some(second.as_str()));
assert!(pid_path(&file).exists() && current.exists() && pid_path(&current).exists());
// Another port named in the discovery file is another app's too.
remove_discovery(&current, &file, 27879, &second);
assert!(current.exists());
// The second closes: nothing of either is left.
remove_token_file(&file, &format!(" {second}\n"));
remove_discovery(&current, &file, 27878, &second);
assert!(!file.exists() && !pid_path(&file).exists() && !current.exists() && !pid_path(&current).exists());
// With no token file left (removed by hand, or never written), the discovery file of the
// port still goes; a missing file is no error.
TEST.write_token_file(&current, "27878").unwrap();
remove_token_file(&file, &second);
remove_discovery(&current, &file, 27878, &second);
assert!(!current.exists());
let _ = std::fs::remove_dir_all(&dir);
}
}
+27
View File
@@ -0,0 +1,27 @@
//! What a client is told about a control request that outlasts its wait (docs/control-protocol.md,
//! #1618): the app's control server and a bridged client both say what the request still does when
//! it finishes. Which requests do what is the app's to say.
/// What a request still running does when it finishes.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Effect {
/// It only reads (the document, the window or the command list): nothing changes.
ReadsOnly,
/// It writes a file (an export, a save, a render to a path); the document stays as it is.
WritesFile,
/// It changes the document (or may: anything not known to read or only write a file).
Changes,
}
impl Effect {
/// What happens when the request finishes, for a message: "it only reads: nothing changes",
/// "it was not cancelled and will still write the file when it finishes; the design does not
/// change", … `document` is what the app calls its document ("design").
pub fn when_done(self, document: &str) -> String {
match self {
Effect::ReadsOnly => "it only reads: nothing changes".into(),
Effect::WritesFile => format!("it was not cancelled and will still write the file when it finishes; the {document} does not change"),
Effect::Changes => "it was not cancelled and will still apply when it finishes".into(),
}
}
}
+17
View File
@@ -0,0 +1,17 @@
//! The control channel any app may share (docs/control-protocol.md): the per-launch token and its
//! files ([`auth`]), the loopback server that admits a connection by the token and hands each
//! request to the window ([`server`]), the request the window answers ([`ControlRequest`]), the
//! document watch subscribed connections wait on ([`DocumentWatch`]), and what a request still running
//! does when it finishes ([`Effect`]). It needs no UI toolkit; the window's own requests over egui
//! (`ui.screenshot`, `ui.resize`) are `solidcraft-control-egui`.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
pub mod auth;
mod effect;
mod request;
#[cfg(not(target_arch = "wasm32"))]
pub mod server;
pub use effect::Effect;
pub use request::{ControlRequest, ControlResponse, DOCUMENT_WATCH, DocumentWatch, Subscriber, cancelled_chat_response, chat_turn_cancelled};
+118
View File
@@ -0,0 +1,118 @@
//! A control request as the window gets it, and the design watch subscribed connections wait on.
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::mpsc::Sender;
use std::sync::{Condvar, Mutex, MutexGuard, PoisonError};
use std::time::Duration;
use serde_json::{Value, json};
pub type ControlResponse = Value;
pub struct ControlRequest {
pub method: String,
pub params: Value,
pub reply: Sender<ControlResponse>,
/// Set once the window starts on it; until then it waits its turn (`control.running`, #3654).
pub started: std::sync::Arc<std::sync::atomic::AtomicBool>,
/// Chat requests already admitted by the gate may outlive a tab switch. This turn's
/// validity follows them through queues, computation and cursor animation.
pub chat_turn: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
}
impl ControlRequest {
pub fn new(method: impl Into<String>, params: Value) -> (Self, std::sync::mpsc::Receiver<ControlResponse>) {
let (tx, rx) = std::sync::mpsc::channel();
(Self { method: method.into(), params, reply: tx, started: Default::default(), chat_turn: None }, rx)
}
/// Answer a request whose agent chat turn has ended, and say so: such a request is not run.
pub fn refuse_cancelled_chat(&self) -> bool {
if chat_turn_cancelled(self.chat_turn.as_deref()) {
let _ = self.reply.send(cancelled_chat_response());
true
} else {
false
}
}
}
/// Whether the agent chat turn a request belongs to has ended (`None`: it belongs to none).
pub fn chat_turn_cancelled(turn: Option<&std::sync::atomic::AtomicBool>) -> bool {
turn.is_some_and(|active| !active.load(std::sync::atomic::Ordering::Relaxed))
}
/// The reply to a request whose agent chat turn has ended.
pub fn cancelled_chat_response() -> Value {
json!({"ok": false, "error": "not run: the agent chat turn ended or its document tab was left"})
}
/// The active design's id and revision for control connections that sent `control.subscribe`
/// (#6985): the window publishes them each frame while one is subscribed, and each such
/// connection waits for them to move, then sends the app's change event (`Protocol::change_event`).
#[derive(Default)]
pub struct DocumentWatch {
subscribers: AtomicUsize,
now: Mutex<Option<(String, u64)>>,
moved: Condvar,
}
/// The app's [`DocumentWatch`].
pub static DOCUMENT_WATCH: DocumentWatch = DocumentWatch::new();
impl DocumentWatch {
pub const fn new() -> Self {
DocumentWatch { subscribers: AtomicUsize::new(0), now: Mutex::new(None), moved: Condvar::new() }
}
fn now(&self) -> MutexGuard<'_, Option<(String, u64)>> {
self.now.lock().unwrap_or_else(PoisonError::into_inner)
}
/// The design as last published (`None` before anything was).
pub fn current(&self) -> Option<(String, u64)> {
self.now().clone()
}
/// The design as it is now; waiters wake only when it moved. Only the latest is kept, so
/// changes made faster than a connection sends them go out as one.
pub fn publish(&self, document_id: &str, revision: u64) {
let mut now = self.now();
if now.as_ref().is_some_and(|(id, r)| id == document_id && *r == revision) {
return;
}
*now = Some((document_id.to_string(), revision));
self.moved.notify_all();
}
/// Whether a connection is subscribed: the window publishes only then.
pub fn subscribed(&self) -> bool {
self.subscribers.load(Ordering::Relaxed) > 0
}
/// Count a subscribed connection until the returned guard is dropped.
pub fn join(&'static self) -> Subscriber {
self.subscribers.fetch_add(1, Ordering::Relaxed);
Subscriber(self)
}
/// Wait up to `wait` for the design to differ from `seen`, or for `stop`; the design then
/// (`None` before anything was published).
pub fn wait(&self, seen: &(String, u64), stop: &AtomicBool, wait: Duration) -> Option<(String, u64)> {
let unmoved = |now: &mut Option<(String, u64)>| now.as_ref() == Some(seen) && !stop.load(Ordering::Relaxed);
let (now, _) = self.moved.wait_timeout_while(self.now(), wait, unmoved).unwrap_or_else(PoisonError::into_inner);
now.clone()
}
}
/// A subscribed connection, counted by its [`DocumentWatch`] while this lives.
pub struct Subscriber(&'static DocumentWatch);
impl Drop for Subscriber {
fn drop(&mut self) {
self.0.subscribers.fetch_sub(1, Ordering::Relaxed);
// Under the lock, so a connection about to wait sees its `stop` set before it does.
let _now = self.0.now();
self.0.moved.notify_all();
}
}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -10,6 +10,7 @@ workspace = true
[dependencies]
solidcore-record = { workspace = true }
solidcraft-color = { workspace = true }
solidcraft-geom = { workspace = true }
solidcraft-sketch = { workspace = true }
solidcraft-kernel = { workspace = true }
+1 -1
View File
@@ -154,7 +154,7 @@ impl VisualPreset {
match self.recipe {
Recipe::Opaque(r) => r.color,
// A UI swatch of the transmission values, not rendered glass or absorption.
Recipe::Glass(r) => r.transmission_linear_rgb.map(solidcraft_geom::srgb::encode8),
Recipe::Glass(r) => r.transmission_linear_rgb.map(solidcraft_color::srgb::encode8),
}
}
+27 -3
View File
@@ -3,7 +3,7 @@
use std::collections::BTreeMap;
use crate::persistent_name::{Base, FaceName, FeatureId};
use crate::persistent_name::{Base, EdgeName, FaceName, FeatureId};
use crate::{FeatureKind, PlaneRef};
/// Remap persistent topology tokens without rewriting arbitrary user expressions or labels.
@@ -18,9 +18,20 @@ pub fn remap_topology(name: &str, ids: &BTreeMap<u64, u64>, bodies: &BTreeMap<St
}
// Imported names belong to the complete canonical body prefix, even when that prefix
// happens to contain text resembling a feature token.
if let Some((body, suffix)) = name.rsplit_once(":face:")
&& let Some(new) = bodies.get(body)
let body_face = name.rsplit_once(":face:").and_then(|(body, suffix)| Some((bodies.get(body)?, body, suffix)));
// An edge name (`A|B`) is remapped one face at a time, so a body face on one side doesn't
// stop the other side's feature or body from being remapped (#13097). A body whose own
// name holds a `|` is a single face, not an edge.
if !body_face.is_some_and(|(_, body, _)| body.contains('|'))
&& let Ok(edge) = EdgeName::parse(name)
&& edge.faces.len() > 1
&& edge.to_string() == name
{
let faces: Vec<String> = edge.faces.iter().map(|face| remap_topology(&face.to_string(), ids, bodies)).collect();
let tie = edge.tie.map(|k| format!("~{k}")).unwrap_or_default();
return format!("{}{tie}", faces.join("|"));
}
if let Some((new, _, suffix)) = body_face {
return format!("{new}:face:{suffix}");
}
// A name that reads is remapped by its structure: every feature it names, and a body
@@ -410,6 +421,19 @@ mod clone_tests {
assert_eq!(remap_topology("UserF1:label", &ids, &bodies), "UserF1:label");
}
#[test]
fn an_edge_name_with_a_body_face_maps_every_face() {
// #13097: a body face on either side of an edge must not stop the other side's
// feature or body from being remapped.
let ids: BTreeMap<u64, u64> = [(2, 20)].into_iter().collect();
let bodies: BTreeMap<String, String> =
[("Plate".to_string(), "Plate (copy)".to_string()), ("A|B".to_string(), "A|B (copy)".to_string())].into_iter().collect();
assert_eq!(remap_topology("Plate:face:3|F2:top", &ids, &bodies), "Plate (copy):face:3|F20:top");
assert_eq!(remap_topology("F2:top|Plate:face:3", &ids, &bodies), "F20:top|Plate (copy):face:3");
assert_eq!(remap_topology("Plate:face:3|F2:top~1", &ids, &bodies), "Plate (copy):face:3|F20:top~1");
// A body whose name holds a `|` is still one face.
assert_eq!(remap_topology("A|B:face:0", &ids, &bodies), "A|B (copy):face:0");
}
#[test]
fn a_sheet_face_follows_its_body_and_its_flange() {
let ids = BTreeMap::from([(1, 2), (10, 20)]);
let bodies = BTreeMap::from([("Body1".into(), "Body1 (copy)".into())]);
+62 -1
View File
@@ -196,6 +196,45 @@ fn edge_pick<'a>(st: &'a ModelState, at: Vec3, name: &Option<String>) -> Result<
}
}
/// The closest point on a polyline to `target`, projecting onto each segment rather than
/// choosing only its vertices. This keeps an edge pick on the edge between tessellation points.
fn closest_point_on_polyline(pts: &[Vec3], target: Vec3) -> Option<Vec3> {
let mut best: Option<(f64, Vec3)> = None;
for w in pts.windows(2) {
let (a, b) = (w[0], w[1]);
let ab = b - a;
let len_sq = ab.dot(ab);
let q = if len_sq < 1e-18 {
a
} else {
let t = ((target - a).dot(ab) / len_sq).clamp(0.0, 1.0);
a + ab * t
};
let d = target.dist(q);
if best.as_ref().is_none_or(|(bd, _)| d < *bd) {
best = Some((d, q));
}
}
best.map(|(_, q)| q)
}
/// The point on an edge nearest a pick location, projecting onto exact edge geometry
/// (such as `kernel::edge_circle`) when available, else onto its drawn polyline segments.
fn edge_point_at_pick(st: &ModelState, pick: Vec3, name: &Option<String>) -> Option<Vec3> {
let (b, i, pts) = edge_pick(st, pick, name).ok()?;
if let Some(c) = kernel::edge_circle(&b.body, i) {
let v = pick - c.center;
let v_perp = v - *c.axis * v.dot(*c.axis);
if let Some(dir) = v_perp.normalized() {
return Some(c.center + dir * c.radius);
}
}
// Until the kernel can project onto any edge geometry (circles are exact above via
// kernel::edge_circle), the fallback projects onto the drawn polyline. This is exact for
// straight edges and approximates splines within mesh display tolerance.
closest_point_on_polyline(&pts, pick)
}
/// The circle a picked edge lies on, from its exact curve (`kernel::edge_circle`) when the drawn
/// edge lies on it, else fitted to the drawn points: (centre, unit normal).
fn edge_circle(b: &ModelBody, index: usize, pts: &[Vec3]) -> Option<(Vec3, Vec3)> {
@@ -743,7 +782,29 @@ pub(crate) fn resolve_geo_plane(ctx: &Ctx, p: &PlaneRef) -> Result<Plane> {
PlaneRef::AxisPoint { axis, point: at } => {
let (origin, dir) = line(ctx, axis)?;
let dir = dir.normalized().ok_or_else(|| bad("the axis has no direction"))?;
let delta = point(ctx, at)? - origin;
let p = match point(ctx, at) {
Ok(p) => {
let delta = p - origin;
let across = delta - dir * delta.dot(dir);
if across.len().is_finite() && across.len() >= 1e-9 {
p
} else if let GeoRef::Edge { at: pick, name } = at.as_ref() {
// When the centre of a circular edge lies on the axis, resolve to the picked point on the edge.
edge_point_at_pick(ctx.st, *pick, name).unwrap_or(p)
} else {
p
}
}
Err(e) => {
// Non-circular edges are not converted to centres by `point`; resolve to the picked edge point.
if let GeoRef::Edge { at: pick, name } = at.as_ref() {
edge_point_at_pick(ctx.st, *pick, name).ok_or(e)?
} else {
return Err(e);
}
}
};
let delta = p - origin;
let across = delta - dir * delta.dot(dir);
if !across.len().is_finite() || across.len() < 1e-9 {
return Err(bad("the point is on the axis: pick a point off the axis to define a unique plane"));
+6 -1
View File
@@ -176,7 +176,12 @@ mod tests {
let sag = mesh
.triangles
.iter()
.map(|t| t.map(|i| mesh.positions[i as usize]))
.zip(&mesh.tri_face)
// The side wall's own triangles: the flat caps have corners on the rim too.
.filter(|(_, f)| {
matches!(solidcraft_kernel::face_surface(&mb.body, **f as usize), Some(solidcraft_kernel::FaceSurface::Cylinder { .. }))
})
.map(|(t, _)| t.map(|i| mesh.positions[i as usize]))
.filter(|t| t.iter().all(|p| (r(*p) - 20.0).abs() < 1e-9))
.map(|[a, b, c]| 20.0 - r((a + b + c) * (1.0 / 3.0)))
.fold(0.0, f64::max);
+275 -97
View File
@@ -143,7 +143,7 @@ impl ModelBody {
/// Face measurements for joint origins, at their original chord tolerance. Pose changes
/// and scratch copies share them; a geometry edit makes a new body and cache.
pub(crate) fn joint_faces(&self) -> Result<Arc<[kernel::FaceInfo]>> {
pub fn joint_faces(&self) -> Result<Arc<[kernel::FaceInfo]>> {
let measure = || {
#[cfg(test)]
cost_tests::count(|c| c.joint_faces += 1);
@@ -1161,6 +1161,18 @@ pub struct Model {
placement_inputs: Option<placement_reuse::Inputs>,
/// All evaluated features use supported local inputs; computed with the existing owner index.
placement_local: bool,
/// A failed sketch shown while it is edited ([`Model::hold_failed_sketch`]).
held: Option<HeldSketch>,
}
/// A failed sketch built for its editor (#12085): `shown` is the final state `base` with that
/// sketch in it. [`Model::state`] gives it only while the results still end in `base`, so the
/// next evaluation, or anything else that changes the results, lets it go.
#[derive(Clone, Debug)]
struct HeldSketch {
feature: u64,
base: Arc<ModelState>,
shown: Arc<ModelState>,
}
/// Parameters a feature uses directly (inputs, sketch dimensions and the expressions of a
@@ -1647,6 +1659,21 @@ impl PlacementReads {
}
}
/// The bodies To / From Object's stored face names find, each where it is placed: evaluation
/// finds a named face by its name ([`with_named_face_points`] fails a feature whose name finds
/// none). Everything when a name is missing or empty, finds no one body, or `st` is `None`:
/// the face is then found by its point, on whatever body lies there.
fn object_faces(&mut self, st: Option<&ModelState>, names: Option<&[String]>) {
let bodies: Option<Vec<&str>> = st.zip(names).and_then(|(st, names)| {
let first_of_its_name = |b: &&ModelBody| st.body(&b.name).is_some_and(|first| std::ptr::eq(first, *b));
names.iter().map(|n| crate::name_lookup::face_body(st, n).filter(first_of_its_name).map(|b| b.name.as_str())).collect()
});
match bodies {
Some(bodies) => bodies.into_iter().for_each(|b| self.placed_body(st, b)),
None => self.all = true,
}
}
/// The same target selection as `apply_traced`, expanded only over the timeline input.
fn drawn_targets(&mut self, st: Option<&ModelState>, f: &Feature) {
let Some(st) = st else {
@@ -1826,11 +1853,22 @@ fn placement_digest(doc: &Document, f: &Feature, st: Option<&ModelState>) -> u64
}
}
}
// The faces To / From Object names, in `face_points` order.
let object_names = match &f.kind {
FeatureKind::Extrude { extent, .. } => usize::from(extent.to.is_some()) + usize::from(extent.from.is_some()),
FeatureKind::Revolve { to, .. } => usize::from(to.is_some()),
_ => 0,
};
match &f.kind {
// To / From Object finds its target where that target's component is placed (#928),
// over every body.
FeatureKind::Extrude { extent, .. } if extent.to.is_some() || extent.from.is_some() => reads.all = true,
FeatureKind::Revolve { to: Some(_), .. } => reads.all = true,
// To / From Object finds its target where the target's body is placed (#928): only that
// body's placement, not every part's (#6325). Its tools meet their targets as other
// joins and cuts do.
kind if object_names > 0 => {
reads.object_faces(st, f.face_names.get(..object_names));
if drawn_tools_change_bodies(kind) {
reads.drawn_targets(st, f);
}
}
// A mirror of placed bodies follows the placements it was picked at and its own component's
// frame, not every occurrence: moving an unrelated one leaves it (and its cache) alone.
FeatureKind::Mirror { placements, bodies, .. } if !placements.is_empty() => return mirror_placements_digest(doc, f, placements, bodies),
@@ -1978,9 +2016,56 @@ impl Model {
/// The final model state.
pub fn state(&self) -> Arc<ModelState> {
let last = self.evaluated_state();
match &self.held {
Some(held) if Arc::ptr_eq(&held.base, &last) => held.shown.clone(),
_ => last,
}
}
/// The state after the last feature evaluated, without a failed sketch held for its editor.
fn evaluated_state(&self) -> Arc<ModelState> {
self.results.iter().rev().find(|r| !r.skipped).map(|r| r.state.clone()).unwrap_or_else(|| self.empty.clone())
}
/// Show the sketch `feature` while it is edited although it failed (#12085): a link whose
/// named face or edge is gone fails the sketch, so the evaluation keeps no sketch for it, and
/// its editor had nothing to show or repair. Here the sketch is built as it is evaluated, but
/// with such a link marked lost and kept where it was (no other face or edge is taken for
/// it), and added to the final state only: the sketch's result keeps its error and the
/// features after it still fail. `None`, a sketch that did not fail, or one that fails for
/// another reason too, shows nothing more. Returns whether a held sketch is shown.
pub fn hold_failed_sketch(&mut self, doc: &Document, feature: Option<u64>) -> bool {
let Some(id) = feature else {
self.held = None;
return false;
};
let base = self.evaluated_state();
if self.held.as_ref().is_some_and(|held| held.feature == id && Arc::ptr_eq(&held.base, &base)) {
return true;
}
self.held = None;
if base.sketch(id).is_some() || !self.result(id).is_some_and(|r| r.error.is_some() && !r.skipped && !r.stale) {
return false;
}
let Some(f) = doc.feature(id) else { return false };
let FeatureKind::Sketch { plane, sketch } = &f.kind else { return false };
let mut next = (*self.state_before(id)).clone();
let vals = evaluation_values(doc).0;
let mut warning = None;
let built = kernel::guard(&f.name, || {
let _frames = ToolFrames::install(doc, f, &next);
eval_sketch(doc, &vals, f, plane, sketch, &mut next, &mut warning, crate::project::LostNames::Mark)
.map_err(|e| kernel::KernelError::Failed(e.to_string()))
});
let Some(solved) = built.ok().and_then(|()| next.sketches.iter().find(|s| s.feature == id).cloned()) else { return false };
let mut shown = (*base).clone();
shown.sketches.retain(|s| s.feature != id);
shown.sketches.push(solved);
self.held = Some(HeldSketch { feature: id, base, shown: Arc::new(shown) });
true
}
/// State just before the feature with this id (for editing a sketch in context).
pub fn state_before(&self, id: u64) -> Arc<ModelState> {
let Some(i) = self.results.iter().position(|r| r.id == id) else { return self.state() };
@@ -2144,6 +2229,10 @@ impl Model {
/// Re-evaluate the document, reusing results up to the first changed feature.
pub fn evaluate(&mut self, doc: &Document) {
// A failed sketch held for its editor is built again from the evaluated design: when the
// sketch and every feature after it fail, the results end in the state they ended in
// before, which would keep showing the sketch as it was before the edit.
self.held = None;
let _span = solidcraft_profile::span("doc", "evaluate");
self.placement_inputs = None;
self.fingerprinted = None;
@@ -4665,9 +4754,13 @@ fn hole_position(vals: &BTreeMap<String, Value>, position: &crate::FacePick, at:
// Pick an actual cylindrical wall, not a depth derived from its radius: a large pipe bore can
// be shallow or pass through a thin plate. Sampling its finite face also skips a wider entry
// pocket and keeps metadata attached to the narrow cylindrical bore.
fn parallel_tapped_wall(st: &ModelState, entry: Vec3, inward: Vec3, radius: f64) -> Option<Vec3> {
//
// A hole drilled across a pocket or a slot leaves its wall in pieces along the axis (#13505):
// every piece the hole reaches (`reach`, its depth from `entry`) is returned, nearest the entry
// first, each as (its start and end from the entry along `inward`, a point on it).
fn parallel_tapped_wall(st: &ModelState, entry: Vec3, inward: Vec3, radius: f64, reach: f64) -> Vec<(f64, f64, Vec3)> {
let tol = (radius * 1e-6).max(1e-6);
let mut best: Option<(f64, Vec3)> = None;
let mut walls: Vec<(f64, f64, Vec3)> = Vec::new();
for body in &st.bodies {
// A wall found lies on the line through `entry` along `inward`, give or take its radius
// (and the slant its axis may have off `inward` over the body's distance), and its
@@ -4694,12 +4787,15 @@ fn parallel_tapped_wall(st: &ModelState, entry: Vec3, inward: Vec3, radius: f64)
let b = (c.axis_point + c.axis * c.end - entry).dot(inward);
let lo = a.min(b).max(0.0);
let hi = a.max(b);
if hi > lo + 1e-9 && best.as_ref().is_none_or(|(old, _)| lo < *old) {
best = Some((lo, entry + inward * ((lo + hi) * 0.5) + inward.any_perp() * radius));
// One piece of wall made of several faces (a cylinder split at its seams) is one.
let mid = (lo + hi) * 0.5;
if hi > lo + 1e-9 && lo < reach - 1e-6 && !walls.iter().any(|(l, h, _)| (*l..=*h).contains(&mid)) {
walls.push((lo, hi, entry + inward * mid + inward.any_perp() * radius));
}
}
}
best.map(|(_, wall)| wall)
walls.sort_by(|a, b| a.0.total_cmp(&b.0));
walls
}
/// The cutting tools of a hole feature at every spot, each with its exact geometry when it is a
@@ -6932,6 +7028,22 @@ fn replay(
instances.push(start..copies.len());
}
}
// A cut or intersect copy with no named targets, made where the pattern's own component
// has no body for it, works on the bodies of its source's component, as the source did
// (#12996). A pattern whose component has bodies cuts them, as before. The same scope
// counts misses, takes sheet cuts and applies the copies.
let scoped;
let f = if matches!(op, Operation::Cut | Operation::Intersect)
&& targets.is_empty()
&& f.participants.is_empty()
&& src.component != f.component
&& !st.bodies.iter().any(|b| b.component == f.component || b.feature == f.id)
{
scoped = Feature { component: src.component, ..f.clone() };
&scoped
} else {
f
};
if op == Operation::Cut {
let (m, t) = count_copy_misses(st, f, &targets, &copies, &instances, &axes);
missed += m;
@@ -7380,72 +7492,7 @@ fn eval_feature(
| FeatureKind::ReplaceFace { .. }
| FeatureKind::Align { .. }
| FeatureKind::Remove { .. } => more::eval(doc, vals, f, st, warning),
FeatureKind::Sketch { plane, sketch } => {
let anchor = crate::face_anchor::plane_face_anchor(plane);
let fst = match anchor {
Some(anchor) => crate::state_in_face_anchor_frame(doc, st, anchor, f.component)?,
// A sketch that reads no geometry (a fixed plane, no projections) needs no other
// component's bodies moved into its frame: placing every body of a large
// assembly again is what made each sketch edit as slow as the design is big.
None if !plane_reads_geometry(plane) && sketch.links.is_empty() => std::borrow::Cow::Borrowed(st),
None => state_in_frame(doc, st, f.component)?,
};
// A further placement a link reads is placed in its frame too (#9957).
let named: Vec<&str> = sketch.links.iter().flat_map(|l| l.source.bodies()).collect();
let fst = match anchor.map(|anchor| anchor.author_frame(doc, f.component)) {
_ if named.is_empty() => fst,
Some(Ok(frame)) => with_placed_copies(doc, st, fst, &frame, &named),
Some(Err(_)) => fst,
None => with_placed_copies(doc, st, fst, &doc.component_transform(f.component), &named),
};
let (plane, face_warning) = match (anchor, plane) {
(Some(_), plane) => doc.resolve_attached_plane_noted(vals, plane, f.component, st, &fst)?,
// A flat face of a sheet: the panel's exact plane, from its flat region and fold.
(None, plane) if crate::sheet::plane_sheet_face(plane).is_some() => {
(doc.resolve_plane_in(vals, &sheet_face_plane_in_frame(doc, st, f.component, plane)?, 0, Some(&fst))?, None)
}
(None, PlaneRef::Face { plane: picked, at, name, anchor: None }) => face_plane(&fst, picked, *at, name.as_deref())?,
(None, other) => (doc.resolve_plane_in(vals, other, 0, Some(&fst))?, None),
};
let mut sk = sketch.clone();
// A text's outlines are rebuilt from its numbers when the links are refreshed: the
// ones its expressions give come first.
doc.apply_text_values(vals, &mut sk)?;
let mut warns = match anchor {
Some(anchor) => crate::project::refresh_face_anchor_links(doc, vals, &fst, &plane, &mut sk, anchor)?,
None => crate::project::refresh_links(doc, vals, &fst, &plane, &mut sk)?,
};
drop(fst);
sk.refresh_offsets()?;
if let Some(w) = face_warning {
warns.insert(0, w);
}
// The stored sketch is solved for its stored values, with its links where they
// were stored: a link that moved must not change the side a dimension from it is on.
let stored = sk.driving_values();
let report = doc.solve_sketch(vals, &mut sk, &stored, Some(sketch))?;
if !report.keeps_orientation() {
warns.insert(0, report.failure(|f| format!("the sketch constraints conflict ({f})")));
} else {
// The solve may have moved the curve a text runs along.
warns.extend(crate::project::relay_path_texts(&mut sk));
}
if !warns.is_empty() {
*warning = Some(warns.join("; "));
}
sk.validate_periodic_splines()?;
// A group of touching curves too large to search whole says so, rather than
// leaving profiles out silently (#6896).
let search = solidcraft_sketch::find_profiles_checked(&sk);
if search.incomplete {
append_warning(warning, profiles_cut_short_note());
}
let profiles = search.profiles;
let ends = solidcraft_sketch::loose_ends(&sk);
st.sketches.retain(|s| s.feature != f.id);
st.sketches.push(Arc::new(SolvedSketch { feature: f.id, name: f.name.clone(), plane, sketch: sk, report, profiles, ends }));
Ok(())
}
FeatureKind::Sketch { plane, sketch } => eval_sketch(doc, vals, f, plane, sketch, st, warning, crate::project::LostNames::Fail),
FeatureKind::Extrude { operation, targets, .. }
| FeatureKind::Pipe { operation, targets, .. }
| FeatureKind::Revolve { operation, targets, .. }
@@ -7476,7 +7523,7 @@ fn eval_feature(
let extra: Vec<String> = if *operation == Operation::Join { to_body.into_iter().collect() } else { Vec::new() };
apply_traced_with(doc, st, f, tools, *operation, &crate::plain(targets), &extra)
}
FeatureKind::Hole { thread, thread_depth, diameter, hand, modeled, ease_out, .. } => {
FeatureKind::Hole { thread, thread_depth, diameter, hand, modeled, ease_out, depth, to, .. } => {
let thread_depth = thread_depth.as_ref().map(|d| val(vals, d, Kind::Length)).transpose()?;
if thread_depth.is_some() && thread.is_none() {
return Err(DocError::Invalid("thread depth requires a tapped hole".into()));
@@ -7491,6 +7538,19 @@ fn eval_feature(
return Err(DocError::Invalid("thread depth must be finite and positive".into()));
}
let (spots, dir) = hole_spots(vals, st, f)?;
// How deep each hole goes (through all: as far as the wall goes), read before the cut:
// the pieces of a wall the thread runs along lie within it.
let reaches: Vec<f64> = if thread.is_some() {
spots
.iter()
.map(|at| match hole_depth_to(st, at, dir, &to.map(Vec3::from), depth)? {
Some(e) => val(vals, &e, Kind::Length),
None => Ok(f64::INFINITY),
})
.collect::<Result<_>>()?
} else {
Vec::new()
};
let tools = hole_feature_tools(vals, st, f)?;
let handled = sheet_hole_cut(st, f, &tools, warning)?;
let tools: Vec<Traced> = tools.into_iter().map(|(tool, _)| tool).collect();
@@ -7526,42 +7586,77 @@ fn eval_feature(
)
});
let ease = thread::runout(vals, ease_out)?;
for p in spots {
let wall = if pipe {
parallel_tapped_wall(st, p, dir, r).ok_or_else(|| DocError::Invalid("no cylindrical pipe-thread wall remains".into()))?
} else {
parallel_tapped_wall(st, p, dir, r).unwrap_or(p + dir.any_perp() * r + dir * (r * 0.5))
for (p, reach) in spots.into_iter().zip(reaches) {
let walls = parallel_tapped_wall(st, p, dir, r, reach);
if pipe && walls.is_empty() {
return Err(DocError::Invalid("no cylindrical pipe-thread wall remains".into()));
}
// A pipe thread is one taper from the entry: its first wall only.
let walls: Vec<(f64, f64, Vec3)> = match walls.first() {
None => vec![(0.0, f64::INFINITY, p + dir.any_perp() * r + dir * (r * 0.5))],
Some(first) if pipe => vec![*first],
Some(_) => walls,
};
// The thread depth runs from where the first wall starts, through every piece
// of the wall it reaches (#13505).
let first = walls.first().map_or(0.0, |w| w.0);
let stop = thread_depth.map(|d| first + d);
let ask = thread::ThreadAsk { designation: Some(d), tapped: true, hand: *hand, ..Default::default() };
if *modeled {
// Cut from the entry, as deep as `thread_depth` or along the whole wall:
// at a blind hole's bottom the groove stops clear of it, or fades out
// before it with a runout.
let extent = thread_depth.map_or(thread::Extent::Full, thread::Extent::Length);
let ask = thread::ThreadAsk { modeled: true, extent, from: Some(p), ease_out: ease, ..ask };
let on = thread_on(st, f.id, wall, &ask)?;
thread::cut(st, on, (crate::thread_forms::ThreadTip::None, None, None))?;
// before it with a runout. Every piece is found before any is cut.
let mut on = Vec::new();
for (lo, hi, wall) in &walls {
let extent = match stop {
None => thread::Extent::Full,
Some(s) if s >= *hi => thread::Extent::Full,
Some(s) if s > *lo => thread::Extent::Length(s - lo),
Some(_) => break,
};
// A piece past the first starts at its end nearest the entry.
let from = if on.is_empty() { p } else { p + dir * *lo };
let ask = thread::ThreadAsk { modeled: true, extent, from: Some(from), ease_out: ease, ..ask };
on.push(thread_on(st, f.id, *wall, &ask)?);
}
for o in on {
thread::cut(st, o, (crate::thread_forms::ThreadTip::None, None, None))?;
}
continue;
}
let mut t = thread_on(st, f.id, wall, &ask)?.info;
let mut threads = Vec::new();
for (lo, _, wall) in &walls {
if stop.is_some_and(|s| s <= *lo) {
break;
}
threads.push(thread_on(st, f.id, *wall, &ask)?.info);
}
if let Some(depth) = thread_depth {
let available = t.end - t.start;
if depth > available {
let useful =
|t: &ThreadInfo| (t.axis_point + t.axis * t.start - p).dot(dir).max((t.axis_point + t.axis * t.end - p).dot(dir));
let available = threads.last().map_or(0.0, |t| useful(t) - first);
if depth > available + 1e-9 {
return Err(DocError::Invalid(format!(
"thread depth {} mm exceeds the cylindrical wall's useful length of {} mm; reduce the thread depth or use full thread depth",
show_mm(depth),
show_mm(available)
)));
}
// Start at the entry in the drilling direction, independently of the
// wall probe's position and the cylinder surface's axis orientation.
if dir.dot(t.axis) > 0.0 {
t.end = t.start + depth;
} else {
t.start = t.end - depth;
// Stop `depth` past the first wall's start in the drilling direction,
// independently of the wall probe's position and the cylinder surface's
// axis orientation.
if let (Some(t), Some(s)) = (threads.last_mut(), stop) {
let past = useful(t) - s;
if past > 0.0 {
if dir.dot(t.axis) > 0.0 {
t.end -= past;
} else {
t.start += past;
}
}
}
}
st.threads.push(t);
st.threads.extend(threads);
}
}
Ok(())
@@ -8264,6 +8359,88 @@ fn eval_feature(
}
}
/// A sketch feature: its links refreshed against the model before it, solved, its profiles
/// found. `lost_names` says what a link whose named face or edge is gone does: the evaluation
/// fails the sketch ([`crate::project::LostNames::Fail`]); a failed sketch shown for editing
/// marks the link lost instead ([`Model::hold_failed_sketch`]).
#[allow(clippy::too_many_arguments)]
fn eval_sketch(
doc: &Document,
vals: &BTreeMap<String, Value>,
f: &Feature,
plane: &PlaneRef,
sketch: &solidcraft_sketch::Sketch,
st: &mut ModelState,
warning: &mut Option<String>,
lost_names: crate::project::LostNames,
) -> Result<()> {
let anchor = crate::face_anchor::plane_face_anchor(plane);
let fst = match anchor {
Some(anchor) => crate::state_in_face_anchor_frame(doc, st, anchor, f.component)?,
// A sketch that reads no geometry (a fixed plane, no projections) needs no other
// component's bodies moved into its frame: placing every body of a large
// assembly again is what made each sketch edit as slow as the design is big.
None if !plane_reads_geometry(plane) && sketch.links.is_empty() => std::borrow::Cow::Borrowed(st),
None => state_in_frame(doc, st, f.component)?,
};
// A further placement a link reads is placed in its frame too (#9957).
let named: Vec<&str> = sketch.links.iter().flat_map(|l| l.source.bodies()).collect();
let fst = match anchor.map(|anchor| anchor.author_frame(doc, f.component)) {
_ if named.is_empty() => fst,
Some(Ok(frame)) => with_placed_copies(doc, st, fst, &frame, &named),
Some(Err(_)) => fst,
None => with_placed_copies(doc, st, fst, &doc.component_transform(f.component), &named),
};
let (plane, face_warning) = match (anchor, plane) {
(Some(_), plane) => doc.resolve_attached_plane_noted(vals, plane, f.component, st, &fst)?,
// A flat face of a sheet: the panel's exact plane, from its flat region and fold.
(None, plane) if crate::sheet::plane_sheet_face(plane).is_some() => {
(doc.resolve_plane_in(vals, &sheet_face_plane_in_frame(doc, st, f.component, plane)?, 0, Some(&fst))?, None)
}
(None, PlaneRef::Face { plane: picked, at, name, anchor: None }) => face_plane(&fst, picked, *at, name.as_deref())?,
(None, other) => (doc.resolve_plane_in(vals, other, 0, Some(&fst))?, None),
};
let mut sk = sketch.clone();
// A text's outlines are rebuilt from its numbers when the links are refreshed: the
// ones its expressions give come first.
doc.apply_text_values(vals, &mut sk)?;
let mut warns = match (anchor, lost_names) {
(Some(anchor), crate::project::LostNames::Fail) => crate::project::refresh_face_anchor_links(doc, vals, &fst, &plane, &mut sk, anchor)?,
(None, crate::project::LostNames::Fail) => crate::project::refresh_links(doc, vals, &fst, &plane, &mut sk)?,
(anchor, crate::project::LostNames::Mark) => crate::project::refresh_links_marking_lost(doc, vals, &fst, &plane, &mut sk, anchor)?,
};
drop(fst);
sk.refresh_offsets()?;
if let Some(w) = face_warning {
warns.insert(0, w);
}
// The stored sketch is solved for its stored values, with its links where they
// were stored: a link that moved must not change the side a dimension from it is on.
let stored = sk.driving_values();
let report = doc.solve_sketch(vals, &mut sk, &stored, Some(sketch))?;
if !report.keeps_orientation() {
warns.insert(0, report.failure(|f| format!("the sketch constraints conflict ({f})")));
} else {
// The solve may have moved the curve a text runs along.
warns.extend(crate::project::relay_path_texts(&mut sk));
}
if !warns.is_empty() {
*warning = Some(warns.join("; "));
}
sk.validate_periodic_splines()?;
// A group of touching curves too large to search whole says so, rather than
// leaving profiles out silently (#6896).
let search = solidcraft_sketch::find_profiles_checked(&sk);
if search.incomplete {
append_warning(warning, profiles_cut_short_note());
}
let profiles = search.profiles;
let ends = solidcraft_sketch::loose_ends(&sk);
st.sketches.retain(|s| s.feature != f.id);
st.sketches.push(Arc::new(SolvedSketch { feature: f.id, name: f.name.clone(), plane, sketch: sk, report, profiles, ends }));
Ok(())
}
/// `copy`, a moved copy of `source` (instance `instance` of a pattern or mirror): its faces are
/// named after the source's.
fn moved_copy(source: &ModelBody, copy: Body, instance: usize) -> Traced {
@@ -8739,6 +8916,7 @@ impl PlacedBodies {
let mut fresh = ModelBody::new(name.to_string(), placed.body.clone(), b.feature);
fresh.mesh = placed.mesh.clone();
fresh.measured = placed.measured.clone();
fresh.joint_faces = placed.joint_faces.clone();
fresh.mesh_placement = placed.mesh_placement.clone();
fresh.plan = placed.plan.clone();
fresh.plan_mesh = placed.plan_mesh.clone();
+120
View File
@@ -656,6 +656,126 @@ fn a_tool_cutting_another_part_cuts_again_when_that_part_moves() {
assert!((volume(&model, &body) - (400.0 - 16.0)).abs() < 1e-6, "{}", volume(&model, &body));
}
/// A root design with two 10 x 10 x 4 mm plates, "Plate" at the origin and "Other" 30 mm along Y,
/// and a sketch on origin plane `plane` of the rectangle `profile` spans (opposite corners, in mm):
/// (the plate part, the other part, the sketch).
fn two_plates_and_a_sketch(doc: &mut Document, plane: &str, profile: [Vec2; 2]) -> (u64, u64, u64) {
let plate = doc.add_component(Some("Plate"), 0).unwrap();
let other = doc.add_component(Some("Other"), 0).unwrap();
place(doc, other, rigid(Vec3::new(0.0, 30.0, 0.0), Vec3::ZERO, Vec3::Z, 0.0));
plate_with_holes(doc, other, other, 0);
plate_with_holes(doc, plate, plate, 0);
let mut sk = Sketch::new();
let [a, c] = profile;
let p = [a, Vec2::new(c.x, a.y), c, Vec2::new(a.x, c.y)].map(|q| sk.add_point(q, None).unwrap());
for k in 0..4 {
sk.add_line_pts(p[k], p[(k + 1) % 4], None).unwrap();
}
let sketch = doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: plane.into() }, sketch: sk }, None).unwrap();
(plate, other, sketch)
}
/// `kind` added to the root with its face points named after the faces under them, as the
/// command that makes it names them.
fn add_named(doc: &mut Document, kind: FeatureKind) -> u64 {
let mut model = Model::new();
model.evaluate(doc);
let names = face_point_names(doc, &model.state(), &kind, 0).unwrap();
assert!(names.iter().all(|n| !n.is_empty()), "{names:?}");
let id = doc.add_feature(kind, None).unwrap();
doc.feature_mut(id).unwrap().face_names = names;
id
}
/// An extrude in the root from the top of the plate (z 4) 6 mm up, over 2 to 8 mm on X and Y.
fn extrude_from_the_plate(doc: &mut Document) -> (u64, u64, u64) {
let (plate, other, sketch) = two_plates_and_a_sketch(doc, "XY", [Vec2::new(2.0, 2.0), Vec2::new(8.0, 8.0)]);
let kind = FeatureKind::Extrude {
thin: None,
sketch: SketchId(sketch),
profiles: ProfileSel::All,
extent: serde_json::from_value(serde_json::json!({"distance": "6", "from": [5.0, 5.0, 4.0]})).unwrap(),
operation: Operation::NewBody,
targets: vec![],
};
(plate, other, add_named(doc, kind))
}
/// Where the body feature `id` made has its centre.
fn centroid_of(model: &Model, id: u64) -> Vec3 {
let st = model.state();
kernel::measure(&st.bodies.iter().find(|b| b.feature == id).unwrap().body).unwrap().centroid
}
/// An extrude from a face of the plate reads where the plate is, and nothing else (#6325): a
/// joint moving another part leaves its result, and every result after it, as it was.
#[test]
fn an_extrude_from_its_own_part_keeps_its_result_when_another_part_moves() {
let mut doc = Document::new("from object");
let (plate, other, extrude) = extrude_from_the_plate(&mut doc);
let mut model = Model::new();
model.evaluate(&doc);
assert!(model.results.iter().all(|r| r.error.is_none()), "{:?}", model.results.iter().find(|r| r.error.is_some()));
assert!((centroid_of(&model, extrude).z - 7.0).abs() < 1e-6, "{:?}", centroid_of(&model, extrude));
place(&mut doc, other, rigid(Vec3::new(0.0, 40.0, 3.0), Vec3::ZERO, Vec3::Z, 0.0));
model.evaluate(&doc);
assert_eq!(model.last_recomputed, 0);
// The plate lifted 5 mm: the extrude starts on its top where it is now (#928).
place(&mut doc, plate, rigid(Vec3::new(0.0, 0.0, 5.0), Vec3::ZERO, Vec3::Z, 0.0));
model.evaluate(&doc);
assert_eq!(model.last_recomputed, 1);
assert!((centroid_of(&model, extrude).z - 12.0).abs() < 1e-6, "{:?}", centroid_of(&model, extrude));
}
/// A revolve To a face of the plate, as the extrude from it: it turns again only when the plate
/// moves.
#[test]
fn a_revolve_to_its_own_part_keeps_its_result_when_another_part_moves() {
let mut doc = Document::new("revolve to object");
let (plate, other, sketch) = two_plates_and_a_sketch(&mut doc, "XZ", [Vec2::new(12.0, 0.0), Vec2::new(15.0, 2.0)]);
let kind = FeatureKind::Revolve {
sketch: SketchId(sketch),
profiles: ProfileSel::All,
axis: AxisRef::SketchAxis { axis: "y".into() },
angle: "90 deg".into(),
operation: Operation::NewBody,
targets: vec![],
angle2: None,
symmetric: false,
convention: Default::default(),
to: Some(serde_json::from_value(serde_json::json!([5.0, 5.0, 4.0])).unwrap()),
};
let revolve = add_named(&mut doc, kind);
let mut model = Model::new();
model.evaluate(&doc);
assert!(model.results.iter().all(|r| r.error.is_none()), "{:?}", model.results.iter().find(|r| r.error.is_some()));
let turned = volume(&model, &model.state().bodies.iter().find(|b| b.feature == revolve).unwrap().name.clone());
place(&mut doc, other, rigid(Vec3::new(0.0, 40.0, 3.0), Vec3::ZERO, Vec3::Z, 0.0));
model.evaluate(&doc);
assert_eq!(model.last_recomputed, 0);
// The plate turned a quarter about Z: the revolve turns to its top where it is now.
place(&mut doc, plate, rigid(Vec3::ZERO, Vec3::ZERO, Vec3::Z, std::f64::consts::FRAC_PI_2));
model.evaluate(&doc);
assert_eq!(model.last_recomputed, 1);
let now = volume(&model, &model.state().bodies.iter().find(|b| b.feature == revolve).unwrap().name.clone());
assert!((now - turned).abs() > 1.0, "{turned} then {now}");
}
/// A From face without a name is found by its point, on whatever body lies there: moving any
/// part extrudes again.
#[test]
fn an_extrude_from_a_face_without_a_name_extrudes_again_when_any_part_moves() {
let mut doc = Document::new("from a point");
let (_, other, extrude) = extrude_from_the_plate(&mut doc);
doc.feature_mut(extrude).unwrap().face_names.clear();
let mut model = Model::new();
model.evaluate(&doc);
assert!((centroid_of(&model, extrude).z - 7.0).abs() < 1e-6, "{:?}", centroid_of(&model, extrude));
place(&mut doc, other, rigid(Vec3::new(0.0, 40.0, 3.0), Vec3::ZERO, Vec3::Z, 0.0));
model.evaluate(&doc);
assert_eq!(model.last_recomputed, 1);
}
#[test]
fn a_body_is_measured_once_while_it_stays_the_same() {
let mut doc = Document::new("measured once");
+20
View File
@@ -1831,6 +1831,26 @@ pub(super) fn line_beyond_reach(mb: &ModelBody, at: Vec3, dir: Vec3, reach: f64)
lo > hi
}
/// Whether [`kernel::cylinder_face_at`] is known to find no face of `mb` at `p`. It takes a face
/// only when its mesh of the body, made to a chord tolerance of `(size * 1e-3).max(1e-3)`, comes
/// within a twentieth of the body's size of the point. That mesh lies within its tolerance of
/// the exact faces, so of the body's exact box ([`exact_box`]): a box farther from the point
/// than a twentieth of the size and twice that tolerance holds no face for it. Working that out
/// from the exact box spares meshing every body in the design for each tap marking (#10227). A
/// mesh body, and a body whose box is unknown, are searched as before.
pub(super) fn beyond_cylinder_reach(mb: &ModelBody, p: Vec3) -> bool {
if mb.body.is_mesh() {
return false;
}
let Some(b) = exact_box(&mb.body) else { return false };
let size = mb.body.size();
let reach = size * 0.05 + 2.0 * (size * 1e-3).max(1e-3);
let off = |at: f64, lo: f64, hi: f64| (lo - at).max(at - hi).max(0.0);
let (dx, dy, dz) = (off(p.x, b.min.x, b.max.x), off(p.y, b.min.y, b.max.y), off(p.z, b.min.z, b.max.z));
let dist = (dx * dx + dy * dy + dz * dz).sqrt();
dist.is_finite() && reach.is_finite() && dist > reach
}
/// Whether two boxes are known to be apart (an unknown box is never apart).
pub(super) fn boxes_apart(a: &Option<Aabb3>, b: &Option<Aabb3>) -> bool {
let (Some(a), Some(b)) = (a, b) else { return false };
+28
View File
@@ -192,6 +192,7 @@ pub(super) fn thread_on(st: &ModelState, feature: u64, p: Vec3, ask: &ThreadAsk)
.bodies
.iter()
.enumerate()
.filter(|(_, b)| !super::more::beyond_cylinder_reach(b, p))
.filter_map(|(i, b)| {
#[cfg(test)]
super::cost_tests::count(|c| c.cylinder_search += 1);
@@ -1122,4 +1123,31 @@ mod tests {
// The square stop leaves about half a pitch's worth of groove uncut: well over a mm³.
assert!(square > trimmed + 1.0, "{square} vs {trimmed}");
}
/// A modelled thread on one hole leaves the other hole's wall a cylinder to thread: the
/// second hole still takes its thread (#11367).
///
/// The modelled thread cuts the body, and the cut must not take the body's other cylinders
/// with it: the second hole's wall is still a cylinder, so it still takes a thread, modelled
/// or cosmetic.
#[test]
fn a_modelled_thread_leaves_the_next_hole_a_cylinder_to_thread() {
// An 80 x 80 x 20 mm block with two 5 mm holes.
let block = kernel::box_solid(Vec3::new(-40.0, -40.0, 0.0), Vec3::new(40.0, 40.0, 20.0)).unwrap();
let hole = |x: f64| kernel::cylinder(Vec3::new(x, 0.0, -1.0), Vec3::new(0.0, 0.0, 1.0), 2.5, 22.0).unwrap();
let mut body = kernel::boolean(&block, &hole(-20.0), BoolOp::Cut).unwrap().unwrap();
body = kernel::boolean(&body, &hole(20.0), BoolOp::Cut).unwrap().unwrap();
let mut st = ModelState::default();
st.bodies.push(ModelBody::new("Part".into(), body, 1));
let ask = ThreadAsk { designation: Some("M6"), ..ask() };
// The first hole takes a modelled M6 thread.
let on = thread_on(&st, 1, Vec3::new(-17.5, 0.0, 10.0), &ask).unwrap();
cut(&mut st, on, (ThreadTip::None, None, None)).unwrap();
// The second hole's wall is still a cylinder: it takes a thread too.
let on = thread_on(&st, 1, Vec3::new(22.5, 0.0, 10.0), &ask);
assert!(on.is_ok(), "the second hole still has a cylinder to thread: {:?}", on.err());
// And a modelled one cuts there as well.
let on = thread_on(&st, 1, Vec3::new(22.5, 0.0, 10.0), &ask).unwrap();
assert!(cut(&mut st, on, (ThreadTip::None, None, None)).is_ok());
}
}
+43
View File
@@ -982,6 +982,12 @@ fn call(f: &str, args: &[Value], d: Defaults) -> Result<Value, DocError> {
// Round in the value's own default unit (round(2.4 in) = 2 in for an inch parameter).
let scale = scale_bare(Value::num(1.0), a.len, a.ang, d);
let x = if scale != 0.0 { a.v / scale } else { a.v };
// Unit conversions leave a rounding error of an ulp or two (`3 in / 1 in` is
// 2.9999999999999996): a value that close to a whole number is that number, so `floor`
// does not drop to 2. The tolerance is relative to the whole number, so a tiny value
// never snaps to 0, and it stays under 1/8 so no real fraction is absorbed.
let nearest = x.round();
let x = if (x - nearest).abs() <= (4.0 * f64::EPSILON * nearest.abs()).min(0.125) { nearest } else { x };
let r = match f {
"floor" => x.floor(),
"ceil" => x.ceil(),
@@ -1444,6 +1450,43 @@ mod tests {
}
}
/// `floor`, `ceil`, `trunc` and `round` work on the number a person typed, not on float noise:
/// `3 in / 1 in` is 2.9999999999999996 in floats, and `floor` of it must still be 3.
#[test]
fn rounding_functions_ignore_float_noise() {
for (e, want) in [("floor(3 in / 1 in)", 3.0), ("ceil(3 in / 1 in)", 3.0), ("trunc(3 in / 1 in)", 3.0), ("floor(-3 in / 1 in)", -3.0)] {
assert_eq!(ev(e, Kind::Unitless).unwrap(), want, "{e}");
}
let d = Defaults { len: 25.4, ang: std::f64::consts::PI / 180.0 };
let look = |_: &str| Err(DocError::Expr("no parameters".into()));
for e in ["floor(3 in)", "ceil(3 in)", "trunc(3 in)", "round(3 in)"] {
let got = eval_with_defaults(e, &look, d).unwrap().to_kind_in(Kind::Length, d).unwrap();
assert!((got - 76.2).abs() < 1e-9, "{e} gave {got}");
}
let got = eval_with_defaults("floor(30 deg)", &look, Defaults::default()).unwrap().to_kind(Kind::Angle).unwrap();
assert!((got - 30f64.to_radians()).abs() < 1e-9, "floor(30 deg) gave {got}");
// A real fraction still rounds the way its name says, however small, and large values keep working.
for (e, want) in [
("floor(2.9999)", 2.0),
("ceil(3.0001)", 4.0),
("floor(100.99999995)", 100.0),
("ceil(100.00000005)", 101.0),
("floor(1000000000.7)", 1_000_000_000.0),
("ceil(1000000000.7)", 1_000_000_001.0),
("trunc(1000000000.7)", 1_000_000_000.0),
("round(1000000000.7)", 1_000_000_001.0),
("ceil(1e-16)", 1.0),
("floor(-1e-16)", -1.0),
("trunc(1e-16)", 0.0),
("trunc(-1e-16)", 0.0),
("round(1e-16)", 0.0),
("round(-1e-16)", 0.0),
("floor(1000000000000000.5)", 1_000_000_000_000_000.0),
] {
assert_eq!(ev(e, Kind::Unitless).unwrap(), want, "{e}");
}
}
/// A count from a length (`(L - 20) / 210`, the 210 read as unit-less) names the fix (#5901).
#[test]
fn a_length_where_a_count_is_expected_says_to_divide_by_a_length() {

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