Compare commits

...

163 Commits

Author SHA1 Message Date
github-actions[bot] c4e1a3a756 Regenerate generated files and fold pending files [skip ci] 2026-11-06 03:24:10 +00:00
raykholo b432b4484b Kernel cases: match the reorder refusal's new wording (#12392)
#12171 changed the reorder refusal to "moving Notch to position 0 breaks
Notch (...)", so the history case that expected "moving Notch there breaks
Notch" now fails in the kernel rig on every pull request that runs it.
The case checks a substring, so expect "breaks Notch", which both wordings
contain.
2026-11-06 02:56:29 +00:00
selune 068c46cf8b Render: restore saved shots, progressive CPU/GPU images and exports (#6201) (#11087)
* Render: restore saved shots, progressive images and exports

* Render: register existing live and coat command tests

* Render: isolate lens saves and retain bounded previews

Save only lens parameters on existing CPU/GPU jobs while keeping unrelated
draft settings dirty. Retain pinned checkpoints through preview and status,
and release host-only snapshots outside the status lock.

Use the admitted PNG bound for registered-executor checkpoint slots. Preserve
executor work credits, final publication and regrade ownership. Add regression
coverage and align help, translations and capability documentation.

* Render: preserve live feedback and address review regressions

* Preserve saved cameras when applying Render library edits

Keep the saved camera when lighting or maps are applied to an existing shot. Fit and centre the measured Render host using the painted picture. Carry forward the upstream CPU and GPU preset and ZoomTool contracts.
2026-11-06 02:47:52 +00:00
github-actions[bot] d7cad1005a Regenerate generated files and fold pending files [skip ci] 2026-11-06 02:12:45 +00:00
bherbruck 8564006ffb Drawing: format_pending is one of the record's fields (#12448)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 01:45:23 +00:00
ReDead 708d5a149d Plane at Angle: use axis direction only with a pivot (#12041) (#12131)
* claim: #12041

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

* Plane at Angle: use axis direction only with a pivot (#12041)

Add a direction_only option that projects the axis onto the base and
turns about a pivot (or the base origin), with dialog checkbox and
conditional Pivot pick. Older designs keep turning about the full line.

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

* i18n: add ar, he, vi for Use axis direction only (#12041)

Complete catalogs need every English message; the pending locale file
was missing those three so cargo xtask i18n --check failed.

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

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: ReDead <ReDead@users.noreply.github.com>
2026-11-06 01:38:49 +00:00
Daniel 0c094142d3 Sketch Break and Extend: a cut or stop on a shape's centre ends there, with no second point (#12299) (#12301)
* Sketch Break and Extend: tests for a cut or stop on a center rectangle's centre

Breaking a center rectangle's diagonal at the centre leaves two points there, and extending a line up to the centre ends it on one diagonal only. These tests fail on main.

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

* Sketch Break and Extend: a cut or stop on a held midpoint ends there, as Trim does

Break dropped the broken line's Midpoint and joined the pieces at a new point held only on the cutter, so a center rectangle's centre came loose and a corner drag pulled the two points apart. It now uses Trim's rule (one helper, hold_cut): the pieces meet at the held point, which moves its hold to the cutter's middle when the sketch already implies that, and otherwise stays on the cutter.

Extend stopped on a point it lays on the curve it meets unless that curve ends there. A point held at another line's middle (a center rectangle's centre) is now a stop too, so the extension joins it.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-11-05 23:27:01 +00:00
Martin Pålsson 1939adba6b Sketch Dimension: pick an origin axis as a reference (#2364) (#2373)
* claim: #2364

* Sketch Dimension: an origin axis can be picked as a reference (#2364)

The dimension tool ignored clicks on an origin axis and the engine's
sketch.dimension did not resolve x/y. Axis picks now map to the sketch's
linked axis line, as Symmetry and the other line constraints already do,
in all three entry points (click-then-pick, select-then-D, and the command).

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

* Sketch Dimension: tests for a circle centre and a line against the sketch axes (#3137, #3320)

Folds in the cases from #3298: a circle's centre point dimensioned to the
x / y axis (#3137), and a line's angle and an end's distance to the x axis
(#3320). The axis resolution from #2364 already covers them; a plain circle
or arc with a line still measures to its edge (#1803).

Co-authored-by: Phanoob <phanoob@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Sketch Dimension: the axis test picks the line off its midpoint, which is a point pick (#4872)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Phanoob <phanoob@gmail.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-11-05 23:13:42 +00:00
Robson Pereira 207679e261 Face and edge dialogs name the body of their picks (#7174)
Fillet, Chamfer, Offset Face (and Press Pull on a curved face, which opens
it), Draft, Replace Face and Delete Faces sent only the picked points. The
feature then checked every body's edges for them and evaluated on the body
whose mesh box is nearest the first point (`body_at`):

- In an assembly, a bore or pin inside a housing that comes first in the
  body list was looked for on the housing, which has no face there, and the
  command failed with "no face at [...]".
- In a large design, `body_at` meshed every body (and Fillet/Chamfer worked
  out every body's edges) on each preview and apply: seconds per change.

These dialogs now name the body when every pick lies on one, as Shell does
since #4555; picks on several bodies name none, as before. Press Pull passes
a given body on to Offset Face.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 22:38:11 +00:00
github-actions[bot] 4cbbccb935 Regenerate generated files and fold pending files [skip ci] 2026-11-05 22:50:08 +00:00
PaddleStroke 22f8da2689 View: hold a shortcut to drag and zoom (#11701) (#11706)
* claim: #11701

* Allow assigned shortcuts to drag the view to zoom
2026-11-05 22:08:53 +00:00
semmelrinde 51d8842e8a Create Sketch: the flat faces of a cone take an attached sketch (#7476) (#7772)
* claim: #7476

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

* Create Sketch: the flat faces of a cone take an attached sketch (#7476)

The cone primitive was evaluated as an unnamed tool, so its faces had
only geometric names, which a face attachment refuses as not exact.
Its faces are now named from the revolve that builds it, as a
cylinder's are: `bottom`, `side` and, on a cut-off cone, `top`. A
sketch on its base or top attaches by that history name and follows
the cone's height.

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

* Fix compile after the layer split

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-11-05 21:54:30 +00:00
kaltenbrunnermarkus 95fd121bb3 Mesh export: mesh bodies in parallel and weld seams through a grid (#9613)
* claim: Mesh export: mesh bodies in parallel and weld seams through a grid

* Mesh export: mesh bodies on threads and find seam splits through a grid

STL, OBJ and 3MF exports meshed and welded one body after the other on
one core. Each body is now meshed (and welded) on its own thread with
map_on_threads, as the model cache encodes them; results stay in body
order and every body is welded on its own, so the files are the same,
byte for byte. Of several failing bodies the first one's error is the
export's, as before.

The search for vertices lying on open sides (T-junctions) looked at
every open-side vertex for every open side. It now bins those vertices
on a grid and looks only in the cells around each side; the vertices it
finds come out in the same ascending order, so the splits are the same.
A side whose box spans more cells than there are vertices, or that is
not finite, still looks at all of them. A test compares the result with
the previous all-pairs search (kept in the test module) on a cube meshed
at six resolutions per face with jittered vertices and broken triangles.

ASCII STL and OBJ write each line into the text instead of formatting
a string per line and appending it (same bytes).

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

* Fix compile after the layer split

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-11-05 21:54:19 +00:00
PeppaWutz 1444801d96 Agents and scripts: the canvas commands describe their exact parameters and reply (#12275) (#12338)
canvas.insert, canvas.decal, canvas.edit, canvas.calibrate and canvas.delete
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 plane check of sketch.create's plane reader is split out so the canvas
contract can use it without changing what it accepts.

Part of #2829.

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-11-05 21:38:07 +00:00
github-actions[bot] 28dcbbc862 Regenerate generated files and fold pending files [skip ci] 2026-11-05 21:52:00 +00:00
potuzai 7e96aabd6d i18n: catalogue 214 dialog labels; complete Czech terms (#8421)
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 21:16:02 +00:00
github-actions[bot] 52893744a5 Regenerate generated files and fold pending files [skip ci] 2026-11-05 21:11:13 +00:00
Thijs Damsma 4699b9cc89 Sketch library: include the built-in font in vendored sources (#12162) (#12174)
* claim: sketch font is included in vendored sources (#12162)

Co-Authored-By: Codex <noreply@openai.com>

* fix: include the sketch default font in vendored sources

Co-Authored-By: Codex <noreply@openai.com>

---------

Co-authored-by: Codex <noreply@openai.com>
2026-11-05 20:26:05 +00:00
austin62jake 4eb0c6f5ec Agents and scripts: Manage, Assign and List Plastic Rules describe their exact parameters and reply (#12313) (#12314)
* claim: #12313

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

* Plastic rules: input and output schemas for Manage, Assign and List Plastic Rules (#12313)

The three plastic rule commands get typed contracts and replies, in a
new `schema/plastic.rs` laid out like the sheet metal rules', and leave
`schema/unconverted.txt`.

- Each key reads as its handler reads it, with the handler's message:
  the rule name as `plastic.manage_rules` trims and limits it, `bodies`
  through `config::NameList` (which mirrors `params::string_list`), the
  ten rule values as `LengthExpr` and `AngleExpr`.
- `name` and `bodies` are optional in the types and required in the
  schemas (a `require` transform), so a call without them still gets
  the handler's own message.
- Keys of the wrong type that the handlers ignore today (a non-boolean
  `active` or `delete`, a rule value that is neither a number nor text,
  a non-text `from`, `material` or `rule`) are refused; in warn the call
  still works, with a warning. A test pins each one.
- The handlers build their replies from the reply types through a small
  `typed_reply`; the JSON is unchanged.

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

* Assign Plastic Rule: the schema's `bodies` is a name or a non-empty list, not null

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.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-11-05 20:00:52 +00:00
PeppaWutz a07b60cd81 Agents and scripts: the linked-component commands describe their exact parameters and reply (#12277) (#12351)
component.insert_design, update_links, relink, break_link, links, extract,
copies and replace_with_link now publish typed input and output schemas.
Each parameter is read as its handler reads it today, with the same error
messages, and each reply is built from the typed value and serializes to the
same JSON as before. Their lines leave unconverted.txt.

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-11-05 20:00:28 +00:00
thocsmith bd3053f57b Insert SVG: shapes a file only defines or hides are not imported (#11039) (#11050)
* claim: #11039

* Insert SVG: shapes a file only defines or hides are not imported (#11039)

read_svg drew every shape it met, whatever its parents were, so a circle
inside defs, clipPath, mask, symbol, marker or pattern, or one hidden by
display: none, became a sketch curve a browser never shows (and could
punch a hole in an extrude). Those elements and their contents are now
skipped; the stack still follows the end tags, so what comes after reads
as before.

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-05 19:52:26 +00:00
Homero Thompson 49ab9179a7 The command reference: the parameters as a table, with their type and whether they are required (#5237) (#11074)
* The command reference: the parameters as a table, with their type and whether they are required (#5237)

The command reference shows each command's parameters as one raw line of
registry notation, so a reader has to read it as code to learn what a
command takes. The generated page now lists the same parameters as a table:
the name, the type, whether the command needs it, and what the registry
says about it. The complete parameter text stays in the automation details,
so nothing is lost for the scripts that read it.

The table is generated from the command registry, like the rest of the page,
so it cannot drift from the commands. The notation is read by a small parser
that knows what the registry writes: names written apart share the type
that follows them, a question mark marks a parameter optional, a pipe
between names offers alternative names for one parameter, a pipe after the
colon lists the types it accepts, a parenthesis says what the parameter
does, and the text after an arrow is the reply the command gives, not a
parameter.

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

* test: the parameter table keeps its description literal

The parameter table shows registry text, so a description that carries
inline Markdown must be shown literally: backticks, emphasis and links
are characters, not syntax.

The type of a parameter ends where its sentence ends, so the sentence
that follows it is not part of the type.

Both tests fail on the current code.

* fix: render the parameter description as prose

The description cell of the parameter table is registry text, and the
rest of the file already renders registry text with prose(): backticks,
emphasis and links are characters, not syntax. table_cell() only kept
the table and the page intact, so a description like `#name = expression`
came out as a code span and a description with `*bold*` came out as
emphasis.

The name and the type keep table_cell(): they are identifiers and
notation, and they are not Markdown prose.

* fix: cut the parameter type at the end of the sentence

A type runs to the end of the clause, so the sentence that follows it
belongs to the command, not to the type: `construction?: bool. Returns
sides {bottom, right, top, left}` showed the whole sentence as the type
of `construction`, and the description of `sides` with it.

The type now ends at a full stop followed by a space and a capital
letter. A dotted or multiword type such as `sketch.rectangle` or
`number or expression` does not match that pattern, so it keeps its
current handling.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-05 19:52:16 +00:00
thocsmith 8c4caf9372 Insert Part help: say which way point and direction place the part (#11729) (#11755)
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 19:51:56 +00:00
austin62jake 202b775de4 Agents and scripts: Add, Rename, Delete, Comment, Favorite and Move Parameter describe their exact parameters and reply (#11941) (#11943)
* claim: #11941

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

* User parameters: input and output schemas for Add, Rename, Delete, Comment, Favorite and Move (#11941)

describe_command now gives the exact parameters and reply of the six
commands that manage a design's user parameters by name, and the warn
and strict parameter checks use them. The handlers read their
parameters from the raw JSON, so the contracts in the new
schema/parameters.rs read each key as its handler does, with its
message; `value` stays as parameters.add's other name for `expression`,
and `comment` is required in the schema but left to the handler's
message in the type. The replies are built from typed values instead of
json! literals, with the same JSON; parameters.add's reply flattens an
untagged AddedValue (a number's value or a text parameter's text).

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

* Parameters: the schemas count a `null` expression and Move's neighbour as left out

`parameters.add` reads a `null` `expression` or `value` as left out
(`expr_arg`), so the schema's "expression or value" alternatives now
also need the key's type (number or text): `{"expression": null}` alone
no longer matches, while `{"expression": null, "value": 3}` does.

`parameters.move` needs exactly one of `before` and `after`. The schema
now says so with a `oneOf` whose alternatives each need their key as
text, so a `null` neighbour (read as left out) counts as neither, and
both or neither is refused. `conforms` doesn't read `oneOf`, so the test
pins it and counts the matching alternatives itself.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-11-05 19:51:46 +00:00
P'tite Tête 2c35a027f5 fix: Edit Feature of a surface revolve maps a new axis into a moved component (#12298)
- timeline.redefine maps a surface revolve's {origin, dir} axis through an edit-only table entry
- a new surface revolve still goes whole through to_active_frame (picked edge and face axes)
- tests: made and edited in a moved and a turned component against the solid revolve; a picked edge axis

Co-authored-by: P'tite Tête <asso@ptitetete.com>
2026-11-05 19:51:18 +00:00
austin62jake 146a43f38e Agents and scripts: Motion Study, List and Delete describe their exact parameters and reply (#12155) (#12156)
* claim: #12155

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

* Motion studies: input and output schemas for Motion Study, List and Delete (#12155)

describe_command now gives the exact parameters and reply of
motion.study, motion.list and motion.delete, and the warn and strict
parameter checks use them. The handler reads its parameters from the
raw JSON, so the contracts in schema/motion.rs read each key as it
does, with its message (a study by id or name, steps, and keys of
[step, value] points); whether the study, joint and value exist stays
the handler's to check. The replies are built from typed values
instead of json! literals, with the same JSON, through `typed_reply`
(the contact commands' helper, renamed now that it serves both);
motion.list's traces stay as motion_trace.rs builds them.

The parameter audit found two tests in cmd/motion_tests.rs sending
`delete: true` to motion.delete, a key only Edit Contact Set takes; a
`deletion` helper now sends it only there.

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

* Motion Study: a point's step must be a number in the schema, as the handler reads it

The schema typed both elements of a key's point as "a number or text",
so `["0", 20]` matched it although the handler refuses a step that isn't
a number ("points are [step, value]"). The two are now typed apart with
`prefixItems`: the step a number, the value a number or an expression,
each under 1e9 in size, as `study_key_problem` and the handler read them.
Elements after those two, which the handler ignores, are left free.

The 1e9 limit, written twice in the key check, is now the named
`MAX_POINT_MAGNITUDE`, used by the check and the schema alike. A new test
checks nine point shapes against both the schema and the key check, so
the two can't drift apart.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-11-05 19:51:04 +00:00
thocsmith e04bc060f6 Web app: clippy for wasm passes with -D warnings (#12222) (#12251)
* claim: #12222

* Web app: clippy for wasm passes with -D warnings (#12222)

cargo clippy -p solidcraft-web --target wasm32-unknown-unknown stopped at
drop(file) in safe_write.rs and showed 12 more warnings only that target
has. Each is fixed where it is:

- safe_write::create_atomic and control_auth::write_token_file_with close
  their File by scope (a block; Result::map's closure) instead of drop(),
  which clippy's drop_non_drop flags where File is a stub.
- Native-only imports, the AHEAD constant and the point-cloud worker's
  Condvar are behind cfg(not(target_arch = "wasm32")); Exported::bodies
  and Start::Later, read or built only natively, allow dead_code there.
- home::ask_present uses ctx in its browser branch; the browser blur stub's
  begin returns a Scope, as the native one does.
- The web app drops three map_err(JsValue::from) on dyn_into's error,
  which is a JsValue already.

Native builds behave the same.

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>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-11-05 19:50:25 +00:00
PeppaWutz 464da90238 Agents and scripts: the Browser folder commands and Rename Body describe their exact parameters and reply (#12276) (#12339)
browser.collapse_all, browser.expand_all, browser.group, browser.move,
browser.order, browser.rename_group, browser.ungroup, browser.visibility
and body.rename now publish 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. The replies are built from typed values and serialize to
the same JSON as before. Part of #2829.

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-11-05 19:49:12 +00:00
SS-EN 0b32d3619d Extract a component whose body was built in another component (#12153)
* Extract a component whose body was built in another component

component.extract now takes along the outside features that built, changed or moved
in a body the component owns, when they touch nothing that stays outside. Otherwise
it names the feature that blocks it. Fixes #12142.

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

* Kernel case: extracting a component with a body moved in now succeeds

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 19:48:19 +00:00
austin62jake eb10552c61 Agents and scripts: the native studio and quality profile commands describe their exact parameters and reply (#11766) (#11768)
* claim: #11766

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

* Render presets: input and output schemas for the native studio and quality profile commands (#11766)

describe_command now gives the exact parameters and reply of
render.preset.resolve and render.preset.apply (review and apply a
native studio composition) and render.quality.prepare (review a fixed
sampling budget), and the warn and strict parameter checks use them.
The handlers already read their parameters with strict serde types;
those types move to the new schema/render_presets.rs, gain descriptions
and JsonSchema, and become the contracts. The replies are built from
typed StudioComposition and QualityPlan values instead of json!
literals, with the same JSON.

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

* Render presets schema: `set` names the setup's fields

As on #11762 and #11742: `patched_settings` reads a `set` patch onto
the saved setup as a RenderSetup, which refuses unknown fields, so the
schema now names the setup's 14 fields with additionalProperties false
(still at most 13 at once). A new test covers it.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-05 19:45:18 +00:00
Homero Thompson e95f65fd54 IO: the STEP import limit is the import limit, not the design limit (#2721) (#11851) 2026-11-05 19:43:45 +00:00
austin62jake 1fdc56835b Agents and scripts: New, Edit, Inspect and Delete Physical Material describe their exact parameters and reply (#11862) (#11865)
* claim: #11862

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

* Physical materials: input and output schemas for New, Edit, Inspect and Delete Physical Material (#11862)

describe_command now gives the exact parameters and reply of
material.create, material.edit, material.inspect and material.delete,
and the warn and strict parameter checks use them. The handlers read
their parameters from the raw JSON, so the contracts in the new
schema/materials.rs read each key as its handler does, with its
message: `null` keeps its meaning (`color: null` and `physical_card:
null` clear), the look keys reuse the appearance commands' types, and
the card keeps the handler's own check, with its schema from
schema/material_properties.rs (whose card_schema becomes pub(super)).
The replies are built from typed values instead of json! literals, with
the same JSON.

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

* Colour schema: a colour's text form allows what color_param reads

inspect::color_param trims the text and drops any number of leading
`#`, so "  #ff00ff  " and "##ff00ff" are colours to appearance.assign
and the material commands, but ColorParam's published pattern refused
them. The pattern is now built from schema::types' whitespace class (as
AxisDir's is), which becomes pub(crate), and a test pins it next to the
reader's answers.

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

* Colour pattern: a byte may be `+` and one hex digit, as the reader takes it

`inspect::color_param` parses each of the three bytes with
`u8::from_str_radix`, which accepts a leading `+`, so "+fff00" is read as
[15, 255, 0] today. The schema's pattern asked for six hex digits and
would have made strict refuse a colour the handler accepts. The pattern
now allows either two hex digits or `+` and one digit per byte, and the
test pins both the reader's answer and the pattern's text, since
`conforms` doesn't read patterns.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-05 19:42:20 +00:00
austin62jake 3fde538320 Agents and scripts: Inspect HDR Source and Save Render Settings with HDR describe their exact parameters and reply (#11761) (#11762)
* claim: #11761

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

* Render HDR: input and output schemas for Inspect HDR Source and Save Render Settings with HDR (#11761)

describe_command now gives render.hdr.inspect's and
render.settings.capture_hdr's exact parameters and reply, and the warn
and strict parameter checks use them. The handlers already read their
parameters with strict serde types; those types move to the new
schema/render_hdr.rs, gain descriptions and JsonSchema, and become the
contracts, so the check is no stricter than the handlers. The replies
are built from typed HdrInspected and HdrCaptured values instead of
json! literals, with the same JSON. `expected_sha256` is the doc crate's
HdrId, whose schema is written by hand (64 lowercase hex digits or null).

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

* Render HDR schema: `set` names the setup's fields, and its job may be the saved one's

`patched_settings` puts a `set` patch's keys onto the saved setup and
reads the result as a RenderSetup, which refuses unknown fields. The
schema now names the setup's 14 fields with additionalProperties false
(still at most 13 at once), so `set: {"widht": 10}` is refused, as the
handler refuses it; the values stay the handler's to check. The
description now says the setup the changes make (a saved one keeps the
fields left out) needs a cpu_raytrace or gpu_path job, not that every
patch must give one. A new test covers the field names.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-05 19:41:27 +00:00
SilentWolf75 283ccd0b21 Web: tab title follows a renamed design (#11375) (#11381)
* claim: #11375

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

* Web: tab title follows a renamed design (#11375).

When an OPFS design is renamed, open tabs keep the new path and a borrowed design name so the tab matches the file list.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 19:41:04 +00:00
erman 6b222aedf7 test(ui): cover #11088 Paste New parent context (#11110)
* claim: #11088

* test(ui): cover Paste New into a selected parent

* test(ui): use SolidCore Vec3 in clipboard regression

* test(ui): verify copied face attachment through menu

* style(ui): apply clipboard test formatting

---------

Co-authored-by: ermanyop-stack <278786764+ermanyop-stack@users.noreply.github.com>
2026-11-05 19:39:38 +00:00
CamWight bcdbb91f26 Assemblies: one children-by-parent index for the occurrence walk (#12173)
Document::placements scanned every occurrence for each component it visited,
so its cost was placements times occurrences, on every world placement. The
new OccurrenceTree (crates/doc/src/occurrence_tree.rs) lists each component's
children once, in one pass and in document order; placements walks it with
the same order, caps and stop behaviour, through placements_with, which takes
each occurrence's transform from the caller. inside_component reads the same
index instead of scanning per component.

Tests compare the walk with the scanning one on 13 shapes and on hostile
generated documents, at its caps, in document order and with a given
transform. Release, 97,540 placements: about 120 ms -> 15.5 ms, the same in
either run order (docs/perf.md).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 19:39:35 +00:00
cronos 21a1269d5c Sketch Trim: keep the stored offset distance after deleting its dimension (#12186) 2026-11-05 19:39:21 +00:00
Alastair D'Silva d580c4256c Evaluation: add --validation to solidcraft-cli eval and skip largest-fit search when validation is off (#12136) 2026-11-05 19:39:16 +00:00
SS-EN 1b52186524 Timeline: reordering a feature says when nothing moved and returns its new index (#12171)
* timeline.reorder: say when nothing moved, and return the new index (#12140)

A position past the end was capped to the last slot; when the feature was
already there the command reported success with no sign nothing moved. The
reply now gives position (index now), from and moved, with a note that says
why nothing moved or that the last slot was used. A move that puts a feature
ahead of what it uses says so, and the command refuses before changing the
design if the feature would not land at the asked position.

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

* timeline.reorder: word a refused move by its direction; note a marker-only change

A feature moved later can fail for reasons other than order, such as a cut that
removes its body, so the move-earlier hint is given only when it moved earlier.
With `active`, a feature left in place gets a note saying only the history
marker moved, or that nothing moved.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 19:38:38 +00:00
Thijs Damsma 3e6eadc118 Offline web test timeouts show which build each script ran (#3956) (#12206)
* claim: offline script marker diagnostics for #3956

Co-Authored-By: Codex <noreply@openai.com>

* Report script build markers in offline web test timeouts

Expose the existing started and shared dataset markers in diagnostic state. Preserve missing markers as null without changing readiness or retry behavior.

Co-Authored-By: Codex <noreply@openai.com>

---------

Co-authored-by: Codex <noreply@openai.com>
2026-11-05 19:37:59 +00:00
ABDULLAH 87358ef724 Arabic: component spelling, tab-close tip, MCP compact note, surface grain angle, Output names, mouse word, Hole bridging, intersection curve help, ground shadow tip, pan term, Move names and newer-file notices (#12151)
* Arabic: one spelling of component in the Browser rows start closed note, dialog wording in the tab-close tip

* Arabic: no direction marks in the MCP compact output note

* Arabic: general term for the surface grain angle, Output name in the image import note and the Output inspector header

* Arabic: one word for the mouse in the right-click notes of the intersection curve pick and the Play History button

* Arabic: bridging term in the Hole dialog, intersection curve help pronoun, ground shadow tip wording

* Arabic: pan term in the drag-keeps-mode help and the vertical pan direction option

* Arabic: Move Sketch Points name, Move tool name in the drag highlight preference, file format wording in the newer-file notices
2026-11-05 19:37:36 +00:00
austin62jake 3d73822442 Agents and scripts: Parameter Sweep's schema requires its parameter, as text (#12312)
* claim: Parameter Sweep's schema requires parameter

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

* Parameter Sweep: the schema requires `parameter`, as text

`inspect.parameter_sweep` refuses a call without `parameter`, or with a
`null` or numeric one ("`parameter` is required (parameters.list)"), but
its published schema (#10978) neither required the key nor ruled out
`null`: the field's `#[schemars(required)]` has no effect on a key with
`serde(default)`, and `Option<String>` publishes `["string", "null"]`.

The contract keeps the key optional in its type, so the handler, not the
check, gives its own message when it's left out; the schema now requires
it through a struct-level transform, as the contact commands do, and
types it as a string. A new test checks the schema against a call
without the key, with `null` and with a number; it fails without this
change.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-05 19:37:22 +00:00
Homero Thompson 51f4074506 Split and Trim errors name the command a script can run (#4712) (#11205)
* Sketch modify: name the public command in the split and trim errors (#4712)

Part of #4712.

The split inside the sketch modify tools refused in the name of the
internal `split` step, and the free-form trim in the name of the
internal `trim` one: `invalid parameters for `split`: a circle needs
two points to split at` names a command nobody can write, so an agent
or script reading the message has nowhere to go.

Both now name the public command the caller used: `sketch.modify` for
the split shared by trim and break, `sketch.trim` for the trim. The
descriptions are unchanged.

Co-authored-by: Hermes Agent <noreply@anthropic.com>

* Sketch modify: the split names the command the caller used (#4712)

`sketch.modify` is not a command a caller can write: the registered
sketch commands are `sketch.trim`, `sketch.trim.path` and `sketch.break`.
The split shared by trim and break therefore refused in the name of a
command that does not exist, and a script reading the message had
nowhere to go.

`split_at` takes the command of its caller and refuses in that name:
`sketch.trim` when the trim splits the curve, `sketch.break` when the
break does. Both callers already had the command in hand.

The test now states the decision: the refusal of `sketch.break` carries
`sketch.break`, and the rendered text names no other command.

Co-authored-by: Hermes Agent <noreply@anthropic.com>

---------

Co-authored-by: Hermes Agent <noreply@anthropic.com>
2026-11-05 19:28:22 +00:00
github-actions[bot] 796b7c40d3 Regenerate generated files and fold pending files [skip ci] 2026-11-05 19:17:37 +00:00
Frank 9cdee588f2 Extrude Two Sides: fix the side and missing value box of Distance 2 (#11203)
* Extrude: Two Sides keeps each distance on the side Direction gives it

Distance 2 stays on the side Direction gives it, whatever the sign of Distance 1, and its value box shows on the canvas. Rebased onto main as one change.

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

* Two Sides extrude: keep older designs' sides by a marker, and the full profile search

- A saved two-sided extrude is marked `second_follows_first` instead of having a literal
  negative distance rewritten: expressions such as `-width` keep the old sides when their
  parameters change, `-0 mm` keeps its span, and a feature's named inputs keep their values.
- Live sketch drags and the restored model cache use the full profile search evaluation uses,
  so a sketch whose only closed boundary is projected keeps its profiles.
- The canvas Distance 2 box stays while its text does not evaluate.

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

* Tests: give the extrude added on main the second_follows_first field

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 18:39:09 +00:00
Nathan Dane 90d9c6488a Panels: add columns, stacks and tabs with exact drag previews (#11305) (#11608)
* claim: #11305

* Panels: add columns, stacks and tabs with exact drag previews (#11305)
2026-11-05 18:38:54 +00:00
Nathan Dane edb7882815 Appearance: browse themes in a carousel or grid, filter them, and set Day and Night themes (#12120)
* Appearance: theme picker with carousel, grid, filters and Day/Night slots

* Appearance: theme picker arrows move a screenful of themes

* Theme picker: translations, token metrics, wheel helper in navigation, reveal tolerance

* Theme picker: keep both new Appearance preferences after the rebase

* Appearance: adapt theme picker to egui 0.37
2026-11-05 18:38:42 +00:00
PaddleStroke db3558d820 Extrude: the distance arrow snaps to a parallel face and outlines it (#2398) (#9688)
* claim: #2398

* Extrude: the distance arrow stops at a parallel face and outlines it (#2398)

While Extrude's distance arrow is dragged, a flat face parallel to the
profile and lying over it takes the distance exactly once the tip comes
within 8 screen points of its plane, and the face is outlined as a hovered
face is. A cut dragged through a block so stops at its far side. Faces
beside the profile don't catch the arrow; Alt/Ctrl (free drag) turns it off.
The distance stays a number in the existing parameter.

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

* Extrude face stop: clippy (test-only current, +=)

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

* Extrude face stops: a preference, on by default, keeps the old round steps (#2398)

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

* Extrude face stops: follow world_state returning a Result (#2398)

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

* Extrude face stops: import Vec/Plane from solidcore_record after the layer split

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-11-05 18:31:55 +00:00
Jens b2f5da6ca7 joints: support custom names and joint type-dependent default names (#10340) (#10165)
* joints: support custom names in dialog and type-based default names

Allow users to specify a custom name in the Joint dialog and generate
type-prefixed default names (e.g. "Planar Joint1", "Revolute Joint1")
instead of generic "Joint{n}".

- Add `name` field to `JointForm` and display a text row in the Joint
  dialog with a dynamic placeholder matching the chosen type.
- Update `new_joint_name` in `cmd/joints.rs` to take `JointKind` and
  prefix auto-generated names with the joint kind label.
- Prioritize non-empty custom names and pass them through `joint.create`,
  `joint.as_built`, and `joint.edit`.
- Pre-populate the existing joint name in `edit_joint` to allow renaming
  directly from the Edit Joint dialog.

* joints: add tests for custom and type-based joint naming

Verify that the joint dialog and engine command layer correctly generate
type-prefixed default names and respect user-specified custom names.

- Add engine-layer test `new_joints_generate_type_prefixed_names_or_keep_custom_name`
  to verify `joint.create` and `joint.as_built` naming logic.
- Add UI-layer test `joint_dialog_applies_custom_name_or_falls_back_to_type_default`
  to ensure the `JointForm` captures and submits names correctly.

* Format dialogs_assembly_tests.rs (trailing blank line)

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

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 18:31:43 +00:00
Steven Waters f1159a5d51 Added mesh import units for STL and OBJ (#10406)
Co-authored-by: Claude <noreply@anthropic.com>
2026-11-05 18:31:26 +00:00
github-actions[bot] ffc84dbe2e Regenerate generated files and fold pending files [skip ci] 2026-11-05 17:28:40 +00:00
Yasen-55 942e1540db Bulgarian localization (#8256) (#8303)
* claim: #8256

* Add initial Bulgarian translation (114 keys)

* Delete claim.txt

* Delete translate.py

* Delete assets/locales/bg.json

* Restore the bg catalog the locale list needs, fix indent

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

* Bulgarian: translate the strings the catalog tests require, drop ids that no longer exist

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

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 16:28:09 +00:00
austin62jake 6500c42c9f Agents and scripts: sketch Copy, Cut and Paste describe their exact parameters and reply (#10963) (#10964)
* claim: #10963

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

* Sketch Copy, Cut and Paste: input and output schemas (#10963)

sketch.copy, sketch.cut and sketch.paste get typed contracts and reply
types in schema/sketch_clipboard.rs and leave unconverted.txt. `at` and
`translate` read as the paste reads them, with its message; the replies
are built from Copied and Pasted, with the same JSON as before.

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

* Sketch clipboard: import Vec2 from solidcore_record after the layer split

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-11-05 16:14:13 +00:00
github-actions[bot] 655c1f3336 Regenerate generated files and fold pending files [skip ci] 2026-11-05 16:21:12 +00:00
SamD98 9f2e117934 Revolve follows the highlighted sketch edge (#7452) (#7479)
* Revolve follows the highlighted sketch edge (#7452)

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

* Attribute the #7452 screenshots and recording

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

* Fix needless borrow in the revolve axis test

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

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 15:31:53 +00:00
github-actions[bot] c6fe613d32 Regenerate generated files and fold pending files [skip ci] 2026-11-05 15:50:37 +00:00
Leo d5e953871b Sketch Mirror: use the preselected construction line (#10486) 2026-11-05 15:09:19 +00:00
kaltenbrunnermarkus c7dcab172f Viewport: no dark patch between nearby bodies (#10442) (#10513)
* claim: #10442

* Viewport: no dark patch between nearby bodies (#10442)

The ground shadow was one ellipse under the box around all shown bodies,
darkest in its middle, which for two bodies side by side is the empty
floor between them. Bodies whose footprints meet now share one ellipse
(as before) and bodies apart get one each, on the same ground level and
with the same rings; more than 64 groups fall back to the single ellipse.

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

---------

Co-authored-by: Markus Kaltenbrunner <kaltenbrunnermarkus@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 14:38:01 +00:00
yhamil04 8312bf0cb8 Create Sketch: pick an origin plane through a body in front of it (#5431) (#6597)
* claim: #5431

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

* Create Sketch: pick an origin plane through a body in front of it (#5431)

While a dialog waits for a plane, the origin and construction planes are
drawn over the bodies, but picking sorted them by depth, so the face of a
body in front always took the click. When the nearest face hides the
nearest plane under the pointer, the planes now sort just ahead of that
face (in their own depth order, still behind a profile on it). A face at
the same depth as a plane still wins the tie (#31), and nothing changes
when no dialog is waiting for a plane.

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

* view_confirm_corner: draw the line where the view shows it

Create Sketch now picks the origin XY plane through the extruded box, so the
sketch is flipped to face the view and (50, 0) -> (70, 10) fell outside it.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-11-05 13:11:52 +00:00
github-actions[bot] 8f20ad4532 Regenerate generated files and fold pending files [skip ci] 2026-11-05 13:17:29 +00:00
DuBearte abdf195132 Sketch: sketch.move_points moves several points, each to its own target (#5961) (#5978)
* claim: #5961

* Sketch: sketch.move_points moves several points, each to its own target (#5961)

A script editing a traced outline could move one sketch point per call,
and only by its id: an agent that had to move two points of a
490-segment polyline redrew the whole sketch instead, breaking every
dimension and feature built on it.

sketch.move_points {moves: [{point | near, to}], tolerance?} drags each
point to its own target in one edit (one undo step), as sketch.move_point
drags one, and the sketch is solved. `near` picks the sketch point
closest to a coordinate, refused when none lies within `tolerance`
(default 0.1 mm) or two are as close. Every point is found before any
moves. The reply gives each point's id, its target, where it ended up and
whether it got there, so a fixed or constrained point is reported rather
than silently left in place.

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

* Move the new command label to a pending locale file

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

* Fix the new command for the sketch store and geometry split

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

* Carry the new command label in every complete catalog

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

* sketch.move_points reads the sketch through sketch_store

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-11-05 12:20:00 +00:00
bherbruck 7e002f182d Opening a design from a newer SolidCraft: it opens with a notice instead of being refused (#12166)
* Open a design from a newer SolidCraft with a warning instead of refusing it

A newer format number or an unknown change no longer blocks opening. What
this version can read opens; a feature it can't read stays on the timeline
with an error, and fields, sections and container parts it doesn't know are
named in the reply and written back by Save, under the file's own number.

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

* Saving a newer design from the CLI, and editing a feature this version can't read

`run --save` writes a design holding a feature only a newer build can build;
`--out` still stops, since it would export the part without that feature.
Edit Feature on such a feature says why instead of opening the palette.

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

* Translate the newer-design notice for the complete catalogs; clippy

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 12:18:25 +00:00
JK3D f0d305a783 Move triad: dragging in large designs does less work per step (#7273) (#7276)
* claim: #7273

* Viewport: drag a component as a GPU layer of its own

Each step of a Move triad drag placed the component anew, and the
viewport then rebuilt the model scene and uploaded it again. The dragged
bodies are now drawn from the scene built where the drag found them,
moved on the GPU by the drag's motion; the rest of the model stays as it
was. In the viewport a step meshes, builds and uploads no geometry;
only the transform (and the ground shadow) changes.

- gpu: Frame::moving draws a second scene with its own model matrix
  (U.model, applied in every vertex entry, instanced parts included);
  blocks, evaluated chunks and instanced triangles pass between the
  model scene and the layer instead of being uploaded again; glass is
  sorted in the layer's frame; the AO prepass and see-through pass
  cover it; the frame key includes the layer.
- doc: ModelBody::display_moved_from gives the rigid motion between two
  worlds' copies of one solid.
- ui-egui: the model scene without the dragged bodies is built once;
  dragged bodies are matched by name; the step's body boxes are the
  start boxes moved; selected or hovered dragged bodies light where they
  stand. A drag back where it began keeps its layer. A section, an
  analysis, continuity seams or no GPU draw the drag as before;
  capturing the move makes the scene whole again.

* feat(ui): a setting to keep the highlight under the pointer while Move drags

A Move triad drag drawn as a GPU layer picks nothing under the pointer, which keeps a step from
meshing the dragged bodies where they stand and from looking up the Browser row a highlight
follows. Preferences > Tools > Assembly Move > "Highlight what is under the pointer while dragging
a component" (`move_drag_highlights`, off by default) picks and highlights as before.

Also: the drag layer test takes `as_chunks` (clippy 1.99's `chunks_exact_to_as_chunks`).

* fix(ui): say when the Move drag highlight setting applies

The label names Move's arrows, and the tooltip says that with a section or an analysis shown the drag highlights all the same, and that a part dragged along its joints never does.
2026-11-05 12:12:06 +00:00
kaltenbrunnermarkus dc33c4a24d Saving: designs with many threaded holes keep all saved states (#10601)
* claim: tap marking records once in the model cache

* Model cache: write each tap marking record once and share it between states

Problem: every state of a saved model wrote the cosmetic tap markings of all the features
before it, so the records grew with the square of the features. A design of 400 marked
cylinders wrote 34 of its 37 MB manifest that way: past the 32 MiB limit the save thinned the
cache (590 of 806 states left out, opening evaluates them again) and wrote the manifest
several times while choosing what to keep.

Solution: the records keep a list of markings, each written once (keyed by its text, as the
sheet bodies are); a state names its markings as runs of places, like its bodies and
sketches. A marking at the same place in the state written just before, equal to the bit, is
found without writing it as text. A thinned model keeps the whole list and counts it once;
the reader counts the markings with what a state holds. The cache format is now /6: an older
cache is a miss and the design is evaluated as before. The design document is unchanged.

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

* Saved model: count each tap marking by its text against the held limit

A state clones every marking it lists, so a run repeating one large marking could hold far more than MAX_HELD items of memory.

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

---------

Co-authored-by: Markus Kaltenbrunner <kaltenbrunnermarkus@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-11-05 12:11:07 +00:00
GreG Petersen 2405f75c38 Viewport: shade the selected face fill, so a selected STL keeps its shape (#3761)
A selected face was filled flat in `sel_face`. An imported mesh (STL, OBJ)
is a single face, so clicking it in the viewport painted the whole part as
one flat blue silhouette and hid every wall, pocket and edge. The fill is
now lit like the hover fill, which already was.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 11:42:56 +00:00
semmelrinde de07905f78 Sketch Offset: an inward offset past a narrow neck is refused, not looped (#11055)
* Sketch: an outline drawn inside a shape past its neck is refused, not looped

An inward offset of a closed outline wider than half a neck of the
shape would pinch it into two; one chain can't be that, and its pieces,
each joined only to its neighbours, ran back over each other in a loop.
When the outline itself doesn't cross itself and its offset would, the
offset is refused like other offsets the shape can't take. An outline
that already crosses itself (a bow tie) keeps its crossing offset.

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

* Loop guard: the arc's direction comes with its source, not with the offset piece

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

* Loop guard: a touch counts, lines along each other count, tolerances from the outline's size

An outline split where it crosses itself only touches itself there and
was taken for simple, so its offset was refused: between pieces that
aren't neighbours a touch now counts as crossing. A neck exactly twice
the distance gives two lines lying along each other, which cross at no
point: such an overlap now counts too. The tolerances come from the
outline's own size plus the coordinates' rounding, not from how far it
lies from the origin.

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

* Sketch offset: pass the arc direction from the outline after the dog bone change

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-11-05 11:34:02 +00:00
sommerper 1cf7ae8e5c Sketch: dragging one of several selected sides no longer runs ahead of the pointer (#11324)
* Sketch: a selection held back in part follows the pointer, not past it

Dragging a selection part of which cannot move (a rectangle with a corner
on the origin, all four sides selected) took the dragged side nearly twice
as far as the pointer. `sketch.drag` reported the grab where the average of
all dragged points went; the held corner kept that behind the side, and the
next frame's catch-up overshot. It now reports how the dragged point that
got furthest toward the pointer moved, so what can follow moves 1:1.

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

* Sketch drag test: unwrap the world state after the engine change

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-11-05 11:33:48 +00:00
PaddleStroke e9efcf4c0b Sketch: scale profiles symmetric about a sketch axis (#11625) (#11708)
* claim: #11625

* Sketch: scale profiles symmetric about a sketch axis
2026-11-05 11:33:32 +00:00
NOWN-DEV 7546e84ef4 Sheet metal: a corner torn between flanges of two features closes (#7126) (#7136)
* claim: #7126

* Sheet metal: a corner torn between flanges of two features closes (#7126)

With the rule's corner relief `tear`, a flange made after another,
meeting it at a corner of the base, left the corner open: its setback
stopped the earlier bend at the corner while that panel ran on R + T
past it (an (R + T) x BA rectangle missing in the flat), and the new
panel stopped at the corner, R from the earlier panel's inner face
rather than the rule's gap. Folded, a hole about R + T across showed at
the base corner. A corner between the flanges of one feature is torn
(#809); one between two features was not.

Such a butt corner is now closed and torn: the new panel ends the gap
from the earlier panel's inner face, and both bends run on to where
their panels' ends start, each end on the plane through its start at
the corner (on the bend's outer face) and the outer edge of its panel's
end. In the flat that end is (cos(th - phi) - cos th)/(1 - cos th) of
the way along at the turn phi (sin phi at 90 degrees), one Bezier of
degree 9 (`tear_curved`). Folded, the corner closes but for a sliver.

In a thick sheet the bends' inner faces would cross beside the corner
(at 90 degrees where R^2 < (R + T)(R - g): with the gap 0.8 from about
2 mm). There the new bend's end eases toward (1 - cos phi)/(1 - cos th)
of the way along (`tear_ease`, still one Bezier), as little as keeps the
bends apart; the earlier bend and both panels' ends stay as they are.
A corner that can't be torn so (opposite or over-90-degree bends, a gap
wider than the radius, overlap in the flat) keeps what it had, with a
note.

The test that pinned a two-feature corner as never torn keeps its
open-corner half; the two-feature corner moves to the new tests, which
cover sheets from 0.9 to 3 mm.

Fixes #7126.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 11:26:40 +00:00
semmelrinde 278ab40a0f Sketch Dimension: a slanted line gets an aligned dimension by default (#8582)
* Sketch Dimension: aligned across the line, horizontal or vertical by the pull past an end

Review on #7355: the aligned length must stay the default, and the box
rule shrank its zone to a thin strip on a shallow line. Now `auto` is
the distance between the two points everywhere across from them
(between the perpendiculars through them, and a tenth of their distance
past), and only a clear pull past an end changes it: up or down from
that end gives the horizontal distance, sideways the vertical one. The
two meet on the diagonals through the end, so both zones stay wide on a
shallow or steep line. Midpoint pairs keep main's box rule.

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

* Sketch Dimension: two midpoints keep the distance between them past their box's corners

As before #7355 and on main: text above or below the midpoints' bounding box
measures horizontally, beside it vertically, and anywhere else (inside it or
past its corners) the distance between them. The corners fell through to the
line rule and became horizontal or vertical (found in review).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 11:26:03 +00:00
github-actions[bot] ed7206cc0d Regenerate generated files and fold pending files [skip ci] 2026-11-05 11:14:17 +00:00
JIJINBEI 16f3b939b3 Update egui/eframe to 0.37 (#9933)
* Update egui/eframe to 0.37 (WIP)

egui, eframe and egui-wgpu 0.36 -> 0.37. SolidCraft's egui-wgpu patch is
rebased onto 0.37.0; the vendored accesskit_macos is dropped (0.27.2 has
the #7535 fix).

Not finished: 36 solidcraft-ui-egui lib tests fail, and the full lib run
crashes with SIGSEGV when tests run in parallel.

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

* Tests: clear texture deltas that egui 0.37 refuses to drop

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

* Drop non-finite pointer input before egui; keep test time monotonic

egui 0.37 debug-asserts on NaN wheel deltas, on time moving backwards in the
pointer history, and on add_space inside a Grid.

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

* Hold a click that follows typing in the same frame until the next frame

egui 0.37 drops a field's focus on a click elsewhere when the field first
reads its style, before it reads the frame's typing, so text typed in the
frame of a click was lost.

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

* Name a document tab's close button "Close tab"

egui 0.37 names an unnamed widget after its tooltip, so the tab's × became a
second control named Close beside a dialog's. The space mouse guard test now
clicks where each button was: by name it found nothing behind the guard.

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

* Turn on egui's default_fonts: 0.37 no longer does

The built-in Hack is the UI's symbol fallback (#2955). The app got it through
eframe, but solidcraft-ui-egui on its own (its tests) did not.

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

* Text menu: act on the selection the menu click took the focus from

egui 0.37 drops a field's focus on a click elsewhere before the field draws,
and an unfocused field loses its selection, so Copy, Cut and Paste from the
right-click menu saw no selection.

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

* Placement field: keep its native height under egui 0.37's interact floor

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

* Tests: pin the theme's 2/3 point radii; measure Japanese ribbon labels with a Japanese face

egui 0.37 rounds widgets 4 points by default, which the theme deliberately
does not follow. The ribbon test measured Japanese with missing glyphs, whose
width changed in 0.37; with BIZ UDPGothic, 駆動 wraps after its particle like
変換 does.

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

* Tests: click the render inspector's Perspective checkbox by position

egui 0.37 orders the accessibility tree differently, so the name found the
stage's Perspective segment first.

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

* Translate "Close tab" for the complete catalogs

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

* Point the focus workarounds at emilk/egui#8763

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

* Cargo.lock: update only what egui 0.37 needs

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

* Web: Id::new -> Id::unique for the wasm-only windows

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

* Command search: keep its hint centred under egui 0.37's interact floor

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

* egui 0.37 renames in code main added since the branch

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

* egui 0.37 renames in code main added since the last rebase

Id::new to Id::unique, ui.id() to ui.scope_id(), WidgetType to Role, the
wheel events' source, and the button stroke read from the widget visuals.

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

* Review: disclosures report expanded, late screenshots drained, chat width reset on both sides

A disclosure's node says whether it is open as expanded, as egui's own
collapsing header does. The screenshot channel is drained every frame, so a
shot whose request was cancelled is dropped. Resetting the chat panel's width
forgets both sides' state, as before 0.37, and the request stays with its
window when tabs switch. Tests main added since the last rebase clear their
texture deltas.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 10:31:50 +00:00
CaRLymx ad230eb4a4 Catalan (ca): retranslate the catalog from English (2026-11-05) (#10896)
* i18n: register the Catalan catalog

Add `ca` to the locale list, between Bosnian and Czech, and attribute
assets/locales/ca.json.

* Catalan (ca): translate preferences and the 61 new messages

- preferences: every prefs.* message is now Catalan (355 strings).
- the 61 messages added upstream since the last catch-up: the layout page,
  sketch options, navigation options and the custom navbar anchors.
- drop the four keys upstream no longer has: thread.direction,
  thread.left_hand, thread.right_hand and ui.print_face_one_body.

Work in progress: ui.*, cmd.*, render.*, the loose sentences and help.*
still mix Spanish and Catalan.

* Catalan (ca): translate the first half of ui.*

400 messages: the 3D mouse settings and calibration, the agent chat and the
AI preferences.

* Catalan (ca): translate ui.* up to the equation and edit-in-place messages

200 more messages: the toolbar and browser controls, colour vision, design
properties, the details panels and editing a linked part in place.

* Catalan (ca): translate ui.* up to the navigation bridge

200 more messages: the ribbon headings, the history panel, the graphics
presets, the physical material review and the mesh formats.

* Catalan (ca): finish ui.*, 1,284 messages

The ribbon headings, the properties, the slicer and point cloud panels, the
view angles and the workspace descriptions are now Catalan. ui.* has no
Spanish left.

* Catalan (ca): translate the command names, 508 messages

Every cmd.* entry, including the render library, the sheet metal tools, the
sketch tools and the named views.

* Catalan (ca): translate the CPU render settings and the environment panel

200 more messages: the CPU light rigs, the reflection targets, the resource
profiles, the preflight details and the stage and HDR environments.

* Catalan (ca): translate the render errors, the shot framing and the inspector

200 more messages: every render error, the frame presets and guides, the image
status, the HDR source details and the appearance inspector.

* Catalan (ca): translate the render library, the lights and the looks

200 more messages: the resource catalog, the physical light editor, the colour
looks and the GPU material and glass overrides.

* Catalan (ca): translate the render output, results and ribbon

200 more messages: the output editor and its size limits, the presentation
filters, the completed-image results, the aging and physical references and
the render ribbon.

* Catalan (ca): finish render.*, 2,392 messages

The saved shots, the stage panels, the studio lights, the subsurface and volume
transport, the wear layers and the render browser. render.* has no Spanish left.

* Catalan (ca): translate the standalone labels, 200 messages

The browser and panel labels, the sheet metal bends and reliefs, the boss, rib,
rest and snap fit drag hints and the issue report steps.

* Catalan (ca): translate the export, folder and material labels, 200 more

The export menu, the sheet metal bend table and reliefs, the font and history
options, the joint labels and the issue report help.

* Catalan (ca): translate the remaining panel labels, 200 more

The report and recovery messages, the profile and edge pickers, the rules
editor, the screenshot steps and the sketch selection prompts.

* Catalan (ca): translate the sheet metal, AI preferences and browser strings

200 more: the standing-up offer for Y-up files, the trim and stitch tools, the
AI availability and permission settings and the browser node names.

* Catalan (ca): translate the crash reports and a large part of help.*

200 more: the copies, the crash reporter, the DXF export progress, the file
loading statuses and the help texts for the sketch and solid tools.

* Catalan (ca): translate the home page, the hole presets and the material editor

200 more: the start page and its dates, the install steps, the issue report
notices, the assembly move handles, the material property card and the motion
keys.

* Catalan (ca): translate the navigation bar and the input presets

200 more: the navigation modes, the navbar customiser and its descriptions,
the network share warning, the fastener dimensions and the picking prompts.

* Catalan (ca): finish the catalog, 5,591 messages

The search box, the settings notes, the sheet metal personal rules, the sketch
report and text tools, the timeline menus, the updater and the web file
prompts. The catalog now has no Spanish left.

* Catalan (ca): translate the 634 messages the pending script had missed

The script only flagged strings identical to the Spanish or English catalog, so
every untouched string that differed from es.json stayed in Spanish: the whole
help.* reference, the import report, the joint limits, most preferences and the
fillet and continuity tips. They are now Catalan, and the script counts a
message as done only when a block file contains it.

* Catalan (ca): keep the sketch hints and stop splitting l·l

The sketch tool hints and engine errors are not in en.json, so they live in a
pending file: add the Catalan messages there instead of letting them fall back
to English. The new asset gets its attribution row under attribution/pending,
so this branch no longer edits ATTRIBUTION.md directly.

The painted-text sweep split labels on the bare middle dot, which is Catalan's
l·l inside a word: "Cancel·la" became "Cancel" and the test reported English
where the catalog is translated. Split only on the spaced middle dot.

* i18n: carry the complete catalogs in the pending sketch hints

A pending file that adds English messages also adds them for the catalogs
delivered complete (ar, he, ja, vi, zh), or i18n --check fails. They keep the
English text, as in the Spanish catch-up branch.

* i18n: attribute the pending hints and fix the break message

The pending sketch hints add an asset, so they get the same attribution row as
in the Spanish catch-up branch (same file, so the two merges without a
conflict). The engine has nothing to break, so say so in English.

* Catalan (ca): translate the 30 new messages

The blur preferences, the dimension step settings and the fillet turn modes
arrived upstream after this branch was opened; merge main and translate them.

* i18n: use US spelling in the pending hints and add en-GB

The English catalog is US-spelled, so the English fallbacks say center; en-GB
keeps centre for the four messages that differ.

* Catalan (ca): fold the pending hints and catch up with main

`main` folded `sketch-tool-hints.json` into every catalog when #10895 merged
and deleted the pending file. This branch still carried it, so the merged tree
failed `cargo xtask lists` with 186 duplicate ids and one repeated attribution
row. The 26 hints now live in `ca.json` and the two pending files go; the
catalog row in `attribution/pending/catalan-catalog.md` stays.

The 118 messages `en.json` gained since this branch opened are translated: the
26 hints and 92 new ones (AI HTTP and instructions preferences, surface-map
import, the ribbon-tabs-only view, hidden-edge selection, recovery, the custom
view navigator and a handful of dialog tips). The obsolete
`cmd.inspect_colored_components_disable` is gone, so `ca.json` matches
`en.json` key for key.

The shaded-with-hidden-edges label said `Sombreado`, which is Spanish.

---------

Co-authored-by: CaRLymx <24634598+carlymx@users.noreply.github.com>
2026-11-05 10:31:17 +00:00
Mario Geiger c1760d4857 Point clouds: draw a PLY file's edges and faces (#8817) (#8820)
* Point clouds: a PLY file's edges and faces as beads and triangles (#5151)

A PLY file's `edge` and `face` elements become segments and triangles
between the cloud's points (a face as a fan), each with its own properties;
a file with them is read whole, since thinning would cut them. A point's
numbers vary along the segments and across the triangles that meet there,
an element's are constant on it.

Segments with `width` and `height` are drawn as beads of that stadium
cross-section, meeting mitred where one runs into the next, from one
indexed template; without them, as lines. Points no segment or triangle
ends on stay dots. A range filter cuts an element where its value crosses
an end, on the GPU and in the tracks alike: each element weighs its
measure (a dot 1, a segment its length, a triangle its area, each kind
over its own total) and spreads it exactly over the values it covers, a
segment's evenly and a triangle's as the tent its corners make.

Columns saved without the element they are on are on the points, so
designs saved with point properties alone still open.

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

* Point clouds: past max_points a PLY is thinned again; the near plane cuts elements exactly

A PLY with edges or faces and more vertices than `max_points` was refused, where it used to be
thinned. It is thinned again: thinning would cut its edges and faces, so they are left out, with
a warning, and raising `max_points` keeps them.

A segment or triangle crossing the near plane moved its corners behind the eye to a made-up
place, which pulled a wedge across the view. A triangle now keeps its true clip-space corners,
which the rasteriser clips exactly. A bead is cut where its axis crosses the plane, a crossing
exact because clip-space coordinates are affine along a line, and its numbers are exact there.

A ramp accepts MAX_BINS bands but an element was split into 4097, so a ramp finer than that lost
measure: half of a segment at 8192. Only the whole numbers have more bands than MAX_BINS.

`xtask mesh-decisions` reads any field named `triangles` as a mesh; a cloud's own complex holds
none, so its three modules are skipped with that reason.

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

* Point clouds: a ramp's measure is whole past both its ends; a column on the points says nothing

A ramp of MAX_BINS bands has one band below it and one above it, so an element running past both
ends crosses MAX_BINS + 1 bands past its first. The limit was one short, and such an element lost
what lay above the ramp.

A column on the points now leaves `on` out when saved, so a design holding only points keeps the
form it had, as the field's comment and the pending format note already say.

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

* Point clouds: a set filter keeps part of a bead whose ends differ; an unreadable point takes only what ends on it

The set filter's whole-element test read the first corner alone, so a bead whose
first end was not kept vanished, while the range tracks counted the part kept. It
now drops a bead only when one value at every corner is not kept; otherwise the
fragments decide, as for triangles.

A PLY with edges or faces and one unreadable vertex was refused whole. It now
keeps the subcomplex the readable points span, renumbered, with its numbers, and
warns about what it left out. A file with numbers on its points gains the same.

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

* Point clouds: build the triangle corner weights without a dynamic vector write

The Windows D3D12 shader compiler rejected w[vi] = 1.0 (error X3500).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-11-05 10:23:20 +00:00
github-actions[bot] 4d69d8c87a Regenerate generated files and fold pending files [skip ci] 2026-11-05 10:35:00 +00:00
Frank fa2faf99e2 Dialogs: an opt-in setting to remember values and options for this app session (#11135)
* Dialogs: remember values and options for this app session (off by default)

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

* Dialogs: translate the remember-values help text again

The help text changed to say the setting is off by default, and the
complete catalogs (ar, he, ja, vi, zh) lost their translation of it.

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

* Dialogs: recall gives a new face a fresh thread suggestion and rebuilds Move/Hole mode inputs

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

* Dialogs: format recall

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 09:48:28 +00:00
github-actions[bot] 20467e39e2 Regenerate generated files and fold pending files [skip ci] 2026-11-05 09:56:27 +00:00
Christian Hammacher 020e3bebe3 Status line: say which design an Open or Insert brought in (#7858) (#7869)
* Status line: say which file an Open or Insert brought in, and whether it got a new tab

"Open done" and "Insert done" were English text built in code, so they stayed English in every
language and didn't say what was opened. They now read "Opened <file> in a new tab", "Opened
<file>" when a blank design was replaced, or "Inserted <file>", from the message catalogs.

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

* Status line: an opened design is named as its tab names it

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

* Status line: an Open's test and Korean strings

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

* Status line: an Open's message in the chosen language, and none for a design that found no tab

The message is built outside the window's language scope, so it takes the locale from the preference as the other statuses of the file loader do
An opened design is named as its tab shows it (a default name translated), an inserted one as its component is named
An open that finds no tab left keeps the error and answers it, instead of naming the design shown as opened
The test injects the finished open instead of running and polling a worker

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

* Status line: a design to recover that finds no tab stays offered

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

* Status line: a design to recover that hits the tab limit stays offered too

An Open or recovery with every tab taken was refused before the design
was read, and a design to recover was dropped from the list on that path.
It goes back in the list, as when its read fails. The staged-open test
reads the new status.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 09:18:10 +00:00
Sain2d 6029a38ea0 Korean: translate the remaining messages, so nothing shows in English (#6413)
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 08:12:27 +00:00
NOWN-DEV 9c93a6c340 Sheet metal convert: a bend split by cut-outs across it is one bend (#7530) (#7581)
A bend that cut-outs across it split into several cylinder faces came out slit, silently: only
the picked piece was bent, the rest of the bend was cut away with the cut-out, and the volume came
out short. Picking the other pieces too was refused ("two faces of the sheet that meet along more
than one edge"). A picked bend cylinder now takes every coaxial piece of the same radius between
the same two faces, on both skins; in the flat, a face that meets its bend neighbour along several
collinear runs gets one bend edge, and the notches between them and the bend zone's pieces there
become holes of the sheet.

Test: a_bend_split_by_slots_across_it_converts_as_one_bend (slots cut through a tray's unfolded
bends, one piece picked or every piece): the volume, flat size, outline area, bends and the three
cut-outs are the source's.

Fixes #7530.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 07:37:58 +00:00
github-actions[bot] 9fdb7fdefc Regenerate generated files and fold pending files [skip ci] 2026-11-05 07:53:13 +00:00
lewis2e 718d5a4383 Intersection Curve: take two sketch curves, no helper surfaces (#11048) (#11049)
* claim: #11048

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

* Intersection Curve: take two sketch curves, no helper surfaces (#11048)

Intersection Curve now also takes curves of other sketches. Each curve
stands in for the surface you would get by extruding it along its own
sketch's normal, both ways and as far as needed, so two sketch curves
(or a curve and a face) give their 3D crossing without the two Surface >
Extrude features it used to take. The surface is never added to the
model. Sketches on parallel planes are refused with an error saying why.

The result stays a linked 3D wire that follows edits to either curve.
In the tool, a curve of another sketch fills a side like a face does;
after the first pick the status bar asks for the second, a right-click
starts the picks over, and the hint returns once the curve is made.

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

* Intersection Curve: keep other tools' right-click, update the tooltip, add a UI scenario (#11048)

The right-click change now applies to Intersection Curve only: Project to
Surface and Spun Profile behave as on main again.

The toolbar tooltip now says Intersection Curve also takes curves of other
sketches, in every catalog that has it, using each language's wording from
the Project to Surface tooltip.

New UI scenario intersection_curve_from_two_sketch_curves: a line on XY and
a line on YZ give one linked 3D wire along X at y = 0, z = 5; it also checks
the second-pick prompt and that a right-click starts the picks over.

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

* Intersection Curve: a picked spline wall meets whole, in one curve (#11048, #9009)

On the SMLib kernel a fit-point spline's wall is one face per span, and
Mesh::surface_patch keeps smooth-joined faces apart, so a face pick met
only the span under the pick and gave part of the crossing (#9009 again,
for faces as well as for sketch curves). A face pick now takes the faces
joined across seams and smooth joins, never across a crease.

The imprint also splits a crossing at every face boundary of either
side, so a crossing came as one wire per span. The pieces are now joined
end to end into one wire, keeping their exact curves; a piece that runs
the other way is reversed as a record curve.

A curve side's sweep is sized from the other body's exact bounds, not
its display mesh. The #9009 test's premise that the spans are one
surface_patch no longer holds and is removed; its checks are unchanged.

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

* Intersection Curve: a picked face joins only tangent spline faces, from exact geometry (#11048)

Which faces a face pick means is now decided on the exact surfaces, not the
display mesh: the picked face and the spline faces joined to it at least
tangentially (the kernel's seam check), so a spline wall's spans still meet
as one (#9009) while a plane, a round or any analytic face is that face
alone. The pieces of a crossing are joined within ten times the kernel's
intersection tolerance instead of a fixed 1e-4 mm.

Tests: a flat face beside a round and the round itself each give their own
crossing; two sketch curves give one curve at 1/100 and 100 times the size.

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

* Known-failing tests: the picked spline wall's intersection curve passes now (#11048, #9009)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 06:52:54 +00:00
github-actions[bot] 1c10f82b34 Regenerate generated files and fold pending files [skip ci] 2026-11-05 07:16:32 +00:00
Jackson Oursland ca4f3ed1a0 Book: getting started, sheet metal and import/export get pictures (#10732)
* Book: sketching step by step, with three clips and a numbered screenshot

The sketching page now opens with a first sketch in four steps: start a sketch on the Top
plane, draw a 60 x 40 rectangle and a 12 mm circle with typed values, dimension the circle
until the sketch is fully constrained, and finish. Each step has a short clip, and the last a
screenshot numbering the Sketch tools, a constraint, a dimension, the sketch's state and
Finish Sketch.

doc_shots.py gains what these needed: a setup that runs before recording, so a clip starts
where the last one ended; sketch framing; Enter sent as two input frames; and clips that keep
their pauses (hovering a plane).

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

* Book: the sketching page's general notes get a heading after Step by step

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

* Book: sketching adds Offset and Trim; the sketching figures again on 0.29.0

Two more clips after the sketching walkthrough: Offset draws the rectangle's outline 4 mm inside
it with one click (Chain), and Trim cuts a notch where a circle crosses the top side. All the
sketching figures are made again on SolidCraft 0.29.0, with the clips keeping a moment on each
result.

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

* Book: solid features step by step, with four clips

The solid features page now opens with the sketch from the sketching page made into an open
box in four steps, each with a short clip: extrude it 12 mm, shell it with the top open,
round its outer top edges, then make it 20 mm tall from the timeline while the shell and the
fillet rebuild. The panel table and what follows get a heading of their own.

doc_shots.py: the feature shots share their steps with the setups that lead up to the next
shot, and fail when a feature on the timeline carries a warning or an error, so no figure
shows one. Shell comes before Fillet for that reason: shelling through the rounds needed the
kernel's boolean retry. trim_idle.py takes --hold, the seconds of each idle stretch to keep
(default 0.1 as before); the doc clips keep 0.8 s, so a reader sees each result. The app gets
up to three minutes to answer on a busy machine.

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

* Book: features add Hole and Circular Pattern; the Parameters dialog, numbered

The features walkthrough gains two steps before the timeline edit: a 4 mm hole on the box's
floor, then Circular Pattern repeating it four times around the tube. The edit at the end now
rebuilds all of it. The parameters page gets a screenshot of the Parameters dialog numbering
apply as you go, user parameters, model parameters by feature, an expression and its value.
The features clips are made again on SolidCraft 0.29.0.

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

* Book: components and joints step by step, with three clips

The components and joints page now opens with a two-leaf hinge in three steps, each with a
short clip: the two bodies made into components from the Bodies folder's menu (the first one
grounded), a revolute joint on their barrel ends with Flip turning Leaf B the right way round,
and a drag that swings Leaf B about the hinge while grounded Leaf A stays put. What follows
gets a heading of its own.

doc_shots.py builds the leaves from commands, with Leaf B lifted and set back so the joint
visibly brings it into place.

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

* Book: assemblies add Drive Joints; the assembly clips again on 0.29.0

A fourth step drives the hinge with Drive Joints: the slider swings Leaf B while a ghost shows
where it started, and OK keeps the new angle. The assembly clips are made again on SolidCraft
0.29.0.

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

* Book: getting started, sheet metal and import/export get pictures

Getting started gains a section on the start page: a numbered screenshot (new design, open,
import, recent, samples, the File menu) and a clip opening a sample from it. Sheet metal opens
with a tray in three steps, each with a clip: a base flange from a sketched rectangle, edge
flanges from two top edges, and the flat pattern with its bends. Import and export opens with
the File menu, numbered.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 06:31:44 +00:00
SS-EN a1d66c238e MCP: batch runs the steps of a script file (path) (#5957) (#5959)
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 06:02:13 +00:00
thocsmith 70603de654 Emboss: an emboss or deboss that changes nothing says so (#10940) (#10944)
* claim: #10940

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

* Emboss: an emboss or deboss that changes nothing says so (#10940)

solid.emboss on a body its tool missed (a sketch on the far side of the
material, a depth that stops short) was recorded as a success with no
warning, so a sheet metal part looked embossed when it was not. The
evaluation now compares the bodies before and after and adds a warning
when none changed. A working emboss or deboss is silent.

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

* Emboss: the changed-nothing warning names what to check, not a cause it can't know (#10940)

CodeRabbit pointed out that an emboss can change nothing for other reasons
than missing the body (for example a profile that lies inside material that
is already there), so the warning no longer claims the profile misses the
body. It says the operation changed nothing and suggests checking where the
profile lies, which side of the face the sketch is on, and the depth.

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

* Emboss test: put the missing sketch below the sheet, since main's sheet metal emboss now cuts from its underside (#10940)

On current main a deboss from the XY plane reaches the sheet (it is changed and gets
the sheet metal flat-pattern warning), so the test no longer showed an emboss that
changes nothing. A sketch 5 mm below the sheet misses it on both bases.

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

---------

Co-authored-by: thocsmith <193096300+thocsmith@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 05:59:00 +00:00
Bartosz Błachut db20c8be23 Timeline: deleting a sketch entity reuses the results it replaced (#3923) (#11283)
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 05:46:56 +00:00
github-actions[bot] 8f4fe740c7 Regenerate generated files and fold pending files [skip ci] 2026-11-05 06:00:07 +00:00
thocsmith aae4282f1e Polygon: By side shows the Length and Sides boxes (#11271) (#11287)
* claim: #11271

* Polygon By side: Length and Sides boxes, the typed length a side dimension (#11271)

The edge polygon tool showed no value boxes after its first click. It now
shows Length (the side from the first click) and Sides, as the inscribed
and circumscribed modes show Radius and Sides. A typed length places the
second corner and becomes a Length dimension on the first side:
sketch.draw.dimensioned takes a `length` role for sketch.polygon.edge,
an alternative to its radius (as_drawn still adds the radius when no
length is given; both together are refused).

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

* Polygon By side test: read the design's own state so it compiles against main (#11271)

Main's Session::world_state() now returns a Result, so the new test failed
Quick compile on the merge. The sketch is in no component, so the design's
own state shows the same sketch on both the old and new bases.

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

* Polygon By side test: take Vec2 from solidcore_record, where main moved it (#11271)

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-05 05:35:52 +00:00
Colin Faulkingham 18b698ffa8 Keep raised models’ ground shadows on the ground (#9838) (#10257)
* claim: #9838

* fix: anchor raised ground shadows to the origin plane (#9838)

* docs: attribute ground shadow preference messages
2026-11-05 05:14:38 +00:00
github-actions[bot] 051e6236c3 Regenerate generated files and fold pending files [skip ci] 2026-11-05 05:26:55 +00:00
selune 8691cefbbf Render: save wood images with physical grain controls (#1599)
* Render: save wood images with physical grain controls

* Render: prepare plain glass before marking the GPU host ready
2026-11-05 04:49:46 +00:00
NOWN-DEV 6d2465b52d Sketch: a fillet whose lines already leave it is not swapped against a mirrored reference (#10401)
The fillet keeping of #5677 swaps an arc's ends when its sweep jumps by more
than half a turn with both its lines turned. Judged against a reference that
shows the corner mirrored (positions before a move, or a mirrored start), a
fillet the solve left exactly where it was looks turned, and the swap made it
its long complement: a 45-degree fillet on a 135-degree corner became a
315-degree arc with both lines cutting back through it, while the solve
reported ok.

The swap is now decided by the lines: an arc whose tangent lines all leave it
after the solve already rounds its corner and keeps its ends. A swap still
gives a corner its arc back when its lines cut back through it. A second test
keeps a real fillet over three quarters of a turn whose corner mirrored as the
long arc its lines leave (a narrower fix, letting a swap only shorten an arc,
would break it).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 04:28:39 +00:00
semmelrinde c517ca0393 Lip and groove: the rim face follows a parameter change (#11209) (#11425)
A lip kept its rim face as a plain point, so it got no persistent face
name. When a parameter made the box taller, the point stayed below the new
rim and the lip failed ("the plastic feature's placement is no longer on
its body face"). The face is now a face pick, as a boss's is, and the lip
names its body like the other placed plastic features. Both are saved as
the same [x, y, z], so files don't change.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 04:04:17 +00:00
github-actions[bot] ee1bdea97b Regenerate generated files and fold pending files [skip ci] 2026-11-05 04:28:35 +00:00
github-actions[bot] dc54d2f229 Regenerate generated files and fold pending files [skip ci] 2026-11-05 04:16:48 +00:00
raykholo b916f9bf33 Ribbon: an option to fold it to its tab row (#10112) (#10214)
* Ribbon: an option to fold it to its tab row (#10112)

Preferences > Layout > "Show only the ribbon's tabs" (off by default), the
Ribbon Tabs Only command (view.ribbon_tabs_only), and its settings-profile key.
Rebuilt on main's redesigned Layout page.

* Ribbon Tabs Only: switching tabs with a panel menu open no longer keeps the overlay open (#10112)

A tab click now forgets the open panel drop-down of the previous tab, so the pointer leaving or Escape still closes the overlay.

* Ribbon Tabs Only: count its settings-profile key in the profile membership test (#10112)
2026-11-05 03:42:58 +00:00
SS-EN 733f0b2254 MCP: opt-in compact inspect_design and tool output (split from #2901) (#3144)
* MCP: compact inspect_design and tool JSON, off by default (--compact / SOLIDCRAFT_MCP_COMPACT)

Split out of #2901 per review: the smaller inspect_design changed the default
for everyone, so it is now an MCP server option that is off unless asked for.

- solidcraft-cli mcp --compact, or SOLIDCRAFT_MCP_COMPACT=1 (true/yes/on;
  0/false/no/off/empty leave it off; anything else is an error at start-up).
  solidcraft_mcp::Options::resolve is the one place that decides, so an app
  Preferences setting can feed it later.
- With it on, inspect_design summarises sketches (id, name, plane, counts of
  points, curves, constraints and profiles, dof, status), returns full contents
  only for the sketches named in `sketches` (names/ids, true, false or
  "summary"), takes a `bodies` filter and measures only those; unknown names
  list the existing ones. Tool JSON is compact rather than pretty-printed.
- Off, tools/list, argument checks, inspect_design and tool JSON are exactly
  as before.
- document.inspect gains an opt-in `sketch_counts` (plane, points,
  constraints); without it its reply is unchanged.

Tests: both modes in the MCP suite, Options parsing, and the CLI flag and
variable end to end.

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

* MCP compact output: a Preferences setting too (AI › Agents through MCP)

The desktop app's Preferences › AI gets "Compact output" under a new "Agents
through MCP" section, off by default and shown with the chat off too. The app
reports it on the control channel as `mcp.options` ({"compact": bool}); the
agent chat lets its own MCP server read it.

A bridged MCP server (`solidcraft-cli mcp --connect`) asks the app when an
agent first lists or calls a tool and keeps the answer for the session, so the
tools it listed keep their shape. An app not running yet, or busy past 2 s, is
asked again on the next call; an app from before the setting counts as off.
`--compact` and SOLIDCRAFT_MCP_COMPACT=1 still turn it on whatever the app says.

Tests: the server follows the app's answer (waits for it, keeps it, the flag
still wins); the checkbox reaches `mcp.options` and Restore Defaults clears it;
the chat gate allows `mcp.options`. docs/mcp.md and docs/control-protocol.md.

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

* MCP compact output: review fixes (mcp.options never blocks, headless test, locales, prefs doc)

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

* MCP compact output: the setting is a section of the AI page; the bridged test answers mcp.options

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

* MCP compact output: the preference test sets it through ui.prefs; rustfmt

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

* MCP compact output: look for a newly started app before asking its options; counts from sketch.inspect for an older app

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 03:33:33 +00:00
Alexander Puzynia ee02080a25 Two-distance chamfers of free-form rims: a test on the SMLib kernel (#4332) (#4333)
* claim: #4332

* Two-distance and distance-angle chamfers of free-form rims: a test on the SMLib kernel (#4332)

A spline, Bézier or text outline's rim used to be refused by the two-distance and
distance-angle chamfers. The SMLib kernel (#11351) chamfers it along the rim; this test pins
that down on a four-Bézier oval, picked as its tangent chain the way a pick takes it: the
volume taken off is the analytic one for two distances (both ways round) and for a distance
and an angle.
2026-11-05 03:25:50 +00:00
Homero Thompson 99d96482af Sketch: groundwork for pierce constraints, no UI yet (#3599) (#11388)
* Sketch: the geometry of a pierce, a point where another sketch's curve crosses this plane (#3599)

* Sketch: the Pierce constraint, a point on a curve of another sketch (#3599)

* Engine: draw a Pierce constraint at the point it holds (#3599)

* Engine: the pierce tests wait for the command that is not written yet

* style: rustfmt, the imports of the pierce geometry in the order rustfmt wants

The rebase onto the current main resolved the rename of Plane, Vec2 and Vec3
from solidcraft_geom to solidcore_record and left the two use lines in the
order the resolution wrote them. rustfmt wants them the other way round, and
the fmt phase of the gate reads the whole tree.

No token of the file changed: the same 2983 tokens before and after, only the
order of the two use lines moved.

---------

Co-authored-by: bherbruck <bherbruck@gmail.com>
Co-authored-by: vdirienzo <vdirienzo@users.noreply.github.com>
2026-11-05 03:20:41 +00:00
github-actions[bot] a5be592b7d Regenerate generated files and fold pending files [skip ci] 2026-11-05 03:32:27 +00:00
aiapienthusiast d92923a964 Agent chat: a Cheaper Inference preset for OpenAI-compatible endpoints (#11174) 2026-11-05 02:58:56 +00:00
froglemonade2 71598c0354 Agent control: ui.menu refuses items the design doesn't have, reports the History bar's right-click menu, and gives Edit Joint Limits its own id (#10745) (#10746)
* claim: #10745

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

* Control channel: ui.menu refuses missing targets, reports the History item menu, and tells the joint menu items apart (#10745)

- ui.menu looks a target up and refuses one the design doesn't have ("no body `NoSuchBody`").
- The History bar's feature menu is the shared context menu (Target::Timeline), so ui.menu
  reports it, ui.menuPick runs its items and {"type":"timeline","feature":id} opens it.
  Its Show All toggle moves to TreeState so the menu can list Delete after the marker.
- Edit Joint Limits is ui.editJointLimits and opens Edit Joint with Limits expanded.

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

* Icon audit: open the section and canvas menus on a real section and canvas; a hidden section has a menu too

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

* ui.menu: check a group's or folder's scope and folder name; Delete after the marker gets its own id

A group or folder target is refused unless its scope is a row of its component (`c<id>` or a
placement of it) and the folder is bodies, sketches or construction. The History item menu's
delete of everything after the marker is ui.deleteAfterMarker, so ui.menuPick can tell it from
Delete (review on #10746).

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

* ui.menu: a component's own c<id> scope is the design root's only; test that Delete after the marker deletes

The browser draws a part's folders and groups under its placements (o:<path>), so a c<id> scope
for any component but the root names a row that isn't there (review on #10746). The History test
now picks ui.deleteAfterMarker and checks the features are gone.

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

* ui.menu: a placement scope must be spelt as the browser keys it

o:012 parses to placement 12 but isn't the row's key (o:12), so Show/Hide would store a key no
row reads (review on #10746).

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

* History item menu: no Delete after the marker when nothing shown is after it

A timeline target for a feature the bar's component filter leaves out listed no features, so the
item did nothing (review on #10746).

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

* ui.menu: a plane target must be construction geometry

The plane menu's Show/Hide keys the feature's name as an origin item, so any other feature got a
menu whose actions don't fit it (review on #10746).

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

* ui.menu: accept the view navigator's and selection filter's menus, which main added

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:35:55 +00:00
Homero Thompson 06a8a9b7d2 AGENTS.md and the contributing skill: say how to work as an agent (#8059)
* AGENTS.md and the contributing skill: say how to work as an agent

Two rules that the loop's own agents follow but the repository never
wrote down, so every agent that reads it had to rediscover them:

* The code, the comments, the commit messages and the docs are in English,
  always. A comment in another language is a defect: the people who
  maintain the code cannot review it. A user-visible string is not inline
  in the code either; it goes to the message catalogs, and the UI text
  skill says how to name and translate it.
* Parallel work runs in parallel, and dependent work stays sequential.
  One subagent per independent part, with a self-contained brief, and no
  two subagents writing the same file. A fix and the test that proves it,
  a refactor and the callers it breaks: those stay in order.

AGENTS.md is the router, so it gets the two rules in one line each, and
skills/solidcraft-contributing/SKILL.md gets the "Working as an agent"
section with the detail.

* AGENTS.md: fit the two agent rules under the router cap

`cargo xtask skills` reads the router as a router: one line per rule,
with the detail in the skill the line points at. The two new lines
broke that contract twice over. They carried their own detail (a
comment in another language is a defect; one subagent per part, no two
writing the same file) instead of pointing at the "Working as an
agent" section that already holds it, and the file grew to 5361
characters, over the 5000 a router may have.

So the two lines shrink to what a router line is, and five clauses
that repeated a rule the pointed-at skill already states in full give
up their place:

* "Act when the code, docs and rules settle a question" is in the
  contributing skill, in more detail.
* The unsafe allowlist is the crash-safety skill's own list.
* "No second PR for an issue a lower-numbered one closes" and "never
  force-push main, no pushes just to catch up with main" are the
  contributing skill's claim and push rules.
* "Do not switch branches while the gate runs" is in the contributing
  skill too.

No rule is lost: each one still has exactly one place that states it,
and that place is a skill. The router is 4970 characters.
2026-11-05 02:25:46 +00:00
Colin Faulkingham 4d2691e636 Assistant: opt-in HTTP authentication for private endpoints (#5341) (#10674)
* claim: #5341

* Allow per-endpoint HTTP authentication with explicit consent

* fix: discard model listings after HTTP consent revocation
2026-11-05 02:12:56 +00:00
Nathalie Schönwetter 6e17874cb9 A Move that copies can be patterned or mirrored (#5931) (#6018)
Co-authored-by: Nathalie Schönwetter <nathalie@cfw-ag.ch>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 02:04:01 +00:00
NAX LAB bf22dad8e8 Fix: flake build fails on git dependencies in Cargo.lock (#12146)
* Set `allowBuiltinFetchGit = true` in `cargoLock`:

* Remove comment
2026-11-05 01:26:31 +00:00
semmelrinde 731029cd87 Report a bug: put the whole report in the GitHub link whenever GitHub keeps it (#7103) (#7104)
Open GitHub issue form filled the issue through the link only up to
2,000 characters, with every space and line break encoded as three, so
most reports opened GitHub with the title alone and their text on the
clipboard, and many arrived empty (#10556, #10654).

The link now encodes only what a query value can't carry (a space is
`+`) and is limited by what GitHub keeps: 6,000 characters, and 7,000
once its sign-in page encodes it again. A report too long for that still
goes through the clipboard, whole.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 01:09:37 +00:00
Raymond Thurman 6b5c5dc6e8 Sketch on a thickened surface's face (#7641) (#7703)
* claim: #7641

* Sketch on a thickened surface follows the face (#7641)

Thicken dropped the kernel's face history, so Create Sketch refused
every face of the solid. Carry that history through, the same way
Shell and Trim do.

The regression covers the offset face and an end wall, and checks
that a boss on the offset face follows a thickness change and reopens.
2026-11-05 01:09:18 +00:00
github-actions[bot] e7ce6c282a Regenerate generated files and fold pending files [skip ci] 2026-11-05 02:47:03 +00:00
rugbymauri 67c58cbd28 Sheet metal rules: a design parameter named Thickness no longer overrides it (#9623) (#9633)
* claim: #9623

Signed-off-by: Maurizio Monticelli <maurizio@whatwedo.ch>

* Sheet metal and plastic rules: Thickness in a rule's expressions is the rule's thickness

A rule's expressions read `Thickness` as the rule's thickness only while the
design had no parameter of that name; with one, the default gap, reliefs and
a `Thickness` bend radius took the parameter instead (a 2 mm rule beside
Thickness = 10 mm closed corners to a 10 mm gap). Now `Thickness` is always
the rule's, as documented; the rule's own thickness still reads the design's
parameters, so `thickness: Thickness` keeps naming the design parameter.

Renaming a parameter called Thickness, its users and what it drives follow
the same reading: only a rule's thickness field refers to it. Renaming a
parameter to Thickness is refused while another rule field uses it, since
that field would then read the rule's thickness instead.

Fixes #9623

Signed-off-by: Maurizio Monticelli <maurizio@whatwedo.ch>

---------

Signed-off-by: Maurizio Monticelli <maurizio@whatwedo.ch>
2026-11-05 01:07:30 +00:00
austin62jake 7c89f7445f Assembly: moving a component without capturing its position marks the design unsaved (#9736) (#9851)
* Claim #9736

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

* Assembly: a move not captured yet is an unsaved change (#9736)

A component moved without capturing its position left the design reading
as saved: file.save wrote the file without the move and said nothing, and
closing the tab didn't ask. Now:

- Session::is_dirty is true while a move is pending, so closing a tab or
  quitting asks "save changes?".
- file.save and file.save_as refuse while a move is pending, naming the
  component and the capture and revert commands.
- In the app, Save As (and Save As JSON, Save a copy, Download design)
  asks the Capture Position question first, as Save already did; a tab
  closing after "Save" waits for that answer, and Cancel keeps it open.

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

* Close: the save-changes card waits behind the Capture Position question (#9736)

Saving a tab that closes asks the Capture Position question first; the
"save changes?" card stayed drawn over it and took a second answer. It
now hides and ignores answers until the question is answered. The design
container doc says a save stops for a move not captured.

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

* Assembly: an exploded view shown is not a move to save (#9736)

explode.show displays a view through the pending moves, as an uncaptured
move does. Those placements last only while the view is shown, so they
don't make the design unsaved and don't stop a save
(Session::moved_uncaptured). A move on top of a shown view still does.

The relink test saved with a move pending; it captures it first now.

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

* Assembly: the pending-move save test reads the world state as main now gives it

world_state() returns a Result since the kernel merge; the test unwraps it.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-05 01:06:46 +00:00
ShawnMarx 26adcfa997 Docs: add BoxInsertDesigner to the showcase (#10095)
* Docs: add BoxInsertDesigner to the showcase

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

* Docs: attribution rows for the BoxInsertDesigner showcase recording

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 01:06:36 +00:00
github-actions[bot] 1e41dddae2 Regenerate generated files and fold pending files [skip ci] 2026-11-05 02:11:35 +00:00
PapaEcho a9b219bc9f joint: let the origin be the design origin or a sketch point (#10290) (#10584)
The joint origin input only accepted faces, edges and vertices, so the world origin and sketch points could not be picked to place a joint. Extend the origin input's snap mask with ORIGIN and POINTS and resolve a sketch point (and the empty-body world-origin vertex) to a world snap in snap_of, so a joint can be placed on the design origin or a hole centre like Fusion.
2026-11-05 01:06:11 +00:00
semmelrinde 32dc8750df Sketch Project: faces and edges of a part's second copy now project (#9957) (#10721)
* Sketch Project: a face or edge of a part's second placement projects where that placement is (#9957)

Projecting the face of `Block (Part:2)` failed with "face of Block: the
face no longer exists". The pick's body name was turned into the
definition's own name, and the model a sketch reads holds only each
component's first placement, so the face was looked for on the first
placement, somewhere else.

Sketch picks now keep the name the browser shows, and the further
placements a sketch's links name are placed into the model it reads
(`with_placed_copies`), when the sketch is evaluated and when a link is
added or refreshed. Only the named ones are placed. A link to a body
the model lacks also makes the sketch follow every placement, so moving
the copy moves what was projected from it.

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

* Sketch Project: a projection from a second placement follows renames of its part and body (#9957)

The link keeps the copy by the name the browser shows (`Block (Part:2)`),
which a rename of the component or of the body changes. component.rename
and body.rename now rename it in the sketches' links as well.

The copies a sketch reads are found through their component's bodies,
not by going through every body for every placement.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 01:05:40 +00:00
semmelrinde ceeff58a7f Scale: X, Y and Z factors from the dialog (#10782) (#10983)
The Scale dialog gets a Type row, Uniform or Per axis. Per axis shows
X, Y and Z rows and sends factors, which solid.scale already takes. A
Scale made with factors opens per axis with its three values; before,
they rode along as an extra and the dialog's factor did nothing.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 01:05:10 +00:00
github-actions[bot] a55286953a Regenerate generated files and fold pending files [skip ci] 2026-11-05 01:37:26 +00:00
github-actions[bot] 9a0846ef68 Regenerate generated files and fold pending files [skip ci] 2026-11-05 01:28:10 +00:00
raykholo 06749ac464 Add the Fair Surface and Match Surface dialogs (#4644) (#4653)
* claim: #4644

* Add the Fair Surface and Match Surface dialogs (#4644)

Neither command had a dialog, so the toolbar and command search ran them
with no parameters and they failed with "`body` is required".

Fair Surface picks the surface or some of its faces and sets the
strength, the iterations, whether the edges are kept and an optional
tolerance (blank: 0.001 of the body's size). Match Surface picks the
surface or face to rebuild, the target and an edge chain of the target,
and the continuity (G1, G2 or G3, default G2). Both preview, and Edit
Feature restores the saved picks and settings. New labels go through
the catalogs with English, Japanese, Chinese, German and Korean text.

* Keep surface-dialog strings pending with complete translations (#4644)

* Fair tolerance is a length; Fair and Match picks reopen on their saved bodies

The Fair dialog's tolerance now goes through the same design-unit length
conversion as the other surface lengths, on OK and when an edit is refilled.
Editing a Fair or Match feature looks its face and edge picks up on the body
they were saved on, so a coincident face or edge of another body cannot take
their place.

* Surface dialogs: strings stay in the pending catalog only (#4644)

* Surface dialogs: edge picks use the session world, as main's edge_sel now does (#4644)

* Surface dialogs: refuse to refill when a saved pick no longer resolves, and pass body names by reference (#4644)

* Surface dialogs: Fair and Match now open dialogs, so the refused-command tab test no longer lists them (#4644)
2026-11-05 00:05:53 +00:00
semmelrinde a6e7126bf1 Closed stderr pipes no longer crash the app, CLI or MCP server (#10111) (#10126)
* Stderr: a closed stderr no longer stops the app, the CLI or the MCP server (#10111)

`eprintln!` panics with "failed printing to stderr" when stderr is a
pipe its reader has closed. A process another program starts writes
into such a pipe: `solidcraft mcp` under an agent, and the app that
one starts, which inherits it. #10111 is the app stopping that way
while an agent (Codex) connected. `solidcraft_geom::note!` (re-exported
as `solidcraft_engine::note!`) writes the same line and ignores a
failed write, as the CLI's logger already does; the app, the CLI, the
MCP server, the crash hook and the crates they run use it instead of
`eprintln!` outside tests.

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

* Stderr: test that a closed stderr leaves the CLI's exit code as it is (#10111)

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

* Stderr: the old name's alias and the fuzz command don't stop on a closed stderr either (#10111)

`solvecraft-cli` writes its deprecation notice before it starts
`solidcraft-cli`, so an agent still configured with the old name got the
panic before the MCP server started (found in review). It and the fuzz
command now write through `note!` too, and the test covers the alias.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 23:29:07 +00:00
Christian Hammacher 4a906699ee Browser: the component menu keeps its everyday actions and groups the rest in flyouts (#8769) (#8770)
* Browser: the component menu keeps its everyday actions and groups the rest in flyouts

A component's or placement's right-click menu had grown to about thirty entries in seven
blocks. Activate, Edit in Place, Open Linked Design, Move/Copy, Show/Hide, Isolate, the
clipboard, Delete, Rename, Appearance, Physical Material and Properties stay on the menu;
Ground, Capture and Revert Position, Unlock Contents and Turn Up Axis move to a Position
flyout, Relink Source or Extract to File and the exports to File, New Component, New Group
and Paste New to New, and Show All Bodies, Cycle Component Color, Expand All and Collapse
All to Display. context_menu::entries lists a menu with its flyouts, as ui.menuPick reads it.

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

* Browser: the component menu nests its flyouts by topic, and flyouts no longer scroll

Position (Ground, Capture and Revert Position, Unlock Contents, Turn Up Axis), Component
(New Component, Rename, Appearance, Physical Material, Cycle Component Color, Relink Source
or Extract to File, Properties; Design on the design's row), Export (Export, Save As Mesh,
Send to Slicer) and Browser (New Group, Expand All, Collapse All) each open a flyout; Paste
New stays with the clipboard and Show All Bodies with Show/Hide. Flyouts open inside flyouts,
so Appearance keeps its quick choices inside Component; the keys walk in and out of them.

A flyout no longer scrolls or sits away from its row: every flyout has its own area, measured
whole, where a short one used to squeeze every later one into its height. A component's only
body is no longer offered as a component of its own on a folded Bodies folder's entries.

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

* Browser: the component menu starts with what a double-click does, and keeps the old order of its sections

A linked component's menu starts with Edit in Place and Open Linked Design when a double-click
edits it in place (double_click_components), and with Activate otherwise. Position, Component
and Export follow Move/Copy, then the clipboard, then Show/Hide and Isolate, with Browser last,
as the sections came before the flyouts.

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

* Browser: Show All Bodies is on a component's menu only while a body is hidden

It is listed while a body is hidden itself, in a hidden placement, or in a hidden Bodies folder or
group of bodies, anywhere in the design, since that is what it shows.

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

* Browser: Relink Source and Extract to File sit right below Rename in the Component flyout

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

* Browser: Placement and Organize flyouts, Turn Up Axis back as a flyout, and flyout fixes

Placement (Ground, Turn Up Axis, Capture and Revert Position, Unlock Contents) follows
Move/Copy above the first separator, Turn Up Axis opens its two turns in a flyout again, and
the Browser flyout is called Organize. The Component flyout has three blocks: New Component
with Relink Source or Extract to File, then Appearance, Physical Material and Cycle Component
Color, then Rename and Properties.

A flyout of a flyout that opened to the left opens to the left too instead of covering the
menu, Left back to the menu chooses the row that opened the flyout, ui.menuPick refuses a row
that only opens a flyout, and the icon audit checks every level. The guides and the control
protocol describe the new menu paths.

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

* Browser: ui.menu gives a flyout's rows their placeholder flag too

ui.menu set placeholder only on the top-level rows, so a client reading
a flyout row's placeholder got nothing. The icon audit now reads the flag
of the flyout rows instead of resolving their icons itself.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 23:05:26 +00:00
github-actions[bot] a2a7102859 Regenerate generated files and fold pending files [skip ci] 2026-11-05 00:53:06 +00:00
cronos a137f2d459 Sketch Trim: keep the driving distance when trimming a line offset first (#11442) 2026-11-04 22:23:12 +00:00
thocsmith f87dd73661 Selection filter: from command search it offers Faces, Bodies, Components (#11505) (#11553)
* claim: #11505

* Selection filter: started from command search, it offers Faces, Bodies, Components (#11505)

Command search ran select.filter with no parameters, which could only fail
with "missing field `mode`". Started from the UI (command search, a
shortcut, ui.start), it now opens its three modes as a menu with the
current one marked, as View > Visual style and Navigation tool do (#8868).
Programmatic calls keep their contract and open nothing.

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-04 22:23:02 +00:00
PaddleStroke af5644aee6 Ribbon: switch panel menus on hover after the first click (#11447) (#11713)
* claim: #11447

* Add an option to switch ribbon panel menus on hover

* Update profile membership count for ribbon hover setting
2026-11-04 22:22:38 +00:00
SixSolid abeb232f17 Sketch Mirror: use an origin axis that is not in the sketch plane (#8201) (#12091)
* claim: #8201

* Sketch Mirror: use an origin axis that is not in the sketch plane (#8201)

A sketch on a part face does not hold the world X, Y or Z axis, so Mirror refused the pick. It now mirrors about the axis's projection into the sketch plane and keeps that line linked as fixed reference geometry. An axis perpendicular to the plane stays refused because it has no line in the plane. The sketch's own X and Y axes keep working as before.
2026-11-04 22:22:10 +00:00
raykholo 1a27c8399d Linux 3D mouse: push/lift and tilt/twist axes are no longer swapped (#3523) (#4833)
* claim: #3523

* Linux 3D mouse: decode spacenavd's axes in its documented frame (#3523)

spacenavd reports x to the right, y up and z away from the user, with the
rotations about those axes. The decoder treated y as away and z as up, so
with a stock spacenavd push and lift traded places, as did sideways tilt
and twist. Map the daemon's axes to the raw HID frame as translation
[x, -z, -y] and rotation [rx, -rz, -ry].

The decode still runs before a device's calibration and the per-axis
motion choice, so both apply unchanged on top of the corrected reading.
The tests now move each daemon axis on its own, in both directions, so a
swap of two axes can no longer pass.
2026-11-04 22:13:57 +00:00
Jackson Oursland 5ab59e7c1b Assistant: DeepSeek and Qwen endpoint presets (#5016) (#5063)
Adds DeepSeek and Qwen presets to both endpoint protocols: OpenAI-compatible (api.deepseek.com, dashscope-intl compatible-mode/v1) and Anthropic-compatible (api.deepseek.com/anthropic, dashscope-intl apps/anthropic).

Co-authored-by: Claude Code <noreply@anthropic.com>
2026-11-04 22:13:47 +00:00
SiroArt 9a274f91df Planned bodies: smaller GPU chunks, and a chunk keeps its colours while they are unchanged (#5490)
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 22:13:38 +00:00
PaddleStroke 08fa042039 Dialogs: drag a value's label sideways to change it (#2217) (#9685)
* claim: #2217

* Dialogs: drag a value's label sideways to change it (#2217)

A value row's label (expr_row, hint_row) now scrubs a literal value: about a
hundredth of its size per point of drag, on that step's grid, Shift ten times
finer, Ctrl/Cmd ten times coarser. The unit and decimal mark stay as typed, a
size never turns negative, and expressions and parameters are left alone. The
text changes as if typed, so previews follow and OK still makes one command.

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

* Label drag: measure from the press point, cap values, keep scrub kit-private

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 22:10:04 +00:00
P'tite Tête 09bb8aaaf5 Revolve: an axis origin written as an expression is no longer read as zero (#10252)
* fix: Revolve axis origin takes expressions instead of silently zero

- AxisRef::Line origin is a PointExpr evaluated with parameter values in the design unit (format note in pending)
- An unreadable origin or dir is a BadParams error instead of (0, 0, 0)
- Edit Feature keeps the saved expressions of an untouched origin or dir, for solid and surface revolves, in moved components too
- Face role names stay stable with a parameter-driven axis

* chore: Re-run PR checks after the description fix

* fix: Edit Feature of a revolve maps nothing where nothing is placed

- unplaced designs skip world_state and the pick table, as before the merge
- the untouched-edit test checks that OK applied the edit (CodeRabbit)

---------

Co-authored-by: P'tite Tête <asso@ptitetete.com>
2026-11-04 22:09:27 +00:00
Mateusz Sych 46edd8d749 Agent chat: Custom instructions in Preferences, added to the system note (#8220) (#11105)
* claim: #8220

* Agent chat: Custom instructions in Preferences, added to the system note (#8220)

Preferences > AI gets a multi-line Custom instructions field, saved with
the other AI preferences (agent_instructions, kept by page resets like
other user-written text). The chat adds the text after its own system
note when a session starts, by the routes #9811 set up: ACP session/new
(the Claude adapter's _meta key, the standard systemPrompt field, the
Antigravity key) and the system message or field of every API protocol.
Connect and New chat set it, so an edit applies from the next chat and
never changes the one running. Blank text adds nothing; at most 4000
characters are kept, in the field, over ui.agent_chat and from a hand-
edited settings file. ui.agent_chat takes and reports `instructions`.

* Custom instructions: typing in the field stays in the field

The text area did not report itself to Preferences search as a key sink, so with
the field focused, search took the typed characters and moved focus away. The
field now notes the sink while it has focus, like the other preference fields,
and the #8220 test types into it.

---------

Co-authored-by: MateuszSychXpl <151755914+MateuszSychXpl@users.noreply.github.com>
2026-11-04 22:07:30 +00:00
semmelrinde c5d3dabd1b Split Body: the tool face follows a parameter change (#11674) (#11699)
The tool face of Split Body is now a face pick: it is named when it is
picked and found again by that name, as other face picks are, so a split
by a rail's face follows the rail when a parameter moves it.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 22:06:13 +00:00
Ben Rog-Wilhelm 92171bd79b Hole: a hole on a face that another part touches drills its own part, not the other one (#12066) (#12067)
* claim: #12066

* Hole: a hole on a face that another part touches drills its own part (#12066)

A hole placed by position with no direction drills against the outward
normal of the face at the point. That face was looked for on every body,
so where another part touched the face at the hole, the part made first
won. The hole then drilled away from the part `targets` named and cut only
the tool's run-in pocket into it, without an error.

A hole that names its bodies now goes by their faces wherever one of them
passes through its position, when it is made (hole_direction_at) and when
a design is evaluated (hole_dir_into_face). Saved holes whose direction
leaves their own body's face there are mirrored into it, as format 18 does
for a thin plate's far side; the pending format note records it. A hole
whose bodies have no face at its position (placed on another part's face
to drill the part behind it) goes by every body's faces, as before.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 22:05:30 +00:00
CamWight 2ce55baba1 Dimensions: a radius leader and an aligned dimension's line reach their text (#12093)
The value text is drawn level, in an upright box, and the line runs
through its middle. The layout stopped the line where the box's farthest
corner reaches along it (half.x*|u.x| + half.y*|u.y|), not where the line
leaves the box (min(half.x/|u.x|, half.y/|u.y|)). They agree only when the
line is level or upright: at 45 degrees a radius leader stopped about
12 px short of its text, and an aligned dimension's line broke too wide
around its value.

One helper, box_exit, gives where the line leaves the box, for the radial
leader, the linear break and the leader to a text pulled past the
extension lines. Whether the text fits between the arrows still goes by
the whole box. A text box that isn't finite counts as empty, and one
larger than a sketch can be is held to MAX_COORD.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 22:05:13 +00:00
github-actions[bot] 6870f9539c Regenerate generated files and fold pending files [skip ci] 2026-11-04 23:51:34 +00:00
rugbymauri 2998d412b6 Primitives: clicking a pick field no longer jumps to the Diameter box (#10428) (#10536)
* claim: #10428

Signed-off-by: Maurizio Monticelli <maurizio@whatwedo.ch>

* Primitives: a pick field waits for its pick and says what it is for

Clicking Face, Reference 1 or 2 in the Cylinder window, or Point in the
Box, Sphere and Torus windows, and then clicking something the field
does not take (empty space, the cylinder's own preview) gave the
keyboard to Diameter, so the field no longer looked active and nothing
said why the click picked nothing. A pick field clicked into now keeps
the keyboard until a click picks something, and a click it does not
take leaves a note under the form saying what the field is for.

These fields also get their own tooltips: Face is the face or vertex
the cylinder stands on, Reference 1 and 2 are straight edges of that
face the distances below are measured from, and Point is a vertex,
edge, face, sketch point or the origin whose point fills X, Y and Z.

Fixes #10428

Signed-off-by: Maurizio Monticelli <maurizio@whatwedo.ch>

---------

Signed-off-by: Maurizio Monticelli <maurizio@whatwedo.ch>
2026-11-04 22:04:36 +00:00
Daniel 2396b6be05 Hole: the drag handle looks like the Rest, Boss and Snap Fit handles (#6818) (#6819)
* claim: #6818

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

* Hole: the handle drawn with the shared marker (#6818)

A Single hole's drag handle (Move holes by clicking or dragging) is drawn with the placement
marker from place_gizmo/look.rs instead of its own ring and cross, so a hole and a Rest on the
same model show one language for the same action. place_gizmo::marker is the one crate-visible
door: it runs the marker's hover and drag motions under the handle's id and draws the crosshair,
never dimmed. Hole keeps its snapping, the click that takes a hole out, its handle names and its
hover text.

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

* Hole: hole_handle removed (#6818)

The shared marker draws the handle now; nothing else used it.

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

* Hole: the marker takes no colours it does not use (#6818)

place_gizmo::marker took a &Tokens only to hand it to crosshair, which reads its colours itself,
so the hole's show fetched tokens nothing read. The argument and that fetch go.

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

* Hole: a test sees the handle drawn as the shared marker (#6818)

The hole tests found the handle by its published position, which the look does not change. This
one reads what the frame paints round it: the white dot and four dashed arms at rest, no ring
round the centre, arrowheads when pointed at, solid shafts while it drags. It fails with the old
ring and cross back on the call line.

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

* Place handles: marker hover eases in 120 ms, drag in 220 ms (Refs #6818)

The marker answered the pointer in 220 ms and a drag in 360 ms, which felt slow on review. Both
stay in look.rs as the one place a later preference can read them.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-04 21:55:30 +00:00
austin62jake 3c1074cfc6 Timeline: moving a construction axis now updates the features that use it (#9544) (#9545)
* Evaluation: a construction axis moved past a feature makes it evaluate again (#9544)

The state digest in a feature's cache key left out the construction
geometry before it, and the context digest hashes construction features
without their order. A feature started from the same key with or without
the axis before it, so after a move it kept its old result, and a cached
state restored whole brought back the construction list it was made with.

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

* Evaluation: say plainly what an empty construction list hashes (#9544)

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-04 21:55:19 +00:00
PaddleStroke c4a63a1d43 GPU path tracer: don't rerun a setup that crashed in the driver (#9236) (#9666)
* claim: #9236

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

* GPU path tracer: don't rerun a setup that crashed in the driver (#9236)

A driver that takes the process down while it compiles the path tracer
(RADV on an RX 7600) is beyond error scopes and unwind guards. A running
setup now keeps gpu-path-setup.json (its adapter key) in the settings
folder, removed when the setup answers or the app closes normally. A
launch that finds it for the same adapter leaves the setup failed with an
explanation and a Try again button; CPU ray tracing stays available, and
a driver update changes the key and so tries again.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 21:54:45 +00:00
dudedagger42 2fdcc5ae49 Appearance: a picture of your own behind the model (#11353)
* Appearance: a picture of your own behind the model

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

* Background picture: a label that fits its row

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 21:53:10 +00:00
austin62jake 4678a04827 Agents and scripts: the contact commands describe their exact parameters and reply (#11949) (#11950)
* claim: #11949

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

* Contact sets: input and output schemas for the seven contact commands (#11949)

describe_command now gives the exact parameters and reply of
contact.enable_sets, enable_all, disable_all, create, edit, list and
check, and the warn and strict parameter checks use them. The handlers
read their parameters from the raw JSON, so the contracts in
schema/motion.rs read each key as its handler does, with its message:
`occurrences` (OccurrenceKeys) and `set` are required in the schemas
but left to the handlers' messages in the types, through a small
`require` transform. The replies are built from typed values instead of
json! literals, with the same JSON; the listed sets mirror the doc
crate's ContactSet.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-04 21:52:53 +00:00
kaltenbrunnermarkus 63ffadfebf Import: a curve that refers to itself is refused instead of overflowing the stack (#11893)
A 2D TRIMMED_CURVE in a pcurve read its basis straight through read_curve,
past the depth check that only the cached entry points (curve_in, curve_2d,
surface_in) made. A trimmed curve whose basis is itself, or a loop of two,
recursed until the stack overflowed and the app closed.

The depth check now sits in read_curve and read_surface, which every curve
and surface read passes through, so any chain of references, a cycle
included, ends at MAX_GEOM_DEPTH with an error that names the entity:
"curve #9: definitions nested more than 12 deep, or referring to
themselves". The other nested entities (surface, seam, intersection and
bounded surface curves, pcurves, 3D offset curves, offset, trimmed,
revolution and extrusion surfaces) already went through the checked entry
points; they now share the one check.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 21:45:25 +00:00
kaltenbrunnermarkus aee947f0f9 Sketch: hovering over a large sketch no longer slows down (#9614)
* claim: Sketch: keep profile outlines and trim/extend cuts between mouse moves

* Sketch: keep profile outlines and trim/extend cuts between mouse moves

Hovering a large sketch sampled every profile's loops into polylines for
each point asked, and Trim/Extend intersected the hovered curve with every
other curve on every mouse move.

- Loop2::contains rejects points clearly outside a box from the loop's end
  and control points before sampling; Region2::outline samples the loops
  once (RegionOutline). Profile keeps both in OnceLocks and answers
  Profile::contains from them (viewport pick, profile_at_point, profile
  selection by points). Answers are unchanged: the box margin covers
  rounding, and non-finite values or negative conic weights skip the box.
- sketch_modify splits the cuts on a curve (TrimHover) from choosing the
  piece around the cursor. The UI keeps one per sketch state and curve,
  and Extend's preview per sketch state, curve and growing end (afresh
  when the sketch has an infinite line).

Plate with 400 circles (801 profiles), release, per mouse move: UI frame
5.19 -> 1.11 ms, with Trim 9.45 -> 1.50 ms; 30 lines x 30 splines: Trim
piece 6.10 -> 0.0002 ms. Equivalence tests compare with the old answers.

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

* Sketch: keep the profile outline helpers in the sketch crate

The outer loop's box, the clearly-outside test and the outline sampled
once move from geom into crates/sketch/src/profiles.rs as private items
built from geom's public types, so the PR leaves crates/geom untouched.
Profile::contains answers exactly as before; the geom test moves to the
sketch profile tests.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 21:42:06 +00:00
Daniel ad56dd6a93 Box window: a picked point fills Corner and Center in the design's unit (#12051) (#12052)
* Box window: a picked point fills Corner and Center in the design's unit

A vertex, edge or face picked into the Box window's Point input wrote its X, Y and Z as millimetre text whatever the design's length unit was, so an inch design showed "50.8 mm" where the other fields read inches. The fields now take the point through length_input::model, as Move and the Hole references do, so they read "2 in" there and stay "50.8 mm" in a millimetre design. The same applies to the Sphere and Torus centre.

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

* Box window: a picked point reads to a millionth of the design's unit

length_input::model divides without rounding, so a vertex at 38.1 mm read "1.5000000000000002 in" in an inch design. length_input::shown rounds to a millionth of the design's unit, as the millimetre text did before, and the test pins that case.

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

* Box window: a picked point keeps the micrometre in every design unit

length_input::shown rounds to a millionth of the design's unit, and the picked point's text is what OK sends, so in a metre design a vertex could move by up to half a millimetre. Document::design_length keeps the micrometre in every unit and still writes 38.1 mm as "1.5 in". The test now covers a metre design with a point that is not round in mm, and the Sphere and Torus centre.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 21:40:17 +00:00
ABDULLAH cafe015ef9 Arabic: sketch tool hints and errors, view-mode name, face term and Fillet name in the hidden-edge selection preference (#12080)
* Arabic: view-mode name, face term and Fillet command name in the hidden-edge selection preference

* Arabic: translate the sketch tool hints and errors, clearer Fillet phrase in the hidden-edge selection help
2026-11-04 21:40:08 +00:00
Daniel 155387bac1 Sketch Trim: a cut on a shape's centre ends the piece there, with no second point (#12101) (#12103)
* Sketch Trim: a cut on a shape's centre ends the piece there, with no second point

Trimming half of a center rectangle's diagonal left two points at the centre. Trim dropped the diagonal's Midpoint constraint and ended the kept half on a new point held only on the other diagonal, so the old centre was free and the new end slid along the other diagonal. Dragging a corner pulled them apart.

A cut that lands on a point held at the middle of the trimmed line or of the cutter now ends the kept piece on that point. When the trimmed line held it, the Midpoint moves to the cutter if the point is the cutter's middle too (a rectangle's diagonals bisect each other), and otherwise the point is held on the cutter as a new end would be.

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

* Sketch Trim: weld the cut end before a parallel distance moves onto it

A distance between parallel lines that measured from the trimmed line's end moves to the kept piece's start. When that start is a cut welded to a centre point, the move named the new point, which the trim then removed along with the dimension. The weld now happens first.

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

* Sketch Trim: test that a parallel distance moves onto the welded centre

Trimming the start half of a center rectangle's diagonal moves a distance to a parallel line onto the kept half's start, which is the centre. The test fails if the weld runs after that move: the dimension then names the cut point and is removed with it.

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

* Sketch Trim: the centre comment names the case that reaches it

Only a line carries a Midpoint, so the comment's ellipse case could not reach this code.

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

* Sketch Trim: a crossing line whose middle is only near the cut gets no midpoint hold

Trimming a line at its held midpoint moved that hold onto the cutter whenever the cutter's middle sat at the cut, so a crossing line that merely happened to have its middle there was tied to the point. The hold now moves only when the sketch makes the two lines diagonals of a parallelogram (each pair of opposite sides held parallel, horizontal or vertical), as a center rectangle's are; otherwise the point stays on the cutter.

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

* Sketch Trim: ask the solver whether a cutter's middle is held at a cut midpoint

Moving a cut midpoint's hold onto the cutter only for a parallelogram of parallel, horizontal or vertical sides missed rectangles the sketch holds another way: with the bottom side held square to a side instead of horizontal, trimming a diagonal at the centre left the centre held only on the other diagonal, one degree of freedom freer than before. The hold now moves when the sketch already implies it, that is when adding it to the sketch as it was takes away no degree of freedom.

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

* Sketch Trim: solve for the implied midpoint only when a cut lands on one

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 21:39:58 +00:00
Daniel de1db6fb8c Parameters: reach the end of a long expression the same way on every OS in its test (#12104)
a_long_expression_opens_wide_while_edited_and_enter_applies_it moved the cursor to the end of the wrapped field with Ctrl+End. The control channel's cmd flag also sets ctrl, and on macOS egui treats Ctrl with End as an unhandled Emacs-style chord, so the cursor stayed where the click put it. The typed " * 2" did not land at the end, the parameter kept its old expression, and the test failed on macOS while it passed on Linux.

The test now uses Command+Down with ctrl off, which egui maps to the end of the text on every platform. What the test checks is unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 21:39:49 +00:00
github-actions[bot] c47e848f4a Regenerate generated files and fold pending files [skip ci] 2026-11-04 23:04:10 +00:00
github-actions[bot] 8b4fe10995 Regenerate generated files and fold pending files [skip ci] 2026-11-04 22:53:51 +00:00
Daniel 8fb046aaa8 Sketch: the whole sketch updates while you type a dimension's value (#11584) (#11585)
* claim: #11584

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

* Sketch: the sketch follows a dimension's value while it is typed (#11584)

While a new dimension's value was typed, only the dimension moved: the sketch's curves, points, constraint glyphs and other dimensions stayed at the old size until Enter. The typed value was already solved in a scratch session; it is now solved in the frame it is typed, and the open sketch is drawn from that solve, for every kind of curve, within the display cache's limits per sketch. Esc draws the design again. Picking and snapping of curves and points stay on the design, and no hover highlight of the design is painted while a value is typed. A value that does not solve keeps the last one that did.

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

* Dimension: the sketch scene follows each solve of a typed value, not its address (#11584)

The sketch scene was keyed on the preview's address, which a later solve can have once the drawn one is freed; the scene would then stay at the old value. Each solve now has a number, and the scene is keyed on it.

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

* Dimension: while a value is typed, no design profile is lit over the sketch, and its curves are sampled before its fills (#11584)

The profile under the pointer was lit where the design has it, over the sketch drawn at the typed value; it is no longer, and the highlight is built again when the value is let go. The typed sketch's profiles were sampled before its curves from the one budget, so a sketch whose fills used it up lost its curves; the curves now go first.

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

* Dimension: a selected profile is not lit over the sketch while a value is typed either (#11584)

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

* Dimension: the view goes to the typed preview only once a value is typed (#11584)

A picked dimension's value box opened with a draft that is the design as it is, and the view already treated it as the typed preview: the profile under the pointer, the selection and the hover went dark before anything was typed. The preview now starts with the first key, completion or reference.

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

* Dimension: a reference picked into the value is solved at once (#11584)

The overlays read the preview before the editor solves a changed value, so for the click's frame they drew the solve before the reference.

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

* Dimension: a selected sketch curve is not lit where the design has it while a value is typed (#11584)

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

* Dimension: the pointer's x, y readout is not drawn while a value is typed, as the hover is not (#11584)

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

* Dimension: curvature combs, the design's teeth, are not drawn while a value is typed (#11584)

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

* Dimension: while a value is typed only the open sketch loses its hover and profile highlights; other sketches keep theirs (#11584)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 21:34:23 +00:00
leValdemar19 7b31c0567b Sketch Transform: turn and scale about a picked point, such as a rectangle's corner (#11718) (#11797)
* claim: #11718

* Sketch Transform: turn and scale about a picked point, such as a rectangle's corner (#11718)

The Transform window turned and scaled the picked entities only about the
middle of their box. It now has a Center field, as Move does. A sketch point
picked there is the point Scale and Angle work about, and Position moves it.
The box, its corner handles and the knob in the view follow the same pivot.
With Center empty, Transform works as before.

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

* Sketch Transform: a click on a corner picks it as Center while Center waits for a pick (#11718)

The rectangle corner the issue wants to turn about sits under the box's
corner handle, so a click there started a scale. While the Center field
waits for a pick, the box's handles leave clicks to the sketch.

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

* Sketch Transform: the handles work again once Center has its point (#11718)

Center is the window's last field, so a pick there left it active, and the
box's handles stayed off until another field was clicked. They are now off
only while Center is empty and waiting for its pick.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 21:33:06 +00:00
Daniel 5efdd52854 Ribbon: a tool pinned into the Finish Sketch group shows pressed (#12049) (#12050)
* Ribbon: a tool pinned into the Finish Sketch group shows pressed

The Finish Sketch and Finish Editing groups never drew the pressed state for any of their buttons, so a drawing tool dragged into them (the group at the ribbon's right edge takes drops) looked like a one-shot action while it ran. Only Finish, Cancel and Finish Edit are now left out of the pressed state.

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

* Ribbon: test that Finish, Cancel and Finish Edit stay plain under a press

None of the three runs as a tool or leaves a dialog behind, so a press is the one way they could light up. The tests now press each one and fail if any of them is dropped from the exclusion. Finish Edit is pressed and released in the edit-in-place test, so the release is also the click that finishes the edit.

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

* Ribbon: Finish with AutoConstrain stays plain under a press like Finish

Finish with AutoConstrain belongs to the Finish Sketch group and ends the sketch like Finish, but it was not in the list of buttons left out of the pressed state, so once pinned as a button it lit while the pointer was held on it.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 21:32:12 +00:00
takuto-NA 61ada52984 Reuse unchanged imported meshes during joint motion (#5476) (#5480)
* perf(mesh): reuse imported contents and display triangles

* fix(viewport): preserve reference mesh detail and glass fallback

* fix(viewport): preserve small faded mesh ordering without OIT
2026-11-04 21:30:58 +00:00
SS-EN 6578d591ac control::execute: check the undo depth before placing the world for the first-body fit (#5940)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 21:30:48 +00:00
JK3D d9421bf2ca Browser: an option to start folders closed and open rows only on request (#8768) (#8771)
* claim: #8768

* feat: Browser folders start closed and find a body only when asked, behind a preference

Preferences › Layout › Browser rows start closed (`browser_rows_start_closed`, off by
default) starts the Browser's folders closed, and placements too whatever "Components start
open" (#9274) says. Rows then open with their arrow, Expand All or Find in Browser, which opens
just the rows down to its body and closes again the ones the last find opened. A body picked in
the 3D view leaves the Browser as it is, and only the body Find in Browser found has the rows
above it marked, so a large assembly's placements are not named again after each edit.

With the preference off, folders start open, placements start as "Components start open"
says, and a pick in the 3D view opens the Browser down to its body, as on main. Find in Browser
looks the body's placement up once and opens its rows before drawing, instead of each row
testing the revealed path while it is drawn.

* Browser tests: read the row order from the Browser's content frame

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

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 21:29:23 +00:00
PaddleStroke 774c45a512 Dynamics: rigid-body solver core with joints, gravity and springs (#9719) (#9721)
* claim: #9719

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

* Dynamics: rigid-body solver core with joints, gravity and springs (#9719)

A new L1 crate, solidcraft-dynamics (geom only): rigid bodies in maximal coordinates, joint constraint equations with analytic Jacobians for rigid, revolute, slider, cylindrical, planar and ball joints, gravity, linear spring-dampers and constant loads; the index-1 equations of motion solved through a rank-revealing Schur complement (repeated joint equations are solved, not refused); RK4 and Dormand-Prince 5(4) with position and velocity projection; joint values, rates, reactions and energies at every output sample; caps on bodies, joints, rows, steps and samples; Result errors only.

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

* Dynamics: take Vec3 from the record crate after the layer split, refresh the lock file

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-11-04 21:27:17 +00:00
P'tite Tête 315d8cfeb7 fix: sketch.intersect keeps a small round between short walls (#9962)
- section() kept the loose fit whenever it had fewer curves, even when it had merged 2 mm walls and an R2 round into one R2.85 arc that misses the walls, leaving the outline open
- The tight fit is now also kept when the loose one strays from the exact crossings (on planes and rulings) and the tight one does not; capped work, other curves count as near
- Test: a box with R2 corner rounds and four tapered grooves stopping 2 mm short, cut at Y=20

Co-authored-by: P'tite Tête <asso@ptitetete.com>
2026-11-04 21:27:05 +00:00
TaeHeon Kim 4ab1640dc8 OCLP detection: run /usr/sbin/nvram by its full path (#10109)
The probe ran `nvram` through PATH, which a Finder launch may not list
and which a look-alike earlier in it could change. The doc comment that
said exec would need unsafe is replaced by a description of what the
code does. Behaviour is otherwise unchanged.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 21:26:51 +00:00
raykholo 3aee35fe01 Web app: keep unsaved work in the browser instead of the leave-page box (#10220) (#10221)
* claim: #10220

* Web app: keep unsaved work in the browser instead of the leave-page box (#10220)

A new preference, off by default, on Preferences > General > Saving (web
app only): "Keep unsaved work in this browser instead of asking when the
tab closes". With it on, each open design with unsaved changes keeps a
copy in the browser's private storage (keep-<page>-<design>.sc/.json),
written once the design has been still for 0.8 s (at most 4 s after the
first change it lacks) and again on pagehide or when the page is hidden.
While every copy is current, beforeunload doesn't ask. A copy goes when
its design is saved, closed, or the preference is turned off.

The next visit offers the copies newer than their design's last save in
SolidCraft's own window, "Restore unsaved design: <name>, changed
<time>?", one window for several, with Restore, Open saved version, and
Discard. A page holds a Web Lock while it runs, so another open tab's
copies are never offered or removed.

With the preference off, the single autosave and the browser's prompt
work exactly as before.

* Web app: the restore window comes from the UI kit's recovery module (#10220)

The kit's text and divider helpers are private to the UI crate, so the
"Restore unsaved design?" window moves to recovery_ui
(browser_copies_window), beside the desktop recovery window, and the web
shell only maps its answer. Also keeps the window handle for the page
listeners instead of using the one moved into the app.

* Web app: restore window buttons sit in one row (#10220)

* Web app: keep unsaved work and still ask, unless told not to (#10220)

Keeping a copy in the browser no longer replaces the browser's leave-page
box by itself. A second option, shown once work is kept and on by default,
leaves the box in place; off, the box appears only while a copy isn't current.

* Web app: kept-copy review fixes (#10220)

A failed write of a kept copy waits 30 s before the next try, a recovery
copy is dropped only after the remembered saved file has opened, the
preference is documented under its current label, and the ui.prefs list
is sorted again.
2026-11-04 21:26:23 +00:00
raykholo 5562d9c6c8 Quick-access bar: pin ribbon commands to the application bar (#10223) (#10224)
* claim: #10223

* Quick-access bar: pin ribbon commands to the application bar (#10223)

A command's menu (a ribbon button, a section drop-down row or a command
search result) has Add to Quick Access: the command's own icon joins the
application bar after Save, Undo and Redo. A click starts it as its
ribbon button does, a drag along the bar reorders the buttons, and the
button's menu has Remove from Quick Access. Empty, and so not drawn, by
default; it holds up to 24 commands.

The list is kept with the toolbar's customisations (`quick_access` in
the toolbar's saved document), so it is saved, exported with the
keyboard shortcuts and carried by settings profiles.

* Quick access: a pinned family variant no longer replaces the ribbon choice

A quick-access click now starts the pinned command with start_pinned, so it
starts exactly that command and leaves the sketch family remembered choice
(the main ribbon button) unchanged.
2026-11-04 21:26:13 +00:00
Colin Faulkingham d16496e057 Navigation: honor reverse zoom for mouse drags (#10354) (#10365)
* claim: #10354

* fix(ui): honor reverse zoom for mouse drags
2026-11-04 21:26:01 +00:00
835 changed files with 135408 additions and 11766 deletions
+7 -5
View File
@@ -3,13 +3,13 @@
SolidCraft is a clean-room, pure-Rust parametric 3D CAD app (sketch solver, feature timeline, B-rep kernel, an egui UI that agents drive). This file is a router: the hard rules, then the skill to read for your task. Humans: CONTRIBUTING.md.
## Start here
Work: ROADMAP.md, "Where help is wanted most" in CONTRIBUTING.md, issues labelled `help wanted`, `good first issue`, `needs-triage`; architecture in `docs/architecture.md`. Act when the code, docs and rules settle a question; ask only when it changes what users see. Local `plan/` notes are optional.
Work: ROADMAP.md, "Where help is wanted most" in CONTRIBUTING.md, issues labelled `help wanted`, `good first issue`, `needs-triage`; architecture in `docs/architecture.md`. Local `plan/` notes are optional.
## Hard rules (what enforces each)
- Independent: never copy another product's code, icons, text or templates; never drive or take data from commercial CAD. `skills/solidcraft-ground-rules/SKILL.md`.
- No GPL/LGPL/AGPL code, not even reading it (OpenCASCADE, FreeCAD, SolveSpace, CadQuery, LibreCAD, QCAD): `cargo xtask licences --check`.
- Every asset is original or openly licensed with a row in ATTRIBUTION.md; read `assets/AGENTS.md` before icon work: `cargo xtask assets`, `cargo xtask icons`.
- Never crash: no unwrap/expect/panic/unsafe in non-test code (unsafe allowlist in crash-safety), errors are `Result`, input is hostile: clippy `-D warnings`, `hostile_params_never_panic`.
- Never crash: no unwrap/expect/panic/unsafe in non-test code, errors are `Result`, input is hostile: clippy `-D warnings`, `hostile_params_never_panic`.
- 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`.
@@ -21,12 +21,14 @@ Work: ROADMAP.md, "Where help is wanted most" in CONTRIBUTING.md, issues labelle
- A design-format change is ONE new file in `crates/doc/src/format/pending/`: `scripts/test-format-pending.sh`.
- A command has input and output schemas (will become required; `schema/unconverted.txt` only shrinks): `skills/solidcraft-commands/SKILL.md`.
- New UI strings go in `assets/locales/pending/<slug>.json`, ATTRIBUTION rows in `attribution/pending/<slug>.md`: `cargo xtask lists`.
- A changed default keeps the old behaviour behind a preference and fills the PR's `Changes a default:` line, and no second PR for an issue a lower-numbered one closes: `pr-checks.yml`.
- A changed default keeps the old behaviour behind a preference and fills the PR's `Changes a default:` line: `pr-checks.yml`.
- A regression test goes in the area's existing test file, never a per-bug file (#3226).
- Look at open PRs first, claim with a draft PR (lowest number wins), one topic per PR, never force-push `main`, no pushes just to catch up with `main`: `skills/solidcraft-contributing/SKILL.md`.
- Look at open PRs first, claim with a draft PR (lowest number wins), one topic per PR: `skills/solidcraft-contributing/SKILL.md`.
- A PR's title and first sentence make sense to someone who never saw your chat or the issue: `skills/solidcraft-contributing/SKILL.md`.
- Code, comments, commit messages and docs are in English, always: `skills/solidcraft-contributing/SKILL.md` ("Working as an agent").
- Independent work runs in parallel, dependent work stays sequential: `skills/solidcraft-contributing/SKILL.md` ("Working as an agent").
- Show observable changes with real screenshots or a recording, captured yourself.
- Run `cargo xtask ci` before every commit, without switching branches while it runs. `cargo xtask skills` keeps this file and `skills/` honest.
- Run `cargo xtask ci` before every commit. `cargo xtask skills` keeps this file and `skills/` honest.
## Working on X: read the skill first
| Working on | Read |
+46 -4
View File
@@ -82,7 +82,6 @@ Editable drawing code, native projects and shipped exports are collected under `
| assets/fonts/ibm-plex-mono/IBMPlexMono-Regular.ttf | IBM Corp. (IBM Plex Mono) | https://github.com/IBM/plex (via google/fonts), unmodified; licence text in assets/fonts/ibm-plex-mono/OFL.txt; the interface monospace font | SIL OFL 1.1 |
| assets/fonts/ibm-plex-sans/IBMPlexSans-Variable.ttf | IBM Corp. (IBM Plex Sans, variable wdth and wght) | https://github.com/IBM/plex (via google/fonts), unmodified; licence text in assets/fonts/ibm-plex-sans/OFL.txt; the default interface font | SIL OFL 1.1 |
| docs/book/src/generated/tool-preview.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/tool-preview.json`; gitignored) | MIT OR Apache-2.0 |
| 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 assets/fonts/liberation-sans/OFL.txt; used for sketch Text outlines | SIL OFL 1.1 |
| assets/fonts/biz-udp-gothic/BIZUDPGothic-Regular.ttf | The BIZ UDGothic Project Authors | https://github.com/googlefonts/morisawa-biz-ud-gothic (Google Fonts), unmodified; licence text in assets/fonts/biz-udp-gothic/OFL.txt; used for web UI Japanese font | SIL OFL 1.1 |
| assets/fonts/craft-ui-sc/CraftUI-SC.otf | Adobe / Google Inc. (Noto Sans SC); SolidCraft subset | https://github.com/notofonts/noto-cjk Sans/SubsetOTF/SC Regular, glyph-subset for assets/locales/zh.json and renamed Craft UI SC (OFL reserved-name); licence text in assets/fonts/craft-ui-sc/OFL.txt; web UI Simplified Chinese | SIL OFL 1.1 |
| assets/fonts/craft-ui-tc/CraftUI-TC.otf | Adobe / Google Inc. (Noto Sans TC); SolidCraft subset | https://github.com/notofonts/noto-cjk Sans/SubsetOTF/TC Regular, glyph-subset for assets/locales/zh-TW.json and renamed Craft UI TC (OFL reserved-name); licence text in assets/fonts/craft-ui-tc/OFL.txt; web UI Traditional Chinese | SIL OFL 1.1 |
@@ -147,7 +146,6 @@ Editable drawing code, native projects and shipped exports are collected under `
| assets/fonts/craft-ui-tc/OFL.txt | Adobe / Google Inc. (Noto CJK); SolidCraft header | Accompanying OFL for CraftUI-TC.otf | SIL OFL 1.1 |
| assets/fonts/ibm-plex-mono/OFL.txt | IBM Corp. | Unmodified accompanying font licence | SIL OFL 1.1 |
| assets/fonts/ibm-plex-sans/OFL.txt | IBM Corp. | Unmodified accompanying font licence | SIL OFL 1.1 |
| assets/fonts/liberation-sans/OFL.txt | Red Hat, Inc.; Liberation Fonts contributors | Unmodified accompanying font licence | SIL OFL 1.1 |
| assets/icons/app/linux/exports/hicolor/128x128/apps/io.tangencylabs.solidcraft.png | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 |
| assets/icons/app/linux/exports/hicolor/16x16/apps/io.tangencylabs.solidcraft.png | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 |
| assets/icons/app/linux/exports/hicolor/24x24/apps/io.tangencylabs.solidcraft.png | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 |
@@ -798,6 +796,51 @@ Original SolidCraft drawings, Open for owner review; no runtime assignments.
| docs/book/src/media/ui-tour/window.png | SolidCraft contributors | screenshot of SolidCraft 0.29.0 itself (`solidcraft --sample`, default dark theme, software renderer, 1280x800) with original numbered callouts (digits set in DejaVu Sans Bold), captured with examples/demo/doc_shots.py | MIT OR Apache-2.0 |
| docs/book/src/media/ui-tour/navigate.mp4 | SolidCraft contributors | original screen recording of the actual SolidCraft 0.29.0 English desktop UI (`--sample`, default dark theme, software renderer) orbiting, panning and zooming, with the original drawn pointer of examples/demo/overlay.py and labels set in DejaVu Sans Bold, captured with examples/demo/doc_shots.py | MIT OR Apache-2.0 |
| assets/locales/pending/sketch-tool-hints.json | carlymx | Original sketch tool hints and engine error translations | MIT OR Apache-2.0 |
| assets/locales/pending/primitive-pick-field-tips.json | SolidCraft contributors | Original primitive pick-field tooltip translations | MIT OR Apache-2.0 |
| assets/locales/pending/ribbon-panel-hover.json | SolidCraft contributors | Original ribbon panel menu preference labels and translations | MIT OR Apache-2.0 |
| docs/screenshots/box-insert-designer.gif | Shawn Marx | original recording made with BoxInsertDesigner's own Motion export (its three.js viewer) of its example game `examples/ccg-example` (https://github.com/ShawnMarx/BoxInsertDesigner): a card game box insert whose trays were built by SolidCraft through `solidcraft-cli`; the published card, board and box art in it is pixelated beyond recognition, and no logos or game names are shown (2.5× playback, 800 px wide) | MIT OR Apache-2.0 |
| docs/screenshots/box-insert-designer.mp4 | Shawn Marx | original recording made with BoxInsertDesigner's own Motion export (its three.js viewer) of its example game `examples/ccg-example` (https://github.com/ShawnMarx/BoxInsertDesigner): a card game box insert whose trays were built by SolidCraft through `solidcraft-cli`; the published card, board and box art in it is pixelated beyond recognition, and no logos or game names are shown (1280×720) | MIT OR Apache-2.0 |
| assets/locales/pending/agent-http-consent.json | SolidCraft contributors | Original endpoint-specific HTTP consent messages and translations | MIT OR Apache-2.0 |
| assets/locales/pending/ground-shadow-placement.json | SolidCraft contributors | Original ground-shadow placement preference messages and translations | MIT OR Apache-2.0 |
| docs/book/src/media/assemblies/components.mp4 | SolidCraft contributors | original screen recording of the actual SolidCraft 0.29.0 English desktop UI (default dark theme, software renderer) making components from two original hinge-leaf bodies, with the original drawn pointer of examples/demo/overlay.py and labels set in DejaVu Sans Bold, captured with examples/demo/doc_shots.py | MIT OR Apache-2.0 |
| docs/book/src/media/assemblies/joint.mp4 | SolidCraft contributors | original screen recording of the actual SolidCraft 0.29.0 English desktop UI (default dark theme, software renderer) creating a revolute joint between two hinge leaves, with the original drawn pointer of examples/demo/overlay.py and labels set in DejaVu Sans Bold, captured with examples/demo/doc_shots.py | MIT OR Apache-2.0 |
| docs/book/src/media/assemblies/drag.mp4 | SolidCraft contributors | original screen recording of the actual SolidCraft 0.29.0 English desktop UI (default dark theme, software renderer) dragging a jointed hinge leaf, with the original drawn pointer of examples/demo/overlay.py and labels set in DejaVu Sans Bold, captured with examples/demo/doc_shots.py | MIT OR Apache-2.0 |
| docs/book/src/media/assemblies/drive.mp4 | SolidCraft contributors | original screen recording of the actual SolidCraft 0.29.0 English desktop UI (default dark theme, software renderer) driving a revolute joint between two hinge leaves, with the original drawn pointer of examples/demo/overlay.py and labels set in DejaVu Sans Bold, captured with examples/demo/doc_shots.py | MIT OR Apache-2.0 |
| docs/book/src/media/features/extrude.mp4 | SolidCraft contributors | original screen recording of the actual SolidCraft 0.29.0 English desktop UI (default dark theme, software renderer) extruding a sketch's profile, with the original drawn pointer of examples/demo/overlay.py and labels set in DejaVu Sans Bold, captured with examples/demo/doc_shots.py | MIT OR Apache-2.0 |
| docs/book/src/media/features/shell.mp4 | SolidCraft contributors | original screen recording of the actual SolidCraft 0.29.0 English desktop UI (default dark theme, software renderer) shelling a block, with the original drawn pointer of examples/demo/overlay.py and labels set in DejaVu Sans Bold, captured with examples/demo/doc_shots.py | MIT OR Apache-2.0 |
| docs/book/src/media/features/fillet.mp4 | SolidCraft contributors | original screen recording of the actual SolidCraft 0.29.0 English desktop UI (default dark theme, software renderer) filleting four edges, with the original drawn pointer of examples/demo/overlay.py and labels set in DejaVu Sans Bold, captured with examples/demo/doc_shots.py | MIT OR Apache-2.0 |
| docs/book/src/media/features/edit.mp4 | SolidCraft contributors | original screen recording of the actual SolidCraft 0.29.0 English desktop UI (default dark theme, software renderer) editing a feature from the timeline, with the original drawn pointer of examples/demo/overlay.py and labels set in DejaVu Sans Bold, captured with examples/demo/doc_shots.py | MIT OR Apache-2.0 |
| docs/book/src/media/features/hole.mp4 | SolidCraft contributors | original screen recording of the actual SolidCraft 0.29.0 English desktop UI (default dark theme, software renderer) placing a hole, with the original drawn pointer of examples/demo/overlay.py and labels set in DejaVu Sans Bold, captured with examples/demo/doc_shots.py | MIT OR Apache-2.0 |
| docs/book/src/media/features/pattern.mp4 | SolidCraft contributors | original screen recording of the actual SolidCraft 0.29.0 English desktop UI (default dark theme, software renderer) patterning a hole around an axis, with the original drawn pointer of examples/demo/overlay.py and labels set in DejaVu Sans Bold, captured with examples/demo/doc_shots.py | MIT OR Apache-2.0 |
| docs/book/src/media/parameters/dialog.png | SolidCraft contributors | screenshot of SolidCraft 0.29.0 itself (default dark theme, software renderer) showing the Parameters dialog, with original numbered callouts (digits set in DejaVu Sans Bold), captured with examples/demo/doc_shots.py | MIT OR Apache-2.0 |
| docs/book/src/media/files/menu.png | SolidCraft contributors | screenshot of SolidCraft 0.29.0 itself (`--sample`, default dark theme, software renderer) showing the File menu, with original numbered callouts (digits set in DejaVu Sans Bold), captured with examples/demo/doc_shots.py | MIT OR Apache-2.0 |
| docs/book/src/media/getting-started/start.png | SolidCraft contributors | screenshot of SolidCraft 0.29.0 itself (default dark theme, software renderer, 1280x800) showing the start page with its built-in sample thumbnails, with original numbered callouts (digits set in DejaVu Sans Bold), captured with examples/demo/doc_shots.py | MIT OR Apache-2.0 |
| docs/book/src/media/getting-started/sample.mp4 | SolidCraft contributors | original screen recording of the actual SolidCraft 0.29.0 English desktop UI (default dark theme, software renderer) opening the built-in Sample Plate from the start page, with the original drawn pointer of examples/demo/overlay.py and labels set in DejaVu Sans Bold, captured with examples/demo/doc_shots.py | MIT OR Apache-2.0 |
| docs/book/src/media/sheet-metal/base.mp4 | SolidCraft contributors | original screen recording of the actual SolidCraft 0.29.0 English desktop UI (default dark theme, software renderer) making a sheet metal base flange, with the original drawn pointer of examples/demo/overlay.py and labels set in DejaVu Sans Bold, captured with examples/demo/doc_shots.py | MIT OR Apache-2.0 |
| docs/book/src/media/sheet-metal/flange.mp4 | SolidCraft contributors | original screen recording of the actual SolidCraft 0.29.0 English desktop UI (default dark theme, software renderer) adding sheet metal edge flanges, with the original drawn pointer of examples/demo/overlay.py and labels set in DejaVu Sans Bold, captured with examples/demo/doc_shots.py | MIT OR Apache-2.0 |
| docs/book/src/media/sheet-metal/flat.mp4 | SolidCraft contributors | original screen recording of the actual SolidCraft 0.29.0 English desktop UI (default dark theme, software renderer) showing a sheet metal flat pattern, with the original drawn pointer of examples/demo/overlay.py and labels set in DejaVu Sans Bold, captured with examples/demo/doc_shots.py | MIT OR Apache-2.0 |
| docs/book/src/media/sketching/create.mp4 | SolidCraft contributors | original screen recording of the actual SolidCraft 0.29.0 English desktop UI (default dark theme, software renderer) starting a sketch on the Top plane, with the original drawn pointer of examples/demo/overlay.py and labels set in DejaVu Sans Bold, captured with examples/demo/doc_shots.py | MIT OR Apache-2.0 |
| docs/book/src/media/sketching/draw.mp4 | SolidCraft contributors | original screen recording of the actual SolidCraft 0.29.0 English desktop UI (default dark theme, software renderer) drawing a rectangle and a circle with typed values, with the original drawn pointer of examples/demo/overlay.py and labels set in DejaVu Sans Bold, captured with examples/demo/doc_shots.py | MIT OR Apache-2.0 |
| docs/book/src/media/sketching/dimension.mp4 | SolidCraft contributors | original screen recording of the actual SolidCraft 0.29.0 English desktop UI (default dark theme, software renderer) dimensioning a sketch until it is fully constrained, with the original drawn pointer of examples/demo/overlay.py and labels set in DejaVu Sans Bold, captured with examples/demo/doc_shots.py | MIT OR Apache-2.0 |
| docs/book/src/media/sketching/parts.png | SolidCraft contributors | screenshot of SolidCraft 0.29.0 itself (default dark theme, software renderer, 1280x800) showing a fully constrained sketch, with original numbered callouts (digits set in DejaVu Sans Bold), captured with examples/demo/doc_shots.py | MIT OR Apache-2.0 |
| docs/book/src/media/sketching/offset.mp4 | SolidCraft contributors | original screen recording of the actual SolidCraft 0.29.0 English desktop UI (default dark theme, software renderer) offsetting a sketch outline, with the original drawn pointer of examples/demo/overlay.py and labels set in DejaVu Sans Bold, captured with examples/demo/doc_shots.py | MIT OR Apache-2.0 |
| docs/book/src/media/sketching/trim.mp4 | SolidCraft contributors | original screen recording of the actual SolidCraft 0.29.0 English desktop UI (default dark theme, software renderer) trimming a notch in a sketch, with the original drawn pointer of examples/demo/overlay.py and labels set in DejaVu Sans Bold, captured with examples/demo/doc_shots.py | MIT OR Apache-2.0 |
| assets/locales/ca.json | carlymx | Catalan translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/pending/sketch-mirror-auto-line.json | SolidCraft contributors | Original Mirror preference label and translations | MIT OR Apache-2.0 |
| docs/screenshots/7452/demo-01-extrude5-edge-frame.png | SamD98 | screenshot of the actual SolidCraft desktop UI showing the revolve axis pick on the Cat Post Topper repro design (docs/screenshots/7452/revolve-edge-frame.solidcraft), for #7452 | MIT OR Apache-2.0 |
| docs/screenshots/7452/demo-04-preview-outer-l11.png | SamD98 | screenshot of the actual SolidCraft desktop UI showing the revolve axis pick on the Cat Post Topper repro design (docs/screenshots/7452/revolve-edge-frame.solidcraft), for #7452 | MIT OR Apache-2.0 |
| docs/screenshots/7452/demo-05-preview-inner-l8.png | SamD98 | screenshot of the actual SolidCraft desktop UI showing the revolve axis pick on the Cat Post Topper repro design (docs/screenshots/7452/revolve-edge-frame.solidcraft), for #7452 | MIT OR Apache-2.0 |
| docs/screenshots/7452/demo-06-result-outer-120.png | SamD98 | screenshot of the actual SolidCraft desktop UI showing the revolve axis pick on the Cat Post Topper repro design (docs/screenshots/7452/revolve-edge-frame.solidcraft), for #7452 | MIT OR Apache-2.0 |
| docs/screenshots/7452/demo-07-preview-360.png | SamD98 | screenshot of the actual SolidCraft desktop UI showing the revolve axis pick on the Cat Post Topper repro design (docs/screenshots/7452/revolve-edge-frame.solidcraft), for #7452 | MIT OR Apache-2.0 |
| docs/screenshots/7452/demo-08-cancelled.png | SamD98 | screenshot of the actual SolidCraft desktop UI showing the revolve axis pick on the Cat Post Topper repro design (docs/screenshots/7452/revolve-edge-frame.solidcraft), for #7452 | MIT OR Apache-2.0 |
| docs/screenshots/7452/demo-13-colliding-id.png | SamD98 | screenshot of the actual SolidCraft desktop UI showing the revolve axis pick on the Cat Post Topper repro design (docs/screenshots/7452/revolve-edge-frame.solidcraft), for #7452 | MIT OR Apache-2.0 |
| docs/screenshots/7452/demo-14-surface-l1.png | SamD98 | screenshot of the actual SolidCraft desktop UI showing the revolve axis pick on the Cat Post Topper repro design (docs/screenshots/7452/revolve-edge-frame.solidcraft), for #7452 | MIT OR Apache-2.0 |
| docs/screenshots/7452/demo-15-surface-result.png | SamD98 | screenshot of the actual SolidCraft desktop UI showing the revolve axis pick on the Cat Post Topper repro design (docs/screenshots/7452/revolve-edge-frame.solidcraft), for #7452 | MIT OR Apache-2.0 |
| docs/screenshots/7452/revolve-selected-edge.mp4 | SamD98 | screen recording of the actual SolidCraft desktop UI showing the revolve axis pick on the Cat Post Topper repro design (docs/screenshots/7452/revolve-edge-frame.solidcraft), for #7452 | MIT OR Apache-2.0 |
| assets/locales/bg.json | Yasen-55 | Original translation | MIT OR Apache-2.0 |
| 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 |
## Code adapted from sibling projects
@@ -867,11 +910,10 @@ copied into SolidCraft.
| Directory | Source | Licence | Changes |
|---|---|---|---|
| vendor/accesskit_macos | accesskit_macos 0.26.3, https://github.com/AccessKit/accesskit (The AccessKit contributors) | MIT OR Apache-2.0 | the subclassing adapter and its tests from 0.27.2: dropping the adapter keeps AppKit's key-value observers (vendor/README.md) |
## Vendored renderer compatibility fix
`vendor/egui-wgpu` is egui-wgpu 0.36.2 from https://github.com/emilk/egui
`vendor/egui-wgpu` is egui-wgpu 0.37.0 from https://github.com/emilk/egui
(MIT OR Apache-2.0; both original licence texts are included). Rust sources and
its original `egui.wgsl` shader are retained; no images or other artwork are copied.
SolidCraft's narrow patch selects a safe 1x MSAA path on the chosen OpenGL adapter
+3 -1
View File
@@ -88,6 +88,8 @@ The [architecture page](docs/architecture.md) has the layer table. The short ver
- `crates/geom`: vectors, planes, meshes, measures.
- `crates/sketch`: our own constraint solver, plus profile finding.
- `crates/dynamics`: multibody dynamics: rigid bodies, joint constraints, gravity and springs,
integrated in time (RFC #9719). It depends only on `geom`.
- `crates/kernel`: the B-rep kernel boundary. It wraps [truck](https://github.com/ricosjp/truck),
and it's the only crate allowed to touch truck. Fillets, chamfers, shell/offset, Delete Face,
Split Face, STEP/IGES read and write, and face provenance are our own code here. Patched
@@ -277,7 +279,7 @@ mean something else, it needs a format change. **Add one file, and edit nothing
change's file, or any `v<N>/` folder: those are fixed once released.
That's all. The build counts the files: while any are pending it writes the next format number,
which no release reads, so a release refuses a design that has your change instead of losing it.
which no release reads, so a release warns about a design that has your change and keeps what it can't read of it.
A file also lists the pending changes it has (`format_pending`), so a later development build
runs only the steps that file is missing. Two pull requests that each add a change merge without
a conflict. `crates/doc/src/format/pending/README.md` has an example.
Generated
+181 -163
View File
@@ -4,9 +4,9 @@ version = 4
[[package]]
name = "accesskit"
version = "0.24.1"
version = "0.25.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320"
checksum = "4e883d1d28c5fd5568c79ed2d2bd48d9f2fd3945c8974ed0f53690ceddb051be"
dependencies = [
"enumn",
"serde",
@@ -15,12 +15,12 @@ dependencies = [
[[package]]
name = "accesskit_atspi_common"
version = "0.18.1"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954"
checksum = "24f5baf62e34a4a6c30e3d697e4782f821a7337049c44c3b40576a27dfbb580d"
dependencies = [
"accesskit",
"accesskit_consumer 0.36.0",
"accesskit_consumer",
"atspi-common",
"phf",
"serde",
@@ -29,41 +29,37 @@ dependencies = [
[[package]]
name = "accesskit_consumer"
version = "0.35.0"
version = "0.39.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10"
checksum = "f3da8f27636015191b2264ee1df681503170a632cafd832fdc1edfdd4e3a2fd3"
dependencies = [
"accesskit",
"hashbrown 0.16.1",
"hashbrown 0.17.1",
]
[[package]]
name = "accesskit_consumer"
version = "0.36.0"
name = "accesskit_ios"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60"
checksum = "a5280724673bbfa8b30a838c013500b719392989be3b97c5a9964062c252c232"
dependencies = [
"accesskit",
"hashbrown 0.16.1",
]
[[package]]
name = "accesskit_consumer"
version = "0.38.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245"
dependencies = [
"accesskit",
"hashbrown 0.16.1",
"accesskit_consumer",
"hashbrown 0.17.1",
"objc2 0.5.3",
"objc2-foundation 0.2.2",
"objc2-ui-kit 0.2.2",
]
[[package]]
name = "accesskit_macos"
version = "0.26.3"
version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "123828ceeec755b05255e3976cb0852993202de98a0626c97dd8d34246991f84"
dependencies = [
"accesskit",
"accesskit_consumer 0.38.0",
"hashbrown 0.16.1",
"accesskit_consumer",
"hashbrown 0.17.1",
"objc2 0.5.3",
"objc2-app-kit 0.2.2",
"objc2-foundation 0.2.2",
@@ -71,9 +67,9 @@ dependencies = [
[[package]]
name = "accesskit_unix"
version = "0.21.1"
version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c"
checksum = "30d5192871228c54f1b0fb4a85cef000e11a3804f25d2165f14afa0daa67f3f6"
dependencies = [
"accesskit",
"accesskit_atspi_common",
@@ -89,13 +85,13 @@ dependencies = [
[[package]]
name = "accesskit_windows"
version = "0.32.1"
version = "0.35.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21"
checksum = "498668b90da9995302a0055aaecad4756932333da2ddef6baee1c00844c430fc"
dependencies = [
"accesskit",
"accesskit_consumer 0.35.0",
"hashbrown 0.16.1",
"accesskit_consumer",
"hashbrown 0.17.1",
"static_assertions",
"windows",
"windows-core",
@@ -103,11 +99,12 @@ dependencies = [
[[package]]
name = "accesskit_winit"
version = "0.32.2"
version = "0.34.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313"
checksum = "cd4ab4437b520ff03088fbe48676a11e7d44a929535e64b31432c1073e48dbcc"
dependencies = [
"accesskit",
"accesskit_ios",
"accesskit_macos",
"accesskit_unix",
"accesskit_windows",
@@ -203,6 +200,7 @@ dependencies = [
"parking_lot",
"percent-encoding",
"windows-sys 0.60.2",
"wl-clipboard-rs",
"x11rb",
]
@@ -687,42 +685,18 @@ dependencies = [
"thiserror 1.0.69",
]
[[package]]
name = "calloop"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43b32313e937c8721c9d17ee9bf60699a6402b4b7c2cb6ac1bee0ffcaa4b4e5e"
dependencies = [
"bitflags 2.13.2",
"polling",
"rustix 1.1.5",
"tracing",
]
[[package]]
name = "calloop-wayland-source"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20"
dependencies = [
"calloop 0.13.0",
"calloop",
"rustix 0.38.44",
"wayland-backend",
"wayland-client",
]
[[package]]
name = "calloop-wayland-source"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa"
dependencies = [
"calloop 0.14.5",
"rustix 1.1.5",
"wayland-backend",
"wayland-client",
]
[[package]]
name = "cbc"
version = "0.1.2"
@@ -1123,9 +1097,9 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "ecolor"
version = "0.36.2"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34"
checksum = "31bcbef0d4263a63eb32c4834e5d711613035ce1333468e107d928923e21e6e2"
dependencies = [
"bytemuck",
"emath",
@@ -1134,9 +1108,9 @@ dependencies = [
[[package]]
name = "eframe"
version = "0.36.2"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2"
checksum = "9c5642501a65ca657d05f00c643a5c14ff2346cb3818032bfe23d58e1f8e5f84"
dependencies = [
"ahash",
"bytemuck",
@@ -1159,6 +1133,7 @@ dependencies = [
"profiling",
"raw-window-handle",
"ron",
"send_wrapper",
"serde",
"static_assertions",
"wasm-bindgen",
@@ -1171,9 +1146,9 @@ dependencies = [
[[package]]
name = "egui"
version = "0.36.2"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba"
checksum = "bec9144f49b7a28b5f395d3147e325b4fe3e77ba686eac11d1bdf0a7da3f375f"
dependencies = [
"accesskit",
"ahash",
@@ -1193,7 +1168,7 @@ dependencies = [
[[package]]
name = "egui-wgpu"
version = "0.36.2"
version = "0.37.0"
dependencies = [
"ahash",
"bytemuck",
@@ -1211,32 +1186,34 @@ dependencies = [
[[package]]
name = "egui-winit"
version = "0.36.2"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc"
checksum = "e4e449f4c4a2a60c7ccb4ba7fa4078417bfe1f9ddc03b5e05ce75748e33a70de"
dependencies = [
"accesskit_winit",
"arboard",
"block2 0.6.2",
"bytemuck",
"egui",
"log",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"objc2-ui-kit 0.3.2",
"profiling",
"raw-window-handle",
"serde",
"smithay-clipboard",
"web-time",
"webbrowser",
"windows-sys 0.61.2",
"winit",
]
[[package]]
name = "egui_glow"
version = "0.36.2"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b"
checksum = "9275ca5cc35a6b7c94e0e8cedf22542f66cb0cf45ed8ab2657c78ff49fec7268"
dependencies = [
"bytemuck",
"egui",
@@ -1254,9 +1231,9 @@ checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
[[package]]
name = "emath"
version = "0.36.2"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1"
checksum = "e73ce90f54750be8672c02be0743fe47530e6f251e066f0ee2869b59eb297af5"
dependencies = [
"bytemuck",
"serde",
@@ -1330,9 +1307,9 @@ dependencies = [
[[package]]
name = "epaint"
version = "0.36.2"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3"
checksum = "97bac86ae3dde93fc3951e790951caea739ff39a6c89d909cdaf1d9278dbb1bd"
dependencies = [
"ahash",
"bytemuck",
@@ -1350,15 +1327,16 @@ dependencies = [
"skrifa",
"smallvec",
"unicode-general-category",
"unicode-properties",
"unicode-segmentation",
"vello_cpu",
]
[[package]]
name = "epaint_default_fonts"
version = "0.36.2"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13"
checksum = "cfc1cc505827f4c23eedc297975a0fd038268d41293f389dd8ff3b574aefab19"
[[package]]
name = "equivalent"
@@ -1444,6 +1422,12 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
[[package]]
name = "fixedbitset"
version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
[[package]]
name = "flate2"
version = "1.1.10"
@@ -1455,6 +1439,12 @@ dependencies = [
"zlib-rs",
]
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foldhash"
version = "0.2.0"
@@ -1651,12 +1641,12 @@ dependencies = [
[[package]]
name = "glifo"
version = "0.2.0"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a"
checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be"
dependencies = [
"bytemuck",
"foldhash",
"foldhash 0.2.0",
"hashbrown 0.17.1",
"log",
"peniko",
@@ -1826,6 +1816,15 @@ dependencies = [
"smallvec",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash 0.1.5",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
@@ -1834,7 +1833,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
"foldhash 0.2.0",
]
[[package]]
@@ -1843,7 +1842,7 @@ version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash",
"foldhash 0.2.0",
]
[[package]]
@@ -2500,6 +2499,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
[[package]]
name = "nom"
version = "8.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
dependencies = [
"memchr",
]
[[package]]
name = "num"
version = "0.4.3"
@@ -2956,6 +2964,16 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "os_pipe"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "parking"
version = "2.2.1"
@@ -3005,10 +3023,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "phf"
version = "0.13.1"
name = "petgraph"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
dependencies = [
"fixedbitset",
"hashbrown 0.15.5",
"indexmap",
]
[[package]]
name = "phf"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6"
dependencies = [
"phf_macros",
"phf_shared",
@@ -3017,9 +3046,9 @@ dependencies = [
[[package]]
name = "phf_generator"
version = "0.13.1"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100"
dependencies = [
"fastrand",
"phf_shared",
@@ -3027,9 +3056,9 @@ dependencies = [
[[package]]
name = "phf_macros"
version = "0.13.1"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085"
dependencies = [
"phf_generator",
"phf_shared",
@@ -3040,9 +3069,9 @@ dependencies = [
[[package]]
name = "phf_shared"
version = "0.13.1"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89"
dependencies = [
"siphasher",
]
@@ -3762,6 +3791,12 @@ version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "send_wrapper"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73"
[[package]]
name = "serde"
version = "1.0.229"
@@ -3986,8 +4021,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016"
dependencies = [
"bitflags 2.13.2",
"calloop 0.13.0",
"calloop-wayland-source 0.3.0",
"calloop",
"calloop-wayland-source",
"cursor-icon",
"libc",
"log",
@@ -4004,44 +4039,6 @@ dependencies = [
"xkeysym",
]
[[package]]
name = "smithay-client-toolkit"
version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0"
dependencies = [
"bitflags 2.13.2",
"calloop 0.14.5",
"calloop-wayland-source 0.4.1",
"cursor-icon",
"libc",
"log",
"memmap2",
"rustix 1.1.5",
"thiserror 2.0.21",
"wayland-backend",
"wayland-client",
"wayland-csd-frame",
"wayland-cursor",
"wayland-protocols",
"wayland-protocols-experimental",
"wayland-protocols-misc",
"wayland-protocols-wlr",
"wayland-scanner",
"xkeysym",
]
[[package]]
name = "smithay-clipboard"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226"
dependencies = [
"libc",
"smithay-client-toolkit 0.20.0",
"wayland-backend",
]
[[package]]
name = "smol_str"
version = "0.2.2"
@@ -4153,6 +4150,17 @@ dependencies = [
"web-time",
]
[[package]]
name = "solidcraft-dynamics"
version = "0.40.0-rc.2"
dependencies = [
"serde",
"serde_json",
"solidcore-record",
"solidcraft-geom",
"thiserror 2.0.21",
]
[[package]]
name = "solidcraft-engine"
version = "0.40.0-rc.2"
@@ -4275,9 +4283,11 @@ dependencies = [
name = "solidcraft-render"
version = "0.40.0-rc.2"
dependencies = [
"flate2",
"image",
"moxcms",
"png",
"robust",
"serde",
"serde_json",
"sha2 0.10.9",
@@ -4652,7 +4662,6 @@ version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"log",
"pin-project-lite",
"tracing-attributes",
"tracing-core",
@@ -4678,6 +4687,17 @@ dependencies = [
"once_cell",
]
[[package]]
name = "tree_magic_mini"
version = "3.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6"
dependencies = [
"memchr",
"nom",
"petgraph",
]
[[package]]
name = "ttf-parser"
version = "0.25.1"
@@ -4752,6 +4772,12 @@ version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "unicode-properties"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "993ad287eb20e8ba755312801cfe1413e0f25d604075863acaddca10b04c1833"
[[package]]
name = "unicode-segmentation"
version = "1.13.3"
@@ -4835,9 +4861,9 @@ dependencies = [
[[package]]
name = "vello_common"
version = "0.1.0"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2"
checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd"
dependencies = [
"bytemuck",
"fearless_simd",
@@ -4850,9 +4876,9 @@ dependencies = [
[[package]]
name = "vello_cpu"
version = "0.1.0"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea"
checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9"
dependencies = [
"bytemuck",
"glifo",
@@ -5027,32 +5053,6 @@ dependencies = [
"wayland-scanner",
]
[[package]]
name = "wayland-protocols-experimental"
version = "20250721.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1"
dependencies = [
"bitflags 2.13.2",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wayland-scanner",
]
[[package]]
name = "wayland-protocols-misc"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8"
dependencies = [
"bitflags 2.13.2",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wayland-scanner",
]
[[package]]
name = "wayland-protocols-plasma"
version = "0.3.12"
@@ -5630,7 +5630,7 @@ dependencies = [
"bitflags 2.13.2",
"block2 0.5.1",
"bytemuck",
"calloop 0.13.0",
"calloop",
"cfg_aliases",
"concurrent-queue",
"core-foundation 0.9.4",
@@ -5651,7 +5651,7 @@ dependencies = [
"raw-window-handle",
"redox_syscall 0.4.1",
"rustix 0.38.44",
"smithay-client-toolkit 0.19.2",
"smithay-client-toolkit",
"smol_str",
"tracing",
"unicode-segmentation",
@@ -5694,6 +5694,24 @@ version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "wl-clipboard-rs"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "959c7ac3b8afde864f7a7448ac16f29b2b83350b7eccbf77fb781c3999aa6848"
dependencies = [
"libc",
"log",
"os_pipe",
"rustix 1.1.5",
"thiserror 2.0.21",
"tree_magic_mini",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wayland-protocols-wlr",
]
[[package]]
name = "writeable"
version = "0.6.4"
+6 -7
View File
@@ -22,6 +22,7 @@ solidcraft-kernel = { path = "crates/kernel" }
solidcraft-smlib = { path = "crates/smlib" }
solidcraft-step = { path = "crates/step" }
solidcraft-render = { path = "crates/render" }
solidcraft-dynamics = { path = "crates/dynamics" }
solidcraft-gpu = { path = "crates/gpu" }
solidcraft-doc = { path = "crates/doc" }
solidcraft-io = { path = "crates/io" }
@@ -102,11 +103,12 @@ tiny-skia = { version = "0.12", default-features = false, features = ["std", "si
toml = "1"
# ui
egui = "0.36"
eframe = { version = "0.36", default-features = false, features = ["persistence"] }
# default_fonts: the built-in Hack is the UI's symbol fallback (#2955); 0.37 no longer turns it on.
egui = { version = "0.37", features = ["default_fonts"] }
eframe = { version = "0.37", default-features = false, features = ["persistence"] }
# Instant/SystemTime that also work in the browser (std panics on wasm32-unknown-unknown).
web-time = "1"
egui-wgpu = "0.36"
egui-wgpu = "0.37"
# the wgpu egui-wgpu uses, for compute (crates/gpu); backends come from the apps' features
wgpu = { version = "30", default-features = false, features = ["std", "wgsl"] }
naga = { version = "30", default-features = false }
@@ -161,12 +163,9 @@ debug = "line-tables-only"
egui-wgpu = { path = "vendor/egui-wgpu" }
# Deliver trackpad pinch and rotation gestures on Wayland (zwp_pointer_gestures_v1), which
# winit 0.30 never binds and eframe 0.36 still requires winit 0.30.
# winit 0.30 never binds and eframe 0.37 still requires winit 0.30.
winit = { path = "vendor/winit" }
# macOS: dropping the accessibility adapter leaves AppKit's key-value observers intact (#7535).
accesskit_macos = { path = "vendor/accesskit_macos" }
# Mutation testing (cargo-mutants, docs/mutation-testing.md): the ci profile with incremental
# builds, since every mutant rebuilds the crates above the one it changes.
[profile.mutants]
+6
View File
@@ -225,6 +225,12 @@ completed drawings publish atomically. The browser still exports synchronously.
## Render foundation
- Wood surface images (#1345): native CPU and GPU opaque rendering share physical
repeat frames and filtered mip chains. Imported maps retain exact bytes and
declared provenance in the design, reopen offline, and expose grain-angle
controls. Scalar face appearances suppress body maps. Planar projection and
CPU encoding/optical limits remain explicit; see [portable surfaces](docs/render-portable-surfaces-and-stages.md).
- Original starter appearances (#1722): 34 opaque recipes through existing commands, with six explicitly unavailable glass recipes and preserved legacy looks; see [the contract](docs/render-starter-appearances.md).
- Bounded linear film accumulation (#1696) supplies caller-admitted mean/variance storage and complete-pass views; actual progressive consumers remain separate. See [the film contract](docs/render-film.md).
+275 -116
View File
@@ -1,18 +1,17 @@
Third-party software in SolidCraft
SolidCraft (the desktop app, solidcraft-cli and the web app) is built from the 537 crates below.
SolidCraft (the desktop app, solidcraft-cli and the web app) is built from the 540 crates below.
Generated by `cargo xtask licences` from Cargo.lock and the crates' own licence files.
SolidCraft's own licence is in LICENSE-MIT and LICENSE-APACHE, and NOTICE.
accesskit 0.24.1 (MIT OR Apache-2.0)
accesskit_atspi_common 0.18.1 (MIT OR Apache-2.0)
accesskit_consumer 0.35.0 (MIT OR Apache-2.0)
accesskit_consumer 0.36.0 (MIT OR Apache-2.0)
accesskit_consumer 0.38.0 (MIT OR Apache-2.0)
accesskit_macos 0.26.3 (MIT OR Apache-2.0)
accesskit_unix 0.21.1 (MIT OR Apache-2.0)
accesskit_windows 0.32.1 (MIT OR Apache-2.0)
accesskit_winit 0.32.2 (Apache-2.0)
accesskit 0.25.2 (MIT OR Apache-2.0)
accesskit_atspi_common 0.21.1 (MIT OR Apache-2.0)
accesskit_consumer 0.39.2 (MIT OR Apache-2.0)
accesskit_ios 0.2.2 (MIT OR Apache-2.0)
accesskit_macos 0.27.2 (MIT OR Apache-2.0)
accesskit_unix 0.24.1 (MIT OR Apache-2.0)
accesskit_windows 0.35.2 (MIT OR Apache-2.0)
accesskit_winit 0.34.2 (Apache-2.0)
adler2 2.0.1 (0BSD OR MIT OR Apache-2.0)
aes 0.8.4 (MIT OR Apache-2.0)
ahash 0.8.12 (MIT OR Apache-2.0)
@@ -65,9 +64,7 @@ cadmpeg-core 0.6.7 (Apache-2.0)
cadmpeg-ir 0.6.7 (Apache-2.0)
cadmpeg-registry 0.6.7 (Apache-2.0)
calloop 0.13.0 (MIT)
calloop 0.14.5 (MIT)
calloop-wayland-source 0.3.0 (MIT)
calloop-wayland-source 0.4.1 (MIT)
cbc 0.1.2 (MIT OR Apache-2.0)
cfg-if 1.0.5 (MIT OR Apache-2.0)
cgl 0.3.2 (MIT / Apache-2.0)
@@ -109,21 +106,21 @@ document-features 0.2.12 (MIT OR Apache-2.0)
downcast-rs 1.2.1 (MIT/Apache-2.0)
dpi 0.1.2 (Apache-2.0 AND MIT)
dyn-clone 1.0.20 (MIT OR Apache-2.0)
ecolor 0.36.2 (MIT OR Apache-2.0)
eframe 0.36.2 (MIT OR Apache-2.0)
egui 0.36.2 (MIT OR Apache-2.0)
egui-wgpu 0.36.2 (MIT OR Apache-2.0)
egui-winit 0.36.2 (MIT OR Apache-2.0)
egui_glow 0.36.2 (MIT OR Apache-2.0)
ecolor 0.37.0 (MIT OR Apache-2.0)
eframe 0.37.0 (MIT OR Apache-2.0)
egui 0.37.0 (MIT OR Apache-2.0)
egui-wgpu 0.37.0 (MIT OR Apache-2.0)
egui-winit 0.37.0 (MIT OR Apache-2.0)
egui_glow 0.37.0 (MIT OR Apache-2.0)
either 1.19.0 (MIT OR Apache-2.0)
emath 0.36.2 (MIT OR Apache-2.0)
emath 0.37.0 (MIT OR Apache-2.0)
encoding_rs 0.8.42 ((Apache-2.0 OR MIT) AND BSD-3-Clause)
endi 1.1.1 (MIT)
enumflags2 0.7.12 (MIT OR Apache-2.0)
enumflags2_derive 0.7.12 (MIT OR Apache-2.0)
enumn 0.1.14 (MIT OR Apache-2.0)
epaint 0.36.2 (MIT OR Apache-2.0)
epaint_default_fonts 0.36.2 ((MIT OR Apache-2.0) AND OFL-1.1 AND Ubuntu-font-1.0)
epaint 0.37.0 (MIT OR Apache-2.0)
epaint_default_fonts 0.37.0 ((MIT OR Apache-2.0) AND OFL-1.1 AND Ubuntu-font-1.0)
equivalent 1.0.2 (Apache-2.0 OR MIT)
errno 0.3.14 (MIT OR Apache-2.0)
error-code 3.4.0 (BSL-1.0)
@@ -134,7 +131,9 @@ fastrand 2.5.0 (Apache-2.0 OR MIT)
fax 0.2.7 (MIT)
fdeflate 0.3.7 (MIT OR Apache-2.0)
fearless_simd 0.4.1 (Apache-2.0 OR MIT)
fixedbitset 0.5.7 (MIT OR Apache-2.0)
flate2 1.1.10 (MIT OR Apache-2.0)
foldhash 0.1.5 (Zlib)
foldhash 0.2.0 (Zlib)
font-types 0.12.6 (MIT OR Apache-2.0)
foreign-types 0.5.0 (MIT/Apache-2.0)
@@ -154,7 +153,7 @@ getrandom 0.2.17 (MIT OR Apache-2.0)
getrandom 0.3.4 (MIT OR Apache-2.0)
getrandom 0.4.3 (MIT OR Apache-2.0)
gif 0.14.2 (MIT OR Apache-2.0)
glifo 0.2.0 (Apache-2.0 OR MIT)
glifo 0.3.0 (Apache-2.0 OR MIT)
glow 0.17.0 (MIT OR Apache-2.0 OR Zlib)
gltf 1.4.1 (MIT OR Apache-2.0)
gltf-derive 1.4.1 (MIT OR Apache-2.0)
@@ -168,6 +167,7 @@ gpu-allocator 0.28.0 (MIT OR Apache-2.0)
guillotiere 0.7.0 (MIT/Apache-2.0)
half 2.7.1 (MIT OR Apache-2.0)
harfrust 0.12.0 (MIT)
hashbrown 0.15.5 (MIT OR Apache-2.0)
hashbrown 0.16.1 (MIT OR Apache-2.0)
hashbrown 0.17.1 (MIT OR Apache-2.0)
hermit-abi 0.5.3 (MIT OR Apache-2.0)
@@ -234,6 +234,7 @@ ndk-sys 0.6.0+11769913 (MIT OR Apache-2.0)
nix 0.29.0 (MIT)
nix 0.31.3 (MIT)
nohash-hasher 0.2.0 (Apache-2.0 OR MIT)
nom 8.0.0 (MIT)
num 0.4.3 (MIT OR Apache-2.0)
num-bigint 0.4.8 (MIT OR Apache-2.0)
num-complex 0.4.6 (MIT OR Apache-2.0)
@@ -275,15 +276,17 @@ orbclient 0.3.55 (MIT)
ordered-float 2.10.1 (MIT)
ordered-float 5.5.0 (MIT)
ordered-stream 0.2.0 (MIT OR Apache-2.0)
os_pipe 1.2.3 (MIT)
parking 2.2.1 (Apache-2.0 OR MIT)
parking_lot 0.12.5 (MIT OR Apache-2.0)
parking_lot_core 0.9.12 (MIT OR Apache-2.0)
peniko 0.6.1 (Apache-2.0 OR MIT)
percent-encoding 2.3.2 (MIT OR Apache-2.0)
phf 0.13.1 (MIT)
phf_generator 0.13.1 (MIT)
phf_macros 0.13.1 (MIT)
phf_shared 0.13.1 (MIT)
petgraph 0.8.3 (MIT OR Apache-2.0)
phf 0.14.0 (MIT)
phf_generator 0.14.0 (MIT)
phf_macros 0.14.0 (MIT)
phf_shared 0.14.0 (MIT)
pin-project 1.1.13 (Apache-2.0 OR MIT)
pin-project-internal 1.1.13 (Apache-2.0 OR MIT)
pin-project-lite 0.2.17 (Apache-2.0 OR MIT)
@@ -352,6 +355,7 @@ security-framework 3.7.0 (MIT OR Apache-2.0)
security-framework-sys 2.17.0 (MIT OR Apache-2.0)
self_cell 1.3.0 (Apache-2.0 OR GPL-2.0-only)
semver 1.0.28 (MIT OR Apache-2.0)
send_wrapper 0.6.0 (MIT/Apache-2.0)
serde 1.0.229 (MIT OR Apache-2.0)
serde-value 0.7.0 (MIT)
serde_core 1.0.229 (MIT OR Apache-2.0)
@@ -375,8 +379,6 @@ slab 0.4.12 (MIT)
slotmap 1.1.1 (Zlib)
smallvec 1.16.2 (MIT OR Apache-2.0)
smithay-client-toolkit 0.19.2 (MIT)
smithay-client-toolkit 0.20.0 (MIT)
smithay-clipboard 0.7.3 (MIT)
smol_str 0.2.2 (MIT OR Apache-2.0)
solidcore 1.0.1 (Apache-2.0)
solidcore-record 0.1.0 (Apache-2.0)
@@ -409,6 +411,7 @@ toml_writer 1.1.3+spec-1.1.0 (MIT OR Apache-2.0)
tracing 0.1.44 (MIT)
tracing-attributes 0.1.31 (MIT)
tracing-core 0.1.36 (MIT)
tree_magic_mini 3.2.2 (MIT)
ttf-parser 0.25.1 (MIT OR Apache-2.0)
twox-hash 2.1.5 (MIT)
type-map 0.5.1 (MIT/Apache-2.0)
@@ -419,6 +422,7 @@ uds_windows 1.2.1 (MIT)
unicode-bidi 0.3.18 (MIT OR Apache-2.0)
unicode-general-category 1.1.0 (Apache-2.0)
unicode-ident 1.0.26 ((MIT OR Apache-2.0) AND Unicode-3.0)
unicode-properties 0.1.5 ((MIT OR Apache-2.0) AND Unicode-3.0)
unicode-segmentation 1.13.3 (MIT OR Apache-2.0)
unicode-width 0.2.2 (MIT OR Apache-2.0)
untrusted 0.9.0 (ISC)
@@ -428,8 +432,8 @@ url 2.5.8 (MIT OR Apache-2.0)
utf8-zero 0.8.1 (MIT OR Apache-2.0)
utf8_iter 1.0.4 (Apache-2.0 OR MIT)
uuid 1.27.0 (Apache-2.0 OR MIT)
vello_common 0.1.0 (Apache-2.0 OR MIT)
vello_cpu 0.1.0 (Apache-2.0 OR MIT)
vello_common 0.2.0 (Apache-2.0 OR MIT)
vello_cpu 0.2.0 (Apache-2.0 OR MIT)
wasi 0.11.1+wasi-snapshot-preview1 (Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT)
wasip2 1.0.4+wasi-0.2.12 (Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT)
wasm-bindgen 0.2.129 (MIT OR Apache-2.0)
@@ -442,8 +446,6 @@ wayland-client 0.31.15 (MIT)
wayland-csd-frame 0.3.0 (MIT)
wayland-cursor 0.31.14 (MIT)
wayland-protocols 0.32.13 (MIT)
wayland-protocols-experimental 20250721.0.1 (MIT)
wayland-protocols-misc 0.3.12 (MIT)
wayland-protocols-plasma 0.3.12 (MIT)
wayland-protocols-wlr 0.3.12 (MIT)
wayland-scanner 0.31.11 (MIT)
@@ -500,6 +502,7 @@ winit 0.30.13 (Apache-2.0)
winnow 1.0.4 (MIT)
winreg 0.56.0 (MIT)
wit-bindgen 0.57.1 (Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT)
wl-clipboard-rs 0.9.5 (MIT OR Apache-2.0)
writeable 0.6.4 (Unicode-3.0)
x11-dl 2.21.0 (MIT)
x11rb 0.13.2 (MIT OR Apache-2.0)
@@ -570,13 +573,14 @@ num_enum 0.7.6, num_enum_derive 0.7.6, once_cell 1.21.4, ordered-stream 0.2.0, p
pin-project 1.1.13, pin-project-internal 1.1.13, pin-project-lite 0.2.17, piper 0.2.5, polling
3.11.0, portable-atomic 1.15.0, portable-atomic-util 0.2.8, proc-macro-crate 3.5.0, proc-macro2
1.0.107, quote 1.0.47, ref-cast 1.0.27, ref-cast-impl 1.0.27, rustc-hash 1.1.0, rustc-hash 2.1.3,
rustix 0.38.44, rustix 1.1.5, rustversion 1.0.23, semver 1.0.28, serde 1.0.229, serde_core 1.0.229,
serde_derive 1.0.229, serde_derive_internals 0.30.0, serde_json 1.0.151, serde_path_to_error 0.1.20,
serde_repr 0.1.21, simd_cesu8 1.2.0, smol_str 0.2.2, syn 2.0.119, syn 3.0.6, thiserror 1.0.69,
thiserror 2.0.21, thiserror-impl 1.0.69, thiserror-impl 2.0.21, typed-path 0.13.0, typeid 1.0.3,
unicode-ident 1.0.26, utf8-zero 0.8.1, wasi 0.11.1+wasi-snapshot-preview1, wasip2 1.0.4+wasi-0.2.12,
wit-bindgen 0.57.1, x11-dl 2.21.0, xdg-home 1.3.0, zbus-lockstep 0.5.2, zbus-lockstep-macros 0.5.2,
zbus_names 3.0.0, zmij 1.0.23, zvariant_utils 2.1.0, zvariant_utils 4.2.0
rustix 0.38.44, rustix 1.1.5, rustversion 1.0.23, semver 1.0.28, send_wrapper 0.6.0, serde 1.0.229,
serde_core 1.0.229, serde_derive 1.0.229, serde_derive_internals 0.30.0, serde_json 1.0.151,
serde_path_to_error 0.1.20, serde_repr 0.1.21, simd_cesu8 1.2.0, smol_str 0.2.2, syn 2.0.119, syn
3.0.6, thiserror 1.0.69, thiserror 2.0.21, thiserror-impl 1.0.69, thiserror-impl 2.0.21, typed-path
0.13.0, typeid 1.0.3, unicode-ident 1.0.26, utf8-zero 0.8.1, wasi 0.11.1+wasi-snapshot-preview1,
wasip2 1.0.4+wasi-0.2.12, wit-bindgen 0.57.1, x11-dl 2.21.0, xdg-home 1.3.0, zbus-lockstep 0.5.2,
zbus-lockstep-macros 0.5.2, zbus_names 3.0.0, zmij 1.0.23, zvariant_utils 2.1.0, zvariant_utils
4.2.0
================================================================================
Permission is hereby granted, free of charge, to any
@@ -1086,25 +1090,26 @@ ahash 0.8.12, arrayvec 0.7.8, async-channel 2.5.0, async-executor 1.14.0, async-
0.9.4, core-foundation-sys 0.8.7, core-graphics 0.23.2, core-graphics-types 0.1.3, core_detect
1.0.0, crossbeam-channel 0.5.17, crossbeam-deque 0.8.8, crossbeam-epoch 0.9.21, crossbeam-utils
0.8.23, dbus-secret-service 4.1.0, displaydoc 0.2.7, either 1.19.0, equivalent 1.0.2, errno 0.3.14,
euclid 0.22.14, event-listener 5.4.2, event-listener-strategy 0.5.4, fastrand 2.5.0, flate2 1.1.10,
form_urlencoded 1.2.2, futures-lite 2.6.1, gethostname 1.1.0, gif 0.14.2, glow 0.17.0, gltf 1.4.1,
gltf-derive 1.4.1, gltf-json 1.4.1, hashbrown 0.16.1, hashbrown 0.17.1, hermit-abi 0.5.3, httparse
1.10.1, idna 1.1.0, idna_adapter 1.2.2, indexmap 2.14.2, itertools 0.15.0, js-sys 0.3.106,
keyboard-types 0.8.3, khronos-egl 6.0.0, lazy_static 1.5.1, linux-raw-sys 0.12.1, linux-raw-sys
0.4.15, lock_api 0.4.14, log 0.4.34, muda 0.21.2, num 0.4.3, num-bigint 0.4.8, num-complex 0.4.6,
num-integer 0.1.47, num-iter 0.1.46, num-rational 0.4.2, num-traits 0.2.19, once_cell 1.21.4,
openssl-probe 0.2.1, ordered-stream 0.2.0, parking 2.2.1, parking_lot 0.12.5, parking_lot_core
0.9.12, percent-encoding 2.3.2, piper 0.2.5, plain 0.2.3, png 0.18.1, polling 3.11.0, pollster
0.4.0, pollster 1.0.1, rayon 1.12.0, rayon-core 1.13.0, renderdoc-sys 1.1.0, robust 1.2.0, ron
0.12.2, roxmltree 0.21.1, rustc-hash 1.1.0, rustix 0.38.44, rustix 1.1.5, rustls 0.23.46,
rustls-native-certs 0.8.5, scoped-tls 1.0.1, scopeguard 1.2.0, secret-service 4.0.0,
security-framework 2.11.1, security-framework 3.7.0, security-framework-sys 2.17.0,
signal-hook-registry 1.4.8, simd_cesu8 1.2.0, smallvec 1.16.2, smol_str 0.2.2, stable_deref_trait
1.2.1, tempfile 3.27.0, ttf-parser 0.25.1, unicode-bidi 0.3.18, unicode-segmentation 1.13.3,
unicode-width 0.2.2, url 2.5.8, uuid 1.27.0, wasi 0.11.1+wasi-snapshot-preview1, wasip2
1.0.4+wasi-0.2.12, wasm-bindgen 0.2.129, wasm-bindgen-futures 0.4.79, wasm-bindgen-macro 0.2.129,
wasm-bindgen-macro-support 0.2.129, wasm-bindgen-shared 0.2.129, web-sys 0.3.106, weezl 0.1.12,
wit-bindgen 0.57.1
euclid 0.22.14, event-listener 5.4.2, event-listener-strategy 0.5.4, fastrand 2.5.0, fixedbitset
0.5.7, flate2 1.1.10, form_urlencoded 1.2.2, futures-lite 2.6.1, gethostname 1.1.0, gif 0.14.2, glow
0.17.0, gltf 1.4.1, gltf-derive 1.4.1, gltf-json 1.4.1, hashbrown 0.15.5, hashbrown 0.16.1,
hashbrown 0.17.1, hermit-abi 0.5.3, httparse 1.10.1, idna 1.1.0, idna_adapter 1.2.2, indexmap
2.14.2, itertools 0.15.0, js-sys 0.3.106, keyboard-types 0.8.3, khronos-egl 6.0.0, lazy_static
1.5.1, linux-raw-sys 0.12.1, linux-raw-sys 0.4.15, lock_api 0.4.14, log 0.4.34, muda 0.21.2, num
0.4.3, num-bigint 0.4.8, num-complex 0.4.6, num-integer 0.1.47, num-iter 0.1.46, num-rational 0.4.2,
num-traits 0.2.19, once_cell 1.21.4, openssl-probe 0.2.1, ordered-stream 0.2.0, parking 2.2.1,
parking_lot 0.12.5, parking_lot_core 0.9.12, percent-encoding 2.3.2, petgraph 0.8.3, piper 0.2.5,
plain 0.2.3, png 0.18.1, polling 3.11.0, pollster 0.4.0, pollster 1.0.1, rayon 1.12.0, rayon-core
1.13.0, renderdoc-sys 1.1.0, robust 1.2.0, ron 0.12.2, roxmltree 0.21.1, rustc-hash 1.1.0, rustix
0.38.44, rustix 1.1.5, rustls 0.23.46, rustls-native-certs 0.8.5, scoped-tls 1.0.1, scopeguard
1.2.0, secret-service 4.0.0, security-framework 2.11.1, security-framework 3.7.0,
security-framework-sys 2.17.0, send_wrapper 0.6.0, signal-hook-registry 1.4.8, simd_cesu8 1.2.0,
smallvec 1.16.2, smol_str 0.2.2, stable_deref_trait 1.2.1, tempfile 3.27.0, ttf-parser 0.25.1,
unicode-bidi 0.3.18, unicode-properties 0.1.5, unicode-segmentation 1.13.3, unicode-width 0.2.2, url
2.5.8, uuid 1.27.0, wasi 0.11.1+wasi-snapshot-preview1, wasip2 1.0.4+wasi-0.2.12, wasm-bindgen
0.2.129, wasm-bindgen-futures 0.4.79, wasm-bindgen-macro 0.2.129, wasm-bindgen-macro-support
0.2.129, wasm-bindgen-shared 0.2.129, web-sys 0.3.106, weezl 0.1.12, wit-bindgen 0.57.1,
wl-clipboard-rs 0.9.5
================================================================================
Apache License
@@ -1310,7 +1315,7 @@ See the License for the specific language governing permissions and
limitations under the License.
================================================================================
allocator-api2 0.2.21, async-trait 0.1.92, dyn-clone 1.0.20, enumn 0.1.14, glifo 0.2.0, itoa 1.0.18,
allocator-api2 0.2.21, async-trait 0.1.92, dyn-clone 1.0.20, enumn 0.1.14, glifo 0.3.0, itoa 1.0.18,
libc 0.2.190, litrs 1.0.0, num_enum 0.7.6, num_enum_derive 0.7.6, proc-macro2 1.0.107, quote 1.0.47,
ref-cast 1.0.27, ref-cast-impl 1.0.27, rustc-hash 2.1.3, rustversion 1.0.23, semver 1.0.28, serde
1.0.229, serde_core 1.0.229, serde_derive 1.0.229, serde_derive_internals 0.30.0, serde_json
@@ -1530,7 +1535,7 @@ android-activity 0.6.1, arboard 3.6.1, color 0.3.3, fdeflate 0.3.7, fearless_sim
image 0.25.10, image-webp 0.2.4, linebender_resource_handle 0.1.1, miniz_oxide 0.8.9, miniz_oxide
0.9.1, naga 30.0.1, naga-types 30.0.1, pin-project 1.1.13, pin-project-internal 1.1.13,
pin-project-lite 0.2.17, portable-atomic 1.15.0, portable-atomic-util 0.2.8, range-alloc 0.1.5,
raw-window-handle 0.6.2, simdutf8 0.1.5, vello_common 0.1.0, vello_cpu 0.1.0, wgpu 30.0.1, wgpu-core
raw-window-handle 0.6.2, simdutf8 0.1.5, vello_common 0.2.0, vello_cpu 0.2.0, wgpu 30.0.1, wgpu-core
30.0.1, wgpu-core-deps-apple 30.0.1, wgpu-core-deps-emscripten 30.0.1, wgpu-core-deps-wasm 30.0.1,
wgpu-core-deps-windows-linux-android 30.0.1, wgpu-hal 30.0.1, wgpu-naga-bridge 30.0.1, wgpu-types
30.0.1
@@ -2588,7 +2593,7 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
bit-set 0.10.0, bit-vec 0.9.1, downcast-rs 1.2.1, egui-wgpu 0.36.2, presser 0.3.1
bit-set 0.10.0, bit-vec 0.9.1, downcast-rs 1.2.1, egui-wgpu 0.37.0, presser 0.3.1
================================================================================
Apache License
@@ -3134,7 +3139,7 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
calloop 0.13.0, calloop 0.14.5, smithay-client-toolkit 0.19.2, smithay-client-toolkit 0.20.0
calloop 0.13.0, smithay-client-toolkit 0.19.2
================================================================================
Copyright (c) 2018 Victor Berger
@@ -3158,7 +3163,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
================================================================================
calloop-wayland-source 0.3.0, calloop-wayland-source 0.4.1
calloop-wayland-source 0.3.0
================================================================================
Copyright (c) 2023 Kirill Chibisov
@@ -5231,7 +5236,7 @@ have been licensed under extremely permissive terms.
------------------------------------------------------------------------------
================================================================================
egui-wgpu 0.36.2
egui-wgpu 0.37.0
================================================================================
Copyright (c) 2018-2021 Emil Ernerfeldt <emil.ernerfeldt@gmail.com>
@@ -5261,7 +5266,7 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
either 1.19.0, itertools 0.15.0
either 1.19.0, itertools 0.15.0, petgraph 0.8.3
================================================================================
Copyright (c) 2015
@@ -5709,6 +5714,36 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
fixedbitset 0.5.7
================================================================================
Copyright (c) 2015-2017
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
flate2 1.1.10
================================================================================
@@ -5740,7 +5775,7 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
foldhash 0.2.0
foldhash 0.1.5, foldhash 0.2.0
================================================================================
Copyright (c) 2024 Orson Peters
@@ -6511,7 +6546,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================================================
glifo 0.2.0
glifo 0.3.0
================================================================================
Copyright 2025 the Vello Authors
@@ -7138,7 +7173,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================================================
hashbrown 0.16.1, hashbrown 0.17.1
hashbrown 0.15.5, hashbrown 0.16.1, hashbrown 0.17.1
================================================================================
Copyright (c) 2016 Amanieu d'Antras
@@ -9459,6 +9494,31 @@ OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
================================================================================
nom 8.0.0
================================================================================
Copyright (c) 2014-2019 Geoffroy Couprie
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
================================================================================
num_enum 0.7.6, num_enum_derive 0.7.6
================================================================================
@@ -9547,6 +9607,30 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
os_pipe 1.2.3
================================================================================
The MIT License (MIT)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
================================================================================
parking 2.2.1
================================================================================
@@ -9562,7 +9646,7 @@ option. All files in the project carrying such notice may not be
copied, modified, or distributed except according to those terms.
================================================================================
phf 0.13.1, phf_generator 0.13.1, phf_macros 0.13.1, phf_shared 0.13.1
phf 0.14.0, phf_generator 0.14.0, phf_macros 0.14.0, phf_shared 0.14.0
================================================================================
The MIT License (MIT)
@@ -12377,30 +12461,6 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
smithay-clipboard 0.7.3
================================================================================
Copyright (c) 2018 Lucas Timmins & Victor Berger
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
================================================================================
solidcore 1.0.1
================================================================================
@@ -12973,6 +13033,32 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
tree_magic_mini 3.2.2
================================================================================
MIT License
Copyright (c) 2017 Aaron Hancock
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================================================
ttf-parser 0.25.1
================================================================================
@@ -13795,7 +13881,7 @@ carrying such notice may not be copied, modified, or distributed except
according to those terms.
================================================================================
unicode-bidi 0.3.18, unicode-segmentation 1.13.3, unicode-width 0.2.2
unicode-bidi 0.3.18, unicode-properties 0.1.5, unicode-segmentation 1.13.3, unicode-width 0.2.2
================================================================================
Copyright (c) 2015 The Rust Project Developers
@@ -13869,7 +13955,51 @@ dealings in these Data Files or Software without prior written
authorization of the copyright holder.
================================================================================
unicode-segmentation 1.13.3, unicode-width 0.2.2
unicode-properties 0.1.5
================================================================================
UNICODE LICENSE V3
COPYRIGHT AND PERMISSION NOTICE
Copyright © 1991-2026 Unicode, Inc.
NOTICE TO USER: Carefully read the following legal agreement. BY
DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR
SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE
TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT
DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE.
Permission is hereby granted, free of charge, to any person obtaining a
copy of data files and any associated documentation (the "Data Files") or
software and any associated documentation (the "Software") to deal in the
Data Files or Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, and/or sell
copies of the Data Files or Software, and to permit persons to whom the
Data Files or Software are furnished to do so, provided that either (a)
this copyright and permission notice appear with all copies of the Data
Files or Software, or (b) this copyright and permission notice appear in
associated Documentation.
THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY
KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
THIRD PARTY RIGHTS.
IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE
BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES,
OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS,
WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA
FILES OR SOFTWARE.
Except as contained in this notice, the name of a copyright holder shall
not be used in advertising or otherwise to promote the sale, use or other
dealings in these Data Files or Software without prior written
authorization of the copyright holder.
================================================================================
unicode-properties 0.1.5, unicode-segmentation 1.13.3, unicode-width 0.2.2
================================================================================
Licensed under the Apache License, Version 2.0
@@ -14015,7 +14145,7 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
vello_common 0.1.0, vello_cpu 0.1.0
vello_common 0.2.0, vello_cpu 0.2.0
================================================================================
Copyright 2020 the Vello Authors
@@ -14046,8 +14176,8 @@ DEALINGS IN THE SOFTWARE.
================================================================================
wayland-backend 0.3.17, wayland-client 0.31.15, wayland-cursor 0.31.14, wayland-protocols 0.32.13,
wayland-protocols-experimental 20250721.0.1, wayland-protocols-misc 0.3.12, wayland-protocols-plasma
0.3.12, wayland-protocols-wlr 0.3.12, wayland-scanner 0.31.11, wayland-sys 0.31.11
wayland-protocols-plasma 0.3.12, wayland-protocols-wlr 0.3.12, wayland-scanner 0.31.11, wayland-sys
0.31.11
================================================================================
Copyright (c) 2015 Elinor Berger
@@ -14942,6 +15072,36 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
================================================================================
wl-clipboard-rs 0.9.5
================================================================================
Copyright (c) 2019 Ivan Molodetskikh
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
x11rb 0.13.2, x11rb-protocol 0.13.2
================================================================================
@@ -15967,15 +16127,14 @@ Crates whose package carries no licence file (their licence is named in Cargo.to
texts are as in SolidCraft's own LICENSE-MIT and LICENSE-APACHE)
================================================================================
accesskit 0.24.1: MIT OR Apache-2.0 (https://github.com/AccessKit/accesskit)
accesskit_atspi_common 0.18.1: MIT OR Apache-2.0 (https://github.com/AccessKit/accesskit)
accesskit_consumer 0.35.0: MIT OR Apache-2.0 (https://github.com/AccessKit/accesskit)
accesskit_consumer 0.36.0: MIT OR Apache-2.0 (https://github.com/AccessKit/accesskit)
accesskit_consumer 0.38.0: MIT OR Apache-2.0 (https://github.com/AccessKit/accesskit)
accesskit_macos 0.26.3: MIT OR Apache-2.0 (https://github.com/AccessKit/accesskit)
accesskit_unix 0.21.1: MIT OR Apache-2.0 (https://github.com/AccessKit/accesskit)
accesskit_windows 0.32.1: MIT OR Apache-2.0 (https://github.com/AccessKit/accesskit)
accesskit_winit 0.32.2: Apache-2.0 (https://github.com/AccessKit/accesskit)
accesskit 0.25.2: MIT OR Apache-2.0 (https://github.com/AccessKit/accesskit)
accesskit_atspi_common 0.21.1: MIT OR Apache-2.0 (https://github.com/AccessKit/accesskit)
accesskit_consumer 0.39.2: MIT OR Apache-2.0 (https://github.com/AccessKit/accesskit)
accesskit_ios 0.2.2: MIT OR Apache-2.0 (https://github.com/AccessKit/accesskit)
accesskit_macos 0.27.2: MIT OR Apache-2.0 (https://github.com/AccessKit/accesskit)
accesskit_unix 0.24.1: MIT OR Apache-2.0 (https://github.com/AccessKit/accesskit)
accesskit_windows 0.35.2: MIT OR Apache-2.0 (https://github.com/AccessKit/accesskit)
accesskit_winit 0.34.2: Apache-2.0 (https://github.com/AccessKit/accesskit)
block2 0.5.1: MIT (https://github.com/madsmtm/objc2)
block2 0.6.2: MIT (https://github.com/madsmtm/objc2)
cadmpeg-codec-freecad 0.6.7: Apache-2.0 (https://github.com/cadmpeg/cadmpeg)
@@ -15988,14 +16147,14 @@ cadmpeg-registry 0.6.7: Apache-2.0 (https://github.com/cadmpeg/cadmpeg)
clipboard-win 5.4.1: BSL-1.0 (https://github.com/DoumanAsh/clipboard-win)
dispatch 0.2.0: MIT (http://github.com/SSheldon/rust-dispatch)
dispatch2 0.3.1: Zlib OR Apache-2.0 OR MIT (https://github.com/madsmtm/objc2)
ecolor 0.36.2: MIT OR Apache-2.0 (https://github.com/emilk/egui)
eframe 0.36.2: MIT OR Apache-2.0 (https://github.com/emilk/egui/tree/main/crates/eframe)
egui 0.36.2: MIT OR Apache-2.0 (https://github.com/emilk/egui)
egui-winit 0.36.2: MIT OR Apache-2.0 (https://github.com/emilk/egui/tree/main/crates/egui-winit)
egui_glow 0.36.2: MIT OR Apache-2.0 (https://github.com/emilk/egui/tree/main/crates/egui_glow)
emath 0.36.2: MIT OR Apache-2.0 (https://github.com/emilk/egui/tree/main/crates/emath)
epaint 0.36.2: MIT OR Apache-2.0 (https://github.com/emilk/egui/tree/main/crates/epaint)
epaint_default_fonts 0.36.2: (MIT OR Apache-2.0) AND OFL-1.1 AND Ubuntu-font-1.0 (https://github.com/emilk/egui/tree/main/crates/epaint_default_fonts)
ecolor 0.37.0: MIT OR Apache-2.0 (https://github.com/emilk/egui)
eframe 0.37.0: MIT OR Apache-2.0 (https://github.com/emilk/egui/tree/main/crates/eframe)
egui 0.37.0: MIT OR Apache-2.0 (https://github.com/emilk/egui)
egui-winit 0.37.0: MIT OR Apache-2.0 (https://github.com/emilk/egui/tree/main/crates/egui-winit)
egui_glow 0.37.0: MIT OR Apache-2.0 (https://github.com/emilk/egui/tree/main/crates/egui_glow)
emath 0.37.0: MIT OR Apache-2.0 (https://github.com/emilk/egui/tree/main/crates/emath)
epaint 0.37.0: MIT OR Apache-2.0 (https://github.com/emilk/egui/tree/main/crates/epaint)
epaint_default_fonts 0.37.0: (MIT OR Apache-2.0) AND OFL-1.1 AND Ubuntu-font-1.0 (https://github.com/emilk/egui/tree/main/crates/epaint_default_fonts)
jni 0.22.4: MIT OR Apache-2.0 (https://github.com/jni-rs/jni-rs)
jni-macros 0.22.4: MIT OR Apache-2.0 (https://github.com/jni-rs/jni-rs)
jni-sys-macros 0.4.1: MIT OR Apache-2.0 (https://github.com/jni-rs/jni-sys)
@@ -16039,7 +16198,7 @@ spirv 0.4.0+sdk-1.4.341.0: Apache-2.0 (https://github.com/gfx-rs/rspirv)
Bundled fonts (SIL Open Font License, Version 1.1)
================================================================================
- LiberationSans-Regular.ttf (assets/fonts/liberation-sans/LiberationSans-Regular.ttf)
- LiberationSans-Regular.ttf (crates/sketch/assets/fonts/liberation-sans/LiberationSans-Regular.ttf)
Copyright 2012 Red Hat, Inc. (Liberation Fonts 2.x; digitized data Google)
Source: https://github.com/liberationfonts
+2 -2
View File
@@ -11,7 +11,7 @@ use std::process::{Command, ExitCode};
const TARGET: &str = "solidcraft-cli";
fn main() -> ExitCode {
eprintln!("solvecraft-cli is deprecated and will be removed in a later release: use solidcraft-cli");
solidcraft_engine::note!("solvecraft-cli is deprecated and will be removed in a later release: use solidcraft-cli");
let sibling = std::env::current_exe()
.ok()
.and_then(|exe| exe.parent().map(|dir| dir.join(format!("{TARGET}{}", std::env::consts::EXE_SUFFIX))))
@@ -23,7 +23,7 @@ fn main() -> ExitCode {
None => ExitCode::FAILURE,
},
Err(e) => {
eprintln!("solvecraft-cli: could not run {}: {e}", program.to_string_lossy());
solidcraft_engine::note!("solvecraft-cli: could not run {}: {e}", program.to_string_lossy());
ExitCode::FAILURE
}
}
+9 -6
View File
@@ -33,8 +33,8 @@ pub fn one(args: &[String]) -> Result<(), String> {
std::panic::set_hook(Box::new(|_| {
PANICS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
}));
eprintln!("design {seed}");
let run = fuzz::run_with(seed, steps, &mut |st| eprintln!("step {}", serde_json::to_string(st).unwrap_or_default()));
solidcraft_engine::note!("design {seed}");
let run = fuzz::run_with(seed, steps, &mut |st| solidcraft_engine::note!("step {}", serde_json::to_string(st).unwrap_or_default()));
let mut design = json!({
"outcome": run.outcome,
"steps": run.steps.iter().map(|s| s.kind).collect::<Vec<_>>(),
@@ -45,13 +45,13 @@ pub fn one(args: &[String]) -> Result<(), String> {
if run.outcome.is_bug() {
design["script"] = run.script();
if minimise {
eprintln!("minimise");
solidcraft_engine::note!("minimise");
let m = fuzz::minimise(&run);
design["minimal"] = json!({"outcome": m.outcome, "steps": m.steps.iter().map(|s| s.kind).collect::<Vec<_>>(), "script": m.script()});
}
}
let (a, b) = solidcraft_engine::fuzz::boolean_shapes(seed);
eprintln!("boolean {seed} {} {}", serde_json::to_string(&a).unwrap_or_default(), serde_json::to_string(&b).unwrap_or_default());
solidcraft_engine::note!("boolean {seed} {} {}", serde_json::to_string(&a).unwrap_or_default(), serde_json::to_string(&b).unwrap_or_default());
let b = boolean_case(seed);
let bp = PANICS.swap(0, std::sync::atomic::Ordering::SeqCst);
println!("{}", json!({"seed": seed, "design": design, "boolean": b, "boolean_panics": bp}));
@@ -162,11 +162,14 @@ fn record(v: &Value, out_dir: Option<&str>) {
let d = &v["design"];
let bug = matches!(d["outcome"]["kind"].as_str(), Some("failed" | "panic" | "invalid" | "volume"));
if bug || v.get("timeout").is_some() || v.get("crash").is_some() {
eprintln!("seed {seed}: {}", if bug { format!("{} {}", d["outcome"]["kind"], d["outcome"]["detail"]) } else { format!("{v}") });
solidcraft_engine::note!(
"seed {seed}: {}",
if bug { format!("{} {}", d["outcome"]["kind"], d["outcome"]["detail"]) } else { format!("{v}") }
);
}
let bk = v["boolean"]["outcome"].as_str().unwrap_or("ok");
if bk != "ok" {
eprintln!("seed {seed}: boolean {bk} {}", v["boolean"]["detail"]);
solidcraft_engine::note!("seed {seed}: boolean {bk} {}", v["boolean"]["detail"]);
}
if let Some(dir) = out_dir {
use std::io::Write;
+57 -24
View File
@@ -18,7 +18,7 @@
//! solidcraft-cli oracle <case-dir>... replay recipes and compare with expected.json
//! solidcraft-cli kernel-cases [--json] [--dumps=DIR] [--validation=MODE] <case-dir>... the kernel regression rig (tests/kernel-cases)
//! solidcraft-cli mcp [--headless] [--in design|script] [--connect HOST:PORT [--token T]] [--allow-path DIR]... [--allow-any-path]
//! [--param-check off|warn|strict]
//! [--param-check off|warn|strict] [--compact]
//! MCP server on stdio (docs/mcp.md)
//! solidcraft-cli mcp setup [--dry-run] [--client NAME] [--live] (alias: mcp install) add it to OpenCode, Claude Desktop, Claude Code, Cursor
//! solidcraft-cli licences SolidCraft's licence and the third-party licences
@@ -64,7 +64,7 @@ struct Command {
/// `run`'s lines in the usage.
const USAGE_RUN: &str = " solidcraft-cli run <script.json|design.sc|part.step> [--in design.sc|part.step] [--out FILE]... [--save FILE] [--quiet] [--results] [--timings] [--profile] [--profile-out trace.json] [--json-out FILE] [--allow-errors] [--validation each_step|each_feature|off]\n [--param-check off|warn|strict]\n solidcraft-cli run --in <design.sc|part.step> [--out FILE]...\n (a timeline feature that fails fails the run and nothing is written; --allow-errors writes anyway)\n (--validation: when SMLib's full check runs on the features computed (diagnostics.validation), default each_feature)\n (--param-check checks each step against its command's contract: warn reports on stderr, strict stops the run)\n (--timings prints {timings: [{index, command, ms}], total_ms} for a script, with --results its results too;\n --profile adds each command's profile: {boolean_ms, tessellate_ms, other_ms}, the kernel's share)\n (--profile also records where the time goes, SMLib's calls, kernel operations, features, commands and import stages,\n and prints the table to stderr: count, total, self time and longest per span; --profile-out FILE writes the spans\n as a Chrome trace for chrome://tracing or ui.perfetto.dev; SOLIDCRAFT_PROFILE=1 does the same for any command)\n";
/// `eval`'s lines in the usage.
const USAGE_EVAL: &str = " solidcraft-cli eval <script.json|design.sc|part.step> [--in design.sc|part.step] [--json-out FILE] [--profile] [--profile-out trace.json] (alias: inspect)\n solidcraft-cli eval --in <script.json|design.sc|part.step> [--json-out FILE]\n (with both inputs, runs the positional script on --in before measuring; does not automatically save the modified design)\n (--json-out FILE writes the JSON that run (with --results, else the measurement) and eval print to FILE instead of stdout,\n as plain UTF-8: a shell's > can re-encode it, e.g. Windows PowerShell adds a byte-order mark)\n (--profile and --profile-out as for run)\n";
const USAGE_EVAL: &str = " solidcraft-cli eval <script.json|design.sc|part.step> [--in design.sc|part.step] [--json-out FILE] [--profile] [--profile-out trace.json] [--validation each_step|each_feature|off] (alias: inspect)\n solidcraft-cli eval --in <script.json|design.sc|part.step> [--json-out FILE]\n (with both inputs, runs the positional script on --in before measuring; does not automatically save the modified design)\n (--json-out FILE writes the JSON that run (with --results, else the measurement) and eval print to FILE instead of stdout,\n as plain UTF-8: a shell's > can re-encode it, e.g. Windows PowerShell adds a byte-order mark)\n (--profile, --profile-out and --validation as for run)\n";
/// `script`'s lines in the usage.
const USAGE_SCRIPT: &str = " solidcraft-cli script <design.sc|script.json> [--out script.json]\n (the design's history as a command script that eval and run replay)\n";
/// `bench-open`'s lines in the usage.
@@ -91,7 +91,7 @@ const USAGE_KERNEL_CASES: &str = " solidcraft-cli kernel-cases <case-dir>... [-
const USAGE_FUZZ: &str =
" solidcraft-cli fuzz [--from N] [--count N] [--jobs J] [--timeout S] [--steps N] [--minimise] [--out DIR] [--report FILE]\n";
/// `mcp`'s lines in the usage.
const USAGE_MCP: &str = " solidcraft-cli mcp [--headless] [--in design.sc|script.json] [--connect 127.0.0.1:PORT [--token T]] [--allow-path DIR]... [--allow-any-path]\n [--param-check off|warn|strict] [--render-gpu]\n solidcraft-cli mcp setup [--dry-run] [--client opencode|claude-desktop|claude-code|cursor] [--live]\n (adds a `solidcraft` server, with this program's path, to each installed AI client; --live adds `solidcraft-live` for the running app; alias: mcp install)\n";
const USAGE_MCP: &str = " solidcraft-cli mcp [--headless] [--in design.sc|script.json] [--connect 127.0.0.1:PORT [--token T]] [--allow-path DIR]... [--allow-any-path]\n [--param-check off|warn|strict] [--render-gpu] [--compact]\n (--compact, SOLIDCRAFT_MCP_COMPACT=1 or the app's Preferences › AI (with --connect): compact tool output for\n agents: inspect_design summarises sketches and takes `sketches` by name and a `bodies` filter, and tool JSON\n is not pretty-printed; off by default)\n solidcraft-cli mcp setup [--dry-run] [--client opencode|claude-desktop|claude-code|cursor] [--live]\n (adds a `solidcraft` server, with this program's path, to each installed AI client; --live adds `solidcraft-live` for the running app; alias: mcp install)\n";
/// `crashes`'s lines in the usage.
const USAGE_CRASHES: &str = " solidcraft-cli crashes [list [--json] | show <id> | replay <id> | dir | clear [<id>]]\n";
/// `licences`'s lines in the usage.
@@ -154,7 +154,7 @@ fn main() -> ExitCode {
}
};
if let Err(e) = max_file_size(&mut args) {
eprintln!("error: {e}");
solidcraft_engine::note!("error: {e}");
return ExitCode::FAILURE;
}
let r = match args.first().map(String::as_str) {
@@ -235,7 +235,7 @@ fn main() -> ExitCode {
match r {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e}");
solidcraft_engine::note!("error: {e}");
ExitCode::FAILURE
}
}
@@ -318,6 +318,19 @@ fn load_checked(path: &str, check: solidcraft_engine::schema::ParamCheck) -> Res
/// `load_checked`, the features computed under `validation` (`diagnostics.validation`), which
/// the session keeps.
fn load_with(path: &str, check: solidcraft_engine::schema::ParamCheck, validation: solidcraft_engine::kernel::Validation) -> Result<Session, String> {
let s = read_session(path, check, validation)?;
// A newer SolidCraft's file opens all the same; stderr says so, stdout stays the caller's.
for warning in s.doc.newer_file.iter().flat_map(|file| file.warnings()) {
eprintln!("{path}: warning: {warning}");
}
Ok(s)
}
fn read_session(
path: &str,
check: solidcraft_engine::schema::ParamCheck,
validation: solidcraft_engine::kernel::Validation,
) -> Result<Session, String> {
let mut s = Session::default();
s.param_check = check;
s.set_validation(validation);
@@ -351,7 +364,7 @@ fn run_script_warning(s: &mut Session, path: &str, script: &Value) -> Result<Vec
/// Step `step`'s `param_warnings` on stderr, one per line, named after the script at `path`.
fn print_param_warnings(path: &str, step: usize, r: &Value) {
for w in r.get("param_warnings").and_then(Value::as_array).into_iter().flatten() {
eprintln!("{path}: step {}: {}", step + 1, w.as_str().unwrap_or_default());
solidcraft_engine::note!("{path}: step {}: {}", step + 1, w.as_str().unwrap_or_default());
}
}
@@ -484,8 +497,10 @@ fn cmd_run(args: &[String]) -> Result<(), String> {
// A feature that fails leaves the bodies as they were before it (a parameter change can
// break a feature without failing the command that made it): writing them would hand out
// the wrong part with success, so fail the run unless asked to write it anyway.
// A feature only a newer SolidCraft can build isn't a broken design: saving the design keeps it
// and hands out no part. Exporting bodies without it would, so that still stops.
if !args.iter().any(|a| a == "--allow-errors") {
timeline_ok(&s)?;
timeline_ok(&s, flags(args, "--out").is_empty())?;
}
for out in flags(args, "--out") {
s.execute("file.export", &json!({"path": out})).map_err(|e| e.to_string())?;
@@ -561,8 +576,12 @@ fn known_args(cmd: &str, args: &[String], valued: &[&str], switches: &[&str], po
}
/// Every failing timeline feature, one per line, as an error; Ok when none fails.
fn timeline_ok(s: &Session) -> Result<(), String> {
let failed: Vec<String> = s.model.results.iter().filter_map(|r| r.error.as_ref().map(|e| format!(" {}: {e}", r.name))).collect();
fn timeline_ok(s: &Session, skip_newer_features: bool) -> Result<(), String> {
let newer = |id: u64| {
skip_newer_features && s.doc.feature(id).is_some_and(|f| matches!(f.kind, solidcraft_engine::doc::FeatureKind::NewerFileFeature { .. }))
};
let failed: Vec<String> =
s.model.results.iter().filter(|r| !newer(r.id)).filter_map(|r| r.error.as_ref().map(|e| format!(" {}: {e}", r.name))).collect();
if failed.is_empty() {
return Ok(());
}
@@ -573,13 +592,15 @@ fn timeline_ok(s: &Session) -> Result<(), String> {
))
}
/// Runs `solidcraft-cli eval` (and its `inspect` alias), opening the input design or script with the requested `--validation` mode and reporting both `inspect.measure` and `document.inspect`.
fn cmd_eval(args: &[String]) -> Result<(), String> {
let json_out = json_out(args)?;
known_args("eval", args, &["--in", "--json-out", "--profile-out"], &["--profile"], true)?;
let mut s = open(flag(args, "--in"), positional(args), solidcraft_engine::schema::ParamCheck::Off)?;
known_args("eval", args, &["--in", "--json-out", "--profile-out", "--validation"], &["--profile"], true)?;
let validation = validation_flag("eval", args)?;
let mut s = open_validating(flag(args, "--in"), positional(args), solidcraft_engine::schema::ParamCheck::Off, validation)?;
let m = s.execute("inspect.measure", &json!({})).map_err(|e| e.to_string())?;
let d = s.execute("document.inspect", &json!({})).map_err(|e| e.to_string())?;
let out = json!({
let mut out = json!({
"body_count": m["body_count"],
"bodies": m["bodies"],
"total": m["total"],
@@ -587,6 +608,10 @@ fn cmd_eval(args: &[String]) -> Result<(), String> {
"params": d["params"],
"sketches": d["sketches"],
});
if let Some(file) = &s.doc.newer_file {
out["newer_file"] = file.report();
out["warnings"] = json!(file.warnings());
}
print_json(&out, json_out)
}
@@ -598,10 +623,10 @@ fn cmd_script(args: &[String]) -> Result<(), String> {
let out = flag(args, "--out");
let r = s.execute("timeline.export_script", &json!({"path": out})).map_err(|e| e.to_string())?;
for what in r["omitted"].as_array().into_iter().flatten().filter_map(Value::as_str) {
eprintln!("note: the script leaves out {what}");
solidcraft_engine::note!("note: the script leaves out {what}");
}
match out {
Some(out) => eprintln!("{out}: {} steps", r["steps"]),
Some(out) => solidcraft_engine::note!("{out}: {} steps", r["steps"]),
None => println!("{}", pretty(&r["script"])),
}
Ok(())
@@ -632,7 +657,7 @@ fn cmd_gpu(args: &[String]) -> Result<(), String> {
);
match s.lands_on {
Some(solidcraft_gpu::select::Landing::Cpu) => {
eprintln!("warning: {}", solidcraft_engine::capture::CPU_WARNING);
solidcraft_engine::note!("warning: {}", solidcraft_engine::capture::CPU_WARNING);
Ok(())
}
Some(_) => Ok(()),
@@ -709,7 +734,7 @@ fn cmd_snapshot(args: &[String]) -> Result<(), String> {
std::fs::write(out, &png.bytes).map_err(|e| format!("{out}: {e}"))?;
let mut reply = json!({"path": out, "width": w, "height": h, "bytes": png.bytes.len(), "renderer": png.renderer});
if let Some(w) = solidcraft_engine::capture::warning(png.renderer) {
eprintln!("warning: {w}");
solidcraft_engine::note!("warning: {w}");
reply["warning"] = json!(w);
}
println!("{}", pretty(&reply));
@@ -792,11 +817,15 @@ fn cmd_mcp(args: &[String]) -> Result<(), String> {
return Err("mcp: --render-gpu installs a headless image host; a connected application owns its own device".into());
}
install_renderer();
let options = solidcraft_mcp::Options::from_args_and_env(args).map_err(|e| format!("mcp: {e}"))?;
if options.compact {
eprintln!("solidcraft mcp: compact output (inspect_design summarises sketches; tool JSON is not pretty-printed)");
}
let scope = (!args.iter().any(|a| a == "--allow-any-path"))
.then(|| solidcraft_mcp::agent_scope(flags(args, "--allow-path").into_iter().map(std::path::PathBuf::from)));
if let Some(s) = &scope {
let roots: Vec<String> = s.roots().iter().map(|r| r.display().to_string()).collect();
eprintln!(
solidcraft_engine::note!(
"solidcraft mcp: files limited to {} and the folders of designs opened or saved here (--allow-path DIR adds one, --allow-any-path lifts the limit)",
roots.join(", ")
);
@@ -804,7 +833,7 @@ fn cmd_mcp(args: &[String]) -> Result<(), String> {
.and_then(|p| std::path::Path::new(p).parent())
.map(|d| if d.as_os_str().is_empty() { std::path::Path::new(".") } else { d });
if let Some(why) = design_dir.and_then(solidcraft_engine::io::vfs::design_folder_refusal) {
eprintln!("solidcraft mcp: the design's folder is left out because {why}; add it with --allow-path if you mean it");
solidcraft_engine::note!("solidcraft mcp: the design's folder is left out because {why}; add it with --allow-path if you mean it");
}
}
// How calls that break a command's parameter contract are treated, headless or bridged.
@@ -818,7 +847,7 @@ fn cmd_mcp(args: &[String]) -> Result<(), String> {
// Serve without the app too: the tools connect once it runs, and say it isn't until then.
let mut remote = solidcraft_mcp::Remote::new(addr, flag(args, "--token")).with_param_check(param_check);
if let Err(e) = remote.try_connect() {
eprintln!(
solidcraft_engine::note!(
"solidcraft mcp: no SolidCraft app at {addr} yet ({e}); start it with `solidcraft --control {port}` \
(or SOLIDCRAFT_CONTROL_PORT={port}), tool calls connect once it runs"
);
@@ -835,9 +864,9 @@ fn cmd_mcp(args: &[String]) -> Result<(), String> {
(None, None) => {
let auto = !args.iter().any(|a| a == "--headless");
if auto && let Some(port) = discover_app() {
eprintln!("solidcraft mcp: bridged to the running app at 127.0.0.1:{port} (--headless starts a session of its own)");
solidcraft_engine::note!("solidcraft mcp: bridged to the running app at 127.0.0.1:{port} (--headless starts a session of its own)");
let remote = solidcraft_mcp::Remote::new(&format!("127.0.0.1:{port}"), flag(args, "--token")).with_param_check(param_check);
return serve_mcp(Box::new(remote), scope);
return serve_mcp(Box::new(remote), scope, options);
}
let mut headless = solidcraft_mcp::Headless::default();
headless.session.param_check = param_check;
@@ -852,13 +881,17 @@ fn cmd_mcp(args: &[String]) -> Result<(), String> {
}
}
};
serve_mcp(backend, scope)
serve_mcp(backend, scope, options)
}
fn serve_mcp(backend: Box<dyn solidcraft_mcp::Backend>, scope: Option<solidcraft_engine::io::vfs::PathScope>) -> Result<(), String> {
fn serve_mcp(
backend: Box<dyn solidcraft_mcp::Backend>,
scope: Option<solidcraft_engine::io::vfs::PathScope>,
options: solidcraft_mcp::Options,
) -> Result<(), String> {
let stdin = std::io::stdin();
let stdout = std::io::stdout();
solidcraft_mcp::Server::new(backend).with_path_scope(scope).serve(stdin.lock(), stdout.lock()).map_err(|e| e.to_string())
solidcraft_mcp::Server::new(backend).with_path_scope(scope).with_options(options).serve(stdin.lock(), stdout.lock()).map_err(|e| e.to_string())
}
/// The port of the running desktop app: the one its discovery file names, if something listens
+23
View File
@@ -188,3 +188,26 @@ fn max_file_size_raises_the_limit_and_refuses_bad_sizes() {
assert!(!output.status.success() && !stderr.contains("too large"), "{stderr}");
let _ = std::fs::remove_dir_all(&dir);
}
/// #10111: a line for a stderr whose reader has gone (the pipe closed before it, as an agent may
/// leave the MCP server's, and the app that one starts) is dropped, and the command ends as it
/// would with stderr open: here a script that isn't there fails with 1, where `eprintln!`
/// panicked with "failed printing to stderr" (101).
#[test]
fn a_closed_stderr_does_not_stop_the_cli() {
for _ in 0..5 {
let mut child = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
.args(["run", "no-such-script.json"])
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.unwrap();
drop(child.stderr.take());
assert_eq!(child.wait().unwrap().code(), Some(1));
}
// The old name's alias writes its deprecation notice first: an agent still configured with
// `solvecraft-cli mcp` must get the server, not that panic.
let mut child = Command::new(env!("CARGO_BIN_EXE_solvecraft-cli")).arg("--help").stdout(Stdio::null()).stderr(Stdio::piped()).spawn().unwrap();
drop(child.stderr.take());
assert_eq!(child.wait().unwrap().code(), Some(0));
}
+48
View File
@@ -0,0 +1,48 @@
//! `mcp --compact` and `SOLIDCRAFT_MCP_COMPACT` turn on compact tool output; without them the
//! server's tools are as they always were.
use std::io::Write;
use std::process::{Command, Stdio};
use serde_json::{Value, json};
/// `inspect_design`'s input schema as `solidcraft-cli mcp` with `args` and `env` lists it, or
/// the server's stderr when it does not start.
fn inspect_schema(args: &[&str], env: Option<&str>) -> Result<Value, String> {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"));
// --headless: never bridge to (and read the preference of) an app the developer has running.
cmd.args(["mcp", "--headless", "--allow-any-path"]).args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped());
match env {
Some(v) => cmd.env("SOLIDCRAFT_MCP_COMPACT", v),
None => cmd.env_remove("SOLIDCRAFT_MCP_COMPACT"),
};
let mut child = cmd.spawn().unwrap();
{
let mut stdin = child.stdin.take().unwrap();
// The server may refuse to start and close stdin first.
let _ = writeln!(stdin, "{}", json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list"}));
}
let out = child.wait_with_output().unwrap();
if !out.status.success() {
return Err(String::from_utf8_lossy(&out.stderr).into_owned());
}
let reply: Value = serde_json::from_slice(&out.stdout).unwrap();
let tools = reply["result"]["tools"].as_array().cloned().unwrap_or_default();
Ok(tools.into_iter().find(|t| t["name"] == "inspect_design").map(|t| t["inputSchema"].clone()).unwrap_or(Value::Null))
}
#[test]
fn compact_output_is_off_unless_asked() {
let plain = inspect_schema(&[], None).unwrap();
assert_eq!(plain["properties"]["sketches"]["type"], "boolean", "{plain}");
assert!(plain["properties"].get("bodies").is_none(), "{plain}");
assert_eq!(inspect_schema(&[], Some("0")).unwrap(), plain);
for (args, env) in [(&["--compact"][..], None), (&[][..], Some("1")), (&[][..], Some("true"))] {
let compact = inspect_schema(args, env).unwrap();
assert!(compact["properties"]["bodies"].is_object(), "{args:?} {env:?}: {compact}");
}
let e = inspect_schema(&[], Some("maybe")).unwrap_err();
assert!(e.contains("SOLIDCRAFT_MCP_COMPACT"), "{e}");
}
+34 -13
View File
@@ -60,15 +60,25 @@ fn mcp_connect_serves_before_the_app_runs_and_reaches_it_once_it_does() {
let listener = TcpListener::bind(&addr).unwrap();
let app = std::thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
let mut line = String::new();
BufReader::new(stream.try_clone().unwrap()).read_line(&mut line).unwrap();
let req: Value = serde_json::from_str(&line).unwrap();
let reply = if req["token"] == json!(token) {
json!({"id": req["id"], "ok": true, "result": {"bodies": [], "method": req["method"]}})
} else {
json!({"id": req["id"], "ok": false, "error": format!("unauthorized: token {}", req["token"])})
};
writeln!(&stream, "{reply}").unwrap();
let mut lines = BufReader::new(stream.try_clone().unwrap());
loop {
let mut line = String::new();
lines.read_line(&mut line).unwrap();
let req: Value = serde_json::from_str(&line).unwrap();
// The server first asks for the agents' output options (the compact preference: off).
let options = req["method"] == "mcp.options";
let reply = if req["token"] != json!(token) {
json!({"id": req["id"], "ok": false, "error": format!("unauthorized: token {}", req["token"])})
} else if options {
json!({"id": req["id"], "ok": true, "result": {"compact": false}})
} else {
json!({"id": req["id"], "ok": true, "result": {"bodies": [], "method": req["method"]}})
};
writeln!(&stream, "{reply}").unwrap();
if !options {
break;
}
}
});
let r = ask(4, "tools/call", measure);
assert_ne!(r["result"]["isError"], true, "{r}");
@@ -111,10 +121,21 @@ fn mcp_without_arguments_finds_the_running_app() {
break (stream, line);
}
};
let req: Value = serde_json::from_str(&line).unwrap();
let ok = req["token"] == json!(token);
let reply = json!({"id": req["id"], "ok": ok, "result": {"bodies": [], "method": req["method"]}, "error": "unauthorized"});
writeln!(&stream, "{reply}").unwrap();
let mut lines = BufReader::new(stream.try_clone().unwrap());
let mut line = line;
// The server first asks for the agents' output options (the compact preference: off).
loop {
let req: Value = serde_json::from_str(&line).unwrap();
let ok = req["token"] == json!(token);
let options = req["method"] == "mcp.options";
let result = if options { json!({"compact": false}) } else { json!({"bodies": [], "method": req["method"]}) };
writeln!(&stream, "{}", json!({"id": req["id"], "ok": ok, "result": result, "error": "unauthorized"})).unwrap();
if !options {
break;
}
line.clear();
lines.read_line(&mut line).unwrap();
}
});
let mut child = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
.args(["mcp", "--allow-any-path"])
+51 -19
View File
@@ -159,7 +159,7 @@ fn cli_loads_both_design_encodings_by_contents() {
}
/// #1369: a pending concept that did not land in the numbered release must not be silently
/// dropped on open/resave, in either encoding or before replacing an existing destination.
/// dropped on open/resave, in either encoding: it opens with a warning, and saving keeps it.
#[test]
fn unknown_pending_concepts_preserve_the_open_design_and_output_files() {
use solidcraft_engine::{Session, doc, io};
@@ -173,34 +173,29 @@ fn unknown_pending_concepts_preserve_the_open_design_and_output_files() {
let source = dir.0.join(format!("experimental-{version}.{extension}"));
std::fs::write(&source, &bytes).unwrap();
let mut session = Session::default();
session.execute("solid.box", &json!({"length": 2, "width": 3, "height": 4})).unwrap();
let original = session.doc.clone();
let original_path = session.path.clone();
let undo = session.undo.len();
let dirty = session.is_dirty();
let err = session.execute("doc.open", &json!({"path": source})).unwrap_err().to_string();
assert!(err.contains("experimental-card") && err.contains("update SolidCraft"), "{err}");
assert_eq!(session.doc, original);
assert_eq!(session.path, original_path);
assert_eq!(session.undo.len(), undo);
assert_eq!(session.is_dirty(), dirty);
// It opens, and the reply says what it is.
let reply = session.execute("doc.open", &json!({"path": source})).unwrap();
let notice = reply["newer_file"]["notice"].as_str().unwrap().to_string();
assert!(notice.contains("experimental-card") && notice.starts_with("Saved by a newer SolidCraft"), "{notice}");
assert_eq!(reply["warnings"][0], notice.as_str());
assert_eq!(session.doc.name, "Experimental");
for existing in [false, true] {
let destination = dir.0.join(format!("output-{version}-{extension}-{existing}.{extension}"));
let backup = io::backup_path(&destination);
if existing {
std::fs::write(&destination, b"existing design").unwrap();
std::fs::write(&backup, b"existing backup").unwrap();
}
let output = cli().args(["run", "--in"]).arg(&source).arg("--save").arg(&destination).output().unwrap();
assert_eq!(output.status.code(), Some(1));
let error = String::from_utf8_lossy(&output.stderr);
assert!(error.contains("experimental-card") && error.contains("update SolidCraft"), "{error}");
assert_eq!(output.status.code(), Some(0), "{error}");
assert!(error.contains("warning: Saved by a newer SolidCraft") && error.contains("experimental-card"), "{error}");
// Saved, it still says it has the change, and opens the same way.
let saved = io::read_design(&std::fs::read(&destination).unwrap()).unwrap();
assert!(saved.format_pending.iter().any(|name| name == "experimental-card"));
assert!(saved.newer_file.is_some());
if existing {
assert_eq!(std::fs::read(&destination).unwrap(), b"existing design");
assert_eq!(std::fs::read(&backup).unwrap(), b"existing backup");
} else {
assert!(!destination.exists() && !backup.exists());
assert_eq!(std::fs::read(&backup).unwrap(), b"existing design");
}
assert_eq!(std::fs::read(&source).unwrap(), bytes);
}
@@ -208,6 +203,43 @@ fn unknown_pending_concepts_preserve_the_open_design_and_output_files() {
}
}
/// `eval` and `run` on a design one format newer, with a feature kind this version doesn't have:
/// the rest builds, the reply names it, and the saved file still holds it, under the file's number.
#[test]
fn the_cli_opens_a_newer_design_and_saving_keeps_what_it_cant_read() {
use solidcraft_engine::{Session, doc, io};
let dir = Scratch::new();
let mut session = Session::default();
session.execute("solid.box", &json!({"length": 2, "width": 3, "height": 4})).unwrap();
let mut file: Value = serde_json::from_slice(&io::write_design(&session.doc)).unwrap();
let newer = doc::format::format_string(doc::format::FORMAT + 1);
file["format"] = json!(newer);
let future = json!({"id": 900, "name": "Lattice1", "type": "gyroid_lattice", "cell": 2.5});
file["features"].as_array_mut().unwrap().push(future.clone());
let source = dir.0.join("newer.json");
std::fs::write(&source, file.to_string()).unwrap();
let output = cli().args(["eval"]).arg(&source).output().unwrap();
let error = String::from_utf8_lossy(&output.stderr);
assert_eq!(output.status.code(), Some(0), "{error}");
assert!(error.contains("warning: Saved by a newer SolidCraft") && error.contains("Lattice1"), "{error}");
let reply: Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(reply["body_count"], 1);
assert!(reply["newer_file"]["not_understood"][0].as_str().unwrap().contains("gyroid_lattice"), "{reply}");
assert!(reply["warnings"][0].as_str().unwrap().starts_with("Saved by a newer SolidCraft"), "{reply}");
for extension in ["json", "sc"] {
let destination = dir.0.join(format!("saved.{extension}"));
let output = cli().args(["run", "--in"]).arg(&source).arg("--save").arg(&destination).output().unwrap();
assert_eq!(output.status.code(), Some(0), "{}", String::from_utf8_lossy(&output.stderr));
let saved = io::read_design(&std::fs::read(&destination).unwrap()).unwrap();
assert_eq!(saved.format, newer, "never a lower number");
let again: Value = serde_json::from_slice(&io::write_design(&saved)).unwrap();
assert_eq!(again["features"].as_array().unwrap().last().unwrap(), &future);
}
}
#[test]
fn known_pending_concepts_and_released_files_round_trip_with_inert_metadata() {
use solidcraft_engine::{doc, io};
+1 -1
View File
@@ -25,6 +25,7 @@ solidcraft-engine = { workspace = true }
solidcraft-ui-egui = { workspace = true }
egui = { workspace = true }
log = { workspace = true }
serde_json = { workspace = true }
url = "2"
percent-encoding = "2"
@@ -33,7 +34,6 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
serde_json = { workspace = true }
web-sys = { version = "0.3", features = [
"Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "Navigator", "StorageManager",
"FileSystemHandle", "FileSystemDirectoryHandle", "FileSystemFileHandle", "FileSystemGetFileOptions", "FileSystemGetDirectoryOptions", "FileSystemWritableFileStream",
+4 -1
View File
@@ -553,7 +553,7 @@ mod web {
/// Open a design remembered from an earlier session (a recent one, after a reload): ask once
/// for access to the file (call in the click), read it into the engine's files and queue it
/// to open. A file that is gone is forgotten and dropped from the recent designs.
pub fn restore(files: &Rc<Files>, path: &str) {
pub fn restore(files: &Rc<Files>, path: &str, on_opened: Option<Box<dyn FnOnce()>>) {
let Some((id, name)) = parse(path) else { return };
let (files, path, id, name) = (files.clone(), path.to_string(), id.to_string(), name.to_string());
spawn_local(async move {
@@ -568,6 +568,9 @@ mod web {
files.opened.borrow_mut().push(path);
files.wake();
touch(&id).await;
if let Some(done) = on_opened {
done();
}
}
Err(e) => {
let gone = e.contains("NotFoundError") || e.contains("could not be found") || e.contains("no longer remembers");
+175 -5
View File
@@ -5,7 +5,8 @@
//! and go out as downloads (exports, and "Download design"). Where the File System Access API
//! exists a design also lives on the computer's disk, saved and opened through the browser's file
//! dialogs (`disk.rs`). An autosave of unsaved work is kept in OPFS and offered again after a
//! reload or a crash.
//! reload or a crash. With "keep unsaved work in this browser" on, each open design's unsaved
//! changes are kept there instead, one copy per design (`keep.rs`, #10220).
use std::cell::{Cell, RefCell};
use std::rc::Rc;
@@ -13,7 +14,7 @@ use std::rc::Rc;
use js_sys::{Array, Function, Object, Reflect, Uint8Array};
use solidcraft_engine::io::vfs;
use crate::disk;
use crate::{disk, keep};
use wasm_bindgen::{JsCast as _, JsValue, closure::Closure};
use wasm_bindgen_futures::{JsFuture, spawn_local};
use web_sys::{
@@ -79,6 +80,16 @@ pub struct Files {
pub recovery: RefCell<Option<(String, Option<String>)>>,
/// The app's context, to wake it when a browser call finishes.
pub ctx: RefCell<Option<egui::Context>>,
/// This page's id: the copies it keeps are named after it (`keep::entry_id`).
pub keep_page: RefCell<String>,
/// Kept copies stored by this page, and the design revision each holds once its write ended.
pub kept: RefCell<std::collections::HashMap<String, Option<u64>>>,
/// Kept copies being written or removed now (one browser call at a time per copy).
pub keeping: RefCell<std::collections::HashSet<String>>,
/// When (ms) the last write of a copy failed, to wait before trying again (`keep::may_write`).
pub kept_failed: RefCell<std::collections::HashMap<String, f64>>,
/// Copies earlier pages left, newer than their design's last save: offered at start.
pub keep_offered: RefCell<Vec<keep::Entry>>,
}
impl Files {
@@ -288,6 +299,9 @@ pub fn start(files: &Rc<Files>) {
let _ = JsFuture::from(p).await;
});
}
let page = keep::page_id(js_sys::Date::now(), js_sys::Math::random());
hold_page_lock(&page);
*files.keep_page.borrow_mut() = page;
let files = files.clone();
spawn_local(async move {
match disk::known_ids().await {
@@ -300,6 +314,7 @@ pub fn start(files: &Rc<Files>) {
files.say("This page can't use the browser's file storage (it needs https or localhost): designs are kept in local storage, a few MB at most.", false);
}
let mut entries = Vec::new();
let kept = kept_copies(&all);
let has_recovery = all.iter().any(|(n, _, _)| n == RECOVERY);
let legacy = (!has_recovery).then(|| LEGACY_RECOVERY.into_iter().find(|l| all.iter().any(|(n, _, _)| n == l))).flatten();
for (name, bytes, modified) in all {
@@ -315,7 +330,7 @@ pub fn start(files: &Rc<Files>) {
*files.recovery.borrow_mut() = Some((n, meta["path"].as_str().map(str::to_string)));
continue;
}
if name != RECOVERY {
if name != RECOVERY && keep::parse_name(&name).is_none() {
entries.push(Entry { name: name.clone(), size: bytes.len() as u64, modified });
}
vfs::insert(&format!("{OPFS}{name}"), bytes);
@@ -326,6 +341,7 @@ pub fn start(files: &Rc<Files>) {
if !vfs::exists(&format!("{OPFS}{RECOVERY}")) {
*files.recovery.borrow_mut() = None;
}
sort_kept(&files, kept, &live_pages().await);
}
Err(e) => files.say(format!("browser storage: {e}"), true),
}
@@ -343,7 +359,7 @@ pub fn flush(files: &Rc<Files>) {
{
let mut e = files.entries.borrow_mut();
e.retain(|x| x.name != name);
if name != RECOVERY && name != RECOVERY_META {
if name != RECOVERY && name != RECOVERY_META && keep::parse_name(&name).is_none() {
e.push(Entry { name: name.clone(), size: bytes.len() as u64, modified: js_sys::Date::now() });
e.sort_by_key(|a| a.name.to_lowercase());
}
@@ -473,7 +489,15 @@ pub fn recovery_path() -> String {
/// A recovery file lives in OPFS, but its relative links belong to the original design's
/// folder (which can be on disk). Resolve them before Open sees the temporary file location.
pub fn prepare_recovery(original_path: Option<&str>) -> Result<String, String> {
let path = recovery_path();
prepare_copy(recovery_path(), original_path)
}
/// The same for a kept copy (#10220).
pub fn prepare_kept(id: &str, original_path: Option<&str>) -> Result<String, String> {
prepare_copy(format!("{OPFS}{}", keep::design_file(id)), original_path)
}
fn prepare_copy(path: String, original_path: Option<&str>) -> Result<String, String> {
let bytes = vfs::read(&path, solidcraft_engine::io::MAX_DESIGN_BYTES).map_err(|e| e.to_string())?;
let mut doc = solidcraft_engine::io::read_design(&bytes).map_err(|e| e.to_string())?;
solidcraft_engine::resolve_design_links(&mut doc, original_path);
@@ -646,3 +670,149 @@ pub fn take_drops(files: &Rc<Files>, raw: &mut egui::RawInput, theme: bool) {
});
}
}
/// Hold this page's browser lock while it runs, so other tabs know its kept copies are in use
/// (`keep.rs`). Browsers without the Web Locks API hold none; their copies are then offered by
/// any later page.
fn hold_page_lock(page: &str) {
let Some(locks) = web_locks() else { return };
let Ok(request) = Reflect::get(&locks, &"request".into()).and_then(|f| f.dyn_into::<Function>()) else { return };
// A promise that never settles: the lock is let go only when the page goes.
let hold = Closure::<dyn FnMut(JsValue) -> js_sys::Promise>::new(|_lock: JsValue| js_sys::Promise::new(&mut |_, _| {}));
let name = format!("{}{page}", keep::LOCK_PREFIX);
if let Err(e) = request.call2(&locks, &name.into(), hold.as_ref().unchecked_ref()) {
log::info!("keep: no page lock: {}", err(e));
}
// The callback lives as long as the page.
hold.forget();
}
fn web_locks() -> Option<JsValue> {
let nav = web_sys::window()?.navigator();
Reflect::get(&nav, &"locks".into()).ok().filter(|l| l.is_object())
}
/// The pages still open (in this browser profile) that hold their lock.
async fn live_pages() -> Vec<String> {
let query = async {
let locks = web_locks().ok_or("no Web Locks")?;
let f: Function = Reflect::get(&locks, &"query".into()).map_err(err)?.dyn_into().map_err(|_| "no query".to_string())?;
let p: js_sys::Promise = f.call0(&locks).map_err(err)?.dyn_into().map_err(|_| "no promise".to_string())?;
let state = JsFuture::from(p).await.map_err(err)?;
let held: Array = Reflect::get(&state, &"held".into()).map_err(err)?.dyn_into().map_err(|_| "no list".to_string())?;
Ok::<_, String>(
held.iter()
.take(10_000)
.filter_map(|l| Reflect::get(&l, &"name".into()).ok()?.as_string())
.filter_map(|n| n.strip_prefix(keep::LOCK_PREFIX).map(str::to_string))
.collect(),
)
};
query.await.unwrap_or_else(|e| {
log::info!("keep: open pages unknown ({e})");
Vec::new()
})
}
/// The kept copies among the stored files: (copy, when its design's file was last written), and
/// the ids of designs stored without their metadata.
struct Found {
copies: Vec<(keep::Entry, Option<f64>)>,
orphans: Vec<String>,
}
fn kept_copies(all: &[(String, Vec<u8>, f64)]) -> Found {
let modified = |path: &str| path.strip_prefix(OPFS).and_then(|n| all.iter().find(|(name, _, _)| name == n)).map(|(_, _, m)| *m);
let mut copies = Vec::new();
let mut designs = Vec::new();
for (name, bytes, _) in all {
match keep::parse_name(name) {
Some((id, keep::Part::Meta)) => {
if let Some(e) = keep::Entry::parse(id, bytes) {
let saved = e.path.as_deref().and_then(modified);
copies.push((e, saved));
}
}
Some((id, keep::Part::Design)) => designs.push(id.to_string()),
None => {}
}
}
// A copy is whole only with its design.
copies.retain(|(e, _)| designs.contains(&e.id));
let orphans = designs.into_iter().filter(|id| !copies.iter().any(|(e, _)| &e.id == id)).collect();
Found { copies, orphans }
}
/// Offer the copies earlier pages left that hold unsaved work, and remove those their design's
/// later save made stale (and parts without the rest), leaving open pages' copies alone.
fn sort_kept(files: &Rc<Files>, found: Found, live: &[String]) {
let mut offered = Vec::new();
let mut gone = Vec::new();
for (e, saved) in found.copies {
match keep::fate(&e, live, saved) {
keep::Fate::Offer => offered.push(e),
keep::Fate::Drop => gone.push(e.id),
keep::Fate::Leave => {}
}
}
gone.extend(found.orphans.into_iter().filter(|id| !keep::page_of(id).is_some_and(|p| live.iter().any(|l| l == p))));
keep::order_offered(&mut offered);
*files.keep_offered.borrow_mut() = offered;
for id in gone {
remove_kept(files, &id);
}
}
/// The path (in the engine's files) a kept copy opens from.
pub fn kept_path(id: &str) -> String {
format!("{OPFS}{}", keep::design_file(id))
}
/// Write the copy of a design with unsaved changes (`bytes`, described by `entry`) that holds
/// its revision `revision`. Its metadata goes last, so a copy is never offered half written.
pub fn write_kept(files: &Rc<Files>, entry: &keep::Entry, bytes: Vec<u8>, revision: u64) {
let id = entry.id.clone();
if !files.keeping.borrow_mut().insert(id.clone()) {
return;
}
files.kept.borrow_mut().entry(id.clone()).or_insert(None);
let meta = entry.to_json();
let files = files.clone();
spawn_local(async move {
let r = async {
write_file(&keep::design_file(&id), &bytes).await?;
write_file(&keep::meta_file(&id), meta.as_bytes()).await
};
match r.await {
Ok(()) => {
files.kept.borrow_mut().insert(id.clone(), Some(revision));
files.kept_failed.borrow_mut().remove(&id);
}
Err(e) => {
files.kept_failed.borrow_mut().insert(id.clone(), js_sys::Date::now());
files.say(format!("keeping unsaved work in this browser: {e}"), true)
}
}
files.keeping.borrow_mut().remove(&id);
files.wake();
});
}
/// Remove kept copy `id` (its design was saved, closed, discarded or restored).
pub fn remove_kept(files: &Rc<Files>, id: &str) {
let id = id.to_string();
if !files.keeping.borrow_mut().insert(id.clone()) {
return;
}
let _ = vfs::remove(&kept_path(&id));
files.kept_failed.borrow_mut().remove(&id);
let files = files.clone();
spawn_local(async move {
// The metadata first: what's left without it is never offered.
let _ = remove_file(&keep::meta_file(&id)).await;
let _ = remove_file(&keep::design_file(&id)).await;
files.kept.borrow_mut().remove(&id);
files.keeping.borrow_mut().remove(&id);
files.wake();
});
}
+2 -2
View File
@@ -23,9 +23,9 @@ fn prompt() {
let _ = js_sys::Reflect::set(&window, &OFFER.into(), &JsValue::NULL);
}
let shown = js_sys::Reflect::get(&offer, &"prompt".into())
.and_then(|f| f.dyn_into::<js_sys::Function>().map_err(JsValue::from))
.and_then(|f| f.dyn_into::<js_sys::Function>())
.and_then(|f| f.call0(&offer))
.and_then(|p| p.dyn_into::<js_sys::Promise>().map_err(JsValue::from));
.and_then(|p| p.dyn_into::<js_sys::Promise>());
match shown {
// Accepted or dismissed, the browser handles it; a refusal is only logged.
Ok(p) => wasm_bindgen_futures::spawn_local(async move {
+360
View File
@@ -0,0 +1,360 @@
//! Keep unsaved work in this browser (#10220): the decisions, in plain Rust (tested natively).
//!
//! With Preferences › General › "Keep unsaved work in this browser instead of asking when the tab
//! closes" on, each open design with unsaved changes has a copy in the browser's private storage:
//! `keep-<id>.sc` (the design) and `keep-<id>.json` (its name, its file and when it last
//! changed). The copy is written shortly after each change ([`due`]) and again when the page is
//! hidden, so it is already current when the tab closes; the page then doesn't ask through the
//! browser's "leave site?" box ([`asks_before_unload`]). A design that is saved, closed or
//! discarded has its copy removed. The next visit offers the copies newer than their design's
//! last save ([`fate`]) in SolidCraft's own window, answered with [`plan`].
//!
//! An id is `<page>-<n>`: the page that wrote it (each page load has its own) and the design's
//! number in that page. A page holds a browser lock named after it while it runs, so another tab
//! never offers, or removes, the copies of a page that is still open.
#![cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
use serde_json::{Value, json};
/// The start of every kept file's name.
pub const PREFIX: &str = "keep-";
/// The browser lock a page holds while it runs: this, then its page id.
pub const LOCK_PREFIX: &str = "solidcraft.keep.";
/// A copy is written once the design has been still this long (ms)…
pub const QUIET_MS: f64 = 800.0;
/// …or at the latest this long after the first change it doesn't hold yet (ms).
pub const MAX_WAIT_MS: f64 = 4000.0;
/// The most copies offered at once (the rest wait for the next visit).
pub const MAX_OFFERED: usize = 32;
const MAX_ID: usize = 64;
const MAX_NAME: usize = 256;
const MAX_PATH: usize = 4096;
/// Copies' metadata is small: a damaged or hostile one is not read.
pub const MAX_META_BYTES: usize = 64 * 1024;
/// Ids are short and safe in a file name: ASCII letters, digits and dashes, with a page part.
pub fn valid_id(id: &str) -> bool {
!id.is_empty() && id.len() <= MAX_ID && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') && page_of(id).is_some()
}
/// The page that wrote copy `id`.
pub fn page_of(id: &str) -> Option<&str> {
id.rsplit_once('-').map(|(page, _)| page).filter(|p| !p.is_empty())
}
/// This page's id, from the time it started (ms since 1970) and a random number in 0..1:
/// letters and digits only.
pub fn page_id(now_ms: f64, random: f64) -> String {
let now = if now_ms.is_finite() && now_ms > 0.0 { now_ms.min(1e15) as u64 } else { 0 };
let random = if random.is_finite() { (random.clamp(0.0, 1.0) * f64::from(0xff_ffff)) as u64 } else { 0 };
format!("{now:x}{random:06x}")
}
/// The copy of design `uid` (`Session::uid`) open in page `page`.
pub fn entry_id(page: &str, uid: u64) -> String {
format!("{page}-{uid}")
}
/// The stored file names of copy `id`: its design and its metadata.
pub fn design_file(id: &str) -> String {
format!("{PREFIX}{id}.{}", solidcraft_engine::io::DESIGN_EXT)
}
pub fn meta_file(id: &str) -> String {
format!("{PREFIX}{id}.json")
}
/// Which part of a copy a stored file is.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Part {
Design,
Meta,
}
/// A stored file's copy id and part; `None` for every other file.
pub fn parse_name(name: &str) -> Option<(&str, Part)> {
let rest = name.strip_prefix(PREFIX)?;
let (id, part) = if let Some(id) = rest.strip_suffix(".json") {
(id, Part::Meta)
} else {
(rest.strip_suffix(solidcraft_engine::io::DESIGN_EXT)?.strip_suffix('.')?, Part::Design)
};
valid_id(id).then_some((id, part))
}
/// A kept copy, as its metadata describes it.
#[derive(Clone, Debug, PartialEq)]
pub struct Entry {
pub id: String,
/// The design's name.
pub name: String,
/// Its file (`None`: never saved).
pub path: Option<String>,
/// When the design last changed (ms since 1970).
pub changed_at: f64,
}
impl Entry {
pub fn to_json(&self) -> String {
json!({"name": self.name, "path": self.path, "changed_at": self.changed_at}).to_string()
}
/// Read copy `id`'s metadata. Anything malformed, oversized or not finite is no copy.
pub fn parse(id: &str, bytes: &[u8]) -> Option<Entry> {
if !valid_id(id) || bytes.len() > MAX_META_BYTES {
return None;
}
let v: Value = serde_json::from_slice(bytes).ok()?;
let name = v.get("name")?.as_str()?.chars().take(MAX_NAME).collect::<String>();
let path = match v.get("path") {
None | Some(Value::Null) => None,
Some(p) => Some(p.as_str().filter(|p| !p.is_empty() && p.len() <= MAX_PATH)?.to_string()),
};
let changed_at = v.get("changed_at")?.as_f64().filter(|t| t.is_finite() && *t >= 0.0)?;
Some(Entry { id: id.to_string(), name, path, changed_at })
}
}
/// Whether a copy that last changed at `changed_at` holds work its design's file doesn't:
/// the file was last written at `saved_at` (ms since 1970; `None` or unknown: never saved, or
/// the time can't be read, so the copy is kept rather than lost).
pub fn newer_than_save(changed_at: f64, saved_at: Option<f64>) -> bool {
match saved_at {
Some(saved) if saved.is_finite() && saved > 0.0 => changed_at > saved,
_ => true,
}
}
/// What the next visit does with a copy it finds.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Fate {
/// Offer it: "Restore unsaved design…?".
Offer,
/// Its design was saved after it: remove it without asking.
Drop,
/// Its page is still open (another tab): leave it alone.
Leave,
}
/// The copy `entry`, whose design's file was last written at `saved_at`, while the pages
/// `live` are open.
pub fn fate(entry: &Entry, live: &[String], saved_at: Option<f64>) -> Fate {
if page_of(&entry.id).is_some_and(|p| live.iter().any(|l| l == p)) {
Fate::Leave
} else if newer_than_save(entry.changed_at, saved_at) {
Fate::Offer
} else {
Fate::Drop
}
}
/// Copies to offer first: the most recently changed, at most [`MAX_OFFERED`].
pub fn order_offered(entries: &mut Vec<Entry>) {
entries.sort_by(|a, b| b.changed_at.total_cmp(&a.changed_at).then_with(|| a.id.cmp(&b.id)));
entries.truncate(MAX_OFFERED);
}
/// Whether a design's copy is due to be written at `now`: the design last changed at
/// `last_change`, and the copy has lacked a change since `first_unwritten` (all ms).
pub fn due(now: f64, first_unwritten: f64, last_change: f64) -> bool {
now - last_change >= QUIET_MS || now - first_unwritten >= MAX_WAIT_MS
}
/// How long after a failed write of a design's copy it isn't tried again (ms).
pub const RETRY_MS: f64 = 30_000.0;
/// Whether a copy whose write last failed at `failed_at` (ms; `None`: it hasn't) may be written
/// at `now`: not before [`RETRY_MS`] has passed, so a full or denied store isn't hit every tick.
pub fn may_write(now: f64, failed_at: Option<f64>) -> bool {
failed_at.is_none_or(|t| !(now >= t) || now - t >= RETRY_MS)
}
/// Whether `wanted` is the design now open, given the open design's path.
pub fn is_open(open: Option<&str>, wanted: &str) -> bool {
open == Some(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).
pub fn asks_before_unload(persists: bool, keep: bool, also_ask: bool, any_unsaved: bool, all_kept: bool) -> bool {
persists && any_unsaved && (also_ask || !(keep && all_kept))
}
/// An answer in the "Restore unsaved design?" window.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Answer {
/// Open the copy, with its unsaved changes, as the design it was (saving goes to its file).
Restore,
/// Open the design's file as last saved; the copy goes.
OpenSaved,
/// The copy goes.
Discard,
}
/// What an answer does.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Plan {
/// Open the copy (and, once it is open, give it back its name and file).
pub open_copy: bool,
/// Open this saved file.
pub open_saved: Option<String>,
/// Remove the copy.
pub drop_copy: bool,
}
/// What `answer` does to copy `entry`; `saved_exists`: its file can still be opened. `None`: the
/// answer isn't offered for it (Open saved version of a design never saved, or gone).
pub fn plan(answer: Answer, entry: &Entry, saved_exists: bool) -> Option<Plan> {
match answer {
Answer::Restore => Some(Plan { open_copy: true, open_saved: None, drop_copy: true }),
Answer::OpenSaved => {
let path = entry.path.clone().filter(|_| saved_exists)?;
Some(Plan { open_copy: false, open_saved: Some(path), drop_copy: true })
}
Answer::Discard => Some(Plan { open_copy: false, open_saved: None, drop_copy: true }),
}
}
/// The answers the window offers for `entry`, in order.
pub fn answers(entry: &Entry, saved_exists: bool) -> Vec<Answer> {
[Answer::Restore, Answer::OpenSaved, Answer::Discard].into_iter().filter(|a| plan(*a, entry, saved_exists).is_some()).collect()
}
#[cfg(test)]
mod tests {
use super::*;
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 }
}
#[test]
fn a_failed_write_waits_before_the_next_try() {
assert!(may_write(1000.0, None));
assert!(!may_write(1000.0 + RETRY_MS - 1.0, Some(1000.0)));
assert!(may_write(1000.0 + RETRY_MS, Some(1000.0)));
// A clock that went back doesn't block for good.
assert!(may_write(10.0, Some(1000.0)));
}
#[test]
fn a_design_counts_as_opened_only_at_its_own_path() {
assert!(is_open(Some("a.sc"), "a.sc"));
assert!(!is_open(Some("b.sc"), "a.sc"));
assert!(!is_open(None, "a.sc"));
}
#[test]
fn copies_are_named_by_page_and_design_and_nothing_else_reads_as_one() {
let page = page_id(1_700_000_000_000.0, 0.5);
assert!(page.chars().all(|c| c.is_ascii_hexdigit()));
let id = entry_id(&page, 7);
assert!(valid_id(&id));
assert_eq!(page_of(&id), Some(page.as_str()));
assert_eq!(parse_name(&design_file(&id)), Some((id.as_str(), Part::Design)));
assert_eq!(parse_name(&meta_file(&id)), Some((id.as_str(), Part::Meta)));
for other in ["part.sc", "recovery.sc", "recovery.json", "keep-.sc", "keep-x.sc", "keep-a/b-1.sc", "keep-a-1.step", "keep-a-1sc"] {
assert_eq!(parse_name(other), None, "{other}");
}
// Hostile clocks still give a usable id.
for (now, random) in [(f64::NAN, f64::NAN), (f64::INFINITY, -1.0), (-5.0, 9.0)] {
assert!(valid_id(&entry_id(&page_id(now, random), 1)));
}
}
#[test]
fn metadata_round_trips_and_hostile_metadata_is_no_copy() {
let e = entry("a1-3", 1234.0, Some("/opfs/Bracket.sc"));
assert_eq!(Entry::parse("a1-3", e.to_json().as_bytes()), Some(e.clone()));
let never_saved = entry("a1-4", 99.0, None);
assert_eq!(Entry::parse("a1-4", never_saved.to_json().as_bytes()), Some(never_saved));
for bad in [
"",
"[]",
r#"{"name":"x","changed_at":"soon"}"#,
r#"{"name":"x","changed_at":-1}"#,
r#"{"name":"x","changed_at":1e999}"#,
r#"{"name":"x","path":7,"changed_at":1}"#,
r#"{"name":"x","path":"","changed_at":1}"#,
r#"{"changed_at":1}"#,
] {
assert_eq!(Entry::parse("a1-3", bad.as_bytes()), None, "{bad}");
}
assert_eq!(Entry::parse("../x", e.to_json().as_bytes()), None);
assert_eq!(Entry::parse("a1-3", &vec![b' '; MAX_META_BYTES + 1]), None);
let long = json!({"name": "n".repeat(10_000), "changed_at": 1}).to_string();
assert_eq!(Entry::parse("a1-3", long.as_bytes()).map(|e| e.name.len()), Some(MAX_NAME));
}
#[test]
fn a_copy_newer_than_the_last_save_is_offered_an_older_one_dropped_an_open_pages_left() {
let e = entry("p1-1", 2000.0, Some("/opfs/Bracket.sc"));
// Changed after the file was last written: offered.
assert_eq!(fate(&e, &[], Some(1000.0)), Fate::Offer);
// Saved since (in this tab or another): nothing to offer.
assert_eq!(fate(&e, &[], Some(2000.0)), Fate::Drop);
assert_eq!(fate(&e, &[], Some(3000.0)), Fate::Drop);
// Never saved, or the save time can't be read: kept rather than lost.
for saved in [None, Some(0.0), Some(f64::NAN), Some(f64::INFINITY), Some(-4.0)] {
assert_eq!(fate(&e, &[], saved), Fate::Offer, "{saved:?}");
}
// Its page is still open in another tab: neither offered nor removed.
assert_eq!(fate(&e, &["p1".into()], Some(1000.0)), Fate::Leave);
assert_eq!(fate(&e, &["p1".into()], Some(3000.0)), Fate::Leave);
assert_eq!(fate(&e, &["p2".into()], Some(1000.0)), Fate::Offer);
}
#[test]
fn the_newest_copies_are_offered_first_and_at_most_a_windowful() {
let mut list: Vec<Entry> = (0..40).map(|i| entry(&format!("p-{i}"), f64::from(i), None)).collect();
order_offered(&mut list);
assert_eq!(list.len(), MAX_OFFERED);
assert_eq!(list.first().map(|e| e.id.as_str()), Some("p-39"));
}
#[test]
fn a_copy_is_written_once_the_design_is_still_or_after_the_longest_wait() {
// Still changing, not waited long: not yet.
assert!(!due(1000.0, 900.0, 900.0));
// Still for QUIET_MS: due.
assert!(due(900.0 + QUIET_MS, 900.0, 900.0));
// Changing all the time: due after MAX_WAIT_MS anyway.
assert!(!due(5000.0, 2000.0, 4900.0));
assert!(due(2000.0 + MAX_WAIT_MS, 2000.0, 2000.0 + MAX_WAIT_MS - 1.0));
}
#[test]
fn the_browser_asks_before_unload_unless_told_not_to_and_every_copy_is_current() {
// Keeping off: exactly as before, unsaved work asks, whatever the second option says.
assert!(asks_before_unload(true, false, true, true, true));
assert!(asks_before_unload(true, false, true, true, false));
assert!(asks_before_unload(true, false, false, true, true));
// Keeping on, asking on (the default): both, the box and the copy.
assert!(asks_before_unload(true, true, true, true, true));
// Keeping on, asking off, every unsaved design kept: no browser box.
assert!(!asks_before_unload(true, true, false, true, true));
// Keeping on, asking off, a copy still being written (or failed): the browser still asks.
assert!(asks_before_unload(true, true, false, true, false));
// Nothing unsaved, or an embedded page that keeps nothing: never.
assert!(!asks_before_unload(true, true, true, false, false));
assert!(!asks_before_unload(true, false, true, false, false));
assert!(!asks_before_unload(false, false, true, true, false));
}
#[test]
fn restore_open_saved_and_discard_each_do_one_thing_and_always_remove_the_copy() {
let saved = entry("p-1", 5.0, Some("/opfs/Bracket.sc"));
assert_eq!(plan(Answer::Restore, &saved, true), Some(Plan { open_copy: true, open_saved: None, drop_copy: true }));
assert_eq!(
plan(Answer::OpenSaved, &saved, true),
Some(Plan { open_copy: false, open_saved: Some("/opfs/Bracket.sc".into()), drop_copy: true })
);
assert_eq!(plan(Answer::Discard, &saved, true), Some(Plan { open_copy: false, open_saved: None, drop_copy: true }));
assert_eq!(answers(&saved, true), [Answer::Restore, Answer::OpenSaved, Answer::Discard]);
// A design never saved, or whose file is gone, has no saved version to open.
let never = entry("p-2", 5.0, None);
assert_eq!(plan(Answer::OpenSaved, &never, true), None);
assert_eq!(plan(Answer::OpenSaved, &saved, false), None);
assert_eq!(answers(&never, false), [Answer::Restore, Answer::Discard]);
}
}
+1 -1
View File
@@ -48,7 +48,7 @@ impl Download {
}
if solidcraft_ui_egui::documents::any_dirty(app) {
let mut answer = None;
let closed = ui_kit::Window::new(egui::Id::new("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_egui::i18n::tr("web.open_link"))
.required()
.show_body(ctx, |ui| {
ui.label(solidcraft_ui_egui::i18n::tr("web.open_link_confirm"));
+267 -10
View File
@@ -19,6 +19,8 @@ mod easyprint;
mod files;
#[cfg(target_arch = "wasm32")]
mod install;
#[cfg(any(target_arch = "wasm32", test))]
mod keep;
#[cfg(target_arch = "wasm32")]
mod linked;
#[cfg(any(target_arch = "wasm32", test))]
@@ -184,6 +186,7 @@ mod web {
use crate::api::Api;
use crate::disk::{self, SaveTarget};
use crate::files::{self, Files, Intent};
use crate::keep;
use crate::space_mouse::SpaceMouse;
const CANVAS_ID: &str = "solidcraft_canvas";
@@ -243,7 +246,9 @@ mod web {
}
/// The browser's "leave site? changes you made may not be saved" prompt while a design has
/// unsaved changes (the page can't show its own dialog when it is being closed).
/// unsaved changes (the page can't show its own dialog when it is being closed). With "keep
/// unsaved work in this browser" on and "still ask" off, there is nothing to ask once every
/// unsaved design's copy is current (`keep::asks_before_unload`, #10220).
fn ask_before_unload(window: &web_sys::Window) {
let on_unload = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::BeforeUnloadEvent)>::new(|e: web_sys::BeforeUnloadEvent| {
if UNSAVED.with(std::cell::Cell::get) {
@@ -256,6 +261,50 @@ mod web {
on_unload.forget();
}
/// With "keep unsaved work in this browser" on, the page brings every unsaved design's copy up
/// to date when it is hidden or goes (`pagehide`, `visibilitychange`): the browser may not
/// wait for that write, so the copies are written shortly after each change as well
/// (`Shell::keep_tick`, #10220).
fn keep_when_hidden(window: &web_sys::Window, runner: &eframe::WebRunner) {
let keep_now = {
let runner = runner.clone();
move || {
if let Some(mut shell) = runner.app_mut::<Shell>() {
shell.keep_tick(None, true);
}
}
};
let on_hide = {
let keep_now = keep_now.clone();
wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::Event)>::new(move |_: web_sys::Event| keep_now())
};
let on_visibility = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::Event)>::new(move |_: web_sys::Event| {
let hidden = web_sys::window()
.and_then(|w| w.document())
.and_then(|d| js_sys::Reflect::get(&d, &"visibilityState".into()).ok())
.and_then(|v| v.as_string())
.is_some_and(|v| v == "hidden");
if hidden {
keep_now();
}
});
let _ = window.add_event_listener_with_callback("pagehide", on_hide.as_ref().unchecked_ref());
if let Some(document) = window.document() {
let _ = document.add_event_listener_with_callback("visibilitychange", on_visibility.as_ref().unchecked_ref());
}
// The listeners live as long as the page.
on_hide.forget();
on_visibility.forget();
}
/// One open design followed for its kept copy: its revision, when that last changed, and since
/// when its copy has lacked a change (`None`: current, or being written).
struct Track {
revision: u64,
last_change: f64,
first_unwritten: Option<f64>,
}
/// eframe cancels the browser's action for every Tab it gets, so the browser couldn't switch
/// tabs while the app has the keyboard. Its tab-switching keys stop here, on the way down to
/// the canvas, before eframe sees them; the browser then acts on them as usual.
@@ -328,6 +377,10 @@ mod web {
/// The settings this tab wrote or loaded last, and those another tab wrote since (#8644).
prefs_writer: crate::PrefsWriter,
other_tab_prefs: PrefsInbox,
/// The open designs followed for their kept copy, by `Session::uid` (#10220).
keep_track: std::collections::HashMap<u64, Track>,
/// Every unsaved design's kept copy is current (and the preference is on).
all_kept: bool,
}
impl Shell {
@@ -442,8 +495,14 @@ mod web {
self.sync_recents();
}
let now = ctx.input(|i| i.time);
// With "keep unsaved work in this browser" on, each design's own copy takes over from
// the single autosave (#10220).
if self.app.preferences.keep_unsaved_in_browser && self.autosaved.is_some() {
files::autosave(&self.files, None);
self.autosaved = None;
}
// Unsaved work is kept (at most every AUTOSAVE_S); saved work needs no autosave.
if self.files.ready.get() && self.files.recovery.borrow().is_none() {
if self.files.ready.get() && self.files.recovery.borrow().is_none() && !self.app.preferences.keep_unsaved_in_browser {
let s = &self.app.session;
let changed = s.is_dirty() && self.autosaved != Some(s.revision);
let wait = AUTOSAVE_S - (now - self.autosaved_at);
@@ -462,6 +521,175 @@ mod web {
if !self.files.ready.get() || !self.files.incoming.borrow().is_empty() {
ctx.request_repaint_after(std::time::Duration::from_millis(100));
}
self.all_kept = self.keep_tick(Some(ctx), false);
}
/// Keep each open design's unsaved changes in the browser, when the preference asks for
/// it: a design's copy is written once it has been still a moment (`keep::due`), or at
/// once with `now` (the page is being hidden); a copy whose design is saved or closed, or
/// with the preference off, goes. Answers whether every unsaved design's copy is current.
fn keep_tick(&mut self, ctx: Option<&egui::Context>, now: bool) -> bool {
if !self.launch.persists() || !self.files.ready.get() {
return false;
}
let Shell { app, files, keep_track, .. } = self;
let on = app.preferences.keep_unsaved_in_browser;
let time = js_sys::Date::now();
let page = files.keep_page.borrow().clone();
let mut wanted = std::collections::HashSet::new();
let mut all_kept = true;
let mut wake: Option<f64> = None;
let mut error = None;
let mut soon = |ms: f64| wake = Some(wake.map_or(ms, |w: f64| w.min(ms)));
if on {
for s in solidcraft_ui_egui::documents::open_sessions(app).into_iter().filter(|s| s.is_dirty()) {
let id = keep::entry_id(&page, s.uid);
wanted.insert(s.uid);
let t = keep_track.entry(s.uid).or_insert(Track { revision: s.revision, last_change: time, first_unwritten: None });
if t.revision != s.revision {
t.revision = s.revision;
t.last_change = time;
}
if files.kept.borrow().get(&id).copied().flatten() == Some(s.revision) {
t.first_unwritten = None;
continue;
}
all_kept = false;
if files.keeping.borrow().contains(&id) {
soon(100.0);
continue;
}
let first = *t.first_unwritten.get_or_insert(time);
let failed = files.kept_failed.borrow().get(&id).copied();
if !keep::may_write(time, failed) {
// The last write failed: try again after a while.
soon(keep::RETRY_MS);
continue;
}
if now || keep::due(time, first, t.last_change) {
match solidcraft_engine::io::write_design_container(&s.design()) {
Ok(bytes) => {
let entry = keep::Entry { id, name: s.doc.name.clone(), path: s.path.clone(), changed_at: t.last_change };
files::write_kept(files, &entry, bytes, s.revision);
t.first_unwritten = None;
}
Err(e) => {
files.kept_failed.borrow_mut().insert(id, time);
error = Some(format!("keeping unsaved work in this browser: {e}"));
}
}
soon(100.0);
} else {
soon((keep::QUIET_MS - (time - t.last_change)).min(keep::MAX_WAIT_MS - (time - first)).max(16.0));
}
}
}
// This page's copies no design needs any more: saved, closed, or the preference off.
let stale: Vec<String> = files
.kept
.borrow()
.keys()
.filter(|id| keep::page_of(id) == Some(page.as_str()))
.filter(|id| !wanted.iter().any(|uid| keep::entry_id(&page, *uid) == **id))
.cloned()
.collect();
for id in stale {
files::remove_kept(files, &id);
soon(100.0);
}
keep_track.retain(|uid, _| wanted.contains(uid));
if let Some(e) = error {
app.set_status(e, true);
}
if let (Some(ctx), Some(ms)) = (ctx, wake) {
ctx.request_repaint_after(std::time::Duration::from_secs_f64(ms / 1000.0));
}
on && all_kept
}
/// Offer the copies an earlier visit kept (#10220): "Restore unsaved design: <name>,
/// changed <time>?", one window for all of them, each with Restore, Open saved version
/// (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() {
return;
}
let now = (js_sys::Date::now() / 1000.0) as u64;
let rows: Vec<_> = offered
.iter()
.map(|e| solidcraft_ui_egui::recovery_ui::BrowserCopy {
name: &e.name,
when: solidcraft_ui_egui::recovery_ui::ago_between(now, (e.changed_at / 1000.0) as u64),
open_saved: keep::answers(e, self.saved_exists(e)).contains(&keep::Answer::OpenSaved),
})
.collect();
let act = solidcraft_ui_egui::recovery_ui::browser_copies_window(ctx, &self.app.preferences.language, &rows);
let Some((index, answer)) = act else { return };
let Some(entry) = offered.get(index) else { return };
let answer = match answer {
solidcraft_ui_egui::recovery_ui::CopyAnswer::Restore => keep::Answer::Restore,
solidcraft_ui_egui::recovery_ui::CopyAnswer::OpenSaved => keep::Answer::OpenSaved,
solidcraft_ui_egui::recovery_ui::CopyAnswer::Discard => keep::Answer::Discard,
};
self.keep_answer(entry, answer);
}
/// Whether a kept copy's design file can still be opened: in the browser's storage, or a
/// file on the computer this browser remembers.
fn saved_exists(&self, e: &keep::Entry) -> bool {
e.path.as_deref().is_some_and(|p| vfs::exists(p) || disk::parse(p).is_some_and(|(id, _)| self.files.disk_known.borrow().contains(id)))
}
/// Act on an answer in the "Restore unsaved design?" window (`keep::plan`).
fn keep_answer(&mut self, entry: &keep::Entry, answer: keep::Answer) {
let Some(plan) = keep::plan(answer, entry, self.saved_exists(entry)) else { return };
if plan.open_copy {
let copy = match files::prepare_kept(&entry.id, entry.path.as_deref()) {
Ok(copy) => copy,
Err(error) => {
self.app.set_status(error, true);
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;
}
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;
}
} 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,
// the picker is dismissed or the file is gone, it stays offered.
let (files, id) = (self.files.clone(), entry.id.clone());
disk::restore(
&self.files,
path,
Some(Box::new(move || {
files.keep_offered.borrow_mut().retain(|e| e.id != id);
files::remove_kept(&files, &id);
})),
);
return;
}
}
if plan.drop_copy {
self.files.keep_offered.borrow_mut().retain(|e| e.id != entry.id);
files::remove_kept(&self.files, &entry.id);
}
}
/// The start page's recent designs are the stored ones: the remembered order first (those
@@ -498,7 +726,7 @@ mod web {
return;
}
let mut act: Option<(&'static str, String)> = None;
let closed = solidcraft_ui_egui::ui_kit::Window::new(egui::Id::new("sc_browser_files"), "folder", "Files in this browser")
let closed = solidcraft_ui_egui::ui_kit::Window::new(egui::Id::unique("sc_browser_files"), "folder", "Files in this browser")
// Room for long names; the kit still caps the window at the browser's width.
.cards(4.0)
.show_body(ctx, |ui| {
@@ -600,7 +828,9 @@ mod web {
self.files.panel.set(false);
}
"insert" => {
self.app.insert_path(&path);
if !solidcraft_ui_egui::mesh_units::ask(&mut self.app, &path, true) {
self.app.insert_path(&path);
}
self.files.panel.set(false);
}
"download" => match vfs::read(&path, solidcraft_engine::io::MAX_DESIGN_BYTES) {
@@ -645,10 +875,16 @@ mod web {
*r = new.clone();
}
}
if let Some(at) = self.app.home.opened_at.remove(&old) {
self.app.home.opened_at.insert(new.clone(), at);
}
// A forgotten design follows its new name.
if self.app.home.forgotten.remove(&old) {
self.app.home.forgotten.insert(new);
self.app.home.forgotten.insert(new.clone());
}
// An open tab of that file keeps its path and, when the name came
// from the file, the title that matches the file list (#11375).
solidcraft_ui_egui::documents::follow_file_rename(&mut self.app, &old, &new);
}
Err(e) => self.app.set_status(e, true),
}
@@ -662,7 +898,7 @@ mod web {
fn recovery_prompt(&mut self, ctx: &egui::Context) {
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::new("sc_browser_recovery"), "restore", "Recover unsaved work?")
let _ = solidcraft_ui_egui::ui_kit::Window::new(egui::Id::unique("sc_browser_recovery"), "restore", "Recover unsaved work?")
.required()
.show_body(ctx, |ui| {
ui.label(format!("“{name}” had unsaved changes when this page last closed."));
@@ -698,6 +934,13 @@ mod web {
files::autosave(&self.files, None);
}
/// Whether leaving the page asks through the browser's own box (`keep::asks_before_unload`).
fn asks_before_unload(&self) -> bool {
let any = solidcraft_ui_egui::documents::any_dirty(&self.app);
let prefs = &self.app.preferences;
keep::asks_before_unload(self.launch.persists(), prefs.keep_unsaved_in_browser, prefs.keep_unsaved_also_asks, any, self.all_kept)
}
/// 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)) {
@@ -809,7 +1052,7 @@ mod web {
self.api.poll();
self.space_mouse.logic(&mut self.app, ctx);
self.files_tick(ctx);
UNSAVED.with(|u| u.set(solidcraft_ui_egui::documents::any_dirty(&self.app)));
UNSAVED.with(|u| u.set(self.asks_before_unload()));
DESIGN_SHOWN.with(|d| d.set(solidcraft_ui_egui::toolbar::design_shown(&self.app)));
}
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
@@ -829,7 +1072,7 @@ mod web {
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.linked.tick(&mut self.app, ui.ctx());
UNSAVED.with(|u| u.set(self.launch.persists() && solidcraft_ui_egui::documents::any_dirty(&self.app)));
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()
@@ -844,10 +1087,15 @@ mod web {
if self.launch.persists() {
self.file_panel(&ctx);
self.recovery_prompt(&ctx);
self.keep_prompt(&ctx);
}
self.watch_text_for_font(&ctx);
self.store_prefs(&ctx);
}
#[cfg(target_arch = "wasm32")]
fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
Some(&mut *self)
}
fn persist_egui_memory(&self) -> bool {
// eframe keeps this under one fixed key: only stable uses it, so a pre-release never rewrites it.
self.launch.persists() && crate::channel::current().is_empty()
@@ -922,7 +1170,8 @@ mod web {
{
create.instance_descriptor.backends = eframe::wgpu::Backends::GL;
}
let result = eframe::WebRunner::new()
let runner = eframe::WebRunner::new();
let result = runner
.start(
canvas,
options,
@@ -966,7 +1215,7 @@ mod web {
can_restore: Some(Box::new(move |path: &str| {
disk::parse(path).is_some_and(|(id, _)| restorable.disk_known.borrow().contains(id))
})),
restore_file: Some(Box::new(move |path: &str| disk::restore(&restorer, path))),
restore_file: Some(Box::new(move |path: &str| disk::restore(&restorer, path, None))),
download_design: Some(Box::new(files::download)),
easyprint_upload: Some(std::sync::Arc::new(crate::easyprint::upload)),
browser_files: Some(Box::new(move || shown.panel.set(true))),
@@ -1033,10 +1282,18 @@ mod web {
wheel_gestures: solidcraft_ui_egui::WebWheelGestures::default(),
prefs_writer: crate::PrefsWriter::default(),
other_tab_prefs,
keep_track: Default::default(),
all_kept: false,
}))
}),
)
.await;
if result.is_ok()
&& crate::Launch::parse(&query()).persists()
&& let Some(window) = web_sys::window()
{
keep_when_hidden(&window, &runner);
}
if let Some(el) = document.get_element_by_id(LOADING_ID) {
match result {
Ok(()) => dismiss_loading(el),
+13 -9
View File
@@ -68,7 +68,9 @@ pub fn start(listen_on: control_auth::Listen, ctx: egui::Context) -> Receiver<Co
Ok(l) => {
if let Some(port) = listen_on_listener(l, token.clone(), tx, ctx, false) {
if matches!(listen_on, control_auth::Listen::Default) {
eprintln!("solidcraft: control channel on the default port 127.0.0.1:{port} (Preferences > Assistant turns it off)");
solidcraft_engine::note!(
"solidcraft: control channel on the default port 127.0.0.1:{port} (Preferences > Assistant turns it off)"
);
}
// The discovery file: where `solidcraft-cli mcp` finds this app with no arguments.
match control_auth::write_current_port(port) {
@@ -77,14 +79,14 @@ pub fn start(listen_on: control_auth::Listen, ctx: egui::Context) -> Receiver<Co
*p = Some((port, token));
}
}
Err(e) => eprintln!("solidcraft: could not write the control discovery file ({e})"),
Err(e) => solidcraft_engine::note!("solidcraft: could not write the control discovery file ({e})"),
}
}
}
Err(e) => eprintln!("solidcraft: control server failed to bind 127.0.0.1: {e}"),
Err(e) => solidcraft_engine::note!("solidcraft: control server failed to bind 127.0.0.1: {e}"),
}
}
None => eprintln!("solidcraft: control server not started: no random token available"),
None => solidcraft_engine::note!("solidcraft: control server not started: no random token available"),
}
rx
}
@@ -95,13 +97,13 @@ pub fn start(listen_on: control_auth::Listen, ctx: egui::Context) -> Receiver<Co
/// requests go to `tx`, where the chat's gate reads them; one may wait long for the user's Allow.
pub fn listen_agent(tx: Sender<ControlRequest>, ctx: egui::Context) -> Option<u16> {
let Some(token) = new_token() else {
eprintln!("solidcraft: agent chat endpoint not started: no random token available");
solidcraft_engine::note!("solidcraft: agent chat endpoint not started: no random token available");
return None;
};
let listener = match TcpListener::bind(("127.0.0.1", 0)) {
Ok(l) => l,
Err(e) => {
eprintln!("solidcraft: control server failed to bind 127.0.0.1: {e}");
solidcraft_engine::note!("solidcraft: control server failed to bind 127.0.0.1: {e}");
return None;
}
};
@@ -115,19 +117,21 @@ fn listen_on_listener(listener: TcpListener, token: String, tx: Sender<ControlRe
match control_auth::token_path(port) {
Some(path) => match control_auth::write_token_file(&path, &token) {
Ok(()) => {
eprintln!("solidcraft: control server listening on 127.0.0.1:{port} (token in {})", path.display());
solidcraft_engine::note!("solidcraft: control server listening on 127.0.0.1:{port} (token in {})", path.display());
if let Ok(mut files) = TOKEN_FILES.lock() {
files.push((path, token.clone()));
}
}
Err(e) => eprintln!(
Err(e) => solidcraft_engine::note!(
"solidcraft: control server listening on 127.0.0.1:{port}, but its token file {} could not be written ({e}); \
clients need SOLIDCRAFT_CONTROL_TOKEN",
path.display()
),
},
None => {
eprintln!("solidcraft: control server listening on 127.0.0.1:{port}; no data folder for its token, clients need SOLIDCRAFT_CONTROL_TOKEN")
solidcraft_engine::note!(
"solidcraft: control server listening on 127.0.0.1:{port}; no data folder for its token, clients need SOLIDCRAFT_CONTROL_TOKEN"
)
}
}
let token = std::sync::Arc::new(token);
+1 -1
View File
@@ -112,7 +112,7 @@ pub fn restart(software: bool, lost_at: u64, reason: &str) {
pub fn end() -> ! {
let watchdog = std::thread::Builder::new().name("solidcraft-exit".into()).spawn(|| {
std::thread::sleep(END_GRACE);
eprintln!("SolidCraft graphics: still closing after the device was lost; aborting");
solidcraft_engine::note!("SolidCraft graphics: still closing after the device was lost; aborting");
std::process::abort();
});
if let Err(e) = watchdog {
+16 -13
View File
@@ -79,7 +79,7 @@ fn mark(step: &str) {
static START: std::sync::OnceLock<(std::time::Instant, bool)> = std::sync::OnceLock::new();
let (start, on) = START.get_or_init(|| (std::time::Instant::now(), solidcraft_engine::geom::env::var_os("PROFILE_STARTUP").is_some()));
if *on {
eprintln!("startup {:>6.0} ms {step}", start.elapsed().as_secs_f64() * 1000.0);
solidcraft_engine::note!("startup {:>6.0} ms {step}", start.elapsed().as_secs_f64() * 1000.0);
}
}
@@ -303,6 +303,8 @@ impl eframe::App for App {
log::warn!("a picture was still being drawn as the app closed");
}
solidcraft_ui_egui::recovery_ui::close(&mut self.solve);
// A GPU path tracer setup still running did not stop the app (#9236).
solidcraft_ui_egui::render_workspace::close(&mut self.solve);
solidcraft_ui_egui::open_elsewhere::close(&mut self.solve);
// Before a restart (`Drop for App`) starts the next instance, which would otherwise get
// nothing from the 3D mouse driver (`solidcraft_3dx_client::stop`).
@@ -366,7 +368,7 @@ impl App {
let Some(gpu) = self.gpu.as_mut().filter(|g| g.lost_at.is_none() && g.lost.is_lost()) else { return };
let reason = gpu.lost.reason().unwrap_or_default();
log::error!("graphics: the device was lost ({reason}); closing to start again");
eprintln!("SolidCraft graphics: the device was lost ({reason}); starting again");
solidcraft_engine::note!("SolidCraft graphics: the device was lost ({reason}); starting again");
solidcraft_engine::crash::phase(&format!("graphics device lost: {reason}"));
gpu.lost_at = Some(device_lost::now_secs());
gpu.reason = reason;
@@ -991,7 +993,7 @@ fn main() -> eframe::Result {
if let Some(value) = max_file_size {
let applied = startup::max_file_size(value.as_deref()).and_then(|bytes| solidcraft_engine::io::limits::set_override(Some(bytes)));
if let Err(error) = applied {
eprintln!("SolidCraft: {error}");
solidcraft_engine::note!("SolidCraft: {error}");
return Err(eframe::Error::AppCreation(std::io::Error::other(error).into()));
}
}
@@ -1023,7 +1025,7 @@ fn main() -> eframe::Result {
let fallback_note = std::env::var(startup::FALLBACK_NOTE_ENV).ok().map(|s| s.chars().take(4096).collect::<String>());
if let Some(note) = &fallback_note {
log::warn!("{note}");
eprintln!("SolidCraft: {note}");
solidcraft_engine::note!("SolidCraft: {note}");
notes.push(note.clone());
}
let saved_samples = if safe_mode { None } else { msaa() };
@@ -1136,13 +1138,13 @@ fn main() -> eframe::Result {
// Windows: the taskbar button shows the mark even when slow first frames keep the
// taskbar waiting for the window's icon.
if !native_frame::set_class_icon(cc) {
eprintln!("SolidCraft window: no class icon; the taskbar may show a generic one");
solidcraft_engine::note!("SolidCraft window: no class icon; the taskbar may show a generic one");
}
// The OS hit test offers the resize border, at the window's DPI, from the first
// message (#1381).
app.native_resize_border = custom_titlebar && native_frame::install(cc);
if custom_titlebar && !app.native_resize_border {
eprintln!("SolidCraft window: no native resize border; egui edge zones instead");
solidcraft_engine::note!("SolidCraft window: no native resize border; egui edge zones instead");
}
}
app.integrated_titlebar = cfg!(target_os = "macos");
@@ -1173,9 +1175,10 @@ fn main() -> eframe::Result {
.collect();
let info = rs.adapter.get_info();
if !app.preferences.graphics_adapter.is_empty() && !info.name.eq_ignore_ascii_case(app.preferences.graphics_adapter.as_str()) {
eprintln!(
solidcraft_engine::note!(
"SolidCraft graphics: preferred adapter {:?} was not used; using {:?} instead",
app.preferences.graphics_adapter, info.name
app.preferences.graphics_adapter,
info.name
);
}
let samples = scene_msaa_request(saved_samples);
@@ -1183,7 +1186,7 @@ fn main() -> eframe::Result {
eframe::egui_wgpu::supported_msaa_samples(&rs.adapter, &rs.device, rs.target_format, depth)
});
let scene_samples = scene_samples(info.backend, u32::from(samples), &supported);
eprintln!("SolidCraft graphics: {:?}, MSAA {scene_samples}x (requested {}x)", rs.adapter.get_info().backend, samples);
solidcraft_engine::note!("SolidCraft graphics: {:?}, MSAA {scene_samples}x (requested {}x)", rs.adapter.get_info().backend, samples);
solidcraft_engine::crash::phase("viewport pipelines (shader compile)");
let scene_samples = app.set_wgpu_split(rs, depth_bits, STENCIL_BITS, scene_samples);
app.msaa_supported = supported;
@@ -1344,7 +1347,7 @@ fn wgpu_options(cpu: bool, preference: Option<&str>) -> eframe::egui_wgpu::WgpuC
let retry = std::env::var(startup::RETRY_BACKEND_ENV).ok();
let preference = if cpu { preference } else { retry.as_deref().or(preference) };
if let Some(backends) = startup::choose_backends(cfg!(target_os = "windows"), env_set, cpu, preference, startup::has_hardware_adapter) {
eprintln!("SolidCraft graphics: starting {backends:?}");
solidcraft_engine::note!("SolidCraft graphics: starting {backends:?}");
setup.instance_descriptor.backends = backends;
}
let intel_mac = cfg!(all(target_os = "macos", target_arch = "x86_64"));
@@ -1370,7 +1373,7 @@ fn wgpu_options(cpu: bool, preference: Option<&str>) -> eframe::egui_wgpu::WgpuC
/// event loop or a failed device; each retry advances through the APIs, then software once.
fn no_window(error: eframe::Error, tried_cpu: bool, failed: eframe::wgpu::Backends, settings: Option<&std::path::Path>) -> eframe::Result {
log::error!("the window could not open: {error}");
eprintln!("SolidCraft: the window could not open: {error}");
solidcraft_engine::note!("SolidCraft: the window could not open: {error}");
// This failure was handled, so neither the marker nor the journal should call it a crash.
if let Some(dir) = settings {
startup::started(dir);
@@ -1383,7 +1386,7 @@ fn no_window(error: eframe::Error, tried_cpu: bool, failed: eframe::wgpu::Backen
let previous = std::env::var(startup::FALLBACK_NOTE_ENV).unwrap_or_default();
let note = startup::fallback_note(&previous, failed, next, &error.to_string());
log::warn!("{note}");
eprintln!("SolidCraft: {note}");
solidcraft_engine::note!("SolidCraft: {note}");
let mut command = std::process::Command::new(exe);
command.args(std::env::args_os().skip(1)).env(startup::FALLBACK_NOTE_ENV, note);
if next == "cpu" {
@@ -1789,7 +1792,7 @@ mod viewport_tests {
viewport.fullscreen = Some(fullscreen);
viewport.native_pixels_per_point = Some(retina);
super::fullscreen_content_insets(&mut raw, 32.0, zoom);
let _ = ctx.run_ui(raw, |ui| super::content_ui(app, ui));
ctx.run_ui(raw, |ui| super::content_ui(app, ui)).textures_delta.clear();
};
// Enter, leave, and enter again: no stale inset when returning to a normal window.
for fullscreen in [false, true, false, true] {
+1 -1
View File
@@ -434,7 +434,7 @@ mod tests {
let mut app = SolveApp::new(solidcraft_engine::Session::default(), solidcraft_ui_egui::Services::default());
app.home.open = false;
let ctx = Context::default();
let _ = ctx.run_ui(egui::RawInput { focused: true, ..Default::default() }, |_| {});
ctx.run_ui(egui::RawInput { focused: true, ..Default::default() }, |_| {}).textures_delta.clear();
let mut bridge = NavBridge { shared: Arc::new(Mutex::new(Shared::default())) };
let queue = |bridge: &NavBridge| {
let mut shared = bridge.shared.lock().unwrap();
+1
View File
@@ -1914,6 +1914,7 @@ mod tests {
fn wheel() -> egui::Event {
egui::Event::MouseWheel {
source: egui::MouseWheelSource::Unknown,
unit: egui::MouseWheelUnit::Line,
delta: egui::vec2(0.0, -1.0),
modifiers: Default::default(),
+23 -17
View File
@@ -40,10 +40,10 @@ fn connect_to(path: &Path) -> Option<UnixStream> {
/// The axes of one record, or `None` for a record that is not motion.
///
/// The daemon's frame is right-handed with x to the right, y away from the user and z up; ours is
/// the device's raw one, x right, y towards the user and z down, so y and z (and the rotations
/// about them) change sign. With nothing set in `spnavcfg` the two give the same motion; what
/// the user swaps or inverts there then carries over.
/// The daemon reports x to the right, y up and z away from the user; our raw HID frame is x
/// right, y towards the user and z down. Swap and negate y/z and ry/rz to match that frame.
/// With nothing set in `spnavcfg` the two readers give the same motion; what the user swaps
/// or inverts there then carries over.
fn decode(record: &[u8; RECORD_BYTES]) -> Option<Axes> {
let mut data = [0_i32; 8];
for (slot, bytes) in data.iter_mut().zip(record.as_chunks::<4>().0) {
@@ -51,8 +51,8 @@ fn decode(record: &[u8; RECORD_BYTES]) -> Option<Axes> {
}
let [kind, x, y, z, rx, ry, rz, _period] = data;
(kind == 0).then(|| Axes {
translation: [unit(x), unit(-i64::from(y)), unit(-i64::from(z))],
rotation: [unit(rx), unit(-i64::from(ry)), unit(-i64::from(rz))],
translation: [unit(x), unit(-i64::from(z)), unit(-i64::from(y))],
rotation: [unit(rx), unit(-i64::from(rz)), unit(-i64::from(ry))],
})
}
@@ -149,22 +149,28 @@ mod tests {
#[test]
fn motion_records_become_axes_in_the_devices_raw_frame() {
// Full deflection of the daemon's x, y and z is the device's full scale, and y and z flip.
assert_eq!(decode(&record([0, 500, 500, 500, 0, 0, 0, 8])), Some(Axes { translation: [350, -350, -350], rotation: [0; 3] }));
// Rotations follow translations: right-handed in both frames, so ry and rz flip as y and z do.
assert_eq!(decode(&record([0, 0, 0, 0, 250, 250, 250, 8])), Some(Axes { translation: [0; 3], rotation: [175, -175, -175] }));
// Each daemon axis reaches only its matching raw HID axis, in both directions.
for (daemon_axis, raw_axis, sign) in [(0, 0, 1), (1, 2, -1), (2, 1, -1), (3, 3, 1), (4, 5, -1), (5, 4, -1)] {
for value in [-500, 500] {
let mut data = [0; 8];
data[daemon_axis + 1] = value;
let mut expected = [0; 6];
expected[raw_axis] = sign * (value * 350 / 500) as i16;
assert_eq!(decode(&record(data)), Some(Axes::from(expected)), "daemon axis {daemon_axis}, value {value}");
}
}
// Rest is a motion record of zeros.
assert_eq!(decode(&record([0, 0, 0, 0, 0, 0, 0, 1200])), Some(Axes::default()));
}
#[test]
fn what_spnavcfg_swaps_reaches_the_view() {
// The daemon sends a push away as y. With Y and Z swapped in spnavcfg it sends the same push
// as z, and the view gets a lift (raw z) instead of the zoom (raw y).
let before = decode(&record([0, 0, 500, 0, 0, 0, 0, 8])).map(|axes| axes.translation);
let after = decode(&record([0, 0, 0, 500, 0, 0, 0, 8])).map(|axes| axes.translation);
assert_eq!(before, Some([0, -350, 0]));
assert_eq!(after, Some([0, 0, -350]));
// A push away is daemon z (raw y); swapping Y/Z makes it a lift (raw z).
// The same swap changes a sideways tilt (daemon rz, raw ry) into a twist (raw rz).
let before = decode(&record([0, 0, 0, 500, 0, 0, 250, 8]));
let after = decode(&record([0, 0, 500, 0, 0, 250, 0, 8]));
assert_eq!(before, Some(Axes { translation: [0, -350, 0], rotation: [0, -175, 0] }));
assert_eq!(after, Some(Axes { translation: [0, 0, -350], rotation: [0, 0, -175] }));
}
#[test]
@@ -179,7 +185,7 @@ mod tests {
// Nothing overflows on the extremes of `i32`.
let extreme = decode(&record([0, i32::MIN, i32::MIN, i32::MAX, i32::MAX, i32::MIN, i32::MIN, -1]));
assert_eq!(extreme.map(|axes| axes.translation[0]), Some(i16::MIN));
assert_eq!(extreme.map(|axes| axes.translation[1]), Some(i16::MAX));
assert_eq!(extreme.map(|axes| axes.translation), Some([i16::MIN, i16::MIN, i16::MAX]));
assert_eq!(extreme.map(|axes| axes.rotation[0]), Some(i16::MAX));
}
+11 -6
View File
@@ -311,13 +311,18 @@ pub const SYSTEM_VERSION_COMPAT_ENV: &str = "SYSTEM_VERSION_COMPAT";
#[cfg(target_os = "macos")]
pub const OCLP_NVRAM_KEY: &str = "4D1FDA02-38C7-4A6A-9CC6-4BCCA8B30102:OCLP-Version";
/// Where macOS keeps its `nvram` tool.
#[cfg(target_os = "macos")]
const NVRAM_TOOL: &str = "/usr/sbin/nvram";
/// `true` if the OCLP NVRAM key is present on this system.
///
/// Runs `nvram <key>` and treats exit 0 as present. Any error (missing binary,
/// permission denied, key absent) returns `false` — this is a best-effort probe.
/// Runs `/usr/sbin/nvram <key>` and treats exit 0 as present. The full path keeps a `PATH` that
/// lacks `/usr/sbin`, or lists a look-alike first, from changing the answer. Any error (missing
/// binary, permission denied, key absent) returns `false`: this is a best-effort probe.
#[cfg(target_os = "macos")]
pub fn oclp_nvram_present() -> bool {
std::process::Command::new("nvram").arg(OCLP_NVRAM_KEY).output().map(|o| o.status.success()).unwrap_or(false)
std::process::Command::new(NVRAM_TOOL).arg(OCLP_NVRAM_KEY).output().map(|o| o.status.success()).unwrap_or(false)
}
/// Whether to re-launch with `SYSTEM_VERSION_COMPAT=1`: it is not set yet and OCLP shows through
@@ -334,8 +339,8 @@ fn compat_wanted(compat_set: bool, nvram: impl FnOnce() -> bool) -> bool {
///
/// `SYSTEM_VERSION_COMPAT=1` must be present **before** the process starts: setting it
/// with `std::env::set_var` inside `main` is too late because the macOS frameworks have
/// already queried the OS version by then. Re-launching is the only safe approach that
/// works without `unsafe` (a true `execve` replacement would require it).
/// already queried the OS version by then, so the program starts itself again with the variable
/// set and waits for that child.
///
/// Returns `Ok(false)` immediately when:
/// - `SYSTEM_VERSION_COMPAT` is already set (re-launched process, no loop), or
@@ -352,7 +357,7 @@ pub fn relaunch_with_version_compat() -> Result<bool, String> {
return Ok(false);
}
log::info!("startup: OCLP detected; re-launching with {}=1", SYSTEM_VERSION_COMPAT_ENV);
eprintln!("SolidCraft: OCLP detected; re-launching with {}=1", SYSTEM_VERSION_COMPAT_ENV);
solidcraft_engine::note!("SolidCraft: OCLP detected; re-launching with {}=1", SYSTEM_VERSION_COMPAT_ENV);
let exe = std::env::current_exe().map_err(|e| format!("could not find own executable: {e}"))?;
let status = std::process::Command::new(&exe)
.args(std::env::args_os().skip(1))
+4 -2
View File
@@ -23,7 +23,7 @@ assets/
appearances/library.rs # built-in visual presets
materials/library.rs # physical defaults and colours
shaders/viewport.wgsl # original viewport shader
fonts/{liberation-sans,biz-udp-gothic}/ # unmodified fonts and OFL licences
fonts/biz-udp-gothic/ # unmodified font and OFL licence
locales/{en,de,ja,ko}.json # shared offline catalogs
platforms/{macos,linux,windows}/ # bundle, freedesktop and WiX templates
platforms/macos/dmg/ # disk image window: layout, dmgbuild settings, background art
@@ -87,7 +87,9 @@ The CLI still supports arbitrary PNG/ICO exports with `solidcraft-cli icon`.
Resources remain compiled by their owning layer through existing APIs:
`ui-egui::icons::paint_icon` and `Tokens` at L5, model presets in L2 `doc`, and sketch
fonts at L1. State, projection, interaction, commands and GPU bindings stay in
fonts at L1. The sketch crate owns its built-in Liberation Sans font and OFL
licence under `crates/sketch/assets/fonts/liberation-sans/`, so Cargo source
packages and vendored builds include both files. State, projection, interaction, commands and GPU bindings stay in
their current modules. Embedded data uses `CARGO_MANIFEST_DIR`; application code
needs no runtime resource registry or repository lookup.
-11
View File
@@ -79,17 +79,6 @@ pub(crate) fn snap_hint(painter: &egui::Painter, c: Pos2, kind: SnapKind, col: C
painter.circle_filled(c, 1.3, col);
}
/// A placed hole's drag handle: a ring round the hole's centre with a cross through it, filled
/// while it is hovered or dragged.
pub(crate) fn hole_handle(painter: &egui::Painter, c: Pos2, col: Color32, hot: bool) {
let ic = Tokens::get().icons;
let r = 6.0;
painter.circle_filled(c, r + 3.5, ic.marker_halo);
painter.circle(c, r, if hot { col } else { col.gamma_multiply(0.35) }, Stroke::new(1.6, col));
let ink = Stroke::new(1.3, if hot { ic.highlight } else { col });
painter.line_segment([c - vec2(r + 3.0, 0.0), c + vec2(r + 3.0, 0.0)], ink);
painter.line_segment([c - vec2(0.0, r + 3.0), c + vec2(0.0, r + 3.0)], ink);
}
pub(crate) fn glyph_icon(name: &str) -> Option<&'static str> {
Some(match name {
"Coincident" => "c_coincident",
+1 -2
View File
@@ -55,8 +55,7 @@ pub(crate) fn face_text(painter: &egui::Painter, face: &Facet, ink: Color32, fon
painter.ctx().add_plugin(LabelAtlas);
let text = Shape::Text(egui::epaint::TextShape::new(Pos2::ZERO, galley, ink));
let atlas = painter.ctx().fonts(|f| f.font_image_size());
let primitives =
painter.ctx().tessellate(vec![egui::epaint::ClippedShape { clip_rect: Rect::EVERYTHING, shape: text }], painter.ctx().pixels_per_point());
let primitives = painter.ctx().tessellate(vec![egui::epaint::ClippedShape::new(Rect::EVERYTHING, text)], painter.ctx().pixels_per_point());
for primitive in primitives {
if let egui::epaint::Primitive::Mesh(mut mesh) = primitive.primitive {
mesh.texture_id = LABEL_PIXELS;
+506 -47
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1621,7 +1621,7 @@
"help.sketch.insert_svg": "Učitava putanje SVG crteža u skicu, skalirane, zakrenute i postavljene gdje zadate. Koristite ga da logotip ili vektorski obris pretvorite u krive koje možete ekstrudirati.",
"help.sketch.inspect": "Otvara izvještaj o otvorenoj skici: koliko je stepeni slobode preostalo i je li potpuno ograničena. Koristite ga da saznate šta se još pomjera prije nego što završite skicu.",
"help.sketch.intersect": "Dodaje krive ili tačke gdje tijela, lica, ivice ili druge skice sijeku ravan skice. Ostaju povezane i prate model kada se promijeni.",
"help.sketch.intersection_curve": "Pravi 3D krive tamo gdje se dva tijela ili lica sastaju, povezane s njima. Koristite ih kao putanje ili vodilice koje prate model.",
"help.sketch.intersection_curve": "Pravi 3D krive tamo gdje se dva tijela, lica ili krive drugih skica sastaju, povezane s njima. Koristite ih kao putanje ili vodilice koje prate model.",
"help.sketch.isoparametric_curve": "Pravi 3D krivu u smjeru U ili V lica kroz tačku koju izaberete, povezanu s licem. Koristite je kao putanju ili liniju razdvajanja koja prati površ.",
"help.sketch.line": "Crta povezane linije tačku po tačku, kao otvoreni lanac ili zatvoren obris. To je osnovni alat za većinu profila.",
"help.sketch.line.midpoint": "Crta liniju od njene središnje tačke do jednog kraja, a drugi kraj se preslikava. Koristite je za linije centrirane na tački, npr. simetričnu ivicu.",
File diff suppressed because it is too large Load Diff
+354 -176
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -1621,7 +1621,7 @@
"help.sketch.insert_svg": "Læser stierne i en SVG-tegning ind i en skitse, skaleret, drejet og placeret, som du angiver. Brug den til at gøre et logo eller en vektorkontur til kurver, du kan ekstrudere.",
"help.sketch.inspect": "Åbner en rapport om den åbne skitse: hvor mange frihedsgrader der er tilbage, og om den er fuldt bundet. Brug den til at finde ud af, hvad der stadig kan bevæge sig, før du afslutter skitsen.",
"help.sketch.intersect": "Tilføjer de kurver eller punkter, hvor emner, flader, kanter eller andre skitser krydser skitseplanet. De forbliver linkede og følger modellen, når den ændres.",
"help.sketch.intersection_curve": "Opretter 3D-kurver, hvor to emner eller flader mødes, linket til dem. Brug dem som baner eller styrekurver, der følger modellen.",
"help.sketch.intersection_curve": "Opretter 3D-kurver, hvor to emner, flader eller kurver fra andre skitser mødes, linket til dem. Brug dem som baner eller styrekurver, der følger modellen.",
"help.sketch.isoparametric_curve": "Opretter en 3D-kurve langs en flades U- eller V-retning gennem et punkt, du vælger, linket til fladen. Brug den som bane eller delelinje, der følger overfladen.",
"help.sketch.line": "Tegner forbundne linjer punkt for punkt som en åben kæde eller en lukket kontur. Det er det grundlæggende værktøj til de fleste profiler.",
"help.sketch.line.midpoint": "Tegner en linje fra dens midtpunkt ud til den ene ende, og den anden ende spejles. Brug den til linjer centreret om et punkt, f.eks. en symmetrisk kant.",
@@ -3807,6 +3807,8 @@
"sample.plate.name": "Eksempelplade",
"sample.vents.about": "Slidser i et rektangulært mønster: Rækker, kolonner og deling er parametre",
"sample.vents.name": "Ventilationsplade",
"scale.per_axis": "Per akse",
"scale.uniform": "Ensartet",
"search.command_keys": "Enter kør Shift+Enter kør og hold åben Esc luk",
"search.commands": "Kommandoer",
"search.design": "I dette design",
+255 -14
View File
@@ -628,6 +628,8 @@
"Suppress Selected Folders": "Ausgewählte Ordner unterdrücken",
"Suppressed": "Unterdrückt",
"Surface": "Flächenkörper",
"Surface or face": "Flächenkörper oder Fläche",
"Surface or faces": "Flächenkörper oder Flächen",
"Surface, or faces to turn over": "Fläche oder umzudrehende Flächen",
"Surfaces": "Flächen",
"Swap Ends": "Enden vertauschen",
@@ -641,6 +643,7 @@
"Target": "Ziel",
"Target Body": "Zielkörper",
"Target Face (optional)": "Zielfläche (optional)",
"Target edges": "Zielkanten",
"Tear": "Reißen",
"Technical details": "Technische Details",
"The dashed sketch is a saved plane guide. Select a new face to repair the attachment.": "Die gestrichelte Skizze ist eine gespeicherte Ebenenführung. Wählen Sie eine neue Fläche, um die Anbindung zu reparieren.",
@@ -648,6 +651,7 @@
"The direction must be an axis or a straight sketch curve that lies in the sketch plane": "Die Richtung muss eine Achse oder eine gerade Skizzenkurve in der Skizzenebene sein",
"The mirror line cannot also be mirrored": "Die Spiegellinie kann nicht gleichzeitig gespiegelt werden",
"The mirror line must be a straight curve of this sketch, not an origin axis": "Die Spiegellinie muss eine gerade Kurve dieser Skizze sein, keine Ursprungsachse",
"The origin axis is perpendicular to the sketch plane, so it has no line in the plane": "Die Ursprungsachse steht senkrecht auf der Skizzenebene und hat daher keine Linie in ihr",
"The saved author occurrence is missing. Select a valid author placement before rebinding.": "Das gespeicherte Autorvorkommen fehlt. Wählen Sie vor dem erneuten Anbinden eine gültige Autorplatzierung.",
"Then": "Then",
"These features will be deleted:": "Diese Merkmale werden gelöscht:",
@@ -689,6 +693,7 @@
"Unsuppress Selected Folders": "Unterdrückung der ausgewählten Ordner aufheben",
"Updating the preview": "Vorschau wird aktualisiert",
"Use active author placement": "Aktive Autorplatzierung verwenden",
"Use axis direction only": "Nur Achsenrichtung verwenden",
"Use fixed plane": "Feste Ebene verwenden",
"Use saved fixed plane": "Gespeicherte feste Ebene verwenden",
"Use selected face as fixed plane": "Ausgewählte Fläche als feste Ebene verwenden",
@@ -739,6 +744,9 @@
"a picked edge's body is gone": "der Körper einer gewählten Kante existiert nicht mehr",
"add a key for a joint first": "zuerst einen Schlüssel für ein Gelenk hinzufügen",
"ai.connected_toast": "SolidCraft ist jetzt in {clients} verfügbar. Starten Sie diese neu, um es zu nutzen (Einstellungen > Assistent).",
"ai.http.allow": "Unsicheres HTTP erlauben",
"ai.http.confirm": "Für diesen Endpunkt erlauben",
"ai.http.warning": "Der API-Schlüssel und die Unterhaltung werden unverschlüsselt gesendet. Jeder mit Zugriff auf das Netzwerk kann sie lesen oder verändern. Verwenden Sie dies nur in einem vertrauenswürdigen Netzwerk.",
"ai.prefs.allow_control": "KI-Agenten verbinden lassen",
"ai.prefs.allow_control_note": "Ein lokaler Kanal (MCP). Nichts verlässt diesen Computer.\n\nÖffnet einen Steuerkanal nur auf diesem Computer (127.0.0.1, Port 27878 oder der nächste freie). Ein verbindendes Programm muss zusätzlich ein geheimes Token vorweisen, das nur Ihr Benutzerkonto lesen kann und bei jedem Start neu erzeugt wird. Wirkt ab dem nächsten Start von SolidCraft. Aus: Es wartet nichts auf Verbindungen, außer Sie starten SolidCraft mit --control.",
"ai.prefs.availability": "Verfügbarkeit",
@@ -1028,6 +1036,7 @@
"cmd.occurrence_move": "Instanz verschieben",
"cmd.occurrence_properties": "Physikalische Eigenschaften der Instanz",
"cmd.occurrence_reparent": "In andere Komponente verschieben",
"cmd.panel.action": "Panelaktion ausführen",
"cmd.panel.activate": "Panel-Registerkarte aktivieren",
"cmd.panel.close": "Panel schließen",
"cmd.panel.collapse": "Panel-Bereich einklappen",
@@ -1071,9 +1080,14 @@
"cmd.pointcloud_edit": "Punktwolke bearbeiten",
"cmd.pointcloud_insert": "Punktwolke einfügen",
"cmd.pointcloud_list": "Punktwolken",
"cmd.render.coat.bind": "Klarlack speichern",
"cmd.render.coat.unbind": "Klarlack entfernen",
"cmd.render.compare.divider": "Render-Vergleichstrenner verschieben",
"cmd.render.result.discard": "Renderergebnis verwerfen",
"cmd.render.result.select": "Renderergebnis auswählen",
"cmd.render.surface.import": "Oberflächentexturen importieren",
"cmd.render.texture.list": "Eingebettete Oberflächenbilder auflisten",
"cmd.render.texture.remove": "Eingebettetes Oberflächenbild entfernen",
"cmd.render_aging_describe": "Mehrschichtige Oberflächenalterung",
"cmd.render_backends": "Render-Backends",
"cmd.render_batch": "Alle Aufnahmen rendern",
@@ -1293,6 +1307,7 @@
"cmd.view.render_browser": "Render-Browser",
"cmd.view.render_dock": "Render-Panel verschieben",
"cmd.view.render_focus": "Render-Fokusansicht",
"cmd.view.render_live": "Live-Rendering pausieren oder fortsetzen",
"cmd.view.status_details": "Statusdetails des Ansichtsfensters",
"cmd.view.status_items": "Ansichtsstatus-Elemente",
"cmd.view_ambient_occlusion": "Umgebungsverdeckung",
@@ -1322,6 +1337,7 @@
"cmd.view_shots_strip": "Aufnahmenleiste",
"cmd.view_spin": "Ansicht kontinuierlich drehen",
"cmd.view_tool": "Navigationswerkzeug",
"cmd.view_tool_zoom": "Durch Ziehen zoomen",
"cmd.view_top": "Draufsicht",
"cmd.view_visual_style": "Darstellungsstil",
"color.blue": "Blau",
@@ -1388,6 +1404,7 @@
"dialog.computing": "Wird berechnet… {seconds} s",
"dialog.could_not_be_checked": "nicht prüfbar",
"dialog.custom_axis": "↖ Eigene Achse",
"dialog.cylinder_face_tip": "Die Fläche oder der Eckpunkt des Modells, auf dem der Zylinder steht: hier klicken, dann in der Ansicht darauf klicken. Leer gelassen platzieren ihn X, Y und Z.",
"dialog.dimension_insert_hint": "Doppelklicken Sie auf eine andere steuernde Bemaßung, um ihren Namen einzufügen",
"dialog.dimension_name": "Bemaßung: {name}",
"dialog.dimension_step_down": "Um {step} verringern",
@@ -1396,6 +1413,8 @@
"dialog.edit_title": "{title} bearbeiten",
"dialog.no_shown_body": "Kein sichtbarer Körper zum Ändern vorhanden. Blenden Sie einen Körper ein oder wählen Sie „Neuer Körper“.",
"dialog.pattern_sketch_not_object": "Körper oder Funktionen wählen: Eine Skizze ist keines von beiden. Ihre Kurven mit Muster oder Spiegeln in der Skizze wiederholen oder sie zuerst extrudieren.",
"dialog.primitive_point_tip": "Ein Punkt, der X, Y und Z füllt: hier klicken, dann einen Eckpunkt, eine Kante oder Fläche des Modells, einen Punkt einer Skizze oder den Ursprung anklicken.",
"dialog.reference_edge_tip": "Eine gerade Kante der gewählten Fläche, von der aus der Abstand darunter gemessen wird: hier klicken, dann die Kante anklicken.",
"dialog.select": "↖ Auswählen",
"dialog.selected_count": "↖ {count} ausgewählt",
"dialog.selection_constraint": "Bedingung {id}",
@@ -1443,8 +1462,11 @@
"file.open.portal_fallback.xwayland": "Die geöffnete Dateiauswahl ist nicht die des Desktops: Unter XWayland lehnt das Portal das Elternfenster ab.",
"file_load.built": "Bisher erstellt: {built} von {total} Merkmalen.",
"file_load.converting": "Volumenkörper {done} von {total} wird konvertiert",
"file_load.inserted": "{name} eingefügt",
"file_load.inserting": "Geometrie wird eingefügt",
"file_load.link_item": "{name} ({done} von {total})",
"file_load.opened": "{name} geöffnet",
"file_load.opened_in_tab": "{name} in neuem Tab geöffnet",
"file_load.opening": "Design wird geöffnet",
"file_load.preparing_thumbnail": "Vorschaubild wird vorbereitet...",
"file_load.preparing_view": "Ansicht wird vorbereitet...",
@@ -1680,7 +1702,7 @@
"help.sketch.insert_svg": "Liest die Pfade einer SVG-Zeichnung in eine Skizze ein, skaliert, gedreht und an der gewünschten Stelle. Damit machen Sie aus einem Logo oder einer Vektorkontur Kurven, die sich extrudieren lassen.",
"help.sketch.inspect": "Öffnet einen Bericht zur offenen Skizze: wie viele Freiheitsgrade übrig sind und ob sie vollständig bestimmt ist. Damit sehen Sie, was sich noch bewegt, bevor Sie die Skizze abschließen.",
"help.sketch.intersect": "Fügt die Kurven oder Punkte hinzu, an denen Körper, Flächen, Kanten oder andere Skizzen die Skizzenebene schneiden. Sie bleiben verknüpft und folgen Änderungen am Modell.",
"help.sketch.intersection_curve": "Erzeugt 3D-Kurven, wo sich zwei Körper oder Flächen treffen, mit diesen verknüpft. Als Pfade oder Leitkurven, die dem Modell folgen.",
"help.sketch.intersection_curve": "Erzeugt 3D-Kurven, wo sich zwei Körper, Flächen oder Kurven anderer Skizzen treffen, mit diesen verknüpft. Als Pfade oder Leitkurven, die dem Modell folgen.",
"help.sketch.isoparametric_curve": "Erzeugt eine 3D-Kurve in U- oder V-Richtung einer Fläche durch einen gewählten Punkt, mit der Fläche verknüpft. Als Pfad oder Trennlinie, die der Fläche folgt.",
"help.sketch.line": "Zeichnet verbundene Linien Punkt für Punkt, als offene Kette oder geschlossene Kontur. Das Grundwerkzeug für die meisten Profile.",
"help.sketch.line.midpoint": "Zeichnet eine Linie von ihrem Mittelpunkt zu einem Ende, das andere Ende gespiegelt. Für Linien, die auf einem Punkt mittig liegen, etwa eine symmetrische Kante.",
@@ -2140,6 +2162,7 @@
"navbar.value_tip": "{name} · {value}",
"navbar.visual_style": "Darstellung",
"navbar.zoom": "Zoomen",
"navbar.zoom_hold_tip": "Zoomen (Tastenkürzel gedrückt halten)",
"needs two joints": "benötigt zwei Gelenke",
"network.allow_once": "Einmalig erlauben",
"network.always_allow": "Diesen Server immer erlauben",
@@ -2278,6 +2301,8 @@
"prefs.browser_eye_left": "Auge neben dem Aufklapppfeil",
"prefs.browser_eye_left_note": "Browserzeilen zeigen ihr Auge zum Ein- und Ausblenden wie bisher neben dem Aufklapppfeil statt in einer Spalte am rechten Zeilenende.",
"prefs.browser_reveal_on_hover": "Beim Überfahren einblenden",
"prefs.browser_rows_start_closed": "Browser-Zeilen beginnen geschlossen",
"prefs.browser_rows_start_closed_note": "Platzierungen und Ordner im Browser beginnen geschlossen, sodass ein Entwurf mit vielen Komponenten seine obersten Zeilen zeigt. Jede öffnet sich mit ihrem Pfeil, „Alle ausklappen“ oder „Im Browser suchen“, das nur die Zeilen bis zu seinem Körper öffnet und die von der letzten Suche geöffneten wieder schließt. Ein in der 3D-Ansicht gewählter Körper lässt den Browser, wie er ist. Ist es aus, beginnen die Ordner geöffnet, Platzierungen so, wie „Komponenten zu Beginn aufgeklappt“ es festlegt, und eine Auswahl in der 3D-Ansicht öffnet den Browser bis zu ihrem Körper, wie bisher.",
"prefs.browser_show_note": "Angezeigt lässt den Browser auf dem Bildschirm. Beim Überfahren einblenden blendet ihn aus, bis der Zeiger über seinen Platz oder die schmale Lasche an seinem linken Rand kommt; er bleibt offen, solange der Zeiger darin ist oder ein Menü daraus offen ist.",
"prefs.browser_shown": "Angezeigt",
"prefs.built_in": "Integriert",
@@ -2288,6 +2313,7 @@
"prefs.carousel_previous": "Zurück",
"prefs.choose_font": "Schriftdatei auswählen…",
"prefs.click": "Klicken",
"prefs.command_dialog_values": "Befehlsdialoge",
"prefs.contact_shading": "Weiche Kontaktschattierung",
"prefs.contact_shading_note": "Erzeugt weiche Schatten an Stellen, an denen sich Bauteile nahekommen.",
"prefs.context_menu": "Kontextmenü",
@@ -2334,6 +2360,8 @@
"prefs.edges": "Kanten und Schattierung",
"prefs.extend": "Erweitern",
"prefs.extra_navigation": "Mit Strg und gedrückter linker Maustaste auf einer freien Fläche drehen. Leertaste gedrückt halten, um die Ansicht zu verschieben.",
"prefs.extrude_arrow_face_stops": "Extrusionspfeil hält an Flächen",
"prefs.extrude_arrow_face_stops_note": "Beim Ziehen des Abstandspfeils von Extrusion hält er an ebenen Flächen parallel zum Profil, etwa an der Rückseite eines Blocks, in den geschnitten wird, und hebt die Fläche hervor. Aus: Der Pfeil bewegt sich nur in runden Schritten.",
"prefs.extrude_view_isometric": "Beim Öffnen von Extrudieren isometrisch ausrichten",
"prefs.extrude_view_return": "Beim Schließen von Extrudieren zur vorigen Ansicht zurück",
"prefs.follows_os": "Wie im Betriebssystem",
@@ -2383,6 +2411,8 @@
"prefs.graphics_page.surfaces_edges": "Flächen und Kanten",
"prefs.graphics_page.view_bar": "Auch in der Ansichtsleiste",
"prefs.graphics_presets_note": "Mit den Profilen werden die unten aufgeführten Optionen gemeinsam eingestellt.",
"prefs.ground_shadow_follows_model": "Bodenschatten folgt dem Modell",
"prefs.ground_shadow_follows_model_help": "Den Schatten unter dem niedrigsten sichtbaren Körper platzieren. Wenn deaktiviert, bleibt der Schatten angehobener Modelle auf der Bodenebene durch den Ursprung.",
"prefs.hide_constraint_glyphs_zoomed_out": "Bindungssymbole bei starkem Herauszoomen ausblenden",
"prefs.hide_constraint_glyphs_zoomed_out_note": "Bindungssymbole verschwinden, wenn die Skizze so weit herausgezoomt ist, dass sie sie überladen würden. Aus: sie behalten bei jedem Zoom eine feste Bildschirmgröße.",
"prefs.history": "Verlauf",
@@ -2418,6 +2448,8 @@
"prefs.layout_page.card": "Karte",
"prefs.layout_page.column": "Spalte",
"prefs.layout_page.hover": "Bei Mauszeiger",
"prefs.layout_page.hover_panel_menus": "Bereichsmenüs beim Darüberfahren wechseln",
"prefs.layout_page.hover_panel_menus_help": "Klicken Sie auf einen Bereichsnamen, um das Menü zu öffnen. Bewegen Sie den Zeiger auf einen anderen Bereichsnamen, um das Menü zu wechseln. Außerhalb des Menübands und des geöffneten Menüs schließt es sich.",
"prefs.layout_page.icons_labels": "Symbole + Beschriftungen",
"prefs.layout_page.ok_cancel": "OK und Abbrechen",
"prefs.layout_page.on_hover": "Bei Mauszeiger",
@@ -2456,6 +2488,8 @@
"prefs.mouse": "Maus und Trackpad",
"prefs.mouse_heading": "Maus",
"prefs.mouse_mapping": "Mausbelegung",
"prefs.move_drag_highlights": "Hervorheben, was unter dem Zeiger liegt, während die Pfeile von Verschieben eine Komponente ziehen",
"prefs.move_drag_highlights_tip": "Aus: Solange die Pfeile von Verschieben eine Komponente ziehen, wird nichts unter dem Zeiger hervorgehoben; so bleiben große Baugruppen flüssig. Mit einem Schnitt oder einer Analyse wird es trotzdem hervorgehoben, und ein entlang seiner Gelenke gezogenes Teil hebt nie etwas hervor.",
"prefs.navigation": "Navigation",
"prefs.navigation.axes_signed": "Achsen",
"prefs.navigation.cube_compact": "Würfel",
@@ -2520,6 +2554,10 @@
"prefs.quality_smoother": "Stärker geglättet",
"prefs.quick_measure": "Schnellmessung in der Ecke der Ansicht",
"prefs.quick_measure_note": "Zeigt unten rechts in der Ansicht die Größe der Auswahl an: bei einer Kante die Länge, bei einer kreisförmigen Kante zusätzlich Durchmesser (Ø) und Radius (R), bei einer Fläche den Flächeninhalt, bei einem Punkt die Position, bei zwei Elementen Abstand und Winkel sowie bei mehreren Kanten oder Flächen die Gesamtlänge bzw. Gesamtfläche. Ein Klick kopiert den Messwert.",
"prefs.remember_dialog_values": "Befehlsdialogwerte merken",
"prefs.remember_dialog_values.help": "Standardmäßig aus. Wenn aktiviert, werden nach erfolgreicher Ausführung eines Befehls seine sicheren Werte und Optionen beim nächsten Öffnen desselben Befehls wiederverwendet, bis SolidCraft geschlossen wird.",
"prefs.render_legacy_image_defaults": "Bisherige Render-Bildvorgaben verwenden",
"prefs.render_legacy_image_defaults_help": "Den bisherigen manuellen Ablauf beibehalten: neue Setups mit CPU-Raster, feste Abtastung beim CPU-Raytracing und manuell geöffnete Ergebnisse. Beim Wechsel zu Render die Ansicht neu einpassen, Aufnahmen ohne Wiederherstellung ihrer Kamera wechseln und die bisherige Bildausrichtung und Navigation verwenden. Automatische Live-Bildrückmeldungen deaktivieren. Gespeicherte Setups bleiben unverändert.",
"prefs.render_previous_geometry_budget": "Bisheriges Geometriebudget für die Render-Vorschau verwenden",
"prefs.render_previous_geometry_budget_help": "Stellt die bisherige feste Obergrenze für die Geometrie wieder her (512 MiB auf 64-Bit-Desktop-Systemen, andernfalls 128 MiB) anstelle des geräteabhängigen Budgets.",
"prefs.reports_saved": "{n} gespeichert",
@@ -2596,6 +2634,7 @@
"prefs.sketch_exit_view_note": "Legt fest, zu welcher Ansicht nach „Skizze beenden“, „Abbrechen“ oder zweimaligem Drücken der Esc-Taste gewechselt wird.",
"prefs.sketch_face_activates_part": "Skizzieren auf der Fläche eines Teils aktiviert dieses Teil",
"prefs.sketch_face_activates_part_note": "Eine Skizze, die auf einer Fläche am Körper einer anderen Komponente begonnen wird, aktiviert zuerst diese Komponente, sodass die Skizze zu ihr gehört und im Browser bei diesem Teil bleibt. Das Teil bleibt danach aktiv. Aus: Die Skizze gehört zur aktiven Komponente.",
"prefs.sketch_mirror_auto_line": "Vorausgewählte Konstruktionslinie zum Spiegeln verwenden",
"prefs.sketch_open_look_at": "Beim Öffnen einer Skizze auf sie ausrichten",
"prefs.sketch_open_look_at_note": "Aus: Beim Erstellen oder Bearbeiten einer Skizze bleibt die Ansicht, wie sie ist. „Ansehen“ richtet die Ansicht bei Bedarf auf die Skizze aus.",
"prefs.sketch_recent_snap": "Automatisch an zuletzt verwendete Mittelpunkte fangen",
@@ -2743,12 +2782,15 @@
"refused.join": "Konnte nicht mit {body} vereinigt werden.",
"refused.repair": "Teil reparieren",
"refused.show_face": "Fläche zeigen",
"render.adaptive.pilot_progress": "Adaptive Pilotphase · {current}/{target} Durchgänge",
"render.adaptive.preview_help": "Pilotbilder bestimmen die Verteilung und sind vom Endbild ausgeschlossen. Produktionsiterationen nutzen ein eigenes Budget pro Pixel; die Iterationszahl ist keine einheitliche Samplezahl pro Pixel.",
"render.adaptive.production_progress": "Adaptive Produktion · {current}/{target} Iterationen",
"render.adaptive_absolute": "Absolute RGB-Fehleruntergrenze",
"render.adaptive_coverage": "Zielwert für den Abdeckungsfehler",
"render.adaptive_enable": "Samples mithilfe unabhängiger Pilot-Samples zuteilen",
"render.adaptive_help": "Nur GPU. Unabhängige Pilot-Samples werden verworfen. Anschließend werden pro Pixel 16 oder mehr Produktions-Samples zugeteilt, bis zu den Samples pro Pixel. Die Zuteilung richtet sich nach der RGB- und Abdeckungsvarianz; seltene Pfade können übersehen werden, und Pixel, die ihr Limit erreicht haben, können verrauscht bleiben. Der Abschlussbericht zählt den tatsächlichen Aufwand. Keine zertifizierte Garantie für ein bestimmtes Rauschniveau oder eine bestimmte Renderzeit.",
"render.adaptive_help": "Unabhängige Pilotsamples werden verworfen und bestimmen dann mindestens 16 Produktionssamples pro Pixel bis zum Wert Samples pro Pixel. CPU-Ressourcengrenzen umfassen beide Phasen. RGB- und Abdeckungsvarianz steuern die Verteilung; Pixel am Limit können verrauscht bleiben. Der Abschlussbericht zählt die tatsächlichen Samples.",
"render.adaptive_minimum": "Minimale Produktions-Samples",
"render.adaptive_pending": "Die adaptive Zuteilung mit unabhängigen GPU-Pilot-Samples und bedingte Fresnel-Schichten sind verfügbar. Zertifizierte Konvergenz, Garantien für seltene Ereignisse und adaptiver Lichttransport auf der CPU bleiben nicht verfügbar.",
"render.adaptive_pending": "Adaptive Verteilung mit unabhängigen Pilotsamples ist für CPU und GPU verfügbar. Bedingte Fresnel-Schichten erfordern die GPU. Die statistische Verteilung bestätigt keine Konvergenz.",
"render.adaptive_pilots": "Pilot-Samples pro Pixel",
"render.adaptive_relative": "Zielwert für den relativen RGB-Fehler",
"render.add_ground": "Add fitted ground to setup",
@@ -2771,6 +2813,7 @@
"render.aging_metallic": "Metallanteil der Schicht",
"render.aging_origin": "Feldursprung · Weltkoordinaten (mm)",
"render.aging_patina": "Kupfergrüne Patina",
"render.aging_raytrace_help": "Alterung wird beim CPU-Raytracing und GPU-Pathtracing angewendet. Raster bleibt eine schematische Darstellung.",
"render.aging_refresh_targets": "Aktuelle Körper prüfen",
"render.aging_roughness": "Rauheit der Schicht",
"render.aging_rust": "Oxidiertes Eisen / Rost",
@@ -2791,6 +2834,7 @@
"render.appearance_editor.closed": "Geschlossener Körper",
"render.appearance_editor.coat": "Klarlack",
"render.appearance_editor.cpu_reason": "Benötigt CPU-Strahlverfolgung.",
"render.appearance_editor.discard_saved_edit": "Ungespeicherte Optikänderungen verwerfen",
"render.appearance_editor.glass": "Glas und Absorption",
"render.appearance_editor.glass_enable": "Physikalisches Glas für diesen Körper verwenden",
"render.appearance_editor.glass_note": "Geschlossenes Glas verwendet geometrische Abstände. Es kann nicht mit Klarlack oder Texturen auf demselben Körper kombiniert werden.",
@@ -2809,7 +2853,7 @@
"render.appearance_editor.replace": "Gespeicherte Optik dieses Körpers ersetzen",
"render.appearance_editor.revalidate": "Diesen Körper erneut prüfen",
"render.appearance_editor.save": "Diese Optik speichern",
"render.appearance_editor.save_reason": "Zum Speichern wird physikalisches Glas auf einem aktuellen GPU-Körper benötigt. Klarlack und lokale Bildtexturen bleiben vorübergehend.",
"render.appearance_editor.save_reason": "Zum Speichern Glas an einem aktuellen CPU- oder GPU-Körper aktivieren. Für Klarlack „Klarlack in Aufnahme speichern“ verwenden. Lokale Bilddateien bleiben temporär.",
"render.appearance_editor.saved": "Mit dieser Aufnahme gespeichert",
"render.appearance_editor.saved_state": "Diese Optik ist mit dieser Aufnahme gespeichert.",
"render.appearance_editor.scatter_reason": "Innere Streuung benötigt einen geschlossenen Körper.",
@@ -2855,9 +2899,13 @@
"render.base_metallic": "Metallanteil",
"render.base_opaque_only": "Dieser Editor lässt Körper undurchsichtig. Für optische Transmission verwenden Sie die gesonderten Glaseinstellungen.",
"render.batch_all_shots": "Alle Ansichten ({count})",
"render.batch_current_settings_help": "Verwendet die aktuellen Bildeinstellungen mit jeder gespeicherten Aufnahmekamera, eine Aufnahme nach der anderen.",
"render.batch_empty": "Vor dem Rendern aller Ansichten eine Ansicht speichern.",
"render.batch_limit": "Es können höchstens 32 Ansichten gerendert werden.",
"render.batch_owner_limit": "Es können höchstens 32 Dokument-Bildfolgen vorgehalten werden. Schließen Sie zuerst eine vorhandene Folge ab oder brechen Sie sie ab.",
"render.batch_preparing": "Nächste Aufnahme wird vorbereitet",
"render.batch_status": "{running} läuft · {waiting} warten",
"render.batch_waiting_worker": "Warten auf die Rendering-Vorbereitung",
"render.bloom_enable": "Darstellungs-Bloom",
"render.bloom_help": "Kreativer Ausgabeglanz aus linearen Glanzlichtern mit drei normierten Durchläufen. Radius in Ausgabepixeln; Addition vor sRGB. Keine Linsen-, Volumen- oder Konvergenzsimulation. Das rohe EXR bleibt unverändert.",
"render.bloom_opaque": "Bloom benötigt eine deckende Ausgabe. Transparentes PNG wird abgelehnt, nicht still abgeflacht.",
@@ -2887,6 +2935,10 @@
"render.category_paint": "Lack",
"render.category_plastic": "Kunststoff",
"render.category_wood": "Holz",
"render.check_glass_cpu": "Die CPU unterstützt geprüfte ebene Glasscheiben und achsenparallele Boxen. Allgemeine geschlossene Netze, Verschachtelung und innere Streuung erfordern indirekte Beleuchtung. Durch geschlossenes Glas übertragene Schatten bleiben nicht unterstützt.",
"render.check_glass_gpu": "Die GPU unterstützt geprüfte getrennte Glasmedien oder ausdrücklich aktivierte geprüfte Verschachtelung. Dafür müssen die äußeren Körper konvex und die inneren streng enthalten sein; Grenzen dürfen sich nicht berühren.",
"render.check_media_remaining": "Raues Glas, Dichtegitter, Displacement und spektraler Lichttransport bleiben nicht verfügbar.",
"render.check_media_scope": "Aktive innere Streuung benötigt ein geschlossenes Glasmedium ohne gerade Schattenübertragung und ohne Rauschreduzierung. Die GPU benötigt außerdem eine Fresnel-Schicht. Aktive äußere Atmosphäre kann nicht mit Glas oder Oberflächen-Rauschreduzierung kombiniert werden.",
"render.check_opaque": "Transparenz: nur undurchsichtige Oberflächen; Hintergrund-Alpha wird unterstützt.",
"render.check_sorted_alpha": "Transparenz: Näherung mit sortiertem Alpha.",
"render.check_transmission": "Transparenz: physikalische Transmission.",
@@ -2905,7 +2957,7 @@
"render.children.copy_request": "Anfrage-JSON kopieren",
"render.children.coverage_error": "Abdeckungsfehler",
"render.children.cpu": "CPU-Raytracing",
"render.children.denoise": "Rauschen im fertigen Bild reduzieren",
"render.children.denoise": "Bildrauschen reduzieren",
"render.children.depth": "Pfadtiefe",
"render.children.distortion": "Verzeichnung",
"render.children.edge_samples": "Kanten-Samples",
@@ -2968,6 +3020,14 @@
"render.choose_look": "Zuerst einen Look in der Bibliothek wählen",
"render.chromatic_offset": "Chromatischer Kantenversatz",
"render.clear_stage": "Remove saved stage",
"render.coat_discard": "Klarlackänderung verwerfen",
"render.coat_pending_edit": "Diese Klarlackänderung ist ungespeichert. Mit der Aufnahme speichern oder verwerfen.",
"render.coat_remove": "Gespeicherten Klarlack entfernen",
"render.coat_remove_note": "Entfernt den Klarlack dieses Ziels aus der Aufnahme. Geometrie, Aussehen und physikalische Materialien bleiben unverändert.",
"render.coat_remove_title": "Gespeicherten Klarlack entfernen?",
"render.coat_revalidate_saved": "Der gespeicherte Klarlack wurde geändert. Diese Änderung verwerfen und das Ziel vor der nächsten Bearbeitung erneut öffnen.",
"render.coat_save": "Klarlack in Aufnahme speichern",
"render.coat_saved_help": "Klarlack fügt undurchsichtigen Flächen eine glänzende Schicht hinzu. Mit der Aufnahme speichern. Glas kann nicht dasselbe Ziel verwenden. Auf der CPU dürfen auch benutzerdefinierte Reflexions- und Anisotropieüberschreibungen dieses Ziel nicht verwenden.",
"render.compare_divider": "Vergleichstrenner",
"render.compare_live": "Live",
"render.compare_live_preview": "Live-Vorschau",
@@ -3001,10 +3061,18 @@
"render.cpu_budget_triangles": "Dreiecksobergrenze",
"render.cpu_budget_workstation": "Workstation (komplexe Szenen)",
"render.cpu_captured_hdr": "Für dieses Bild erfasstes HDR",
"render.cpu_celestial_adopt": "CPU-Raytracing mit indirekter Beleuchtung · Pfadtiefe {depth} · {spp} SPP · verfolgte Schatten · Seed {seed} · 1 Primärsample",
"render.cpu_celestial_adopt_label": "CPU-Raytracing mit indirekter Beleuchtung",
"render.cpu_celestial_path_required": "Endliche Sonne und Verlaufshimmel verwenden indirekte CPU-Beleuchtung.",
"render.cpu_celestial_switch": "Wähle CPU-Raytracing mit indirekter Beleuchtung, um diese Sonne oder diesen Himmel beizubehalten.",
"render.cpu_celestial_unportable": "Behalte GPU-Pathtracing für die aktuellen Transmissions-Sample-Einstellungen bei.",
"render.cpu_check_capture_help": "Prüfen und Starten verifizieren dieselbe Datei-Prüfsumme oder verwenden das eingebettete HDR. Jedes Bild behält seine erfasste Quelle.",
"render.cpu_check_changed": "Die Einstellungen oder das Modell haben sich nach dieser Prüfung geändert. Prüfen Sie erneut, bevor Sie rendern.",
"render.cpu_constant_direct_help": "Ohne indirekte Beleuchtung kann diese konstante Quelle in Reflexionen und durch Glas sichtbar sein. Sie beleuchtet keine undurchsichtigen Flächen. Schalte indirekte Beleuchtung ein, um sie als Szenenlicht zu nutzen.",
"render.cpu_constant_enable_indirect_plan": "Pfadtiefe {depth}; progressives Sampling mit {spp} Samples/Pixel (Seed {seed}); ein Primärstrahl pro Sample; Raytracing-Schatten. Bildgröße, CPU-Budget und andere Einstellungen bleiben erhalten.",
"render.cpu_constant_help": "Konstante Strahldichte ist für reflektierte oder durchgelassene Strahlen sichtbar. Sie liefert keine direkte diffuse Beleuchtung.",
"render.cpu_custom_directions": "Vorgabe durch eigene Richtungslichter ersetzen",
"render.cpu_denoise_help": "Verringert das Rauschen bei jeder Bildaktualisierung. Feine Details und Übergänge der Tiefenschärfe können weicher werden. Die EXR-Rohdaten bleiben unverändert. Nachträgliche Anpassungen eines fertigen Bildes verwenden dessen Rohbild und wenden die Rauschreduzierung nicht erneut an.",
"render.cpu_direction": "Richtung zum Licht (X, Y, Z)",
"render.cpu_directional": "Richtungslicht",
"render.cpu_directional_add": "Richtungslicht hinzufügen",
@@ -3016,8 +3084,11 @@
"render.cpu_environment_samples": "HDR-Beleuchtungs-Samples",
"render.cpu_exposure_help": "Die CPU-Belichtung muss zwischen −16 und 16 EV liegen. Eine GPU-Belichtung außerhalb dieses Bereichs bleibt erhalten, bis Sie sie ändern.",
"render.cpu_fixed": "Einzelbild",
"render.cpu_fixed_retained": "Feste Abtastung beibehalten",
"render.cpu_glass_enable_path": "Indirekte Beleuchtung einschalten",
"render.cpu_hdr_help": "Radiance-HDR-Datei (.hdr), Panorama im Format 2:1, bis zu 2048 × 1024 Pixel und 16 MiB. Für die CPU müssen die sRGB/D65-Primärfarben ausdrücklich in der Datei angegeben sein. Dateien ohne Angabe der Primärfarben sowie EXR-Dateien werden abgelehnt.",
"render.cpu_hdr_reflection_help": "Raue HDR-Reflexionen erfordern vollständig metallische Körper- und Flächenoberflächen. Die Prüfung meldet nicht unterstützte Kombinationen.",
"render.cpu_indirect_method_required": "Wähle CPU-Raytracing, um diesen Modus für indirekte Beleuchtung einzuschalten.",
"render.cpu_key_rim": "Haupt- und Konturlicht",
"render.cpu_last_check": "Letzte CPU-Prüfung",
"render.cpu_light_help": "Bis zu acht Richtungslichter. Eine leere eigene Liste schaltet sie aus; die Wahl einer Vorgabe stellt deren Lichter wieder her. Farben sind lineares RGB, und die Intensität ist ein relativer Wert, keine Leistungsangabe in Watt.",
@@ -3029,18 +3100,32 @@
"render.cpu_lighting": "Beleuchtungsvorgabe",
"render.cpu_lights": "CPU-Lichter",
"render.cpu_linear_color": "Lineare Farbe (R, G, B)",
"render.cpu_maps_saved_help": "CPU-Renderings können gespeicherte Karten undurchsichtiger Flächen verwenden: Oberfläche wählen und die Karten in der Aufnahme speichern. Mit indirekter Beleuchtung darf ein anderer Körper Glas verwenden; der Körper mit Karten darf kein Glas sein. Ausgewählte Reflexionen, Anisotropie und prozedurale Effekte ausschalten. Temporäre Kartendateipfade verwenden die GPU.",
"render.cpu_material_certification": "Die CPU-Prüfung validiert unterstützte Glasgeometrie vor dem Rendern. Geschlossenes Glas verwendet geometrische Abstände.",
"render.cpu_neutral": "Neutral",
"render.cpu_opaque_hint": "CPU-Raytracing verwendet einen undurchsichtigen Hintergrund.",
"render.cpu_opaque_required": "CPU-Raytracing benötigt einen undurchsichtigen Hintergrund. Entfernen Sie das Häkchen bei „Transparenter Hintergrund“, um fortzufahren.",
"render.cpu_options_pending": "Dieses CPU-Bedienfeld kann Emission, Oberflächeneffekte, Glas oder Anisotropie noch nicht bearbeiten. Entfernen Sie diese API-Optionen, bevor Sie es verwenden.",
"render.cpu_path_depth": "Pfadtiefe",
"render.cpu_path_help": "Berechnet Licht, das zwischen undurchsichtigen Flächen und durch unterstützte Glasformen läuft. Erfordert progressives Rendern und Raytracing-Schatten. Die Pfadtiefe zählt beleuchtete Flächen, Glasgrenzen und Streuereignisse ab dem ersten Treffer (1–16). Tiefe 1 liefert direkte Beleuchtung auf undurchsichtigen Flächen; höhere Werte erlauben mehr Reflexionen oder Glasdurchgänge und können länger dauern. Für eigene Reflexionen oder Anisotropie die indirekte Beleuchtung ausschalten.",
"render.cpu_path_lighting": "Indirekte Beleuchtung",
"render.cpu_path_optics_reason": "Die Einstellungen bleiben erhalten. Für eigene Reflexionen oder Anisotropie die indirekte Beleuchtung ausschalten.",
"render.cpu_point": "Punktlicht",
"render.cpu_point_add": "Punktlicht hinzufügen",
"render.cpu_point_help": "Bis zu acht Punktlichter, zusätzlich zur gewählten Richtungsbeleuchtung. Abstände in mm; der Mindestabstand darf den Referenzabstand nicht überschreiten.",
"render.cpu_primary_samples": "Kanten-Samples pro Pixel",
"render.cpu_progressive": "CPU progressiv",
"render.cpu_progressive_help": "Den vorhandenen CPU-Integrator in Durchgängen mit je einem Primär-Sample akkumulieren. Ausgewählte Reflexionen erfordern ein Sample; Flächenlichter, Dielektrika und Anisotropie erfordern „Einzelbild“.",
"render.cpu_progressive_adopt": "Progressive Abtastung verwenden",
"render.cpu_progressive_adopt_help": "Die progressive Abtastung verwendet ein Kamerasample pro Durchlauf. Bildgröße, Lichtquellen und Reflexionseinstellungen bleiben unverändert.",
"render.cpu_progressive_always": "Progressive Abtastung ist immer aktiv",
"render.cpu_progressive_help": "Neue CPU-Kamerasamples sammeln und jeden vollständigen Durchgang zeigen. Ausgewählte Reflexionen verwenden einen Versuch pro Kamerasample; zertifiziertes Glas und planare Anisotropie behalten ihre bisherigen Grenzen. Adaptive Pilot- und Produktionssamples teilen sich das CPU-Profilbudget.",
"render.cpu_progressive_limits": "Höchstens 1.048.576 Pixel und 16.777.216 Auswertungen von Pixel × Sample. Das Ändern der Samples passt die Bildgröße nie automatisch an.",
"render.cpu_progressive_reflection_adopt": "Setzen Sie die Reflexionsversuche ausgewählter Körper auf 1, bevor Sie die progressive Abtastung übernehmen.",
"render.cpu_progressive_seed": "Startwert für das CPU-Sampling",
"render.cpu_progressive_spp": "Samples pro Pixel",
"render.cpu_quality_budget_help": "Ändert Größe, Obergrenze der Produktionssamples, Startwert und CPU-Ressourcenprofil. Kamera, Objektiv, Beleuchtung, GI-Tiefe und Effekte bleiben erhalten.",
"render.cpu_quality_progressive_required": "Aktiviere unten progressive Samples, bevor du ein CPU-Budget auswählst.",
"render.cpu_quality_summary": "{width} × {height} · Sample-Grenze {samples} · Startwert {seed} · {profile}",
"render.cpu_reflection_add": "Raue Reflexion hinzufügen",
"render.cpu_reflection_depth": "Reflexionstiefe (1–8)",
"render.cpu_reflection_empty": "Keine Körper ausgewählt: Szenenreflexionen sind aus.",
@@ -3054,7 +3139,21 @@
"render.cpu_runtime_help": "Numerische Licht- und Umgebungseinstellungen werden mit dem Setup gespeichert. Körperreflexionen und HDR-Dateien bleiben vorübergehend. Vorhandene Werte für Flächenlicht und Spotkegel bleiben erhalten.",
"render.cpu_sampling_mode": "CPU-Sampling-Modus",
"render.cpu_shadows": "Schatten werfen",
"render.cpu_sky_bounds": "Die Himmelsrotation muss endlich innerhalb ±1.000.000 Radiant liegen; die Intensität muss 0–100 sein.",
"render.cpu_studio_enable": "Indirekte Beleuchtung für dieses Lichtset aktivieren",
"render.cpu_studio_enable_apply": "Indirekte Beleuchtung aktivieren und anwenden",
"render.cpu_studio_indirect_help": "Dieses Sonnen-Lichtset benötigt indirekte Beleuchtung mit Tiefe 8, verfolgte Schatten und eine Kamerastichprobe pro Durchlauf. Ist progressives Rendern ausgeschaltet, beginnt es mit 16 Stichproben pro Pixel und Startwert 0. Vorhandene progressive Einstellungen und das Ressourcenprofil bleiben erhalten. Unvereinbare Einstellungen verhindern die Anwendung, ohne sie zu ändern.",
"render.cpu_studio_radiance_limit": "Dieses Lichtset hat eine Flächenstrahldichte von {radiance}, über der CPU-Grenze von 64. Die vorgegebene Beleuchtung bleibt erhalten; wählen Sie ein anderes Lichtset oder GPU.",
"render.cpu_subsurface_denoise_reason": "Streuung im Glas auf der CPU benötigt ausgeschaltete Rauschreduzierung. Die Einstellungen bleiben erhalten.",
"render.cpu_subsurface_help": "Streuung lenkt Licht im Inneren geschlossenen Glases um. Positive Raten benötigen indirekte Beleuchtung ohne Rauschreduzierung. Die Rate zählt Kollisionen pro mm; 0 fügt keine Streuung hinzu. Positive Richtungswerte bevorzugen vorwärts, negative rückwärts.",
"render.cpu_summary": "CPU-Raytracing · Reflexionen · HDR · Richtungs- und Punktlichter",
"render.cpu_transparent_help": "Transparentes PNG entfernt den Hintergrund und erhält die Kantenabdeckung. Körper- und Flächendeckkraft müssen 100 % bleiben.",
"render.cpu_volume_enable_path": "Indirekte Beleuchtung einschalten",
"render.cpu_volume_enable_path_plan": "CPU-Raytracing · Pfadtiefe {depth} · {spp} Samples/Pixel · Seed {seed} · Raytracing-Schatten. Atmosphäre und Bildbudget bleiben erhalten.",
"render.cpu_volume_help": "CPU-Atmosphäre nutzt indirekte Beleuchtung. Bei Extinktion null bleibt die gespeicherte Box ohne Nebel erhalten.",
"render.cpu_volume_method_adopt": "CPU-Raytracing mit indirekter Beleuchtung",
"render.cpu_volume_method_incompatible": "Diese Licht-, Sampling- oder Atmosphäreinstellungen lassen sich nicht auf diese Methode übertragen. Die Einstellungen bleiben erhalten.",
"render.cpu_volume_path_required": "Indirekte Beleuchtung einschalten, um CPU-Atmosphäre zu nutzen.",
"render.cpu_warm_cool": "Warm und kühl",
"render.diag_bodies": "Sichtbare Körper",
"render.diag_job_details": "Erfasste Auftragsdetails",
@@ -3064,6 +3163,7 @@
"render.diag_preflight_details": "Details der Vorabprüfung",
"render.diag_request": "Aktuelle Anfrage im Wortlaut",
"render.diag_triangles": "Dreiecke",
"render.directional_sun_limit": "Die GPU unterstützt insgesamt acht gerichtete Lichtquellen, darunter höchstens eine weiche Sonne. Dupliziere ein hartes gerichtetes Licht für eine weitere entfernte Quelle.",
"render.discard_continue": "Entwurf verwerfen und fortfahren",
"render.draft_confirm_help": "Der aktuelle Render-Entwurf enthält nicht angewendete Änderungen. Speichern Sie sie vor dem Wechsel, verwerfen Sie sie ausdrücklich oder bearbeiten Sie weiter.",
"render.draft_confirm_title": "Bearbeitete Rendereinstellungen beibehalten",
@@ -3213,7 +3313,7 @@
"render.export_display": "Export: Anzeige-PNG",
"render.export_image_label": "Bild exportieren",
"render.export_linear": "Export: lineares Roh-EXR",
"render.exr_pending": "Die GPU kann rohe lineare EXR-Dateien behalten. Kompression, mehrschichtige Bilder und zusätzliche Renderpässe bleiben nicht verfügbar.",
"render.exr_pending": "CPU-Raytracing und GPU-Pathtracing können rohe lineare EXR-Dateien behalten. Kompression, mehrschichtige Bilder und zusätzliche Renderpässe bleiben nicht verfügbar.",
"render.film_grain": "Fotografisches Korn",
"render.fit": "Produkt einpassen",
"render.fixed_camera": "Kamera",
@@ -3253,9 +3353,12 @@
"render.frame_wide": "16:9 · Breitbild",
"render.gpu_depth": "Maximale Pfadtiefe",
"render.gpu_options_help": "Samples, Tiefe, Startwert und Belichtung werden mit diesem Setup gespeichert. Es wird nur die aktive Bildmethode gespeichert.",
"render.gpu_path_crashed": "SolidCraft wurde beim letzten Einrichten auf diesem Grafikadapter beendet, wahrscheinlich im Grafiktreiber, und richtet ihn deshalb nicht von selbst erneut ein. Verwenden Sie stattdessen Raytracing auf der CPU oder versuchen Sie es erneut (nach einem Treiberupdate wird es ebenfalls erneut versucht).",
"render.gpu_path_failed": "GPU-Pathtracer nicht verfügbar",
"render.gpu_path_first_setup": "Das erste Setup auf dieser GPU kann mehrere Minuten dauern; SolidCraft bleibt währenddessen bedienbar. Spätere Starts verwenden die kompilierten Shader erneut, sofern der Treiber sie behält.",
"render.gpu_path_not_started": "Die Einrichtung des GPU-Pfadtracers wurde noch nicht gestartet.",
"render.gpu_path_preparing": "GPU-Pathtracer: Shader werden kompiliert…",
"render.gpu_path_retry": "Erneut versuchen",
"render.gpu_seed": "Zufallsstartwert",
"render.gpu_unavailable": "GPU-Pathtracing ist auf diesem Gerät nicht bereit.",
"render.ground_apply": "Boden anpassen und speichern",
@@ -3449,6 +3552,8 @@
"render.light_product_centre": "Produktmitte",
"render.light_size": "Größe",
"render.light_softness": "Weichheit",
"render.light_space_world": "Lichter in Weltkoordinaten",
"render.light_space_world_help": "Lichtpositionen und -richtungen verwenden Weltkoordinaten. Beim Umkreisen mit der Kamera bleiben die Lichter an ihrer Position.",
"render.light_temperature": "Farbtemperatur",
"render.light_temperature_help": "Angenäherte Schwarzkörperfarbe; als lineares RGB gespeichert.",
"render.light_type": "Typ",
@@ -3505,6 +3610,25 @@
"render.linear_help": "Behält ein lineares 32-Bit-Gleitkomma-RGBA-Rohbild vor Entrauschen, Belichtung, Tone-Mapping, Filtern und Hintergrund. Lineares sRGB/D65; Abdeckung vormultipliziert. Zusätzlicher Speicher wird ausdrücklich zugelassen. Keine Komprimierung und keine zusätzlichen Durchgänge.",
"render.linear_retain": "Lineares Roh-EXR behalten",
"render.linear_unavailable": "Für das gewählte fertige Bild wurde kein EXR behalten. Aktivieren Sie das Behalten vor dem Rendern; ein älteres PNG kann die rohe Strahldichte nicht wiederherstellen.",
"render.live.adaptive": "Live · {width} × {height} · {progress}",
"render.live.final_changed": "Die Render-Anfrage hat sich beim Anhalten des Live-Renderings geändert. Starten Sie das finale Bild erneut.",
"render.live.final_waiting": "Finales Bild vorbereiten · Live-Render-Ressourcen freigeben",
"render.live.invalid_size": "Live-Rendering benötigt eine positive Ausgabebreite und -höhe.",
"render.live.pause": "Live pausieren",
"render.live.paused": "Live · Pausiert",
"render.live.paused_outdated": "Live · Pausiert · Veraltet",
"render.live.quality_help": "Interaktive Qualität: höchstens 512 Pixel an der längsten Kante. Verwendet den gewählten Renderer und seine unterstützten Effekte. Endauflösung, Einstellungen und Export bleiben getrennt.",
"render.live.refused": "Live nicht verfügbar: {reason}",
"render.live.resume": "Live fortsetzen",
"render.live.samples": "Live · {width} × {height} · {current}/{target} Samples",
"render.live.source_current": "{method} · Live",
"render.live.source_outdated": "{method} · Vorheriges Bild",
"render.live.source_outdated_help": "Zeigt das zuletzt aufgenommene Bild, während das Live-Rendering aktualisiert wird oder nicht verfügbar ist. Es kann von der aktuellen Szene oder den Bildeinstellungen abweichen.",
"render.live.source_settings_help": "Ausgabe öffnen, um Renderer und Bildeinstellungen zu bearbeiten.",
"render.live.stopped": "Live-Rendering angehalten",
"render.live.updating": "Live · Interaktive Qualität (bis 512 px) · Aktualisierung",
"render.live.waiting_canvas": "Warten auf die Render-Ansicht",
"render.live.worker_stopped": "Der Aufnahmeprozess für das Live-Rendering wurde vor der Rückgabe eines Ergebnisses beendet",
"render.look_add": "Farblook hinzufügen",
"render.look_applied": "{name} angewendet",
"render.look_bleach": "Von Bleach-Bypass inspiriert",
@@ -3534,6 +3658,9 @@
"render.material_coat_help": "Illustrative dielektrische Beschichtung über dem vorhandenen undurchsichtigen Erscheinungsbild. Gewicht null lässt das Grundmaterial unverändert.",
"render.material_color_map": "Grundfarbentextur (sRGB)",
"render.material_color_replace": "Basismap ersetzt Körperfarbe",
"render.material_cpu_glass_only": "Temporäre Bilddatei-Karten benötigen die GPU. Die CPU kann gespeicherte integrierte Oberflächenkarten verwenden.",
"render.material_cpu_optics_limits": "Indirekte Beleuchtung für unterstützte geschlossene Glasformen, geprüfte Verschachtelung und Streuung im Inneren einschalten. Dünne Scheiben können Licht für Schatten durchlassen. Sonst eine ebene Scheibe oder achsenparallele Box verwenden. Berührende Formen und durch geschlossenes Glas übertragene Schatten werden weiterhin nicht unterstützt. Die Werte bleiben erhalten.",
"render.material_cpu_saved_runtime_conflict": "Gespeicherte CPU-Optik kann nicht mit temporären optischen Bindungen kombiniert werden. Geprüfte Optik speichern oder die temporäre Bindung entfernen.",
"render.material_custom": "Eigene illustrative Optik",
"render.material_enable": "Diese GPU-Materialüberschreibungen verwenden",
"render.material_filter": "Szenenkörper filtern…",
@@ -3554,6 +3681,7 @@
"render.material_normal_map": "Normalenbild (OpenGL, +Y)",
"render.material_normal_strength": "Normalenstärke (0–1)",
"render.material_origin": "Texturursprung X · Y · Z (mm)",
"render.material_raytrace_required": "Physikalisches Glas benötigt CPU-Raytracing oder GPU-Pathtracing.",
"render.material_refresh": "Szenenkörper aktualisieren",
"render.material_refresh_needed": "Die Szene hat sich geändert. Aktualisieren Sie die Körperliste, bevor Sie ein Ziel hinzufügen.",
"render.material_remove": "Diese Zuweisung entfernen",
@@ -3563,6 +3691,7 @@
"render.material_revalidate_needed": "Diese Zuweisung gehört zu einer früheren Szenenrevision. Prüfen Sie die genaue Körperidentität erneut, bevor Sie rendern.",
"render.material_roughness": "Rauheit des Klarlacks (0,045–1)",
"render.material_roughness_map": "Rauheitsmap (linear)",
"render.material_saved_edit_pending": "Diese Optikänderungen sind ungespeichert. Geprüfte Werte speichern, die gespeicherte Zuordnung entfernen oder Änderungen verwerfen. Temporäre Bindungen und nicht unterstützte Effekte können das gespeicherte Ziel nicht überschreiben.",
"render.material_shadow": "Gerade Schattenstrahlen durchlassen",
"render.material_target_missing": "Ordnungsnummer und Name des Körpers stimmen nicht mehr überein. Entfernen Sie diese Zuweisung und wählen Sie den gewünschten Körper ausdrücklich erneut.",
"render.material_thickness": "Dicke der dünnen Schicht (mm)",
@@ -3574,7 +3703,7 @@
"render.method_cpu": "CPU-Raytracing",
"render.method_gpu": "GPU-Pathtracing",
"render.method_limit_raster": "Die Rasterung verwendet die feste undurchsichtige Studiovorschau. Wählen Sie ausdrücklich eine Raytracing-Methode für die unterstützten optischen Einstellungen.",
"render.method_raster": "CPU-Rasterung",
"render.method_raster": "CPU-Rasterung (schematisch)",
"render.navigation_help": "Die Kamera bewegt sich wie im Konstruieren (Einstellungen › Navigation). Setup speichern übernimmt die aktuelle Kamera.",
"render.nested_enable": "Zertifizierte verschachtelte Dielektrika",
"render.nested_help": "Bis zu 16 umschließende Schichten; jedes übergeordnete Element muss konvex und jedes untergeordnete strikt darin enthalten sein. Überschneidungen, sich berührende Flächen, dünne Schichten und die Transmission gerader Schattenstrahlen werden abgelehnt. Der Zustand „Kamera im Inneren“ folgt der zertifizierten Hierarchie. Erzeugt keine Flüssigkeitsoberfläche und verschmilzt keine sich berührenden CAD-Grenzflächen.",
@@ -3602,16 +3731,19 @@
"render.output_exr_unavailable": "EXR-Ausgabeeinstellungen sind hier nicht verfügbar. Linearer EXR-Export erfordert ein fertiges GPU-Bild mit gespeichertem linearem Original.",
"render.output_fit": "Einpassen",
"render.output_format": "Format",
"render.output_format_status": "PNG enthält das exakte fertige Anzeigebild. Optionales GPU-EXR bewahrt rohe lineare FLOAT32-Strahldichte vor Filtern. Export ersetzt nie durch Vorschaucache oder anderen Auftrag.",
"render.output_format_status": "PNG enthält das exakte fertige Anzeigebild. Optionales CPU/GPU-EXR bewahrt rohe lineare FLOAT32-Strahldichte vor Anzeigefiltern. Der Export verwendet nie den Vorschaucache oder einen anderen Auftrag.",
"render.output_grading": "Farbkorrektur",
"render.output_image": "Bild",
"render.output_largest": "{width} × {height} max.",
"render.output_lock_ratio": "Seitenverhältnis sperren",
"render.output_master_unavailable": "Zum EXR-Export ein fertiges Bild mit EXR-Rohdaten auswählen. EXR-Rohdaten vor dem Rendern neuer Bilder aktivieren.",
"render.output_over_budget": "Das angeforderte Bild überschreitet den gewählten Ressourcenrahmen. Wählen Sie ausdrücklich eine passende Größe oder ein passendes Profil; es erfolgt keine automatische Herabstufung.",
"render.output_presentation_limits": "CPU-Bildeffekte und EXR-Ausgabe unterstützen Bilder mit bis zu 8192 Pixeln Breite oder Höhe und insgesamt höchstens 16.777.216 Pixeln.",
"render.output_presets_help": "Format, Qualität und Prüfliste befinden sich im Ausgabeeditor.",
"render.output_quality": "Qualität",
"render.output_quality_waiting": "Qualitätsdefinition steht noch aus",
"render.output_ratio_refused": "Die verknüpfte Abmessung überschreitet das Größenlimit dieser Methode. Die vorherige Größe bleibt erhalten; entsperren Sie das Verhältnis oder wählen Sie einen kleineren Wert.",
"render.output_raytrace_only": "Bildeffekte erfordern CPU-Raytracing oder GPU-Pathtracing.",
"render.output_ready": "Bereit zum Rendern",
"render.output_run_check": "Prüfung starten",
"render.output_size_limit": "{method}: bis zu {dimension} px pro Seite und {pixels} Pixel. Größte passende Größe: {width} × {height}.",
@@ -3666,6 +3798,12 @@
"render.post_saturation": "Sättigung",
"render.post_title": "Darstellungsfilter",
"render.post_vignette": "Vignettierung",
"render.preparation_cancelled": "Rendering-Vorbereitung abgebrochen",
"render.preparation_checking": "Rendering wird geprüft",
"render.preparation_retirement_failed": "Die Bereinigung der Rendering-Vorbereitung ist fehlgeschlagen. Dieses Fenster kann kein weiteres Bild vorbereiten.",
"render.preparation_retiring": "Rendering-Vorbereitung wird abgeschlossen",
"render.preparation_unavailable": "Rendering-Vorbereitung nicht verfügbar",
"render.presentation_banner": "Bildeffekte funktionieren mit CPU- und GPU-Raytracing. Das Öffnen dieser Seite ändert die Methode nicht.",
"render.preview": "Integrierte Vorschau",
"render.preview_help": "Rasterdarstellung mit gemischter Transparenz. Brechung, HDRI und finales Rendering sind noch nicht verfügbar.",
"render.preview_image_settings_help": "Die Live-Vorschau behält eine feste Studiobeleuchtung. Belichtung und Bildbeleuchtung gelten für den Bildauftrag, wenn Sie „Prüfen“ oder „Start“ verwenden.",
@@ -3684,11 +3822,15 @@
"render.quality_ack": "Die Projektion mit festem Budget und ihre Auslassungen bestätigen",
"render.quality_apply": "Dieses feste Sampling-Budget verwenden",
"render.quality_balanced": "Ausgewogen",
"render.quality_budget_help": "Legt Größe, Samples, Tiefe, Startwert und Rauschreduzierung fest. Andere Bildeinstellungen bleiben erhalten.",
"render.quality_custom": "Benutzerdefiniert",
"render.quality_denoise_off": "Rauschreduzierung: Aus",
"render.quality_denoise_on": "Rauschreduzierung: Ein",
"render.quality_draft": "Entwurf",
"render.quality_high": "Hoch",
"render.quality_projection_help": "Q-Vorgaben enthalten künftige Anforderungen. Diese Aktion projiziert nur ein unterstütztes PNG-Budget mit festen Samples und lässt adaptives Stoppen, rohe lineare Ausgabe und unabhängige Transmissionslimits ausdrücklich aus.",
"render.quality_projection_help": "Diese Prüfung ändert nur Auflösung, Abtastbudget, Pfadtiefe, Startwert und Rauschreduzierung. Andere Einstellungen bleiben erhalten; das Budget garantiert keine Konvergenz.",
"render.quality_review": "Sampling-Budget prüfen",
"render.quality_summary": "{width} × {height} · {samples} Samples · Tiefe {depth} · Startwert {seed} · {denoise}",
"render.queue_pending": "In dieser Oberfläche gibt es keine automatische Produktionswarteschlange und keinen Animations-Renderer. Fertige Einzelbilder bleiben separat exportierbar.",
"render.radial_assign": "Erscheinungsbild zuweisen",
"render.radial_compare": "Vergleichen",
@@ -3718,7 +3860,7 @@
"render.result_navigation": "Verschieben und Zoomen folgen den Navigationseinstellungen. Leertaste + Ziehen verschiebt.",
"render.result_open": "Das fertige Bild in der Hauptansicht öffnen.",
"render.result_preview": "Live-Vorschau",
"render.result_provisional": "Vorläufiges CPU-Bild",
"render.result_provisional": "Vorläufiges Bild",
"render.result_provisional_help": "Dieser Zwischenstand ist nicht exportierbar. Der Export behält das letzte fertige Ergebnis; ein Abbruch kehrt zu diesem Ergebnis zurück.",
"render.result_reduced_display": "Anzeigevorschau auf die Geräte-/Cachegrenze reduziert. PNG und Export behalten die volle Auflösung; native 100%-Ansicht ist hier nicht verfügbar.",
"render.result_retained": "Das zuletzt fertiggestellte Bild wird angezeigt; ein neueres Rendering hat es noch nicht ersetzt.",
@@ -3807,9 +3949,11 @@
"render.shot_frame_subject": "Motiv einpassen",
"render.shot_invalid_camera": "Kamerawerte müssen endlich und im gültigen Bereich sein.",
"render.shot_lens": "Objektiv",
"render.shot_lens_cpu_progressive_reason": "Vor der CPU-Schärfentiefe unter Sampling die progressive Abtastung übernehmen.",
"render.shot_lens_enable_reason": "Schärfentiefe aktivieren, um Blende und Fokusabstand zu bearbeiten.",
"render.shot_lens_gpu_reason": "Schärfentiefe benötigt GPU-Pfadverfolgung.",
"render.shot_lens_perspective_reason": "Schärfentiefe benötigt perspektivische Projektion.",
"render.shot_lens_raytrace_reason": "Schärfentiefe ist für CPU-Raytracing und GPU-Pathtracing verfügbar.",
"render.shot_look_selection": "Auswahl anvisieren",
"render.shot_manager": "Gespeicherte Aufnahmen",
"render.shot_new_name": "Neuer Aufnahmename",
@@ -3843,7 +3987,12 @@
"render.sky_horizon": "Horizont · lineares RGB",
"render.sky_zenith": "Zenit · lineares RGB",
"render.soft_focus": "Weichzeichnung",
"render.spot_axis": "Abstrahlrichtung",
"render.spot_cone": "Spotkegel",
"render.spot_gpu_help": "Die Winkel werden von der Abstrahlrichtung aus gemessen. Zwischen innerem und äußerem Winkel wird das Licht schwächer. Sehr schmale Übergänge erfordern CPU-Rendering.",
"render.spot_help": "Die Winkel werden von der Abstrahlrichtung aus gemessen. Zwischen innerem und äußerem Winkel wird das Licht schwächer.",
"render.spot_inner": "Innerer Halbwinkel",
"render.spot_outer": "Äußerer Halbwinkel",
"render.stage_frame": "Bildausschnitt",
"render.stage_layout.appearance_note": "Zum Anwenden auf die Auswahl klicken.",
"render.stage_layout.studio_note": "Vor dem Anwenden zur Prüfung klicken.",
@@ -3869,6 +4018,7 @@
"render.stage_panels.extent": "Ausdehnung",
"render.stage_panels.fit_save": "Einpassen und speichern",
"render.stage_panels.gpu_required": "Benötigt GPU-Pfadverfolgung",
"render.stage_panels.grain_angle": "Maserungswinkel",
"render.stage_panels.keep": "Behalten",
"render.stage_panels.kept": "Beibehalten",
"render.stage_panels.kept_fields": "Kamera, Abtastung, Abmessungen, optische Bindungen, CAD-Geometrie und technische Eigenschaften.",
@@ -3882,7 +4032,7 @@
"render.stage_panels.prepare_plan": "Erstellungsplan vorbereiten",
"render.stage_panels.projects": "Projiziert",
"render.stage_panels.quality_ack": "Ich bestätige das feste Budget und seine Auslassungen",
"render.stage_panels.quality_omissions": "Adaptives Stoppen und Fehlerziele, separate Transmissionstiefe, Russian-Roulette-Regeln, lineare Rohausgabe, EXR-Master und Konvergenznachweis.",
"render.stage_panels.quality_omissions": "Dieses Budget legt keine adaptiven Fehlerziele, eigenen Transmissionsgrenzen, Russian-Roulette-Regeln oder Speicherung des linearen Masters fest und garantiert keine Konvergenz.",
"render.stage_panels.reference": "Referenz",
"render.stage_panels.references": "Referenzen",
"render.stage_panels.remove_optics": "Optik entfernen",
@@ -3926,7 +4076,7 @@
"render.stage_panels.subtitle_environment": "Originale HDR-Umgebung · gesamte Szene",
"render.stage_panels.subtitle_maps": "Erstellte räumliche Texturen · temporäre Vorschau",
"render.stage_panels.subtitle_physical": "Schreibgeschützter Plan · kein Material erstellt oder zugewiesen",
"render.stage_panels.subtitle_quality": "Festes GPU-Budget · zukünftige Anforderungen ausgelassen",
"render.stage_panels.subtitle_quality": "GPU-Abtastbudget · nur aufgeführte Einstellungen",
"render.stage_panels.subtitle_rig": "Erstellter Lichtaufbau · Lichtquellen und passive Flächen",
"render.stage_panels.subtitle_saved_maps": "Erstellte Texturen · mit dieser Aufnahme gespeichert",
"render.stage_panels.subtitle_stage": "Physischer Boden und Bühne · an das Produkt angepasst",
@@ -3951,7 +4101,7 @@
"render.studio_area_help": "Bis zu vier einseitige Rechtecke. Orthogonale Halbkanten U und V bestimmen die Größe; U × V zeigt zur beleuchteten Seite. Die Koordinaten sind Weltmillimeter.",
"render.studio_center": "Mittelpunkt X, Y, Z (mm)",
"render.studio_denoise": "Rauschen im fertigen Bild reduzieren",
"render.studio_denoise_help": "Abschließender kantenerhaltender Filter; feine Details können weicher werden. Glas und scharfe spiegelnde Oberflächen umgehen den Filter.",
"render.studio_denoise_help": "Kantenerhaltender Filter für progressive und fertige Bilder; feine Details können weicher werden. Glas und scharfe spiegelnde Oberflächen umgehen den Filter.",
"render.studio_direction": "Richtung zur Sonne (X, Y, Z)",
"render.studio_environment": "Umgebung",
"render.studio_environment_bounds": "Die Umgebungsdrehung muss endlich und innerhalb von ±1.000.000 Radiant liegen, die Intensität zwischen 0 und 100.",
@@ -3987,6 +4137,19 @@
"render.subsurface_enable": "Random Walk für innere Streuung",
"render.subsurface_help": "Nur für geschlossene Dielektrika. Streuung und RGB-Absorption sind getrennte Koeffizienten pro mm. Pfade streuen innerhalb der tatsächlichen Geometrie und werden dann an der Grenzfläche gebrochen oder reflektiert. Deaktivieren Sie die Transmission gerader Schattenstrahlen, das Entrauschen von Oberflächen und das äußere Volumen; verwenden Sie eine Fresnel-Schicht. Kein Diffusions-Shader, kein Dichtegitter und kein gemessenes Material. Dichte Medien können mehr Samples und Pfadtiefe benötigen.",
"render.subsurface_rate": "Streukollisionsrate",
"render.surface_import.author": "Autor",
"render.surface_import.busy": "Bild wird importiert…",
"render.surface_import.complete": "Wählen Sie ein Bild und geben Sie Autor, Quelle und Lizenz an.",
"render.surface_import.embedded": "Importiertes Bild",
"render.surface_import.failed": "Der Bildimport wurde nicht abgeschlossen.",
"render.surface_import.license": "Lizenz / Eigentum",
"render.surface_import.raytrace_required": "Bilder erfordern CPU- oder GPU-Raytracing. Wählen Sie es unter Ausgabe.",
"render.surface_import.repeat_u": "Wiederholung U",
"render.surface_import.repeat_v": "Wiederholung V",
"render.surface_import.save": "Bild am Körper speichern",
"render.surface_import.saved_cpu": "Speichern Sie Texturen für das CPU-Rendering.",
"render.surface_import.source": "Quelle",
"render.surface_import.title": "Bild importieren",
"render.tone_aces_fitted": "ACES angepasst",
"render.tone_reinhard": "Reinhard",
"render.transmission_scope": "GPU-Glas unterstützt zertifizierte getrennte Medien. Unbeschränkt verschachtelte Medien und ein rauschfreier Transmissionsschätzer werden nicht bereitgestellt.",
@@ -4031,11 +4194,15 @@
"render.use_preview": "Integrierte Vorschau verwenden",
"render.volume_albedo": "Streualbedo · lineares RGB",
"render.volume_anisotropy": "Mittlerer Phasenkosinus",
"render.volume_denoise_required": "Für Atmosphäre die Rauschreduzierung ausschalten.",
"render.volume_enable": "Teilnehmendes Medium aktivieren",
"render.volume_extinction": "Extinktionskoeffizient",
"render.volume_glass_required": "Atmosphäre und Glas können in dieser Einstellung nicht kombiniert werden.",
"render.volume_help": "GPU-Transport innerhalb einer endlichen Box, mit Beer-Lambert-Abschwächung und HG-Streuung auf Kamera- und indirekten Pfaden. Verwendet ausdrückliche Studio-, HDR- und Himmelsbeleuchtung einschließlich Schattenabschwächung. Ein positiver Phasenkosinus streut nach vorn. Glasgrenzflächen und Oberflächenentrauschen müssen ausdrücklich deaktiviert werden. Extinktion null lässt den Transport unverändert. Keine physikalische Mondatmosphäre, kein Dichtegitter, keine Volumenemission, kein spektraler Transport und keine zertifizierte Materialeigenschaft.",
"render.volume_lighting_required": "Für Atmosphäre eine Umgebung oder ein eigenes Lichtrig wählen.",
"render.volume_max": "Obere Grenzen · Weltkoordinaten (mm)",
"render.volume_min": "Untere Grenzen · Weltkoordinaten (mm)",
"render.volume_single_stratum_required": "Für Atmosphäre ein Transmissionssample verwenden.",
"render.volume_title": "Begrenztes homogenes Volumen",
"render.wait_add_light": "Noch nicht unterstützt: benannte Lichtquellen erstellen",
"render.wait_batch": "Noch nicht unterstützt: Renderwarteschlange gespeicherter Aufnahmen",
@@ -4063,6 +4230,8 @@
"sample.plate.name": "Beispielplatte",
"sample.vents.about": "Langlöcher in einem Rechteckmuster: Zeilen, Spalten und Abstand sind Parameter",
"sample.vents.name": "Lüftungsplatte",
"scale.per_axis": "Je Achse",
"scale.uniform": "Gleichmäßig",
"search.command_keys": "Eingabe ausführen Umschalt+Eingabe ausführen und offen lassen Esc schließen",
"search.commands": "Befehle",
"search.design": "In diesem Design",
@@ -4705,6 +4874,7 @@
"ui.click_selects_whole_bodies": "Klick wählt ganze Körper aus",
"ui.click_then_press_key": "Klicken, dann die neue Taste drücken",
"ui.close": "Schließen",
"ui.close_tab": "Tab schließen",
"ui.coil": "Schraube",
"ui.coincident": "Koinzident",
"ui.collapse": "Einklappen",
@@ -4920,6 +5090,11 @@
"ui.factor": "Faktor",
"ui.fade": "Auslauf",
"ui.fade_length": "Auslauflänge",
"ui.fair_iterations": "Iterationen",
"ui.fair_keep_edges": "Kanten behalten",
"ui.fair_strength": "Stärke",
"ui.fair_surface_title": "FLÄCHENBEGRADIGUNG",
"ui.fair_tolerance_default": "0,001 der Körpergröße",
"ui.feature_history": "Feature-Verlauf",
"ui.feature_options": "Funktionsoptionen",
"ui.features": "Funktionen",
@@ -5102,6 +5277,7 @@
"ui.inspect": "Prüfen",
"ui.instances": "Instanzen",
"ui.intersect": "Schnitt",
"ui.intersection_curve_second_pick": "Schnittkurve: zweite Fläche oder Kurve anklicken (Rechtsklick zum Neubeginn)",
"ui.isolate": "Isolieren",
"ui.join": "Verbinden",
"ui.joint_play": "▶ Abspielen",
@@ -5129,6 +5305,7 @@
"ui.manipulator_free_alt": "Freies Ziehen des Manipulators auch mit Alt/Option",
"ui.manipulator_free_alt_tip": "An: Alt/Option oder Strg/Cmd auf Extrusions-/Gizmo-Pfeilen deaktiviert das Einrasten von Werten. Aus: nur Strg/Cmd.",
"ui.manufacture": "Fertigung",
"ui.match_surface_title": "FLÄCHENANPASSUNG",
"ui.material_review.axes": "Materiallokale Achsen; keine Körperausrichtung zugewiesen.",
"ui.material_review.claims": "Quellen und Lizenzen sind ungeprüfte Angaben. Fehlende Werte bleiben unbekannt.",
"ui.material_review.compressive_strength": "Druckfestigkeit",
@@ -5151,6 +5328,9 @@
"ui.material_review.title": "Physikalische Materialdaten prüfen",
"ui.material_review.unknown": "Unbekannt",
"ui.material_review.yield_strength": "Streckgrenze",
"ui.mcp_agents": "Agenten über MCP",
"ui.mcp_compact": "Kompakte Ausgabe",
"ui.mcp_compact_note": "Für Agenten, die über den MCP-Server (solidcraft-cli mcp) verbunden sind: inspect_design fasst Skizzen zusammen und Werkzeugergebnisse werden nicht formatiert, sodass sie weniger Tokens verbrauchen. Gilt für Agentensitzungen, die nach der Änderung beginnen; --compact oder SOLIDCRAFT_MCP_COMPACT=1 schalten es ebenfalls ein.",
"ui.measure": "Messen",
"ui.mesh_format_3mf": "3MF",
"ui.mesh_format_3mf_tagged": "3MF mit Flächenmarken",
@@ -5192,6 +5372,7 @@
"ui.options": "Optionen",
"ui.orbit": "Drehen",
"ui.orbit_around": "Umkreisen um",
"ui.organize": "Organisieren",
"ui.orientation": "Ausrichtung",
"ui.origin": "Ursprung",
"ui.palette_construction_mode": "Konstruktionsmodus (X)",
@@ -5199,6 +5380,65 @@
"ui.palette_sketch_options": "Skizzenoptionen",
"ui.pan": "Verschieben",
"ui.pan_zoom_orbit": "Verschieben, zoomen, umkreisen",
"ui.panel_fixture.apply": "Anwenden",
"ui.panel_fixture.cancel": "Abbrechen",
"ui.panel_fixture.caption": "Synthetisches Panelmuster",
"ui.panel_fixture.draft": "Beibehaltener Entwurf",
"ui.panel_fixture.editor": "Mustereditor",
"ui.panel_fixture.explanation": "Dieser synthetische Inhalt behält seinen Entwurf beim Ausblenden oder Verschieben.",
"ui.panel_fixture.fill": "Füllendes Muster",
"ui.panel_fixture.finish": "Abschließen",
"ui.panel_fixture.finished": "Synthetische Transaktion beendet",
"ui.panel_fixture.footer": "Musteraktionen lassen den Entwurf unverändert.",
"ui.panel_fixture.guard": "Abbruch bei laufender Transaktion verweigern",
"ui.panel_fixture.inline": "Einzeiliges Muster",
"ui.panel_fixture.live": "Synthetische Transaktion läuft",
"ui.panel_fixture.log": "Musterprotokoll",
"ui.panel_fixture.pin": "Angeheftetes Muster",
"ui.panel_fixture.title": "{title} · {id}",
"ui.panel_fixture.updates": "Dienstaktualisierungen: {count}",
"ui.panel_host.attention": "Aufmerksamkeit erforderlich",
"ui.panel_host.bottom": "Unten",
"ui.panel_host.close": "Panel schließen",
"ui.panel_host.collapse": "Panel einklappen",
"ui.panel_host.expand": "Panel ausklappen",
"ui.panel_host.floating": "Schwebend",
"ui.panel_host.focus": "Panel fokussieren",
"ui.panel_host.focus_target": "{title} fokussieren",
"ui.panel_host.header": "Panelkopf",
"ui.panel_host.join": "Tabs verbinden",
"ui.panel_host.join_target": "Tabs mit {title} verbinden",
"ui.panel_host.left": "Links",
"ui.panel_host.menu": "Panelaktionen",
"ui.panel_host.move": "Verschieben nach",
"ui.panel_host.overflow": "Weitere Panels",
"ui.panel_host.popout": "In einem Fenster öffnen",
"ui.panel_host.resize": "Panelgröße ändern",
"ui.panel_host.right": "Rechts",
"ui.panel_host.rule.availability": "Dieses Panel ist nicht verfügbar.",
"ui.panel_host.rule.bounds": "Die Panels passen nicht in ihre Größenbegrenzungen.",
"ui.panel_host.rule.capability": "Dieser Host unterstützt diese Aktion nicht.",
"ui.panel_host.rule.collection_limit": "Das Panellayout hat seine Grenze erreicht.",
"ui.panel_host.rule.duplicate_id": "Diese Panelkennung wird bereits verwendet.",
"ui.panel_host.rule.exactly_once": "Jedes Panel muss einmal erscheinen.",
"ui.panel_host.rule.feature_disabled": "Diese Aktion ist für dieses Panel deaktiviert.",
"ui.panel_host.rule.id": "Die Panelkennung ist ungültig.",
"ui.panel_host.rule.incompatible_tabs": "Diese Panels können keine Tabgruppe teilen.",
"ui.panel_host.rule.lifecycle": "Das Panel konnte diese Aktion nicht abschließen.",
"ui.panel_host.rule.missing_target": "Dieses Panelziel ist nicht mehr verfügbar.",
"ui.panel_host.rule.no_space": "Für dieses Panellayout ist kein Platz.",
"ui.panel_host.rule.pin_conflict": "An dieser Kante ist bereits ein anderes Panel angeheftet.",
"ui.panel_host.rule.pin_order": "Ein angeheftetes Panel muss an seiner Kante zuerst bleiben.",
"ui.panel_host.rule.placement": "Dieses Panel kann nicht an diese Position verschoben werden.",
"ui.panel_host.rule.revision": "Das Layout wurde geändert. Versuchen Sie die Aktion erneut.",
"ui.panel_host.rule.schema": "Die Panelanfrage ist ungültig.",
"ui.panel_host.rule.version": "Diese Panellayoutversion wird nicht unterstützt.",
"ui.panel_host.sash": "Benachbarte Panels skalieren",
"ui.panel_host.scroll_horizontal": "Panelspalten scrollen",
"ui.panel_host.scroll_vertical": "Panelstapel scrollen",
"ui.panel_host.surface": "Panel",
"ui.panel_host.tab": "Paneltab",
"ui.panel_host.tabs": "Alle Tabs",
"ui.parallel": "Parallel",
"ui.parameters": "Parameter",
"ui.part_size_needs_a_part": "Wählen Sie ein eingefügtes Standardbauteil (Schraube, Mutter, Scheibe oder Stift) aus, um seine Größe zu ändern. Einen Körper vergrößern oder verkleinern Sie mit Skalieren.",
@@ -5236,6 +5476,7 @@
"ui.point_cloud_add_filter": "Filter hinzufügen…",
"ui.point_cloud_auto_range": "Bereich automatisch",
"ui.point_cloud_color_by": "Färben nach",
"ui.point_cloud_dots": "Einzelpunkte",
"ui.point_cloud_file_colors": "Farben der Datei",
"ui.point_cloud_filter_by": "Filtern nach",
"ui.point_cloud_height_high": "Oberste Höhe",
+4
View File
@@ -134,6 +134,7 @@
"render.aging_metallic": "Layer metallic fraction",
"render.aging_origin": "Field origin · world mm",
"render.aging_patina": "Copper-green patina",
"render.aging_raytrace_help": "Ageing is applied by CPU ray tracing and GPU path tracing. Raster keeps schematic rendering.",
"render.aging_refresh_targets": "Inspect current bodies",
"render.aging_roughness": "Layer roughness",
"render.aging_rust": "Oxidized iron / rust",
@@ -235,6 +236,8 @@
"render.studio_area_help": "Up to four one-sided rectangles. Orthogonal half-edges U and V define size; U × V faces the lit side. Coordinates are world-space millimetres.",
"render.studio_center": "Centre X, Y, Z (mm)",
"render.studio_hdr_help": "Radiance .hdr, 2:1 panorama, up to 2048 × 1024. EXR is unavailable. The colour assumption applies only when primaries are unspecified.",
"render.surface_import.complete": "Choose an image and enter its author, source and licence.",
"render.surface_import.license": "Licence / ownership",
"sketch.hint.arc.center_point": "Arc: click centre and start, go round either way and click the end, or type a signed sweep",
"sketch.hint.circle.center": "Circle: click the centre, then a point on the circle",
"sketch.hint.constraint.midpoint": "MidPoint: pick a point and a line, or two lines to centre the second on the first",
@@ -266,6 +269,7 @@
"ui.layout_compact": "Compact",
"ui.layout_floating": "Floating",
"ui.material_review.claims": "Sources and licences are unverified claims. Missing values remain unknown.",
"ui.organize": "Organise",
"ui.point_cloud_color_by": "Colour By",
"ui.point_cloud_file_colors": "File Colours",
"ui.point_cloud_no_file_colors": "The file has no colours",
+474 -15
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -1392,6 +1392,7 @@
"dialog.computing": "Calculando… {seconds} s",
"dialog.could_not_be_checked": "no se pudo comprobar",
"dialog.custom_axis": "↖ Eje personalizado",
"dialog.cylinder_face_tip": "La cara o el vértice del modelo sobre el que se apoya el cilindro: haz clic aquí y después haz clic en él en la vista. Si se deja vacío, lo colocan X, Y y Z.",
"dialog.dimension_insert_hint": "Haga doble clic en otra cota de control para insertar su nombre",
"dialog.dimension_name": "Cota: {name}",
"dialog.dimension_step_down": "Bajar {step}",
@@ -1400,6 +1401,8 @@
"dialog.edit_title": "EDITAR {title}",
"dialog.no_shown_body": "No hay cuerpo mostrado para cambiar: muestra uno, o elige Nuevo cuerpo",
"dialog.pattern_sketch_not_object": "Selecciona cuerpos o operaciones: un boceto no es ninguno. Repite sus curvas con el Patrón o Espejo del propio boceto, o extrúdelas primero.",
"dialog.primitive_point_tip": "Un punto que rellena X, Y y Z: haz clic aquí y después en un vértice, una arista o una cara del modelo, en un punto de un boceto o en el origen.",
"dialog.reference_edge_tip": "Una arista recta de la cara elegida desde la que se mide la distancia de abajo: haz clic aquí y después en la arista.",
"dialog.select": "↖ Seleccionar",
"dialog.selected_count": "↖ {count} seleccionados",
"dialog.selection_constraint": "Restricción {id}",
@@ -2346,6 +2349,8 @@
"prefs.edges": "Aristas y sombreado",
"prefs.extend": "Extensiones",
"prefs.extra_navigation": "Ctrl + arrastrar con el botón izquierdo sobre un espacio vacío orbita; mantén Espacio para desplazar.",
"prefs.extrude_arrow_face_stops": "La flecha de Extruir se detiene en las caras",
"prefs.extrude_arrow_face_stops_note": "Al arrastrar la flecha de distancia de Extruir, se detiene en las caras planas paralelas al perfil, como el lado opuesto de un bloque que se corta, y resalta la cara. Desactivado: la flecha solo avanza en pasos redondos.",
"prefs.extrude_view_isometric": "Girar la vista a isométrica al abrir Extrusión",
"prefs.extrude_view_return": "Volver a la vista anterior al cerrar Extrusión",
"prefs.follows_os": "Sigue al sistema operativo",
@@ -2470,6 +2475,8 @@
"prefs.mouse": "Ratón y panel táctil",
"prefs.mouse_heading": "Ratón",
"prefs.mouse_mapping": "Asignación del ratón",
"prefs.move_drag_highlights": "Resaltar lo que hay bajo el puntero mientras las flechas de Mover arrastran un componente",
"prefs.move_drag_highlights_tip": "Desactivado: mientras las flechas de Mover arrastran un componente no se resalta nada bajo el puntero, y los ensamblajes grandes se mueven con fluidez. Con un corte o un análisis visible se resalta igualmente, y una pieza arrastrada a lo largo de sus uniones nunca lo resalta.",
"prefs.navigation": "Navegación",
"prefs.navigation.axes_signed": "Ejes",
"prefs.navigation.cube_compact": "Cubo",
@@ -3042,6 +3049,8 @@
"render.cpu_lighting": "Preajuste de iluminación",
"render.cpu_lights": "Luces CPU",
"render.cpu_linear_color": "Color lineal (R, G, B)",
"render.cpu_maps_saved_help": "Los renders CPU pueden usar mapas opacos guardados: elige una superficie y guarda sus mapas en la toma. Con Iluminación indirecta, otro cuerpo puede usar Vidrio; el cuerpo con mapas no puede usar Vidrio. Desactiva los reflejos seleccionados, la anisotropía y los efectos procedurales. Las rutas temporales de archivos de mapas usan GPU.",
"render.cpu_material_certification": "La comprobación de CPU valida la geometría de vidrio admitida antes de renderizar. El vidrio cerrado usa la distancia geométrica.",
"render.cpu_neutral": "Neutro",
"render.cpu_opaque_required": "El trazado CPU requiere un fondo opaco. Desactiva Fondo transparente para continuar.",
"render.cpu_options_pending": "Este panel CPU todavía no puede editar emisión, efectos de superficie, vidrio ni anisotropía. Borra esas opciones de la API antes de usarlo.",
@@ -4076,6 +4085,8 @@
"sample.plate.name": "Placa de ejemplo",
"sample.vents.about": "Ranuras en un patrón rectangular: las filas, las columnas y el paso son parámetros",
"sample.vents.name": "Placa de ventilación",
"scale.per_axis": "Por eje",
"scale.uniform": "Uniforme",
"search.command_keys": "Intro ejecutar Mayús+Intro ejecutar y mantener abierto Esc cerrar",
"search.commands": "Comandos",
"search.design": "En este diseño",
@@ -4747,6 +4758,7 @@
"ui.click_selects_whole_bodies": "Un clic selecciona cuerpos completos",
"ui.click_then_press_key": "Haz clic y pulsa la nueva tecla",
"ui.close": "Cerrar",
"ui.close_tab": "Cerrar pestaña",
"ui.coil": "Hélice",
"ui.coincident": "Coincidente",
"ui.collapse": "Contraer",
+2
View File
@@ -4077,6 +4077,8 @@
"sample.plate.name": "Esimerkkilevy",
"sample.vents.about": "Urat suorakulmaisessa kuviossa: rivit, sarakkeet ja jako ovat parametreja",
"sample.vents.name": "Tuuletuslevy",
"scale.per_axis": "Akseleittain",
"scale.uniform": "Tasainen",
"search.command_keys": "Enter suorita Shift+Enter suorita ja pidä auki Esc sulje",
"search.commands": "Komennot",
"search.design": "Tässä mallissa",
+16 -1
View File
@@ -1363,11 +1363,14 @@
"dialog.computing": "Calcul… {seconds} s",
"dialog.could_not_be_checked": "non vérifiable",
"dialog.custom_axis": "↖ Axe personnalisé",
"dialog.cylinder_face_tip": "La face ou le sommet du modèle sur lequel le cylindre est posé : cliquer ici, puis dessus dans la vue. Laissé vide, X, Y et Z le placent.",
"dialog.dimension_insert_hint": "Double-cliquez sur une autre cote pilotante pour insérer son nom",
"dialog.dimension_name": "Cote : {name}",
"dialog.edit_title": "MODIFIER {title}",
"dialog.no_shown_body": "Aucun corps affiché à modifier : affichez-en un ou choisissez Nouveau corps",
"dialog.pattern_sketch_not_object": "Choisissez des corps ou des fonctions : une esquisse n'est ni l'un ni l'autre. Répétez ses courbes avec la Répétition ou la Symétrie de l'esquisse, ou extrudez-la d'abord.",
"dialog.primitive_point_tip": "Un point qui remplit X, Y et Z : cliquer ici, puis sur un sommet, une arête ou une face du modèle, sur un point d'une esquisse ou sur l'origine.",
"dialog.reference_edge_tip": "Une arête droite de la face choisie, depuis laquelle la distance ci-dessous est mesurée : cliquer ici, puis sur l'arête.",
"dialog.select": "↖ Sélectionner",
"dialog.selected_count": "↖ {count} sélectionné(s)",
"dialog.selection_constraint": "Contrainte {id}",
@@ -1621,7 +1624,7 @@
"help.sketch.insert_svg": "Lit les tracés d'un dessin SVG dans une esquisse, mis à l'échelle, tournés et placés comme vous le définissez. À utiliser pour transformer un logo ou un contour vectoriel en courbes que vous pouvez extruder.",
"help.sketch.inspect": "Ouvre un rapport sur l'esquisse ouverte : combien de degrés de liberté il reste et si elle est entièrement contrainte. À utiliser pour savoir ce qui bouge encore avant de terminer l'esquisse.",
"help.sketch.intersect": "Ajoute les courbes ou points où des corps, faces, arêtes ou d'autres esquisses traversent le plan d'esquisse. Ils restent liés et suivent le modèle lorsqu'il change.",
"help.sketch.intersection_curve": "Crée des courbes 3D là où deux corps ou faces se rencontrent, liées à eux. À utiliser comme trajectoires ou rails guides qui suivent le modèle.",
"help.sketch.intersection_curve": "Crée des courbes 3D là où deux corps, faces ou courbes d'autres esquisses se rencontrent, liées à eux. À utiliser comme trajectoires ou rails guides qui suivent le modèle.",
"help.sketch.isoparametric_curve": "Crée une courbe 3D le long de la direction U ou V d'une face, par un point que vous choisissez, liée à la face. À utiliser comme trajectoire ou ligne de partage qui suit la surface.",
"help.sketch.line": "Dessine des lignes connectées point par point, en chaîne ouverte ou en contour fermé. C'est l'outil de base de la plupart des profils.",
"help.sketch.line.midpoint": "Dessine une ligne de son milieu vers une extrémité, l'autre extrémité étant son symétrique. À utiliser pour des lignes centrées sur un point, comme une arête symétrique.",
@@ -2198,6 +2201,8 @@
"prefs.edges": "Arêtes et ombrage",
"prefs.extend": "Étendre",
"prefs.extra_navigation": "Ctrl+glisser gauche dans le vide fait orbiter ; maintenir Espace pour un panoramique.",
"prefs.extrude_arrow_face_stops": "La flèche d'extrusion s'arrête aux faces",
"prefs.extrude_arrow_face_stops_note": "En faisant glisser la flèche de distance d'Extrusion, elle s'arrête aux faces planes parallèles au profil, comme l'autre côté d'un bloc qu'on découpe, et souligne la face. Désactivé : la flèche avance seulement par pas ronds.",
"prefs.extrude_view_isometric": "Passer la vue en isométrique à l'ouverture de l'extrusion",
"prefs.extrude_view_return": "Revenir à la vue précédente à la fermeture de l'extrusion",
"prefs.follows_os": "Suit le système",
@@ -2277,6 +2282,8 @@
"prefs.mouse": "Souris et pavé tactile",
"prefs.mouse_heading": "Souris",
"prefs.mouse_mapping": "Affectation des boutons de la souris",
"prefs.move_drag_highlights": "Mettre en évidence ce qui est sous le pointeur pendant que les flèches de Déplacer font glisser un composant",
"prefs.move_drag_highlights_tip": "Désactivé : pendant que les flèches de Déplacer font glisser un composant, rien n’est mis en évidence sous le pointeur, ce qui garde les grands assemblages fluides. Avec une coupe ou une analyse affichée, la mise en évidence reste, et une pièce glissée le long de ses liaisons n’en montre jamais.",
"prefs.navigation": "Navigation",
"prefs.navigation_note": "Comment la souris, le pavé tactile et la souris 3D déplacent la vue.",
"prefs.navigator_fit": "Cadrer le modèle après un changement de vue du repère de navigation",
@@ -2775,6 +2782,8 @@
"render.cpu_lighting": "Préréglage d'éclairage",
"render.cpu_lights": "Lumières CPU",
"render.cpu_linear_color": "Couleur linéaire (R, V, B)",
"render.cpu_maps_saved_help": "Le rendu CPU accepte les cartes opaques enregistrées : choisissez une surface, puis enregistrez ses cartes dans la vue. Avec l’éclairage indirect, un autre corps peut utiliser Verre ; le corps avec les cartes ne le peut pas. Désactivez les réflexions sélectionnées, l’anisotropie et les effets procéduraux. Les fichiers temporaires de cartes utilisent le GPU.",
"render.cpu_material_certification": "La vérification CPU valide la géométrie de verre prise en charge avant le rendu. Le verre fermé utilise la distance géométrique.",
"render.cpu_neutral": "Neutre",
"render.cpu_opaque_required": "Le lancer de rayons CPU nécessite un arrière-plan opaque. Décochez Arrière-plan transparent pour continuer.",
"render.cpu_options_pending": "Ce panneau CPU ne peut pas encore modifier l'émission, les effets de surface, le verre ni l'anisotropie. Effacez ces options d'API avant de l'utiliser.",
@@ -2999,9 +3008,11 @@
"render.frame_wide": "16:9 · Écran large",
"render.gpu_depth": "Profondeur maximale des chemins",
"render.gpu_options_help": "Les échantillons, la profondeur, la graine et l'exposition sont enregistrés avec ce préréglage. Seule la méthode d'image active est enregistrée.",
"render.gpu_path_crashed": "SolidCraft s’est fermé pendant sa dernière préparation sur cet adaptateur graphique, très probablement dans le pilote graphique ; il n’est donc plus préparé de lui-même. Utilisez le lancer de rayons sur le CPU, ou réessayez (une mise à jour du pilote réessaie aussi).",
"render.gpu_path_failed": "Traceur de chemins GPU indisponible",
"render.gpu_path_first_setup": "La première initialisation sur ce GPU peut prendre plusieurs minutes ; SolidCraft reste utilisable pendant ce temps. Les démarrages suivants réutilisent les shaders compilés lorsque le pilote les conserve.",
"render.gpu_path_preparing": "Traceur de chemins GPU : compilation des shaders…",
"render.gpu_path_retry": "Réessayer",
"render.gpu_seed": "Graine aléatoire",
"render.gpu_unavailable": "Le tracé de chemins GPU n'est pas prêt sur cet appareil.",
"render.ground_apply": "Ajuster et enregistrer le sol",
@@ -3809,6 +3820,8 @@
"sample.plate.name": "Plaque d'exemple",
"sample.vents.about": "Rainures en répétition rectangulaire : lignes, colonnes et pas sont des paramètres",
"sample.vents.name": "Plaque d'aération",
"scale.per_axis": "Par axe",
"scale.uniform": "Uniforme",
"search.command_keys": "Entrée exécuter Maj+Entrée exécuter et garder ouvert Échap fermer",
"search.commands": "Commandes",
"search.design": "Dans cette conception",
@@ -4438,6 +4451,7 @@
"ui.click_selects_whole_bodies": "Un clic sélectionne des corps entiers",
"ui.click_then_press_key": "Cliquer, puis appuyer sur la nouvelle touche",
"ui.close": "Fermer",
"ui.close_tab": "Fermer l’onglet",
"ui.coil": "Hélice",
"ui.coincident": "Coïncidence",
"ui.collapse": "Réduire",
@@ -4921,6 +4935,7 @@
"ui.point_cloud_add_filter": "Ajouter un filtre…",
"ui.point_cloud_auto_range": "Plage automatique",
"ui.point_cloud_color_by": "Colorer par",
"ui.point_cloud_dots": "Points isolés",
"ui.point_cloud_file_colors": "Couleurs du fichier",
"ui.point_cloud_filter_by": "Filtrer par",
"ui.point_cloud_height_high": "Plus haut",
+3 -1
View File
@@ -1621,7 +1621,7 @@
"help.sketch.insert_svg": "Le os trazados dun debuxo SVG nun bosquexo, escalados, xirados e colocados onde indiques. Úsao para converter un logotipo ou un contorno vectorial en curvas que podes extruír.",
"help.sketch.inspect": "Abre un informe do bosquexo aberto: cantos graos de liberdade quedan e se está totalmente restrinxido. Úsao para saber o que aínda se move antes de rematar o bosquexo.",
"help.sketch.intersect": "Engade as curvas ou os puntos onde corpos, caras, arestas ou outros bosquexos cruzan o plano do bosquexo. Quedan ligados e seguen o modelo cando cambia.",
"help.sketch.intersection_curve": "Crea curvas 3D onde se atopan dous corpos ou caras, ligadas a eles. Úsaas como traxectorias ou guías que seguen o modelo.",
"help.sketch.intersection_curve": "Crea curvas 3D onde se atopan dous corpos, caras ou curvas doutros bosquexos, ligadas a eles. Úsaas como traxectorias ou guías que seguen o modelo.",
"help.sketch.isoparametric_curve": "Crea unha curva 3D na dirección U ou V dunha cara por un punto que escollas, ligada á cara. Úsaa como traxectoria ou liña de división que segue a superficie.",
"help.sketch.line": "Debuxa liñas conectadas punto a punto, como cadea aberta ou como contorno pechado. É a ferramenta básica para a maioría dos perfís.",
"help.sketch.line.midpoint": "Debuxa unha liña desde o seu punto medio ata un extremo, co outro extremo reflectido. Úsao para liñas centradas nun punto, como unha aresta simétrica.",
@@ -3807,6 +3807,8 @@
"sample.plate.name": "Placa de exemplo",
"sample.vents.about": "Ranuras nun patrón rectangular: filas, columnas e paso son parámetros",
"sample.vents.name": "Placa de ventilación",
"scale.per_axis": "Por eixo",
"scale.uniform": "Uniforme",
"search.command_keys": "Intro executar Maiús+Intro executar e manter aberto Esc pechar",
"search.commands": "Ordes",
"search.design": "Neste deseño",
+473 -14
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1621,7 +1621,7 @@
"help.sketch.insert_svg": "Čita putanje SVG crteža u skicu, skalirane, zakrenute i postavljene kako zadate. Upotrijebite ga za pretvaranje logotipa ili vektorskog obrisa u krivulje koje možete ekstrudirati.",
"help.sketch.inspect": "Otvara izvješće o otvorenoj skici: koliko je stupnjeva slobode preostalo i je li potpuno ograničena. Upotrijebite ga kako biste saznali što se još pomiče prije dovršetka skice.",
"help.sketch.intersect": "Dodaje krivulje ili točke u kojima tijela, lica, bridovi ili druge skice presijecaju ravninu skice. Ostaju povezane i prate model kad se promijeni.",
"help.sketch.intersection_curve": "Izrađuje 3D krivulje na mjestu gdje se dva tijela ili lica susreću, povezane s njima. Upotrijebite ih kao putanje ili vodilice koje prate model.",
"help.sketch.intersection_curve": "Izrađuje 3D krivulje na mjestu gdje se dva tijela, lica ili krivulje drugih skica susreću, povezane s njima. Upotrijebite ih kao putanje ili vodilice koje prate model.",
"help.sketch.isoparametric_curve": "Izrađuje 3D krivulju u smjeru U ili V lica kroz točku koju odaberete, povezanu s licem. Upotrijebite je kao putanju ili liniju razdvajanja koja prati plohu.",
"help.sketch.line": "Crta povezane linije točku po točku, kao otvoreni lanac ili zatvoreni obris. To je osnovni alat za većinu profila.",
"help.sketch.line.midpoint": "Crta liniju od njezine središnje točke do jednog kraja, a drugi kraj zrcali. Upotrijebite ga za linije centrirane na točku, primjerice simetričan brid.",
+3 -1
View File
@@ -1621,7 +1621,7 @@
"help.sketch.insert_svg": "Beolvassa egy SVG-rajz útvonalait egy vázlatba, az Ön által megadott léptékkel, elforgatással és helyen. Használja egy logó vagy vektoros körvonal kihúzható görbékké alakítására.",
"help.sketch.inspect": "Jelentést nyit a megnyitott vázlatról: hány szabadságfok maradt, és teljesen kényszerezett-e. Használja annak kiderítésére, mi mozdul még, mielőtt befejezi a vázlatot.",
"help.sketch.intersect": "Hozzáadja azokat a görbéket vagy pontokat, ahol testek, lapok, élek vagy más vázlatok metszik a vázlatsíkot. Kapcsoltak maradnak, és követik a modellt, ha az megváltozik.",
"help.sketch.intersection_curve": "3D görbéket hoz létre ott, ahol két test vagy lap találkozik, hozzájuk kapcsolva. Használja őket útvonalként vagy vezérgörbeként, amely követi a modellt.",
"help.sketch.intersection_curve": "3D görbéket hoz létre ott, ahol két test, lap vagy más vázlatok görbéi találkozik, hozzájuk kapcsolva. Használja őket útvonalként vagy vezérgörbeként, amely követi a modellt.",
"help.sketch.isoparametric_curve": "3D görbét hoz létre egy lap U vagy V iránya mentén egy kiválasztott ponton át, a laphoz kapcsolva. Használja útvonalként vagy osztóvonalként, amely követi a felületet.",
"help.sketch.line": "Összefüggő vonalakat rajzol pontról pontra, nyitott láncként vagy zárt körvonalként. Ez a legtöbb profil alapeszköze.",
"help.sketch.line.midpoint": "Vonalat rajzol a felezőpontjától az egyik végéig, a másik véget tükrözve. Használja egy pontra középen álló vonalakhoz, például szimmetrikus élhez.",
@@ -3807,6 +3807,8 @@
"sample.plate.name": "Mintalemez",
"sample.vents.about": "Hornyok téglalap mintában: a sorok, oszlopok és osztás paraméterek",
"sample.vents.name": "Szellőzőlemez",
"scale.per_axis": "Tengelyenként",
"scale.uniform": "Egyenletes",
"search.command_keys": "Enter futtatás Shift+Enter futtatás és nyitva tartás Esc bezárás",
"search.commands": "Parancsok",
"search.design": "Ebben a tervben",
+3 -1
View File
@@ -1621,7 +1621,7 @@
"help.sketch.insert_svg": "Membaca path gambar SVG ke dalam sketsa, diskalakan, diputar, dan ditempatkan sesuai pengaturan Anda. Gunakan untuk mengubah logo atau kontur vektor menjadi kurva yang dapat diekstrusi.",
"help.sketch.inspect": "Membuka laporan tentang sketsa yang terbuka: berapa derajat kebebasan yang tersisa dan apakah sketsa terbatasi penuh. Gunakan untuk mengetahui apa yang masih bergerak sebelum Anda menyelesaikan sketsa.",
"help.sketch.intersect": "Menambahkan kurva atau titik tempat bodi, muka, tepi, atau sketsa lain memotong bidang sketsa. Semuanya tetap tertaut dan mengikuti model saat model berubah.",
"help.sketch.intersection_curve": "Membuat kurva 3D di tempat dua bodi atau muka bertemu, tertaut padanya. Gunakan sebagai jalur atau rel pemandu yang mengikuti model.",
"help.sketch.intersection_curve": "Membuat kurva 3D di tempat dua bodi, muka, atau kurva sketsa lain bertemu, tertaut padanya. Gunakan sebagai jalur atau rel pemandu yang mengikuti model.",
"help.sketch.isoparametric_curve": "Membuat kurva 3D sepanjang arah U atau V sebuah muka melalui titik yang Anda pilih, tertaut ke muka itu. Gunakan sebagai jalur atau garis pisah yang mengikuti permukaan.",
"help.sketch.line": "Menggambar garis-garis tersambung titik demi titik, sebagai rantai terbuka atau kontur tertutup. Ini alat dasar untuk sebagian besar profil.",
"help.sketch.line.midpoint": "Menggambar garis dari titik tengahnya ke satu ujung, dengan ujung lainnya dicerminkan. Gunakan untuk garis yang dipusatkan pada sebuah titik, seperti tepi simetris.",
@@ -3807,6 +3807,8 @@
"sample.plate.name": "Pelat Contoh",
"sample.vents.about": "Slot dalam pola persegi panjang: baris, kolom, dan pitch adalah parameter",
"sample.vents.name": "Pelat Ventilasi",
"scale.per_axis": "Per sumbu",
"scale.uniform": "Seragam",
"search.command_keys": "Enter jalankan Shift+Enter jalankan dan tetap buka Esc tutup",
"search.commands": "Perintah",
"search.design": "Di desain ini",
+6 -1
View File
@@ -1364,11 +1364,14 @@
"dialog.computing": "Calcolo in corso… {seconds} s",
"dialog.could_not_be_checked": "impossibile verificare",
"dialog.custom_axis": "↖ Asse personalizzato",
"dialog.cylinder_face_tip": "La faccia o il vertice del modello su cui poggia il cilindro: fai clic qui, poi su di esso nella vista. Se resta vuoto, lo posizionano X, Y e Z.",
"dialog.dimension_insert_hint": "Fai doppio clic su un’altra quota di controllo per inserirne il nome",
"dialog.dimension_name": "Quota: {name}",
"dialog.edit_title": "MODIFICA {title}",
"dialog.no_shown_body": "Nessun corpo visibile da modificare: mostrane uno o scegli Nuovo corpo",
"dialog.pattern_sketch_not_object": "Seleziona corpi o lavorazioni: uno schizzo non è nessuno dei due. Ripeti le sue curve con Ripetizione o Specchia dello schizzo stesso, oppure estrudilo prima.",
"dialog.primitive_point_tip": "Un punto che compila X, Y e Z: fai clic qui, poi su un vertice, uno spigolo o una faccia del modello, su un punto di uno schizzo oppure sull'origine.",
"dialog.reference_edge_tip": "Uno spigolo rettilineo della faccia scelta da cui si misura la distanza sotto: fai clic qui, poi sullo spigolo.",
"dialog.select": "↖ Seleziona",
"dialog.selected_count": "↖ Selezionati: {count}",
"dialog.selection_constraint": "Vincolo {id}",
@@ -1622,7 +1625,7 @@
"help.sketch.insert_svg": "Legge i tracciati di un disegno SVG in uno schizzo, scalati, ruotati e posizionati come imposti. Usalo per trasformare un logo o un contorno vettoriale in curve che puoi estrudere.",
"help.sketch.inspect": "Apre un rapporto sullo schizzo aperto: quanti gradi di libertà restano e se è completamente vincolato. Usalo per scoprire cosa si muove ancora prima di terminare lo schizzo.",
"help.sketch.intersect": "Aggiunge le curve o i punti in cui corpi, facce, spigoli o altri schizzi attraversano il piano dello schizzo. Restano collegati e seguono il modello quando cambia.",
"help.sketch.intersection_curve": "Crea curve 3D dove si incontrano due corpi o facce, collegate a essi. Usale come percorsi o guide che seguono il modello.",
"help.sketch.intersection_curve": "Crea curve 3D dove si incontrano due corpi, facce o curve di altri schizzi, collegate a essi. Usale come percorsi o guide che seguono il modello.",
"help.sketch.isoparametric_curve": "Crea una curva 3D lungo la direzione U o V di una faccia passante per un punto che scegli, collegata alla faccia. Usala come percorso o linea di divisione che segue la superficie.",
"help.sketch.line": "Disegna linee collegate punto per punto, come catena aperta o contorno chiuso. È lo strumento di base per la maggior parte dei profili.",
"help.sketch.line.midpoint": "Disegna una linea dal suo punto medio fino a un'estremità, con l'altra estremità speculare. Usalo per linee centrate su un punto, come un bordo simmetrico.",
@@ -3810,6 +3813,8 @@
"sample.plate.name": "Piastra di esempio",
"sample.vents.about": "Asole in ripetizione rettangolare: righe, colonne e passo sono parametri",
"sample.vents.name": "Piastra di ventilazione",
"scale.per_axis": "Per asse",
"scale.uniform": "Uniforme",
"search.command_keys": "Invio esegui Maiusc+Invio esegui e tieni aperto Esc chiudi",
"search.commands": "Comandi",
"search.design": "In questo progetto",
+473 -14
View File
File diff suppressed because it is too large Load Diff
+1002 -52
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -1621,7 +1621,7 @@
"help.sketch.insert_svg": "Membaca laluan lukisan SVG ke dalam lakaran, diskalakan, diputar dan diletakkan mengikut tetapan anda. Gunakan untuk menukar logo atau garis luar vektor kepada lengkung yang boleh disemperit.",
"help.sketch.inspect": "Membuka laporan tentang lakaran yang terbuka: berapa banyak darjah kebebasan yang tinggal dan sama ada ia terkekang sepenuhnya. Gunakan untuk mengetahui apa yang masih bergerak sebelum anda menamatkan lakaran.",
"help.sketch.intersect": "Menambah lengkung atau titik tempat jasad, muka, tepi atau lakaran lain melintasi satah lakaran. Ia kekal terpaut dan mengikut model apabila model berubah.",
"help.sketch.intersection_curve": "Mencipta lengkung 3D tempat dua jasad atau muka bertemu, terpaut kepadanya. Gunakan sebagai laluan atau rel pemandu yang mengikut model.",
"help.sketch.intersection_curve": "Mencipta lengkung 3D tempat dua jasad, muka atau lengkung lakaran lain bertemu, terpaut kepadanya. Gunakan sebagai laluan atau rel pemandu yang mengikut model.",
"help.sketch.isoparametric_curve": "Mencipta lengkung 3D di sepanjang arah U atau V muka melalui titik yang anda pilih, terpaut kepada muka itu. Gunakan sebagai laluan atau garis belahan yang mengikut permukaan.",
"help.sketch.line": "Melukis garis bersambung titik demi titik, sebagai rantaian terbuka atau garis luar tertutup. Ia ialah alat asas bagi kebanyakan profil.",
"help.sketch.line.midpoint": "Melukis garis daripada titik tengahnya ke satu hujung, dengan hujung satu lagi dicerminkan. Gunakan untuk garis yang berpusat pada satu titik, seperti tepi simetri.",
@@ -3807,6 +3807,8 @@
"sample.plate.name": "Plat Contoh",
"sample.vents.about": "Corak slot segi empat tepat: baris, lajur dan pic sebagai parameter",
"sample.vents.name": "Plat Pengudaraan",
"scale.per_axis": "Mengikut paksi",
"scale.uniform": "Seragam",
"search.command_keys": "Enter jalankan Shift+Enter jalankan dan kekalkan terbuka Esc tutup",
"search.commands": "Arahan",
"search.design": "Dalam reka bentuk ini",
+3 -1
View File
@@ -1621,7 +1621,7 @@
"help.sketch.insert_svg": "Leser banene fra en SVG-tegning inn i en skisse, skalert, dreid og plassert slik du angir. Bruk det til å gjøre en logo eller vektorkontur om til kurver du kan ekstrudere.",
"help.sketch.inspect": "Åpner en rapport om den åpne skissen: hvor mange frihetsgrader som gjenstår og om den er fullstendig bundet. Bruk det til å finne ut hva som fortsatt beveger seg før du fullfører skissen.",
"help.sketch.intersect": "Legger til kurvene eller punktene der kropper, flater, kanter eller andre skisser krysser skisseplanet. De forblir koblet og følger modellen når den endres.",
"help.sketch.intersection_curve": "Oppretter 3D-kurver der to kropper eller flater møtes, koblet til dem. Bruk dem som baner eller føringskurver som følger modellen.",
"help.sketch.intersection_curve": "Oppretter 3D-kurver der to kropper, flater eller kurver fra andre skisser møtes, koblet til dem. Bruk dem som baner eller føringskurver som følger modellen.",
"help.sketch.isoparametric_curve": "Oppretter en 3D-kurve langs U- eller V-retningen til en flate gjennom et punkt du velger, koblet til flaten. Bruk den som en bane eller delelinje som følger flaten.",
"help.sketch.line": "Tegner sammenhengende linjer punkt for punkt, som en åpen kjede eller en lukket kontur. Det er det grunnleggende verktøyet for de fleste profiler.",
"help.sketch.line.midpoint": "Tegner en linje fra midtpunktet og ut til den ene enden, med den andre enden speilet. Bruk det til linjer sentrert på et punkt, for eksempel en symmetrisk kant.",
@@ -3807,6 +3807,8 @@
"sample.plate.name": "Eksempelplate",
"sample.vents.about": "Spor i et rektangulært mønster: rader, kolonner og stigning er parametere",
"sample.vents.name": "Ventilasjonsplate",
"scale.per_axis": "Per akse",
"scale.uniform": "Jevn",
"search.command_keys": "Enter kjør Shift+Enter kjør og behold åpen Esc lukk",
"search.commands": "Kommandoer",
"search.design": "I dette designet",
+3 -1
View File
@@ -1625,7 +1625,7 @@
"help.sketch.insert_svg": "Leest de paden van een SVG-tekening in een schets, geschaald, gedraaid en geplaatst waar je instelt. Gebruik dit om een logo of vectorcontour om te zetten in curven die je kunt extruderen.",
"help.sketch.inspect": "Opent een rapport over de geopende schets: hoeveel vrijheidsgraden er nog over zijn en of hij volledig beperkt is. Gebruik dit om te zien wat nog beweegt voordat je de schets voltooit.",
"help.sketch.intersect": "Voegt de curven of punten toe waar lichamen, vlakken, randen of andere schetsen het schetsvlak kruisen. Ze blijven gekoppeld en volgen het model wanneer dat verandert.",
"help.sketch.intersection_curve": "Maakt 3D-curven waar twee lichamen of vlakken samenkomen, eraan gekoppeld. Gebruik ze als paden of geleiders die het model volgen.",
"help.sketch.intersection_curve": "Maakt 3D-curven waar twee lichamen, vlakken of curven van andere schetsen samenkomen, eraan gekoppeld. Gebruik ze als paden of geleiders die het model volgen.",
"help.sketch.isoparametric_curve": "Maakt een 3D-curve langs de U- of V-richting van een vlak door een punt dat je kiest, gekoppeld aan het vlak. Gebruik dit als pad of splitslijn die het oppervlak volgt.",
"help.sketch.line": "Tekent verbonden lijnen punt voor punt, als open keten of gesloten contour. Het is het basisgereedschap voor de meeste profielen.",
"help.sketch.line.midpoint": "Tekent een lijn vanuit zijn middelpunt naar één uiteinde, waarbij het andere uiteinde wordt gespiegeld. Gebruik dit voor lijnen die op een punt zijn gecentreerd, zoals een symmetrische rand.",
@@ -3818,6 +3818,8 @@
"sample.plate.name": "Voorbeeldplaat",
"sample.vents.about": "Sleuven in een rechthoekig patroon: rijen, kolommen en spoed zijn parameters",
"sample.vents.name": "Ventilatieplaat",
"scale.per_axis": "Per as",
"scale.uniform": "Uniform",
"search.command_keys": "Enter uitvoeren Shift+Enter uitvoeren en open houden Esc sluiten",
"search.commands": "Opdrachten",
"search.design": "In dit ontwerp",
+2
View File
@@ -1408,6 +1408,8 @@
"sample.plate.name": "Placa de exemplo",
"sample.vents.about": "Rasgos em padrão retangular: linhas, colunas e passo são parâmetros",
"sample.vents.name": "Placa de ventilação",
"scale.per_axis": "Por eixo",
"scale.uniform": "Uniforme",
"search.command_keys": "Enter executar Shift+Enter executar e manter aberto Esc fechar",
"search.commands": "Comandos",
"search.design": "Neste projeto",
+3 -1
View File
@@ -1621,7 +1621,7 @@
"help.sketch.insert_svg": "Citește traseele unui desen SVG într-o schiță, scalate, rotite și plasate așa cum stabiliți. Folosiți-o pentru a transforma un logo sau un contur vectorial în curbe pe care le puteți extruda.",
"help.sketch.inspect": "Deschide un raport despre schița deschisă: câte grade de libertate au rămas și dacă este complet constrânsă. Folosiți-l pentru a afla ce se mai mișcă înainte de a finaliza schița.",
"help.sketch.intersect": "Adaugă curbele sau punctele în care corpuri, fețe, muchii sau alte schițe taie planul schiței. Rămân legate și urmează modelul când acesta se schimbă.",
"help.sketch.intersection_curve": "Creează curbe 3D acolo unde se întâlnesc două corpuri sau fețe, legate de ele. Folosiți-le ca trasee sau ghidaje care urmează modelul.",
"help.sketch.intersection_curve": "Creează curbe 3D acolo unde se întâlnesc două corpuri, fețe sau curbe ale altor schițe, legate de ele. Folosiți-le ca trasee sau ghidaje care urmează modelul.",
"help.sketch.isoparametric_curve": "Creează o curbă 3D pe direcția U sau V a unei fețe, printr-un punct ales de dumneavoastră, legată de față. Folosiți-o ca traseu sau linie de separare care urmează suprafața.",
"help.sketch.line": "Desenează linii conectate punct cu punct, ca lanț deschis sau contur închis. Este instrumentul de bază pentru majoritatea profilurilor.",
"help.sketch.line.midpoint": "Desenează o linie de la mijlocul ei spre un capăt, celălalt capăt fiind în oglindă. Folosiți-o pentru linii centrate pe un punct, de exemplu o muchie simetrică.",
@@ -3807,6 +3807,8 @@
"sample.plate.name": "Placă exemplu",
"sample.vents.about": "Canale într-o matrice dreptunghiulară: rândurile, coloanele și pasul sunt parametri",
"sample.vents.name": "Placă de ventilație",
"scale.per_axis": "Pe axe",
"scale.uniform": "Uniform",
"search.command_keys": "Enter execută Shift+Enter execută și păstrează deschis Esc închide",
"search.commands": "Comenzi",
"search.design": "În acest proiect",
+4 -1
View File
@@ -647,6 +647,7 @@
"The direction must be an axis or a straight sketch curve that lies in the sketch plane": "Направление должно быть осью или прямолинейной кривой эскиза, лежащей в плоскости эскиза",
"The mirror line cannot also be mirrored": "Линию зеркала нельзя одновременно отражать",
"The mirror line must be a straight curve of this sketch, not an origin axis": "Линия зеркала должна быть прямолинейной кривой этого эскиза, а не осью начала координат",
"The origin axis is perpendicular to the sketch plane, so it has no line in the plane": "Ось начала координат перпендикулярна плоскости эскиза, поэтому линии в этой плоскости нет",
"The saved author occurrence is missing. Select a valid author placement before rebinding.": "Сохранённое исходное вхождение отсутствует. Выберите корректное исходное положение перед перепривязкой.",
"Then": "Затем",
"These features will be deleted:": "Эти операции будут удалены:",
@@ -1610,7 +1611,7 @@
"help.sketch.insert_svg": "Читает контуры рисунка SVG в эскиз, масштабируя, поворачивая и размещая их так, как вы зададите. Используйте, чтобы превратить логотип или векторный контур в кривые, которые можно выдавить.",
"help.sketch.inspect": "Открывает отчёт об открытом эскизе: сколько осталось степеней свободы и определён ли он полностью. Используйте, чтобы выяснить, что ещё двигается, прежде чем завершить эскиз.",
"help.sketch.intersect": "Добавляет кривые или точки, где тела, грани, рёбра или другие эскизы пересекают плоскость эскиза. Они остаются связанными и следуют за моделью при её изменении.",
"help.sketch.intersection_curve": "Создаёт 3D-кривые там, где встречаются два тела или две грани, связанные с ними. Используйте как траектории или направляющие, следующие за моделью.",
"help.sketch.intersection_curve": "Создаёт 3D-кривые там, где встречаются два тела, две грани или кривые других эскизов, связанные с ними. Используйте как траектории или направляющие, следующие за моделью.",
"help.sketch.isoparametric_curve": "Создаёт 3D-кривую вдоль направления U или V грани через выбранную точку, связанную с гранью. Используйте как траекторию или линию разделения, следующую за поверхностью.",
"help.sketch.line": "Рисует соединённые линии точка за точкой, как открытую цепочку или замкнутый контур. Это основной инструмент для большинства профилей.",
"help.sketch.line.midpoint": "Рисует линию от её середины до одного конца, второй конец отражается зеркально. Используйте для линий с центром в точке, например симметричного ребра.",
@@ -3750,6 +3751,8 @@
"sample.plate.name": "Образец пластины",
"sample.vents.about": "Пазы в прямоугольном массиве: число рядов, столбцов и шаг заданы параметрами",
"sample.vents.name": "Вентиляционная пластина",
"scale.per_axis": "По осям",
"scale.uniform": "Равномерно",
"search.command_keys": "Enter выполнить Shift+Enter выполнить и оставить открытым Esc закрыть",
"search.commands": "Команды",
"search.design": "В этом дизайне",
+1 -1
View File
@@ -1621,7 +1621,7 @@
"help.sketch.insert_svg": "Учитава путање SVG цртежа у скицу, скалиране, окренуте и постављене како зададете. Користите га да логотип или векторски обрис претворите у криве које можете екструдирати.",
"help.sketch.inspect": "Отвара извештај о отвореној скици: колико је степени слободе остало и да ли је потпуно ограничена. Користите га да сазнате шта се још помера пре него што завршите скицу.",
"help.sketch.intersect": "Додаје криве или тачке у којима тела, лица, ивице или друге скице секу раван скице. Оне остају повезане и прате модел када се он промени.",
"help.sketch.intersection_curve": "Прави 3D криве тамо где се два тела или лица састају, повезане са њима. Користите их као путање или водилице које прате модел.",
"help.sketch.intersection_curve": "Прави 3D криве тамо где се два тела, лица или криве других скица састају, повезане са њима. Користите их као путање или водилице које прате модел.",
"help.sketch.isoparametric_curve": "Прави 3D криву дуж U или V смера лица кроз тачку коју изаберете, повезану са лицем. Користите је као путању или линију поделе која прати површ.",
"help.sketch.line": "Црта повезане линије тачку по тачку, као отворени ланац или затворени обрис. То је основни алат за већину профила.",
"help.sketch.line.midpoint": "Црта линију од њене средишње тачке до једног краја, а други крај се пресликава. Користите је за линије центриране на тачки, на пример за симетричну ивицу.",
+3 -1
View File
@@ -1622,7 +1622,7 @@
"help.sketch.insert_svg": "Läser in banorna från en SVG-ritning i en skiss, skalade, vridna och placerade där du anger. Använd det för att göra en logotyp eller en vektorkontur till kurvor som du kan extrudera.",
"help.sketch.inspect": "Öppnar en rapport om den öppna skissen: hur många frihetsgrader som återstår och om den är fullt definierad. Använd det för att ta reda på vad som fortfarande rör sig innan du avslutar skissen.",
"help.sketch.intersect": "Lägger till de kurvor eller punkter där kroppar, ytor, kanter eller andra skisser korsar skissplanet. De förblir länkade och följer modellen när den ändras.",
"help.sketch.intersection_curve": "Skapar 3D-kurvor där två kroppar eller ytor möts, länkade till dem. Använd dem som banor eller styrkurvor som följer modellen.",
"help.sketch.intersection_curve": "Skapar 3D-kurvor där två kroppar, ytor eller kurvor från andra skisser möts, länkade till dem. Använd dem som banor eller styrkurvor som följer modellen.",
"help.sketch.isoparametric_curve": "Skapar en 3D-kurva längs en ytas U- eller V-riktning genom en punkt som du väljer, länkad till ytan. Använd den som en bana eller delningslinje som följer ytan.",
"help.sketch.line": "Ritar sammanhängande linjer punkt för punkt, som en öppen kedja eller en sluten kontur. Det är det grundläggande verktyget för de flesta profiler.",
"help.sketch.line.midpoint": "Ritar en linje från dess mittpunkt ut till ena änden, med den andra änden speglad. Använd det för linjer centrerade på en punkt, till exempel en symmetrisk kant.",
@@ -3808,6 +3808,8 @@
"sample.plate.name": "Exempelplatta",
"sample.vents.about": "Spår i ett rektangulärt mönster: rader, kolumner och delning är parametrar",
"sample.vents.name": "Ventilationsplatta",
"scale.per_axis": "Per axel",
"scale.uniform": "Likformig",
"search.command_keys": "Enter kör Shift+Enter kör och behåll öppen Esc stäng",
"search.commands": "Kommandon",
"search.design": "I denna konstruktion",
+3 -1
View File
@@ -1621,7 +1621,7 @@
"help.sketch.insert_svg": "Bir SVG çiziminin yollarını, belirlediğiniz ölçekte, açıda ve konumda bir eskize okur. Bir logoyu veya vektör dış hattını ekstrüzyon yapabileceğiniz eğrilere dönüştürmek için kullanın.",
"help.sketch.inspect": "Açık eskiz için bir rapor açar: kaç serbestlik derecesi kaldığını ve tam kısıtlı olup olmadığını. Eskizi bitirmeden önce nelerin hâlâ hareket ettiğini öğrenmek için kullanın.",
"help.sketch.intersect": "Gövdelerin, yüzlerin, kenarların veya diğer eskizlerin eskiz düzlemini kestiği eğrileri ya da noktaları ekler. Bağlı kalır ve model değiştiğinde onu izler.",
"help.sketch.intersection_curve": "İki gövdenin veya yüzün buluştuğu yerde, onlara bağlı 3B eğriler oluşturur. Bunları modeli izleyen yollar veya kılavuz raylar olarak kullanın.",
"help.sketch.intersection_curve": "İki gövdenin, yüzün veya diğer eskizlerin eğrilerinin buluştuğu yerde, onlara bağlı 3B eğriler oluşturur. Bunları modeli izleyen yollar veya kılavuz raylar olarak kullanın.",
"help.sketch.isoparametric_curve": "Bir yüzün U veya V yönü boyunca, seçtiğiniz bir noktadan geçen ve yüze bağlı bir 3B eğri oluşturur. Yüzeyi izleyen bir yol veya bölme çizgisi olarak kullanın.",
"help.sketch.line": "Bağlantılı çizgileri nokta nokta, açık bir zincir veya kapalı bir dış hat olarak çizer. Çoğu profil için temel araçtır.",
"help.sketch.line.midpoint": "Bir çizgiyi orta noktasından bir uca doğru çizer; diğer uç yansıtılır. Bir nokta etrafında ortalanmış çizgiler, örneğin simetrik bir kenar için kullanın.",
@@ -3807,6 +3807,8 @@
"sample.plate.name": "Örnek plaka",
"sample.vents.about": "Dikdörtgen desende yuvalar: satırlar, sütunlar ve aralık parametredir",
"sample.vents.name": "Havalandırma plakası",
"scale.per_axis": "Eksen başına",
"scale.uniform": "Tekdüze",
"search.command_keys": "Enter çalıştır Shift+Enter çalıştır ve açık tut Esc kapat",
"search.commands": "Komutlar",
"search.design": "Bu tasarımda",
+3 -1
View File
@@ -1681,7 +1681,7 @@
"help.sketch.insert_svg": "Зчитує контури малюнка SVG в ескіз, масштабовані, повернуті й розміщені так, як ви задасте. Використовуйте, щоб перетворити логотип або векторний контур на криві, які можна видавити.",
"help.sketch.inspect": "Відкриває звіт про відкритий ескіз: скільки ступенів вільності залишилося і чи він повністю обмежений. Використовуйте його, щоб дізнатися, що ще рухається, перш ніж завершити ескіз.",
"help.sketch.intersect": "Додає криві чи точки, де тіла, грані, ребра або інші ескізи перетинають площину ескізу. Вони залишаються пов'язаними й слідують за моделлю, коли вона змінюється.",
"help.sketch.intersection_curve": "Створює 3D-криві там, де сходяться два тіла чи грані, пов'язані з ними. Використовуйте їх як траєкторії або напрямні, що слідують за моделлю.",
"help.sketch.intersection_curve": "Створює 3D-криві там, де сходяться два тіла, грані чи криві інших ескізів, пов'язані з ними. Використовуйте їх як траєкторії або напрямні, що слідують за моделлю.",
"help.sketch.isoparametric_curve": "Створює 3D-криву, пов'язану з гранню, вздовж напрямку U або V грані через вибрану вами точку. Використовуйте як траєкторію або лінію розділення, що слідує за поверхнею.",
"help.sketch.line": "Малює з'єднані прямі точка за точкою — як розімкнений ланцюг або замкнений контур. Це основний інструмент для більшості профілів.",
"help.sketch.line.midpoint": "Малює пряму від її середини до одного кінця, а другий кінець відображається дзеркально. Використовуйте для прямих, центрованих на точці, наприклад симетричного краю.",
@@ -4074,6 +4074,8 @@
"sample.plate.name": "Зразкова пластина",
"sample.vents.about": "Пази в прямокутному масиві: рядки, стовпці та крок є параметрами",
"sample.vents.name": "Вентиляційна пластина",
"scale.per_axis": "По осях",
"scale.uniform": "Рівномірно",
"search.command_keys": "Enter виконати Shift+Enter виконати й залишити відкритим Esc закрити",
"search.commands": "Команди",
"search.design": "У цьому проєкті",
+473 -14
View File
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -1364,11 +1364,14 @@
"dialog.computing": "正在計算… {seconds} 秒",
"dialog.could_not_be_checked": "無法檢查",
"dialog.custom_axis": "↖ 自訂軸",
"dialog.cylinder_face_tip": "圓柱體所立的模型面或頂點:先點擊此處,再在視圖中點擊它。留空時由 X、Y、Z 放置。",
"dialog.dimension_insert_hint": "點擊其他驅動尺寸以插入其名稱",
"dialog.dimension_name": "尺寸:{name}",
"dialog.edit_title": "編輯 {title}",
"dialog.no_shown_body": "沒有可變更的已顯示實體:請顯示一個,或選擇「新建實體」",
"dialog.pattern_sketch_not_object": "請選取實體或特徵:草圖兩者皆非。請用草圖自己的「陣列」或「鏡射」重複其曲線,或先將它擠出。",
"dialog.primitive_point_tip": "填入 X、Y、Z 的點:先點擊此處,再點擊模型的頂點、邊或面、草圖中的點,或原點。",
"dialog.reference_edge_tip": "所選面上的一條直邊,下方的距離從它量起:先點擊此處,再點擊該邊。",
"dialog.select": "↖ 選擇",
"dialog.selected_count": "↖ 已選擇 {count} 個",
"dialog.selection_constraint": "拘束 {id}",
@@ -1621,7 +1624,7 @@
"help.sketch.insert_svg": "將 SVG 圖形的路徑讀入草圖,並依您的設定縮放、旋轉和放置。用於把標誌或向量輪廓轉成可擠出的曲線。",
"help.sketch.inspect": "開啟目前草圖的報告:還剩多少個自由度,以及是否已完全拘束。用於在完成草圖之前找出哪些部分仍會移動。",
"help.sketch.intersect": "加入實體、面、邊或其他草圖與草圖平面相交的曲線或點。它們保持連結,並在模型變更時跟隨。",
"help.sketch.intersection_curve": "在兩個實體或面相接處建立 3D 曲線,並與它們連結。用作跟隨模型的路徑或導軌。",
"help.sketch.intersection_curve": "在兩個實體、面或其他草圖的曲線相接處建立 3D 曲線,並與它們連結。用作跟隨模型的路徑或導軌。",
"help.sketch.isoparametric_curve": "通過您選取的點,沿面的 U 或 V 方向建立 3D 曲線,並與該面連結。用作跟隨曲面的路徑或分割線。",
"help.sketch.line": "逐點畫出相連的直線,可為開放的鏈或封閉的輪廓。這是大多數輪廓的基本工具。",
"help.sketch.line.midpoint": "從中點向一端畫直線,另一端對稱映射。用於以一點為中心的直線,例如對稱的邊。",
@@ -3810,6 +3813,8 @@
"sample.plate.name": "範例板",
"sample.vents.about": "依矩形陣列排列的槽:列數、欄數和間距均為參數",
"sample.vents.name": "通風板",
"scale.per_axis": "依軸",
"scale.uniform": "等比",
"search.command_keys": "Enter 執行 Shift+Enter 執行並保持開啟 Esc 關閉",
"search.commands": "命令",
"search.design": "此設計中",
@@ -4440,6 +4445,7 @@
"ui.click_selects_whole_bodies": "點擊選擇整個實體",
"ui.click_then_press_key": "點擊,然後按新按鍵",
"ui.close": "關閉",
"ui.close_tab": "關閉分頁",
"ui.coil": "線圈",
"ui.coincident": "重合",
"ui.collapse": "摺疊",
+473 -14
View File
File diff suppressed because it is too large Load Diff
+192 -31
View File
@@ -16,6 +16,9 @@ struct U {
ana_c: array<vec4<f32>, 8>,
ana_f: vec4<f32>, // x = 1: stripe the negative draft and blocked faces
depth: vec4<f32>, // x = 1: reversed depth (1 near, 0 far); y, z: the faces' push (`FACE_BIAS`)
// Where the drawn geometry is moved in the world: identity, or a dragged component's move
// from where its scene was built (`MovingLayer`)
model: mat4x4<f32>,
tint: vec4<f32>, // xyz added to a whole hovered body's colour before lighting
sel: vec4<f32>, // xyz a whole selected body's colour before lighting
// the section hatch colours by part (`solidcraft_render::hatch`), the first the cap's own
@@ -25,6 +28,16 @@ struct U {
sky: vec4<f32>, // xyz = world up; w = 1: shade in linear light with a sky fill (#7974)
};
/// A scene's point in the world (see `U.model`).
fn placed(p: vec3<f32>) -> vec3<f32> {
return (u.model * vec4<f32>(p, 1.0)).xyz;
}
/// A scene's direction in the world (see `placed`).
fn turned(n: vec3<f32>) -> vec3<f32> {
return (u.model * vec4<f32>(n, 0.0)).xyz;
}
fn reversed() -> bool {
return u.depth.x > 0.5;
}
@@ -83,10 +96,11 @@ struct TOut {
};
@vertex
fn vs_tri(@location(0) p: vec3<f32>, @location(1) n: vec3<f32>, @location(2) c: u32) -> TOut {
fn vs_tri(@location(0) local: vec3<f32>, @location(1) n: vec3<f32>, @location(2) c: u32) -> TOut {
var o: TOut;
let p = placed(local);
o.pos = u.vp * vec4<f32>(p, 1.0);
o.n = n;
o.n = turned(n);
let e = palette_entry(c);
o.c = unpack_color(e.x);
o.h = e.y;
@@ -95,7 +109,8 @@ fn vs_tri(@location(0) p: vec3<f32>, @location(1) n: vec3<f32>, @location(2) c:
}
/// Shared triangles (in their part's own frame) drawn at one placement: the rows of its rigid
/// move (`r0`..`r2`, a point's coordinates are their dot products with (p, 1)) and its colour.
/// move (`r0`..`r2`, a point's coordinates are their dot products with (p, 1)) and its colour,
/// then moved like the rest of its scene (`placed`: a dragged component's layer).
/// The vertex colour (location 2) is not read.
@vertex
fn vs_tri_inst(
@@ -107,10 +122,11 @@ fn vs_tri_inst(
@location(6) c: u32,
) -> TOut {
let q = vec4<f32>(p, 1.0);
let wp = vec3<f32>(dot(r0, q), dot(r1, q), dot(r2, q));
let wp = placed(vec3<f32>(dot(r0, q), dot(r1, q), dot(r2, q)));
let pn = vec3<f32>(dot(r0.xyz, n), dot(r1.xyz, n), dot(r2.xyz, n));
var o: TOut;
o.pos = u.vp * vec4<f32>(wp, 1.0);
o.n = vec3<f32>(dot(r0.xyz, n), dot(r1.xyz, n), dot(r2.xyz, n));
o.n = turned(pn);
let e = palette_entry(c);
o.c = unpack_color(e.x);
o.h = e.y;
@@ -140,7 +156,7 @@ fn vs_tri_inst_flat(
@location(6) c: u32,
) -> FOut {
let q = vec4<f32>(p, 1.0);
let wp = vec3<f32>(dot(r0, q), dot(r1, q), dot(r2, q));
let wp = placed(vec3<f32>(dot(r0, q), dot(r1, q), dot(r2, q)));
var o: FOut;
o.pos = u.vp * vec4<f32>(wp, 1.0);
o.rp = wp - u.eye.xyz;
@@ -785,7 +801,9 @@ fn lifted_z(p: vec3<f32>, c: vec4<f32>, px: f32, half: vec2<f32>) -> f32 {
}
@vertex
fn vs_line(@builtin(vertex_index) vi: u32, @location(0) a: vec3<f32>, @location(1) b: vec3<f32>, @location(2) c: u32, @location(3) wbits: u32) -> LOut {
fn vs_line(@builtin(vertex_index) vi: u32, @location(0) local_a: vec3<f32>, @location(1) local_b: vec3<f32>, @location(2) c: u32, @location(3) wbits: u32) -> LOut {
let a = placed(local_a);
let b = placed(local_b);
var pa = a;
var pb = b;
var ca = u.vp * vec4<f32>(a, 1.0);
@@ -832,7 +850,7 @@ fn vs_line(@builtin(vertex_index) vi: u32, @location(0) a: vec3<f32>, @location(
var z = base.z;
if (lifted && base.w > 1e-6) {
let p = select(pa, pb, t > 0.5);
let g = grazing(p, octahedral(wbits & 4095u), octahedral((wbits >> 12u) & 4095u));
let g = grazing(p, turned(octahedral(wbits & 4095u)), turned(octahedral((wbits >> 12u) & 4095u)));
z = lifted_z(p, base, wd * 0.5 * g + 1.0, half);
}
var o: LOut;
@@ -883,10 +901,10 @@ fn fs_line_all(i: LOut) -> @location(0) vec4<f32> {
return out_color(i.c);
}
// Point clouds (`view/clouds.rs`): each point is a round dot a few pixels across, facing the
// screen, depth-tested and written like the model's faces, so the model hides the points behind
// it and they hide it. The cloud's style colours them by their own colour, a ramp or a palette,
// and shows only those whose filtered values lie in its ranges or sets.
// Point clouds and their cells (`view/clouds.rs`): dots, segments as beads (or lines when they
// have no size) and triangles, from one record per element. The cloud's style colours them by
// their own colour, a ramp or a palette, and shows only those whose filtered values lie in its
// ranges or sets; values vary across an element, so a range cuts it where its value crosses an end.
struct Cloud {
style: vec4<f32>, // x: colour (0 own, 1 ramp, 2 palette), y: dot size (px), z, w: ramp range
@@ -895,43 +913,88 @@ struct Cloud {
@group(1) @binding(3) var<uniform> cloud: Cloud;
struct CIn {
@location(0) p: vec3<f32>,
@location(1) v: f32,
@location(2) f: vec4<f32>,
@location(3) rgb: vec4<f32>,
@location(0) a: vec3<f32>,
@location(1) b: vec3<f32>,
@location(2) c: vec3<f32>,
@location(8) before: vec3<f32>,
@location(9) after: vec3<f32>,
@location(3) v: vec3<f32>,
@location(4) f0: vec3<f32>,
@location(5) f1: vec3<f32>,
@location(10) f2: vec3<f32>,
@location(11) f3: vec3<f32>,
@location(6) size: vec2<f32>,
@location(7) rgb: vec4<f32>,
};
struct COut {
@builtin(position) pos: vec4<f32>,
@location(0) wp: vec3<f32>,
@location(1) v: f32,
@location(2) f: vec4<f32>,
@location(3) rgb: vec3<f32>,
@location(4) at: vec2<f32>, // the place on its disc
@location(5) @interpolate(flat) has: u32,
@location(1) n: vec3<f32>, // zero: not lit
@location(2) v: f32,
@location(3) f: vec4<f32>,
@location(4) rgb: vec3<f32>,
@location(5) at: vec2<f32>, // a dot's place on its disc
@location(6) @interpolate(flat) has: u32,
};
fn cloud_out(i: CIn, w: vec3<f32>, wp: vec3<f32>, pos: vec4<f32>, n: vec3<f32>) -> COut {
var o: COut;
o.pos = pos;
o.wp = wp;
o.n = n;
o.v = dot(w, i.v);
o.f = vec4<f32>(dot(w, i.f0), dot(w, i.f1), dot(w, i.f2), dot(w, i.f3));
o.rgb = i.rgb.rgb;
o.at = vec2<f32>(0.0);
o.has = u32(round(i.rgb.a * 255.0));
return o;
}
fn behind(c: vec4<f32>) -> bool {
return near_z(c) < 0.0;
}
@vertex
fn vs_cloud_dot(@builtin(vertex_index) vi: u32, i: CIn) -> COut {
var xs = array<f32, 6>(-1.0, 1.0, 1.0, -1.0, 1.0, -1.0);
var ys = array<f32, 6>(-1.0, -1.0, 1.0, -1.0, 1.0, 1.0);
let at = vec2<f32>(xs[vi], ys[vi]);
let size = max(cloud.style.y, 1.0);
let centre = u.vp * vec4<f32>(i.p, 1.0);
var o: COut;
o.pos = vec4<f32>(centre.xy + at * size / u.screen.xy * centre.w, centre.z, centre.w);
// Behind the eye: nothing.
if (near_z(centre) < 0.0) { o.pos = vec4<f32>(0.0, 0.0, 0.0, -1.0); }
o.wp = i.p;
o.v = i.v;
o.f = i.f;
o.rgb = i.rgb.rgb;
o.has = u32(round(i.rgb.a * 255.0));
let centre = u.vp * vec4<f32>(i.a, 1.0);
var pos = vec4<f32>(centre.xy + at * size / u.screen.xy * centre.w, centre.z, centre.w);
if (behind(centre)) { pos = vec4<f32>(0.0, 0.0, 0.0, -1.0); }
var o = cloud_out(i, vec3<f32>(1.0, 0.0, 0.0), i.a, pos, vec3<f32>(0.0));
// Dots under two pixels are squares: a disc that small loses its centre pixel.
o.at = at * select(1.0, 0.0, size < 2.0);
return o;
}
/// The least half-width of a segment on screen, in pixels.
const MIN_HALF_PX: f32 = 0.75;
/// The frame of a bead along `d`: along it, across it level, and up, its height axis.
fn bead_frame(d: vec3<f32>) -> mat3x3<f32> {
var t = vec3<f32>(1.0, 0.0, 0.0);
if (length(d) > 1e-9) { t = d / length(d); }
var side = cross(vec3<f32>(0.0, 0.0, 1.0), t);
if (length(side) < 1e-6) { side = vec3<f32>(1.0, 0.0, 0.0); }
side = normalize(side);
return mat3x3<f32>(t, side, cross(t, side));
}
/// Corner `j` (0…9) of a bead's cross-section, a stadium: its place across and up, and its
/// normal. Corners 0…4 go up the right half-disc, 5…9 down the left one, so 4–5 is the flat top
/// and 9–0 the flat bottom.
fn stadium(j: u32, size: vec2<f32>) -> vec4<f32> {
let k = j % 10u;
let right = k < 5u;
let a = -1.5707963 + 0.7853982 * f32(k % 5u) + select(3.1415927, 0.0, right);
let r = 0.5 * size.y;
let flat_half = 0.5 * max(size.x - size.y, 0.0);
return vec4<f32>(select(-flat_half, flat_half, right) + r * cos(a), r * sin(a), cos(a), sin(a));
}
/// Whether filter `f` keeps the value `v`.
fn cloud_keeps(f: vec4<f32>, v: f32) -> bool {
if (f.x < 0.5) { return true; }
@@ -940,6 +1003,101 @@ fn cloud_keeps(f: vec4<f32>, v: f32) -> bool {
return k >= 0 && k < 32 && ((bitcast<u32>(f.w) >> u32(k)) & 1u) != 0u;
}
/// Whether filter `f` leaves out a whole element with values `v` at its corners: all on one
/// side of a range, or one value at every corner, not kept. A set of values with corners that
/// differ may keep part of the element: its fragments decide.
fn cloud_drops(f: vec4<f32>, v: vec3<f32>) -> bool {
if (f.x > 1.5) { return all(v == vec3<f32>(v.x)) && !cloud_keeps(f, v.x); }
return f.x > 0.5 && (all(v < vec3<f32>(f.y)) || all(v > vec3<f32>(f.z)));
}
/// Whether a filter leaves out the whole element.
fn filtered_out(i: CIn) -> bool {
let has = u32(round(i.rgb.a * 255.0));
let values = array<vec3<f32>, 4>(i.f0, i.f1, i.f2, i.f3);
for (var k = 0u; k < 4u; k++) {
if (((has >> k) & 1u) != 0u && cloud_drops(cloud.filters[k], values[k])) { return true; }
}
return false;
}
/// The part in front of the near plane of the segment between clip-space `a` and `b`, as
/// fractions of it from `a`; empty (first above second) when all of it is behind. Clip-space
/// coordinates are affine along a line, so the crossing is exact.
fn in_front(a: vec4<f32>, b: vec4<f32>) -> vec2<f32> {
let za = near_z(a);
let zb = near_z(b);
if (za < 0.0 && zb < 0.0) { return vec2<f32>(1.0, 0.0); }
let t = za / (za - zb);
return vec2<f32>(select(0.0, t, za < 0.0), select(1.0, t, zb < 0.0));
}
@vertex
fn vs_cloud_segment(@builtin(vertex_index) vi: u32, i: CIn) -> COut {
// Corner `vi % 10` of the cross-section at the start (`vi < 10`) or the end; the index
// buffer joins them into the bead's sides.
let pa = u.vp * vec4<f32>(i.a, 1.0);
let pb = u.vp * vec4<f32>(i.b, 1.0);
let part = in_front(pa, pb);
if (filtered_out(i) || part.x > part.y) {
return cloud_out(i, vec3<f32>(1.0, 0.0, 0.0), i.a, vec4<f32>(0.0, 0.0, 0.0, -1.0), vec3<f32>(0.0));
}
// Where the near plane cuts it, the bead ends there, square; its numbers are exact at `e`.
let at_end = vi >= 10u;
let e = select(part.x, part.y, at_end);
let cut = e != select(0.0, 1.0, at_end);
let f = bead_frame(i.b - i.a);
let c = stadium(vi % 10u, i.size);
let axis = mix(i.a, i.b, e);
// Where it meets the next or the previous bead, the cross-section lies in the plane halfway
// between their directions, stretched across to keep its width: both beads end on the same
// section. A turn sharper than 120° ends square instead.
let other = select(i.a - i.before, i.after - i.b, at_end);
var across_dir = f[1];
var stretch = 1.0;
if (!cut && length(other) > 1e-9) {
let g = bead_frame(other);
let mid = f[1] + g[1];
if (length(mid) > 1e-6 && dot(normalize(mid), f[1]) > 0.5) {
across_dir = normalize(mid);
stretch = 1.0 / dot(across_dir, f[1]);
}
}
let wp = axis + across_dir * (c.x * stretch) + f[2] * c.y;
let ca = u.vp * vec4<f32>(axis, 1.0);
let cw = u.vp * vec4<f32>(wp, 1.0);
let half = u.screen.xy * 0.5;
var pos = cw;
// At least a pixel and a half across: a bead seen from afar, or a line without size. A corner
// behind the eye has no place on the screen and needs no widening: the rasteriser clips it.
if (cw.w > 0.0) {
let c0 = mix(pa, pb, part.x);
let c1 = mix(pa, pb, part.y);
let along = (c1.xy / c1.w - c0.xy / c0.w) * half;
var across = vec2<f32>(-along.y, along.x);
if (length(across) < 1e-6) { across = vec2<f32>(1.0, 0.0); }
across = normalize(across);
var px = (cw.xy / cw.w - ca.xy / ca.w) * half;
if (length(px) < MIN_HALF_PX * abs(c.z)) {
px = across * c.z * MIN_HALF_PX;
pos = vec4<f32>((ca.xy / ca.w + px / half) * cw.w, cw.z, cw.w);
}
}
let n = select(f[1] * c.z + f[2] * c.w, vec3<f32>(0.0), i.size.y <= 0.0);
return cloud_out(i, vec3<f32>(1.0 - e, e, 0.0), wp, pos, n);
}
@vertex
fn vs_cloud_triangle(@builtin(vertex_index) vi: u32, i: CIn) -> COut {
// Not `w[vi] = 1.0`: the Windows D3D12 shader compiler cannot write through a dynamic vector index.
let w = vec3<f32>(f32(vi == 0u), f32(vi == 1u), f32(vi == 2u));
let wp = i.a * w.x + i.b * w.y + i.c * w.z;
var n = cross(i.b - i.a, i.c - i.a);
if (length(n) > 0.0) { n = normalize(n); }
// The true clip-space corner, even behind the eye: the rasteriser clips a triangle exactly.
return cloud_out(i, w, wp, u.vp * vec4<f32>(wp, 1.0), n);
}
fn cloud_ramp(t: f32) -> vec3<f32> {
var stops = array<vec3<f32>, 5>(
vec3<f32>(40.0, 70.0, 200.0), vec3<f32>(30.0, 170.0, 230.0), vec3<f32>(60.0, 200.0, 90.0),
@@ -974,5 +1132,8 @@ fn fs_cloud(o: COut) -> @location(0) vec4<f32> {
} else if ((o.has & 16u) != 0u && mode > 0.5) {
c = cloud_ramp((o.v - cloud.style.z) / max(cloud.style.w - cloud.style.z, 1e-12));
}
if (length(o.n) > 0.5) {
c = c * (0.45 + 0.55 * abs(dot(normalize(o.n), normalize(u.back.xyz))));
}
return out_color(vec4<f32>(c, 1.0));
}
+7 -21
View File
@@ -210,13 +210,16 @@ fn map_plane_ref(m: &Mat, p: &mut PlaneRef) {
map_plane_ref(m, base);
map_geo_ref(m, point);
}
PlaneRef::AtAngle { base, axis_origin, axis_dir, line, .. } => {
PlaneRef::AtAngle { base, axis_origin, axis_dir, line, pivot, .. } => {
map_plane_ref(m, base);
*axis_origin = apply_point(m, *axis_origin);
*axis_dir = apply_vector(m, *axis_dir);
if let Some(g) = line {
map_geo_ref(m, g);
}
if let Some(g) = pivot {
map_geo_ref(m, g);
}
}
// Named origin planes and construction planes are the component's own.
PlaneRef::Origin { .. } | PlaneRef::Construction { .. } => {}
@@ -353,25 +356,7 @@ impl Document {
/// Every placement of each component: (component, occurrence path ids, world transform).
/// A branch stops past [`MAX_PLACEMENT_DEPTH`] levels, the walk past [`MAX_PLACEMENTS`] placements.
pub fn placements(&self) -> Vec<(u64, Vec<u64>, Mat)> {
let mut out = vec![(0, Vec::new(), IDENTITY)];
let mut stack: Vec<(u64, Vec<u64>, Mat)> = vec![(0, Vec::new(), IDENTITY)];
while let Some((comp, path, m)) = stack.pop() {
if out.len() > MAX_PLACEMENTS {
break;
}
// Only this branch stops past the depth limit; the others are still walked.
if path.len() > MAX_PLACEMENT_DEPTH {
continue;
}
for o in self.occurrences.iter().filter(|o| o.parent == comp) {
let mut p = path.clone();
p.push(o.id);
let w = mat_mul(&m, &o.transform);
out.push((o.component, p.clone(), w));
stack.push((o.component, p, w));
}
}
out
crate::occurrence_tree::OccurrenceTree::new(self).placements_with(|o| o.transform)
}
/// Every body as the world shows it: its world name ("Body1", or "Body1 (Occ:2)" for a
@@ -461,13 +446,14 @@ impl Document {
if self.component_within(parent, component) {
return true;
}
let tree = crate::occurrence_tree::OccurrenceTree::new(self);
let mut stack = vec![component];
let mut seen = std::collections::BTreeSet::new();
while let Some(c) = stack.pop() {
if !seen.insert(c) || seen.len() > MAX_PLACEMENTS {
continue;
}
for o in self.occurrences.iter().filter(|o| o.parent == c) {
for o in tree.children(c) {
if o.component == parent {
return true;
}
+8 -2
View File
@@ -119,11 +119,14 @@ fn remap_plane(p: &mut PlaneRef, ids: &BTreeMap<u64, u64>, bodies: &BTreeMap<Str
remap_plane(base, ids, bodies, depth + 1)?;
remap_geo(point, ids, bodies, depth + 1)?;
}
PlaneRef::AtAngle { base, line, .. } => {
PlaneRef::AtAngle { base, line, pivot, .. } => {
remap_plane(base, ids, bodies, depth + 1)?;
if let Some(g) = line {
remap_geo(g, ids, bodies, depth + 1)?;
}
if let Some(g) = pivot {
remap_geo(g, ids, bodies, depth + 1)?;
}
}
PlaneRef::Face { name, anchor, .. } => {
if let Some(name) = name {
@@ -323,11 +326,14 @@ fn checked_plane(p: &PlaneRef, depth: usize) -> crate::Result<()> {
}
match p {
PlaneRef::Offset { base, .. } | PlaneRef::Flipped { base, .. } => checked_plane(base, depth + 1)?,
PlaneRef::AtAngle { base, line, .. } => {
PlaneRef::AtAngle { base, line, pivot, .. } => {
checked_plane(base, depth + 1)?;
if let Some(g) = line {
checked_geo(g, depth + 1)?;
}
if let Some(g) = pivot {
checked_geo(g, depth + 1)?;
}
}
PlaneRef::Midplane { a, b } | PlaneRef::TwoEdges { a, b } | PlaneRef::AxisPoint { axis: a, point: b } => {
checked_geo(a, depth + 1)?;
+1 -1
View File
@@ -185,7 +185,7 @@ fn shell_planar_format13_keeps_v12_documents_and_refuses_future() {
}
assert_eq!(legacy, current, "the migration preserves every prior field");
legacy["format"] = json!(crate::format::format_string(crate::format::FORMAT + 1));
assert!(crate::format::read(&legacy.to_string()).is_err());
assert!(crate::format::read(&legacy.to_string()).unwrap().newer_file.is_some());
}
#[test]
+2 -1
View File
@@ -923,9 +923,10 @@ impl PlaneRef {
refs.push(point.as_mut());
refs
}
PlaneRef::AtAngle { base, line, .. } => {
PlaneRef::AtAngle { base, line, pivot, .. } => {
let mut v = base.refs_mut();
v.extend(line.as_deref_mut());
v.extend(pivot.as_deref_mut());
v
}
PlaneRef::Offset { base, .. } | PlaneRef::Flipped { base, .. } => base.refs_mut(),
+72 -6
View File
@@ -61,6 +61,9 @@ pub enum PlaneRef {
/// Another plane rotated about a line (in world coordinates) by an angle expression. With
/// `line`, an edge or round face of the model ([`crate::construct::model_axis`]), the line is
/// found there on each evaluation and `axis_origin`/`axis_dir` are where it was when picked.
/// With `direction_only`, only the axis direction is used: it is projected onto the base
/// (nothing of that projection is kept as geometry) and the plane turns about the line
/// through `pivot`, or through the base origin when `pivot` is absent.
AtAngle {
base: Box<PlaneRef>,
axis_origin: Vec3,
@@ -68,6 +71,12 @@ pub enum PlaneRef {
angle: Expr,
#[serde(default, skip_serializing_if = "Option::is_none")]
line: Option<Box<GeoRef>>,
/// Use only the axis direction (projected onto the base), not the axis as a full line.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
direction_only: bool,
/// Pivot for `direction_only`; absent means the base plane's origin.
#[serde(default, skip_serializing_if = "Option::is_none")]
pivot: Option<Box<GeoRef>>,
},
/// A construction plane feature (by id).
Construction { feature: FeatureId },
@@ -321,6 +330,12 @@ pub struct Extent {
/// Through all bodies in the direction(s) instead of `distance`.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub through_all: bool,
/// Two Sides as older builds read it: `distance2` goes the other way from wherever the
/// first side went, so a negative entered distance sends it back along the normal. Saved
/// designs from before entered distances kept their sides carry it, so they rebuild as
/// they did whatever their expressions evaluate to; new and edited extrudes leave it off.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub second_follows_first: bool,
/// Taper angle (expression; positive grows the profile).
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(angle = "Taper angle")]
@@ -452,8 +467,14 @@ pub enum AxisRef {
SketchAxis { axis: String },
/// World X / Y / Z axis through the origin.
World { axis: String },
/// Any line in the sketch plane, in world coordinates.
Line { origin: Position, dir: Dir },
/// Any line in the sketch plane, in world coordinates. The origin's coordinates are length
/// expressions, so the axis follows parameter changes.
Line {
#[serde(with = "point_expr")]
#[walk(placement)]
origin: PointExpr,
dir: Dir,
},
/// An edge or round face of the model (a straight or circular edge, a cylinder, cone or
/// torus face), found again on each evaluation: [`crate::construct::model_axis`].
Model { geo: GeoRef },
@@ -659,6 +680,12 @@ impl AlignFrame {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, solidcraft_derive::Walk)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum FeatureKind {
/// A feature a newer SolidCraft saved that this build can't read ([`crate::newer_file`]): it
/// holds its place on the timeline, reports an error when evaluated, and goes back into the
/// file as it came.
NewerFileFeature {
raw: crate::newer_file::RawFeature,
},
/// A recorded timeline ownership boundary. Geometry is converted from its
/// current local frame into the destination component frame exactly once.
BodyTransfer {
@@ -1044,6 +1071,8 @@ pub enum FeatureKind {
Split {
body: BodyName,
plane: PlaneRef,
// The tool face is picked (and named), so the split follows it (#11674).
#[walk(picks_faces)]
#[serde(default, skip_serializing_if = "Option::is_none")]
tool: Option<FaceAt>,
/// A straight line of this sketch as the tool (#1552): the plane through it along the
@@ -1479,7 +1508,7 @@ pub enum FeatureKind {
},
/// Plastic lip (raised band) or groove (recess) along the rim face picked at `face`.
Lip {
face: Position,
face: FacePick,
#[walk(length = "Width")]
width: Expr,
#[walk(length = "Height")]
@@ -2600,6 +2629,7 @@ impl FeatureKind {
/// Short type name (timeline tooltip, `inspect`).
pub fn type_name(&self) -> &'static str {
match self {
FeatureKind::NewerFileFeature { .. } => "NewerFileFeature",
FeatureKind::BodyTransfer { .. } => "BodyTransferFeature",
FeatureKind::Sketch { .. } => "Sketch",
FeatureKind::Extrude { .. } => "ExtrudeFeature",
@@ -2685,6 +2715,7 @@ impl FeatureKind {
/// Design-file variant key (e.g. `"extrude"`, `"box"`, `"sheet_flange"`).
pub fn kind_key(&self) -> &'static str {
match self {
FeatureKind::NewerFileFeature { .. } => "newer_file_feature",
FeatureKind::BodyTransfer { .. } => "body_transfer",
FeatureKind::Sketch { .. } => "sketch",
FeatureKind::Extrude { .. } => "extrude",
@@ -2847,6 +2878,7 @@ impl FeatureKind {
/// Default name prefix (`Extrude` → `Extrude1`).
pub fn base_name(&self) -> &'static str {
match self {
FeatureKind::NewerFileFeature { .. } => "Unknown",
FeatureKind::BodyTransfer { .. } => "BodyTransfer",
FeatureKind::Sketch { .. } => "Sketch",
FeatureKind::Extrude { .. } => "Extrude",
@@ -3119,6 +3151,10 @@ pub fn is_counted_body_name(name: &str) -> bool {
/// The document.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Document {
/// Set when a newer SolidCraft saved the file this came from: what to tell the user, and what
/// this build couldn't read and keeps for the next save ([`crate::newer_file`]).
#[serde(skip)]
pub newer_file: Option<std::sync::Arc<crate::newer_file::NewerFile>>,
/// Named image intent and immutable embedded HDR sources. Active jobs and device state are not saved.
#[serde(default, skip_serializing_if = "crate::render_settings::RenderSettings::is_empty")]
pub render: std::sync::Arc<crate::render_settings::RenderSettings>,
@@ -3480,6 +3516,7 @@ impl Document {
pub fn new(name: &str) -> Self {
Document {
newer_file: None,
render: Default::default(),
format: default_format(),
format_pending: crate::format::pending_names(),
@@ -3901,7 +3938,12 @@ impl Document {
/// Read a design file's JSON (any format this build knows; older ones are upgraded).
pub fn from_json(s: &str) -> Result<Document> {
crate::render_resources::check_json_budget(s)?;
// A newer build's resources may not read here; the section stays out then (`newer_file`).
if let Err(why) = crate::render_resources::check_json_budget(s).and_then(|()| crate::render_texture_resources::check_json_budget(s))
&& !crate::format::says_newer(s)
{
return Err(why);
}
let mut d = crate::format::read(s)?;
if d.features.len() > MAX_FEATURES || d.params.len() > MAX_PARAMS {
return Err(DocError::Invalid("document too large".into()));
@@ -3913,6 +3955,11 @@ impl Document {
Ok(d)
}
pub fn to_json(&self) -> String {
if self.newer_file.is_some() {
let mut value = serde_json::to_value(self).unwrap_or_default();
crate::newer_file::restore(self, &mut value);
return serde_json::to_string_pretty(&value).unwrap_or_default();
}
serde_json::to_string_pretty(self).unwrap_or_default()
}
@@ -4396,7 +4443,7 @@ impl Document {
let b = self.resolve_plane_in(vals, base, depth + 1, st)?;
Ok(b.offset(Self::eval_in(vals, distance, Kind::Length)?))
}
PlaneRef::AtAngle { base, axis_origin, axis_dir, angle, line } => {
PlaneRef::AtAngle { base, axis_origin, axis_dir, angle, line, direction_only, pivot } => {
let b = self.resolve_plane_in(vals, base, depth + 1, st)?;
let a = Self::eval_in(vals, angle, Kind::Angle)?;
let (axis_origin, axis_dir) = match line {
@@ -4406,7 +4453,26 @@ impl Document {
}
None => (*axis_origin, *axis_dir),
};
let d = axis_dir.normalized().ok_or_else(|| DocError::Invalid("rotation axis".into()))?;
let (axis_origin, d) = if *direction_only {
let n = b.normal();
let in_plane = axis_dir - n * n.dot(axis_dir);
let d = in_plane
.normalized()
.ok_or_else(|| DocError::Invalid("the axis is square to the plane, so it has no direction in the plane".into()))?;
let pivot = match pivot {
Some(g) => {
let st = st.ok_or_else(|| DocError::Invalid("this plane is built from the model: evaluate first".into()))?;
crate::construct::point(&crate::construct::Ctx { doc: self, vals, st, depth }, g)?
}
None => b.origin,
};
// Keep the pivot in the base so a slightly off pick still turns about an in-plane line.
let pivot = pivot - n * n.dot(pivot - b.origin);
(pivot, d)
} else {
let d = axis_dir.normalized().ok_or_else(|| DocError::Invalid("rotation axis".into()))?;
(axis_origin, d)
};
let rot = |v: Vec3| v * a.cos() + d.cross(v) * a.sin() + d * (d.dot(v) * (1.0 - a.cos()));
let o = axis_origin + rot(b.origin - axis_origin);
Plane::new(o, rot(b.x), rot(b.y)).ok_or_else(|| DocError::Invalid("degenerate plane".into()))
+125 -3
View File
@@ -84,6 +84,65 @@ pub struct Drawing {
pub language: String,
/// In order, each behind its own `Arc`, so an undo step shares every sheet it didn't change.
pub sheets: Vec<Arc<Sheet>>,
/// Set when a newer SolidCraft saved the file this came from.
#[serde(skip)]
pub newer_file: Option<Arc<NewerDrawing>>,
}
/// The fields a drawing's record has: anything else in a file is a newer build's.
const RECORD_FIELDS: [&str; 8] = ["format", "format_pending", "name", "sources", "properties", "units", "language", "sheets"];
/// Move the elements of the list `key` that don't read as `T` out of a newer build's drawing.
fn take_unread<T: serde::de::DeserializeOwned>(top: &mut serde_json::Map<String, Value>, key: &str, one: &str, file: &mut NewerDrawing) {
let Some(Value::Array(list)) = top.get_mut(key) else { return };
let mut readable = Vec::with_capacity(list.len());
for element in std::mem::take(list) {
match DRAWING.check_numbers(&element, "properties").and_then(|()| serde_json::from_value::<T>(element.clone()).map_err(damaged)) {
Ok(_) => readable.push(element),
Err(why) => {
let name = element.get("name").or(element.get("path")).and_then(Value::as_str).unwrap_or("unnamed");
file.not_understood.push(format!("{one} `{name}` ({why})"));
file.elements.push((key.to_string(), element));
}
}
}
*list = readable;
}
/// What a drawing file says and holds that this build doesn't read ([`Drawing::from_newer`]).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct NewerDrawing {
/// The number the file says.
pub format: u32,
/// The number this build reads up to.
pub reads_up_to: u32,
/// The changes the file names that this build doesn't have.
pub unknown_changes: Vec<String>,
/// What this build couldn't read, by name.
pub not_understood: Vec<String>,
fields: Vec<(String, Value)>,
elements: Vec<(String, Value)>,
}
impl NewerDrawing {
/// What to tell the user.
pub fn notice(&self) -> String {
format!(
"Saved by a newer SolidCraft (format {}, this version reads up to {}). If something's missing, update.",
self.format, self.reads_up_to
)
}
fn restore(&self, value: &mut Value) {
for (key, field) in &self.fields {
value[key] = field.clone();
}
for (key, element) in &self.elements {
if let Some(list) = value.get_mut(key).and_then(Value::as_array_mut) {
list.push(element.clone());
}
}
}
}
/// A design the drawing draws.
@@ -175,6 +234,7 @@ impl Drawing {
units: Units::default(),
language: language.into(),
sheets: vec![Arc::new(first)],
newer_file: None,
};
drawing.validate()?;
Ok(drawing)
@@ -188,11 +248,65 @@ impl Drawing {
}
let mut v: Value = serde_json::from_str(s).map_err(|e| DocError::Invalid(format!("not a SolidCraft drawing: {e}")))?;
let (version, applied) = DRAWING.take_version(&mut v)?;
if let Some(newer) = DRAWING.newer(version, applied.as_deref()) {
return Self::from_newer(v, version, applied, newer);
}
let d = Self::decode(v, version, applied.as_deref())?;
Ok(Drawing { format: DRAWING.format_string(DRAWING.current()), format_pending: DRAWING.pending_names(), ..d })
}
fn decode(mut v: Value, version: u32, applied: Option<&[String]>) -> Result<Drawing> {
DRAWING.check_numbers(&v, "properties")?;
DRAWING.upgrade(&mut v, version, applied.as_deref())?;
DRAWING.upgrade(&mut v, version, applied)?;
let d: Drawing = serde_json::from_value(v).map_err(damaged)?;
d.validate().map_err(damaged)?;
Ok(Drawing { format: DRAWING.format_string(DRAWING.current()), format_pending: DRAWING.pending_names(), ..d })
Ok(d)
}
/// A drawing a newer build saved: it opens with what this build can read of it. Everything in a
/// drawing refuses a field it doesn't know, so what it can't read shows as a top-level field,
/// a source or a sheet that doesn't read, which stays out and is named in
/// [`NewerDrawing::not_understood`]; [`Drawing::to_json`] writes it all back, with the file's
/// format. It takes at least one sheet this build can read to open.
fn from_newer(mut v: Value, version: u32, applied: Option<Vec<String>>, newer: crate::format::Newer) -> Result<Drawing> {
let list = applied.as_deref();
let can_read = |v: Value| Self::decode(v, version, list).map_err(|e| e.to_string());
let mut file = NewerDrawing {
format: newer.format,
reads_up_to: newer.reads_up_to,
unknown_changes: newer.unknown_changes.clone(),
..NewerDrawing::default()
};
let mut read = can_read(v.clone());
if read.is_err() {
let top = v.as_object_mut().ok_or_else(|| damaged("expected a JSON object"))?;
let keys: Vec<String> = top.keys().cloned().collect();
for key in keys {
if !RECORD_FIELDS.contains(&key.as_str())
&& let Some(value) = top.remove(&key)
{
file.not_understood.push(format!("field `{key}`"));
file.fields.push((key, value));
}
}
take_unread::<Sheet>(top, "sheets", "sheet", &mut file);
take_unread::<Source>(top, "sources", "source", &mut file);
read = can_read(v);
}
let d = read.map_err(|why| {
DocError::Invalid(format!(
"this drawing was saved by a newer SolidCraft (format {}, this version reads up to {}) and this version can't read it: {why}. Update SolidCraft to open it",
newer.format, newer.reads_up_to
))
})?;
let (format, format_pending) = if newer.format > newer.reads_up_to {
(DRAWING.format_string(newer.format), applied.unwrap_or_default())
} else {
let mut pending = DRAWING.pending_names();
pending.extend(newer.unknown_changes.iter().cloned());
(DRAWING.format_string(DRAWING.current()), pending)
};
Ok(Drawing { format, format_pending, newer_file: Some(Arc::new(file)), ..d })
}
/// The drawing's JSON: the same bytes for the same drawing. JSON longer than a read takes
@@ -200,7 +314,15 @@ impl Drawing {
/// properties are kept as they came, and indenting them can make a drawing that read in
/// under the limit save over it.
pub fn to_json(&self) -> Result<String> {
let text = serde_json::to_string_pretty(self).map_err(|e| DocError::Invalid(format!("drawing not saved: {e}")))?;
let unsaved = |e: serde_json::Error| DocError::Invalid(format!("drawing not saved: {e}"));
let text = match &self.newer_file {
None => serde_json::to_string_pretty(self).map_err(unsaved)?,
Some(file) => {
let mut value = serde_json::to_value(self).map_err(unsaved)?;
file.restore(&mut value);
serde_json::to_string_pretty(&value).map_err(unsaved)?
}
};
require(text.len() <= MAX_DRAWING_BYTES, || format!("drawing too large to save (at most {} MiB)", MAX_DRAWING_BYTES >> 20))?;
Ok(text)
}
@@ -8,7 +8,7 @@ whole format bump: never edit the `DRAWING` family in `format.rs`, another chang
- Name it after the change, lowercase with dashes: `sheet-views.rs`.
- Start it with a `//!` note: what is saved, what an older drawing gets without it, and what
the last release does with a drawing that has it (it refuses the newer format number).
the last release does with a drawing that has it (it warns about the newer format number, opens what it can read and keeps the rest).
- If older drawings must be rewritten as they open, add the step to the same file:
```rust
+42 -13
View File
@@ -325,28 +325,28 @@ fn damaged_files_are_refused_and_never_patched() {
let why = refused(&d, edit);
assert!(why.starts_with("damaged drawing: ") && why.contains("unknown field"), "{i}: {why}");
}
// A newer build's file is refused as one before anything in it is read: a field this build
// doesn't know, or a number out of its range.
// A newer build's file isn't held to this build's rules: a field it doesn't know, or a sheet with
// a number out of range, stays out. With no sheet left to show, it says why.
let (current, newer) = (DRAWING.current(), DRAWING.current() + 1);
let why = refused(&d, |v| {
v["format"] = json!(DRAWING.format_string(newer));
v["views"] = json!([]);
v["sheets"][0]["dpi"] = json!(1e10);
});
assert_eq!(
why,
format!(
"this drawing was saved by a newer SolidCraft (file format {newer}; this version reads formats up to {current}): update SolidCraft to open it"
)
assert_eq!(why, "read");
let why = refused(&d, |v| {
v["format"] = json!(DRAWING.format_string(newer));
v["sheets"] = json!([{"id": "future", "a_future_field": true}]);
});
assert!(
why.starts_with(&format!(
"this drawing was saved by a newer SolidCraft (format {newer}, this version reads up to {current}) and this version can't read it: "
)),
"{why}"
);
// A list of pending changes means nothing beside a format made of none; beside the pending
// number, a change this build doesn't know is a newer build's.
let why = refused(&d, |v| v["format_pending"] = json!(["a-later-change"]));
if DRAWING.pending_names().is_empty() {
assert_eq!(why, "read");
} else {
assert!(why.contains("newer SolidCraft"), "{why}");
}
assert_eq!(why, "read");
// A design is never read as a drawing, nor a drawing as a design.
let design = crate::Document::new("Plate").to_json();
let design_format = crate::format::format_string(crate::format::FORMAT);
@@ -551,3 +551,32 @@ fn mutate(v: &mut Value, pick: &mut usize, lie: &Value, how: u64) -> bool {
_ => false,
}
}
/// A drawing a newer build saved opens, with what it can read, and saving keeps the rest.
#[test]
fn a_newer_builds_drawing_opens_and_keeps_what_it_cant_read() {
let d = plate();
let mut file: Value = file(&d);
assert!(file.as_object().unwrap().keys().all(|key| RECORD_FIELDS.contains(&key.as_str())), "a new field goes in RECORD_FIELDS");
let newer = DRAWING.format_string(DRAWING.current() + 1);
file["format"] = json!(newer);
// With nothing it can't read: the same drawing.
let opened = Drawing::from_json(&file.to_string()).unwrap();
assert_eq!(opened.sheets, d.sheets);
assert!(opened.newer_file.as_ref().unwrap().notice().starts_with("Saved by a newer SolidCraft"));
assert_eq!(serde_json::from_str::<Value>(&opened.to_json().unwrap()).unwrap()["format"], json!(newer));
// A field and a second sheet it can't read stay out, named, and go back.
file["views"] = json!([{"id": "a"}]);
let mut future_sheet = file["sheets"][0].clone();
future_sheet["name"] = json!("Future");
future_sheet["future_layers"] = json!([1]);
file["sheets"].as_array_mut().unwrap().push(future_sheet);
let opened = Drawing::from_json(&file.to_string()).unwrap();
assert_eq!(opened.sheets, d.sheets);
let said = &opened.newer_file.as_ref().unwrap().not_understood;
assert!(said.iter().any(|what| what.contains("views")) && said.iter().any(|what| what.contains("Future")), "{said:?}");
let again: Value = serde_json::from_str(&opened.to_json().unwrap()).unwrap();
assert_eq!(again["views"], file["views"]);
assert_eq!(again["sheets"].as_array().unwrap().len(), d.sheets.len() + 1);
assert_eq!(again["format"], json!(newer));
}
+381 -75
View File
@@ -254,6 +254,35 @@ impl ModelBody {
self.mesh.clone()
}
/// Identity of the display cells of the solid this body shows: its own, or for a rigidly
/// placed copy its definition's (shared by every placement of it).
fn solid_id(&self) -> usize {
self.mesh_placement.as_deref().map_or(Arc::as_ptr(&self.mesh) as usize, placed_mesh::PlacedMesh::definition_id)
}
/// How this body is drawn moved from `before`: [`crate::IDENTITY`] when it is `before` where
/// it stood (its meshes and plans the same), the rigid motion between them when it is the same
/// solid placed elsewhere (a dragged component's body: `before`'s meshes and plans drawn moved
/// show it; a definition shown where it is defined is its own placement), `None` when it is
/// another body or its geometry differs.
pub fn display_moved_from(&self, before: &ModelBody) -> Option<crate::Mat> {
if self.name != before.name || self.solid_id() != before.solid_id() {
return None;
}
let (now, was) = (self.mesh_placement.as_deref(), before.mesh_placement.as_deref());
if let (Some(now), Some(was)) = (now, was)
&& now.tolerance.to_bits() != was.tolerance.to_bits()
{
return None;
}
let at = |p: Option<&placed_mesh::PlacedMesh>| p.map_or(crate::IDENTITY, |p| *p.transform());
let (now, was) = (at(now), at(was));
if now == was {
return Some(crate::IDENTITY);
}
Some(crate::mat_mul(&now, &crate::mat_inverse(&was)?))
}
/// [`ModelBody::mesh`] as a shared mesh and the move that places it: for a rigid placement
/// of a definition, the definition's own mesh (the same one for every placement of it) and
/// the placement; for any other body its own mesh, unmoved. The mesh moved by the matrix is
@@ -438,6 +467,21 @@ fn removed_note(gone: &[Vec3]) -> String {
}
}
thread_local! {
/// Current feature evaluation validation mode (`EachStep`, `EachFeature`, or `Off`).
static EVAL_VALIDATION: std::cell::Cell<kernel::Validation> = const { std::cell::Cell::new(kernel::Validation::EachFeature) };
}
/// Restores `EVAL_VALIDATION` to its previous value when dropped.
struct EvalValidationGuard(kernel::Validation);
impl Drop for EvalValidationGuard {
/// Restores the saved validation mode on scope exit.
fn drop(&mut self) {
EVAL_VALIDATION.set(self.0);
}
}
/// A blend's refusal as too large (`why`) with the largest `size` (radius, distance) that fits,
/// as `search` finds it by building candidates ([`kernel::fillet_max`]): the refusal as it is
/// when none fits or the search itself fails (a candidate failing for another reason says
@@ -447,6 +491,9 @@ fn with_largest_fit(
size: &str,
search: impl FnOnce() -> std::result::Result<Option<(f64, kernel::Body)>, kernel::KernelError>,
) -> kernel::KernelError {
if EVAL_VALIDATION.get() == kernel::Validation::Off {
return kernel::KernelError::TooLarge(why);
}
// On solidcore the refusal names a size already, less a margin (34.99 where 35 builds), and
// its edge: the edge stays, the size is the largest the search builds, solidcore's where the
// search finds none.
@@ -659,7 +706,7 @@ impl ModelState {
}
/// A digest of everything later features can read: bodies (by content identity), sketches,
/// the body counter, threads and sheet bodies.
/// the body counter, threads, sheet bodies and construction geometry.
fn digest(&self) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
for b in &self.bodies {
@@ -701,12 +748,19 @@ impl ModelState {
}
serde_json::to_string(&self.threads).unwrap_or_default().hash(&mut h);
serde_json::to_string(&self.sheets).unwrap_or_default().hash(&mut h);
// Construction axes, planes and points so far: features find them here by name, and a
// result taken from the cache brings back the ones it was made with, so one moved past
// a feature must change the key (#9544). A state without any hashes as before.
if !self.construct.is_empty() {
serde_json::to_string(&self.construct).unwrap_or_default().hash(&mut h);
}
h.finish()
}
/// After a feature evaluated to this state from `before` with cache key `key`: bodies it
/// made or changed take that key, sketches it made a digest of their solved geometry (see
/// [`quantize`]); the rest keep theirs.
/// `f` must have gone through [`naming_feature`]: face naming reads no parameter values.
fn stamp(&mut self, doc: &Document, before: &Arc<ModelState>, f: &Feature, key: u64, mats: Vec<Mat>) {
let feature = f.id;
let depth = before.bodies.iter().filter_map(|b| b.names.depth()).max().unwrap_or(0) + 1;
@@ -777,7 +831,7 @@ impl ModelState {
let mut h = std::collections::hash_map::DefaultHasher::new();
serde_json::to_string(&s.plane).unwrap_or_default().hash(&mut h);
let mut sketch = s.sketch.clone();
quantize(&mut sketch);
fingerprint_sketch(&mut sketch);
serde_json::to_string(&sketch).unwrap_or_default().hash(&mut h);
s.report.ok().hash(&mut h);
self.sketch_keys.insert(feature, h.finish());
@@ -1084,8 +1138,8 @@ pub struct Model {
pub no_cache: bool,
/// Results kept besides the cache ([`Model::keep`]).
kept: KeptSlot,
/// The imported texts' digests the last `evaluate` fingerprinted the features with.
step_digests: StepDigests,
/// Embedded imports' content digests from the last evaluation.
import_digests: ImportDigests,
/// The address of the shared document whose fingerprints the results were last given in
/// full, with [`fingerprints_digest`] of them then ([`Model::pinned_prefix`]). Only an
/// address: a reference to the document would make every in-place edit of it a copy.
@@ -1124,7 +1178,7 @@ fn param_names(f: &Feature) -> Vec<String> {
names
}
fn fingerprint(doc: &Document, prev: u64, f: &Feature, vals: &BTreeMap<String, Value>, rolled_back: bool, steps: &StepDigests) -> u64 {
fn fingerprint(doc: &Document, prev: u64, f: &Feature, vals: &BTreeMap<String, Value>, rolled_back: bool, steps: &ImportDigests) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
prev.hash(&mut h);
rolled_back.hash(&mut h);
@@ -1145,34 +1199,73 @@ fn fingerprint(doc: &Document, prev: u64, f: &Feature, vals: &BTreeMap<String, V
h.finish()
}
/// The digests of the Import features' STEP texts, by feature id, with the text each was taken
/// from. Every evaluation fingerprints every feature, and writing out a design's imported files
/// each time made every command as slow as the files are large: a text is shared (`Arc`), so
/// its digest is taken once and kept while the feature holds that same text.
/// Content digests for embedded imports. Shared, unchanged arrays/text are read once;
/// feature metadata is still fingerprinted on every evaluation. Rebuilding this map prunes
/// removed features and never trusts an address without retaining and checking its Arc.
#[derive(Clone, Debug, Default)]
struct StepDigests(std::collections::HashMap<u64, (Arc<str>, u64)>);
struct ImportDigests {
steps: std::collections::HashMap<u64, (Arc<str>, u64)>,
meshes: std::collections::HashMap<u64, Vec<MeshDigest>>,
}
impl StepDigests {
/// The digests of `doc`'s imported texts, reusing `known` for texts it has seen.
fn of(doc: &Document, known: &StepDigests) -> StepDigests {
let texts = doc.features.iter().filter_map(|f| match &f.kind {
FeatureKind::Import { step, .. } => Some((f.id, step)),
_ => None,
});
StepDigests(
texts
.map(|(id, step)| {
let digest = known.get(id, step).unwrap_or_else(|| step_text_digest(step));
(id, (step.clone(), digest))
})
.collect(),
)
#[derive(Clone, Debug)]
struct MeshDigest {
positions: Arc<[f64]>,
triangles: Arc<[u32]>,
digest: u64,
}
impl MeshDigest {
fn matches(&self, mesh: &crate::MeshData) -> bool {
Arc::ptr_eq(&self.positions, &mesh.positions) && Arc::ptr_eq(&self.triangles, &mesh.triangles)
}
}
impl ImportDigests {
fn of(doc: &Document, known: &ImportDigests) -> ImportDigests {
let mut out = ImportDigests::default();
for f in &doc.features {
match &f.kind {
FeatureKind::Import { step, .. } => {
let digest = known.get(f.id, step).unwrap_or_else(|| step_text_digest(step));
out.steps.insert(f.id, (step.clone(), digest));
}
FeatureKind::MeshImport { meshes, .. } => {
let digests = meshes
.iter()
.enumerate()
.map(|(index, mesh)| {
let digest = known.mesh(f.id, index, mesh).unwrap_or_else(|| mesh_content_digest(mesh));
MeshDigest { positions: mesh.positions.clone(), triangles: mesh.triangles.clone(), digest }
})
.collect();
out.meshes.insert(f.id, digests);
}
_ => {}
}
}
out
}
/// The digest of `step`, when it is the text feature `id` was last seen with.
fn get(&self, id: u64, step: &Arc<str>) -> Option<u64> {
self.0.get(&id).filter(|(text, _)| Arc::ptr_eq(text, step)).map(|(_, digest)| *digest)
self.steps.get(&id).filter(|(text, _)| Arc::ptr_eq(text, step)).map(|(_, digest)| *digest)
}
fn mesh(&self, id: u64, index: usize, mesh: &crate::MeshData) -> Option<u64> {
self.meshes.get(&id)?.get(index).filter(|cached| cached.matches(mesh)).map(|cached| cached.digest)
}
}
fn mesh_content_digest(mesh: &crate::MeshData) -> u64 {
#[cfg(test)]
cost_tests::count(|c| c.mesh_digested += 1);
let mut h = std::collections::hash_map::DefaultHasher::new();
mesh.positions.len().hash(&mut h);
for coordinate in mesh.positions.iter() {
coordinate.to_bits().hash(&mut h);
}
mesh.triangles.hash(&mut h);
h.finish()
}
fn step_text_digest(text: &str) -> u64 {
@@ -1207,13 +1300,20 @@ fn quantize(sk: &mut solidcraft_sketch::Sketch) {
}
}
/// The stored solve as a cache key: on the [`QUANTUM`] grid, without the editor state
/// (see [`solidcraft_sketch::Sketch::clear_id_counters`]).
fn fingerprint_sketch(sk: &mut solidcraft_sketch::Sketch) {
quantize(sk);
sk.clear_id_counters();
}
/// A feature as fingerprinted: its definition, a sketch's stored solve quantized, an imported
/// file's text by its digest.
/// file's text or mesh payload by its content digest.
/// Hash [`feature_json`] of `f` into `h`. Every evaluation fingerprints every feature, and in a
/// design built command by command almost all of them are the same as last time: the text of a
/// feature is kept (by id) with the feature it was written from, and written again only when the
/// feature differs. An Import's text also depends on its STEP digest, so it is written each time
/// as before.
/// feature differs. Embedded imports also depend on their content digests, so their small
/// metadata-and-digest representation is written each time without comparing large arrays.
///
/// The texts are shared by every thread: a command evaluated ahead runs on a new worker thread
/// each time (the desktop's deferred commands and delete checks, the control channel's ahead
@@ -1221,10 +1321,10 @@ fn quantize(sk: &mut solidcraft_sketch::Sketch) {
/// feature again. The lock is held only to look up or store an entry, never while a feature is
/// compared or written; an entry is the same whichever thread wrote it, since the text is a
/// function of the feature alone.
fn hash_feature_json(f: &Feature, steps: &StepDigests, h: &mut impl Hasher) {
fn hash_feature_json(f: &Feature, steps: &ImportDigests, h: &mut impl Hasher) {
type Written = std::collections::HashMap<u64, Arc<(Feature, String)>>;
static WRITTEN: std::sync::LazyLock<std::sync::Mutex<Written>> = std::sync::LazyLock::new(Default::default);
if matches!(f.kind, FeatureKind::Import { .. }) {
if matches!(f.kind, FeatureKind::Import { .. } | FeatureKind::MeshImport { .. }) {
feature_json(f, steps).hash(h);
return;
}
@@ -1247,7 +1347,21 @@ fn hash_feature_json(f: &Feature, steps: &StepDigests, h: &mut impl Hasher) {
written.insert(f.id, entry);
}
fn feature_json(f: &Feature, steps: &StepDigests) -> String {
fn feature_json(f: &Feature, steps: &ImportDigests) -> String {
if matches!(&f.kind, FeatureKind::MeshImport { .. }) {
let mut g = f.clone();
let mut digests = Vec::new();
if let FeatureKind::MeshImport { meshes, .. } = &mut g.kind {
for (index, mesh) in meshes.iter_mut().enumerate() {
digests.push(steps.mesh(f.id, index, mesh).unwrap_or_else(|| mesh_content_digest(mesh)));
mesh.positions = Arc::from([]);
mesh.triangles = Arc::from([]);
}
}
// Keep the complete definition (including mesh order, names and colors) without
// writing the large numeric arrays out again. Equal content in fresh Arcs hashes alike.
return serde_json::to_string(&(g, digests)).unwrap_or_default();
}
if let FeatureKind::Import { step, .. } = &f.kind {
let mut g = f.clone();
if let FeatureKind::Import { step: text, .. } = &mut g.kind {
@@ -1259,7 +1373,7 @@ fn feature_json(f: &Feature, steps: &StepDigests) -> String {
if let FeatureKind::Sketch { .. } = &f.kind {
let mut g = f.clone();
if let FeatureKind::Sketch { sketch, .. } = &mut g.kind {
quantize(sketch);
fingerprint_sketch(sketch);
}
return serde_json::to_string(&g).unwrap_or_default();
}
@@ -1322,6 +1436,12 @@ impl Document {
pub fn evaluation_values(&self) -> BTreeMap<String, Value> {
evaluation_values(self).0
}
/// A placement point read as the features read it: the parameters, and a bare number
/// beside them in the design's length unit (#9015).
pub fn eval_point(&self, p: &crate::PointExpr) -> Result<Vec3> {
point(&self.evaluation_values(), p)
}
}
/// The values features are evaluated with: the parameters, and the design's length unit.
@@ -1344,7 +1464,7 @@ fn chain_fingerprint(
f: &Feature,
prev: u64,
rolled_back: bool,
steps: &StepDigests,
steps: &ImportDigests,
st: Option<&ModelState>,
) -> u64 {
let mut fp = fingerprint(doc, prev, f, vals, rolled_back, steps) ^ placement_digest(doc, f, st);
@@ -1422,7 +1542,7 @@ fn context_digest(doc: &Document, vals: &BTreeMap<String, Value>) -> u64 {
for f in doc.features.iter().filter(|f| {
matches!(f.kind, FeatureKind::ConstructionPlane { .. } | FeatureKind::ConstructionAxis { .. } | FeatureKind::ConstructionPoint { .. })
}) {
fingerprint(doc, 0, f, vals, false, &StepDigests::default()).hash(&mut h);
fingerprint(doc, 0, f, vals, false, &ImportDigests::default()).hash(&mut h);
}
doc.units.hash(&mut h);
serde_json::to_string(&doc.sheet).unwrap_or_default().hash(&mut h);
@@ -1504,6 +1624,9 @@ fn cache_key(f: &Feature, chain_fp: u64, local: u64, ctx: u64, state: &ModelStat
#[derive(Default)]
struct PlacementReads {
all: bool,
/// A body named that `st` lacks: a further placement's (`Block (Part:2)`), found through
/// its occurrence (#9957), or gone.
unlisted: bool,
components: std::collections::BTreeSet<u64>,
paths: Vec<Vec<u64>>,
bodies: std::collections::BTreeSet<String>,
@@ -1542,14 +1665,17 @@ impl PlacementReads {
}
}
/// The component of body `name` in `st`; every component when the body is not there (a
/// projection then takes the body nearest its point, wherever that is).
/// The component of body `name` in `st`; every component and every placement when the body
/// is not there (a further placement's body, or the body nearest its point, wherever that is).
fn body(&mut self, doc: &Document, st: Option<&ModelState>, name: &str) {
match st.and_then(|st| st.body(name)) {
Some(b) => {
self.components.insert(doc.model_body_component(b));
}
None => self.all = true,
None => {
self.all = true;
self.unlisted = true;
}
}
}
@@ -1785,6 +1911,9 @@ fn placement_digest(doc: &Document, f: &Feature, st: Option<&ModelState>) -> u64
}
}
hash_placed_bodies(doc, st, &reads.bodies, &inverse, &mut h);
if reads.unlisted {
placements_hash(doc).hash(&mut h);
}
// Legacy and component-wide reads still need the conservative offset dependency. Body
// reads above already include just their own offsets, in the frame that evaluates them.
if reads.all || !reads.components.is_empty() || !reads.paths.is_empty() {
@@ -1922,7 +2051,7 @@ impl Model {
}
// The fingerprints from the first stored sketch on.
let vals = evaluation_values(doc).0;
let steps = StepDigests::of(doc, &self.step_digests);
let steps = ImportDigests::of(doc, &self.import_digests);
let marker = doc.marker.unwrap_or(usize::MAX);
let mut prev_fp = first.checked_sub(1).and_then(|i| self.results.get(i)).map_or(0, |r| r.fingerprint);
for (i, f) in doc.features.iter().enumerate().skip(first) {
@@ -1972,7 +2101,7 @@ impl Model {
return false;
}
let vals = evaluation_values(doc).0;
let steps = StepDigests::of(doc, &self.step_digests);
let steps = ImportDigests::of(doc, &self.import_digests);
let marker = doc.marker.unwrap_or(usize::MAX);
let mut prev = 0;
// The model each feature starts from (which components the bodies it names are in).
@@ -2037,7 +2166,7 @@ impl Model {
let previous_context = self.context;
let mut rolled_results = std::mem::take(&mut self.rolled_results);
self.context = ctx;
self.step_digests = StepDigests::of(doc, &self.step_digests);
self.import_digests = ImportDigests::of(doc, &self.import_digests);
let primed = self.primed.as_ref().filter(|(context, _)| !self.no_cache && *context == ctx).map(|(_, results)| results.clone());
// Each feature's component, for `Document::legacy_body_component` on every body after
// every feature without searching the timeline for each (the first feature with an id).
@@ -2072,7 +2201,7 @@ impl Model {
};
// Opening was cancelled: the rest is skipped and the result discarded.
let cancelled = watched.step(i);
let fp = chain_fingerprint(doc, &vals, f, prev_fp, rolled_back, &self.step_digests, Some(&state));
let fp = chain_fingerprint(doc, &vals, f, prev_fp, rolled_back, &self.import_digests, Some(&state));
prev_fp = fp;
let current = |old: &&FeatureResult| old.id == f.id && old.fingerprint == fp && !old.stale;
// A state the saved model did not keep is evaluated again from the one before it; the
@@ -2148,7 +2277,7 @@ impl Model {
}
// The same feature on the same inputs: its earlier result.
let key =
cache_key(f, fp, fingerprint(doc, 0, f, &vals, false, &self.step_digests) ^ placement_digest(doc, f, Some(&state)), ctx, &state);
cache_key(f, fp, fingerprint(doc, 0, f, &vals, false, &self.import_digests) ^ placement_digest(doc, f, Some(&state)), ctx, &state);
let kept = self.kept.0.as_ref().filter(|_| !self.no_cache);
if !self.no_cache
&& let Some(c) = self
@@ -2239,6 +2368,7 @@ impl Model {
let r = match frame_error.or(bad_param) {
Some(e) => Err(kernel::KernelError::Failed(e)),
None => kernel::guard(&f.name, || {
let _val_guard = EvalValidationGuard(EVAL_VALIDATION.replace(validation));
let (r, deferred) =
kernel::validating(validation, || eval_feature(doc, &vals, f, &mut next, &mut warning, &mut import_report, &mut repair));
r.map_err(|e| match e {
@@ -2283,7 +2413,9 @@ impl Model {
}
_ => mats,
};
next.stamp(doc, &state, f, key, mats);
// A revolve axis origin that follows parameters names its faces from its values.
let named = naming_feature(&vals, f);
next.stamp(doc, &state, &named, key, mats);
// Evaluated again only for what came before it (a mirror moved by a drag).
if let Some(old) = self.results.get(i).filter(|old| old.id == f.id) {
let made = old.state.bodies.iter().filter(|b| b.feature == f.id).map(|b| (b.name.as_str(), b));
@@ -2502,7 +2634,7 @@ impl Model {
pub(crate) fn refingerprint_from(&mut self, doc: &Document, start: usize) {
self.placement_inputs = None;
self.fingerprinted = None;
self.step_digests = StepDigests::of(doc, &self.step_digests);
self.import_digests = ImportDigests::of(doc, &self.import_digests);
let start = start.min(self.results.len()).min(doc.features.len());
if start == self.results.len().min(doc.features.len()) {
return;
@@ -2514,7 +2646,7 @@ impl Model {
// Each feature's fingerprint reads the model it starts from, as evaluating does.
let mut before = last.map_or_else(|| Arc::new(ModelState::default()), |r| r.state.clone());
for (i, (r, f)) in self.results.iter_mut().zip(&doc.features).enumerate().skip(start) {
prev = chain_fingerprint(doc, &vals, f, prev, i >= marker, &self.step_digests, Some(&before));
prev = chain_fingerprint(doc, &vals, f, prev, i >= marker, &self.import_digests, Some(&before));
r.fingerprint = prev;
before = r.state.clone();
}
@@ -3600,10 +3732,11 @@ pub fn hole_on_surface(st: &ModelState, p: Vec3, dir: Vec3, scope: &[String]) ->
/// The faces of the model near `p`: for each, how far `p` is from it, the outward normal there
/// (the surface's own normal for planes, cylinders and spheres, turned the way the face looks out
/// of its body; the nearest mesh facet's otherwise) and, for a plane, how far `p` is from the
/// plane itself. Only faces within `reach` of `p`, nearest first.
fn faces_through(st: &ModelState, p: Vec3, reach: f64) -> Vec<(f64, Vec3, Option<f64>)> {
/// plane itself. Only faces within `reach` of `p`, nearest first, of the bodies in `scope`
/// (every body when it is empty).
fn faces_through(st: &ModelState, p: Vec3, reach: f64, scope: &[String]) -> Vec<(f64, Vec3, Option<f64>)> {
let mut out: Vec<(f64, Vec3, Option<f64>)> = Vec::new();
for b in st.bodies.iter().filter(|b| !b.body.is_mesh()) {
for b in st.bodies.iter().filter(|b| !b.body.is_mesh() && in_hole_scope(scope, b)) {
let m = b.mesh();
// The nearest facet of each face of this body.
let mut near: BTreeMap<u32, (f64, Vec3)> = BTreeMap::new();
@@ -3649,13 +3782,29 @@ fn faces_through(st: &ModelState, p: Vec3, reach: f64) -> Vec<(f64, Vec3, Option
out
}
/// The faces near `p` a hole placed there goes by (as [`faces_through`]): those of the bodies in
/// `scope`, the hole's own, when one of them passes within `on` of `p`, else those of every body.
/// Where a plate's face touches another body both faces pass through `p`, and a hole that drills
/// the plate goes by the plate's face, never the other body's (#12066). A hole placed on another
/// body's face, to drill through it into its own body behind, still goes by that face.
fn hole_faces_through(st: &ModelState, p: Vec3, reach: f64, on: f64, scope: &[String]) -> Vec<(f64, Vec3, Option<f64>)> {
if !scope.is_empty() {
let own = faces_through(st, p, reach, scope);
if own.first().is_some_and(|(d, _, _)| *d <= on) {
return own;
}
}
faces_through(st, p, reach, &[])
}
/// The way a hole placed at `p` drills: into the material, against the outward normal of the
/// face `p` lies on. A point on no face (a planar face's centroid outside it, say) takes the
/// nearest planar face whose plane it lies in, else the face nearest it within its body's pick
/// tolerance. None when no face is near.
pub fn hole_direction_at(st: &ModelState, p: Vec3) -> Option<Vec3> {
/// tolerance. None when no face is near. Where the faces of several bodies pass through `p`, those
/// of the bodies in `scope` (the bodies the hole drills; empty for every body) decide.
pub fn hole_direction_at(st: &ModelState, p: Vec3, scope: &[String]) -> Option<Vec3> {
let size = st.bodies.iter().map(|b| b.body.size()).fold(1.0, f64::max);
let faces = faces_through(st, p, f64::INFINITY);
let faces = hole_faces_through(st, p, f64::INFINITY, size * 1e-4, scope);
if let Some((d, n, _)) = faces.first()
&& *d <= size * 1e-4
{
@@ -3672,10 +3821,13 @@ pub fn hole_direction_at(st: &ModelState, p: Vec3) -> Option<Vec3> {
/// starts on (as designs saved before format 18 can say: their direction was taken from any face
/// whose plane passed near the point, such as the other side of a thin plate or a face of another
/// body at the same height), it is mirrored in the face it starts on, so it goes in at the same
/// angle. A direction that enters a face there, or a spot on no face, is kept.
fn hole_dir_into_face(st: &ModelState, p: Vec3, dir: Vec3) -> Vec3 {
/// angle. A direction that enters a face there, or a spot on no face, is kept. Where the faces of
/// the hole's own bodies (`scope`, see [`hole_drill_scope`]) pass through the spot, only they
/// count: a hole that names the plate it drills, saved with the direction into a body touching
/// the plate there, drills the plate (#12066).
fn hole_dir_into_face(st: &ModelState, p: Vec3, dir: Vec3, scope: &[String]) -> Vec3 {
let size = st.bodies.iter().map(|b| b.body.size()).fold(1.0, f64::max);
let faces = faces_through(st, p, size * 1e-5);
let faces = hole_faces_through(st, p, size * 1e-5, size * 1e-5, scope);
if faces.is_empty() || faces.iter().any(|(_, n, _)| n.dot(dir) < -1e-9) {
return dir;
}
@@ -4147,10 +4299,26 @@ fn blend_body(
Ok((Traced::step(nb.with_preserved_planar_sources(&mb.body)?, &[&from]), used, tapered))
}
/// `f` as the naming of its faces reads it: a revolve's explicit axis origin written as the values
/// it has now (the naming has no parameter values to read an expression with).
fn naming_feature<'a>(vals: &BTreeMap<String, Value>, f: &'a Feature) -> std::borrow::Cow<'a, Feature> {
if let FeatureKind::Revolve { axis: AxisRef::Line { origin, .. }, .. } = &f.kind
&& let Ok(o) = point(vals, origin)
{
let mut g = f.clone();
if let FeatureKind::Revolve { axis: AxisRef::Line { origin, .. }, .. } = &mut g.kind {
*origin = [format!("{} mm", o.x).into(), format!("{} mm", o.y).into(), format!("{} mm", o.z).into()];
}
return std::borrow::Cow::Owned(g);
}
std::borrow::Cow::Borrowed(f)
}
/// The revolve axis in the sketch's plane coordinates (a point and a direction). `st` is the model
/// an axis on an edge or face of it is found in; `construct` gives a construction axis, by name, in
/// the sketch's 3D frame.
/// the sketch's 3D frame; `vals` are the parameter values an explicit axis's origin is read with.
pub(crate) fn revolve_axis(
vals: &BTreeMap<String, Value>,
ss: &SolvedSketch,
axis: &AxisRef,
st: &ModelState,
@@ -4185,7 +4353,7 @@ pub(crate) fn revolve_axis(
}
Ok((pl.to_local(Vec3::ZERO), Vec2::new(d.dot(pl.x), d.dot(pl.y))))
}
AxisRef::Line { origin, dir } => line_in_sketch_plane(ss, origin.0, dir.0),
AxisRef::Line { origin, dir } => line_in_sketch_plane(ss, point(vals, origin)?, dir.0),
AxisRef::Model { geo } => {
let (o, d) = crate::construct::model_axis(st, geo)?;
line_in_sketch_plane(ss, o, d)
@@ -4294,6 +4462,13 @@ fn hole_scope(f: &Feature) -> &[String] {
}
}
/// The bodies whose faces decide which way a hole placed at a position drills, where they pass
/// through it: the bodies it cuts when it names them, so a body touching the face there can't
/// turn it round (#12066). Empty (every body) otherwise, and for `["*"]`.
fn hole_drill_scope(f: &Feature) -> &[String] {
if f.participants.iter().any(|b| b == "*") { &[] } else { &f.participants }
}
fn in_hole_scope(scope: &[String], b: &ModelBody) -> bool {
scope.is_empty() || scope.contains(&b.name)
}
@@ -4319,7 +4494,7 @@ fn hole_spots(vals: &BTreeMap<String, Value>, st: &ModelState, f: &Feature) -> R
let dir = direction.normalized().ok_or_else(|| DocError::Invalid("hole direction".into()))?;
let at = hole_on_surface(st, single_hole_position(vals, st, position, at, points)?, dir, &[]);
let at = with_references(vals, st, f, at, dir, true)?;
let dir = hole_dir_into_face(st, at, dir);
let dir = hole_dir_into_face(st, at, dir, hole_drill_scope(f));
return Ok((vec![hole_on_surface(st, at, dir, &[])], dir));
}
};
@@ -4367,7 +4542,7 @@ fn hole_spots_raw(vals: &BTreeMap<String, Value>, st: &ModelState, f: &Feature)
let dir = direction.normalized().ok_or_else(|| DocError::Invalid("hole direction".into()))?;
let p = single_hole_position(vals, st, position, at, points)?;
let p = if hole_references(f).is_empty() { p } else { with_references(vals, st, f, hole_on_surface(st, p, dir, &[]), dir, false)? };
Ok((vec![p], hole_dir_into_face(st, hole_on_surface(st, p, dir, &[]), dir)))
Ok((vec![p], hole_dir_into_face(st, hole_on_surface(st, p, dir, &[]), dir, hole_drill_scope(f))))
}
}
}
@@ -5291,8 +5466,9 @@ fn feature_tools_with_constructor(
f,
)?;
let regions = solidcraft_sketch::merge_regions(&select_profiles(&ss, profiles)?);
let (o, d) =
revolve_axis(&ss, axis, &*axis_state(doc, st, f.component, axis)?, &|name| construction_axis_in(Some(doc), st, name, f.component))?;
let (o, d) = revolve_axis(vals, &ss, axis, &*axis_state(doc, st, f.component, axis)?, &|name| {
construction_axis_in(Some(doc), st, name, f.component)
})?;
// To Object is in this feature's component frame: the model as seen from there.
let tst = if to.is_some() { state_in_frame(doc, st, f.component)? } else { std::borrow::Cow::Borrowed(st) };
let angles = if to.is_some() {
@@ -5588,13 +5764,13 @@ fn feature_tools_with_constructor(
FeatureKind::Torus { center, major, minor, .. } => {
Ok(unnamed(vec![kernel::torus(point(vals, center)?, val(vals, major, Kind::Length)?, val(vals, minor, Kind::Length)?)?]))
}
FeatureKind::Cone { base, axis, radius, top_radius, height, .. } => Ok(unnamed(vec![more::cone(
FeatureKind::Cone { base, axis, radius, top_radius, height, .. } => Ok(vec![more::cone(
point(vals, base)?,
axis.0,
val(vals, radius, Kind::Length)?,
val(vals, top_radius, Kind::Length)?,
val(vals, height, Kind::Length)?,
)?])),
)?]),
FeatureKind::Hole { .. } => Ok(hole_feature_tools(vals, st, f)?.into_iter().map(|(tool, _)| tool).collect()),
_ => Err(DocError::Invalid(format!("{} cannot be patterned or mirrored yet", f.name))),
}
@@ -5926,15 +6102,28 @@ fn from_profile_on_closed_path(segs: Vec<solidcore_record::Seg2>, path: &Plane,
/// Where an extrude of distance `d` starts and ends along the sketch normal, before any start
/// offset: one side, the other, both (symmetric), or a second distance on the other side.
///
/// A second distance (`distance2`) goes the other way from the first side, wherever that went:
/// against the normal for `direction: negative` (#2700), and back along it when a negative
/// distance or a To Object below the sketch sends the first side down.
/// A second distance (`distance2`) goes the other way from the first side: against the normal,
/// or along it for `direction: negative` (#2700). Entered distances keep those sides whatever
/// their signs; a To Object below the sketch or a Through All that reaches down sends the first
/// side down and the second back along the normal.
pub(crate) fn extent_sides(vals: &BTreeMap<String, Value>, extent: &crate::Extent, d: f64, direction: crate::Direction) -> Result<(f64, f64)> {
Ok(match (&extent.distance2, direction) {
(Some(d2), _) => {
let first = if direction == crate::Direction::Negative { -d } else { d };
let negative = direction == crate::Direction::Negative;
let first = if negative { -d } else { d };
let d2 = val(vals, d2, Kind::Length)?;
(if first < 0.0 { d2 } else { -d2 }, first)
// An entered distance keeps its side whatever its sign: Direction fixes the sides,
// and a sign only turns its own side over. A reach computed from the model (To
// Object, Through All) has the sign of where the object is, so the other side
// follows it.
let second = if extent.second_follows_first || extent.to.is_some() || extent.through_all {
if first < 0.0 { d2 } else { -d2 }
} else if negative {
d2
} else {
-d2
};
(second, first)
}
(None, crate::Direction::Positive) => (0.0, d),
(None, crate::Direction::Negative) => (-d, 0.0),
@@ -6614,6 +6803,24 @@ fn replay(
}
continue;
}
// A moved copy is copied again as a body: each instance is the copy the Move made,
// placed by the pattern (#5931).
if let FeatureKind::Move { copy: true, .. } = &src.kind {
let made: Vec<ModelBody> = st.bodies.iter().filter(|b| b.feature == src.id).cloned().collect();
if made.is_empty() {
return Err(DocError::Invalid(format!("{} made no copy to repeat", src.name)));
}
let mut output = 0;
for (k, m) in mats.iter().enumerate() {
for mb in &made {
let moved = moved_copy(mb, kernel::transform_matrix(&mb.body, *m)?, first + k + 1);
let name = copy_body_name(st, f, output, &mb.name);
st.bodies.push(ModelBody::traced_new(name, moved, f.id));
output += 1;
}
}
continue;
}
let (op, targets) = feature_op(src);
// A primitive's or a hole's targets are its participants (#1108): the copies change
// those bodies only, like the feature itself (#2755).
@@ -6976,12 +7183,14 @@ pub(crate) fn sheet_face_plane_in_frame(doc: &Document, st: &ModelState, comp: u
PlaneRef::Offset { base, distance } => {
PlaneRef::Offset { base: Box::new(sheet_face_plane_in_frame(doc, st, comp, base)?), distance: distance.clone() }
}
PlaneRef::AtAngle { base, axis_origin, axis_dir, angle, line } => PlaneRef::AtAngle {
PlaneRef::AtAngle { base, axis_origin, axis_dir, angle, line, direction_only, pivot } => PlaneRef::AtAngle {
base: Box::new(sheet_face_plane_in_frame(doc, st, comp, base)?),
axis_origin: *axis_origin,
axis_dir: *axis_dir,
angle: angle.clone(),
line: line.clone(),
direction_only: *direction_only,
pivot: pivot.clone(),
},
PlaneRef::Flipped { base, about_y } => {
PlaneRef::Flipped { base: Box::new(sheet_face_plane_in_frame(doc, st, comp, base)?), about_y: *about_y }
@@ -7082,6 +7291,7 @@ fn eval_feature(
// Its tools meet bodies in other placements where those are shown (#3383).
let _frames = ToolFrames::install(doc, f, st);
match &f.kind {
FeatureKind::NewerFileFeature { raw } => Err(DocError::Invalid(raw.why())),
FeatureKind::BodyTransfer { bodies, from, to, transform } => {
crate::body_transfer::evaluate(doc, st, &crate::plain(bodies), *from, *to, *transform)
}
@@ -7120,7 +7330,7 @@ fn eval_feature(
| FeatureKind::Rib { .. }
| FeatureKind::ReplaceFace { .. }
| FeatureKind::Align { .. }
| FeatureKind::Remove { .. } => more::eval(doc, vals, f, st),
| 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 {
@@ -7131,6 +7341,14 @@ fn eval_feature(
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.
@@ -7772,8 +7990,8 @@ fn eval_feature(
FeatureKind::Cone { base, axis, radius, top_radius, height, operation } => {
let (radius, top_radius, height) =
(val(vals, radius, Kind::Length)?, val(vals, top_radius, Kind::Length)?, val(vals, height, Kind::Length)?);
let b = more::cone(point(vals, base)?, axis.0, radius, top_radius, height)?;
apply_op(doc, st, f, vec![b], *operation, &[])
let tool = more::cone(point(vals, base)?, axis.0, radius, top_radius, height)?;
apply_traced(doc, st, f, vec![tool], *operation, &[])
}
FeatureKind::Combine { target, tools, operation, keep_tools } => {
if tools.iter().any(|t| t == target) {
@@ -8122,7 +8340,7 @@ fn move_constructions_with(doc: &Document, st: &mut ModelState, moved: &[crate::
fn plane_is_fixed(plane: &PlaneRef) -> bool {
match plane {
PlaneRef::Origin { .. } | PlaneRef::Custom { .. } | PlaneRef::Placed { .. } => true,
PlaneRef::AtAngle { line: Some(_), .. } => false,
PlaneRef::AtAngle { line: Some(_), .. } | PlaneRef::AtAngle { pivot: Some(_), .. } => false,
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } | PlaneRef::Flipped { base, .. } => plane_is_fixed(base),
_ => false,
}
@@ -8133,7 +8351,7 @@ fn plane_is_fixed(plane: &PlaneRef) -> bool {
fn plane_reads_geometry(plane: &PlaneRef) -> bool {
match plane {
PlaneRef::Origin { .. } | PlaneRef::Custom { .. } | PlaneRef::Placed { .. } | PlaneRef::Construction { .. } => false,
PlaneRef::AtAngle { line: Some(_), .. } => true,
PlaneRef::AtAngle { line: Some(_), .. } | PlaneRef::AtAngle { pivot: Some(_), .. } => true,
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } | PlaneRef::Flipped { base, .. } => plane_reads_geometry(base),
PlaneRef::ThroughPoint { .. }
| PlaneRef::Midplane { .. }
@@ -8368,6 +8586,51 @@ pub fn state_in_frame_at<'a>(doc: &Document, st: &'a ModelState, comp: u64, fram
Ok(Cow::Owned(out))
}
/// `framed` (the model `st` in the frame that `frame` places in the world) with the further
/// placements `names` names (`Block (Part:2)`), where they are in the world. A component's model
/// holds its first placement only, so a sketch projecting a copy's face found no body of that
/// name and took the first placement's (#9957). Only the named ones are placed: placing every
/// body of an assembly for each sketch made sketch edits as slow as the design is big.
pub fn with_placed_copies<'a>(
doc: &Document,
st: &ModelState,
framed: std::borrow::Cow<'a, ModelState>,
frame: &Mat,
names: &[&str],
) -> std::borrow::Cow<'a, ModelState> {
let missing: Vec<&str> = names.iter().copied().filter(|n| !n.is_empty() && framed.body(n).is_none()).collect();
if missing.is_empty() || doc.occurrences.is_empty() {
return framed;
}
let Some(inv) = crate::mat_inverse(frame) else { return framed };
let mut by_component: std::collections::HashMap<u64, Vec<&ModelBody>> = std::collections::HashMap::new();
for (b, owner) in st.bodies.iter().zip(doc.model_body_components(st)) {
by_component.entry(owner).or_default().push(b);
}
let world = crate::WorldNames::new(doc, st);
let mut placed: Vec<ModelBody> = Vec::new();
for (comp, path, m) in doc.placements() {
for b in by_component.get(&comp).into_iter().flatten() {
let name = world.name(&b.name, comp, &path);
if !missing.contains(&name.as_str()) || placed.iter().any(|p| p.name == name) {
continue;
}
let bm = doc.model_body_offset(b).map(|o| crate::mat_mul(&m, o)).unwrap_or(m);
if let Ok(mut copy) = st.body_in_frame(b, &crate::mat_mul(&inv, &bm)) {
copy.name = name;
copy.source_name = Some(b.definition_name().to_string());
placed.push(copy);
}
}
}
if placed.is_empty() {
return framed;
}
let mut out = framed.into_owned();
out.bodies.extend(placed);
std::borrow::Cow::Owned(out)
}
/// The model placed in the world: each component's bodies, sketches, threads and construction
/// geometry through every occurrence of it (bodies of the first placement keep their names;
/// further instances get the occurrence path in brackets). Without components this is the model
@@ -8795,6 +9058,49 @@ mod world_placement_cache_tests {
assert!(Arc::ptr_eq(&original_mesh, &again.body("Block").unwrap().mesh()));
assert!(Arc::ptr_eq(&names, &crate::naming::face_names(again.body(&second_name).unwrap())));
}
/// A dragged component's bodies are drawn from the meshes and plans made where the drag found
/// them, moved: each is the same solid moved rigidly, the others stand where they were, and a
/// body whose geometry changed is no such move.
#[test]
fn a_moved_placement_is_drawn_moved_from_where_it_stood() {
// A placement moved, and the part moved off where it is defined.
for from_definition in [false, true] {
let mut doc = Document::new("placements");
let component = doc.add_component(Some("Part"), 0).unwrap();
doc.body_components.insert("Block".into(), component);
let start = if from_definition { crate::IDENTITY } else { crate::rigid(Vec3::new(10., 20., 0.), Vec3::ZERO, Vec3::Z, 0.4) };
let second = doc.add_occurrence(component, 0, start).unwrap();
let mut state = ModelState::default();
state.bodies.push(ModelBody::new("Block".into(), kernel::box_solid(Vec3::ZERO, Vec3::new(2., 3., 4.)).unwrap(), 1));
let before = world_state(&doc, &state).unwrap();
let delta = crate::rigid(Vec3::new(4., -7., 1.5), Vec3::new(1., 1., 0.), Vec3::new(0., 1., 1.), 0.6);
let moved = doc.occurrences.iter_mut().find(|o| o.id == second).unwrap();
moved.transform = crate::mat_mul(&delta, &moved.transform);
let after = world_state(&doc, &state).unwrap();
assert_eq!(after.bodies.len(), 2);
let mut moves = 0;
for now in &after.bodies {
let was = before.body(&now.name).unwrap();
let d = now.display_moved_from(was).unwrap();
if d == crate::IDENTITY {
assert!(Arc::ptr_eq(&now.mesh(), &was.mesh()), "{}: unchanged, the same mesh", now.name);
continue;
}
moves += 1;
assert!(d.iter().flatten().zip(delta.iter().flatten()).all(|(a, b)| (a - b).abs() < 1e-9), "{}: {d:?}", now.name);
// `was`'s mesh drawn moved is `now`'s.
for (p, q) in was.mesh().positions.iter().zip(&now.mesh().positions) {
assert!(crate::apply_point(&d, *p).dist(*q) < 1e-9);
}
}
assert_eq!(moves, 1, "from its definition: {from_definition}");
// Another body, or other geometry: no move draws it.
assert!(after.bodies[0].display_moved_from(&before.bodies[1]).is_none());
let other = ModelBody::new(before.bodies[0].name.clone(), kernel::box_solid(Vec3::ZERO, Vec3::new(1., 1., 1.)).unwrap(), 1);
assert!(other.display_moved_from(&before.bodies[0]).is_none());
}
}
}
#[cfg(test)]
+150 -1
View File
@@ -16,7 +16,7 @@ use solidcraft_sketch::Sketch;
use crate::eval::{PlacementReads, body_world, hash_placement, placement_digest};
use crate::*;
/// Work counted on this thread: bodies moved, STEP texts digested, joint faces and bodies
/// Work counted on this thread: bodies moved, STEP/mesh contents digested, joint faces and bodies
/// measured, bodies searched for by name, features searched for by id, bodies a cut (or
/// intersect) ran its booleans on, bodies searched for the cylinder a tap marking lies on,
/// bodies whose faces a tapped hole read for its wall, intersects run to find a pattern's copies
@@ -26,6 +26,7 @@ use crate::*;
pub(super) struct Counts {
pub moved: usize,
pub digested: usize,
pub mesh_digested: usize,
pub joint_faces: usize,
pub measures: usize,
pub body_searches: usize,
@@ -42,6 +43,7 @@ thread_local! {
Cell::new(Counts {
moved: 0,
digested: 0,
mesh_digested: 0,
joint_faces: 0,
measures: 0,
body_searches: 0,
@@ -1637,3 +1639,150 @@ fn tools_are_apart_only_when_they_neither_meet_nor_nest() {
// A rod in the tube's bore, clear of its wall, is apart from it.
assert!(super::tools_clearly_apart(&[tube, rod(0.0, 0.0, 3.0)]));
}
fn imported_meshes() -> Document {
let mut doc = Document::new("reference meshes");
let comp = doc.add_component(Some("Part"), 0).unwrap();
let meshes = (0..2)
.map(|i| MeshData {
name: format!("Mesh{i}"),
positions: [0.0, 0.0, 0.0, 2.0, 0.0, 0.0, 0.0, 3.0, 0.0, 0.0, 0.0, 4.0].into(),
triangles: [0, 2, 1, 0, 1, 3, 0, 3, 2, 1, 2, 3].into(),
color: Some([0.2, 0.4, 0.6]),
})
.collect();
let id = doc.add_feature(FeatureKind::MeshImport { file: "tetrahedra.stl".into(), meshes }, Some("Meshes")).unwrap();
doc.feature_mut(id).unwrap().component = comp;
doc
}
fn meshes_mut(doc: &mut Document) -> &mut Vec<MeshData> {
let FeatureKind::MeshImport { meshes, .. } = &mut doc.features[0].kind else { panic!("mesh fixture") };
meshes
}
fn same_as_fresh(doc: &Document, model: &Model) {
let mut fresh = Model::new();
fresh.no_cache = true;
fresh.evaluate(doc);
assert_eq!(model.results.iter().map(|r| r.fingerprint).collect::<Vec<_>>(), fresh.results.iter().map(|r| r.fingerprint).collect::<Vec<_>>());
let (actual, expected) = (model.state(), fresh.state());
assert_eq!(actual.bodies.len(), expected.bodies.len());
for (a, b) in actual.bodies.iter().zip(&expected.bodies) {
assert_eq!(a.name, b.name);
assert_eq!(a.mesh().positions, b.mesh().positions);
assert_eq!(a.mesh().triangles, b.mesh().triangles);
assert_eq!(a.body.color(), b.body.color());
}
assert!(model.results.iter().all(|r| r.error.is_none()));
}
#[test]
fn unchanged_mesh_payloads_are_not_read_again_for_rigid_motion() {
let mut doc = imported_meshes();
let mut model = Model::new();
assert_eq!(counted(|| model.evaluate(&doc)).mesh_digested, 2);
for k in 1..5 {
doc = doc.clone(); // Undo/preview copies share the arrays, not the document itself.
doc.occurrences[0].transform = rigid(Vec3::new(f64::from(k), 2.0, 3.0), Vec3::ZERO, Vec3::Z, 0.1 * f64::from(k));
assert_eq!(
counted(|| {
model.evaluate(&doc);
assert!(model.is_current(&doc));
})
.mesh_digested,
0
);
assert_eq!(model.last_recomputed, 0);
same_as_fresh(&doc, &model);
}
let saved = serde_json::to_string(&doc).unwrap();
let reopened: Document = serde_json::from_str(&saved).unwrap();
assert_eq!(counted(|| model.evaluate(&reopened)).mesh_digested, 2, "new array identities must be checked");
assert_eq!(model.last_recomputed, 0, "equal content has equal fingerprints");
same_as_fresh(&reopened, &model);
}
#[test]
fn mesh_payload_edits_invalidate_the_cache_and_undo_restores_the_geometry() {
let mut doc = imported_meshes();
let original = doc.clone();
let mut model = Model::new();
model.evaluate(&doc);
let fingerprint = model.results[0].fingerprint;
Arc::make_mut(&mut meshes_mut(&mut doc)[0].positions)[3] = 5.0;
assert!(!model.is_current(&doc));
assert_eq!(counted(|| model.evaluate(&doc)).mesh_digested, 1);
assert_ne!(model.results[0].fingerprint, fingerprint);
same_as_fresh(&doc, &model);
let edited = doc.clone();
Arc::make_mut(&mut meshes_mut(&mut doc)[0].triangles).swap(0, 1);
assert_eq!(counted(|| model.evaluate(&doc)).mesh_digested, 1);
same_as_fresh(&doc, &model);
for restored in [&original, &edited, &original] {
model.evaluate(restored);
same_as_fresh(restored, &model);
}
assert_eq!(model.results[0].fingerprint, fingerprint);
}
#[test]
fn mesh_metadata_and_order_remain_part_of_the_fingerprint() {
let mut doc = imported_meshes();
let mut model = Model::new();
model.evaluate(&doc);
for edit in 0..7 {
let before = model.results[0].fingerprint;
match edit {
0 => meshes_mut(&mut doc)[0].name = "Renamed".into(),
1 => meshes_mut(&mut doc)[0].color = Some([0.8, 0.1, 0.3]),
2 => doc.features[0].name = "Renamed feature".into(),
3 => meshes_mut(&mut doc).swap(0, 1),
4 => {
meshes_mut(&mut doc).pop();
}
5 => {
let mesh = meshes_mut(&mut doc)[0].clone();
meshes_mut(&mut doc).push(MeshData { name: "Added".into(), ..mesh });
}
_ => {
let FeatureKind::MeshImport { file, .. } = &mut doc.features[0].kind else { panic!("mesh fixture") };
*file = "renamed.stl".into();
}
}
assert!(!model.is_current(&doc));
let work = counted(|| model.evaluate(&doc));
if edit < 3 || edit == 6 {
assert_eq!(work.mesh_digested, 0, "metadata alone: {edit}");
}
assert_ne!(model.results[0].fingerprint, before, "edit {edit}");
same_as_fresh(&doc, &model);
}
}
#[test]
fn an_added_and_deleted_sketch_entity_keeps_its_fingerprint() {
let mut doc = Document::new("sketch fingerprint");
let id = doc.add_feature(line_sketch(PlaneRef::Origin { name: "XY".into() }, Vec2::new(10.0, 0.0)), None).unwrap();
let mut model = Model::new();
model.evaluate(&doc);
let key = model.state().sketch_keys.get(&id).copied();
let json = super::feature_json(doc.feature(id).unwrap(), &super::ImportDigests::default());
// Make and delete an id: the counters advance, but the geometry is the same.
{
let sk = doc.sketch_mut(id).unwrap();
let a = sk.add_point(Vec2::new(2.0, 2.0), None).unwrap();
let b = sk.add_point(Vec2::new(3.0, 2.0), None).unwrap();
let line = sk.add_line_pts(a, b, None).unwrap();
sk.remove_curves(&[line]);
sk.remove_points(&[b]);
sk.remove_points(&[a]);
}
let json_after = super::feature_json(doc.feature(id).unwrap(), &super::ImportDigests::default());
assert_eq!(json, json_after, "deleted ids are not geometry");
model.evaluate(&doc);
assert_eq!(model.state().sketch_keys.get(&id).copied(), key);
assert_eq!((model.last_recomputed, model.last_restored), (0, 0));
}
+21 -5
View File
@@ -8,7 +8,7 @@ use crate::sheet;
use solidcore_record::{Loop2, Seg2};
use solidcraft_geom::{Aabb3, Ring};
pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState) -> Result<()> {
pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState, warning: &mut Option<String>) -> Result<()> {
match &f.kind {
FeatureKind::Emboss { sketch, profiles, depth, deboss, targets, face } => {
let ss = st.sketch(sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?;
@@ -59,7 +59,20 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
if st.bodies.is_empty() {
return Err(DocError::Invalid("there is no body to emboss".into()));
}
apply_op(doc, st, f, tools, if *deboss { Operation::Cut } else { Operation::Join }, &crate::plain(targets))
// #10940: an emboss or deboss whose tool lies clear of the body (a sketch on the far
// side of the material, a depth that stops short) used to report success and change
// nothing. Say so: the body is compared before and after, as the command's reply does.
let before: Vec<Body> = st.bodies.iter().map(|b| b.body.clone()).collect();
apply_op(doc, st, f, tools, if *deboss { Operation::Cut } else { Operation::Join }, &crate::plain(targets))?;
let unchanged = st.bodies.len() == before.len() && st.bodies.iter().zip(&before).all(|(b, old)| old.same(&b.body));
if unchanged {
let what = if *deboss { "deboss" } else { "emboss" };
append_warning(
warning,
format!("the {what} changed nothing; check where its profile lies, which side of the face the sketch is on, and the depth"),
);
}
Ok(())
}
FeatureKind::Rib { .. } => {
let tools = rib_tools(vals, st, f, None)?;
@@ -1428,7 +1441,8 @@ const MAX_CONE_SIZE: f64 = 1e7;
/// of `top_radius` `height` along `axis` (a negative height goes the other way). A top radius of
/// 0 (or below a micrometre) makes a full cone ending in an apex. Built, as the torus is, by
/// revolving its half section, a trapezoid (a triangle for a full cone) with an edge on the axis.
pub(super) fn cone(base: Vec3, axis: Vec3, radius: f64, top_radius: f64, height: f64) -> Result<Body> {
/// Its faces are named `bottom`, `side` and `top` ([`crate::lineage::cone_roles`]).
pub(super) fn cone(base: Vec3, axis: Vec3, radius: f64, top_radius: f64, height: f64) -> Result<Traced> {
let in_range = |v: f64| v.is_finite() && v.abs() <= MAX_CONE_SIZE;
if !(in_range(base.x) && in_range(base.y) && in_range(base.z)) {
return Err(DocError::Invalid("the cone's base is out of range".into()));
@@ -1462,14 +1476,16 @@ pub(super) fn cone(base: Vec3, axis: Vec3, radius: f64, top_radius: f64, height:
(Some(b), true) => b,
_ => return Err(DocError::Invalid("the cone could not be built".into())),
};
// Read off the revolve's own history; moving the body keeps its faces' order.
let roles = crate::lineage::cone_roles(&body);
if (dir - Vec3::Z).len() < 1e-12 && base.len() == 0.0 {
return Ok(body);
return Ok(Traced::made(body, roles));
}
// A rotation taking +Z onto the axis (x and y square to it, right-handed), then the base.
let x = Plane::from_normal(base, dir).ok_or_else(|| DocError::Invalid("the cone's axis has no direction".into()))?.x;
let y = dir.cross(x);
let m = [[x.x, x.y, x.z, 0.0], [y.x, y.y, y.z, 0.0], [dir.x, dir.y, dir.z, 0.0], [base.x, base.y, base.z, 1.0]];
Ok(kernel::transform_matrix(&body, m)?)
Ok(Traced::made(kernel::transform_matrix(&body, m)?, roles))
}
/// A primitive tool's exact geometry, for [`sheet_primitive_cut`].
+7 -4
View File
@@ -63,13 +63,16 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
let t = val(vals, thickness, Kind::Length)?;
let mut tools = Vec::new();
for n in bodies {
let b = st.body(n).ok_or_else(|| DocError::Unknown(format!("body `{n}`")))?;
tools.push(kernel::thicken(&b.body, t, *symmetric)?);
let b = st.body(n).cloned().ok_or_else(|| DocError::Unknown(format!("body `{n}`")))?;
// Kept face, offset and edge walls. Carry that history so a sketch can attach
// to the solid's faces and follow the thickness (#7641).
let thickened = kernel::thicken(&b.body, t, *symmetric)?;
tools.push(Traced::step(thickened, &[&b.traced()]));
}
if tools.is_empty() {
return Err(DocError::Invalid("pick the surfaces to thicken".into()));
}
apply_op(doc, st, f, tools, *operation, &crate::plain(targets))
apply_traced(doc, st, f, tools, *operation, &crate::plain(targets))
}
FeatureKind::SurfaceTrim { body, plane, tool, keep, keep_more, curves, with, keep_with, stitch, extend } => {
let i = st.bodies.iter().position(|b| &b.name == body).ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?;
@@ -308,7 +311,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
st.sketch(sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?,
f.component,
)?;
let (o, d) = revolve_axis(&ss, axis, &*super::axis_state(doc, st, f.component, axis)?, &|name| {
let (o, d) = revolve_axis(vals, &ss, axis, &*super::axis_state(doc, st, f.component, axis)?, &|name| {
construction_axis_in(Some(doc), st, name, f.component)
})?;
let a = val(vals, angle, Kind::Angle)?;
+4 -2
View File
@@ -55,7 +55,8 @@ impl FeatureKind {
/// The body a feature that works on one body's faces names (shell, draft, split, offset,
/// delete and replace face, surface offset, the placed plastic features, sheet metal
/// conversion, ruled surface, untrim): its face points pick faces of that body.
/// conversion, ruled surface, untrim, Split Body's tool face): its face points pick faces of
/// that body.
pub fn face_body(&self) -> Option<&str> {
match self {
FeatureKind::Shell { body, .. }
@@ -66,10 +67,11 @@ impl FeatureKind {
| FeatureKind::ReplaceFace { body, .. }
| FeatureKind::SurfaceOffset { body, .. }
| FeatureKind::Boss { body, .. }
| FeatureKind::Lip { body, .. }
| FeatureKind::SnapFit { body, .. }
| FeatureKind::Rest { body, .. } => body.as_deref(),
FeatureKind::SheetConvert { body, .. } | FeatureKind::SurfaceRuled { body, .. } => Some(body.as_ref()),
FeatureKind::SurfaceUntrim { face, .. } => Some(face.body.as_ref()),
FeatureKind::SurfaceUntrim { face, .. } | FeatureKind::Split { tool: Some(face), .. } => Some(face.body.as_ref()),
_ => None,
}
}
@@ -29,6 +29,7 @@ pub(super) fn fixtures() -> Vec<(&'static str, Value)> {
let hole =
|h: Value| json!({"type": "hole", "position": o, "direction": [0, 0, 1], "diameter": "hole.diameter", "depth": "hole.depth", "hole": h});
vec![
("newer_file_feature", json!({"type": "newer_file_feature", "raw": {"type": "gyroid_lattice", "cell": 2}})),
(
"body_transfer",
json!({"type": "body_transfer", "bodies": ["B"], "from": 0, "to": 1, "transform": [[1,0,0,0],[0,1,0,0],[0,0,1,0],[0,0,0,1]]}),
@@ -1,3 +1,4 @@
newer_file_feature
body_transfer
sketch
inputs:
@@ -1,3 +1,4 @@
newer_file_feature
body_transfer
sketch
moves: plane.axis_dir[0], plane.axis_dir[1], plane.axis_origin[0], plane.axis_origin[1], plane.axis_origin[2]
@@ -12,6 +13,7 @@ revolve
moves: axis.dir[0], axis.dir[1], axis.dir[2], axis.origin[0], axis.origin[1], axis.origin[2], to[0], to[1], to[2]
face points: [3, 3.5, 3.25]
profiles: 1 all
placement: 9, 9.5, 9.25
fillet_constant
moves: edges[0][0], edges[0][1], edges[0][2]
fillet_chord
@@ -86,6 +88,7 @@ construction_plane
moves: plane.axis_dir[0], plane.axis_dir[1], plane.axis_origin[0], plane.axis_origin[1], plane.axis_origin[2]
split
moves: plane.axis_dir[0], plane.axis_dir[1], plane.axis_origin[0], plane.axis_origin[1], plane.axis_origin[2], tool.point[0], tool.point[1], tool.point[2]
face points: [0, 0, 0]
split_line
moves: plane.axis_dir[0], plane.axis_dir[1], plane.axis_origin[0], plane.axis_origin[1], plane.axis_origin[2]
construction_axis
@@ -148,6 +151,7 @@ boss
face points: [0, 0, 0]
lip
moves: face[0], face[1], face[2]
face points: [0, 0, 0]
snap_fit
moves: direction[0], direction[2], hook[0], hook[1], position[0], position[1], position[2]
face points: [0, 0, 0]
@@ -166,6 +170,7 @@ surface_extrude
face points: [1, 1.5, 1.25], [2, 2.5, 2.25]
surface_revolve
moves: axis.dir[0], axis.dir[1], axis.dir[2], axis.origin[0], axis.origin[1], axis.origin[2]
placement: 9, 9.5, 9.25
surface_sweep
surface_sweep_options
surface_loft
@@ -1,3 +1,4 @@
newer_file_feature
body_transfer
remap_bodies: bodies[0]
sketch
+140 -117
View File
@@ -2,8 +2,9 @@
//!
//! A design file starts with `"format": "solidcraft/N"`. A file of an older format is read and
//! upgraded step by step (N → N+1 → … → current): JSON-level steps first, for changes in the
//! file's shape, then document-level steps. A file of a newer format than this build knows is
//! refused with a message saying so, never guessed at.
//! file's shape, then document-level steps. A file a newer build saved opens too, with a warning:
//! what this build reads is read, what it doesn't is reported by name and kept for the next save
//! (`crate::newer_file`).
//!
//! History:
//! - 1: every design up to 2026-10-08 (format string since M0). Fields added over time all
@@ -264,7 +265,7 @@ const FORMATS: Formats<'static> = Formats { hand: &JSON_STEPS, folded: FOLDED, p
/// A kind of file whose format is versioned here: designs, and drawings (#3890). Each family has
/// its own format tag, its own numbers and its own folder of change files (`build.rs` lists them);
/// reading a file's format, refusing a file a newer build saved and the upgrade are the same code
/// reading a file's format, telling a file a newer build saved and the upgrade are the same code
/// for every family.
pub struct Family<'a> {
/// What a file of the family is called in messages.
@@ -317,8 +318,8 @@ impl Family<'_> {
}
/// Read a file's format and take its `format_pending` out of its JSON, before anything else
/// in it is read: a file that isn't this family's, or that a newer build saved, is refused
/// here, so nothing a newer build saved is judged by this build's rules.
/// in it is read: a file that isn't this family's is refused here. One a newer build saved is
/// not: [`Family::newer`] says so.
pub fn take_version(&self, v: &mut Value) -> Result<(u32, Option<Vec<String>>)> {
let noun = self.noun;
let not_ours = |what: String| DocError::Invalid(format!("not a SolidCraft {noun} ({what})"));
@@ -329,10 +330,19 @@ impl Family<'_> {
Some(_) => return Err(not_ours("format is not a string".into())),
};
let applied = take_names(v, "format_pending", noun)?;
self.formats.check(noun, version, applied.as_deref())?;
if version == 0 {
return Err(not_ours("format 0".into()));
}
Ok((version, applied))
}
/// What says a file of format `version` with the changes `applied` came from a newer build:
/// a number past this build's, or a change this build doesn't have anywhere in its history.
/// `None` for a file this build fully knows.
pub fn newer(&self, version: u32, applied: Option<&[String]>) -> Option<Newer> {
self.formats.newer(version, applied)
}
/// Bring a file of format `version` up to this build's; `applied` is its `format_pending`
/// (both from [`Family::take_version`]).
pub fn upgrade(&self, v: &mut Value, version: u32, applied: Option<&[String]>) -> Result<()> {
@@ -350,6 +360,17 @@ impl Family<'_> {
}
}
/// How a file shows a newer build saved it ([`Family::newer`]).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Newer {
/// The number the file says.
pub format: u32,
/// The number this build reads up to.
pub reads_up_to: u32,
/// The changes the file names that this build doesn't have.
pub unknown_changes: Vec<String>,
}
/// The names of the pending changes this build writes into a design (`format_pending`).
pub fn pending_names() -> Vec<String> {
DESIGN.pending_names()
@@ -394,35 +415,25 @@ impl Formats<'_> {
}
}
/// Refuse a `noun` of format `version` that this build can't read: format 0, a newer number,
/// or a change `applied` (its `format_pending`) names that this build doesn't have anywhere in
/// its history, which only a newer build's file can. Changes are recognized across the entire
/// history: a branch's pending change may have landed at a later number. A list beside a format
/// that isn't made of changes means nothing (a tool or a test set an older number on a newer
/// file).
fn check(&self, noun: &str, version: u32, applied: Option<&[String]>) -> Result<()> {
let newer = |what: String| DocError::Invalid(format!("this {noun} was saved by a newer SolidCraft ({what}): update SolidCraft to open it"));
/// [`Family::newer`]. Changes are recognized across the entire history: a branch's pending
/// change may have landed at a later number. A list beside a format that isn't made of changes
/// means nothing (a tool or a test set an older number on a newer file).
fn newer(&self, version: u32, applied: Option<&[String]>) -> Option<Newer> {
let current = self.current();
if version == 0 {
return Err(DocError::Invalid(format!("not a SolidCraft {noun} (format 0)")));
}
if version > current {
return Err(newer(format!("file format {version}; this version reads formats up to {current}")));
}
let applied = applied.filter(|_| self.parts(version).is_some()).unwrap_or_default();
match applied.iter().find(|name| !self.knows_part_name(self.canonical_applied_name(version, name))) {
Some(unknown) => Err(newer(format!("file format {version} with `{unknown}`, which this version doesn't know"))),
None => Ok(()),
}
let listed = applied.filter(|_| version > current || self.parts(version).is_some()).unwrap_or_default();
let unknown_changes: Vec<String> =
listed.iter().filter(|name| !self.knows_part_name(self.canonical_applied_name(version, name))).cloned().collect();
(version > current || !unknown_changes.is_empty()).then_some(Newer { format: version, reads_up_to: current, unknown_changes })
}
/// Bring a `noun` of format `version` up to [`Formats::current`]. `applied` is the file's
/// `format_pending`: the changes of its format it already has, when it was written while they
/// were pending; the others' steps run first. A file [`Formats::check`] refuses is refused,
/// whoever calls, before anything is changed; already-applied changes never run again at
/// their later number.
/// were pending; the others' steps run first, and already-applied changes never run again at
/// their later number. A file past this build's number has no steps to run.
fn upgrade(&self, noun: &str, v: &mut Value, version: u32, applied: Option<&[String]>) -> Result<()> {
self.check(noun, version, applied)?;
if version == 0 {
return Err(DocError::Invalid(format!("not a SolidCraft {noun} (format 0)")));
}
// A list beside a format that isn't made of changes is ignored, like any field an upgrade
// drops.
let applied = applied.filter(|_| self.parts(version).is_some());
@@ -677,18 +688,53 @@ fn v2_to_v3(v: &mut Value) -> Result<()> {
pub fn read(s: &str) -> Result<Document> {
let mut v: Value = serde_json::from_str(s).map_err(|e| DocError::Invalid(format!("not a SolidCraft design: {e}")))?;
let (version, applied) = DESIGN.take_version(&mut v)?;
check_design_numbers(&v)?;
crate::config::validate_rules_json(&v)?;
crate::config::validate_styles_json(&v)?;
crate::face_anchor::validate_face_anchors_json(&v)?;
validate_versioned_geometry(&v)?;
validate_emboss_targets(&v)?;
validate_freeform_offsets(&v)?;
validate_sketch_sizes(&v)?;
crate::format_offsets::validate_raw(&v)?;
let legacy_wires = validate_wires(&v, version)?;
match DESIGN.newer(version, applied.as_deref()) {
Some(newer) => crate::newer_file::read(v, version, applied, newer),
None => decode(v, version, applied.as_deref()),
}
}
/// Whether a design's text says a newer build saved it, read before anything else in it is.
pub fn says_newer(text: &str) -> bool {
#[derive(serde::Deserialize)]
struct Says {
format: Option<String>,
format_pending: Option<Vec<String>>,
}
serde_json::from_str::<Says>(text)
.ok()
.and_then(|says| Some((DESIGN.version_of(&says.format?)?, says.format_pending)))
.is_some_and(|(version, applied)| DESIGN.newer(version, applied.as_deref()).is_some())
}
/// The checks of a design's JSON that need nothing outside the part of it they are given.
pub(crate) fn check_raw(v: &Value, version: u32) -> Result<Vec<(usize, usize)>> {
check_design_numbers(v)?;
crate::config::validate_rules_json(v)?;
crate::config::validate_styles_json(v)?;
crate::face_anchor::validate_face_anchors_json(v)?;
validate_versioned_geometry(v)?;
validate_emboss_targets(v)?;
validate_freeform_offsets(v)?;
validate_sketch_sizes(v)?;
crate::format_offsets::validate_raw(v)?;
validate_wires(v, version)
}
/// [`check_raw`], the upgrade and the typed record alone: what a piece of a design must pass by
/// itself ([`crate::newer_file`]), with nothing else in the file for it to refer to.
pub(crate) fn check_piece(mut v: Value, version: u32, applied: Option<&[String]>) -> Result<()> {
check_raw(&v, version)?;
DESIGN.upgrade(&mut v, version, applied)?;
let d: Document = serde_json::from_value(v).map_err(|e| DocError::Invalid(format!("design file: {e}")))?;
check_typed_numbers(&d)
}
/// A design's JSON, its format taken out, as the document it holds.
pub(crate) fn decode(mut v: Value, version: u32, applied: Option<&[String]>) -> Result<Document> {
let legacy_wires = check_raw(&v, version)?;
validate_body_transfers(&v)?;
DESIGN.upgrade(&mut v, version, applied.as_deref())?;
DESIGN.upgrade(&mut v, version, applied)?;
let mut d: Document = serde_json::from_value(v).map_err(|e| DocError::Invalid(format!("design file: {e}")))?;
check_typed_numbers(&d)?;
let mut legacy_by_feature = std::collections::BTreeMap::<usize, Vec<usize>>::new();
@@ -1327,9 +1373,9 @@ mod tests {
let mut v = serde_json::json!({});
formats.upgrade("design", &mut v, next, Some(&["a-first".into(), "b-second".into()])).unwrap();
assert_eq!(trace_of(&v), Vec::<&str>::new());
// A change this build doesn't know is a newer build's: refused, not guessed at.
let err = formats.upgrade("design", &mut serde_json::json!({}), next, Some(&["c-later".into()])).unwrap_err();
assert!(err.to_string().contains("newer SolidCraft"), "{err}");
// A change this build doesn't know is a newer build's: said, and kept.
let said = formats.newer(next, Some(&["c-later".into()])).unwrap();
assert_eq!(said, Newer { format: next, reads_up_to: next, unknown_changes: vec!["c-later".into()] });
// A list on a format that isn't made of changes means nothing.
let mut v = serde_json::json!({});
formats.upgrade("design", &mut v, HAND_WRITTEN, Some(&["a-first".into()])).unwrap();
@@ -1372,15 +1418,12 @@ mod tests {
}
#[test]
fn personal_card_alias_without_support_refuses_before_mutation() {
fn personal_card_alias_without_support_is_a_newer_builds() {
let hand: [JsonStep; 27] = [|_| Ok(()); 27];
let first = [traced("a-first")];
let formats = Formats { hand: &hand, folded: &[(29, &first)], pending: &[] };
let mut file = serde_json::json!({"trace": [], "sentinel": 7});
let original = file.clone();
let error = formats.upgrade("design", &mut file, 29, Some(&["physical-material-cards".into()])).unwrap_err();
assert!(error.to_string().contains("physical-material-cards"), "{error}");
assert_eq!(file, original);
let said = formats.newer(29, Some(&["physical-material-cards".into()])).unwrap();
assert_eq!(said.unknown_changes, ["physical-material-cards"]);
}
#[test]
@@ -1391,11 +1434,8 @@ mod tests {
let cards = [traced("material-engineering-cards")];
let formats = Formats { hand: &hand, folded: &[(28, &first), (29, &second), (30, &cards)], pending: &[] };
for version in [28, 30] {
let mut file = serde_json::json!({"trace": []});
let original = file.clone();
let error = formats.upgrade("design", &mut file, version, Some(&["physical-material-cards".into()])).unwrap_err();
assert!(error.to_string().contains("physical-material-cards"), "{error}");
assert_eq!(file, original);
let said = formats.newer(version, Some(&["physical-material-cards".into()])).unwrap();
assert_eq!(said.unknown_changes, ["physical-material-cards"]);
}
}
@@ -1408,11 +1448,7 @@ mod tests {
for names in
[["physical-material-cards".into(), "unknown-card-codec".into()], ["unknown-card-codec".into(), "physical-material-cards".into()]]
{
let mut file = serde_json::json!({"trace": ["b"]});
let original = file.clone();
let error = formats.upgrade("design", &mut file, 29, Some(&names)).unwrap_err();
assert!(error.to_string().contains("unknown-card-codec"), "{error}");
assert_eq!(file, original);
assert_eq!(formats.newer(29, Some(&names)).unwrap().unknown_changes, ["unknown-card-codec"]);
}
}
@@ -1446,18 +1482,14 @@ mod tests {
}
/// An experimental producer can use a number that a release later folds without its
/// change. Its unknown concept must still be refused before any migration runs.
/// change. Its unknown concept is still a newer build's.
#[test]
fn unknown_pending_concepts_are_refused_after_folding() {
fn unknown_pending_concepts_are_said_after_folding() {
let parts = [traced("a-first")];
let later = [traced("b-second")];
for pending in [&[][..], &later[..]] {
let formats = Formats { hand: &[], folded: &[(2, &parts)], pending };
let mut file = serde_json::json!({"trace": []});
let original = file.clone();
let err = formats.upgrade("design", &mut file, 2, Some(&["experimental-card".into()])).unwrap_err();
assert!(err.to_string().contains("experimental-card") && err.to_string().contains("update SolidCraft"), "{err}");
assert_eq!(file, original, "refuse before applying even a known migration");
assert_eq!(formats.newer(2, Some(&["experimental-card".into()])).unwrap().unknown_changes, ["experimental-card"]);
}
}
@@ -1479,23 +1511,20 @@ mod tests {
}
}
/// A design naming a pending change this build doesn't have is a newer build's, refused as
/// such before anything else in it is checked (as a newer number is), so a newer build's
/// fields never read as damage.
/// A design naming a pending change this build doesn't have is a newer build's: what it can't
/// read of it doesn't read as damage.
#[test]
fn a_newer_builds_pending_change_is_refused_before_the_design_is_checked() {
fn a_newer_builds_pending_change_opens_and_its_fields_are_not_damage() {
let mut file: Value = serde_json::from_str(&Document::new("A").to_json()).unwrap();
file["format_pending"] = serde_json::json!(["not-a-change-anyone-made"]);
file["features"] = serde_json::json!([{"type": "emboss", "face": {"body": "Body", "at": [0, 0, 0], "a_newer_key": true}}]);
let error = read(&file.to_string()).unwrap_err().to_string();
let opened = read(&file.to_string()).unwrap();
// With changes pending, FORMAT is made of them; once they are folded it is the folded
// format, which is made of changes too. Either way an unknown change is a newer build's.
assert_eq!(
error,
format!(
"this design was saved by a newer SolidCraft (file format {FORMAT} with `not-a-change-anyone-made`, which this version doesn't know): update SolidCraft to open it"
)
);
let said = opened.newer_file.as_ref().unwrap();
assert_eq!(said.unknown_changes, ["not-a-change-anyone-made"]);
assert!(said.notice().contains(&format!("format {FORMAT} with `not-a-change-anyone-made`")), "{}", said.notice());
assert!(matches!(opened.features[0].kind, crate::FeatureKind::NewerFileFeature { .. }));
}
/// Drawings follow the same rules from their own first format: folded formats follow on from 1
@@ -1575,17 +1604,17 @@ mod tests {
fn tagged(family: &Family<'_>, n: u32) -> Value {
serde_json::json!({"format": family.format_string(n)})
}
fn newer(noun: &str, n: u32, current: u32) -> String {
format!(
"this {noun} was saved by a newer SolidCraft (file format {n}; this version reads formats up to {current}): update SolidCraft to open it"
)
}
assert_eq!(refusal(&DESIGN, tagged(&DESIGN, FORMAT + 1)), newer("design", FORMAT + 1, FORMAT));
assert_eq!(read(&tagged(&DESIGN, FORMAT + 1).to_string()).unwrap_err().to_string(), newer("design", FORMAT + 1, FORMAT));
// A newer number isn't refused: it is said.
let says = |family: &Family<'_>, mut file: Value| {
let (version, applied) = family.take_version(&mut file).unwrap();
family.newer(version, applied.as_deref())
};
assert_eq!(says(&DESIGN, tagged(&DESIGN, FORMAT + 1)), Some(Newer { format: FORMAT + 1, reads_up_to: FORMAT, unknown_changes: vec![] }));
assert_eq!(read(&tagged(&DESIGN, FORMAT + 1).to_string()).unwrap().newer_file.unwrap().format, FORMAT + 1);
let current = DRAWING.current();
assert!(matches!(DRAWING.take_version(&mut tagged(&DRAWING, current)), Ok((v, None)) if v == current));
for n in [current + 1, u32::MAX] {
assert_eq!(refusal(&DRAWING, tagged(&DRAWING, n)), newer("drawing", n, current));
assert_eq!(says(&DRAWING, tagged(&DRAWING, n)), Some(Newer { format: n, reads_up_to: current, unknown_changes: vec![] }));
}
for (family, noun) in [(&DESIGN, "design"), (&DRAWING, "drawing")] {
assert_eq!(refusal(family, tagged(family, 0)), format!("not a SolidCraft {noun} (format 0)"));
@@ -1607,12 +1636,7 @@ mod tests {
let design = Family { formats: Formats { hand: &JSON_STEPS, folded: FOLDED, pending: &pending }, ..DESIGN };
let next = design.current();
let file = json!({"format": format_string(next), "format_pending": ["c-later"]});
assert_eq!(
refusal(&design, file),
format!(
"this design was saved by a newer SolidCraft (file format {next} with `c-later`, which this version doesn't know): update SolidCraft to open it"
)
);
assert_eq!(says(&design, file), Some(Newer { format: next, reads_up_to: next, unknown_changes: vec!["c-later".into()] }));
assert!(matches!(DESIGN.take_version(&mut json!({})), Ok((1, None))));
assert_eq!(refusal(&DRAWING, json!({})), "not a SolidCraft drawing (no format)");
}
@@ -1639,19 +1663,13 @@ mod tests {
assert_eq!(open(json!({"format": "solidcraft-drawing/2", "format_pending": ["a-first"]})).unwrap(), Vec::<String>::new());
// A list beside format 1 means nothing: the step still runs.
assert_eq!(open(json!({"format": "solidcraft-drawing/1", "format_pending": ["c-later"]})).unwrap(), ["a"]);
let error = open(json!({"format": "solidcraft-drawing/2", "format_pending": ["a-first", "c-later"]})).unwrap_err().to_string();
assert_eq!(
error,
"this drawing was saved by a newer SolidCraft (file format 2 with `c-later`, which this version doesn't know): update SolidCraft to open it"
);
let error = open(json!({"format": "solidcraft-drawing/3"})).unwrap_err().to_string();
assert_eq!(
error,
"this drawing was saved by a newer SolidCraft (file format 3; this version reads formats up to 2): update SolidCraft to open it"
);
// An upgrade called without `take_version` refuses in the drawing's words too, format 0 included.
let error = drawing.upgrade(&mut json!({}), 3, None).unwrap_err().to_string();
assert!(error.starts_with("this drawing was saved by a newer SolidCraft (file format 3;"), "{error}");
// Of a newer build: said, and what can run runs.
let list = ["a-first".to_string(), "c-later".to_string()];
assert_eq!(drawing.newer(2, Some(&list)).unwrap().unknown_changes, ["c-later"]);
assert_eq!(open(json!({"format": "solidcraft-drawing/2", "format_pending": list})).unwrap(), Vec::<String>::new());
assert_eq!(drawing.newer(3, None).unwrap(), Newer { format: 3, reads_up_to: 2, unknown_changes: vec![] });
assert_eq!(open(json!({"format": "solidcraft-drawing/3"})).unwrap(), Vec::<String>::new());
// An upgrade called without `take_version` still says format 0 isn't one.
assert_eq!(drawing.upgrade(&mut json!({}), 0, None).unwrap_err().to_string(), "not a SolidCraft drawing (format 0)");
}
@@ -1666,7 +1684,8 @@ mod tests {
assert!(read(&file.to_string()).is_err());
}
file["format_pending"] = serde_json::json!(["not-a-change-anyone-made"]);
assert!(read(&file.to_string()).is_err(), "unknown pending concepts are refused even after folding");
let newer = read(&file.to_string()).unwrap();
assert_eq!(newer.newer_file.as_ref().unwrap().unknown_changes, ["not-a-change-anyone-made"], "even after folding");
file["format_pending"] = Value::from(pending_names());
let opened = read(&file.to_string()).unwrap();
assert_eq!(opened.format_pending, pending_names());
@@ -1895,6 +1914,7 @@ mod tests {
distance2: None,
start_offset: None,
through_all: false,
second_follows_first: false,
taper: Some("0 deg".into()),
corner: Default::default(),
to: Some(crate::FacePick(solidcore_record::Vec3::new(0.0, 0.0, 10.0))),
@@ -2138,8 +2158,9 @@ mod tests {
value["features"] = serde_json::json!([{"type":"sketch", "sketch":{"freeform_offsets":records}}]);
let error = read(&value.to_string()).unwrap_err().to_string();
assert!(error.contains("free-form offset metadata"), "{error}");
// A newer build's file isn't held to this: the feature waits for that build.
value["format"] = Value::from(format_string(FORMAT + 1));
assert!(read(&value.to_string()).unwrap_err().to_string().contains("newer SolidCraft"));
assert!(matches!(read(&value.to_string()).unwrap().features[0].kind, crate::FeatureKind::NewerFileFeature { .. }));
}
}
@@ -2163,8 +2184,7 @@ mod tests {
let error = read(&value.to_string()).unwrap_err().to_string();
assert!(error.contains("emboss face"), "{error}");
value["format"] = Value::from(format_string(FORMAT + 1));
let error = read(&value.to_string()).unwrap_err().to_string();
assert!(error.contains("newer SolidCraft"), "{error}");
assert!(matches!(read(&value.to_string()).unwrap().features[0].kind, crate::FeatureKind::NewerFileFeature { .. }));
}
}
@@ -2207,7 +2227,7 @@ mod tests {
}
#[test]
fn version_seven_styles_upgrade_and_newer_versions_are_refused_before_columns() {
fn version_seven_styles_upgrade_and_a_newer_versions_columns_are_kept() {
use crate::config::{Column, ConfigRow};
let mut d = Document::new("configured styles");
d.configs.columns = vec![Column::Material("Body".into()), Column::Appearance("Body".into())];
@@ -2227,9 +2247,11 @@ mod tests {
value["format"] = Value::from(format_string(FORMAT + 1));
value["configs"]["columns"] = serde_json::json!([{"unknown_future_column":"Body"}]);
value["configs"]["rules"] = serde_json::json!({"unknown_future_rule": true});
let error = read(&value.to_string()).unwrap_err().to_string();
assert!(error.contains("newer SolidCraft") && error.contains("update SolidCraft"), "{error}");
assert!(!error.contains("unknown variant"), "format refusal precedes decoding new variants");
let opened = read(&value.to_string()).unwrap();
let said = opened.newer_file.as_ref().unwrap();
assert!(said.not_understood.iter().any(|what| what.contains("configs")), "{:?}", said.not_understood);
let saved: Value = serde_json::from_str(&opened.to_json()).unwrap();
assert_eq!(saved["configs"], value["configs"], "kept for the build that reads them");
}
#[test]
@@ -2284,8 +2306,10 @@ mod tests {
assert_eq!(read(&saved.to_string()).unwrap(), d);
saved["format"] = Value::from(format_string(FORMAT + 1));
saved["features"][0]["sketch"]["constraints"][0]["type"] = Value::from("future_dimension");
let error = read(&saved.to_string()).unwrap_err().to_string();
assert!(error.contains("newer SolidCraft") && !error.contains("unknown variant"), "{error}");
let opened = read(&saved.to_string()).unwrap();
assert!(matches!(opened.features[0].kind, crate::FeatureKind::NewerFileFeature { .. }));
let again: Value = serde_json::from_str(&opened.to_json()).unwrap();
assert_eq!(again["features"][0], saved["features"][0]);
}
#[test]
@@ -2392,15 +2416,14 @@ mod tests {
}
#[test]
fn older_upgraded_newer_refused_others_rejected() {
fn older_upgraded_newer_opened_others_rejected() {
let mut v: Value = serde_json::from_str(&Document::new("A").to_json()).unwrap();
v["format"] = Value::from("solidcraft/1");
assert_eq!(read(&v.to_string()).unwrap().format, format_string(FORMAT));
v.as_object_mut().unwrap().remove("format");
assert!(read(&v.to_string()).is_ok(), "a file without a format is format 1");
v["format"] = Value::from(format_string(FORMAT + 1));
let e = read(&v.to_string()).unwrap_err().to_string();
assert!(e.contains("newer SolidCraft"), "{e}");
assert_eq!(read(&v.to_string()).unwrap().newer_file.unwrap().format, FORMAT + 1);
for bad in ["solidcraft/0", "acad/3", "solidcraft/-1", "solidcraft/99999999999999999999"] {
v["format"] = Value::from(bad);
assert!(read(&v.to_string()).is_err(), "{bad}");
+1 -1
View File
@@ -5,7 +5,7 @@ here is the whole format bump: never edit `FORMAT`, `JSON_STEPS` or another chan
- Name it after the change, lowercase with dashes: `placed-plane-origins.rs`.
- Start it with a `//!` note: what is saved, what an older design gets without it, and what
the last release does with a design that has it (it refuses the newer format number).
the last release does with a design that has it (it warns about the newer format number, opens what it can read and keeps the rest).
- If older designs must be rewritten as they open, add the step to the same file:
```rust
@@ -0,0 +1,91 @@
//! CPU render jobs can save optional `options.output` presentation: tone map, finishing,
//! backdrop and raw linear EXR retention. Older designs omit it and retain the exact previous
//! f64 PNG output; exposure remains owned by RenderSetup. Readers missing these changes open
//! the newer design with a warning and preserve saved values they cannot read.
//! CPU jobs also save optional `options.lens` using the shared physical thin-lens
//! aperture radius and axial focus distance. Positive aperture requires perspective
//! plus explicit progressive camera sampling; absent/zero aperture keeps old pixels.
//! CPU raytrace jobs also retain default-false `transparent_background` beside options.
//! It exports straight-alpha primary geometry coverage; absent fields keep opaque PNGs.
//! CPU jobs also save opt-in `options.denoise`; omission/false preserves the prior
//! raw PNG calculation. Every complete published pass is filtered without changing
//! raw film or the final unfiltered EXR. Older files need no rewrite for this flag.
//! CPU jobs also preserve optional `options.path: {max_depth: 1..16}` for explicit
//! progressive diffuse/specular transport and certified Glass boundaries. The depth
//! counts shaded surfaces, optical boundaries and active scattering events. Traced shadows are required;
//! selected reflection/anisotropy overrides and unsupported closed-Glass
//! shadow connections refuse with authored intent retained. Omission keeps the legacy producer.
//!
//! CPU raytrace jobs save optional `options.aging` using the existing ordered
//! SavedAgingStack schema and stable definition/feature/component/occurrence targets.
//! Empty/absent stacks preserve prior saved JSON, CPU pixel arithmetic and work.
//! Check and Start resolve stable targets against the captured scene; stale/hidden
//! targets are refused rather than made global. Captured indices/names remain
//! runtime-only. Aging changes the opaque BRDF before lighting and denoising guides;
//! it does not change geometry, emission, physical materials or mass. Certified
//! glass targets and passive stages remain excluded; independently authored anisotropic axes
//! cannot be replaced by scalar aging roughness and are refused explicitly.
//!
//! Saves up to eight hard GPU directional sources alongside the existing optional
//! finite Sun (eight combined). Older designs omit the new list and retain their
//! exact implicit studio or finite-Sun lighting. Readers missing the new list warn
//! about the newer design and preserve these unread sources.
//! Also saves up to eight GPU point/spot sources with exact world-mm positions,
//! reference/minimum distances and optional world-axis/radian half cones. Missing
//! punctual lists stay empty. CPU cone intent retains its wider f64 domain; GPU
//! cone feathers below the documented precision bound refuse without rewriting.
//!
//! CPU jobs may also consume the existing portable saved `surfaces` bindings for
//! opaque built-in base color, roughness, metallic and normal maps. No new map
//! schema is added; raster and unsupported CPU optical combinations refuse the
//! authored bindings instead of dropping them. Old files without maps keep the
//! old CPU allocation/work/pixel path.
//! CPU jobs also save optional `options.adaptive` using the existing independent-pilot
//! policy. Explicit progressive production sampling is required; the unchanged CPU
//! profile budgets pilots plus the production cap. Exact per-pixel counts and pilot
//! statistics stay runtime-only; final SamplingReport and PreviewInfo.adaptive reuse
//! the existing metadata contract without changing linear-master schema/channels.
//! CPU `options.path` may consume the existing saved ClosedMesh optical boundary
//! after exact f64 edge, vertex-link, embedding/contact, outward and conservative
//! nested-parent certification. Runtime-only ClosedMesh and thin-shadow lowering
//! preserve stable authored optics. No new saved field or geometry repair is added.
//! Absent-path sheet/box transport and default serialized bytes remain unchanged.
//! Closed-shadow connections, Glass combined with external volume and selected
//! reflection/anisotropy combinations refuse explicitly with authored values retained.
//! Perspective CPU Path captures classify each physical eye/pupil cooperatively
//! against the certified boundaries and initialize the nested Beer medium stack.
//! Exact origin/near-clip boundary contacts refuse without changing saved cameras
//! or optics. This runtime-only extension adds no serialized field.
//! Existing saved OpticalMaterial.subsurface may also lower to optional captured
//! CPU dielectric subsurface parameters with explicit progressive Path. Selected
//! certified closed media use scalar free flight, HG directions and separate Beer
//! absorption, including physical eye/pupil starts and nested current media. No
//! refracting-boundary next-event approximation is added. Active scattering and
//! surface denoising refuse together; unsupported thin/legacy routes retain intent.
//! Some(rate0) remains serialized but adds no collision work/RNG or pixel change.
//! CPU jobs may also preserve optional options.volume using the same bounded
//! HomogeneousVolume world-box/extinction/RGB albedo/HG contract as GPU Atmosphere.
//! Positive extinction requires explicit progressive Path and disables no existing
//! option implicitly: Glass/SSS and surface denoising refuse with intent retained.
//! Absent/zero volume keeps the original transport work/RNG/heap owner count/pixels.
//! Inline intent's actual fixed retained-request size is prepaid even when absent;
//! regrade owns its separate header reservation until its last status handle drops.
//! Physical eye/pupil prefixes and the orthographic signed line/box union do not
//! alter saved camera/framing/model bounds or its model near interval. Existing
//! ray-distance/profile/task/frame/codec/host caps remain unchanged.
//! CPU jobs also retain optional `sun` and `sky` intent. `sun` reuses the shared
//! finite-cap direction/angular-radius/radiance domain; `sky` reuses the portable
//! gradient/rotation/intensity schema. Both require explicit CPU Path transport.
//! Omitted fields preserve old designs and the absent producer allocation/work/RNG
//! path. Constant, Sky and HDR sources are exclusive; finite Sun is additive.
//! No generated sky image or runtime resource is serialized.
//!
//! CPU and GPU jobs also save `coats`, up to the existing 64 runtime coat target
//! bound, with stable definition/feature/component/occurrence identities and exact
//! weight, roughness and dielectric IOR. Empty/absent lists keep prior JSON and
//! capture behavior. Coats require opaque targets; even a zero-weight authored coat
//! cannot share a target with Glass. Capture resolves targets without changing
//! appearances, engineering materials or geometry. Unsupported CPU selected
//! reflection/anisotropy overrides refuse instead of dropping the coat.
@@ -0,0 +1,3 @@
//! Saves exact encoded texture bytes, inert provenance and explicit saved surface roles.
//! Older designs have no embedded textures and retain their built-in surface bindings.
//! The last release asks for an update instead of silently dropping the new resources.
@@ -0,0 +1,12 @@
//! Nothing new is saved for holes, but a hole placed on a face that names the bodies it drills
//! (`participants`: its `targets`, or the body of another component it was picked on) now finds
//! the face it starts on among those bodies' faces, where one of them passes through its position
//! (#12066). Where another body touched that face at the hole, older designs could save a
//! `direction` taken from the other body's face, pointing out of the drilled body; such a hole cut
//! only a shallow pocket. It is now mirrored into the drilled body's face when the design is
//! evaluated, as format 18 does for a direction taken from the far side of a thin plate, and
//! drills through. Holes whose direction enters one of their own bodies' faces there, holes whose
//! own bodies have no face at their position (placed on another body's face to drill through it),
//! holes that name no bodies, and holes at several sketch points (already limited to their bodies,
//! #2882) build as before. No rewrite is needed. The last release refuses a design with this
//! change (the newer format number) instead of building the pocket again.
@@ -0,0 +1,5 @@
//! A sketch's intersection link can save a curve of another sketch as either side (`a` or `b`,
//! `{"type": "sketch_curve", sketch, curve}`), standing in for that curve's extrusion along its
//! sketch's normal (#11048). An older design has only bodies and faces there, and opens as before.
//! The last release refuses a design that has one (it asks for an update) instead of opening the
//! link as lost.
@@ -0,0 +1,5 @@
//! An at-angle construction plane may save `direction_only` and an optional `pivot` point: the
//! plane turns about the line through the pivot (or the base origin) using only the axis
//! direction projected onto the base. An older design has neither and keeps turning about the
//! axis as a full line. The last release refuses a design that has this change (the newer
//! format number) instead of opening it without the option.
@@ -0,0 +1,6 @@
//! A point cloud keeps the segments and triangles its file draws over its points (#8817):
//! `segments` and `triangles` in its `complex`, the indices of their points, and a column of named
//! numbers may lie on them (`on`: `segment` or `triangle`; a column that does not say is on the
//! points, as every design saved before this has it). Older designs need no transformation: their
//! clouds are points alone. The last release refuses a design saved with this (the newer format
//! number) instead of opening its clouds as loose points without their edges and faces.
@@ -0,0 +1,5 @@
//! A revolve's explicit axis `{origin, dir}` can save its origin's coordinates as length
//! expressions (`["W / 2", 0, "groove_floor"]`) so the axis follows the parameters; a plain number
//! stays a number. An older design has numbers only and reads as before. The last release refuses
//! a design with this change and asks for an update, instead of opening it and reading an
//! expression as the origin.
@@ -0,0 +1,61 @@
//! In a two-sided extrude (`distance2`) with entered distances, Direction fixes the sides and the
//! sign of a distance only turns its own side over; `distance2` no longer follows the first
//! side across the sketch plane. Older builds sent `distance2` to the other side of a negative
//! first distance, so this step marks every saved two-sided extrude with entered distances
//! `second_follows_first`, which keeps that reading. The distances themselves are left as they
//! were, so an expression keeps the old sides whatever it evaluates to and a feature's named
//! inputs keep their values for the expressions that use them. An extent that reaches to an
//! object or through all already lets the second side follow the first, and a symmetric one has
//! no second side. The last release asks for an update instead of opening a design that carries
//! this change.
use serde_json::Value;
pub fn step(v: &mut Value) -> crate::Result<()> {
let Some(Value::Array(features)) = v.get_mut("features") else {
return Ok(());
};
for feature in features {
if !matches!(feature.get("type").and_then(Value::as_str), Some("extrude" | "surface_extrude")) {
continue;
}
let Some(extent) = feature.get_mut("extent").and_then(Value::as_object_mut) else { continue };
let plain = extent.get("distance2").is_some_and(|d| !d.is_null())
&& extent.get("to").is_none_or(Value::is_null)
&& !extent.get("through_all").and_then(Value::as_bool).unwrap_or(false)
&& extent.get("direction").and_then(Value::as_str) != Some("symmetric");
if plain {
extent.insert("second_follows_first".into(), Value::Bool(true));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use serde_json::json;
#[test]
fn saved_two_sided_extrudes_keep_their_sides_and_distances() {
let mut v = json!({"features": [
{"type": "extrude", "extent": {"distance": "-10 mm", "distance2": "5 mm"}},
{"type": "extrude", "extent": {"distance": "-10 mm", "direction": "negative", "distance2": "5 mm"}},
{"type": "extrude", "extent": {"distance": "-0 mm", "distance2": "5 mm"}},
{"type": "extrude", "extent": {"distance": "-width", "distance2": "10 mm"}},
{"type": "extrude", "extent": {"distance": "-10 mm"}},
{"type": "extrude", "extent": {"distance": "-10 mm", "through_all": true, "distance2": "5 mm"}},
{"type": "extrude", "extent": {"distance": "-10 mm", "to": [0.0, 0.0, 1.0], "distance2": "5 mm"}},
{"type": "sketch"}
]});
let before = v.clone();
super::step(&mut v).unwrap();
let features = v["features"].as_array().unwrap();
let marked: Vec<bool> = features.iter().map(|f| f["extent"]["second_follows_first"] == json!(true)).collect();
assert_eq!(marked, [true, true, true, true, false, false, false, false]);
for (f, b) in features.iter().zip(before["features"].as_array().unwrap()) {
assert_eq!((&f["extent"]["distance"], &f["extent"]["direction"]), (&b["extent"]["distance"], &b["extent"]["direction"]));
}
let mut empty = json!({"name": "Untitled"});
super::step(&mut empty).unwrap();
assert_eq!(empty, json!({"name": "Untitled"}));
}
}
+2 -1
View File
@@ -399,7 +399,7 @@ mod tests {
damaged["features"][1]["profiles"]["associations"] = associations;
assert!(validate_raw(&damaged).is_err());
damaged["format"] = json!(crate::format::format_string(crate::format::FORMAT + 1));
assert!(crate::format::read(&damaged.to_string()).unwrap_err().to_string().contains("newer SolidCraft"));
assert!(crate::format::read(&damaged.to_string()).unwrap().newer_file.is_some());
}
// Absent metadata preserves the historical one-to-one chain representation.
let mut legacy = fixture();
@@ -463,6 +463,7 @@ mod tests {
distance2: None,
start_offset: None,
through_all: false,
second_follows_first: false,
taper: None,
corner: Default::default(),
to: None,
+6 -2
View File
@@ -11,6 +11,7 @@ mod assembly;
mod body_transfer;
mod body_transfer_sheet;
mod browser_tree;
pub mod newer_file;
pub mod pbr_appearance;
pub use body_transfer::{valid_transfer_matrix, validate_body_transfer};
pub mod canvas;
@@ -48,6 +49,7 @@ pub mod motion;
mod name_lookup;
mod names;
pub mod naming;
mod occurrence_tree;
mod params;
pub mod persistent_name;
pub mod physical_material;
@@ -72,11 +74,13 @@ pub use print_face::PrintFace;
pub mod project;
mod project3d;
pub mod render_aging;
pub mod render_coat;
pub mod render_job;
pub mod render_optics;
pub mod render_resources;
pub mod render_settings;
pub mod render_surface;
pub mod render_texture_resources;
pub mod sheet;
pub mod sketch_equation;
pub mod text_params;
@@ -92,8 +96,8 @@ pub use eval::{
blend_target, curve_pick_error, draft_corner_options, edge_names_for, edge_refs, face_point_names, face_point_state, hole_direction_at,
hole_on_surface, import_body_names, join_pieces_added, map_wire, metric_thread_sizes, object_height, parse_metric_thread, pattern_matrices,
revolve_extent, revolve_extent_for_handle, revolve_extent_with_convention, select_profiles, sketch_hole_direction, sketch_line_plane,
state_in_frame, state_in_frame_at, suggested_thread, suggested_thread_in, tangent_face_chain, through_all_distance, world_state,
world_state_reusing,
state_in_frame, state_in_frame_at, suggested_thread, suggested_thread_in, tangent_face_chain, through_all_distance, with_placed_copies,
world_state, world_state_reusing,
};
pub use eval::{EvalCache, KeptResults};
pub use expr::Expr;
+18
View File
@@ -429,6 +429,24 @@ pub(crate) fn cylinder_tool(body: Body) -> Traced {
Traced::made(body, roles)
}
/// The roles of a cone primitive's faces, from the body revolved from its half section (base
/// side first, then the sloped side, then the top side of a cut-off cone; the side on the axis
/// makes no face): `bottom`, `side` and `top`, as a cylinder's are (#7476). `None` when the body
/// has no history of its own.
pub(crate) fn cone_roles(body: &Body) -> Option<Vec<Vec<String>>> {
body.history().filter(|h| h.operands == 0).map(|h| {
h.faces
.iter()
.map(|f| match f.origins.as_slice() {
[FaceOrigin::Profile { segment: 0 }] => vec!["bottom".to_string()],
[FaceOrigin::Profile { segment: 1 }] => vec!["side".to_string()],
[FaceOrigin::Profile { segment: 2 }] => vec!["top".to_string()],
_ => Vec::new(),
})
.collect()
})
}
/// A tool a feature builds without a sketch (a boss's post, a rib, a lip's band), its faces named
/// by the part it makes: `<part>:top` (its end cap), `<part>:base` (its start cap) and
/// `<part>:side:<k>` (swept from segment `k` of its profile), as its construction records them.
+99 -10
View File
@@ -30,6 +30,39 @@ const MAX_HELD: usize = 50_000_000;
/// evaluated again on every open (#8541).
const MAX_TEXT: usize = 1 << 24;
/// Whether two tap markings are the same to the bit: then they write the same record. (`==` takes
/// 0.0 and -0.0 for the same number, whose records differ.) Every field is named, so a new one
/// cannot be left out.
fn same_marking(a: &ThreadInfo, b: &ThreadInfo) -> bool {
let numbers = |t: &ThreadInfo| {
let ThreadInfo {
feature: _,
body: _,
designation: _,
major_diameter,
radius,
pitch,
internal: _,
axis_point,
axis,
start,
end,
form: _,
hand: _,
starts: _,
} = t;
[*major_diameter, *radius, *pitch, axis_point.x, axis_point.y, axis_point.z, axis.x, axis.y, axis.z, *start, *end]
};
a.feature == b.feature
&& a.body == b.body
&& a.designation == b.designation
&& a.internal == b.internal
&& a.form == b.form
&& a.hand == b.hand
&& a.starts == b.starts
&& numbers(a).iter().zip(numbers(b).iter()).all(|(x, y)| x.to_bits() == y.to_bits())
}
fn hex(v: u64) -> String {
format!("{v:016x}")
}
@@ -120,6 +153,14 @@ struct Writer {
/// share them (writing them with every state made a sheet-metal design's model too large to keep).
sheets: Vec<Json>,
sheet_index: HashMap<String, usize>,
/// Tap markings (cosmetic threads) by their record, as the sheets: every state lists the
/// markings of all the features before it, so writing them with each state grew with the
/// square of the features (400 marked cylinders: 34 of a 37 MB manifest, past its limit).
threads: Vec<Json>,
thread_index: HashMap<String, usize>,
/// The state written last and the places of its markings: a state mostly keeps the markings
/// of the one before it, which are found again without writing them as text.
last_threads: Option<(Arc<ModelState>, Vec<usize>)>,
states: Vec<Json>,
state_index: HashMap<usize, usize>,
/// Each kernel body's place in `bodies`, by its record.
@@ -258,6 +299,32 @@ impl Writer {
Some(self.sheets.len() - 1)
}
/// The places of `st`'s markings among the written ones. A marking at the same place in the
/// state written last, the same to the bit, is that one (no text built); any other is keyed by
/// its text, as a sheet is.
fn threads(&mut self, st: &Arc<ModelState>) -> Option<Vec<usize>> {
let mut out = Vec::with_capacity(st.threads.len());
for (i, t) in st.threads.iter().enumerate() {
let before = self.last_threads.as_ref().and_then(|(s, at)| Some((s.threads.get(i)?, *at.get(i)?)));
if let Some((_, at)) = before.filter(|(b, _)| same_marking(b, t)) {
out.push(at);
continue;
}
let key = serde_json::to_string(t).ok()?;
let at = match self.thread_index.get(&key) {
Some(at) => *at,
None => {
self.threads.push(serde_json::to_value(t).ok()?);
self.thread_index.insert(key, self.threads.len() - 1);
self.threads.len() - 1
}
};
out.push(at);
}
self.last_threads = Some((st.clone(), out.clone()));
Some(out)
}
fn state(&mut self, st: &Arc<ModelState>) -> Option<usize> {
let id = Arc::as_ptr(st) as usize;
if let Some(i) = self.state_index.get(&id) {
@@ -266,11 +333,12 @@ impl Writer {
let bodies = st.bodies.iter().map(|b| self.model_body(b)).collect::<Option<Vec<_>>>()?;
let sketches = st.sketches.iter().map(|s| self.sketch(s)).collect::<Option<Vec<_>>>()?;
let sheets = st.sheets.iter().map(|s| self.sheet(s)).collect::<Option<Vec<_>>>()?;
let threads = self.threads(st)?;
let r = json!({
"bodies": runs(&bodies), "sketches": runs(&sketches), "body_counter": st.body_counter,
"reserved_body_names": st.reserved_body_names.iter().collect::<Vec<_>>(),
"sheets": sheets, "construct": serde_json::to_value(&st.construct).ok()?,
"threads": serde_json::to_value(&st.threads).ok()?,
"threads": runs(&threads),
"sketch_key_set": self.sketch_key_set(&st.sketch_keys),
"blend_edges": serde_json::to_value(&st.blend_edges).ok()?, "fitted": serde_json::to_value(&st.fitted).ok()?,
"tapered": serde_json::to_value(&st.tapered).ok()?,
@@ -306,7 +374,8 @@ impl Model {
// The check mode the results were evaluated under: they are another mode's results.
let records = json!({
"features": features, "states": w.states, "model_bodies": w.model_bodies, "names": w.names, "sketches": w.sketches,
"sheets": w.sheets, "validation": self.validation().id(), "rounds": w.rounds, "sketch_key_sets": w.sketch_key_sets,
"sheets": w.sheets, "threads": w.threads, "validation": self.validation().id(), "rounds": w.rounds,
"sketch_key_sets": w.sketch_key_sets,
});
Ok(SavedModel { records, bodies: w.bodies })
})
@@ -388,6 +457,10 @@ impl SavedModel {
for s in list("sheets")? {
sheets.push(serde_json::from_value::<crate::sheet::SheetBody>(s.clone()).map_err(|_| bad("saved model: sheet"))?);
}
let mut threads = Vec::new();
for t in list("threads")? {
threads.push(serde_json::from_value::<ThreadInfo>(t.clone()).map_err(|_| bad("saved model: tap marking"))?);
}
// The sketch-key sets, each the changes from an earlier one (`base`): a short record can
// stand for a large set, so what they hold counts towards MAX_HELD as they are built.
let mut key_sets: Vec<BTreeMap<u64, u64>> = Vec::new();
@@ -413,6 +486,12 @@ impl SavedModel {
}
key_sets.push(keys);
}
// Running totals of what each marking costs to clone (its items and text), by place.
let mut thread_weight = vec![0usize];
for t in &threads {
let w = 1usize.saturating_add((t.body.len().saturating_add(t.designation.len())) / 64);
thread_weight.push(thread_weight.last().copied().unwrap_or(0).saturating_add(w));
}
let records = list("states")?;
// What every state holds, counted from its runs and its sketch-key set before any is
// built (a hostile file cannot make the states clone more than MAX_HELD items).
@@ -422,6 +501,14 @@ impl SavedModel {
held = held.saturating_add(run.get(1).and_then(Json::as_u64).and_then(|n| usize::try_from(n).ok()).unwrap_or(usize::MAX));
}
}
// A marking is cloned deeply into every state that lists it, so each counts by its
// text, not as one item: a run of a large marking repeated cannot exhaust memory.
for run in s.get("threads").and_then(Json::as_array).ok_or_else(|| bad("saved model: state"))? {
let first = index(run.get(0), threads.len())?;
let count = run.get(1).and_then(Json::as_u64).and_then(|n| usize::try_from(n).ok()).ok_or_else(|| bad("saved model: run"))?;
let end = first.checked_add(count).filter(|end| *end <= threads.len()).ok_or_else(|| bad("saved model: run"))?;
held = held.saturating_add(thread_weight[end].saturating_sub(thread_weight[first]));
}
held = held.saturating_add(s.get("sheets").and_then(Json::as_array).map_or(0, Vec::len));
held = held.saturating_add(key_sets.get(index(s.get("sketch_key_set"), key_sets.len())?).map_or(0, BTreeMap::len));
if held > MAX_HELD {
@@ -465,7 +552,7 @@ impl SavedModel {
.map(|i| index(Some(i), sheets.len()).and_then(|i| sheets.get(i).cloned().ok_or_else(|| bad("saved model: sheet"))))
.collect::<Result<Vec<_>>>()?,
construct: serde_json::from_value(field("construct")?).map_err(invalid)?,
threads: serde_json::from_value(field("threads")?).map_err(invalid)?,
threads: ids("threads", threads.len())?.into_iter().filter_map(|i| threads.get(i).cloned()).collect(),
sketch_keys,
blend_edges: serde_json::from_value(field("blend_edges")?).map_err(invalid)?,
fitted: serde_json::from_value(field("fitted")?).map_err(invalid)?,
@@ -613,10 +700,11 @@ impl SavedModel {
let mut kept_bodies = vec![false; model_bodies.len()];
let (mut names_used, mut sketches_used) = (vec![false; names.len()], vec![false; sketches.len()]);
let mut stored = std::collections::HashSet::new();
// The sheet bodies, each written once and named by place in the states: all kept.
// The sheet bodies and tap markings, each written once and named by place in the states: all kept.
let sheets = list("sheets").cloned().unwrap_or_default();
let threads = list("threads").cloned().unwrap_or_default();
let key_sets = list("sketch_key_sets").cloned().unwrap_or_default();
let mut used = bytes(sheets.as_slice()).iter().chain(bytes(key_sets.as_slice()).iter()).fold(0usize, |t, b| t.saturating_add(*b));
let mut used = [&sheets, &threads, &key_sets].iter().flat_map(|l| bytes(l.as_slice())).fold(0usize, |t, b| t.saturating_add(b));
for s in std::iter::once(last).chain(order) {
let (mut adds, mut new_names, mut new_sketches) = (HashMap::new(), std::collections::HashSet::new(), std::collections::HashSet::new());
let mut records = *state_bytes.get(s)?;
@@ -722,7 +810,8 @@ impl SavedModel {
}
let mut records = json!({
"features": out_features, "states": out_states, "model_bodies": out_bodies, "names": kept_of(names.as_slice(), &names_used),
"sketches": kept_of(sketches.as_slice(), &sketches_used), "sheets": sheets, "rounds": out_rounds, "sketch_key_sets": key_sets,
"sketches": kept_of(sketches.as_slice(), &sketches_used), "sheets": sheets, "threads": threads, "rounds": out_rounds,
"sketch_key_sets": key_sets,
});
// The check mode the results were evaluated under, as the model records it (a model
// that records none stays without one: adopting it is refused either way).
@@ -760,7 +849,7 @@ impl Model {
return Err(bad("saved model: features"));
}
let (vals, param_errors) = evaluation_values(doc);
let step_digests = StepDigests::of(doc, &StepDigests::default());
let import_digests = ImportDigests::of(doc, &ImportDigests::default());
let marker = doc.marker.unwrap_or(usize::MAX);
// As `evaluate` digests the context under the mode, so the next evaluation reuses these.
let ctx = context_digest(doc, &vals) ^ validation_digest(validation);
@@ -776,7 +865,7 @@ impl Model {
// A fingerprint reads the state before its feature (where the bodies it takes lie).
// After a state the file left out, the saved one is taken: `evaluate` works that state
// out, checks the fingerprints after it, and evaluates again from one that differs.
prev = if exact { chain_fingerprint(doc, &vals, f, prev, i >= marker, &step_digests, Some(&before)) } else { saved };
prev = if exact { chain_fingerprint(doc, &vals, f, prev, i >= marker, &import_digests, Some(&before)) } else { saved };
if r.get("id").and_then(Json::as_u64) != Some(f.id) || saved != prev {
return Err(bad("saved model: the document changed"));
}
@@ -826,7 +915,7 @@ impl Model {
}
results.push(result);
}
let mut model = Model { results, param_errors, step_digests, context: ctx, frame_bodies, validation, ..Model::default() };
let mut model = Model { results, param_errors, import_digests, context: ctx, frame_bodies, validation, ..Model::default() };
let unkept = model.results.iter().filter(|r| r.unkept).count();
if unkept > 0 {
// The features whose state the file did not keep, each evaluated again from the state
@@ -844,7 +933,7 @@ impl Model {
let mut before = Arc::new(ModelState::default());
if let Ok(mut cache) = model.cache.lock() {
for (f, r) in doc.features.iter().zip(&model.results).filter(|(_, r)| !r.skipped && r.error.is_none()) {
let local = fingerprint(doc, 0, f, &vals, false, &model.step_digests) ^ placement_digest(doc, f, Some(&before));
let local = fingerprint(doc, 0, f, &vals, false, &model.import_digests) ^ placement_digest(doc, f, Some(&before));
cache.put(
cache_key(f, r.fingerprint, local, ctx, &before),
CachedEval {
+5 -2
View File
@@ -835,7 +835,7 @@ pub(crate) fn face_centres(b: &ModelBody) -> Vec<Vec3> {
samples(&b.mesh(), face_count(b)).into_iter().map(|ps| ps.iter().fold(Vec3::ZERO, |a, p| a + *p) * (1.0 / ps.len().max(1) as f64)).collect()
}
/// The name of a face new to `f`.
/// The name of a face new to `f` (a feature that went through `eval::naming_feature`).
fn role_name(f: &Feature, before: &ModelState, ps: &[Vec3], ns: &[Vec3], fi: usize, fid: u64, curves: &std::cell::OnceCell<Curves>) -> String {
let c = ps.iter().fold(Vec3::ZERO, |a, p| a + *p) * (1.0 / ps.len().max(1) as f64);
match &f.kind {
@@ -858,7 +858,10 @@ fn role_name(f: &Feature, before: &ModelState, ps: &[Vec3], ns: &[Vec3], fi: usi
}
FeatureKind::Revolve { sketch, axis, .. } => {
let Some(ss) = before.sketch(sketch.0) else { return format!("F{fid}:face:{fi}") };
let Ok((o2, d2)) = crate::eval::revolve_axis(ss, axis, before, &|name| crate::eval::construction_axis_in(None, before, name, 0)) else {
// The origin was written as its values when the feature was evaluated (`naming_feature`).
let Ok((o2, d2)) =
crate::eval::revolve_axis(&Default::default(), ss, axis, before, &|name| crate::eval::construction_axis_in(None, before, name, 0))
else {
return format!("F{fid}:face:{fi}");
};
let (o, d) = (ss.plane.to_world(o2), (ss.plane.to_world(o2 + d2) - ss.plane.to_world(o2)).normalized().unwrap_or(Vec3::Z));
+530
View File
@@ -0,0 +1,530 @@
//! A design a newer SolidCraft saved: it opens, with a warning, and saving it loses nothing.
//!
//! A newer format number, or a change the file names that this build doesn't have, says a newer
//! build saved the file. It may use nothing this build lacks, so it is read as usual. When part of
//! it can't be read, that part stays out of the way and the rest opens:
//! - a feature this build can't read keeps its place on the timeline as
//! [`FeatureKind::NewerFileFeature`](crate::FeatureKind::NewerFileFeature), which reports an error when evaluated;
//! - a parameter it can't read is left out;
//! - any other top-level section it can't read is left out whole;
//! - a field it doesn't know, which typed reading would drop, is noticed by comparing the file
//! with what was read.
//!
//! All of these are named in [`NewerFile::not_understood`] and go back into the file when it is
//! saved ([`restore`]), as they came. Saving keeps the file's own format number and change list:
//! the file still holds what the newer build wrote, and a lower number would make that build
//! upgrade its own data as if it were old. What can't be put back because the design changed around
//! it ([`not_kept`]) is said in the reply to the save.
use std::collections::BTreeMap;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use crate::format::{self, DESIGN, Newer};
use crate::{DocError, Document, Result};
/// How a [`RawFeature`] is written while it is inside this build; [`restore`] unwraps it.
const FOREIGN_TAG: &str = "newer_file_feature";
/// Sections a design can't open without: filled in when the file's can't be read.
const CORE: [&str; 3] = ["name", "params", "features"];
/// The most sections left out one at a time to find the one a design can't be read with.
const MAX_SECTION_TRIES: usize = 64;
/// Fields a design container adds beside what a design holds; not unknown to this build.
const CONTAINER_ONLY: [&str; 6] = ["step_blob", "mesh_blob", "image_blob", "points_blob", "hdr_blob", "texture_blob"];
/// A feature as a newer build wrote it.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct RawFeature(pub Box<Value>);
impl RawFeature {
/// The kind of feature it says it is.
pub fn kind(&self) -> &str {
self.0.get("type").and_then(Value::as_str).unwrap_or("unnamed")
}
/// Why it doesn't build here.
pub fn why(&self) -> String {
format!("saved by a newer SolidCraft: this version can't read a `{}` feature. Update SolidCraft to build it; saving keeps it", self.kind())
}
}
/// What the file says and holds that this build doesn't read.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct NewerFile {
/// The number the file says.
pub format: u32,
/// The number this build reads up to.
pub reads_up_to: u32,
/// The changes the file names that this build doesn't have.
pub unknown_changes: Vec<String>,
/// What this build couldn't read, by name.
pub not_understood: Vec<String>,
/// Fields known records lost in typed reading.
kept: Option<Kept>,
/// Top-level sections left out whole.
sections: Vec<Section>,
/// Elements left out of a top-level list: its key and the element.
elements: Vec<(String, Value)>,
/// The parts of a design container this build has no use for: name and bytes. A blob only one
/// of the kept pieces refers to is one of them.
pub parts: Vec<(String, Arc<[u8]>)>,
/// The file's `container.json`, when this build doesn't know what it says.
pub container: Option<Value>,
}
#[derive(Clone, Debug, PartialEq)]
struct Section {
key: String,
value: Value,
/// This build's record of the section, without the file's: while it is unchanged, the file's
/// goes back.
empty: Option<Value>,
}
/// The fields of a record that typed reading dropped.
#[derive(Clone, Debug, PartialEq)]
enum Kept {
Field(Value),
Fields(BTreeMap<String, Kept>),
Items(Vec<(Anchor, Kept)>),
}
/// Which element of a list some kept fields belong to.
#[derive(Clone, Debug, PartialEq)]
enum Anchor {
Id(u64),
Name(String),
/// Its place, good only while the list keeps its length.
Index {
at: usize,
of: usize,
},
}
impl NewerFile {
/// What to tell the user, whatever else is said.
pub fn notice(&self) -> String {
let reads = self.reads_up_to;
let format = self.format;
match self.unknown_changes.first() {
Some(change) if format <= reads => {
format!(
"Saved by a newer SolidCraft (format {format} with `{change}`, which this version doesn't know). If something's missing, update."
)
}
_ => format!("Saved by a newer SolidCraft (format {format}, this version reads up to {reads}). If something's missing, update."),
}
}
/// Every line to report: the notice, then what couldn't be read, by name.
pub fn warnings(&self) -> Vec<String> {
let unread = self.not_understood.iter().map(|what| format!("Not read: {what}. Saving keeps it."));
std::iter::once(self.notice()).chain(unread).collect()
}
/// What a reply says about it.
pub fn report(&self) -> Value {
json!({
"format": self.format,
"reads_up_to": self.reads_up_to,
"unknown_changes": self.unknown_changes,
"not_understood": self.not_understood,
"notice": self.notice(),
})
}
/// Whether the next save needs a container to keep what the file holds: plain JSON has no room
/// for parts beside the design.
pub fn needs_container(&self) -> bool {
!self.parts.is_empty()
}
/// Whether anything kept back names this text (a blob's name in a container).
pub fn mentions(&self, text: &str) -> bool {
fn value(v: &Value, text: &str) -> bool {
match v {
Value::String(s) => s == text,
Value::Array(a) => a.iter().any(|x| value(x, text)),
Value::Object(o) => o.values().any(|x| value(x, text)),
_ => false,
}
}
fn kept(k: &Kept, text: &str) -> bool {
match k {
Kept::Field(v) => value(v, text),
Kept::Fields(m) => m.values().any(|x| kept(x, text)),
Kept::Items(l) => l.iter().any(|(_, x)| kept(x, text)),
}
}
self.kept.as_ref().is_some_and(|k| kept(k, text))
|| self.sections.iter().any(|s| value(&s.value, text))
|| self.elements.iter().any(|(_, v)| value(v, text))
}
/// Say that a section the container reader couldn't finish reading is left out.
/// `empty` is what the design writes for the section while it holds nothing, if anything.
pub fn keep_section(&mut self, key: &str, value: Value, empty: Option<Value>, why: &str) {
self.not_understood.push(format!("section `{key}` ({why})"));
self.sections.push(Section { key: key.into(), value, empty });
}
}
/// Read a design a newer build saved, or say why it truly can't be read.
pub(crate) fn read(mut raw: Value, version: u32, applied: Option<Vec<String>>, newer: Newer) -> Result<Document> {
let applied_names = applied.as_deref();
let mut file =
NewerFile { format: newer.format, reads_up_to: newer.reads_up_to, unknown_changes: newer.unknown_changes.clone(), ..NewerFile::default() };
let (mut doc, read_as) = match format::decode(raw.clone(), version, applied_names) {
Ok(doc) => (doc, raw),
Err(_) => {
let mut foreign = BTreeMap::new();
let mut doc = salvage(&mut raw, version, applied_names, &mut file, &mut foreign).map_err(|why| {
DocError::Invalid(format!(
"this design was saved by a newer SolidCraft (format {}, this version reads up to {}) and this version can't read it: {why}. Update SolidCraft to open it",
newer.format, newer.reads_up_to
))
})?;
fill_foreign(&mut doc, foreign);
(doc, raw)
}
};
let mut upgraded = read_as;
// Compared with what was read, the file's own shape after the same upgrade.
DESIGN.upgrade(&mut upgraded, version, applied_names)?;
let known = serde_json::to_value(&doc).map_err(|e| DocError::Invalid(format!("design file: {e}")))?;
let mut paths = Vec::new();
file.kept = vanished(&upgraded, &known, "", &mut paths);
file.not_understood.extend(paths);
for section in &mut file.sections {
section.empty = known.get(&section.key).cloned();
}
if newer.format > newer.reads_up_to {
// The file's own number and list: it still holds what that build wrote.
doc.format = DESIGN.format_string(newer.format);
doc.format_pending = applied.unwrap_or_default();
} else {
// Upgraded to this build's format; the changes it doesn't know are still in it.
doc.format_pending.extend(newer.unknown_changes.iter().cloned());
}
doc.newer_file = Some(Arc::new(file));
Ok(doc)
}
/// Read the design around what can't be read, which moves to `file`. `raw` ends as what was read.
fn salvage(
raw: &mut Value,
version: u32,
applied: Option<&[String]>,
file: &mut NewerFile,
foreign: &mut BTreeMap<u64, Value>,
) -> std::result::Result<Document, String> {
let piece = |key: &str, value: &Value| {
let mut stub = json!({"name": "", "params": [], "features": []});
stub[key] = value.clone();
format::check_piece(stub, version, applied)
};
let Some(top) = raw.as_object_mut() else { return Err("expected a JSON object".into()) };
// A feature it can't read keeps its place; a parameter it can't read stays out.
if let Some(Value::Array(features)) = top.get_mut("features") {
let mut free = features.iter().filter_map(|f| f.get("id").and_then(Value::as_u64)).max().unwrap_or(0).saturating_add(1);
for feature in features.iter_mut() {
if let Err(why) = piece("features", &json!([feature])) {
file.not_understood.push(feature_note(feature, &why.to_string()));
*feature = wrapped(std::mem::take(feature), &mut free, foreign);
}
}
}
if let Some(Value::Array(params)) = top.get_mut("params") {
params.retain(|param| match piece("params", &json!([param])) {
Ok(()) => true,
Err(why) => {
let name = param.get("name").and_then(Value::as_str).unwrap_or("unnamed");
file.not_understood.push(format!("parameter `{name}` ({})", short(&why.to_string())));
file.elements.push(("params".into(), param.clone()));
false
}
});
}
// Any other section, by itself.
let keys: Vec<String> = top.keys().cloned().collect();
for key in &keys {
let value = top.get(key).cloned().unwrap_or_default();
if let Err(why) = piece(key, &value) {
move_out(top, key, &why.to_string(), file);
}
}
if !top.get("name").is_some_and(Value::is_string) {
top.insert("name".into(), json!("Untitled"));
}
for key in ["params", "features"] {
top.entry(key).or_insert_with(|| json!([]));
}
let attempt = |top: &Map<String, Value>| format::decode(Value::Object(top.clone()), version, applied);
let first = match attempt(top) {
Ok(doc) => return Ok(doc),
Err(why) => why,
};
// Sections that are fine by themselves but not together with the rest.
let keys: Vec<String> = top.keys().filter(|k| !CORE.contains(&k.as_str())).cloned().collect();
// Each try reads the whole design: a file of many sections goes straight to the last resort.
for key in keys.iter().filter(|_| keys.len() <= MAX_SECTION_TRIES) {
let mut without = top.clone();
without.remove(key);
if let Ok(doc) = attempt(&without) {
move_out(top, key, &first.to_string(), file);
return Ok(doc);
}
}
let mut bare = top.clone();
bare.retain(|key, _| CORE.contains(&key.as_str()));
match attempt(&bare) {
Ok(doc) => {
for key in &keys {
move_out(top, key, &first.to_string(), file);
}
Ok(doc)
}
Err(_) => Err(first.to_string()),
}
}
/// Leave a section out and keep it. A section the design needs is left empty.
fn move_out(top: &mut Map<String, Value>, key: &str, why: &str, file: &mut NewerFile) {
let Some(value) = top.remove(key) else { return };
file.not_understood.push(format!("section `{key}` ({})", short(why)));
file.sections.push(Section { key: key.into(), value, empty: None });
}
/// The first line of a reader's error, without the list of every kind it knows.
fn short(why: &str) -> &str {
let why = why.split(", expected").next().unwrap_or(why);
why.strip_prefix("design file: ").unwrap_or(why)
}
fn feature_note(feature: &Value, why: &str) -> String {
let name = feature.get("name").and_then(Value::as_str).unwrap_or("unnamed");
let kind = feature.get("type").and_then(Value::as_str).unwrap_or("unnamed");
let why = short(why);
if why.starts_with("unknown variant") {
format!("feature `{name}`, a `{kind}` feature this version doesn't know")
} else {
format!("feature `{name}`, a `{kind}` feature ({why})")
}
}
/// A feature this build can't read as this build reads it: the fields every feature has, which stay
/// editable. Its `raw` is filled in after reading ([`fill_foreign`]), so nothing in a feature this
/// build can't read is judged by this build's rules. `free` gives an id to one the file left
/// without.
fn wrapped(raw: Value, free: &mut u64, foreign: &mut BTreeMap<u64, Value>) -> Value {
let id = raw.get("id").and_then(Value::as_u64).unwrap_or_else(|| {
let id = *free;
*free = free.saturating_add(1);
id
});
let name = raw.get("name").and_then(Value::as_str).unwrap_or("Unknown feature").to_string();
let suppressed = raw.get("suppressed").and_then(Value::as_bool).unwrap_or(false);
let component = raw.get("component").and_then(Value::as_u64).unwrap_or(0);
foreign.insert(id, raw);
json!({"id": id, "name": name, "suppressed": suppressed, "component": component, "type": FOREIGN_TAG, "raw": null})
}
/// Give the features [`wrapped`] left without one what the file said.
fn fill_foreign(doc: &mut Document, mut foreign: BTreeMap<u64, Value>) {
for feature in &mut doc.features {
if let crate::FeatureKind::NewerFileFeature { raw } = &mut feature.kind
&& let Some(value) = foreign.remove(&feature.id)
{
*raw.0 = value;
}
}
}
/// A feature back as the file had it, with the changes this build can make to any feature.
fn unwrapped(feature: &mut Value) {
let Some(wrapper) = feature.as_object_mut() else { return };
let Some(Value::Object(mut raw)) = wrapper.remove("raw") else { return };
for key in ["id", "name", "suppressed", "component"] {
match wrapper.get(key) {
// Left out as a writer leaves it out, unless the file said it.
Some(v) if key != "id" && !raw.contains_key(key) && looks_default(v) => {}
Some(v) => {
raw.insert(key.into(), v.clone());
}
None => {
raw.remove(key);
}
}
}
*feature = Value::Object(raw);
}
/// A value a writer leaves out without losing anything: not worth telling anyone about.
fn looks_default(v: &Value) -> bool {
match v {
Value::Null => true,
Value::Bool(b) => !b,
Value::Number(n) => n.as_f64() == Some(0.0),
Value::String(s) => s.is_empty(),
Value::Array(a) => a.is_empty(),
Value::Object(o) => o.is_empty(),
}
}
/// What `raw` has that `known`, what was read of it, doesn't. `paths` gets those fields' names.
fn vanished(raw: &Value, known: &Value, path: &str, paths: &mut Vec<String>) -> Option<Kept> {
match (raw, known) {
(Value::Object(raw), Value::Object(known)) => {
if known.get("type").and_then(Value::as_str) == Some(FOREIGN_TAG) {
return None;
}
let mut kept = BTreeMap::new();
for (key, value) in raw {
if CONTAINER_ONLY.contains(&key.as_str()) || (path.is_empty() && matches!(key.as_str(), "format" | "format_pending")) {
continue;
}
let inner = if path.is_empty() { key.clone() } else { format!("{path}.{key}") };
match known.get(key) {
None => {
if !looks_default(value) {
paths.push(format!("field `{inner}`"));
}
kept.insert(key.clone(), Kept::Field(value.clone()));
}
Some(known) => {
if let Some(k) = vanished(value, known, &inner, paths) {
kept.insert(key.clone(), k);
}
}
}
}
(!kept.is_empty()).then_some(Kept::Fields(kept))
}
(Value::Array(raw), Value::Array(known)) => {
let mut items = Vec::new();
for (at, element) in raw.iter().enumerate() {
let anchor = anchor_of(element).unwrap_or(Anchor::Index { at, of: raw.len() });
let Some(partner) = find(known, &anchor) else { continue };
let label = match (element.get("name").and_then(Value::as_str), &anchor) {
(Some(name), _) => format!("{path}[{name}]"),
(None, Anchor::Id(id)) => format!("{path}[{id}]"),
_ => format!("{path}[{at}]"),
};
if let Some(k) = vanished(element, partner, &label, paths) {
items.push((anchor, k));
}
}
(!items.is_empty()).then_some(Kept::Items(items))
}
_ => None,
}
}
fn anchor_of(element: &Value) -> Option<Anchor> {
if let Some(id) = element.get("id").and_then(Value::as_u64) {
return Some(Anchor::Id(id));
}
element.get("name").and_then(Value::as_str).map(|name| Anchor::Name(name.to_string()))
}
fn find<'a>(list: &'a [Value], anchor: &Anchor) -> Option<&'a Value> {
match anchor {
Anchor::Id(id) => list.iter().find(|x| x.get("id").and_then(Value::as_u64) == Some(*id)),
Anchor::Name(name) => list.iter().find(|x| x.get("name").and_then(Value::as_str) == Some(name)),
Anchor::Index { at, of } => (list.len() == *of).then(|| list.get(*at)).flatten(),
}
}
fn find_mut<'a>(list: &'a mut [Value], anchor: &Anchor) -> Option<&'a mut Value> {
match anchor {
Anchor::Id(id) => list.iter_mut().find(|x| x.get("id").and_then(Value::as_u64) == Some(*id)),
Anchor::Name(name) => list.iter_mut().find(|x| x.get("name").and_then(Value::as_str) == Some(name)),
Anchor::Index { at, of } => (list.len() == *of).then(|| list.get_mut(*at)).flatten(),
}
}
/// Put the kept fields back. An element that is gone was deleted here, its fields with it; one
/// whose place can't be told any more, and a field whose record is gone, go into `lost`.
fn apply(kept: &Kept, value: &mut Value, path: &str, lost: &mut Vec<String>) {
match kept {
Kept::Field(_) => {}
Kept::Fields(fields) => {
let Some(object) = value.as_object_mut() else {
lost.push(format!("field `{path}`"));
return;
};
for (key, sub) in fields {
let inner = if path.is_empty() { key.clone() } else { format!("{path}.{key}") };
match (sub, object.get_mut(key)) {
(Kept::Field(v), None) => {
object.insert(key.clone(), v.clone());
}
(Kept::Field(_), Some(_)) => {}
(sub, Some(inner_value)) => apply(sub, inner_value, &inner, lost),
// The list it was in is empty and not written: its elements were deleted.
(Kept::Items(_), None) => {}
(_, None) => lost.push(format!("field `{inner}`")),
}
}
}
Kept::Items(items) => {
let Some(list) = value.as_array_mut() else {
lost.push(format!("field `{path}`"));
return;
};
for (anchor, sub) in items {
match (find_mut(list, anchor), anchor) {
(Some(element), _) => apply(sub, element, path, lost),
(None, Anchor::Index { at, .. }) => lost.push(format!("element {at} of `{path}`")),
(None, _) => {}
}
}
}
}
}
/// Write back what the design holds of the file a newer build saved, into `value`, the design's
/// JSON. Returns what couldn't go back ([`not_kept`]).
pub fn restore(doc: &Document, value: &mut Value) -> Vec<String> {
let mut lost = Vec::new();
let Some(file) = &doc.newer_file else { return lost };
if let Some(Value::Array(features)) = value.get_mut("features") {
for feature in features.iter_mut().filter(|f| f.get("type").and_then(Value::as_str) == Some(FOREIGN_TAG)) {
unwrapped(feature);
}
}
if let Some(kept) = &file.kept {
apply(kept, value, "", &mut lost);
}
if let Some(top) = value.as_object_mut() {
for section in &file.sections {
if top.get(&section.key) == section.empty.as_ref() {
top.insert(section.key.clone(), section.value.clone());
} else {
lost.push(format!("section `{}`, replaced by what was edited here", section.key));
}
}
for (key, element) in &file.elements {
let name = element.get("name");
match top.entry(key.clone()).or_insert_with(|| json!([])) {
Value::Array(list) if name.is_none() || !list.iter().any(|x| x.get("name") == name) => list.push(element.clone()),
_ => lost.push(format!("`{key}` element {}, replaced by what was made here", name.and_then(Value::as_str).unwrap_or("unnamed"))),
}
}
}
lost
}
/// What of the file a newer build saved the next save can't keep, because the design was changed
/// around it. Empty almost always.
pub fn not_kept(doc: &Document) -> Vec<String> {
let Ok(mut value) = serde_json::to_value(doc) else { return Vec::new() };
restore(doc, &mut value)
}
#[cfg(test)]
#[path = "newer_file_tests.rs"]
mod tests;
+236
View File
@@ -0,0 +1,236 @@
//! Designs a newer SolidCraft saved: they open, say so, and saving loses nothing.
use serde_json::{Value, json};
use crate::format::{DESIGN, FORMAT, format_string};
use crate::{Document, FeatureKind, Model, PlaneRef};
/// A real design: a sketched disc and its extrusion, and a parameter.
fn design() -> Document {
use solidcraft_sketch::Sketch;
let mut d = Document::new("Plate");
d.set_param("depth", "5 mm", Some("mm"), None).unwrap();
let mut sketch = Sketch::new();
sketch.add_circle(solidcore_record::Vec2::new(0.0, 0.0), 10.0, None, None).unwrap();
let base = d.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch }, Some("Base")).unwrap();
d.add_feature(extrude(base), Some("Pad")).unwrap();
d
}
fn extrude(sketch: u64) -> FeatureKind {
FeatureKind::Extrude {
thin: None,
sketch: crate::SketchId(sketch),
profiles: crate::ProfileSel::All,
extent: serde_json::from_value(json!({"distance": "depth"})).unwrap(),
operation: crate::Operation::NewBody,
targets: vec![],
}
}
fn saved(d: &Document) -> Value {
serde_json::from_str(&d.to_json()).unwrap()
}
/// `(body name, volume)` of everything the design builds, and the error of each feature.
fn built(d: &Document) -> (Vec<(String, f64)>, Vec<Option<String>>) {
let mut model = Model::default();
model.evaluate(d);
let bodies = model.state().bodies.iter().map(|b| (b.name.clone(), b.body.volume_at(0.01).unwrap())).collect();
(bodies, model.results.iter().map(|r| r.error.clone()).collect())
}
fn newer() -> String {
format_string(FORMAT + 1)
}
#[test]
fn one_format_newer_with_nothing_unknown_opens_with_the_same_bodies() {
let original = design();
let mut file = saved(&original);
file["format"] = json!(newer());
let opened = Document::from_json(&file.to_string()).unwrap();
let (bodies, errors) = built(&opened);
assert_eq!((bodies.clone(), errors), built(&original));
assert_eq!(bodies.len(), 1);
assert_eq!(opened.features, original.features);
let notice = opened.newer_file.as_ref().unwrap().notice();
assert_eq!(
notice,
format!("Saved by a newer SolidCraft (format {}, this version reads up to {FORMAT}). If something's missing, update.", FORMAT + 1)
);
assert!(opened.newer_file.as_ref().unwrap().not_understood.is_empty());
// Saved, it still says what it is; read again, the same.
let again = saved(&opened);
assert_eq!(again["format"], json!(newer()));
assert_eq!(Document::from_json(&opened.to_json()).unwrap(), opened);
}
#[test]
fn an_unknown_change_opens_with_a_warning_and_stays_on_the_file() {
let original = design();
let mut file = saved(&original);
file["format_pending"] = json!(["a-later-change"]);
let opened = Document::from_json(&file.to_string()).unwrap();
let report = opened.newer_file.as_ref().unwrap();
assert_eq!(report.unknown_changes, ["a-later-change"]);
assert!(report.notice().contains("a-later-change") && report.notice().starts_with("Saved by a newer SolidCraft"), "{}", report.notice());
assert_eq!(built(&opened), built(&original));
let again = saved(&opened);
assert_eq!(again["format"], json!(format_string(FORMAT)));
assert!(again["format_pending"].as_array().unwrap().contains(&json!("a-later-change")));
assert_eq!(Document::from_json(&opened.to_json()).unwrap(), opened);
}
/// The file, with a feature of a kind this build doesn't have between the sketch and the pad.
fn with_unknown_feature() -> Value {
let mut file = saved(&design());
file["format"] = json!(newer());
file["format_pending"] = json!(["a-later-change"]);
let future = json!({"id": 900, "name": "Lattice1", "type": "gyroid_lattice", "cell": 2.5, "nested": {"big": 1e300}, "suppressed": false});
file["features"].as_array_mut().unwrap().insert(1, future);
file
}
#[test]
fn an_unknown_feature_kind_opens_the_rest_and_reports_that_feature() {
let file = with_unknown_feature();
let opened = Document::from_json(&file.to_string()).unwrap();
let report = opened.newer_file.as_ref().unwrap();
assert!(report.not_understood.iter().any(|what| what.contains("Lattice1") && what.contains("gyroid_lattice")), "{:?}", report.not_understood);
assert_eq!(opened.features.len(), 3);
assert!(matches!(&opened.features[1].kind, FeatureKind::NewerFileFeature { raw } if raw.kind() == "gyroid_lattice"));
let (bodies, errors) = built(&opened);
assert_eq!(bodies.len(), 1, "the pad after it still builds");
assert!(errors[0].is_none() && errors[2].is_none(), "{errors:?}");
let why = errors[1].as_deref().unwrap();
assert!(why.contains("newer SolidCraft") && why.contains("gyroid_lattice"), "{why}");
}
#[test]
fn saving_keeps_an_unknown_feature_as_the_file_had_it_and_the_files_number() {
let file = with_unknown_feature();
let opened = Document::from_json(&file.to_string()).unwrap();
let again = saved(&opened);
assert_eq!(again["format"], file["format"], "never a lower number");
assert_eq!(again["format_pending"], file["format_pending"]);
assert_eq!(again["features"][1], file["features"][1], "the feature as the file had it");
assert_eq!(
again["features"].as_array().unwrap().iter().map(|f| f["type"].clone()).collect::<Vec<_>>(),
[json!("sketch"), json!("gyroid_lattice"), json!("extrude")]
);
assert_eq!(Document::from_json(&opened.to_json()).unwrap(), opened, "saved and read again, the same");
// A rename here goes with it; the rest stays.
let mut renamed = opened.clone();
renamed.features[1].name = "Infill".into();
let again = saved(&renamed);
assert_eq!(again["features"][1]["name"], "Infill");
assert_eq!(again["features"][1]["nested"], json!({"big": 1e300}));
}
#[test]
fn saving_keeps_fields_and_sections_this_version_doesnt_know() {
let mut file = saved(&design());
file["format"] = json!(newer());
file["future_section"] = json!({"anything": [1, 2, 3]});
file["features"][1]["draft_v2"] = json!({"angle": 3.0});
file["params"][0]["future_note"] = json!("keep me");
let opened = Document::from_json(&file.to_string()).unwrap();
assert_eq!(built(&opened).0.len(), 1);
let report = opened.newer_file.as_ref().unwrap();
for field in ["future_section", "draft_v2", "future_note"] {
assert!(report.not_understood.iter().any(|what| what.contains(field)), "{field}: {:?}", report.not_understood);
}
let again = saved(&opened);
assert_eq!(again["future_section"], file["future_section"]);
assert_eq!(again["features"][1]["draft_v2"], file["features"][1]["draft_v2"]);
assert_eq!(again["params"][0]["future_note"], "keep me");
// They follow their records when the design changes around them.
let mut changed = opened.clone();
let id = changed.features[1].id;
changed.features.insert(0, changed.features[0].clone());
changed.features[0].id = 777;
assert_eq!(changed.feature(id).unwrap().name, "Pad");
let again = saved(&changed);
assert_eq!(again["features"][2]["draft_v2"], file["features"][1]["draft_v2"]);
assert!(crate::newer_file::not_kept(&changed).is_empty());
}
#[test]
fn a_section_that_doesnt_read_is_left_out_named_and_kept() {
let mut file = saved(&design());
file["format"] = json!(newer());
file["configs"] = json!({"columns": [{"unknown_future_column": "Body"}], "rules": {"unknown_future_rule": true}});
let opened = Document::from_json(&file.to_string()).unwrap();
assert_eq!(built(&opened).0.len(), 1);
let report = opened.newer_file.as_ref().unwrap();
assert!(report.not_understood.iter().any(|what| what.contains("configs")), "{:?}", report.not_understood);
assert_eq!(saved(&opened)["configs"], file["configs"]);
assert!(crate::newer_file::not_kept(&opened).is_empty());
}
#[test]
fn a_parameter_that_doesnt_read_is_left_out_named_and_kept() {
let mut file = saved(&design());
file["format"] = json!(newer());
file["params"].as_array_mut().unwrap().push(json!({"name": "future", "expression": {"kind": "table"}}));
let opened = Document::from_json(&file.to_string()).unwrap();
assert_eq!(built(&opened).0.len(), 1);
assert!(opened.newer_file.as_ref().unwrap().not_understood.iter().any(|what| what.contains("`future`")));
let names: Vec<_> = saved(&opened)["params"].as_array().unwrap().iter().map(|p| p["name"].clone()).collect();
assert!(names.contains(&json!("future")) && names.contains(&json!("depth")), "{names:?}");
}
#[test]
fn what_cant_go_back_after_an_edit_is_said() {
let mut file = saved(&design());
file["format"] = json!(newer());
file["configs"] = json!({"columns": [{"unknown_future_column": "Body"}]});
let opened = Document::from_json(&file.to_string()).unwrap();
assert!(crate::newer_file::not_kept(&opened).is_empty());
let mut edited = opened.clone();
edited.favorites.insert("depth".into());
assert!(crate::newer_file::not_kept(&edited).is_empty(), "an edit elsewhere costs nothing");
let baseline: Value = saved(&edited);
assert_eq!(baseline["configs"], file["configs"]);
}
#[test]
fn a_feature_this_version_reads_wrongly_is_kept_whole() {
// A kind this build has, in a shape it doesn't: the feature can't read, and is not guessed at.
let mut file = saved(&design());
file["format"] = json!(newer());
file["features"][1]["operation"] = json!("weave");
let opened = Document::from_json(&file.to_string()).unwrap();
assert!(matches!(&opened.features[1].kind, FeatureKind::NewerFileFeature { raw } if raw.kind() == "extrude"));
assert!(built(&opened).1[1].as_deref().unwrap().contains("newer SolidCraft"));
assert_eq!(saved(&opened)["features"][1], file["features"][1]);
}
#[test]
fn what_truly_cant_be_read_says_why() {
for (text, says) in [
("not json", "HDR resource admission"),
("{\"format\":\"solidcraft/0\",\"name\":\"x\"}", "format 0"),
("{\"format\":\"someone-else/9\"}", "not a SolidCraft design (format `someone-else/9`)"),
] {
let why = Document::from_json(text).unwrap_err().to_string();
assert!(why.contains(says), "{text}: {why}");
}
// Two features of one id are damage whatever build saved them.
let mut file = saved(&design());
file["format"] = json!(newer());
let same = file["features"][0].clone();
file["features"].as_array_mut().unwrap().push(same);
let why = Document::from_json(&file.to_string()).unwrap_err().to_string();
assert!(why.contains(&format!("format {}", FORMAT + 1)) && why.contains("can't read it") && why.contains("damaged"), "{why}");
}
#[test]
fn a_file_this_build_fully_knows_is_not_flagged_and_damage_is_still_refused() {
assert!(Document::from_json(&design().to_json()).unwrap().newer_file.is_none());
assert_eq!(DESIGN.newer(FORMAT, None), None);
let mut file = saved(&design());
file["features"][1]["type"] = json!("gyroid_lattice");
assert!(Document::from_json(&file.to_string()).is_err(), "of this build's own format, that is damage");
}
+57
View File
@@ -0,0 +1,57 @@
//! The occurrence tree of a document: each component's child occurrences, gathered in one pass,
//! and the walk of every placement from the root over them. Walking it costs the placements,
//! where scanning every occurrence for each component visited cost placements times occurrences.
use std::collections::HashMap;
use crate::{Document, IDENTITY, MAX_PLACEMENT_DEPTH, MAX_PLACEMENTS, Mat, Occurrence, mat_mul};
/// Each component's child occurrences, in document order, as a document held them when made.
pub(crate) struct OccurrenceTree<'a> {
children: HashMap<u64, Vec<&'a Occurrence>>,
}
impl<'a> OccurrenceTree<'a> {
pub(crate) fn new(doc: &'a Document) -> Self {
let mut children: HashMap<u64, Vec<&Occurrence>> = HashMap::new();
for o in &doc.occurrences {
children.entry(o.parent).or_default().push(o);
}
OccurrenceTree { children }
}
/// The occurrences placed in `component`, in document order.
pub(crate) fn children(&self, component: u64) -> &[&'a Occurrence] {
self.children.get(&component).map_or(&[], Vec::as_slice)
}
/// Every placement from the root: (component, occurrence path ids, world transform), with
/// each occurrence placed in its parent by `transform`. Depth first, children in document
/// order. A branch stops past [`MAX_PLACEMENT_DEPTH`] levels, the walk past [`MAX_PLACEMENTS`]
/// placements.
pub(crate) fn placements_with(&self, transform: impl Fn(&Occurrence) -> Mat) -> Vec<(u64, Vec<u64>, Mat)> {
let mut out = vec![(0, Vec::new(), IDENTITY)];
let mut stack: Vec<(u64, Vec<u64>, Mat)> = vec![(0, Vec::new(), IDENTITY)];
while let Some((comp, path, m)) = stack.pop() {
if out.len() > MAX_PLACEMENTS {
break;
}
// Only this branch stops past the depth limit; the others are still walked.
if path.len() > MAX_PLACEMENT_DEPTH {
continue;
}
for o in self.children(comp) {
let mut p = path.clone();
p.push(o.id);
let w = mat_mul(&m, &transform(o));
out.push((o.component, p.clone(), w));
stack.push((o.component, p, w));
}
}
out
}
}
#[cfg(test)]
#[path = "occurrence_tree_tests.rs"]
mod tests;

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