Compare commits

...

247 Commits

Author SHA1 Message Date
bherbruck e0cc740cc3 Release 0.17.0
The tree is main at 511faedcf exactly; the 0.16.x release commits are kept as a parent so release only moves forward.
2026-10-20 11:41:48 -04:00
Christian Svensson 511faedcfa Fix OpenGL startup with portable AO depth reads (#2888) (#2933)
* claim: #2888

* fix: preserve AO depth in a portable integer texture (#2888)
2026-10-20 11:41:40 -04:00
bherbruck c5129a45d7 Release 0.17.0 (#3629)
Version 0.17.0; design format 28 fixed (13 pending changes folded).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:41:10 -04:00
bherbruck db75b7bfac Make the off-thread OK test deterministic (#3622)
The test could lose a race: with a zero wait the evaluation sometimes finished and applied
before the check that the dialog was still computing. The evaluation is now held at a test
gate until the dialog has been seen saying so.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:18:32 -04:00
amolinari-autotex 4dcca282da claim: #3245 (#3246) 2026-10-20 14:36:04 +00:00
bherbruck e98e065584 Open saved designs from the model they were saved with (#3053) (#3102)
* WIP: adopt the saved model on open (#3053)

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

* WIP: tests

* WIP: bench-open opens as Open does

* docs: opening from the saved model

* Tests: an edit after opening evaluates from the feature it changes

* clippy

* Read SOLIDCRAFT_NO_MODEL_CACHE through the env helper

* Open: no picture from the model when the model itself opens; cancel test evaluates

* Saved model: bodies passed through found without writing their record; states list runs of bodies

* Adopted results go into the evaluation cache: an undone edit, or the marker rolled forward, takes them again

* Model cache size guard: the saved model's writer

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:24:34 +00:00
github-actions[bot] af32243df1 Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci] 2026-10-20 14:48:16 +00:00
rlartot 885d2aafbb sketch.create: reject a non-numeric custom plane origin (#3248) (#3252)
* claim: #3248

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

* sketch.create: reject a custom plane origin that is not three numbers

A custom plane {origin, x_dir, y_dir} whose origin was given as an
expression ("w/2") or a number with a unit ("20 mm") silently became
[0, 0, 0], while a bad x_dir or y_dir was an error. A missing origin is
still the world origin; one that is present but is not three numbers now
returns a clear error. Expressions are not evaluated, so the saved
format is unchanged.

Fixes #3248

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 14:24:18 +00:00
SS-EN c7bd467be7 joint.inspect: show agents what each joint is attached to (#3453)
* joint.inspect: what each joint is attached to, for agents

A read-only `joint.inspect {joint?}` gives each joint's definition: for each
side the occurrence, its component and path, the snap as saved, and what
each persistent face/edge name refers to (the feature that made it, and the
sketch and curve a swept side came from), whether the origin resolves and
why not; plus kind, offset, angle, flip, values with limits, expressions,
the motion links it is in, and its solve errors and conflicts. Its id ends
in `.inspect`, so agent gates treat it as reading only.

MCP `inspect_design` includes the definitions when the design has joints
(`joints: false` leaves them out). joint.list and joint.inspect share the
motion-link JSON.

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

* i18n: catalog entries for Inspect Joint (en, ja, zh)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:21:54 +00:00
SS-EN fb811fbf21 Sketch trim: drag a path across curves to trim every piece it crosses (#3182)
`sketch.trim.path {points, dry_run?}` walks a polyline and, at each place it
crosses a curve trim may change, removes that piece exactly as `sketch.trim`
would there, in the order crossed and in one undo step. A piece already gone
is not trimmed again; projected geometry, infinite lines, offset outputs and
pieces trim refuses are passed over (reported in `skipped`). It returns each
trim's result and each removed piece as a polyline; `dry_run` reports them
without changing the design.

With Trim on, a press and drag in the viewport draws that path: the pieces it
would remove light up (a dry run of the command) and letting go runs it. A
click still trims the one piece under it.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:20:12 +00:00
takuto-NA 843c0bf97a Reduce repeated sketch diagnostics and enable normal-CI profiling regressions (#2355) (#2359)
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-20 13:55:25 +00:00
Irakli Kourtis 50da77abc4 CI: a run another label starts reports the commit's real result as ci (#3596)
The merge queue reads the required ci check from the newest run of a commit. Adding any label but
approved-for-ci starts a run whose jobs all skip, and its ci job was skipped too (as
'ci (ignored label)'), so a pull request that had passed could not enter the queue (#1850, #3182,
#3252). On such a run the ci job now waits for the commit's other runs to finish and passes only
when the newest one that ran its checks passed.


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

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:09:49 +00:00
rlartot 95de388ba1 Sheet metal: refuse an edge flange on a bend line (#3234) (#3273)
* claim: #3234

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

* Sheet metal: refuse an edge flange on a flange's bend line (#3234)

A pick on the edge where a bend meets the plate (or the flange panel)
found a panel edge that is already a bend line and made a second flange
on it, leaving a gap and a sheet of two shells. add_flanges_each now
refuses such an edge before anything moves.

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 12:46:10 +00:00
takuto-NA d8eb65d26e Make constrained sketch edges follow the cursor (#3360) (#3362)
* claim: #3360

* Fix constrained sketch edge following and final drag coordinates
2026-10-20 12:43:59 +00:00
cronos 7c6845da95 Paste: the file-link test builds proper file URLs on Windows too (#3348) (#3494)
The test turned a temp path into a link with `file://` + the path, which on
Windows gives `file://C:\Users\...`, not a file URL, so the test failed there.
It now builds `file:///C:/Users/.../my%20part.stl` on Windows (what Explorer
and browsers put on the clipboard) and `file:///tmp/...` on Linux and macOS,
as before. `files_in_text` already reads Windows file URLs correctly; the
fixed test now checks that on Windows.

Co-authored-by: Ray <raykholo@users.noreply.github.com>
2026-10-20 12:42:43 +00:00
SS-EN 9c26f6dbc9 Agent cursor: bound the show per request; run engine.batch while shown (#2900)
Every engine.execute was acted out stop by stop (0.45 s glide + 0.25 s click
per toolbar button and per on-screen point), so a 720-point sketch.line took
minutes while the app answered no other control request. Now the pointer
visits the toolbar button and at most three picks (a long point list is
summarised by its first, middle and last point on screen), and a request is
acted out within 1.5 s whatever its size (the legs get shorter).

engine.batch is no longer refused while the cursor is shown: the pointer acts
out a short summary (the toolbar buttons of up to four of its distinct
commands, within the same budget), then the whole batch runs in one request.
A failed engine.batch now says whether the design really went back
(rolled_back).


(cherry picked from commit 55535e01af45cc9ee7555320863ff5ddcd7c151f)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:41:05 +00:00
shop-3d 2061ad1694 i18n: add missing Danish translations (#3028)
* i18n: add Danish (da) UI translation

* i18n: refine Danish part properties wording

* i18n: sort Danish catalog in canonical key order
2026-10-20 12:39:11 +00:00
Lucas Montenegro Carvalhaes e37a8fa6bb Fillet: skip smooth seams in tangent chain and restore picked edges when unticked (#3370) (#3437)
* claim: #3370

* fillet: stop tangent chains at smooth seams and sync chain toggle (#3370)
2026-10-20 12:38:48 +00:00
Igor Bronovskyi b3811168fc fix(web): keep the CJK face a fallback for a non-CJK UI (#3535)
BIZ UDPGothic's Cyrillic glyphs are full-width (1.0 em), so promoting the
CJK font to the front of Proportional made Ukrainian text look wide with
large gaps between letters, broke word wrapping, and truncated UI labels.
When CJK text loaded on the web, install_cjk_font hard-coded "ja" and
stayed promoted after switching to Ukrainian or English.

Pass the live UI language to install_cjk_font_for so the CJK face only
leads a CJK UI. Keep fetched CJK font bytes on the web shell and re-apply
fonts when the language changes, and add a regression test that a
non-CJK UI keeps the CJK face as the last fallback.
2026-10-20 12:38:27 +00:00
rlartot c64a611818 Boolean: fail an unverifiable intersection instead of keeping what it should trim (#3244) (#3344)
* claim: #3244

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

* Fail an unverifiable intersection instead of keeping what it should trim (#3244)

When every route of the general boolean fails, it falls back on the sampled answer
(one body whole, or nothing) with a "not verified ... that part is ignored" warning,
even when the bodies' facets cross well past their faceting error. For an
intersection that keeps exactly the material it was asked to trim: teeth standing
0.27 mm out of a trimming cylinder came back untrimmed, and `solidcraft-cli run`
exited 0 with a part 16.918 mm in radius.

An intersection whose sampled answer is contradicted by facets crossing by more than
twice the depth the warning reports (6 x the check mesh tolerance, three times the
faceting error of two meshes) now fails, and the model is kept. Cuts and unions keep
the fallback: shelling a body in two pieces relies on it (a cavity tool grazing the
other piece), with the same depth and ignored volume as this case.

Fixes #3244

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

* Leave an empty unverified intersection as it was (#3244)

Only a doubted intersection that keeps one body whole is refused. An empty
one ("the bodies are apart") stays a result reported unverified: the clearance
and interference checks rely on it, and main's new
a_pin_through_a_d_bore_of_its_size_still_overlaps_the_flat (#2714) expects it.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:37:43 +00:00
phanoob 9a0e6f8ae0 A body dropped on a component's Bodies folder moves into that component (#3270) (#3347)
* claim: #3270

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

* A body dropped on a component's Bodies folder moves into that component (#3270)

The Bodies (and Sketches) folder row only took items of its own
component, to take them out of groups. A body dragged there from another
component was refused, though the folder looks like the place to drop
it, so moving a body back to a parent seemed impossible. Items of the
same folder from another component now move into the folder's
component, as a drop on the component's own row does.

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

---------

Co-authored-by: Phanoob <phanoob@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:36:37 +00:00
rlartot 689038d187 Delete Face asks for its faces instead of failing (#2167) (#3296)
* claim: #2167

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

* Delete Face asks for its faces instead of failing (#2167)

Clicking Delete Face in the toolbar ran face.delete with no parameters and
reported "`faces` must be a list of points or face names", ignoring any
preselected face. It now opens a dialog with a Faces input, like Shell,
Draft and Offset Face, takes the preselected faces, and reopens from the
timeline.

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 12:35:30 +00:00
Nathan Charles 3f7959b536 Canvas and Decal: the file picker opens on PNG and JPEG images (#3266)
* Canvas and Decal: the file picker opens on PNG and JPEG images

canvas.insert and canvas.decal opened the general Open dialog with no file
type, so it started on "Designs, CAD files and meshes" and PNG/JPEG files
were greyed out. Offer the image types first, as Insert SVG/DXF do (#1801).

* Canvas and Decal: rustfmt the file-picker test
2026-10-20 12:34:23 +00:00
Irakli Kourtis a7bc7fe2ff AGENTS.md: merge queue, no merges of main, check open work before starting (#3559)
- The repository is tangency-labs/solidcraft (origin and the claim check pointed at the old one).
- PRs land through the merge queue (`gh pr merge N --squash --auto`) after a maintainer adds
  approved-for-ci; merging your own PR straight away is gone.
- A PR is rebased onto main, never merged with it.
- New "Before you start": an authenticated gh, the full open PR and issue lists, and a look for the
  same feature or the same files before opening a second PR for one change.


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

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:28:22 +00:00
Georgette 876d57a67b Mouse gestures: ring of tools on the right button, per studio and customizable (#2934) (#2936)
* Mouse gestures: a ring of tools on the right button, per studio and customizable

Preferences > Navigation gets a Mouse gestures switch and a Customize window (number of
tools and their place, per studio). Gestures need the SolidWorks mouse mode and turn the
right-drag orbit off. The ring is 280/350 pt, one zone per tool, alternating tints (three
when the count is odd). The radial context menu is unchanged.

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

* Mouse gestures: the ring opens on the right-button press; the right-click menus are off while gestures are on

The ring opens at once on the press, even while a tool or dialog is active. While gestures are
active the viewport's right-click context menu, the right-click that finishes a tool and the
radial context menu no longer fire. With gestures off nothing changes.

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

* Mouse gestures: the ring opens after 4 px with the right button held; a plain right-click keeps its menu

The ring no longer opens on the press. It opens where the button went down once
the pointer has moved 4 points with the button held, even while a tool is in
progress. A right-click that does not move opens the usual context menu (or
finishes the tool) again; the release that ends a gesture opens no menu. The
radial menu stays off while gestures are active.

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

* Re-trigger CI (the run was cancelled when approved-for-ci was added)

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-20 12:28:11 +00:00
bherbruck 47108fa063 Open: show a design as its bodies are meshed (#3053) (#3178)
* Open: show the design as its bodies are meshed, off the UI thread (#3053)

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

* Open: hold screenshots until the design is meshed; profile lines

* Tests: time-bounded opens

* Open: a Loading parts n/N note while bodies are meshed

* Open: frame the design from outlines while it goes into the window

* Outline bounds: vertices and edges only; test

* Frame watch: the window thread's frame times by open phase (SOLIDCRAFT_PROFILE_FRAMES)

* Open: the picture grows in mesh order and stays once complete; refreshes pace themselves; named frame steps; watchdog test

* Open: outlines measured on the file worker and kept per body

* Open: draw the Home picture under the opened file's own key once meshed

* Format the open wait

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:27:42 +00:00
raykholo ca9ebb44db Sheet metal: refuse a flat pattern that no longer matches the body (#3324) (#3325)
* claim: #3324

* Sheet metal: refuse a flat pattern that no longer matches the body

A feature that is not sheet metal can change a sheet body's material
without its sheet model knowing: a mirror combined into it, an extrude
joined onto it, a fillet or chamfer on a corner. The flat pattern, the
DXF and the bend table then wrote the sheet as it was before, a part
other than the one in the design, with no warning.

The evaluator now notes, per sheet, the first such feature: one that
changes the body's shape while its sheet model stays the same. That
feature warns, and sheet.flat_pattern, sheet.export_dxf (also with
per_body) and sheet.export_bend_table refuse with "not supported yet",
naming it. A sheet metal feature, or machining the sheet keeps as
cut-outs, rebuilds the body from the model: it clears the note and
warns that the change is not kept. Moving the body, splitting its faces
and cosmetic threads leave the flat pattern as it is.

* Sheet metal: a modelled thread is refused in the flat pattern too

Only a cosmetic thread leaves a sheet body's material as it is. A
modelled thread cuts the thread profile into the hole, which the flat
pattern does not have, and a later sheet metal feature dropped it
without a word. It now marks the sheet like a fillet does: it warns,
the flat pattern, DXF and bend table refuse naming it, and the feature
that rebuilds the body says the thread is not kept.
2026-10-20 12:07:24 +00:00
jb69 e44dd4ff9e Loft: refuse consecutive sections that lie in one plane (#3412)
A loft through two sketches on the same plane built a flat body of zero
volume and reported success, so the mistake surfaced later, if at all
(a measure of 0, or a boolean that did nothing).

`loft_wires` now checks each pair of consecutive sections: parallel
planes with the second section's centroid in the first's plane are
refused with "loft sections i and i+1 lie in the same plane (no volume
between them)". The tolerance scales with the section's distance from
its plane's origin.

Test: two circles on XY; the loft fails with that message and leaves no
body.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:43:14 +00:00
jb69 ebada963a4 Parameters: walk through a flipped plane to its base's expressions (#3481)
`PlaneRef::walk_exprs` stopped at `Flipped`, so a sketch created with
`flip` on an offset plane (`{plane: "XZ", offset: "d", flip: "x"}`) did
not list the offset as an input. Changing `d` rebuilt nothing that read
the sketch, and the model went stale.

`Flipped` now continues to its base, as `Offset` and `AtAngle` do, in
both `walk_exprs` and `walk_exprs_mut`.

Test: a loft between XZ and a flipped XZ plane offset by `d` has the
volume for d = 30, and for d = 50 after `parameters.change`.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:43:10 +00:00
raykholo 9666c0be56 Convert to Sheet Metal keeps a plate where it is (#3316)
* claim: Convert to Sheet Metal keeps a plate where it is

* Convert to Sheet Metal keeps a plate where it is

Converted without bend edges, a plate moved by one thickness: a 100 x 60 x 2
plate on XY came out at z -2..0 picked on its top face, 2..4 picked on its
bottom. The sheet's frame (its base panel's bottom face, thickness along its
normal) was put on the plate's far face facing away from the picked face, so
the material grew out into empty space.

The frame now lies on the picked face with z into the material, as Convert
with bend edges does. The sheet is the old one moved back by the thickness:
same orientation, flat pattern, flange directions and pocket layers.

A test converts the plate from either face and checks its bounding box; the
engraved-plate test now checks the plate's position, not only its thickness.
2026-10-20 11:43:07 +00:00
yadi choudhary 9506336829 Fonts: find a Chinese fallback font on Linux and Windows (#3376) (#3466)
* claim: #3376

* Fonts: find a Chinese fallback font on Linux and Windows (#3376)

The Chinese fallback was searched for at a few fixed paths, and in the
font folders only on macOS. On Linux and Windows a Chinese font anywhere
else (Fedora's NotoSansCJK-VF.ttc, WenQuanYi under truetype/wqy/, Source
Han Sans, a per-user font) was never found, so a Japanese CJK font drew
the Chinese interface alone and its simplified characters (设, 语, 实)
showed as boxes.

Search the system and per-user font folders on every desktop system,
three folders deep, and take a font only when it draws simplified
Chinese, so a Japanese-only file with a matching name is skipped. The
"sourcehanss" keyword matched no real file; match SourceHanSans* and
SourceHanSerif*, and Noto Serif CJK/SC.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:33:06 +00:00
Jakobonly 9dfa9e2d52 Timeline: the mouse wheel scrolls the horizontal timeline (#3356) (#3409)
* claim: #3356

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

* Timeline: the mouse wheel scrolls the horizontal timeline (#3356)

The horizontal timeline is a horizontal-only scroll area, which egui scrolls
only with Shift + wheel or a sideways trackpad swipe, so with a plain mouse
wheel a history wider than the window could not be scrolled at all. The strip
now takes the vertical wheel as sideways scrolling.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:33:03 +00:00
rlartot c5b637600c Chamfer: one pick follows a straight edge's curved tangent chain at any part size (#2672) (#3295)
* claim: #2672

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

* Chamfer: one pick follows a straight edge's curved tangent chain at any size (#2672)

chamfer_tool built a single-edge cutter for a straight edge even when the
edge runs on tangentially into an arc (a slot side into its end). The
cutter's overshoot (max(2*distance, 1% of the part size)) is probed for
material beyond the edge ends; on a small part the probe hit the arc's
material and the cutter was refused, so the blend followed the whole
chain. Past a bounding-box diagonal of about 200 mm the overshoot cleared
the arc, the cutter was accepted, and only the straight edge was chamfered,
silently.

A straight edge whose smooth chain contains a curved edge no longer gets a
cutter; it takes the chain-following blend, as at small sizes and as a
fillet does.

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 11:21:32 +00:00
JEREMY 3855c4c7e2 Sheet metal: two corner reliefs that would overlap are both left out (#3421)
The two reliefs that would overlap are still resolved one after the other: the
second one is left out, with a note. The corners come out in flange-index
order, so which of the two keeps its relief depends on the order the flanges
were made in.

Two corners that share an edge never come this far: each relief reaches
exactly r along that edge, and a relief is refused unless 2r fits in it. Two
corners that do not share one can: the square relief at a corner of 120°
reaches 2r from it along the bisector, so at the two 120° corners opposite
each other in a rhombus the two reach each other.

Two reliefs that would overlap are now left out together, each with a note
naming the other, so that which of them is cut does not depend on the order
the flanges were made in.

Fixes #3417.

Co-authored-by: jeremymiribung-blip <jeremymiribung-blip@users.noreply.github.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-10-20 11:21:29 +00:00
DuBearte d60b438f42 A finished sketch shows its standalone points (#3267) (#3304)
* claim: #3267

* fix(view): a finished sketch shows its standalone points (#3267)

Points were drawn only for the sketch being edited, so a point placed for a
hole vanished once the sketch was finished although it could still be picked.
The standalone points of every finished sketch shown are now drawn.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:21:26 +00:00
github-actions[bot] 2ace233cd4 Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci] 2026-10-20 11:33:12 +00:00
AlF c83446c6c9 Fix issue 3120, import of STEP file not correct (#3208) 2026-10-20 11:07:50 +00:00
bherbruck 349a6c6fc4 perf: a copy placed for showing doesn't turn its revolved faces again (#3044) (#3104)
Moving an occurrence places every body under it again, and each placed copy
searched every revolved face for where it sits to turn it away from its seam,
although its source had been turned already. On the Prusa Core One assembly
that search was most of the 12.7 s a move of the top occurrence took. A body
now records that its revolved faces are turned, and world placement
(`place_matrix`) skips the search for such a body. Modelling moves (pattern
copies, hole tools) keep `transform_matrix`, which still turns them in the
copy's own frame: where a face wraps all the way round the turn it finds
depends on those coordinates, and later booleans depend on it.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:01:56 +00:00
bherbruck f6cf8ceaad perf: an instanced STEP part's first instance shares its display mesh (#2814) (#3047)
Later instances of a part showed a mesh shared through the first instance,
but the first instance meshed its own, so every repeated part was meshed
twice. The first instance of a part placed more than once now shows the
shared mesh too; a part placed once keeps its own (no second copy).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:01:50 +00:00
bherbruck 83ecf91ae7 perf: autosave writes on a worker thread (#3044) (#3139)
Autosave serialised and wrote the whole design on the window's thread: about
120 ms on the Prusa Core One assembly (its 61 MB STEP text inside), a hitch
once per interval after every change, and more once saves keep the evaluated
model. A tick now takes a snapshot of the design (its Arc and what is hidden)
and writes it on a worker; a later tick takes up the outcome, and no new write
starts while one runs. Crash replays rebase onto the autosave only when the
design is still the one written. Save and close still wait for the write, and
dropping the autosaver lets a running write end.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:00:11 +00:00
fugazister a3f28c0b89 Look At frames the sketch's geometry, not its plane's origin (#3198) (#3199)
* claim: #3198

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

* Look At frames the sketch's geometry, not its plane's origin (#3198)

Look At on a sketch (its menu, Ctrl+8, the navigation bar, entering a
sketch) centred the view on the sketch plane's origin and kept the zoom,
so a sketch drawn away from it - an imported part placed far from the
origin - ended up out of view. Both paths now share onto_sketch: centred on
the sketch's bounds, zoomed out when they would not fit (never in); an
empty sketch keeps the plane's origin.

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

* Register the Look At tests

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

* Look At tests: Vec3::len

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:47:26 +00:00
SS-EN ac757960dc Boolean retries: a candidate whose edges run far outside the operands is discarded unmeshed (#3020) (#3185)
The retries in boolean_whole_inner mesh every candidate to compare its
volume. A nudged union at the coarse tolerance could come out with a face
on a cone whose trim loop ran metres along the cone's continuation (edges
up to 12.7 m outside a 155 mm part); meshing it took seconds to minutes per
face and once never finished, though its volume (+-1e10 mm3) failed the
check anyway.

check() first samples the candidate's edges (16 per edge) and discards a
candidate with a point further outside the operands' mesh box than 5% of
their size (or a non-finite point): no edge of a union, cut or
intersection can lie there. The reason goes into the new
BooleanDiagnostics::rejected (omitted from JSON when empty). Results of
sound candidates are unchanged.

On the reporting design the PitchTail join drops from 18.7 s to 4.3 s with
the same result; the regression tests synthesize a cone face whose loop
runs off its surface.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:45:27 +00:00
Matthew Knutson 953fd7d5f8 Typed parameter contracts and JSON Schemas for solid.box and solid.extrude (#2829) (#2611)
* Spike: typed parameter contracts and JSON Schemas for solid.box and solid.extrude

The schema RFC's spike (plan/schema-spike.md). solid.box and solid.extrude get
typed contracts with generated JSON Schemas (2020-12), checked in front of the
unchanged handlers by a per-session level: off (the default, which changes
nothing), warn (the call runs and gains param_warnings) or strict (the call is
refused and the design left alone). Also engine.describe and the MCP tool
describe_command, `solidcraft-cli mcp --param-check off|warn|strict`, a
FeatureResult type for feature replies, and an env-var audit recorder
(SOLIDCRAFT_PARAM_AUDIT) used to check the contracts against every test.

Local spike branch; not for upstream as is (see the results report).

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

* Parameter contracts: put the audit recorder behind a feature; drop two ignored test keys

The audit recorder is now on only in a build with the engine's `param-audit` feature
and `SOLIDCRAFT_PARAM_AUDIT` set. Its code is still compiled, linted and tested without
the feature. `scripts/param_audit.py` summarises a test run's audit logs, and the schema
module's docs give the steps for converting a further command.

Two tests passed keys their command ignores, which the audit found: `position` on
`solid.box` (`hole_face_tests.rs`) and `symmetric` on `solid.extrude` (`face_tests.rs`).
Both are removed, so the tests build what they always did. Comments no longer call
the contracts a spike.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-20 10:45:08 +00:00
Artem Sushchev ae2ab87b99 Cut: a body with a sealed void no longer comes out empty (#3175)
truck placed a face that no intersection divides (every face of a shell
that meets nothing of the other) by counting the faces a ray crosses,
taking each face's surface as stored. A revolved body's reversed faces
counted the wrong way, and an inward shell (a void, a cut's inverted
tool) was read as a solid: a cut beside a sealed void came out empty,
refused or, with a tool whose bounding box covers the target, taken with
the body gone. Intersections of such bodies came out empty as well.

In an AND the crossings now count as each face is oriented, and an
inward shell holds what lies outside it. An OR keeps truck's count.

The corrected cut let a folded offset through: an arc edge next to a
face it meets tangentially had its ends moved past its middle, and the
arc went the long way round (robustness seed 73's shell). The offset
refuses an arc whose middle crossed its chord, as it does a straight
edge that turned round.

Goldens: seeds 0, 45 and 77 had a cut refused as empty that now takes
what it should; their volumes are checked by hand in the PR.
2026-10-20 10:30:13 +00:00
Mateusz Sych 8a562f2719 Mesh faces whose boundary touches itself within tolerance (#1648) (#1850)
A face pinched to a point, where its boundary touches itself or another of its loops within
TOLERANCE (a sphere touching a box's edge leaves one on the box), had a constraint refused by the
CDT, and the fallback cut across the face: it came out with no triangles or without those at the
pinch. When a constraint is refused, the vendored truck-meshalgo now puts every boundary point
within TOLERANCE of a segment into it (at most 8192) and triangulates the face again. Faces whose
segments all go in mesh as before.

The regression tests live in crates/kernel/src/thin_face_tests.rs, and the #1648 property test in
boolean_props.rs is no longer ignored. Robustness seeds 20 and 55 are recorded with their Linux
values.

Co-authored-by: MateuszSychXpl <151755914+MateuszSychXpl@users.noreply.github.com>
2026-10-20 08:28:21 +00:00
fugazister e9b1a077d7 Delete: check what a delete breaks off the window thread (#2678) (#2679)
* claim: #2678

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

* WIP: timeline.dependents shares the evaluation cache (#2678)

The check before a delete evaluated the edited design on a fresh model:
it recomputed the whole timeline and left nothing for the delete. It now
evaluates on a copy of the session's model. Adds Ahead::replies for
running such checks off the window thread (the UI part is still to come).

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

* Delete: check what a delete breaks off the window thread (#2678)

Both checks a delete makes first (timeline.dependents for the timeline's
Delete, the selection.delete dry run for Delete on the selection) evaluate
the history after the deleted feature, on the window thread. A part with a
slow feature froze the window for as long as everything after it took
(5.9 s in the report). They now compute off the window thread like undo and
the history marker: within the app's 150 ms wait they carry on at once,
otherwise the window says it computes and the delete (or the confirmation)
follows when the report is in. A design changed meanwhile is checked again;
a second Delete meanwhile is left out.

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

* ci: run the checks again (the run for 3b94e8e0 was cancelled when the PR left draft)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-20 06:37:50 +00:00
Irakli Kourtis 37a8402cae surface.blend: seam alignment, chain-topology refusal and a dialog (#764) (#2488)
* kernel: a blend runs along chains of several faces and lines closed rims up (#764)

A blend's chain may run along the boundaries of several faces (a tube's rim
along the halves of its wall): each edge continues from its own best-facing
face, read station by station. Before, a chain had to be the edges of one face,
so every rim of a revolved or cylindrical surface was refused.

Two closed chains blend from matching seams: the second chain's start (searched
by arclength, then narrowed) and direction are the ones that put each station
closest to its partner, so rims whose seams sit apart no longer twist. A
picked `seam` point on each chain fixes the start instead. A closed chain
against an open one is refused. Closed chains leave towards each other's
centroid, not a point on the rim.

Stations are read on the edges' own curves, not on the chords of the length
table (they sat up to the chord's sagitta inside a curved edge), and the
reported gap is measured to the curves.

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

* kernel: closed blend chains run round the same way; Hermite strip and seam tests (#764)

The second of two closed chains runs round the same way as the first (by
their area vectors), so a picked seam turns the blend by just the angle
between the seams instead of folding it over. The nearer pairing decides only
when the loops stand almost at right angles.

Tests for the issue's gates: the blend between two offset box rims is the
analytic cubic Hermite strip (control points from the chord) and keeps the
top faces' normal along both seams; picked seams a quarter apart turn the
blend a quarter round.

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

* surface.blend: `seam` picks where each chain's stations start (#764)

A point on each chain, saved with the feature (one pending format change),
moved with the design like `align`, and refused when it is not two points.

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

* surface.blend: a test between two tube rims, their seams picked and saved (#764)

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

* Blend dialog: edges at each end, continuity and bulge per end, stitch (#764)

SURFACE › CREATE › Blend Surface opens a dialog: the edges the blend leaves
and the ones it arrives at, each end's continuity (G0–G3) and bulge, and
Stitch to both bodies. Edit Feature brings it back and keeps what the dialog
does not show (align and seam points). Labels in every locale catalog.

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

* ROADMAP: M8 Blend runs along several faces, pairs closed rims and has a dialog (#764)

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

* kernel: a blend station on the vertex between two support faces is read on the neighbour (#764)

A station where a tube wall's two halves meet sits at a corner of one half's
parameters; with a leave direction that turns round the wall (tubes off each
other's axis) the shape could not be read there and the blend was refused. The
neighbouring chain edge's face holds the same point and reads it.

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

* blend: review clean-ups (#764)

- ChainLine keeps one list of samples instead of four parallel lists.
- frames_on and boundary_gap take the chain's ChainLine instead of rebuilding it.
- A station is read on the neighbouring face only when it sits on the vertex the two edges
  share, each face once, and each face keeps its own walk.
- The seam rule (a closed chain starts at it, an open one runs from the nearer end) lives in
  ChainLine::start_at_seam; pair_with reuses the start of its both-ways search.
- support_faces projects with curveblend::project; loop_area sits with plane_through.
- surface.blend parses `align` and `seam` through one point_pair; the Blend and Ruled dialogs
  share body_and_edges.

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

---------

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 04:26:12 +00:00
Erlis Vidal 9e15995a74 A cut ending in a face of its target no longer leaves the hole closed by a skin (#2827)
A cut whose tool ends exactly on a face of the target (a hole cut from a pocket's floor up to the
plate's top face) could come out of the general boolean with the target's face whole over the hole
and the tool's end face under it: a skin of no thickness that keeps the hole closed. No volume
shows it and the sampled membership checks see the hole either way, so the result was kept. The
result check now refuses a body with a planar face lying back to back on another of its own planar
faces with nothing behind part of it (`coplanar::has_skin`), and the retries take the cut.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 04:15:37 +00:00
Peppe Bergqvist 926a7353a3 Sheet metal: possible to create flange on a flange (#3274) 2026-10-20 04:02:20 +00:00
Victor Santos Oliveira c400188778 fix(ci): normalize Windows Cargo metadata paths (#3164) 2026-10-20 04:01:10 +00:00
phanoob 58303ba036 Preview: nothing is drawn once the current inputs fail (#3254) (#3256)
* claim: #3254

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

* Preview: nothing is drawn once the current inputs fail (#3254)

The last good preview stayed up when the current inputs made the feature
fail, so the view contradicted the dialog (Extrude at 0 mm still showed
the 1 mm extrusion beside its error). Once the result for the current
inputs is an error, the preview scene is hidden and the bodies it stood
in for come back; while the next preview computes, the last good one
still stays up as before.

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

* Extrude preview: a zero distance is refused at once (#3254)

The straight extrusion feedback gave up on a zero distance, so the
preview built a whole scratch design only for the kernel to refuse it,
and the old preview hung on until then. It now returns the extrusion's
own error straight away.

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

---------

Co-authored-by: Phanoob <phanoob@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 03:50:52 +00:00
JIJINBEI fe3b14e798 refactor: Aabb2 and point-built Aabb3 for every hand-rolled box (#2428)
* Iterate instead of indexing in nine loops

Replace index loops that look up neighbours by position with enumerate,
zip(cycle().skip(1)) for wrap-around pairs and zip(skip(1)) for
consecutive pairs. No behaviour change.

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

* geom: ring_edges for the edges of a closed ring

Every walk over a polygon's edges, the last back to the first, spelled
x.iter().zip(x.iter().cycle().skip(1)); eighteen of them now call
geom::ring_edges. Three redundant take(len) calls go with them.

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

* geom: Ring, a closed ring that knows it is closed

A Ring holds items in order around a closed polygon, loop or fan; edges()
walks each with the next and the last with the first, corners() each
with both neighbours. Ring<Vec2> has signed_area and even-odd contains.

Loop2::polyline returns a Ring, as do doc's face_loops and
boundary_loops, kernel's coplanar wire samples, CapLoop's segments,
tess pieces' rings, the gizmo's plane quads and the navigation cube's
facets. Their walks take edges() or corners() instead of wrapping an
index; sketch's text-hole test and doc's poly_area become contains and
signed_area. Loop2::joints pairs each segment with the next.

Replaces the ring_edges free function.

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

* geom: Chain, Ring and Path; bounds for a chain of points

Ring becomes Chain<T, Closed> beside Path = Chain<T, Open>: edges()
closes a ring and stops at a path's end, Path::close makes the ring,
and bounds() (the empty box for no points) serves both. Eight
hand-rolled min/max folds over a loop's polyline call bounds(); doc's
flat_polyline returns a Ring.

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

* geom: area and inside on Ring for any planar point

Ring's signed_area and contains take any Planar point (Vec2 or
[f64; 2]). The cam profile in joints_cam is a Ring: its own even-odd
test goes, and its edge walks, gap and area take edges(). face_params'
loop area, the text path's winding, tess pieces' ring area and the
copies in three tests call signed_area or contains; the formulas are
the same term for term.

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

* doc: sheet's closed outlines go through Ring

dedup takes and returns a Ring (dropping a last point back on the first
is what makes it a ring's), near_outline walks a Ring's edges, and the
copy of it beside fold checks calls it. point_in goes: its callers ask
the Ring. Its formula anchored each crossing on the other end of the
edge, so the two can differ only for a point on an edge to the last
digit; one caller sets such points aside first, the others test a
region's interior point, a cut's midpoint or another outline's corners.

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

* doc: cam tests build their outlines with Ring::from

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

* geom: area_vector on Ring<Vec3>; three Newell sums use it

Ring<Vec3>::area_vector is half the sum of a × b round the ring. heal's
loop_area, lip's outer-loop turning and the revolve's loop orientation
summed it by hand with a wrapped index; they take it from the Ring
(the half is exact, and two of them only read its sign).

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

* geom: Aabb3 from points; boxes built and asked through it

Aabb3 collects from points (skipping non-finite ones, as add does) and
gains inflated, contains and meets. Fifteen hand-rolled min/max folds
over points or boxes in kernel, doc and engine become collect or union,
and their "within tol of the box" tests become inflated(tol).contains.
blend's refused-edge boxes and naming's face index hold an Aabb3.

A NaN point is now outside every box, where the old negated
comparisons let it in; an infinite point no longer stretches a box.

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

* geom: Aabb2, the plane's box; 2D folds and box tuples use it

Aabb2 mirrors Aabb3 (collect, add, union, inflated, contains, meets)
and adds relative, a point's place in the box. sketch's link and
link_loops each had that formula; link_loops' boundaries, prism's
segment and loop boxes, profiles' shape boxes and grid, and ends'
segment box hold an Aabb2 instead of a (low, high) pair. Folds in the
cam, sketch bounds, sheet's perforation grid and the sketch clipboard
collect one.

Boxes are built from the same terms (a round's box keeps c - r - tol).
As with Aabb3, a NaN point is outside every box and an infinite point
no longer stretches one.

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

* kernel: prism's segment boxes are plain Aabb2s

The SegBox alias named a (low, high) pair; Aabb2 says it itself.

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

* geom: Chain::bounds is an Aabb2; the boxes #2382 left as pairs follow

Chain::bounds returns an Aabb2. Its callers keep it: sheet's hole and
cut boxes (loop_box), letter boxes, island outlines and line-near-cut
test use meets, contains and inflated; profile's outline overlap and
max_clearance, profiles' box tests and the cam test read its corners.
flat_size and the flat pattern's JSON size are the union of the loops'
boxes (0 × 0 for no loop, where the pair fold gave -∞).

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

* geom: Aabb3::intersection; hand-grown and hand-met boxes use the box

Aabb3::intersection is the box both hold. ops' four overlap boxes of a
boolean's operands take it, and seven boxes grown by hand (ops' body
boxes, the distance set's bounds, coplanar's face box, eval_more's
padded mesh box, compare's group and region boxes) call inflated with
the same amount on every side.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-20 03:44:49 +00:00
leValdemar19 e27d43553c Sketch Dimension: line to circle/arc edge (#1803) (#1863)
A line and a circle or arc can now be dimensioned to the circle's edge
(ConstraintKind::LineCircleDistance, saved as line_circle_distance): the gap
while the line misses the circle (0 is tangency), or how far the edge reaches
past the line when it cuts through (stored as `inside`).

Rebased onto main as one commit (the branch's own changes; its merges of main
dropped). Conflicts resolved: the test mod lines in cmd/sketch.rs (both kept,
sorted), and README.md, whose feature list main moved to docs/capabilities.md;
the line-to-circle dimension is listed there now.



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

Co-authored-by: jyviko <jyviko@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 03:44:16 +00:00
Thodoris Tsagkogeorgas de7fabc3bc Preferences: a Plugins page lists installed plugin folders (#2718)
* Preferences: a Plugins page lists installed plugin folders

Each folder under the per-user plugins directory that has a plugin.json
appears in Preferences. Turning one off is saved with the other settings.
The page reads the manifest and does not run any code from the folder.

* Retrigger checks after the cancelled run

The required ci check failed because the workflow was cancelled before
any job started. This commit does not change the plugins page.
2026-10-20 03:38:47 +00:00
Igor Bronovskyi 1b6c10f6e0 Translate the Ukrainian catalog messages added after #1632 (#2569) 2026-10-20 03:36:01 +00:00
fkirill 59b6eea516 Support tapered extrusions at line/arc corners (#2754) (#2788)
* claim: #2752

* claim: #2754

* claim: #2751

* claim: #2752

* claim: #2754

* claim: #2751
2026-10-20 03:31:55 +00:00
phanoob b168750fb3 Viewport: the ground grid stays when zoomed in close (#3087) (#3124)
* claim: #3087

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

* Viewport: the ground grid is centred where the view's axis meets the ground (#3087)

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

* Grid: its centre stays within reach of the point under the target

At a grazing slant in perspective, where the view's axis meets the
ground runs far off, and the ground in front of the camera had no grid.
The centre now moves no further than the grid's half-size from the
point under the target (review of #3124).

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

---------

Co-authored-by: Phanoob <phanoob@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 03:28:11 +00:00
Nathan Dane 25ea9e6419 icons: add the silhouette master and wire it to inspect.silhouette (#2102)
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-20 03:25:17 +00:00
Colin Faulkingham fa3ada1983 claim: #2708 (#2739)
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-20 03:25:05 +00:00
Karl Von Richter 759e22b582 Try exact local cuts after invalid empty numerical results (#1765) (#1858)
Two empty numerical cut attempts stopped the boolean retry sequence before
the exact geometric strategies ran. When the result guard refuses that empty
candidate, the exact strategies (sliced planar, pushed walls, local prism,
sphere lens, seam halves) now try the cut first; if none recovers it, the
original empty candidate and diagnostics are kept, before any displaced
numerical retry.

Rebased onto main as one commit (the branch's own changes, its merges of main
dropped). The only conflict was the new engine regression test next to
main's #1985 STEP export tests in boolean_regression_tests.rs; both are kept.
2026-10-20 03:25:02 +00:00
Irakli Kourtis 14b7529465 Loft guide rails (#201) (#3371)
* kernel: loft guide rails (#201)

A loft follows one or two guide rails between its sections: stations are added between each two
sections, each the plain loft there moved so that the point a rail guides lands where the rail
crosses the station's plane — turned about the loft's direction and scaled (or stretched across
to the rail) with one rail, carried, turned and sized between two. The sections and caps stay
exact; the smooth loft runs through sections and stations. Solid and sheet lofts.

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

* doc, engine: solid.loft and surface.loft take guide rails (#201)

`rails` (one or two {sketch, curves}) and `rail_fit` (scale | stretch), stored on Loft and
SurfaceLoft (one pending format note), parsed by the sweep's rail parser (now shared) and built
through the kernel's railed loft. Rails on a loft to a point, or on a surface loft with edge
sections or end continuity, are refused as not supported yet. The rails' sketches are walked as
the feature's references and their components placed like a sweep's.

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

* ui: Rail, Second Rail and Rail Fit in the Loft and Surface Loft dialogs (#201)

The rail inputs and fit row the sweep dialogs have, shared through dialogs_sweep; Edit Feature
brings the rails back.

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

* docs(roadmap): loft guide rails (#201)

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

* loft rails: review clean-ups (#201)

- Sweep and loft rails share one turn-and-size map (`sweep_path::rail_frame`).
- A station's middle and the loft's direction come from one curve through the sections'
  middles (exact derivative) instead of two more plain-loft evaluations per station; at most
  33 wires, so railed faces stay light.
- Nearest points on a section use `param_on` (kept only where the search comes nearer); the
  size uses `Aabb3`; one B-spline builder; anchors keep their nudge.
- The railed and plain lofts share one path in `loft_with_rails` and `loft_sheet_with_rails`.
- The engine leaves the not-yet-supported refusals to the design's evaluation; both lofts start
  their rails from the first section's first point.

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

* loft rails: refuse rails on sections with holes, with a test (#201)

Main's lofts now take sections with holes (#2447); rails shape only the
outlines, so a railed loft of rings is refused until the rails carry the
holes too.

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

---------

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 03:22:30 +00:00
github-actions[bot] bdfaf690d7 Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci] 2026-10-20 03:32:45 +00:00
cronos b4520d265e perf(ui): show sample thumbnails immediately and reuse Recent previews (#2944) 2026-10-20 02:33:35 +00:00
Rifqi Al Fatih 46a0b25280 STEP import: CJK names read back whole (#2344) (#2347)
Adjacent \X2\...\X0\ runs, as step_out writes one per character, now
decode: the backslash opening the next run was pushed as a literal, so a
name exported as 圆角 came back as 圆\X2\89D2\X0\ (also every linked
component insert, which round-trips through STEP).

STEP and IGES bytes that are not UTF-8 are decoded in their detected
legacy encoding (chardetng + encoding_rs) instead of from_utf8_lossy, so
GBK or Shift_JIS names from vendor files no longer turn into U+FFFD.

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-20 02:32:50 +00:00
HiroKenEz ca9c363a98 Sketch: a click that moves on the way places its point (#2448) (#2449)
* claim: #2448

* Sketch: a click that moves on the way places its point (#2448)

egui reports a press that moves past its click distance, or is held past its
click time, as a drag, not a click. The viewport only placed points on clicks,
so a hand that moved while clicking placed nothing with the Slot, Line, arc,
three-point, Ellipse, Spline and the other tools, and on the first point of a
Rectangle, Circle or Polygon the few pixels became the press-drag-release
gesture of #1630 and drew a sliver.

- Any tool's press that turns into a drag (no navigation, no Select Other
  hold) clicks where the button is let go.
- A Rectangle, Circle or Polygon drag let go within 24 screen points of its
  press is such a click too: the first point goes where it was let go and the
  shape goes on by clicks. A longer drag still draws the shape (#1630).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-20 02:32:23 +00:00
bherbruck d0afc31632 Save the evaluated model with every design: exact, stored once, scaled caps (#3053) (#3093)
* Model cache written by default: exact bodies, stored once, scaled caps (#3053)

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

* bench-open: say why bodies were not kept

* Exact planes and revolutions; budgets for large bodies (#3053)

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

* Native B-rep: rebuild shells as the kernel made them (closed and seam edges) (#3053)

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

* docs: the model cache and the exact native B-rep (#3053)

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

* bench-open: time a save without the cache too

* Model cache entries are Deflate-compressed (#3053)

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

* Model cache: one that does not fit the design file is left out, never a failed save (#3053)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 02:31:08 +00:00
Artem Sushchev 80719744cf Union: when the slab past pushed-apart coplanar faces can't be cut off, trim it by editing faces (#2801) 2026-10-20 02:11:23 +00:00
JEREMY 2de99c728e Sheet metal: a corner relief no longer depends on the order the corners are cut in (#3280)
* Sheet metal: a corner relief no longer depends on the order the corners are cut in

The corner reliefs are planned against the base as it is before any of them is
cut, and the two reliefs at the ends of one edge are compared with each
other. Before, every relief was cut into the base as it went, so the corner
after it saw the relief of the corner before it: the square relief reaches
further from its corner than the round one does (4 / cos(α / 2) against 4), so
on a short edge it was left out where the round one was cut, and which of the
two corners kept its relief depended on the order the flanges were made in.

The reliefs themselves are unchanged: a round about the corner through the
angle the base fills there, or the square with that side (a kite where the
corner is not square, its cuts square to the two edges). What is new is that
they are planned first and cut together, so that a corner's relief depends on
the base and on the other reliefs, and not on the order they are cut in. Two
reliefs that would overlap are still refused, now with a note that says so.

Covered: the four relief shapes at corners of 60° and 120°, with the flanges
of equal and of unequal height, through the kernel and through the engine
(where the flat pattern and the DXF keep one clean loop).

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

* Sheet metal: cover the corner reliefs at a corner that is not square

The engine and the UI had no test at all for a base whose corners are 60° and
120°: the flat pattern, the DXF, the unfold and the rule manager's own square
relief were all untested there. They work; this says so.

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

* Sheet metal: say what happens when a corner relief is left out

The guide now says that the reliefs at the corners of one edge are cut
together, and that two of them that would overlap leave the second one out.
A relief that cannot be cut is reported, not dropped.

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

* Sheet metal: write the corner relief's expected corners out

The engine test hard-coded the corners of the trimmed base; it works them out
instead, and the numbers the test and the sketch share are written with the
precision they need.

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

---------

Co-authored-by: jeremymiribung-blip <jeremymiribung-blip@users.noreply.github.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-10-20 01:28:34 +00:00
bherbruck adaac4d1b6 perf: STEP import reads and orients distinct parts in parallel (#2814) (#3046)
Converting an assembly went one occurrence at a time: a part's solid was read
from the file and oriented on the calling thread, and only runs of four or
more solids in one representation used the workers. Each step now takes the
next solids across occurrences and parts (up to 64, stopping where a part not
yet oriented comes back), and the workers read and orient the new parts side
by side, each taking the next one when it is done. Later instances still only
place the part their first instance oriented.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 00:26:36 +00:00
bherbruck 0a328a3d0a perf: placed parts draw their definition's display plan, moved (#3044) (#3127)
World placement already shares a part's reference mesh between its
occurrences (#2924), but the display plan the GPU viewport draws was made
again for every occurrence from its placed B-rep: planning every face of
every instance of the part. A placement now carries the definition's plan
cells too, and a placed body's plan (and its CPU mesh) is the definition's,
moved by the placement's matrix (`TessPlan::transformed`): the part is
planned once however often it is placed.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 00:22:36 +00:00
bherbruck 74af4a3471 test: the agent chat's long-message layout check waits for agent detection (#3349)
`a_long_message_keeps_the_buttons_in_the_window` failed now and then on CI
(Send 914.5 -> 888.5, also on #2893): looking for agents runs on its own
thread, and what it finds (and the status it reports) can land between the
two measurements and change what is around the panel. The test now waits for
the search to finish and measures the buttons from the panel's bottom, which
is what the box growing upwards keeps fixed.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 00:21:38 +00:00
bherbruck 9e7a7b260b perf: Save and Save As write on a worker thread (#3044) (#3146)
Save ran on the window's thread: about 130 ms on the Prusa Core One design,
and more than a second once saves keep the evaluated model. The toolbar's
Save and Save As now run the command on a scratch copy of the session on a
worker; the design waits for it as it waits for a file picker (commands are
refused meanwhile), and the outcome is taken up as running the command here
would have left it: the name and links the save gave the design, its file,
its saved state (`Session::adopt_save`), the echo, the error status, the
recent files and the home thumbnail. A tab closing or the app quitting after
Save waits for the write, as does the app's exit. Scripts, the control
channel and the browser build still save in place.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 00:19:29 +00:00
NOWN-DEV e661e6f809 inspect.clearance: pins on one cylinder with their hole are answered or reported in seconds (#3027)
* inspect.clearance: pairs whose bodies did not change are not measured again

Each clearance check measured every close pair again, though between two
checks usually only a few bodies change or move. Results are now kept per pair,
keyed by both bodies' geometry and placement and the check's settings; a pair
whose two bodies are unchanged is taken from that cache. With 40 overlapping
parts, a check after moving one of them took 0.8 s instead of 23 s (741 of 780
pairs reused). Pairs that could not be worked out are tried again each time.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* inspect.clearance: boxes and placed keys exactly as before the reuse

The pair boxes are taken over the mesh's triangles with finite corners, as a
distance shape's are, so pruning is unchanged. A placed body's identity now
includes its model body's own frame, so two placements are never mistaken for
one another.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* inspect.clearance: a large body is meshed once for all its pairs

Each pair that touches cuts the larger body down to the pair's region with a
kernel boolean, and that boolean meshed the whole body twice: once for its
checks and once inside the intersection. A sheet with 600 faces paid about
2.5 s per pair for meshes that were the same every time.

A body can now keep its meshes for reuse (Body::keeping_meshes); its booleans
take the meshes from there, and the intersection takes its shell meshes
from there too (truck-shapeops: and_with_history_meshed). Clearance uses such
copies for one check. Results are unchanged to the bit. On a 3 x 2 m sheet
with bends and 300 holes, the clip took 0.23 s per pair instead of 2.6 s, and
24 rivets took 5.6 s instead of 16.5 s.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* inspect.clearance: pins on one cylinder with their hole are answered or reported in seconds

A pin seated line to line in its hole, on its axis, and with a blend running into its wall
(a fillet under a head) sent the boolean through minutes of retries that ended unchecked; a
pin a hair off its hole's axis was passed as clear. Clearance now asks first whether the pair
holds such a pin (from the faces' surfaces alone). On its axis the boolean gets a few tries,
which answer plain pins at once; a hair off, or when those tries fail, the shared volume is
worked out apart by coaxial_overlap, which now also takes the zero-offset, equal-radius case,
moves the hole's wall when a blend keeps the pin's from moving, and checks its band more
tightly. What neither answers is reported as unchecked, naming the pin.

The kernel gains with_attempt_budget: the booleans run inside it get a number of tries of
the kernel's own intersection between them, the same on every machine.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* inspect.clearance: the pin tests sit beside the other measured pairs, not at the file's end

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* inspect.clearance: the bodies left whole meet their booleans with their meshes kept

A body that meets several others at one size (a bracket with balls pressed into it) was
meshed again for each pair's intersect: only the clipping used the copies that keep their
meshes. The intersect, the pin work and the union check now take those copies too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Near-coaxial overlap: ten tries per intersection, enough for a flush head pulled back

#1984's headed pin made before its plate needs more than eight.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aac7mP9rthK3WsPpPRvCsF

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-20 00:09:50 +00:00
Colin Faulkingham b622a46624 Fix large modeled threads and thin crest reconstruction (#2209)
* Fix large modeled threads and thin crest reconstruction

Helix stations follow the pitch (radial chord error under 1% of it, 48 to
720 stations), and the faceted rebuild retries at the BSP's precision when
the usual weld tolerance collapses a thin crest facet.

Rebased onto main as one commit (the branch's own commits touched UI files
that later moved to #2263 and #2264). Adapted to main's UnitVec3 thread
axis: `*k` in thread_groove and `*cylinder.axis` in the new flange test.

* Fix icon review revisions for same-timestamp edits
2026-10-20 00:06:44 +00:00
fkirill 3ffd8efffc Loft: preserve holes through matching sketch sections (#2435) (#2447)
Rebase the complete PR change onto current main, preserving changes previously stored in merge commits.
2026-10-19 23:57:07 +00:00
Colin Faulkingham 26dd2548b7 Preview modeled threads using the desktop background worker (#2264)
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-19 23:57:03 +00:00
amolinari-autotex 07be3f8852 Paste saved linked designs at the viewport pointer (#2430) (#2433)
* claim: #2430

* feat: copy saved designs between documents as linked components

* fix: keep occurrence movement controls with their document tab

* test: cover linked design clipboard across tabs

* fix: place linked design pastes at the viewport pointer

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-19 23:56:48 +00:00
DuBearte 33d7a86d50 Extrude: a tapered side drawn in collinear pieces is one face too (#1788) (#2583)
#1949 joined the straight runs of a profile into one side face for straight
extrudes; tapered extrudes (extrude_tapered, extrude_tapered_between) still
sloped each piece as its own face, so a drafted mirrored profile kept the
edge. They now join the runs the same way (NumberedLoop::merged_lines), the
sloped side generated by every piece under it.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-19 23:56:46 +00:00
amolinari-autotex e984e7d738 Resolve picked-face feature actions and reveal the creator in Browser (#2301) (#2431)
* claim: #2301

* fix: resolve face context actions from persistent face provenance

* test: cover feature targeting from picked faces

* fix: reveal picked face creators in the Browser

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-19 23:56:42 +00:00
NOWN-DEV 62b49f9f89 Boolean: a pin in a bore of its size is clear at every placement (#2714) (#2715)
* Boolean: a pin in a bore of its own size is clear of it, whatever its facets

A pin whose wall is in other faces than the bore's (turned from a profile: three
faces; the bore two) is faceted at other angles. Where one body's faces end on
the shared cylinder (the pin's wall through the end face of the tube, the pin's
end inside the bore) the facets cross by the facetings' error, so the sampled
shortcut doubted its own "apart" answer, every retry failed on the coincident
walls, and the result came back unverified after seconds: inspect.clearance
left such pairs unchecked ("could not be intersected there").

The shortcut now leaves out crossings that lie on a cylinder both bodies have
faces on (axes and radii within the kernel's same-surface tolerance, where both
faces run). A pin a hair off its hole's axis, or one pushed into a narrower
bore, is still not taken for apart.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Boolean: skip only the shared cylinder's own crossings, not a flat along it

The shortcut left out every crossing near a cylinder both bodies have faces
on, from any face: a pin through a D-bore whose flat is 0.022 mm inside it
came back as contact. Mesh::crosses_except now hands the predicate the two
triangles' faces, and only crossings between the faces on the shared
cylinder, or between one of them and an end face across its axis that keeps
to its body's side of the cylinder (within the facetings' error), are left
out. The D-bore's end face reaches into the cylinder to the flat, so the
pin's wall through it counts again; a flat, key or rib along the axis always
counts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Boolean: a pin in a bore of its size is clear at every placement

The sampled shortcut's check that no facet has material under it on the
wrong side of the other body still caught the facetings of a shared
cylinder. A facet of a pin's end inside the bore (or of the bore part's
end face round the pin) with all three corners on the rim has its middle
just inside the cylinder. Where the other body's chords reach further in
at that angle, the point under it lies inside the other mesh, though the
bodies only touch. Which facets do so depends on the face splits and the
placement, so the same pair read as clear at one placement and as "not
verified" at another.

Such points are no evidence now when they lie on the shared cylinder,
under a facet of one of its end faces: the end faces and band the
crossing check already leaves out. A pin 0.02 mm larger is no shared
cylinder, and its overlap is still found.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-19 23:56:40 +00:00
DuBearte c8283f2196 Thread: a hole too narrow (or a shaft too thick) says so, and what to drill (#2688) (#2853)
An M8 modelled thread in a 6 mm hole, narrower than M8's minor diameter, was
refused as "the face is too far from the thread's diameter". It now says what
is wrong and what fits, as a hole that is too wide already does (#995): "this
6 mm hole is too narrow for a modelled M8 thread (its teeth would fill the
hole); drill 6.75 mm for M8", with the thread that fits this hole when there
is one. A shaft too thick for its thread gets "make the shaft 8 mm for M8".

Fixes #2688

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-19 23:56:36 +00:00
Loohi f97a84c100 inspect.clearance: measure the 0.2 mm gap on trimmed faces (#2283) (#2337)
* claim: #2283

* inspect.clearance: a point straight past an edge's end no longer passes as on the face (#2283)

A witness point on a face's surface is kept only if it lies on the face
itself; near the face's boundary the nearest edge decides which side it is
on. A point straight past the end of an edge (on the line through it) was
meant to be left to the other edge meeting there, but "straight" was an
absolute 1e-9 of the body size. With a slightly tilted axis (1.5e-6 rad, as
a STEP round trip leaves it) a point 0.2 mm past the end of a knuckle's seam
edge is 4.5e-6 mm off that line, so its side came down to rounding and the
point on the knuckle's extended cylinder, level with the frame band's rim,
was taken as contact (0.000) at some angles.

Straight along is now relative to the distance from the edge (1e-3 of it),
so the rim edge meeting there decides. Regression test: a hinge knuckle
turned 30-80 degrees between two bands reads 0.2 mm at every angle, with
both witness points on the bodies (41 degrees read 0.0 before).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 23:56:34 +00:00
NOWN-DEV af436493a2 Joints: a point origin keeps the x it is given (#2597) (#2598)
* Joints: a point origin keeps the x it is given

`joint.origin` and `joint.create` take `x` for a point origin (a
direction, laid square to z) and the point snap saves it. Two origins
given at one point with the same z and x now meet as they are: a part
turned a quarter turn about z no longer turns back (and moves) when
rigid-joined there. A point origin without x takes its x from z alone,
as before. An x along z, or of no length, is refused.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Joint relations test: a point origin has an x too

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aac7mP9rthK3WsPpPRvCsF

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-19 23:56:32 +00:00
N3MI-DG 57b52d8a49 Browser: light the rows of what is selected or hovered in the 3D view (#3009)
* Browser model: find the placement a body is in

Model::body_path maps a placed body (by its world name) to the occurrence
path of the placement holding it, as the browser's occurrence rows nest.
Every placement is walked once per model, from the placements the
browser already caches, so asking it every frame stays cheap. The next
commits use it to mark the rows leading to a body.

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

* Browser: rows can carry a mark for a lit item and its branch

A row's Mark draws a bar down its left edge: a solid one on a lit item's
own row (which also takes the hover colour unless it is selected), and a
thinner, fainter one on each row leading to it, so the bars trace the
item's branch through the tree. Nothing sets a mark yet.

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

* Browser: light the rows of what is selected or hovered in the 3D view

The tree now follows the 3D view. Whatever is selected (in the tree or
in the view) or under the pointer in the view lights its row: the
selection colour when selected, the hover colour when hovered, with a
solid bar; each row leading to it gets a faint bar, also when the
folders are closed.

- A body lights for itself and for a selected or hovered face, edge or
  vertex of it.
- A sketch lights for its curves, points and profiles, including those
  of the sketch being edited.
- Construction planes, and the origin planes and axes, light their rows;
  the Origin folder carries the faint bar.
- The rows leading to an item are the design, the placements above it
  (by the placement path of a body; for a sketch or plane, every
  placement of its component), its folder and its group.

A row's own selected flag, which drag and the context menu go by, is
unchanged: the selection colour for things picked in the view is drawn
separately.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 23:56:28 +00:00
kezmdor d55521aa3e Parameters window: the add row lines up with the table's columns (#2920) (#2932)
* claim: #2920

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

* Parameters window: the add row lines up with the table's columns (#2920)

The add row was a row of its own under the User Parameters table, so
its fields did not sit under the headings, least of all before the
first user parameter, when the headings had no rows to size their
columns. It is now the table's last row. Its text boxes take the rows'
widths, since a grid cell otherwise gives them only the column's width
so far.

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

---------

Co-authored-by: kezmdor <15539254+kezmdor@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 23:56:26 +00:00
NOWN-DEV 68c7208203 sketch.edit_text: batch several text edits into one rebuild (#2793)
* sketch.edit_text: several texts in one edit, one rebuild

Re-texting the labels of a design took one sketch.edit_text per label, and
each one rebuilt every feature after the sketch (seconds per label in a large
assembly). sketch.edit_text {edits: [{link, text, ...}, ...]} changes them
together in one command: one rebuild and one undo step, all or nothing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* sketch.edit_text: a live frame takes the edits list too

The live path (live: true) still read one text's link and refused a list.
Both paths now go through one helper.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-19 23:56:22 +00:00
Matt Ruhland 91753051a1 docs(skills): multi-body primitives, mirror body names, unit suffixes, set_parameter call (#2964)
Add body_name and targets to the solid.box and solid.cylinder rows, the
bodies form of solid.mirror and the names its bodies get, unit-suffixed
parameter values, and the set_parameter argument shape.
2026-10-19 23:56:19 +00:00
amolinari-autotex b2622325a1 i18n: fill missing Italian catalog entries (#2389)
Translate 21 messages while keeping Italian partial and preserving existing translations and catalog guards.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-10-19 23:56:17 +00:00
bherbruck b93d8fa0b1 CI: Windows app startup smoke back on windows-latest (#3355)
Blacksmith's Windows runners start the app but never present a frame (no interactive desktop),
so the startup smoke failed on main after #3343. The shader job stays on Blacksmith.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 21:06:05 -04:00
Irakli Kourtis 843726a8cb CI: merge-queue groups run on Blacksmith, test shards on 16 vCPU (#3343)
* CI: merge-queue groups run on Blacksmith, test shards on 16 vCPU

The merge queue builds up to three groups at once, but every group's
checks, Clippy, doc and reference tests and all four shards ran on the one
self-hosted machine, so the groups waited for each other. Merge groups now
run on Blacksmith like pull requests (test archive built once, shards on
16 vCPU). Pushes to main keep the self-hosted runners and their warm build
directories, and a push the queue already tested still runs only the
repository checks.

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

* CI: every job on Blacksmith: pushes to main and the Windows jobs too

Pushes to main ran on the one self-hosted machine and the Windows jobs on
GitHub's runners. Both move to Blacksmith (Windows Server 2025, 8 vCPU),
so nothing in ci.yml depends on a single box any more. With no
self-hosted job left, the local routing goes: no warm build directories,
every run builds the test archive once and the shards run it, and main
saves the build caches every other run restores.

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

---------

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 20:26:39 -04:00
HiroKenEz 65e6d9dafd Sketch: dragging part of a selection moves the whole selection (#2621) (#2720)
* claim: #2621

* Sketch: dragging part of a selection moves the whole selection (#2621)

A drag that started on a selected sketch line or point moved only that
entity; the rest of the selection was left to the solver, so geometry that
kept only its length (the reporter's 5 mm tail) trailed behind like a string
and chamfers stretched.

- sketch.drag takes `with`: the rest of a selection dragged by `entity`.
  Every free point of all of it slides by the cursor's move and the held
  solves keep them all (circles and ellipses keep their radii), so the
  selection moves as one (`plan_group`). `with` is checked and capped like
  the other parameters.
- The viewport sends the rest of the selection when the drag starts on a
  selected point or curve. A curve's own points, picked with it by a window,
  are not a group: a circle grabbed on its edge still takes its radius.
- Snapping looks at all the moving points.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 19:14:16 -04:00
Vodmordia 803ae6d5be Script replay keeps a feature name another feature has (#1990) (#1997)
A sketch and the feature built from it may share a name, and the sample and
oracle designs do. `timeline.add` refused such a recorded name, so a design's
own exported script failed to replay. `Document::insert_feature` now keeps the
name, as `add_feature` does; a blank name still gets a fresh one.

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-19 19:14:12 -04:00
weyne chen d90ec9586c Delete Face: a face that only loses whole loops keeps its own surface (#1927)
Deleting a pocket or a boss from an imported part failed with "face N cannot
close: cannot attach a plane to a wire that is not on one plane". The face
around the gap only loses an inner loop, but it was rebuilt as a new plane
through its outer boundary, and an imported boundary is often a few 1e-5 mm off
the plane: within the planar test, too far for a plane fitted to it. Such a face
now keeps its surface and only drops the loop.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-19 19:14:09 -04:00
HiroKenEz a108ffac38 Replace Face keeps its body's faces attachable (#1925)
Replace Face rebuilt its body with ModelBody::new and dropped the body's
lineage, so no face of the result was named exactly: a sketch on any face of
it, even one the replacement didn't touch, failed with "this face can't keep
an attachment". Each face it moves is now a lineage step, as in Offset Face
(which does the same geometry), and the result keeps the lineage.

The test of a body without history used Replace Face as its example of an
operation that reports none; it now lifts the block with Align instead, the
same geometry.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-19 19:14:05 -04:00
rlartot ee67ed53af STEP export no longer hangs on a box with a screw hole (#1985) (#2243)
* claim: #1985

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

* STEP export no longer hangs on a box with a screw hole (#1985)

The export reads its own STEP text back, and meshing the read-back screw
hole never finished. On a seam cylinder the boundary walk projected points
to angles up to 5e7 rad apart and moved each by two turns at most, so the
11-point loop spanned millions of turns and the polyline closing it across
the seam had 4.3 million points. Each projected parameter is now brought to
within half a period of the point before it, whatever the gap.

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

* Second #1985 regression: the reporter's flanged enclosure

The issue's reporter delta-debugged a second design whose STEP export hung
on main: a flanged enclosure with two standoffs and no screw hole. It hangs
without the get_mindiff change and reads back with 29 faces and the same
volume with it. Both tests share the read-back checks.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-19 19:14:01 -04:00
amolinari-autotex 504d7c9488 Fix occurrence Move/Copy viewport gizmo input (#3014) (#3015)
Thanks @amolinari-autotex.
2026-10-19 18:07:32 -04:00
SiroArt 206fffdd80 Agent chat tests: the two "cap" tests no longer share one folder (#2958)
Thanks @SiroArt.
2026-10-19 18:05:37 -04:00
fkirill 54b00fa7b1 Chamfer straight lips at tangent wall junctions (#2408) (#2490)
Thanks @fkirill.
2026-10-19 18:04:24 -04:00
phanoob a9f621837f Sketch Trim: the hover shows the piece that will go, not the whole curve (#3052) (#3113)
* claim: #3052

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

* Sketch Trim: the hover shows the piece a click takes away (#3052)

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

* Sketch Trim: the piece is shown in the error colour, thicker

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

* Trim hover: preview keeps its doc line; the piece is worked out once a frame

The new function had gone between preview's doc line and preview. The
cursor's Trim piece is now kept by design revision, sketch, curve and
cursor, so the viewport's hover and the preview no longer both intersect
the curve with every other curve each frame.

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

---------

Co-authored-by: Phanoob <phanoob@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 17:59:39 -04:00
bherbruck 68ac9976f3 README: CI powered by Blacksmith badge (#3299)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 17:57:48 -04:00
bherbruck 9c954b46ed Trademark notice: name every CAD vendor and product we mention, not only Autodesk (#3294)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 17:54:10 -04:00
bherbruck 66951921a1 README: Blacksmith sponsor banner (#3287)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 17:45:57 -04:00
SS-EN 98c30d28f9 Joints: a replaced motion link keeps the driven joint where it is; links count in dof (#2898)
Thanks @SS-EN.
2026-10-19 17:29:40 -04:00
bherbruck 4d99d79205 README: shorter front page; details move to docs (#3275)
* README: shorter front page; details move to docs

* Downloads page: drop stray lines
2026-10-19 17:23:29 -04:00
shop-3d 44cbd7ae73 I18n danish (#2671)
I18n danish.
2026-10-19 17:23:00 -04:00
NOWN-DEV 53a9260aeb Interference: near-coaxial pins after a STEP round trip, with flush heads, in either order (#1984)
Interference: near-coaxial pins after a STEP round trip, with flush heads, in either order.
2026-10-19 17:22:56 -04:00
kezmdor e5291dc8f7 Fonts: egui's built-in Hack as the last fallback for symbols (#2956)
Fonts: egui's built-in Hack as the last fallback for symbols.
2026-10-19 17:22:50 -04:00
plexi32 a1badaed17 Convert Mesh uses the selected mesh body (#2482)
Convert Mesh uses the selected mesh body.
2026-10-19 17:22:44 -04:00
NOWN-DEV 3d105fb281 parameters: import evaluates once; parameters.change takes several names (#2777)
* parameters: import evaluates once; parameters.change takes several names

parameters.import checked every row through change_param, which cloned the
design and evaluated every parameter per row: 2000 rows took 9.5 s, 2823 in
an assembly minutes. Rows are now checked and applied in one pass with the
values carried along, then evaluated once (2000 rows: 0.016 s), still all or
nothing; a row may name a parameter defined by a later row.

parameters.change {changes: {name: expression, ...}} sets several existing
parameters together, rebuilt once and undone in one step.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* parameters.change: the params doc names the `changed` result of `changes`

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-19 17:22:37 -04:00
amolinari-autotex 0b787f8eae Show and edit standard part configurations (#2680) (#2681)
Thanks @amolinari-autotex.
2026-10-19 17:07:01 -04:00
SS-EN a6b44ab0d3 Profiles and cap attachments fall back to the best match when a sketch outline changes (#2897)
Thanks @SS-EN.
2026-10-19 17:00:55 -04:00
Logan Night b0c4b6e939 Chamfer: a manipulator for Distance 2 with Two Distances (#2711) (#2856)
Thanks @flyboy-byte.
2026-10-19 16:47:08 -04:00
jyviko edc01b19bf README: Blacksmith sponsors the CI minutes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QWhMwdqJYQKQq8RCLSL1LT
2026-10-19 16:44:19 -04:00
jyviko 69db6ee6e4 CI: fork pull requests run on Blacksmith too
The self-hosted-untrusted pool is being retired for CI, so fork PRs whose
number ends in 7, 8 or 9 no longer route there. Build tests, the test shards
and the reference tests reduce to our own runners for main and the merge
queue, Blacksmith for everything else; the build's 90-minute gVisor timeout
goes. Blacksmith VMs are ephemeral and fork runs get no secrets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QWhMwdqJYQKQq8RCLSL1LT
2026-10-19 16:44:19 -04:00
jyviko 247cf357b6 CI: steps tell where they run from the routing, not the runner's name
Blacksmith runners report runner.environment == 'self-hosted', and a list of
our machines' name prefixes breaks when a box is added or renamed. Select
checks already decides where each job runs; its local and untrusted outputs
(and the trusted-run condition in the repository checks) now drive the warm
build directory, cache and lavapipe steps. Same results as before on every
event.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QWhMwdqJYQKQq8RCLSL1LT
2026-10-19 16:44:19 -04:00
jyviko f945e79278 CI: run the GitHub-hosted Linux jobs on Blacksmith again, sized per job
Re-applies #2237 (reverted in #2277 while the Blacksmith account awaited verification) on today's
ci.yml: every ubuntu-24.04 job or fallback runs on blacksmith-<N>vcpu-ubuntu-2404, sized as #2237
did (quick-compile, new since, gets 8), and steps that asked runner.environment now tell our own
machines apart by runner name, since Blacksmith runners report 'self-hosted' too. The trusted
self-hosted routing and the fork routing are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QWhMwdqJYQKQq8RCLSL1LT
2026-10-19 16:44:19 -04:00
jyviko fd5795b5ad Contributing: catch up with main only when pushing anyway or on a conflict
From the review: the guidance said a pull request's run tests it merged with the latest main,
which is only main as of that run; a break between two pull requests that land close together
shows up in main's own run. It also forbade rebasing. Now: don't push only to catch up; when you
push a change anyway or GitHub reports a conflict, rebase onto main (--force-with-lease on your
own branch) or merge it in once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QWhMwdqJYQKQq8RCLSL1LT
2026-10-19 16:42:11 -04:00
jyviko f1b721b00c CI: Docker rebuilds for ATTRIBUTION.md; generated files follow licence inputs
From the review: the web app embeds ATTRIBUTION.md, so the Docker image's Markdown exclusion now
keeps it; and THIRD-PARTY-LICENSES.txt also reads the vendored crates' licence files and the
fonts' OFL texts, so changes there regenerate it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QWhMwdqJYQKQq8RCLSL1LT
2026-10-19 16:42:11 -04:00
jyviko c41845e6d7 CI: carry only from this pull request's runs, compare patches verbatim
From the review: git patch-id --stable ignores whitespace, so a conflict resolution that only
respaced the change (inside a string, or Python indentation) counted as no change and was
carried; --verbatim compares every byte. And a passing run is now taken only from this pull
request (by number, or for a fork, where GitHub lists none on the run, by head repository and
branch), not from another pull request with the same head commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QWhMwdqJYQKQq8RCLSL1LT
2026-10-19 16:42:11 -04:00
jyviko 2f92aaee6c CI: one tested rule for code changes; review fixes
From the review of this PR:
- What counts as code lives only in scripts/ci/affected.py now: nothing selected and not full
  means no code changed, on pull requests and pushes to main alike. The untested bash copy in
  Select checks disagreed with it. Root files and folders a crate embeds now select that crate
  (the licence texts and NOTICE: the engine; skills/: the MCP server's tests), where before a
  change to them ran no test at all. Other workflows and the bots' scripts select nothing.
- A push to main skips the native jobs only when nothing a build reads changed since the last
  push to main whose run passed. Comparing with the push just before was unsafe: a newer push
  cancels a waiting run, so that commit may never have been tested.
- Fail fast applies to pull requests only. On main, nightly and Run workflow the test build and
  the shards run regardless of the repository checks, so the action matrix and property tests
  that hang off Build tests keep their signal. Doc tests (main and nightly only now) no longer
  wait for the checks.
- tests/robustness/ turns on the goldens, so a change to goldens.json runs the seed tests.
- tests/oracle/ no longer selects the kernel, which never reads it.
- The workflow header and CONTRIBUTING describe what main and pull requests now run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QWhMwdqJYQKQq8RCLSL1LT
2026-10-19 16:42:11 -04:00
jyviko 6b4026fd19 CI: carry through consecutive merges of main
A change that merged main twice in a row ran the full set on every other push: the change before
the second merge was itself a carried run, its shards skipped, so it had no passing shards to
carry. The script now walks down the first parents through consecutive merges of main that kept
the change's patch (each equal to the next), at most 10, to the run where the shards ran.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QWhMwdqJYQKQq8RCLSL1LT
2026-10-19 16:42:11 -04:00
jyviko e9b850572d Contributing: don't bring main into an open PR, don't re-run unchanged commits
The guidance told contributors and agents to keep their branch current with every upstream merge
and to re-run CI after integrating. Over three days that meant 1,241 merges of main into pull
requests and 730 re-runs: about 45,000 of 79,800 ci runner-minutes, on runners that capped how
fast everyone's checks ran. CI already tests each pull request merged with the latest main, and
a real failure fails again on the same commit. Now: merge main in only when GitHub reports a
conflict, re-run only a job runner trouble cancelled, and maintainers add approved-for-ci when a
pull request is ready to merge and merge approved ones in groups by area.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QWhMwdqJYQKQq8RCLSL1LT
2026-10-19 16:42:11 -04:00
jyviko b045d9deae CI: a push that only merges main into a passing change carries its result
Merging main into a pull request (Merge branch 'main', or GitHub's Update branch) restarted the
whole run. Over three days 1,241 such merges started 966 runs and about 21,000 runner-minutes, and
872 of the merges brought in nothing that touched the pull request's files. Select checks now
recognises the case (scripts/ci/main_merge.py): HEAD is a merge whose other parent is on main, the
change's own patch is unchanged (git patch-id), and a ci.yml run of the change before the merge
passed with its test shards. Then only the repository checks run, the ci job names the passing run
in its summary, and main tests the merged result after it lands. Anything else, or any doubt,
runs the full set as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QWhMwdqJYQKQq8RCLSL1LT
2026-10-19 16:42:11 -04:00
jyviko 7555b34afb CI: doc tests in the pull request fast lane; heavy jobs wait for the repository checks
Doc tests move to main and nightly, beside the reference tests and the Windows jobs: on pull
requests they were the only failing job 3 times in about 7,900 runner-minutes over three days.
Build tests, the test shards and the doc tests now start only after the format and repository
checks pass; 212 attempts in those three days ran their heavy jobs beside a failed cheap check.
Also corrects the header, which still said pull requests run the Windows jobs and the reference
tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QWhMwdqJYQKQq8RCLSL1LT
2026-10-19 16:42:11 -04:00
jyviko 948c208db9 CI: run only what a change needs, on pull requests and on main
- Pushes to main use the same code/no-code rule as pull requests (`touches_code` in Select
  checks): a push changing only documentation, packaging, other workflows or the bots' scripts
  runs the repository checks only. With the merge queue off, every push to main ran the full suite
  and the Windows jobs, docs-only ones included.
- affected.py: test data under tests/ selects the packages that read it (robustness, hole matrix,
  projection, taper: solidcraft-engine; oracle: the CLI, the kernel, xtask; action matrix: the
  CLI, the engine, xtask), and a vendored crate the packages that use it (truck: the kernel and up;
  egui-wgpu: the UI and the apps), instead of the whole workspace. tests/tools and unknown vendored
  crates still select everything.
- Docker (the web app image) skips pushes touching only docs, test data or bot scripts; Pages skips
  test data and bot scripts; Generated files runs only when the manifests, lockfile, command
  registry, CLI or xtask change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QWhMwdqJYQKQq8RCLSL1LT
2026-10-19 16:42:11 -04:00
fkirill 983fbe2b14 Fix lip and groove on round objects (#2751) (#2790)
Thanks @fkirill.
2026-10-19 16:34:50 -04:00
github-actions[bot] 80dcf9de7f Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci] 2026-10-19 20:16:40 +00:00
NOWN-DEV 02c77df5f7 inspect.clearance: mesh each body once per check instead of once per pair (#2867)
* inspect.clearance: pairs whose bodies did not change are not measured again

Each clearance check measured every close pair again, though between two
checks usually only a few bodies change or move. Results are now kept per pair,
keyed by both bodies' geometry and placement and the check's settings; a pair
whose two bodies are unchanged is taken from that cache. With 40 overlapping
parts, a check after moving one of them took 0.8 s instead of 23 s (741 of 780
pairs reused). Pairs that could not be worked out are tried again each time.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* inspect.clearance: boxes and placed keys exactly as before the reuse

The pair boxes are taken over the mesh's triangles with finite corners, as a
distance shape's are, so pruning is unchanged. A placed body's identity now
includes its model body's own frame, so two placements are never mistaken for
one another.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* inspect.clearance: a large body is meshed once for all its pairs

Each pair that touches cuts the larger body down to the pair's region with a
kernel boolean, and that boolean meshed the whole body twice: once for its
checks and once inside the intersection. A sheet with 600 faces paid about
2.5 s per pair for meshes that were the same every time.

A body can now keep its meshes for reuse (Body::keeping_meshes); its booleans
take the meshes from there, and the intersection takes its shell meshes
from there too (truck-shapeops: and_with_history_meshed). Clearance uses such
copies for one check. Results are unchanged to the bit. On a 3 x 2 m sheet
with bends and 300 holes, the clip took 0.23 s per pair instead of 2.6 s, and
24 rivets took 5.6 s instead of 16.5 s.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-19 16:14:24 -04:00
takuto-NA 77c3b1e765 Sketch: preview slot outlines before committing (#2535) (#2537)
* claim: #2535

* claim: #2535

* Sketch: preview all slot sides and caps while choosing width

Fix missing temporary slot geometry for all five tools and paint every preview curve with a shared sampling budget. Preserve the arc slot construction guide. Add geometry, painting, invalid-input and interactive typed-width regression coverage.

* Sketch: honor typed slot widths below the pointer minimum

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-19 16:13:51 -04:00
Erikas 28d67fb577 Regression tests: a cut short of a face keeps its skin, and the issue's pleats mirror and build at the far end (#1669) (#1730)
* Extrude: a profile edge short of a face is not grown through the skin beyond it (#1669)

A cut or join profile edge that lies on a face of the target is grown off it by 0.5 % of the body, so that the tool's wall does not lie in the face. Whether it lies on a face was judged half a growth step either side of it: empty just outside, material just inside. An edge 0.25 mm short of a 3.75 mm wall's far face reads the same, and the growth cut through the skin it should leave (55 050 mm³ instead of 56 130 on the test's wall). Now the space just past the edge, 1e-4 of the body out, must already be as the operation wants too.

In #1669, the pleat's window built directly at the far end of a case opened through the wall at 3.75 and 4 mm, while its mirror, built without the growth, rightly left the skin and then failed to join the loft through it. Built either way, the window now leaves the skin.

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

* Regression tests: the issue's pleats mirror and build at the far end (#1669)

The issue's battery case at 3.5 mm: the mirror of the window and the pleat builds (it needed #1670's meshing of twisted faces), and at 3 mm the same window and pleat built directly at the far end of the finished case join it (they needed #1667's end rounds).

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

* Far-end pleat test: the direct build matches the mirror's volume

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

---------

Co-authored-by: Erikas <erkexzcx@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-19 16:13:26 -04:00
Mariusz Pacek 1999e763ab Tapered extrude: arcs meeting at corners meet where their moved circles cross (#2810) (#2813)
A loop made only of arcs took the all-arcs shortcut in `offset_loop`, which
moves each arc by its radius and keeps its start and sweep angles: exact for
arcs on one circle or meeting tangentially, but where arcs of different
circles meet at a corner (a circle with lobes) the moved arcs no longer
met, and the sides were built through the wrong points without an error.

`arc_corners` moves each such corner to where the two moved circles cross,
nearest the old corner (`circles_cross`, closed form), and cuts both arcs
there. Circles that no longer cross are refused ("not supported yet"), an
arc the taper turns around closes the profile. Between the sections the
corner is a straight ruling, as for every side in `taper_loop`: on the test
profile (2.5 mm at 45°) it stays within 0.015 mm of the exact corner.

Test `tapered_extrude_of_arcs_meeting_at_corners`: the volume against the
integral of the union of the shrunk circles, 18905.42 mm³ against 18907.67
(was 18975.45).

Co-authored-by: Mariusz Pacek <pacekm90@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-19 16:12:52 -04:00
rlartot 750e08aab7 Sketch palette: a new empty sketch is not "Fully constrained" (#2906) (#3011)
* claim: #2906

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

* Sketch palette: an empty sketch says "No geometry yet", not "Fully constrained" (#2906)

A new sketch has no degree of freedom (only the fixed origin), so the
palette read it as fully constrained. With no drawn curve or 3D curve it
now shows "No geometry yet" and not the fully-constrained styling.
Adds the message to the English, Japanese and Chinese catalogs.

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

* i18n fr: Aucune géométrie pour l'instant (#2906), à valider avant push

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 16:12:10 -04:00
rlartot 7ce9c29f95 French catalog: catch up on 46 new messages (#3136)
Adds the 46 missing French messages.
2026-10-19 16:11:44 -04:00
rlartot 31b41978fc Construction geometry on model vertices follows them when a parameter moves them (#568) (#2409)
Thanks @rlartot.
2026-10-19 16:11:38 -04:00
rlartot f42c068063 Hole: size the tool by the body it drills, not the largest body (#2904) (#2975)
* claim: #2904

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

* Hole: size the tool by the bodies its axis meets (#2904)

hole_tools_and_prisms sized the tool (reach above the face, depth of a
through hole) by the largest body of the whole model, so a large body far
away changed a countersink's outline and the volume it removed. Use the
largest body the axis meets, falling back to the model as before.

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 16:11:35 -04:00
Brazz 26f42ae09b Snapshot: draw a see-through appearance see-through, and show a tool body inside its target (#2329) (#2330)
* Snapshot: draw a see-through appearance see-through, and give the CLI --body, --tool and --section

A headless picture dropped a body's opacity: the scene carried only a colour, so a design with
a translucent appearance was drawn opaque by `solidcraft-cli snapshot`, by MCP screenshots and
in a design's thumbnail, although the window has always drawn it as glass. There was then no
way to look at the body a cut would remove inside the material it meets: only a volume to
measure and reason about, which hides exactly the errors a picture makes obvious (a tool that
stops short, or lands in the wrong place).

- `SceneMesh` carries the body's opacity; `view::build_scene` takes it from the design
  (`model_body_opacity`), and a selected body stays solid so the selection colour reads.
- The GPU capture routes a see-through body into the scene's glass at its own alpha, as the
  viewport does; the CPU rasterizer draws the solid bodies first (they own the depth buffer),
  then the see-through ones blended over them, far sides before near sides and without writing
  depth, so what lies inside shows and a line between the two is not hidden.
- `snapshot --body NAME:opacity=0.3,color=#rrggbb`, `--tool NAME` (that body solid in the tool
  colour, the rest see-through) and `--section z=27` run the commands the app itself runs
  (`appearance.assign`, `inspect.section`): the options are shortcuts, not a second renderer.

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

* Tests: measure the tool colour as it is drawn through a see-through target

The tool-colour filter was derived on paper and demanded a red more than twice either other
channel, which the target in front of it tints away (measured: (200, 108, 85) through opacity
0.3); and the colour check painted the tool, which a solid target hides.

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

* fmt

* Snapshot options: strip the colour's # rather than slicing it; README

Slicing `value[1..]` on an input-derived string is what the house rules ask us not to write,
even where a length check has already made it safe. The error a failed assignment reports now
names the body rather than claiming it came from --body, since --tool reaches it too.

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

* --tool: assign the bodies around the tool in groups, not one command at a time

Each appearance.assign is an undo step with a document snapshot behind it, so one per body
made --tool cost grow with the size of the design for no reason: the bodies around the tool
all get the same opacity, and the ones that share a colour can be named in one call. A design
whose bodies all wear the default grey now costs two commands whatever its size.

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

* Rasterizer: say where its glass and the GPU's differ

Splitting each see-through body by facing orders its own two layers; it does not sort across
bodies, which the GPU does. Worth stating rather than leaving a reader to find it.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-19 16:10:45 -04:00
NOWN-DEV f68a3dd5d5 Joints: an as-built joint at an origin on one of its parts follows it (#2600)
`joint.as_built` with `at` an origin on A or B keeps that origin on its
side (found again by its face's name on each solve) and holds the frame
where it is now on the other, so the joint follows the face or hole as
the design changes. Without `at`, or with an origin on another part,
both sides hold their frames as before.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-19 16:10:10 -04:00
vandang890615 72601e678a i18n: add Vietnamese (vi) localization support (#2915)
* i18n: add Vietnamese (vi) localization support

Add Vietnamese (vi) message catalog with 1,583 translated keys covering CAD modeling commands, UI dialogs, timeline, analysis tools, and AI agent chat.

Register VIETNAMESE catalog in ui-egui/src/i18n.rs and add unit test verifying translations, labels, and dynamic browser naming.

* chore: attribute Vietnamese locale and sort catalog

* i18n: fix corrupted and half-English strings in Vietnamese catalog

* i18n: polish residual command and UI translations in Vietnamese catalog
2026-10-19 16:09:36 -04:00
fkirill 42e991069f Fix shelling revolved barrels (#2421) (#2486)
* claim: #2421

* claim: #2421

* Fix shelling circular-meridian barrels (#2421)
2026-10-19 16:09:11 -04:00
rlartot c1f9224579 Fillet fit: an outside round concentric with a wall's inside round is no longer refused (#2852)
* Fillet fit: a round counts only what lies between its edge and its arc

The fit check refused a round when any other face of the body reached into
the wedge between its edge and its two setback lines. A round changes only
the part of that wedge between the edge and its arc: a face on the ball's
side of the arc is left as it is. An outside round concentric with the
inside round of a 3 mm wall (R18 over R15) was refused as too large
(largest 14.21 mm) although it keeps the wall 3 mm thick.

The face check now tests a round's fan of prisms from its edge to its arc
(Frame::pieces, already used for blends that cross), after the wedge as a
quick filter. Chamfers keep the wedge, which is what they change.

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

* Fillet fit: a curved face clears a round's thin end pieces only by a chord (#324)

The fan test shrank each piece by the curved faces' tolerance; the thin pieces at the
arc's ends vanished, so a hole wall 0.3 mm into a round beside it was missed and the
round failed to build instead of offering the radius that fits (issue_324_tests).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-19 16:08:37 -04:00
NOWN-DEV b0b59480eb Joints: relations that tie part of a placement (#2497)
Joints can tie part of a placement: parallel, angle, point on plane and more (#2496).
2026-10-19 16:08:23 -04:00
Colin Faulkingham e1268c6ad9 Fix fillets around tiny rounded-rectangle end edges (#2506)
Fillets around tiny rounded-rectangle end edges now build (#2498).
2026-10-19 16:08:06 -04:00
NOWN-DEV 473f1e6516 Sheet metal: readable face names from the sheet itself (#2275)
Sheet metal faces get readable names (#2272).
2026-10-19 16:08:01 -04:00
NOWN-DEV 539c17b425 Joint sweep: drive a joint until parts touch, and report where (#1981)
Joint sweeps can stop where parts touch and report the contact and remaining room.
2026-10-19 16:07:44 -04:00
The Nomad d3fa22e987 3D mouse calibration in Preferences (#2326)
Adds a 3D mouse calibration section to Preferences (#1463).
2026-10-19 16:07:38 -04:00
DuBearte 45ae7c00e0 Convert Mesh asks for the mesh body and its tolerance (#3001) (#3012)
* claim: #3001

* fix(ui): Convert Mesh asks for the mesh body and its tolerance (#3001)

It had no dialog, so the toolbar ran mesh.convert with no body and it always
reported "select a mesh body". A dialog now takes the mesh body (a selected one
fills it) and the tolerance.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 16:06:16 -04:00
DuBearte 24d64799d8 Update window: release-note links open in the browser (#2943) (#3004)
* claim: #2943

* fix(update): release-note links open in the browser (#2943)

The notes kept a link's text and dropped its target, so the blue text could not
be clicked. Links keep their web address, and each paragraph or item is
followed by its links as buttons that open them, as the agent chat shows links.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 16:06:06 -04:00
DuBearte a9c7616e7f Lines thinner than a pixel no longer vanish without multisampling (grid lines, #2823) (#2871)
* claim: #2823

* fix(view): lines thinner than a pixel no longer vanish without multisampling (#2823)

A line quad narrower than a pixel covers no pixel centre when it falls between
two rows, so the grid's 0.7 px lines came and went as the view zoomed where the
viewport draws without 4x MSAA (some browsers and devices). Such lines are now
drawn a pixel wide, faded by the width they lack.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 16:05:56 -04:00
CamWight d306d6e658 Two-sided extrudes: through all (or To Object) on either side, a distance on the other (#2700) (#2728)
* claim: #2700

* Two-sided extrudes: the second distance goes the other way from the first side, either way round (#2700)

extent_sides ignored `direction` when an extrude had `distance2`, so through all with
`direction: negative` and a second distance went the positive way. The second distance now goes
opposite the first side wherever that went (direction: negative, a negative distance, a To
Object below the sketch). A pending format step makes older designs' reversed two-sided
extrudes positive, which keeps their shape.

The Extrude dialog's Two Sides shows Distance 2 for every extent type (it was hidden for All and
To Object but still sent as 10 mm) and offers Reverse; the second arrow points the other way
from the first.

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

* The two-sided arrows test checks each tip against the sketch, not just their order

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 16:05:45 -04:00
phanoob b20e693c2e Sketch text: a letter crossed by an edge is picked whole with its text (#2619)
Co-authored-by: Phanoob <phanoob@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-19 16:05:28 -04:00
DuBearte b3f205a638 Agent chat: the design bridge gets its own MCP name, so a user's solidcraft entry can't shadow it (#2592) (#2596)
* claim: #2592

* fix(agent-chat): the design bridge is named solidcraft-app (#2592)

A user's own MCP entry named `solidcraft` (as docs/mcp.md suggests) took the
bridge's place in agents that keep their own entry on a name clash, so the chat
worked on a headless session instead of the open design.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-19 16:05:17 -04:00
cronos bd4eb42c2d Pin analytic robustness and edge-contact scale regressions (#1950)
* test: pin analytic robustness and edge-contact scale regressions

* test: freeze patterned-bore reference after body grouping change
2026-10-19 16:05:00 -04:00
HiroKenEz 45d0b6053b Icon tools: hashes and palette revisions ignore CRLF checkouts, text I/O is UTF-8 (#1930) (#1931)
On Windows, Git's default core.autocrlf checks the icon masters and the
palette out with CRLF, and `cargo xtask icons` failed four ways:
- the accepted-icon hashes (lint_icons.py and the review page's
  review_state.master_hash) were taken over the files' bytes, so every
  accepted icon read as changed; both now use master_hash, which hashes
  the text with CRLF read as LF;
- icon_palette.revision hashed the bytes while save_colours hashed the
  text it read, so every palette save was refused as stale; revision now
  reads the text too;
- the tools and their tests read and wrote text in the locale's encoding
  (cp1252 can't write U+2194); they now say utf-8;
- the icon inventory listed UI files with backslashes; it writes /.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-19 16:04:49 -04:00
NOWN-DEV 99d8d382b9 Bill of materials: part properties on components and assembly.bom (#1888) (#1899)
* Bill of materials: part properties on components and assembly.bom

A component definition now carries part properties (part number,
description, revision, other tools' keys) and free text properties,
saved with the component and shared by its occurrences.
component.metadata and component.metadata.set read and change them in
one undo step; Paste New copies them; parts.insert numbers standard
parts from their standard and size, and parts.resize renumbers them.

assembly.bom counts the placed components (flat: parts at any depth
through their subassemblies; top: direct children), grouped by part
number or by definition, sorted by part number, and writes CSV (fixed,
quoted columns) or JSON. The app gets a Bill of Materials window on the
INSPECT panel that lists, edits and exports the rows.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Bill of materials: name a merged row's equal definitions once

Two standard parts with the same size are separate definitions with the
same name; their row now reads "SHCS M6x20" instead of repeating it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Window widths test: close the Bill of Materials window between commands

The test closes the floating windows a command opens before starting
the next one; the Bill of Materials window was left open and measured
against the next dialogs' limits.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Bill of materials: sub-parts in their part's row, and part properties without re-evaluating

assembly.bom takes group_children: true. A part with a part number keeps
what is placed inside it (a bolt's washer and nut) in its own row, listed
under it as children (items 1.1, 1.2, with each and count) instead of rows
of their own; bolts with different contents stay different rows, and
total_occurrences is the same either way. The window has a switch for it
and shows the children under their part; the CSV lists them after their row.

Changing part properties re-evaluated the model, re-seated every joint and
rebuilt the placed model: 361 ms per change with 30 jointed bolts. A change
that only touches components' metadata and properties now takes the same
path as a render-only change (proved on the whole Document), also for its
undo and redo: 0.6 ms.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Bill of materials: look the structure up once, not per subassembly

Deciding whether a subassembly is a part (it has bodies of its own) scanned
every body of the model for each subassembly, and each body looked up its
feature by a linear search of the timeline: subassemblies × bodies × features.
assembly.bom now indexes the placements, the body owners
(Document::body_owners: one pass over the bodies with the timeline in a map,
the same rule as model_body_component) and the numbered components once per
call. 600 bodies and features with 60 subassemblies: 8.1 ms → 1.3 ms; the
old cost grows with the cube of the design's size.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-19 16:04:39 -04:00
William Dunn d8d04c9009 Tool modifiers: shared resolve, classic defaults, preview-safe Ctrl (#1616)
* Centralise tool modifiers with classic defaults and preview-safe Ctrl.

Route sketch/selection chords through a shared resolver. Keep Alt snap
suppress and dual toggle as defaults; optional Shift add-only and Ctrl
snap mapping. Skip preview rollback while a manipulator drag is active.

* Address #1616 review: classic snap suppress, single viewport owner, docs.

Default suppress to Ctrl/Cmd or Alt; drop navigation set_suppressed; remove unused label_key; add preview rollback gate test; document sketch modifiers.

* fix(#1616): correct Modifiers primary in tests; simplify rollback fixture.

* Treat Ctrl as primary off macOS, and rustfmt the modifier files.
2026-10-19 16:04:28 -04:00
Victor Santos Oliveira 6f93e7a851 fix(examples): cut both mounting holes in the bracket (#1515)
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-19 16:04:23 -04:00
Irakli Kourtis 1cd9983c8f Surface trim: projected curves, several kept regions, mutual trim (#767) (#1466)
* kernel: trim keeps picked regions, mutual trim, curves swept along a direction (#767)

Trim moves to surface_trim.rs: TrimKeep::Side (the old behaviour) or
Regions (the connected pieces the points pick, parted where the tool runs
along an edge). mutual_trim cuts two one-face surfaces by each other's
surface, extending one that stops short when asked. SplitTool::Curves takes
a sweep direction; a closed curve's field is signed by inside/outside
instead of extending its end segments.

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

* kernel: classify display edges of a face with a closed-edge hole without a panic

A hole cut by a closed curve is a loop of one edge that starts and ends at
one vertex; mapping the face rebuilt that edge through a check that
panics on it (the guard caught it and dropped every smooth flag of the
body). Such faces are now left out of the ruled-surface test.

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

* surface.trim: keep several regions, trim by projected sketch curves, mutual trim (#767)

keep takes a list of points (the regions they pick); curves: {sketch,
curves?, direction?} sweeps sketch curves onto the surface (along the
surface's normal: not supported yet); with + keep_with trims two
surfaces by each other, extend? meets a short one, stitch? sews the
kept pieces. Trimmed faces carry their lineage, so they are named
after the faces they are pieces of. New fields noted in a pending format file.

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

* Trim dialog: sketch curves as the tool, several keep points, mutual trim (#767)

The tool input takes sketch curves too; keep points are places (several
on one face add up); another surface with its keep points makes a mutual
trim with Extend to meet and Stitch toggles. Edit Feature brings it all
back. Labels in every locale catalog.

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

* ROADMAP: M8 trim by projected curves, several regions and mutual trim (#767)

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

---------

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-19 16:04:02 -04:00
bherbruck f1f5298bc5 Agent instructions: one source in AGENTS.md; CLAUDE.md imports it (#3240)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 16:02:11 -04:00
bherbruck 15db51304e Agent instructions: a regression test goes into its area's test file, not a new per-bug file (#3230)
Refs #3226.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 15:53:34 -04:00
bherbruck b00d45cc7b Open: draw the Home picture after the design opens (#3053) (#3140)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 15:44:06 -04:00
bherbruck f3505f0db6 World placement on all cores (#3053) (#3123)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 15:43:22 -04:00
NOWN-DEV e4d9015f68 sketch: points placed by expressions (#2864)
* Sketch points placed by expressions over the design's parameters

A point typed as an expression ("W", "H / 2") in sketch.point, sketch.line and
the other point arguments is fixed where its expressions come to, and moves
there again before each solve when the parameters change: an outline that
follows its parameters needs no dimensions and no solve per change. The
expressions are saved with the point (one pending design-format change), listed
with the sketch's other expressions, and renamed with their parameters; one
that cannot be worked out is refused.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Sketch point expressions: accurate params doc, out-of-range regression test

The sketch.line params said every point argument takes expressions; only those that
also take a point id do (the rectangle corners, an arc's through point and the like
are plain [x, y]). PointExprs' comment called its expressions mm. A test covers a
parameter that puts a placed point out of range and a design file whose point
expression overflows: both are errors, not crashes, and the sketch recovers.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-19 13:05:28 -04:00
fkirill 3b4db52c10 Respect display units in sketch fillet fields (#2420) (#2487)
Thanks @fkirill.
2026-10-19 10:38:48 -04:00
SS-EN fb82ccb273 Timeline/MCP API: suppress lists, dotted edit keys, set_parameter expression (#2899)
Timeline and MCP edits take suppress lists, dotted edit keys and parameter expressions.
2026-10-19 10:14:23 -04:00
SS-EN 3115c50359 Chamfer: two distances and distance + angle on curved edges (#2893)
Two-distance and distance + angle chamfers work on curved edges.
2026-10-19 10:14:18 -04:00
fkirill 5bed5196c7 Fix lens-profile cuts into revolved quarter spheres (#2791)
Lens-profile cuts into revolved quarter spheres now build (#2750).
2026-10-19 10:14:12 -04:00
fkirill b4e049f9a6 Preserve equal-radius fillet mitre tangency (#2485)
Equal-radius mitres keep their endpoint tangent so a later fillet can select the chain (#2422).
2026-10-19 10:14:07 -04:00
takuto-NA b76302e850 Assembly: reuse reference meshes during rigid joint motion (#2922) (#2924)
Thanks @takuto-NA.
2026-10-19 09:15:26 -04:00
SS-EN 771f62be71 Interference: clear apart pairs from their meshes, intersect the rest in parallel, time budget (#2895)
Thanks @SS-EN.
2026-10-19 09:14:52 -04:00
fugazister aee47fca00 Messages window: keep status messages to read and copy again (#2763) (#2764)
Status messages are kept for the session in a Messages window (Help menu, or click the status chip), newest first, with Copy all.
2026-10-19 09:09:35 -04:00
The Nomad 4c30d944f5 3D mouse: only the window with focus moves (#2077) (#2212)
With two windows open, the 3D mouse now moves only the one with focus.
2026-10-19 08:48:06 -04:00
bherbruck 5f399207bf CI: a translation change tests the UI and xtask, not the whole workspace (#3061)
assets/locales/ belongs to no crate, so affected.py selected the full
workspace (every crate, all four test shards) for a catalog-only PR. Only
solidcraft-ui-egui embeds the catalogs and xtask checks them; map the folder
to those two packages and their dependents.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 08:31:49 -04:00
SS-EN 7fd51ce6a5 timeline.roll_to: accept "end", refuse other non-number positions (#2902)
A position that is not a number or "end" is now refused instead of moving the marker to the end.
2026-10-19 08:18:01 -04:00
rlartot aef13b52f4 Pattern and Mirror copies of a primitive change only the seed's targets (#2755) (#2756)
Thanks @rlartot.
2026-10-19 07:44:40 -04:00
JIJINBEI 355154fa9b refactor: geom::Grid for control nets; one surface fit through a grid of samples (#2331)
Thanks @jijinbei.
2026-10-19 07:39:22 -04:00
github-actions[bot] f1a5f4ab30 Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci] 2026-10-19 11:38:55 +00:00
takuto-NA 3a9b053f5b Fix constrained point dragging within the shared frame budget (#1976) (#1977) 2026-10-19 07:24:04 -04:00
amolinari-autotex aaa35a94d8 Relink a linked assembly source from the Browser (#2772) (#2773)
Thanks @amolinari-autotex.
2026-10-19 07:14:33 -04:00
Colin Faulkingham 852d364d5f Preserve curved face picks when reopening modeled threads (#2265)
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-19 07:14:07 -04:00
Jacques 34cb3167b6 Sketch: trim keeps the rest of a dimensioned line where it was (#2910)
Thanks @Jacquesvdberg92.
2026-10-19 06:44:50 -04:00
abteenz 1666ca7018 Fix the macOS launch hang with the 3Dconnexion driver installed (#2841)
Thanks @abteenz.
2026-10-19 05:54:43 -04:00
NOWN-DEV 768e69c4c7 Joints: a new joint between faces that already meet leaves the part as it is (#2709) (#2710)
Thanks @NOWN-DEV.
2026-10-19 05:54:21 -04:00
bherbruck 5997d28de7 STEP import: orient each instanced part once (#2814) (#2875)
Thanks @bherbruck.
2026-10-19 04:07:39 -04:00
bherbruck 5122b98af3 Parameters window: no per-frame re-evaluation on big designs (#2723)
The window listed (and so evaluated) every parameter and re-checked the add row's
expression on every frame, and laid out every row. It now lists once per design
revision, caches the add row's check, and lays out only the rows in view.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 02:16:36 -04:00
bherbruck f9342aa8b6 Malay: drop the "Gap" entry that is not in the English catalog
The catalog test rejects ids English doesn't have.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 00:38:57 -04:00
bherbruck d9c3fdb80f Agent chat: drops on the popped-out window attach (#2812) (#2861)
The chat in a window of its own takes the files dropped on it and shows the drop hint while files are dragged over it; a window floating inside the app counts as the chat's place for the app's drop handling.
2026-10-19 00:03:11 -04:00
bherbruck dc27988bc2 Agent chat: PDFs as documents, any other file by reference (#2812) (#2858)
A PDF goes whole to agents that take embedded documents; to the others, or past 12 MB, the message names it with its type, size and place. Any file that is not a picture, text or CAD file is attached by reference instead of refused. Chips show the type's extension; the file dialog offers All files.
2026-10-19 00:02:29 -04:00
bherbruck e3831b05d4 Agent chat: Ctrl/Cmd+V attaches copied files and pictures (#2812) (#2854)
Files copied in the file manager attach as files, a picture on the clipboard attaches as a picture, text pastes as text. A picture-only or files-only paste is seen through the V key coming up (egui-winit sends nothing when the clipboard has no text); macOS sends no key-up while Cmd is held, so there only pastes that come with text are seen and + > Paste a picture still works.
2026-10-18 23:30:21 -04:00
bherbruck 59ba87103e CI: heavy checks run after review (approved-for-ci label) (#2689)
* CI: heavy checks run after review (approved-for-ci label)

Pull requests without the approved-for-ci label run Select checks, the
format and repository checks and a quick cargo check. The label runs the
full suite, and every push after that does too. Other labels start no work
and never cancel a run. Main, merge groups, nightly and Run workflow are
unchanged.

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

* CI: approved fork PRs run heavy jobs on self-hosted-fast in private directories

An approved-for-ci label on a fork pull request sends Clippy, Build tests,
the test shards and Doc tests to the trusted runners, with private copies
of the build directory, Cargo home and rustup home that are deleted after
the job. Only the labeling event qualifies; later pushes use the old fork
routing. The skipped run of an unrelated label names its ci job differently.

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

* Revert "CI: approved fork PRs run heavy jobs on self-hosted-fast in private directories"

This reverts commit 439f2f7dab.

* CI: name the ci job differently on an ignored label's skipped run

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 23:28:17 -04:00
bherbruck 19ffb0dd75 Orbit drags turn from where they began, so erratic drags don't drift (#2842)
An orbit drag now applies its whole turn so far to the camera it began
with, instead of adding each pointer step to the camera of the step
before. The view then depends only on where the pointer is: dragging
past straight up or down and back no longer loses the pitch that the
clamp swallowed, and returning to the starting point brings back the
starting view. The pivot is still picked once, when the drag begins,
and kept still on screen by the rigid turn about it.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:12:55 -04:00
NOWN-DEV c66e33284b Joints: offsets, angles and values typed as expressions follow their parameters (#2385) (#2386)
Thanks @NOWN-DEV.
2026-10-18 22:13:13 -04:00
NOWN-DEV 57d210130c Assembly: place an occurrence or an inserted STEP file by a matrix (#1890) (#1901)
Thanks @NOWN-DEV.
2026-10-18 22:03:20 -04:00
SiroArt 71534117af Mesh a face thinner than the tolerance instead of leaving a hole (#2712)
Thanks @SiroArt.
2026-10-18 21:56:28 -04:00
github-actions[bot] 38f6ed76c9 Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci] 2026-10-19 01:41:22 +00:00
A.Hakim Noor 66108a6b99 Add Malay language support and update localization doc (#2188)
Thanks @a-hakim.
2026-10-18 21:34:54 -04:00
CypherNaugh_0x 8e762fee57 Add optional ambient occlusion and graphics quality presets (#1135)
Thanks @CypherNaught-0x.
2026-10-18 21:29:03 -04:00
phanoob 6b1fc16d47 Sketch projection: a solid's edges come in as their exact curves (#1508)
Thanks @phanoob.
2026-10-18 21:23:11 -04:00
bherbruck 1ea85ff231 Merge pull request #2831 from tangency-labs/ci-fast-runners-main-only
CI: repository checks keep off the fast runners for pull requests
2026-10-18 21:18:10 -04:00
bherbruck 2aa808b116 Merge pull request #2303 from tangency-labs/sketch-grid
Sketch: always show a grid on the open sketch's plane
2026-10-18 21:16:52 -04:00
CamWight aa438611a7 S command search and the new design's name: shown and matched in the current language (#2544) (#2555)
* claim: #2544

* S command search and the command line: names, places and matching in the current language (#2544)

The S box showed each command's English label and toolbar place in every language, and found
only what was typed in English; the command line matched English only too. Rows, places and
tooltips now go through the label lookup, and the query matches the translated label, tab and
panel as well as the English ones and the id. The pin tooltips get catalog entries.

A new sweep (i18n_painted_tests) runs every language, reads the text each frame paints, and
fails on English painted where the language has a translation, skipping the design's own names
and key names, so a painter call that skips the lookup can't come back unnoticed.

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

* The painted-text sweep also covers every Preferences page and four right-click menus

Each surface must show (the Preferences title in that language, or new text for a menu), so the
sweep can't pass by painting nothing.

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

* S box rows: labels stay 'static; the place's separator is drawn, level in every language

A font's "›" (and one centred on the row) sits on the baseline beside Japanese or Chinese words,
where it reads as a comma. The separator is now a drawn chevron (glyphs::breadcrumb) centred on
the ink of the panel's name. search() returns the place as (tab, panel).

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

* A new design's name, Untitled, shows translated in the browser too, like the other default names

The tab and the save prompt translated "Untitled" through their own special case; the browser's
top row showed it in English. It is now one of the default names the label layer translates
(Locale::default_name, with Sketch1, Body2, Component3), so every place that shows a given name
shows it translated, and the two special cases go. The painted-text sweep no longer skips a design
name that the language translates, and splits a tab's dirty mark off, so it catches a place that
shows the raw name.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:16:48 -04:00
leValdemar19 86b2b97d6f Sketch Offset: constrain to the ends of an offset spline (#2424) (#2615)
* claim: #2424

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

* Sketch Offset: constrain to the ends of an offset spline (#2424)

An associated offset spline hid all of its generated fit nodes, its two
end points included, so nothing snapped to them, and a curve drawn from
one shared the node and was refused ("invalid offset output point roles").

- An open offset's end points are shown and snapped to again; its
  interior fit nodes stay private.
- After every sketch edit, a curve that took an offset node gets its own
  point there, coincident with the node: the offset keeps sole ownership
  and the curve follows the offset when its source moves.

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

* Scenario: lines snap to and close onto an offset spline's ends (#2424)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:16:43 -04:00
NOWN-DEV c177adfceb Clearance: check a shared volume against the union, allowing for the bodies' measuring error (#2090)
* Clearance: check a shared volume against the bodies and their union

An intersection can come out well formed and still wrong: one of a
mirrored pair of imported parts shared three times the volume of its
twin. The shared volume is now compared with vol(a) + vol(b) - vol(a ∪ b),
on the same (cut down) bodies; when they differ by more than 1 % (plus the
bodies' own measuring error), the pair is reported unchecked with both
numbers. Where the union gives no answer, the intersection stands.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Clearance: let the union cross-check allow for the bodies' measuring error

vol(a) + vol(b) - vol(a ∪ b) is a difference of measured volumes and
carries the error of all three, so correct small overlaps on large bodies
came back unchecked (0.96 mm³ where the union implied -0.75). measure
keeps a face's fine tessellation when it is within 1 % of one with a
5 times coarser chord, so a measured volume can be off by up to 0.25 %:
the allowance is now 1 % of the larger of the two answers plus 0.25 % of
the three volumes. Past it, a union that implies nothing (within that
noise of zero, or below it) while the shared part is at most a tenth of
the smaller body cannot tell, and the shared part stands. A shared part
of 412 mm³ where bodies of a few thousand imply 141 is still caught.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Clearance: tighten the union check's measuring allowance to 1e-4

The old note cited measure's 1 % gate, which compares face areas, not
volumes, and its 0.25 % was about 100 times the disagreement the bodies'
measures actually show. The allowance is now justified from volumes: a
curved face's chord shortfall cancels between a body and the union except
where the chord changes, which stays near 2e-5 of the three volumes; 1e-4
leaves five times that. A 40 mm roller pressed 0.2 mm into a 2 m plate
exercises it: with no allowance the pair is left unchecked (7.52 mm³
measured against 8.16 implied), with it the overlap is reported.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-18 21:16:37 -04:00
JIJINBEI 7ad5130cd2 refactor: geom::UnitVec3 for unit axes and normals (#2603)
* geom: UnitVec3; a face's analytic axes and normals are unit by type

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

* geom: UnitVec3 adds to and subtracts from a Vec3

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

* kernel, doc, engine: cylinder axes and measured normals held as UnitVec3

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

* kernel, engine: functions that take a unit axis or direction say so by type

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

* doc: a face's normal goes to Plane::from_normal as is

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

* kernel, doc: main's new cylinder helpers take the face's axis as a UnitVec3

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:16:31 -04:00
ealitt a3080d02d6 claim: #2561 (#2562) 2026-10-18 21:16:25 -04:00
DuBearte 17e173cb7b Construct tools ask for their picks instead of failing with "a is required" (#2474, #2494) (#2563)
* claim: #2474, #2494

* fix(ui): construct tools ask for their picks instead of failing (#2474, #2494)

The 13 construct tools without a dialog of their own ran their command with no
parameters from the toolbar and reported that `a` (or `face`, `edge`...) is
required. One pick dialog now serves them: labelled inputs per tool,
preselection fills them, and each pick becomes the construction reference the
command takes; Point Along Path keeps its position.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:16:20 -04:00
bherbruck 46bc44b706 Merge pull request #2821 from tangency-labs/ci-faster-shards
CI: faster test shards
2026-10-18 21:16:14 -04:00
bherbruck 9eb1540a34 Docs: describe SolidCraft as independent CAD (#2768)
Reword comments, docs and package descriptions so they describe SolidCraft's own behaviour instead of comparing it to another product. The trademark notice, the commercial-CAD rule and the navigation preset name stay as they were.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 21:16:11 -04:00
bherbruck 3a1e1eed39 Merge pull request #2267 from tangency-labs/ci/slow-runner-timeouts
Tests: more time for the web offline launches and the icon review server
2026-10-18 21:16:09 -04:00
fkirill 9ee2de9a0f Fix drilled hole cuts through tapered walls (#2387) (#2401)
Rebase the complete PR change onto current main, preserving changes previously stored in merge commits.
2026-10-18 21:16:06 -04:00
NOWN-DEV ed64988d8f Joints: a round origin keeps its end when the face it opens onto gets other neighbours (#2760)
A round origin's end is kept by the name of the face across the rim. On
a face in pieces (a sheet split by a groove or a bend) that name lists
the faces round the piece, and when those change (a hole nearby moves
or goes) the name matched neither rim, so the end fell back to the
nearer end of the stored pick: a pick near one end of a thin wall that
the design moved past mid-wall turned a part through 180 degrees. The
end whose rim matches the name better now wins: the face itself, a face
merged into it, then a piece of it, then the same face by its stem.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 21:16:01 -04:00
bherbruck 03e6c3d468 Version 0.16.2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 00:40:00 +00:00
bherbruck 5de48e916d Release 0.16.2 2026-10-19 00:39:30 +00:00
bherbruck 80af7bbe3e Release 0.16.2
Version 0.16.2: 0.16.1 with the rigid orbit fix (#2753). Same design format (27).

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

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit e88779218)
2026-10-19 00:38:52 +00:00
bherbruck cbf589eb8c Revert #2142: orbit pivot choice back to 0.15.0's, as in 0.16.1 (#2832)
This reverts commit cac4bccea8. Orbiting
about the point under the cursor goes back to 0.15.0's pivot choice
(the picked face or vertex point, an edge's midpoint, or the visible
geometry's centre over empty space), matching 0.16.1.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:36:37 -04:00
bherbruck cd85efd73d CI: the repository checks also keep off the fast runners for pull requests
the format and repository checks job still took a self-hosted-fast runner for every same-repo
pull request. It now follows the same rule, and the notes at the top say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 00:36:35 +00:00
bherbruck 2452513b65 CI: faster test shards
- Pull requests that can change a robustness golden check every seed but the 17 slowest, in the
  same nextest run as the other tests instead of a second step after it (16-30 minutes on its own).
  The slow seeds (about four fifths of the goldens' time) still run in the merge queue, on main and
  nightly. A failed golden still prints how to re-record it, and junit.xml now holds the whole run.
- The slowest tests start first (nextest priority), so a shard no longer ends with one of them
  running alone.
- tspline::tests::hostile_tsplines_never_panic joins the heavy tests pull requests skip (up to
  12 minutes on a GitHub runner; the same kind of check as hostile_params_never_panic).
- The ci profile builds dependencies without line tables: the test archive pull requests' shards
  download goes from 3.4 GB to 2.2 GB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 00:36:14 +00:00
bherbruck 9c411859bc CI: self-hosted test shards build in their warm directories, no 3 GB download (#2833)
On main and in the merge queue every test shard ran on the self-hosted machine but waited for
the GitHub-hosted Build tests job and then spent about 12.5 minutes downloading its 3.2 GB
archive (four shards at once over one connection). There, each shard now builds the tests in
the runner's warm build directory (the existing build-tests one) and runs its slice directly;
Build tests runs only where the shards run on GitHub's or the untrusted runners.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:35:03 -04:00
bherbruck 22a86a744e CI: keep the fast self-hosted runners for main and the merge queue (#2830)
Same-repo pull requests ran their heavy jobs on the three fast runners too, and the merge
queue's test shards waited behind them for hours. Pull requests now use the GitHub-hosted path.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:31:22 -04:00
bherbruck 143435dc1c Format and clippy
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 23:53:43 +00:00
bherbruck 36aa4a15fe Merge remote-tracking branch 'origin/main' into sketch-grid 2026-10-18 23:52:12 +00:00
bherbruck e887792182 Fix orbit about the picked point (regression in 0.16.0) (#2753)
Orbiting about the point under the cursor (or the origin) turned the camera
about its target and then panned to put the pivot back. The pan works at the
target's depth with the parallel-view scale, so in a perspective view the
pivot slid off its pixel and the model swung about the camera.

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

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

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

This reverts commit cac4bccea8.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:12:00 +00:00
bherbruck 0b6d6fecd8 Release 0.16.0 2026-10-18 18:16:11 +00:00
bherbruck a940f77447 Sketch: always show a grid on the open sketch's plane
While a sketch is open its grid is drawn on the sketch's own plane, centred
near the view target and snapped to the major step, whatever the Grid switch,
the sketch's Sketch Grid option or Grid follows the view say. Headless window
pictures follow. Outside a sketch nothing changes.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 01:44:15 +00:00
bherbruck c59842b8e1 Merge branch 'main' into ci/slow-runner-timeouts 2026-10-17 20:26:18 -04:00
bherbruck a587d3339e Tests: more time for the web offline launches and the icon review server
On a busy CI machine (several fork jobs under gVisor at once) a web
offline launch took over 90 s and the icon review server took over ~2 s
to answer. Launches now get 240 s and one retry; the review server gets
up to 60 s to start.

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

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

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

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

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

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

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

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

* CI: a test filter YAML parses

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

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

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

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

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

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

---------

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

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

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

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

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

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

---------

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

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

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:56:47 +00:00
567 changed files with 53540 additions and 5417 deletions
+31 -2
View File
@@ -1,8 +1,9 @@
# cargo-nextest settings for CI (`cargo xtask test` does not use nextest).
# PR runs build the tests once and run them in 4 shards (.github/workflows/ci.yml).
# CI builds the tests once and runs them in shards: 2 for a pull request, 4 for main and the nightly
# (.github/workflows/ci.yml).
[profile.ci]
# Heavy tests (one to four minutes each on a CI runner) that pull requests skip. They run on
# Heavy tests (one to twelve minutes each on a CI runner) that pull requests skip. They run on
# every push to main and nightly (`--ignore-default-filter`), and in `cargo xtask test`. The
# boolean_props property tests run only nightly (ci.yml excludes them from pushes to main).
# Keep this list in sync with docs/book/src/dev/testing.md and CONTRIBUTING.md.
@@ -17,6 +18,7 @@ not (
| test(=cmd::plastic::tests::enclosure_snap_fit_follows_resized_front_wall)
))
| (package(solidcraft-kernel) & test(=tests::boolean_identity_on_tangent_primitives))
| (package(solidcraft-kernel) & test(=tspline::tests::hostile_tsplines_never_panic))
| (package(solidcraft-kernel) & binary(boolean_props))
| (package(solidcraft-engine) & binary(atomic_commands))
| (package(solidcraft-ui-egui) & binary(scenarios) & test(=acceptance_sensor_enclosure))
@@ -39,3 +41,30 @@ path = "junit.xml"
filter = "binary(action_matrix) & test(=action_matrix_sample)"
threads-required = "num-cpus"
# The slowest tests start first, so a shard does not end with one of them running alone on a
# core while the others sit idle (seed_81 alone takes about 20 minutes under CI load). Medians
# from the CI test timings (the junit artifacts), slowest first; a test over ~100 s belongs here.
[[profile.ci.overrides]]
filter = """
(package(solidcraft-engine) & (
test(/^tests::robustness_goldens::seed_(81|45|20|24|95|02|57|40|18|48|32|46|85|61)$/)
| test(/^tests::robustness::/)
| test(=tests::hostile_params_never_panic)
| test(=cmd::clearance::tests::distances_are_exact_on_edges_and_curved_faces)
| test(=cmd::clipboard::tests::a_pasted_coil_joins_the_pasted_body)
| test(=tests::pleat_join::the_pleats_join_at_every_wall_from_2_4_to_2_6_mm)
| test(=tests::flush_join_loft::a_flange_flush_with_the_end_of_a_bent_tapered_tube_joins)
| test(=cmd::sheet::tests::perforated_sheet_takes_a_thousand_round_holes)
| test(=boolean_regression_tests::joined_loft_frames_plan_their_display_at_every_quality)
))
| (package(solidcraft-engine) & (binary(atomic_commands) | binary(step_export_regressions)))
| (package(solidcraft-kernel) & (
test(=curveblend::tests::rolling_ball_turns_about_a_rounded_corner_of_its_radius)
| test(=tests::fillet_tabbed_plate_outer_loop_and_partial_selection_after_hole)
| test(=tspline::tests::hostile_tsplines_never_panic)
| test(=fill::tests::regular_rims_of_three_to_eight_sides_fill_with_one_face_and_close_the_cup)
| test(/^issue_788_tests::equal_bores_crossing_/)
))
"""
priority = 100
+6
View File
@@ -2,3 +2,9 @@ self-hosted-runner:
labels:
- self-hosted-fast
- self-hosted-light
# Blacksmith (see the top of workflows/ci.yml).
- blacksmith-2vcpu-ubuntu-2404
- blacksmith-4vcpu-ubuntu-2404
- blacksmith-8vcpu-ubuntu-2404
- blacksmith-16vcpu-ubuntu-2404
- blacksmith-8vcpu-windows-2025
+244 -126
View File
@@ -2,38 +2,50 @@
# PRs test what they touch; main and nightly test everything. On a pull request the "Select
# checks" job maps the changed files to workspace packages (scripts/ci/affected.py), adds the
# packages that depend on them, and Clippy, the test archive and the test shards cover only
# those. Changes to the lockfile, root manifest, toolchain/cargo/nextest config, .github/,
# vendor/, tests/, scripts/ or any file no package owns select the whole workspace, as do
# pushes to main, the nightly run and Run workflow.
# Tests are built once and run in 4 shards. A few heavy tests (see .config/nextest.toml) are
# those. Test data under tests/, vendored crates under vendor/ and root files a crate embeds
# select the packages that read them. Changes to the lockfile, root manifest,
# toolchain/cargo/nextest config, this workflow, scripts/ or any file no package owns select the
# whole workspace, as do pushes to main that change code, the nightly run and Run workflow.
# Documentation, packaging, the other workflows and the GitHub bots' scripts select nothing:
# no native job runs for them, on pull requests or on main.
# Tests are built once and run in shards. A few heavy tests (see .config/nextest.toml) are
# skipped on pull requests and run on pushes to main and nightly. The Windows jobs (D3D12 shaders
# and app startup) run on pull requests only when what they test changes (the GPU crate, the app,
# the viewport's GPU glue, the graphics dependencies, their scripts or this file); main and
# nightly always run them. Only main saves the build caches; pull requests restore them.
# The merge queue runs the full suite on exactly the commit it then puts on main, so a push to
# main that a passing merge-queue run already tested runs only the repository checks; a push
# that skipped the queue still runs everything. Pull requests run the reference tests when the
# command line (or anything it depends on) changes, and doc tests for the affected packages.
# and app startup), the reference tests and the doc tests run on main and nightly only, not on
# pull requests (the fast lane, in Select checks); a break there is fixed forward on main. Only
# main saves the build caches; pull requests restore them. On pull requests the test build and the
# shards start only after the format and repository checks pass.
# A push to main runs only the repository checks when a passing merge-queue run tested that very
# commit, or when nothing a build reads changed since the last push to main whose run passed.
# Otherwise it runs everything.
# Heavy checks wait for review: a pull request without the `approved-for-ci` label runs only
# Select checks, the format and repository checks and a quick compile. Adding the label (after
# review) runs everything above, and every later push to the PR does too, except a push that only
# merges main into a change that already passed (scripts/ci/main_merge.py): it carries that
# result and runs the repository checks. Merge groups, the nightly run and Run workflow are not
# pull requests and always run the full suite.
# GitHub cannot filter `labeled` events by label name, so any other label starts a run whose jobs
# are all skipped; it gets its own concurrency group (below) so it never cancels the real run. Its
# `ci` job still runs and reports the commit's real result (see the `ci` job): the merge queue
# reads `ci` from the newest run of a commit, so a run without it would block a PR that passed.
# Cross-compilation is opt-in through Run workflow; release builds remain in release.yml.
# Trusted runs use both pools: pushes to main, merge-queue groups and pull requests from a branch
# of this repository (people with write access; fork pull requests never run on this machine).
# There, Select checks and ci run on the self-hosted light runner (`self-hosted-light`, quick
# jobs, so they never wait behind a build), and the repository checks, Clippy, the doc tests,
# the reference tests and all four test shards on the self-hosted runners (`self-hosted-fast`, warm
# build directories via scripts/ci/warm-target.sh). Fork PRs' Linux jobs run on the isolated, ephemeral
# `self-hosted-untrusted` runners (no warm caches) when the PR number ends in 7, 8 or 9 (the rest of
# the fork PRs, and everything else, run on GitHub's runners, which have far more free slots).
# The test archive is always built on GitHub's runners: the self-hosted machine has a newer
# glibc, so its binaries wouldn't start on ubuntu-24.04, while the archive runs there unchanged
# (same /home/runner/work checkout path). Fork pull requests, the nightly run and Run workflow
# use GitHub's runners only; the self-hosted runners refuse any other job.
# Every Linux job runs on Blacksmith's Ubuntu 24.04 runners (blacksmith-<N>vcpu-ubuntu-2404), sized
# per job: 16 vCPU for Build tests, Clippy and the merge queue's test shards, 8 for compiles, the
# other test shards, doc and reference tests, 4 for the light checks, 2 for Select checks and ci.
# The Windows shader job runs on Blacksmith's Windows Server 2025 runners (blacksmith-8vcpu-windows-2025);
# the Windows app startup smoke stays on windows-latest, which has a desktop session that presents frames.
# Pull requests, forks included, the merge queue, pushes to main and the nightly run all use them:
# the runners are ephemeral, fork runs get no secrets, and main saves the build caches that every
# other run restores read-only. The tests are built once (Build tests) into an archive the shards
# run; Blacksmith's image matches ubuntu-24.04, so the archive and the recorded goldens behave the
# same.
name: CI
on:
# Draft pull requests run no CI (see `changes`, `checks` and `ci`): it starts when the PR is
# marked ready for review, and on every push after that.
# marked ready for review, and on every push after that. `labeled` is there for approved-for-ci
# only (other labels are skipped, see the top of this file).
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
types: [opened, synchronize, reopened, ready_for_review, labeled]
# The merge queue: each queued group runs the full suite before it lands on main.
merge_group:
push:
@@ -53,7 +65,10 @@ permissions:
concurrency:
# A new PR push replaces the old run. Only one run per group may wait, so a busy main would
# cancel the waiting nightly (or a Run workflow) before it starts: they get groups of their own.
group: ci-${{ github.event_name }}-${{ github.ref }}
# A `labeled` event for any label but approved-for-ci does no work and must not cancel (or queue
# behind) the PR's real run, so it gets a group of its own; approved-for-ci replaces the
# unlabelled run with the full one.
group: ci-${{ github.event_name }}-${{ github.ref }}${{ github.event.action == 'labeled' && github.event.label.name != 'approved-for-ci' && format('-ignored-{0}', github.run_id) || '' }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
@@ -63,7 +78,7 @@ jobs:
dependency-advisories:
name: Dependency advisories
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-24.04
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -75,10 +90,10 @@ jobs:
changes:
name: Select checks
# Not on a draft: every other check needs this one, so they skip too.
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
# Trusted runs (see the top of this file) use the self-hosted runners.
runs-on: ${{ (github.event_name == 'merge_group' || (github.event_name == 'push' && github.ref == 'refs/heads/main') || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository)) && 'self-hosted-light' || 'ubuntu-24.04' }}
# Not on a draft: every other check needs this one, so they skip too. Not on a label other than
# approved-for-ci either (the same skip, for the same reason).
if: (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event.action != 'labeled' || github.event.label.name == 'approved-for-ci')
runs-on: blacksmith-2vcpu-ubuntu-2404
permissions:
contents: read
# To ask whether the merge queue already tested a commit pushed to main.
@@ -89,12 +104,13 @@ jobs:
goldens: ${{ steps.paths.outputs.goldens }}
windows: ${{ steps.paths.outputs.windows }}
full: ${{ steps.paths.outputs.full }}
# Code changed on a pull request that has no approved-for-ci label yet: only Quick compile.
quick: ${{ steps.paths.outputs.quick }}
packages: ${{ steps.paths.outputs.packages }}
oracle: ${{ steps.paths.outputs.oracle }}
# Whether the other Linux jobs use the self-hosted runners too.
# Trusted self-hosted runs only: a fork PR's checks run on the untrusted pool and must never
# route the jobs after them to the trusted runners.
local: ${{ runner.environment == 'self-hosted' && !(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) }}
doctests: ${{ steps.paths.outputs.doctests }}
# The passing run a merge of main carries forward (its URL), when this push is only that.
carried: ${{ steps.paths.outputs.carried }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
@@ -106,7 +122,11 @@ jobs:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PUSH_SHA: ${{ github.sha }}
PR_NUMBER: ${{ github.event.pull_request.number }}
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
HEAD_REF: ${{ github.event.pull_request.head.ref }}
REPO: ${{ github.repository }}
APPROVED: ${{ contains(github.event.pull_request.labels.*.name, 'approved-for-ci') }}
GH_TOKEN: ${{ github.token }}
run: |
native=false
@@ -115,17 +135,38 @@ jobs:
windows=false
full=true
packages=
# Whether the NUL-separated paths in $1 reach any code: scripts/ci/affected.py selects
# something or the whole workspace. Documentation, packaging, other workflows and the bots'
# scripts select nothing. Pull requests and pushes to main share it.
select_packages() {
python3 -I scripts/ci/affected.py --paths "$1" > "$RUNNER_TEMP/ci-affected"
cat "$RUNNER_TEMP/ci-affected"
full=$(sed -n 's/^full=//p' "$RUNNER_TEMP/ci-affected")
packages=$(sed -n 's/^packages=//p' "$RUNNER_TEMP/ci-affected")
}
if [ "$EVENT_NAME" = push ]; then
# The queue lands the very commit its merge_group run tested. If that run passed,
# testing the push again proves nothing new; otherwise (a merge that skipped the
# queue, or the API cannot answer) test everything.
# curl and grep only: Select checks of a push runs on the self-hosted runner.
# The API through curl and grep, which every runner image has.
tested=$(curl -fsS -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/$REPO/actions/workflows/ci.yml/runs?event=merge_group&head_sha=$PUSH_SHA&status=success&per_page=1" \
| grep -o '"total_count": *[0-9]*' | grep -o '[0-9]*$' || echo 0)
if [ "${tested:-0}" -gt 0 ]; then
echo "The merge queue already tested $PUSH_SHA: repository checks only."
elif tested=$(curl -fsS -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/$REPO/actions/workflows/ci.yml/runs?event=push&branch=main&status=success&per_page=1" \
| grep -o '"head_sha": *"[0-9a-f]*"' | head -n 1 | grep -o '[0-9a-f]\{40\}') \
&& [ -n "$tested" ] && git merge-base --is-ancestor "$tested" "$PUSH_SHA" \
&& git diff --no-renames --name-only -z "$tested" "$PUSH_SHA" > "$RUNNER_TEMP/ci-paths" \
&& select_packages "$RUNNER_TEMP/ci-paths" && [ "$full" != true ] && [ -z "$packages" ]; then
# Nothing a build reads changed since the last push to main whose run passed (the
# push before may never have run: a newer push cancels a waiting one).
echo "$tested..$PUSH_SHA changes no code: repository checks only."
else
# Code changed (or the push can't be compared): main runs everything.
full=true
packages=
native=true
windows=true
fi
@@ -139,7 +180,7 @@ jobs:
case "$path" in crates/kernel/*|vendor/truck-*) kernel=true ;; esac
# What can change a robustness golden: the kernel and the crates a design is built with.
case "$path" in
crates/kernel/*|crates/engine/*|crates/doc/*|crates/sketch/*|crates/geom/*|vendor/truck-*) goldens=true ;;
crates/kernel/*|crates/engine/*|crates/doc/*|crates/sketch/*|crates/geom/*|vendor/truck-*|tests/robustness/*) goldens=true ;;
esac
# What the Windows D3D12 jobs exercise: the GPU crate and its shaders, the app and
# its startup, the viewport's wgpu glue, the vendored egui-wgpu, their scripts.
@@ -147,14 +188,6 @@ jobs:
crates/gpu/*|apps/solidcraft/*|crates/ui-egui/src/gpu.rs|vendor/egui-wgpu/*) windows=true ;;
tools/windows-*.ps1|.github/workflows/ci.yml|rust-toolchain*|.cargo/*) windows=true ;;
esac
case "$path" in
.github/workflows/ci.yml) native=true ;;
*.rs|Cargo.toml|Cargo.lock|*/Cargo.toml|.cargo/*|rust-toolchain*) native=true ;;
docs/*|*.md|.github/*|packaging/*|LICENSE*|NOTICE|THIRD-PARTY-LICENSES.txt) ;;
# The GitHub bots' scripts: no Rust, tested in this job (Claim and triage bot tests).
scripts/claim/*|scripts/triage/*) ;;
*) native=true ;;
esac
done < "$RUNNER_TEMP/ci-paths"
# A lockfile change that moves the graphics stack (wgpu, naga, egui, winit, windows).
if [ "$windows" = false ] && grep -qxz 'Cargo.lock' "$RUNNER_TEMP/ci-paths" \
@@ -162,13 +195,10 @@ jobs:
| grep -Eq '^[ +-]name = "(wgpu[a-z_-]*|naga|egui[a-z_-]*|eframe|winit|windows[a-z_-]*|d3d12)"'; then
windows=true
fi
# Pull requests build and test the touched packages and their dependents.
python3 -I scripts/ci/affected.py --paths "$RUNNER_TEMP/ci-paths" > "$RUNNER_TEMP/ci-affected"
cat "$RUNNER_TEMP/ci-affected"
full=$(sed -n 's/^full=//p' "$RUNNER_TEMP/ci-affected")
packages=$(sed -n 's/^packages=//p' "$RUNNER_TEMP/ci-affected")
# Code changed but nothing was selected: be safe and test everything.
if [ "$native" = true ] && [ "$full" != true ] && [ -z "$packages" ]; then full=true; fi
# Pull requests build and test the touched packages and their dependents; nothing
# selected means no code changed.
select_packages "$RUNNER_TEMP/ci-paths"
if [ "$full" = true ] || [ -n "$packages" ]; then native=true; fi
# The goldens are solidcraft-engine tests: its tests must be in the archive.
if [ "$goldens" = true ] && [ "$full" != true ]; then
case " $packages " in *" solidcraft-engine "*) ;; *) packages="$packages solidcraft-engine" ;; esac
@@ -180,36 +210,60 @@ jobs:
if [ "$EVENT_NAME" = pull_request ] && [ "$full" != true ]; then
case " $packages " in *" solidcraft-cli "*) ;; *) oracle=false ;; esac
fi
# The pull request fast lane: Windows and the reference tests run on main (and the nightly),
# not on every pull request push; a break there is fixed forward on main.
if [ "$EVENT_NAME" = pull_request ]; then windows=false; oracle=false; fi
# The pull request fast lane: Windows, the reference tests and the doc tests run on main
# (and the nightly), not on every pull request push; a break there is fixed forward on
# main. Over three days of pull requests the doc tests were the only failing job 3 times
# in about 7,900 runner-minutes, and the reference tests never were.
doctests=$native
if [ "$EVENT_NAME" = pull_request ]; then windows=false; oracle=false; doctests=false; fi
# Until a maintainer adds approved-for-ci, a pull request gets a quick compile instead of
# Clippy, the tests, the doc tests and the goldens.
quick=false
if [ "$EVENT_NAME" = pull_request ] && [ "$APPROVED" != true ]; then
quick=$native
native=false
oracle=false
goldens=false
fi
# A push that only merges main into the change (Merge branch 'main', Update branch) when
# the change itself already passed: nothing new to test here, and main tests the merged
# result after it lands. Over three days, 872 of 1,241 such merges brought in nothing that
# touched the pull request's files, and each restarted the whole run.
carried=
if [ "$EVENT_NAME" = pull_request ] && [ "$native" = true ]; then
carried=$(python3 -I scripts/ci/main_merge.py --base "$BASE_SHA" --head "$HEAD_SHA" --repo "$REPO" \
--pr "$PR_NUMBER" --head-repo "$HEAD_REPO" --head-ref "$HEAD_REF" | sed -n 's/^carried=//p')
if [ -n "$carried" ]; then
echo "Only a merge of main into a change that passed in $carried: repository checks only."
native=false
goldens=false
fi
fi
{
echo "native=$native"
echo "kernel=$kernel"
echo "goldens=$goldens"
echo "windows=$windows"
echo "oracle=$oracle"
echo "doctests=$doctests"
echo "carried=$carried"
echo "full=$full"
echo "quick=$quick"
echo "packages=$packages"
} >> "$GITHUB_OUTPUT"
checks:
name: Format and repository checks
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
runs-on: ${{ (github.event_name == 'merge_group' || (github.event_name == 'push' && github.ref == 'refs/heads/main') || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository)) && 'self-hosted-fast' || 'ubuntu-24.04' }}
if: (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event.action != 'labeled' || github.event.label.name == 'approved-for-ci')
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Warm build directory (self-hosted runner)
if: runner.environment == 'self-hosted' && !(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
run: scripts/ci/warm-target.sh checks
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
# The trusted self-hosted runners keep their build directory and Cargo home instead; the
# untrusted ones (fork pull requests) start empty and restore main's cache, read-only.
- if: runner.environment == 'github-hosted' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
# Main saves the cache; every other run restores it, read-only.
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: repo-checks
save-if: ${{ github.ref == 'refs/heads/main' }}
@@ -231,27 +285,43 @@ jobs:
run: cargo xtask web-offline-test
- name: Affected-package selection self-test
run: python3 -I scripts/ci/test_affected.py
- name: Merge-of-main detection self-test
run: python3 -I scripts/ci/test_main_merge.py
- name: Claim and triage bot tests
run: node --test scripts/claim/*.test.js scripts/triage/*.test.js
# Fast feedback for a pull request that has not been approved for the full CI yet.
quick-compile:
name: Quick compile
needs: changes
if: needs.changes.outputs.quick == 'true'
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Size parallel compiles to the job's memory
run: scripts/ci/build-jobs.sh
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: quick-compile
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Cargo check
run: cargo check --workspace --all-targets --locked
clippy:
name: Native Linux Clippy
needs: changes
if: needs.changes.outputs.native == 'true'
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-fast' || 'ubuntu-24.04' }}
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Warm build directory (self-hosted runner)
if: runner.environment == 'self-hosted' && !(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
run: scripts/ci/warm-target.sh clippy
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
# The trusted self-hosted runners keep their build directory and Cargo home instead; the
# untrusted ones (fork pull requests) start empty and restore main's cache, read-only.
- if: runner.environment == 'github-hosted' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
# Main saves the cache; every other run restores it, read-only.
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: native-clippy
save-if: ${{ github.ref == 'refs/heads/main' }}
@@ -267,11 +337,15 @@ jobs:
build-tests:
name: Build tests
needs: changes
if: needs.changes.outputs.native == 'true'
runs-on: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && (endsWith(format('{0}', github.event.pull_request.number), '7') || endsWith(format('{0}', github.event.pull_request.number), '8') || endsWith(format('{0}', github.event.pull_request.number), '9'))) && 'self-hosted-untrusted' || 'ubuntu-24.04' }}
# Fork builds compile inside gVisor on shared boxes; give them room to finish and upload.
timeout-minutes: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && (endsWith(format('{0}', github.event.pull_request.number), '7') || endsWith(format('{0}', github.event.pull_request.number), '8') || endsWith(format('{0}', github.event.pull_request.number), '9'))) && 90 || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) && 60 || 45 }}
# Fail fast on pull requests: the test archive (and so the shards) waits for the repository
# checks, the most common cheap failure; heavy jobs used to run on to the end beside a failed
# format check. Main, nightly and Run workflow build regardless, so the action matrix and the
# property tests that hang off this job keep their signal.
needs: [changes, checks]
if: ${{ !cancelled() && needs.changes.result == 'success' && needs.changes.outputs.native == 'true' && (github.event_name != 'pull_request' || needs.checks.result == 'success') }}
runs-on: blacksmith-16vcpu-ubuntu-2404
# Fork builds start from main's cache, read-only; give them room to finish and upload.
timeout-minutes: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) && 60 || 45 }}
env:
CARGO_INCREMENTAL: "0"
RUSTC_WRAPPER: sccache
@@ -314,8 +388,9 @@ jobs:
tests:
name: Native Linux tests (${{ matrix.shard }}/4)
needs: [changes, build-tests]
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-fast' || ((github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && (endsWith(format('{0}', github.event.pull_request.number), '7') || endsWith(format('{0}', github.event.pull_request.number), '8') || endsWith(format('{0}', github.event.pull_request.number), '9'))) && 'self-hosted-untrusted' || 'ubuntu-24.04') }}
needs: [changes, checks, build-tests]
if: ${{ !cancelled() && needs.changes.outputs.native == 'true' && (github.event_name != 'pull_request' || needs.checks.result == 'success') && needs.build-tests.result == 'success' }}
runs-on: ${{ github.event_name == 'merge_group' && 'blacksmith-16vcpu-ubuntu-2404' || 'blacksmith-8vcpu-ubuntu-2404' }}
timeout-minutes: 60
strategy:
fail-fast: false
@@ -331,47 +406,49 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install a software Vulkan driver (lavapipe)
# Preinstalled on the self-hosted runner, which has no sudo.
if: runner.environment == 'github-hosted'
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends mesa-vulkan-drivers libvulkan1
- uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28
with:
tool: cargo-nextest
# Each shard runs the archive the Build tests job made.
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: nextest-archive
- name: Run tests
env:
EVENT_NAME: ${{ github.event_name }}
GOLDENS: ${{ needs.changes.outputs.goldens }}
SHARD: ${{ matrix.shard }}
SHARDS: ${{ github.event_name == 'pull_request' && 2 || 4 }}
# The robustness golden seeds a pull request skips: each takes one to twenty minutes under
# CI load, together about four fifths of the goldens' time. Pushes to main, the merge
# queue and the nightly run every seed.
SLOW_SEEDS: 81|45|20|24|95|02|57|40|18|48|32|46|85|61|77|34|14
run: |
# Pull requests skip the heavy tests (default-filter in .config/nextest.toml).
all=()
# The boolean property tests draw new random inputs every run; they run only in the
# nightly property-tests job, so a known kernel bug cannot turn main red at random.
filter=()
if [ "$EVENT_NAME" != pull_request ]; then
all=(--ignore-default-filter -E 'not (package(solidcraft-kernel) & binary(boolean_props))')
# The boolean property tests draw new random inputs every run; they run only in the
# nightly property-tests job, so a known kernel bug cannot turn main red at random.
filter=(--ignore-default-filter -E 'not (package(solidcraft-kernel) & binary(boolean_props))')
elif [ "$GOLDENS" = true ]; then
# A pull request that can change a robustness golden (tests/robustness/goldens.json)
# also checks every seed but the slow ones, in the same run so they share its cores.
filter=(--ignore-default-filter -E "default() | (package(solidcraft-engine) & test(/^tests::robustness_goldens::/) & not test(/^tests::robustness_goldens::seed_($SLOW_SEEDS)\$/))")
fi
junit=${CARGO_TARGET_DIR:-target}/nextest/ci/junit.xml
rm -f "$junit"
# Extract into ./target: tests that run a built binary (env!("CARGO_BIN_EXE_*")) have
# the build job's path, which is the same checkout path here.
cargo nextest run --archive-file tests.tar.zst --workspace-remap . --extract-to . --extract-overwrite \
--profile ci --partition "slice:$SHARD/$SHARDS" --no-tests=pass "${all[@]}"
- name: Robustness goldens (pull requests that change geometry)
# Every seed of tests/robustness/goldens.json (each seed is a test), split over the shards;
# pushes to main and the nightly run them in the step above.
if: github.event_name == 'pull_request' && needs.changes.outputs.goldens == 'true'
env:
SHARD: ${{ matrix.shard }}
SHARDS: ${{ github.event_name == 'pull_request' && 2 || 4 }}
run: |
if ! cargo nextest run --archive-file tests.tar.zst --workspace-remap . --extract-to . --extract-overwrite \
--profile ci --ignore-default-filter --partition "slice:$SHARD/$SHARDS" --no-tests=pass \
-E 'package(solidcraft-engine) & test(/^tests::robustness_goldens::/)'; then
echo "::error::A robustness golden changed. If the new result is intended, re-record it in this pull request and say why:"
echo "::error::ROBUSTNESS_GOLDENS_BLESS=\"why it changed\" cargo test --profile ci -p solidcraft-engine --lib robustness_goldens:: (see docs/robustness.md)"
run=(cargo nextest run --archive-file tests.tar.zst --workspace-remap . --extract-to . --extract-overwrite)
if ! "${run[@]}" --profile ci --partition "slice:$SHARD/$SHARDS" --no-tests=pass "${filter[@]}"; then
# A failed golden gets the hint to re-record it (junit.xml lists the failures).
if [ -f "$junit" ] && tr '\n' ' ' < "$junit" | grep -q '<testcase name="tests::robustness_goldens::[^"]*"[^>]*>[[:space:]]*<failure'; then
echo "::error::A robustness golden changed. If the new result is intended, re-record it in this pull request and say why:"
echo "::error::ROBUSTNESS_GOLDENS_BLESS=\"why it changed\" cargo test --profile ci -p solidcraft-engine --lib robustness_goldens:: (see docs/robustness.md)"
fi
exit 1
fi
- name: Upload test timings
@@ -379,7 +456,7 @@ jobs:
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: junit-${{ matrix.shard }}
path: target/nextest/ci/junit.xml
path: ${{ env.CARGO_TARGET_DIR || 'target' }}/nextest/ci/junit.xml
retention-days: 7
action-matrix:
@@ -390,7 +467,7 @@ jobs:
name: Action matrix (${{ matrix.shard }}/16)
needs: build-tests
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-24.04
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 55
strategy:
fail-fast: false
@@ -432,7 +509,7 @@ jobs:
name: Action matrix determinism
needs: build-tests
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-24.04
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 45
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -454,7 +531,7 @@ jobs:
name: Property tests (many cases)
needs: build-tests
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-24.04
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 60
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -477,8 +554,8 @@ jobs:
doc-tests:
name: Doc tests
needs: changes
if: needs.changes.outputs.native == 'true'
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-fast' || 'ubuntu-24.04' }}
if: needs.changes.outputs.doctests == 'true'
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 45
env:
CARGO_INCREMENTAL: "0"
@@ -489,14 +566,9 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Size parallel compiles to the job's memory
run: scripts/ci/build-jobs.sh
- name: Warm build directory (self-hosted runner)
if: runner.environment == 'self-hosted' && !(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
run: scripts/ci/warm-target.sh doc-tests
- uses: dtolnay/rust-toolchain@stable
# The trusted self-hosted runners keep their build directory and Cargo home instead; the
# untrusted ones (fork pull requests) start empty and restore main's cache, read-only.
- if: runner.environment == 'github-hosted' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
# Main saves the cache; every other run restores it, read-only.
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: doc-tests
save-if: ${{ github.ref == 'refs/heads/main' }}
@@ -531,7 +603,7 @@ jobs:
name: Reference tests
needs: changes
if: needs.changes.outputs.oracle == 'true'
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-fast' || ((github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && (endsWith(format('{0}', github.event.pull_request.number), '7') || endsWith(format('{0}', github.event.pull_request.number), '8') || endsWith(format('{0}', github.event.pull_request.number), '9'))) && 'self-hosted-untrusted' || 'ubuntu-24.04') }}
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 60
env:
CARGO_INCREMENTAL: "0"
@@ -542,14 +614,9 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Size parallel compiles to the job's memory
run: scripts/ci/build-jobs.sh
- name: Warm build directory (self-hosted runner)
if: runner.environment == 'self-hosted' && !(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
run: scripts/ci/warm-target.sh oracle
- uses: dtolnay/rust-toolchain@stable
# The trusted self-hosted runners keep their build directory and Cargo home instead; the
# untrusted ones (fork pull requests) start empty and restore main's cache, read-only.
- if: runner.environment == 'github-hosted' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
# Main saves the cache; every other run restores it, read-only.
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: oracle
save-if: ${{ github.ref == 'refs/heads/main' }}
@@ -577,7 +644,7 @@ jobs:
name: Windows D3D12 FXC shaders
needs: changes
if: needs.changes.outputs.windows == 'true'
runs-on: windows-latest
runs-on: blacksmith-8vcpu-windows-2025
timeout-minutes: 45
env:
WGPU_BACKEND: dx12
@@ -605,6 +672,8 @@ jobs:
name: Windows app startup
needs: changes
if: needs.changes.outputs.windows == 'true'
# GitHub-hosted: the startup smoke needs an interactive desktop that presents frames; on
# Blacksmith's Windows runners the window never presents ("no frame was presented").
runs-on: windows-latest
timeout-minutes: 60
steps:
@@ -627,17 +696,54 @@ jobs:
# Preserve the existing check name for branch protection, including docs-only PRs.
ci:
needs: [changes, checks, clippy, build-tests, tests, doc-tests, oracle, windows-fxc, windows-startup]
# On a run another label starts (every other job skipped), this job reports the result of the
# commit's real run instead: the merge queue reads `ci` from the newest run of a commit, so a
# run without a passing `ci` would block a pull request that passed (#1850, #3182, #3252).
name: ci
needs: [changes, checks, quick-compile, clippy, build-tests, tests, doc-tests, oracle, windows-fxc, windows-startup]
if: always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft)
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-light' || 'ubuntu-24.04' }}
runs-on: blacksmith-2vcpu-ubuntu-2404
permissions:
contents: read
# To read the commit's other runs of this workflow.
actions: read
steps:
- name: Report the commit's real result (a run another label started)
if: github.event.action == 'labeled' && github.event.label.name != 'approved-for-ci'
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
SHA: ${{ github.event.pull_request.head.sha }}
RUN_ID: ${{ github.run_id }}
run: |
# The other runs of ci.yml for this commit. While one is still going, wait for it (at
# most 90 minutes); then pass only when the newest one that ran its checks passed.
deadline=$((SECONDS + 5400))
while :; do
runs=$(gh api "repos/$REPO/actions/workflows/ci.yml/runs?head_sha=$SHA&event=pull_request&per_page=100" \
--jq "[.workflow_runs[] | select(.id != $RUN_ID)]")
pending=$(jq '[.[] | select(.status != "completed")] | length' <<<"$runs")
if [ "$pending" -eq 0 ] || [ "$SECONDS" -ge "$deadline" ]; then break; fi
echo "Waiting for $pending run(s) of this commit to finish."
sleep 30
done
# Runs with every job skipped (labels, before this job reported) are not a result.
latest=$(jq -r '[.[] | select(.status == "completed" and .conclusion != "skipped")]
| sort_by(.created_at) | last | "\(.conclusion) \(.html_url)"' <<<"$runs")
echo "The commit's latest run: $latest"
echo "A label started this run: \`ci\` repeats the commit's latest result ($latest)." >> "$GITHUB_STEP_SUMMARY"
test "${latest%% *}" = success
- name: Verify required checks
if: github.event.action != 'labeled' || github.event.label.name == 'approved-for-ci'
env:
CHANGES: ${{ needs.changes.result }}
CHECKS: ${{ needs.checks.result }}
NATIVE: ${{ needs.changes.outputs.native }}
QUICK: ${{ needs.changes.outputs.quick }}
QUICK_COMPILE: ${{ needs.quick-compile.result }}
WINDOWS: ${{ needs.changes.outputs.windows }}
ORACLE_RUN: ${{ needs.changes.outputs.oracle }}
DOCTESTS: ${{ needs.changes.outputs.doctests }}
CLIPPY: ${{ needs.clippy.result }}
BUILD: ${{ needs.build-tests.result }}
TESTS: ${{ needs.tests.result }}
@@ -648,10 +754,16 @@ jobs:
run: |
test "$CHANGES" = success
test "$CHECKS" = success
# An unapproved pull request: the heavy jobs are skipped on purpose.
if [ "$QUICK" = true ]; then
test "$QUICK_COMPILE" = success
fi
if [ "$NATIVE" = true ]; then
test "$CLIPPY" = success
test "$BUILD" = success
test "$TESTS" = success
fi
if [ "$DOCTESTS" = true ]; then
test "$DOCS" = success
fi
if [ "$ORACLE_RUN" = true ]; then
@@ -661,11 +773,17 @@ jobs:
test "$WINDOWS_FXC" = success
test "$WINDOWS_STARTUP" = success
fi
- name: Note a carried result
if: needs.changes.outputs.carried != ''
env:
CARRIED: ${{ needs.changes.outputs.carried }}
run: |
echo "This push only merges main into a change that already passed; the heavy checks were not repeated. Passing run: $CARRIED" >> "$GITHUB_STEP_SUMMARY"
wasm:
name: Compile WebAssembly (manual)
if: github.event_name == 'workflow_dispatch' && inputs.cross_compile
runs-on: ubuntu-24.04
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -681,7 +799,7 @@ jobs:
riscv64-gates:
name: Compile RISC-V (manual)
if: github.event_name == 'workflow_dispatch' && inputs.cross_compile
runs-on: ubuntu-24.04
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+5
View File
@@ -14,6 +14,11 @@ name: Docker
on:
push:
branches: [main]
# The image holds the web app only: documentation, test data and the GitHub bots' scripts can't
# change it, so a push touching only those builds nothing (edge stays at the last code change).
# ATTRIBUTION.md is the exception among the Markdown files: the app embeds it (Help, About).
# The last matching pattern wins.
paths: ["**", "!docs/**", "!**/*.md", "ATTRIBUTION.md", "!tests/**", "!scripts/claim/**", "!scripts/triage/**"]
release:
types: [published]
pull_request:
+12
View File
@@ -6,6 +6,18 @@ name: Generated files
on:
push:
branches: [main]
# THIRD-PARTY-LICENSES.txt follows the manifests, the lockfile, the vendored crates' licence
# files and the fonts' licences; docs/features.md follows the command registry (crates/engine),
# read through solidcraft-cli. Other pushes regenerate nothing.
paths:
- "Cargo.lock"
- "**/Cargo.toml"
- "vendor/**"
- "assets/fonts/**"
- "crates/engine/**"
- "apps/solidcraft-cli/**"
- "xtask/**"
- ".github/workflows/generated.yml"
workflow_dispatch:
permissions:
+2
View File
@@ -6,6 +6,8 @@ name: Pages
on:
push:
branches: [main]
# The site is the book and the web app: test data and the GitHub bots' scripts change neither.
paths-ignore: ["tests/**", "scripts/claim/**", "scripts/triage/**"]
workflow_dispatch:
inputs:
release_tag:
+37 -20
View File
@@ -27,8 +27,10 @@ new uses of it anywhere else. Public docs/web/Flatpak deployments use project Pa
## Never crash
People trust SolidCraft with their designs; a crash loses their work. **This outranks feature work.**
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`,
`unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). The three exceptions, in the app crate
(which denies it instead), have no safe API: two functions in
`unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). The exceptions, in the app crate and in
`crates/threedx-client` (which deny it instead), have no safe API: the macOS-only
`crates/threedx-client/src/macos.rs` (loads the 3Dconnexion driver's client framework at run time
and calls through its function pointers), two functions in
`apps/solidcraft/src/open_documents.rs` (macOS hands Finder files only to an Objective-C
method), the Windows-only `apps/solidcraft/src/native_crash.rs` (the exception filter that
records crashes inside native code such as graphics drivers) and the Windows-only
@@ -48,6 +50,8 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
`crashes replay <id>` (or the `diagnostics.crash_reports` command / MCP `crash_reports` tool).
- **Prove it:** every crash fix lands with a regression test (see `hostile_params_never_panic`); a
crash report's `design.solidcraft` + `replay.json` is the test's starting point.
- **Where tests go:** a regression test goes into the existing test file for its area (booleans, blends,
shell, sweep, STEP, naming…), never a new file named after the bug (#3226).
- Every production crate root carries
`#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
@@ -96,12 +100,13 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
Keep them minimal; `cargo xtask web-offline-test` covers the service worker.
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests,
assets, layers). Commit after every feature arc that builds.
- **Publishing**: `origin` is github.com/bherbruck/solidcraft. Changes land through **pull requests**:
push a branch, open a PR, and let GitHub CI run. Maintainers (and agents working for them) may
merge their own PR as soon as CI is green (`gh pr merge --squash`); a trivial fix (docs, typos)
may be merged right away. Keep each PR to one topic, rebase or merge `main` into it when it
conflicts, and never force-push `main`. Never commit `plan/`, build dirs (`target*/`) or
generated reference data; check `git ls-files` before pushing.
- **Publishing**: the repository is github.com/tangency-labs/solidcraft. Changes land through
**pull requests** and the **merge queue**: push a branch and open a PR. The heavy CI jobs start
when a maintainer adds `approved-for-ci` after reviewing the code. Approved PRs go into the queue
with `gh pr merge N -R tangency-labs/solidcraft --squash --auto`; the queue runs the full suite
on each group before it lands on `main`. Keep each PR to one topic and one issue, and never
force-push `main`. Never commit `plan/`, build dirs (`target*/`) or generated reference data;
check `git ls-files` before pushing.
- **Never break wasm**: everything below the apps must build for `wasm32-unknown-unknown`
(`cargo xtask wasm`, part of `cargo xtask ci`).
@@ -120,22 +125,34 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
- Use a small public-safe example and check for private designs, paths, account details or
other sensitive content before sharing. Attach media to the issue/PR or link to an accessible
recording; do not commit generated screenshots or videos just to provide review evidence.
- Throughout the work and before handoff, check current upstream commits and the relevant
PR's merge status. Keep your branch current with upstream merges, resolve conflicts without
discarding others' work, and re-run validation when integration changes the result. Use
`git pull --rebase origin main` before pushing as above; never force-push. Report the checked
upstream/PR state, any conflicts resolved and the validation performed. If a PR remains open,
keep monitoring it while the task is active and make its pending status clear at handoff;
do not claim it merged without checking.
- Throughout the work and before handoff, check the relevant PR's merge status. Don't push to
an open PR only to catch up with `main` (no merge of `main`, no Update branch, no rebase for
that alone): every push restarts the whole CI run, and `main`'s own run catches what lands in
between, to be fixed forward. When you push a change anyway, or GitHub reports a conflict,
rebase onto the latest `main` (push your own branch with `--force-with-lease`); never merge
`main` into a PR. Resolve conflicts without discarding others' work; never force-push `main`.
Don't re-run CI on an unchanged commit: a real failure fails again. Re-run only a job that
runner trouble cancelled, with "Re-run failed jobs". Report the PR state you checked and any
conflicts resolved. If a PR remains open, keep monitoring it while the task is active and
make its pending status clear at handoff; do not claim it merged without checking.
## Before you start: look at what is already open
- Work with an authenticated GitHub CLI (`gh auth status`) and `git`; the checks below need both.
- Pull the whole list, not one search: `gh pr list -R tangency-labs/solidcraft --state open --limit 500`
(drafts included) and `gh issue list -R tangency-labs/solidcraft --state open --limit 500`. Look for
work on the same issue, the same feature under another number, and PRs that change the files
you are about to change (`gh pr list ... --json number,title,files`).
- If someone already has it, comment on their PR or issue instead of opening a second one. Two
PRs for one change can't both land, and one of them is wasted work and CI.
## Claim = a draft PR
- Before working on issue #N, check nobody has it: `gh pr list -R bherbruck/solidcraft --state open --search "#N"`.
- Before working on issue #N, check nobody has it: `gh pr list -R tangency-labs/solidcraft --state open --search "#N"`.
- If none, branch from the latest `origin/main`, `git commit --allow-empty -m "claim: #N"`, push,
and open a **draft** PR titled `<summary> (#N)` right away. **The lowest-numbered open PR for an
issue wins**; if yours lost, close it. A comment or file edit alone does not replace the draft PR.
- After opening the draft PR, comment `/claim` on the issue so the bot records the claimant and
adds the `in progress` label. **Do both:** the draft PR establishes ownership and `/claim` marks
the issue as taken. Reference `#N` in the PR so the bot can find it.
issue wins**; if yours lost, close it. Opening the PR is the claim: the bot assigns you and
labels the issue `in progress` (or says on your PR who holds it). Reference `#N` in the PR title
so the bot can find it. Don't claim by editing files; comment `/claim` on the issue only to stake
a claim before you have a branch.
- Push work in progress at least daily. A draft with no pushes for 72 hours may be taken over:
comment on it first, then open your own claim (you may start from its branch).
This takeover policy is separate from the bot's automatic cleanup deadlines in CONTRIBUTING.md.
+14 -1
View File
@@ -28,6 +28,13 @@ Editable drawing code, native projects and shipped exports are collected under `
| docs/screenshots/class-a-crown.png | SolidCraft contributors | screenshot of SolidCraft itself: a patch crowned with `surface.patch crown=0.3` over a saddle rim (`solidcraft-cli snapshot`) | MIT OR Apache-2.0 |
| docs/screenshots/class-a-boundary.png | SolidCraft contributors | screenshot of SolidCraft itself: a patch through two sketch lines built by `surface.boundary` with `crown: 0.4` (`solidcraft-cli snapshot`) | MIT OR Apache-2.0 |
| docs/screenshots/sample-plate.png | SolidCraft contributors | screenshot of SolidCraft itself (`solidcraft --sample`) | MIT OR Apache-2.0 |
| assets/samples/exports/0.png | SolidCraft contributors | original Sample Plate from `crates/engine/src/sample.rs`, exported by the SolidCraft thumbnail renderer | MIT OR Apache-2.0 |
| assets/samples/exports/1.png | SolidCraft contributors | original Sheet Metal Bracket from `crates/engine/src/sample.rs`, exported by the SolidCraft thumbnail renderer | MIT OR Apache-2.0 |
| assets/samples/exports/2.png | SolidCraft contributors | original Hinge Assembly from `crates/engine/src/sample.rs`, exported by the SolidCraft thumbnail renderer | MIT OR Apache-2.0 |
| assets/samples/exports/3.png | SolidCraft contributors | original Plastic Enclosure from `crates/engine/src/sample.rs`, exported by the SolidCraft thumbnail renderer | MIT OR Apache-2.0 |
| assets/samples/exports/4.png | SolidCraft contributors | original Bolted Flange from `crates/engine/src/sample.rs`, exported by the SolidCraft thumbnail renderer | MIT OR Apache-2.0 |
| assets/samples/exports/5.png | SolidCraft contributors | original Vent Plate from `crates/engine/src/sample.rs`, exported by the SolidCraft thumbnail renderer | MIT OR Apache-2.0 |
| assets/samples/exports/scripts.json | SolidCraft contributors | SHA-256 fingerprints of the original sample scripts, generated with the shipped thumbnails | MIT OR Apache-2.0 |
| docs/screenshots/demo-0.8.gif | SolidCraft contributors | original screen recording of the actual SolidCraft 0.8 English desktop UI modelling an original Raspberry Pi 4 case (no logos or branding; the engraved text is set in the system's DejaVu Sans Bold), generated with examples/demo/record_ui.sh and its original animated pointer overlay (9× playback) | MIT OR Apache-2.0 |
| docs/screenshots/demo-0.8.mp4 | SolidCraft contributors | original screen recording of the actual SolidCraft 0.8 English desktop UI modelling an original Raspberry Pi 4 case (no logos or branding; the engraved text is set in the system's DejaVu Sans Bold), generated with examples/demo/record_ui.sh and its original animated pointer overlay (9× playback) | MIT OR Apache-2.0 |
| docs/screenshots/rover-build.gif | 0xlillith (model); SolidCraft contributors (recording) | screen recording of the actual SolidCraft desktop UI replaying the timeline of the SELENE rover (`Rover-recorded.solidcraft` from https://github.com/0xlillith/solidcraft-rover, an original model by 0xlillith built through SolidCraft MCP); the model's lettering follows Ubuntu Light outlines (Canonical Ltd., Ubuntu Font Licence 1.0, as noted in that repository); the emblem is the SolidCraft project icon (2× playback) | MIT OR Apache-2.0 |
@@ -45,6 +52,7 @@ Editable drawing code, native projects and shipped exports are collected under `
| docs/book/src/generated/enclosure.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/enclosure.json`; gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/revolve.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/revolve.json`; gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/sheet-metal.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/sheet-metal.json`; gitignored) | MIT OR Apache-2.0 |
| 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 |
| tests/oracle/17-loft/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/17-loft/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
@@ -88,6 +96,7 @@ Editable drawing code, native projects and shipped exports are collected under `
| assets/icons/ui/source/navigation.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/icons/ui/source/pointer.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/locales/cs.json | potuzai | Czech translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/da.json | SolidCraft contributors | Original Danish translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
| assets/locales/de.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/locales/en.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/locales/es.json | jawler | Spanish (Spain) translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
@@ -96,11 +105,13 @@ Editable drawing code, native projects and shipped exports are collected under `
| assets/locales/it.json | SolidCraft contributors | Original Italian translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
| assets/locales/ja.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/locales/ko.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/locales/ms.json | A.Hakim (a-hakim) | Malay translation of the English message catalog | MIT OR Apache-2.0 |
| assets/locales/pl.json | cyryllo | Polish translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/ru.json | Alex Pro (del000) | Russian translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/pt-BR.json | DuBearte | Brazilian Portuguese translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/tr.json | macrulz54 | Turkish translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/uk.json | Igor Bronovskyi (BrunIF) | Ukrainian translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/vi.json | vandang890615 | Vietnamese translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/zh.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/materials/library.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/platforms/linux/ai.storyteller.solidcraft.desktop | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
@@ -269,6 +280,7 @@ Editable drawing code, native projects and shipped exports are collected under `
| assets/icons/ui/design/svg/model/pattern_rect.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/perforate.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/pipe.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/silhouette.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/placeholder.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/plane.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/plane_angle.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
@@ -408,6 +420,7 @@ Editable drawing code, native projects and shipped exports are collected under `
| assets/icons/ui/design/tools/author/lib.py | SolidCraft contributors | icon authoring script (standard-library Python), original SolidCraft code | MIT OR Apache-2.0 |
| docs/book/src/og.png | SolidCraft contributors | copy of assets/branding/web/source/og.png staged by cargo xtask book (gitignored) | MIT OR Apache-2.0 |
| docs/sponsors/blacksmith-ci-powered.png | Blacksmith (blacksmith.sh) | "CI powered by Blacksmith" sponsor banner supplied by Blacksmith for the sponsorship credit; used only to credit the sponsor, not part of the application | © Blacksmith, used with permission |
## Code adapted from sibling projects
@@ -469,7 +482,7 @@ copied into SolidCraft.
|---|---|---|---|
| vendor/truck-meshalgo | truck-meshalgo 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | meshing: bounded parameter searches, collapsed-boundary projection, subdivision and provisional fixed-grid previews (vendor/README.md) |
| vendor/truck-stepio | truck-stepio 0.3.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | STEP writer: intersection-curve surfaces written at unique ids (vendor/README.md) |
| vendor/truck-shapeops | truck-shapeops 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | robust meshing and nearest-parameter fallback in booleans; degenerate intersection-curve rejection; face and edge history of booleans; trace macro; corner choice at vertices several intersection curves end on (vendor/README.md) |
| vendor/truck-shapeops | truck-shapeops 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | robust meshing and nearest-parameter fallback in booleans; degenerate intersection-curve rejection; face and edge history of booleans; trace macro; corner choice at vertices several intersection curves end on; reuse of an operand's meshes (vendor/README.md) |
| vendor/truck-geometry | truck-geometry 0.5.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | intersection curves divided from their neighbours' surface parameters; surface second derivatives (vendor/README.md) |
| vendor/matext4cgmath | matext4cgmath 0.1.0, https://github.com/ricosjp/matext4cgmath (RICOS Co. Ltd.) | Apache-2.0 | root refinement stops after 100 Newton steps (vendor/README.md) |
+2 -182
View File
@@ -1,183 +1,3 @@
# SolidCraft — instructions for agents
@AGENTS.md
SolidCraft is a clean-room, open-source, pure-Rust parametric 3D CAD application:
sketches with a constraint solver, a parametric timeline of features, a B-rep
kernel, and a desktop UI that agents can drive. Projects published by
[storytold](https://github.com/storytold) can serve as references for implementation conventions.
Any code adapted from other projects (MIT OR Apache-2.0) is noted in ATTRIBUTION.md.
## Product name
SolidCraft everywhere: crates `solidcraft-*`, binaries `solidcraft` and `solidcraft-cli`,
`.solidcraft` designs, the `solidcraft/N` format tag, `SOLIDCRAFT_*` environment variables,
settings and recovery folders, the MCP server name, docs, CI and packaging. The old name
survives only in the explicit compatibility shims listed in `docs/compatibility.md`; don't add
new uses of it anywhere else. Public docs/web/Flatpak deployments use project Pages
`/solidcraft/`; do not add permanent old-name Pages aliases.
## Start every session here
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md`
and the relevant `plan/architecture.md` section.
2. Kernel and library decisions: `plan/adr/0001-geometry-kernel.md` (what we may depend on, what
is rejected for licence reasons).
3. Follow the autonomous operation protocol (`plan/execution-plan.md` §6). Don't stop to ask
unless it lists the decision as the owner's.
`plan/` is gitignored (local only).
## Never crash
People trust SolidCraft with their designs; a crash loses their work. **This outranks feature work.**
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`,
`unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). The exceptions, in the app crate and in
`crates/threedx-client` (which deny it instead), have no safe API: the macOS-only
`crates/threedx-client/src/macos.rs` (loads the 3Dconnexion driver's client framework at run time
and calls through its function pointers), two functions in
`apps/solidcraft/src/open_documents.rs` (macOS hands Finder files only to an Objective-C
method), the Windows-only `apps/solidcraft/src/native_crash.rs` (the exception filter that
records crashes inside native code such as graphics drivers) and the Windows-only
`apps/solidcraft/src/native_frame.rs` (the window procedure that answers the resize-border hit
test of the window without OS decorations).
- **Errors are `Result<T, E>`** through the crate's error type and `?`. An unfinished feature
returns an error ("not supported yet: …"); it never panics.
- **Input-derived numbers are hostile** (command parameters, design files, control-channel
input): `get()` not `[i]`, finite checks, caps on sizes and loop counts.
- **Third-party kernel calls are guarded:** everything that calls `truck` goes through
`solidcraft_kernel::guard`, which turns a panic into `KernelError::Internal`.
- **Last-resort guard:** `Session::execute` runs every command under `catch_unwind`; an escaped
panic restores the design and reports `EngineError::Internal`.
- **Crash reports:** the desktop app writes every panic and every unclean exit to a local crash folder,
with the design and the commands that reproduce it (`solidcraft_engine::crash`, docs/crash-reports.md).
When someone says the app crashed, look there first: `solidcraft-cli crashes`, `crashes show <id>`,
`crashes replay <id>` (or the `diagnostics.crash_reports` command / MCP `crash_reports` tool).
- **Prove it:** every crash fix lands with a regression test (see `hostile_params_never_panic`); a
crash report's `design.solidcraft` + `replay.json` is the test's starting point.
- Every production crate root carries
`#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
## Non-negotiables
- **Independent.** SolidCraft is independent. Behaviour is designed from public documentation,
general CAD conventions and user feedback. Reference tests use analytic values and open-source
tools. Never copy another product's code, icons, artwork, help text, tooltips, templates or
materials. File formats come from public specifications.
- **No commercial CAD products.** Agents must not drive, script, automate or extract data from
commercial CAD products (Fusion, SolidWorks, Onshape, …) for this project: no test data,
measurements, command lists, ids or files produced with them.
- **Never read, copy or link GPL/LGPL/AGPL code**: OpenCASCADE, FreeCAD (incl. planegcs),
SolveSpace, CadQuery/OCP, LibreCAD, QCAD — don't even read their sources. (`tools/freecad-oracle/`
runs FreeCAD as a separate program through its public scripting API to compute reference values;
nothing from FreeCAD is linked, vendored or shipped.) The sketch solver and the blend
operations are our own.
- **Assets — absolutely essential.** SolidCraft contains **no iconography, images, fonts or
artwork from commercial CAD products — ever.** Every UI icon is drawn in collected code
(`assets/icons/ui/source/`, compiled through `crates/ui-egui/src/icons.rs`). Any file asset must be original or openly licensed and **must have a row in ATTRIBUTION.md**
(`cargo xtask assets` enforces it). Screenshots of commercial CAD software are never committed.
Breaking this rule is the most serious mistake you can make in this repo.
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*`
(`CommandSpec`: id = our own dotted, lower-case id `<area>.<thing>[.<variant>]` such as
`solid.extrude` or `sketch.rectangle.two_point`, never another product's command id; label;
toolbar tab and panel; icon; shortcut; params doc; `enabled`; JSON `run`) + tests. The toolbar,
dialogs, sketch tools, palette, scripts, CLI and control channel all reach the same commands.
- **Programmatic calls never open dialogs.** `engine.execute` runs the JSON form; only toolbar
clicks (`ui.start`) start interactive tools and dialogs, which then run commands.
- **Layering** is enforced by `cargo xtask layers`: L0 `geom` → L1 `sketch`, `kernel`, `render`
→ L2 `doc` → L3 `io` → L4 `engine` → L5 `ui-egui` → apps. Nothing below L5 depends on
egui/eframe/winit/wgpu/rfd. **The UI crate is swappable.** Only `crates/kernel` may use `truck`.
- **The UI is thin**: panels read engine state and act through `app.run(id, params)` /
`app.start(id)`. Colours come from `theme::Tokens`.
- **One set of camera controls**: every page or canvas with a camera (viewport, Render
preview, future flat pattern or drawing pages) calls `navigation::handle` with its `NavCaps`
(orbit off for flat 2D pages). New canvases must use `navigation::handle`; never hand-roll
camera input (a test rejects it).
- **Rust only.** Units are millimetres and radians internally; Z is up. The one exception is
JavaScript the browser requires: the service worker `apps/solidcraft-web/sw.js` (browsers only
run service workers as JavaScript) and the small inline scripts in `apps/solidcraft-web/index.html`.
Keep them minimal; `cargo xtask web-offline-test` covers the service worker.
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests,
assets, layers). Commit after every feature arc that builds.
- **Publishing**: `origin` is github.com/bherbruck/solidcraft. Changes land through **pull requests**:
push a branch, open a PR, and let GitHub CI run. Maintainers (and agents working for them) may
merge their own PR as soon as CI is green (`gh pr merge --squash`); a trivial fix (docs, typos)
may be merged right away. Keep each PR to one topic, rebase or merge `main` into it when it
conflicts, and never force-push `main`. Never commit `plan/`, build dirs (`target*/`) or
generated reference data; check `git ls-files` before pushing.
- **Never break wasm**: everything below the apps must build for `wasm32-unknown-unknown`
(`cargo xtask wasm`, part of `cargo xtask ci`).
## Showing and following through on changes
- Help reviewers see what is broken or changed. For observable CAD, UI or model behavior,
issues and PRs must include actual descriptive screenshots or a short recording from a
small reproduction. This includes kernel, geometry, assembly and backend changes whose
effects can be shown in the app. Coding agents should run the app and capture this evidence
themselves; do not ask a human for evidence the agent can produce. Show the actual failure
or result, preferably before and after, with brief reproduction steps, expected/actual
behavior and annotations that point out the relevant detail.
- Flowcharts and Mermaid diagrams cannot substitute for screenshots or recordings of visible
behavior. Use diagrams for architectural reasoning when actual screenshots cannot illustrate
the point. Changes with no useful observable behavior do not need screenshot or video evidence.
- Use a small public-safe example and check for private designs, paths, account details or
other sensitive content before sharing. Attach media to the issue/PR or link to an accessible
recording; do not commit generated screenshots or videos just to provide review evidence.
- Throughout the work and before handoff, check current upstream commits and the relevant
PR's merge status. Keep your branch current with upstream merges, resolve conflicts without
discarding others' work, and re-run validation when integration changes the result. Use
`git pull --rebase origin main` before pushing as above; never force-push. Report the checked
upstream/PR state, any conflicts resolved and the validation performed. If a PR remains open,
keep monitoring it while the task is active and make its pending status clear at handoff;
do not claim it merged without checking.
## Claim = a draft PR
- Before working on issue #N, check nobody has it: `gh pr list -R bherbruck/solidcraft --state open --search "#N"`.
- If none, branch from the latest `origin/main`, `git commit --allow-empty -m "claim: #N"`, push,
and open a **draft** PR titled `<summary> (#N)` right away. **The lowest-numbered open PR for an
issue wins**; if yours lost, close it. Opening the PR is the claim: the bot assigns you and
labels the issue `in progress` (or says on your PR who holds it). Don't claim by editing files.
- Push work in progress at least daily. A draft with no pushes for 72 hours may be taken over:
comment on it first, then open your own claim (you may start from its branch).
- On a shared build machine, check the load before a big build (`cut -d' ' -f1 /proc/loadavg`)
and wait while it is over 24; build with `nice`.
## Design file format
A change that saves something new in a design adds ONE file, `crates/doc/src/format/pending/<name>.rs`
(a `//!` note, and `pub fn step` if older designs must be rewritten). Never edit `FORMAT`,
`JSON_STEPS`, the history in `format.rs`, another change's file or a `v<N>/` folder: the build
works out the number (the next one while anything is pending), and the release folds pending
changes into `v<N>/` with `scripts/fold-format-pending.sh`. See CONTRIBUTING.md.
## Application releases
Development pushes are not application releases. Follow `docs/releasing.md` when asked to
prepare or ship a release: choose the version from the actual changes, update Cargo.toml and
Cargo.lock, pass the quality gates, advance `release` without force-pushing, and verify the
resulting draft and its artifacts. When shipping is authorized, publish that verified draft
and confirm its tag and downstream deployment. Do not auto-release every push to `main`, infer
version bumps solely from commit prefixes, overwrite published versions, or push release tags
before the draft is verified.
## Running and looking at the app
- `cargo run --release -p solidcraft -- --sample --control PORT` (sample design + control channel).
Pick a free port. Without a display, run it under `Xvfb :99` with `DISPLAY=:99`.
- Drive it with JSON lines on `127.0.0.1:PORT` (see `docs/control-protocol.md`). The first
request on a connection carries the launch's token as `"token"`: read it from
`~/.local/share/solidcraft/control/PORT.token`, or start the app with
`SOLIDCRAFT_CONTROL_TOKEN=$(openssl rand -hex 32)` and send that.
- `{"id":1,"method":"engine.execute","params":{"command":"solid.extrude","params":{"distance":20}}}`
- `{"id":2,"method":"ui.start","params":{"command":"sketch.create"}}` then `ui.click {x, y}`
- `{"id":3,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}` then read the PNG.
- **For UI work, look at the result** (screenshot, read the PNG). Without a window use
`ui.render` or `solidcraft-cli snapshot`.
- Headless: `solidcraft-cli run script.json --out part.step`, `solidcraft-cli eval design.solidcraft`,
`solidcraft-cli snapshot script.json --out shot.png`, `solidcraft-cli commands`.
- Reference tests: `cargo xtask oracle` replays `tests/oracle/*/recipe.json` and compares with
each case's `expected.json` (analytic or FreeCAD values; writes `docs/oracle.md`). Expected
values never come from a commercial CAD product.
- Build with `CARGO_TARGET_DIR=<repo>/target`. Windows: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solidcraft`.
## Roadmap
`ROADMAP.md` (committed) tracks status, milestones and estimates. Update it whenever a
milestone task lands. `docs/features.md` (SolidCraft's commands by area, from the registry) and
`THIRD-PARTY-LICENSES.txt` are regenerated on `main` after each merge by the Generated files
workflow; never regenerate them in a PR.
## Lists many PRs add to
Keep them conflict-free (CONTRIBUTING.md, "Lists many changes add to"): message catalogs sorted
(`cargo xtask lists --fix`), `mod` lines in their sorted group, the language list English first
then by id, generated files left to `main`, and a design-format change as one new file.
The agent instructions for this repo live in AGENTS.md; edit that file only.
+39 -15
View File
@@ -72,7 +72,7 @@ desktop integration and how to distinguish an old executable on `PATH`.
## How the code is laid out
The [README](README.md#architecture) has the layer table. The short version:
The [architecture page](docs/architecture.md) has the layer table. The short version:
- `crates/geom`: vectors, planes, meshes, measures.
- `crates/sketch`: our own constraint solver, plus profile finding.
@@ -183,25 +183,27 @@ depend on wall-clock timing.
**Ready pull requests test what they touch.** CI maps changed files to workspace packages and
runs Clippy, the test build and tests for those and their dependents. The
[package selector](scripts/ci/affected.py) and [CI workflow](.github/workflows/ci.yml) define
which changes require native checks; documentation, packaging and bot-script-only PRs can run
repository checks only. Workspace-wide changes, merge-queue groups, nightly runs and Run
workflow select the full workspace. A push to `main` reuses a successful merge-queue run for
the same commit; otherwise it runs the full suite.
which changes require native checks; documentation, packaging, other-workflow and bot-script-only
changes run the repository checks only, on pull requests and on `main`. Workspace-wide changes,
merge-queue groups, nightly runs and Run workflow select the full workspace. A push to `main`
reuses a successful merge-queue run for the same commit, and runs only the repository checks
when no code changed since the last push to `main` that passed; otherwise it runs the full
suite. Doc tests, reference tests and the Windows jobs run on `main` and nightly only.
**CI shards the tests.** One job builds them into a cargo-nextest archive; two shards run on
pull requests and four during full validation. Every test runs in its own process: don't rely
on state left by another test.
Pull requests skip a few heavy tests (one to four minutes each), which run in full validation
Pull requests skip a few heavy tests (one to twelve minutes each), which run in full validation
(the merge queue, nightly, manual runs and pushes to `main` not already tested by the queue).
Check that validation after merging. They are listed in
`.config/nextest.toml` and the [testing page](docs/book/src/dev/testing.md):
- `solidcraft-engine`: `tests::hostile_params_never_panic`, everything in `tests::robustness` and
the seeds of `tests::robustness_goldens` (`seed_00` … `seed_95`; pull requests matching the workflow's geometry paths run all seeds),
the seeds of `tests::robustness_goldens` (`seed_00` … `seed_95`; pull requests matching the workflow's geometry paths run all but the slowest, `SLOW_SEEDS` in `ci.yml`),
`pattern_suppress_tests::enclosure_arc_extinction_keeps_a_real_sharp_inner_lip_after_resize_and_reopen`,
`pattern_suppress_tests::enclosure_datums_follow_wall_height_and_board_depth_with_material_proofs`,
`cmd::plastic::tests::enclosure_snap_fit_follows_resized_front_wall`, the `atomic_commands` contract tests
- `solidcraft-kernel`: `tests::boolean_identity_on_tangent_primitives`, and the `boolean_props` property tests
- `solidcraft-kernel`: `tests::boolean_identity_on_tangent_primitives`, `tspline::tests::hostile_tsplines_never_panic`, and the `boolean_props` property tests
(these run only in the nightly "Property tests" job: their inputs are random, so main skips them too)
- `solidcraft-ui-egui` scenarios: `acceptance_sensor_enclosure`
@@ -285,6 +287,11 @@ and a fresh CI run:
2. Branch from `main` and keep the PR to one topic.
3. Run `cargo xtask ci` before pushing. It runs fmt, the asset, licence and layer checks,
clippy (`-D warnings`), then the tests with the wasm build check alongside. A PR has to pass it.
Heavy CI runs after review: a pull request gets the formatting and repository checks and a
quick compile (`cargo check`) when you push. Once a maintainer has reviewed it they add the
`approved-for-ci` label when the PR is ready to merge, which runs the full CI (build, tests,
goldens, Clippy); every push after that runs the full set again. Maintainers merge approved
PRs in groups by area, so PRs that touch the same code meet a conflict at most once.
4. Reference the issue (`Fixes #12`). In the PR, say what you tested and how. For geometry work, include the measured numbers (and
the expected ones).
@@ -309,12 +316,15 @@ Changes with no useful observable behavior do not need screenshot or video evide
small example without private designs or sensitive details; attach the media or link to an
accessible recording rather than committing generated review artifacts.
Keep checking upstream commits and the relevant PR's merge status throughout the work and
before handoff. Integrate upstream merges, resolve conflicts without losing others' changes,
and validate the integrated result. Before pushing, run `git pull --rebase origin main`, re-run
CI if it brought changes, and push without force. Report the upstream/PR state you checked,
any conflicts resolved and validation results. While a task remains active, follow an open
PR through its status changes; at handoff, clearly identify anything still awaiting merge.
Keep an eye on your PR's status, but don't push to it only to catch up with `main` (no merge of
`main`, no Update branch button, no rebase for that alone): every push restarts the whole CI
run for everyone waiting behind it, and `main`'s own run catches what lands in between. When you
push a change anyway, or GitHub reports a conflict, rebase onto the latest `main` (push your
branch with `--force-with-lease`) or merge `main` in once, and resolve conflicts without losing
others' changes. Don't re-run CI on an unchanged commit either: a real failure fails again.
Re-run only a job that runner trouble cancelled, with "Re-run failed jobs". While a task remains
active, follow an open PR through its status changes; at handoff, clearly identify anything
still awaiting merge.
Much of SolidCraft so far was written by AI coding agents (Claude) working in parallel, each
owning one area, with a human setting direction and reporting bugs. Their working notes are in
@@ -359,7 +369,21 @@ In order of how much they'd help users:
3. **Drawings, CAM and mesh tools.** These are out of scope for now (see
[ROADMAP.md](ROADMAP.md)), but anyone who wants to own one is welcome.
## Updating the generated feature list
After adding or renaming commands, run `cargo xtask features`. The registry is the
source of truth for `docs/features.md`; do not hand-edit the generated rows.
The generator sorts commands by ID and omits global and per-section counters,
so additions to unrelated areas no longer rewrite shared summary lines.
If two branches still conflict in this file (for example, adjacent additions in
the same area), first resolve the command registry and source conflicts, then run
`cargo xtask features` again and stage the regenerated file. Do not use Git's
`union` merge driver: it can silently retain stale or duplicate command rows.
The generator tests cover stable ordering and additions that leave existing lines
unchanged. The initial switch to sorted rows is a one-time larger diff.
## Licence
By contributing you agree that your contribution is licensed as MIT OR Apache-2.0, like the rest
of SolidCraft. SolidCraft isn't affiliated with Autodesk. Fusion is a trademark of Autodesk, Inc.
of SolidCraft. SolidCraft isn't affiliated with any CAD vendor; product names are trademarks of their owners (see NOTICE).
Generated
+110 -17
View File
@@ -813,6 +813,17 @@ dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "chardetng"
version = "0.1.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14b8f0b65b7b08ae3c8187e8d77174de20cb6777864c6b832d8ad365999cf1ea"
dependencies = [
"cfg-if",
"encoding_rs",
"memchr",
]
[[package]]
name = "chrono"
version = "0.4.45"
@@ -1164,6 +1175,12 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
[[package]]
name = "dyn-clone"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "ecolor"
version = "0.36.2"
@@ -3503,6 +3520,26 @@ dependencies = [
"bitflags 2.13.2",
]
[[package]]
name = "ref-cast"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3"
dependencies = [
"ref-cast-impl",
]
[[package]]
name = "ref-cast-impl"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "regex-syntax"
version = "0.8.11"
@@ -3730,6 +3767,31 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "schemars"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e"
dependencies = [
"dyn-clone",
"ref-cast",
"schemars_derive",
"serde",
"serde_json",
]
[[package]]
name = "schemars_derive"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1c3a92094fa7d61aa124645844facb6b554dfc797136d0f5fd1f890e2bffc69"
dependencies = [
"proc-macro2",
"quote",
"serde_derive_internals",
"syn 3.0.6",
]
[[package]]
name = "scoped-tls"
version = "1.0.1"
@@ -3849,6 +3911,17 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "serde_derive_internals"
version = "0.30.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "serde_json"
version = "1.0.151"
@@ -3862,6 +3935,17 @@ dependencies = [
"zmij",
]
[[package]]
name = "serde_path_to_error"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
dependencies = [
"itoa",
"serde",
"serde_core",
]
[[package]]
name = "serde_repr"
version = "0.1.21"
@@ -4078,7 +4162,7 @@ dependencies = [
[[package]]
name = "solidcraft"
version = "0.16.1"
version = "0.17.0"
dependencies = [
"block2 0.6.2",
"eframe",
@@ -4109,11 +4193,11 @@ dependencies = [
[[package]]
name = "solidcraft-3dx-client"
version = "0.16.1"
version = "0.17.0"
[[package]]
name = "solidcraft-cli"
version = "0.16.1"
version = "0.17.0"
dependencies = [
"image",
"serde_json",
@@ -4124,7 +4208,7 @@ dependencies = [
[[package]]
name = "solidcraft-derive"
version = "0.16.1"
version = "0.17.0"
dependencies = [
"proc-macro2",
"quote",
@@ -4133,7 +4217,7 @@ dependencies = [
[[package]]
name = "solidcraft-doc"
version = "0.16.1"
version = "0.17.0"
dependencies = [
"log",
"serde",
@@ -4150,11 +4234,13 @@ dependencies = [
[[package]]
name = "solidcraft-engine"
version = "0.16.1"
version = "0.17.0"
dependencies = [
"log",
"schemars",
"serde",
"serde_json",
"serde_path_to_error",
"solidcraft-doc",
"solidcraft-geom",
"solidcraft-io",
@@ -4167,7 +4253,7 @@ dependencies = [
[[package]]
name = "solidcraft-geom"
version = "0.16.1"
version = "0.17.0"
dependencies = [
"serde",
"serde_json",
@@ -4176,7 +4262,7 @@ dependencies = [
[[package]]
name = "solidcraft-gpu"
version = "0.16.1"
version = "0.17.0"
dependencies = [
"log",
"naga",
@@ -4193,12 +4279,15 @@ dependencies = [
[[package]]
name = "solidcraft-io"
version = "0.16.1"
version = "0.17.0"
dependencies = [
"cadmpeg-core",
"cadmpeg-ir",
"cadmpeg-registry",
"chardetng",
"encoding_rs",
"gltf",
"log",
"quick-xml",
"rustix 1.1.5",
"serde",
@@ -4213,7 +4302,7 @@ dependencies = [
[[package]]
name = "solidcraft-kernel"
version = "0.16.1"
version = "0.17.0"
dependencies = [
"log",
"proptest",
@@ -4233,7 +4322,7 @@ dependencies = [
[[package]]
name = "solidcraft-mcp"
version = "0.16.1"
version = "0.17.0"
dependencies = [
"serde_json",
"solidcraft-engine",
@@ -4242,7 +4331,7 @@ dependencies = [
[[package]]
name = "solidcraft-render"
version = "0.16.1"
version = "0.17.0"
dependencies = [
"image",
"moxcms",
@@ -4256,7 +4345,7 @@ dependencies = [
[[package]]
name = "solidcraft-sketch"
version = "0.16.1"
version = "0.17.0"
dependencies = [
"serde",
"serde_json",
@@ -4267,11 +4356,14 @@ dependencies = [
[[package]]
name = "solidcraft-space-mouse"
version = "0.16.1"
version = "0.17.0"
dependencies = [
"serde",
]
[[package]]
name = "solidcraft-ui-egui"
version = "0.16.1"
version = "0.17.0"
dependencies = [
"arboard",
"egui",
@@ -4286,6 +4378,7 @@ dependencies = [
"sha2 0.10.9",
"solidcraft-engine",
"solidcraft-gpu",
"solidcraft-space-mouse",
"tiny-skia",
"toml",
"ttf-parser",
@@ -4296,7 +4389,7 @@ dependencies = [
[[package]]
name = "solidcraft-web"
version = "0.16.1"
version = "0.17.0"
dependencies = [
"eframe",
"egui",
@@ -5826,7 +5919,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.16.1"
version = "0.17.0"
dependencies = [
"serde_json",
"sha2 0.10.9",
+12 -1
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
exclude = ["vendor"]
[workspace.package]
version = "0.16.1"
version = "0.17.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -36,6 +36,9 @@ proc-macro2 = "1"
quote = "1"
syn = { version = "2", features = ["full"] }
serde_json = { version = "1", features = ["float_roundtrip"] }
# command parameter and result schemas
schemars = "1.2"
serde_path_to_error = "0.1"
log = "0.4"
sha2 = "0.10"
moxcms = { version = "0.8.1", default-features = false, features = ["extended_range"] }
@@ -55,6 +58,11 @@ rayon = "1.12"
# packages (3MF)
zip = { version = "9", default-features = false, features = ["deflate"] }
quick-xml = "0.41"
# STEP text in a legacy encoding (GBK, Shift_JIS, …) instead of Part 21 escapes (#2344): detect
# and decode it. Both MIT OR Apache-2.0 (encoding_rs adds BSD-3-Clause data); encoding_rs is
# already in the tree through cadmpeg-codec-step.
chardetng = "0.1"
encoding_rs = "0.8"
# glTF 2.0 import (MIT OR Apache-2.0): the document model and GLB container only; buffers are
# read by crates/io itself, so no image decoding or file access comes with it.
gltf = { version = "1.4", default-features = false, features = ["names"] }
@@ -123,6 +131,9 @@ incremental = false
[profile.ci.package."*"]
opt-level = 2
# No line tables for dependencies: they were over a third of the test archive every CI shard
# downloads. Our own crates keep theirs, so a backtrace still names file:line in them.
debug = false
[profile.release]
opt-level = 3
+5 -2
View File
@@ -29,5 +29,8 @@ for sketch text, BIZ UDPGothic for web Japanese display) are licensed under SIL
per-file list is in ATTRIBUTION.md.
SolidCraft is an independent project and is not affiliated with, sponsored by or endorsed by
Autodesk, Inc. Autodesk and Fusion are trademarks of Autodesk, Inc.; they are used here only to
describe compatibility goals.
Autodesk, Dassault Systèmes, PTC, Siemens, Blender or any other CAD vendor. Fusion, AutoCAD,
Inventor, Alias and Tinkercad are trademarks of Autodesk, Inc.; SolidWorks is a trademark of
Dassault Systèmes; Onshape and Creo are trademarks of PTC; Blender is a trademark of the Blender
Foundation. Product names appear only to describe compatibility, such as the mouse navigation
presets in Preferences › Navigation and file formats from public specifications.
+76 -267
View File
@@ -26,6 +26,7 @@
<a href="https://solidcraft.io/download">Download</a> ·
<a href="https://solidcraft.io/app">Try it in the browser</a> ·
<a href="https://docs.solidcraft.io/">Documentation</a> ·
<a href="https://www.blacksmith.sh"><img src="https://img.shields.io/badge/CI-Blacksmith-000000?logo=githubactions&logoColor=white" alt="CI powered by Blacksmith" /></a>
<a href="https://discord.gg/FtU4NQNVDH">Discord</a> ·
<a href="https://github.com/bherbruck/solidcore">SolidCore</a>
</p>
@@ -46,30 +47,85 @@
## Download
- **Windows:** download and run the [installer][windows-msi].
Also for [ARM64][windows-arm64-msi] and [32-bit][windows-x86-msi] PCs, or as a
[portable zip][windows-portable] that runs without installing.
With [WinGet](https://learn.microsoft.com/windows/package-manager/winget/):
`winget install bherbruck.SolidCraft`.
- **macOS:** open the [disk image][macos-dmg] and drag SolidCraft into Applications
(Apple silicon and Intel).
- **Linux:** download the [AppImage][linux-appimage], allow it to run as a program and open it.
Or install the [.deb][linux-deb] (Ubuntu, Debian, Mint) or [.rpm][linux-rpm] (Fedora,
openSUSE), or use [Flatpak](https://docs.solidcraft.io/guide/getting-started.html#linux-flatpak).
With Snap: `sudo snap install solidcraft` ([Snap Store][snap-store], or the [.snap file][linux-snap]).
For ARM64: [AppImage][linux-arm64-appimage], [.deb][linux-arm64-deb], [.rpm][linux-arm64-rpm].
- **Windows:** the [installer][windows-msi], or `winget install bherbruck.SolidCraft`.
- **macOS:** the [disk image][macos-dmg] (Apple silicon and Intel).
- **Linux:** the [AppImage][linux-appimage], or `sudo snap install solidcraft` ([Snap Store][snap-store]).
- **Web:** [try it in your browser][web-app], no install.
The downloads aren't signed yet. On Windows, if SmartScreen warns you, choose **More info**, then
**Run anyway**. On macOS, the first time, right-click SolidCraft in Applications and choose **Open**.
ARM64 and 32-bit builds, portable zip, .deb, .rpm, Flatpak and the other packages are on the
[downloads page](docs/downloads.md). The downloads aren't signed yet: on Windows choose
**More info**, then **Run anyway** in SmartScreen; on macOS right-click SolidCraft and choose **Open**
the first time. On SolidCraft 0.11 to 0.14, download once by hand; see
[updating](https://docs.solidcraft.io/guide/updates.html#updating-from-011-to-014).
**On SolidCraft 0.11 to 0.14?** Its **Update now** can't install 0.15.0 or later, because the
releases moved to a new address. Download the new version once from the links above; after that,
updating from inside SolidCraft works again ([details](https://docs.solidcraft.io/guide/updates.html#updating-from-011-to-014)).
## What works
Older versions, checksums and other formats are on the [releases page](https://github.com/tangency-labs/solidcraft/releases).
Install details, upgrading from earlier builds and building from source are in
[Getting started](https://docs.solidcraft.io/guide/getting-started.html).
Early but usable for simple parts: 75 of 76 public reference parts match their analytic or
FreeCAD-computed volume, area, centre of mass and bounding box ([oracle](docs/oracle.md)); the
other is a tracked bug.
- **Sketches and constraints:** lines, arcs, splines, text and more, solved by our own constraint solver, with parameter-driven dimensions.
- **Features:** extrude, revolve, sweep, loft, fillet, chamfer, shell, draft, holes, threads, patterns, mirror, split and combine on an editable parametric timeline.
- **Parameters and configurations** with unit-aware expressions.
- **Assemblies** with joints, motion studies, contact sets, interference and mass properties.
- **Sheet metal** with flanges, hems, fold/unfold and flat patterns, and plastic enclosure features.
- **Files:** STEP, IGES, 3MF, STL, OBJ and glTF in and out where supported, URDF export, `.solidcraft` designs.
- **Desktop app, browser app, headless CLI and MCP server**, all running the same commands.
The full list is in [docs/capabilities.md](docs/capabilities.md) and [docs/features.md](docs/features.md);
status and plans are in [ROADMAP.md](ROADMAP.md). See also [what people built](docs/showcase.md),
[3D mouse support](docs/3d-mouse.md) and the [architecture](docs/architecture.md).
## Build from source
```sh
cargo run --release -p solidcraft -- --sample # desktop app with a sample part
cargo run --release -p solidcraft-cli -- commands # the command registry
cargo run --release -p solidcraft-cli -- run examples/bracket.json --out bracket.step
cargo xtask ci # fmt, clippy, tests, icons, assets, layers, wasm
```
More commands, the Windows cross-build and the browser build are in [docs/building.md](docs/building.md).
## AI agents (MCP)
`solidcraft-cli mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) server. Headless it
runs its own design session, or with `--connect` it drives the running app so you can watch.
Claude Code: `claude mcp add solidcraft -- solidcraft-cli mcp`. Tools, access rules and an
example session: [docs/mcp.md](docs/mcp.md).
## Scripts
A script is a JSON list of the same commands the UI runs, executed with `solidcraft-cli run`.
Format and example: [docs/scripts.md](docs/scripts.md). Pages can also be driven through the
[browser command API](docs/browser-api.md).
## Contributing
Help is welcome. [CONTRIBUTING.md](CONTRIBUTING.md) covers how to build and test, the code map,
the clean-room and licence rules, and where help is wanted most. Open work is in
[GitHub issues](https://github.com/tangency-labs/solidcraft/issues); chat is on
[Discord](https://discord.gg/FtU4NQNVDH).
## Sponsors
[![CI powered by Blacksmith](docs/sponsors/blacksmith-ci-powered.png)](https://www.blacksmith.sh)
SolidCraft's continuous integration runs on [Blacksmith](https://www.blacksmith.sh), which
sponsors its CI minutes.
## Licence
MIT OR Apache-2.0, at your option. Developed under the
[clean-room contribution rules](AGENTS.md); third-party code and assets retain their
documented licences and attribution.
SolidCraft is independent. Behaviour is designed from public documentation, general CAD
conventions and user feedback. Reference tests use analytic values and open-source tools.
SolidCraft is not affiliated with, sponsored by or endorsed by Autodesk, Dassault Systèmes, PTC,
Siemens or any other CAD vendor. Fusion, AutoCAD, Inventor, SolidWorks, Onshape, Creo, Blender and
Tinkercad are trademarks of their respective owners, named only to describe compatibility
(see [NOTICE](NOTICE)).
[badge-windows]: https://img.shields.io/badge/Windows-Download-0078D6?style=for-the-badge&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0id2hpdGUiPjxwYXRoIGQ9Ik0yIDJoOS41djkuNUgyek0xMi41IDJIMjJ2OS41aC05LjV6TTIgMTIuNWg5LjVWMjJIMnpNMTIuNSAxMi41SDIyVjIyaC05LjV6Ii8%2BPC9zdmc%2B
[badge-macos]: https://img.shields.io/badge/macOS-Download-333333?style=for-the-badge&logo=apple&logoColor=white
@@ -77,254 +133,7 @@ Install details, upgrading from earlier builds and building from source are in
[badge-snap]: https://img.shields.io/badge/Snap-Snap_Store-82BEA0?style=for-the-badge&logo=snapcraft&logoColor=white
[badge-web]: https://img.shields.io/badge/Web-Try_in_browser-2EA44F?style=for-the-badge&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSJ3aGl0ZSIgc3Ryb2tlLXdpZHRoPSIyIj48Y2lyY2xlIGN4PSIxMiIgY3k9IjEyIiByPSIxMCIvPjxwYXRoIGQ9Ik0yIDEyaDIwTTEyIDJhMTUgMTUgMCAwIDEgMCAyME0xMiAyYTE1IDE1IDAgMCAwIDAgMjAiLz48L3N2Zz4%3D
[windows-msi]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-windows-x64.msi
[windows-arm64-msi]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-windows-arm64.msi
[windows-x86-msi]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-windows-x86.msi
[windows-portable]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-windows-x64-portable.zip
[macos-dmg]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-macos-universal.dmg
[linux-appimage]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-linux-x86_64.AppImage
[linux-deb]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-linux-x86_64.deb
[linux-rpm]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-linux-x86_64.rpm
[linux-arm64-appimage]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-linux-aarch64.AppImage
[linux-arm64-deb]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-linux-aarch64.deb
[linux-arm64-rpm]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-linux-aarch64.rpm
[linux-snap]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-linux-x86_64.snap
[snap-store]: https://snapcraft.io/solidcraft
[web-app]: https://solidcraft.io/app
**Documentation:** the [SolidCraft book](https://docs.solidcraft.io/) (user guide,
developer guide and command reference; sources in [docs/book](docs/book)).
**Community:** chat with users and contributors on [Discord](https://discord.gg/FtU4NQNVDH).
**Status: early but usable for simple parts.** 75 of 76 public reference parts
match their analytic or FreeCAD-computed volume, area, centre of mass and bounding box
([docs/oracle.md](docs/oracle.md)); the other is a tracked bug.
These checks do not establish complete geometric or workflow equivalence. The commands SolidCraft has
are listed by area in [docs/features.md](docs/features.md); status and plans are in [ROADMAP.md](ROADMAP.md).
Open work and known bugs are in [GitHub issues](https://github.com/tangency-labs/solidcraft/issues),
and help is welcome: see [CONTRIBUTING.md](CONTRIBUTING.md).
## Built with SolidCraft
<a href="https://github.com/0xlillith/solidcraft-rover"><img src="docs/screenshots/rover-build.gif" alt="SELENE, a scientific rover by 0xlillith, assembling itself as the timeline plays through its 3,323 steps in SolidCraft: tub, wheels, electronics, suspension, sampling arm, solar wings, dish and camera turret" loading="lazy"></a>
*SELENE, a scientific rover built entirely through SolidCraft's MCP server by
[@0xlillith](https://github.com/0xlillith/solidcraft-rover): 3,323 editable timeline steps that
rebuild with no errors, replayed here at 2× speed.
[Full video](https://github.com/tangency-labs/solidcraft/raw/refs/heads/main/docs/screenshots/rover-build.mp4)
· [download the editable model](https://github.com/0xlillith/solidcraft-rover/releases/tag/v1.0.0).*
## What works
- **Current-view DXF export** from File → Export Current View as DXF, with hidden edges
omitted or dashed, shown body placements and the current camera. See [view DXF](docs/view-dxf.md).
- **Sketches** on origin planes, offset planes or planar faces: lines, rectangles, circles,
arcs, polygons, slots, ellipses, fit/control splines, conics and text; construction geometry,
projection, trimming, fillets and chamfers, including corners at the origin.
New face attachments retain supported construction identities and chosen occurrences;
missing sources expose an explicit plane repair workflow. See [face attachments](docs/face-attachments.md).
Explicitly closed fit splines and closed associative offsets use periodic C2 interpolation.
Supported convex line-and-arc offsets retain their identity when an inset consumes corner
arcs, and linked extrusions follow the current boundary. Breaking the association freezes
the boundary and keeps existing extrusions usable.
Curve drawing and picking adapt together to zoom and display scale, including small circles
at Fit and sketches in placed components.
- **Constraints and dimensions** solved by our own solver. Points the constraints fix relative
to known geometry are placed exactly by construction (loci intersections and rigid-cluster
decomposition, after Owen 1991 and Fudos–Hoffmann 1997); Levenberg–Marquardt solves the rest,
and the rank of its Jacobian gives the remaining degrees of freedom. Constraints: coincident,
point on curve, horizontal/vertical, parallel, perpendicular, tangent, curvature (G2), equal,
concentric, collinear, midpoint, symmetry, fix; linear, aligned, horizontal, vertical,
point-to-line, radius, diameter, angle, arc sweep, arc length and slot length dimensions driven
by parameters. Over-constraining edits are rejected; fully constrained geometry turns black.
Dragging solves the sketch every frame and rebuilds the model once, when the drag ends.
Select a line or its two endpoints, type its dimension and press Enter to commit one undo
step; with Sketch Dimension it's D, click, type, Enter. Fit keeps sketch geometry clear of the palette and navigation controls.
- **Profiles** found automatically, including regions split by lines and holes inside outlines.
Newly captured complete adaptive boundaries retain their outer and hole source identities
when spline span counts change. Partial or ambiguous boundaries do not qualify.
- **Features** on a parametric timeline: extrude, revolve, sweep, loft, fillet/chamfer,
shell, draft, holes, threads, patterns, mirror, split, combine, move and construction geometry.
The timeline rebuilds incrementally; features can be edited, suppressed, renamed, reordered
and deleted. Supported geometry and current limitations are described in [ROADMAP.md](ROADMAP.md).
New revolves preserve symmetric and two-sided angular placement across reversed axes;
older designs retain their saved convention when reopened or edited.
- **Parameters and configurations** with unit-aware expressions (`2 * width + 5 mm`,
`angle / 2`), ordered rules, feature suppression and per-row body materials/appearances.
- **Assemblies** with nested components and occurrences, joints, motion studies, contact sets,
interference and material-aware mass properties. Recorded body transfers preserve modelling
history and world placement; 3D sketch wires retain their coordinate frames through edits.
- **Sheet metal** with flanges, hems, fold/unfold/refold, material cutouts, flat patterns,
retained shallow prismatic pockets, exact line/arc/circle DXF outlines and bend tables;
plastic enclosure features and rules. Pocket depth and side survive unfold/refold and appear
separately from through-hole outlines in the flat preview and DXF.
- **Files**: design containers (`.solidcraft`) with lossless binary mesh payloads;
plain JSON remains supported for opening, Save As and export. Desktop Open can show a saved
provisional picture from warm geometry caches while rebuilding before editing; caches never
replace the parametric design. Export to STEP (AP242 with body names, colours and
components as assemblies; revolved faces as native planes, cylinders, cones, spheres and tori),
IGES 5.3 (manifold solid B-reps with names and colours), 3MF, STL (binary/ASCII) and OBJ, and
assemblies with joints as URDF for robot simulators (docs/urdf.md);
3MF, STL, OBJ and glTF 2.0 (`.glb`/`.gltf`, its node tree as components and its materials as
appearances) import as mesh bodies (they render, measure, move and export; solid features
need B-rep bodies). The `mesh.convert` command reconstructs supported closed constant-section parts
as parametric solids with planar and circular cylindrical faces;
STEP import (AP203/AP214/AP242 solids, assemblies, units, names, colours) and IGES import
(manifold solid B-reps, or trimmed and bounded surfaces sewn into solids) as a base feature
that later features build on — open a `.step`/`.stp`/`.igs`/`.iges` file, insert one into a
design, or drop it on the window ([docs/step-import.md](docs/step-import.md)); FreeCAD
`.FCStd` and Rhino `.3dm` solids import the same way through
[cadmpeg](https://github.com/cadmpeg/cadmpeg) in the desktop app and CLI.
- **Render:** opaque surfaces can use metal fraction, roughness and dielectric IOR in the
integrated product viewport and CPU preview PNGs. Existing simple colors remain supported.
Fixed direct lighting is a preview; HDRI, refraction and texture maps are not implemented.
See [opaque surfaces](docs/render-opaque-pbr.md).
- **Display meshes** from our own tessellation planner: each face becomes a plan of (u, v)
points and triangles that shares its edge nodes with its neighbours, so the mesh is watertight.
Facets follow the chord tolerance, the facet angle and a longest side, with cells at most 2:1
along straight directions. Box-bounded and four-cornered faces mesh as structured grids.
Trimmed faces are cut into four-sided patches: squares around holes, butterflies in round
faces, motorcycle traces from concave corners (after Lyon et al. 2019). Each patch then meshes
as a Coons grid. Intersection edges are evaluated by Newton on both surfaces. A compute shader
evaluates the plans for the whole scene in one dispatch, with a CPU fallback when the GPU
can't build it. Planning and evaluating the 78 reference bodies takes about 65 ms, 50–65×
faster than the previous mesher.
- **Desktop app** (egui + wgpu): toolbar with workspace tabs, browser, timeline, 3D viewport with
axis view and navigation bar, sketch tools, feature dialogs, command palette, and a JSON-lines
control channel so agents and tests can drive it.
Feature errors and Help can open an editable **Create Issue** draft with relevant context
and an optional reviewed screenshot; opening GitHub does not submit the report.
- **Headless CLI** `solidcraft-cli`: run command scripts, measure (volume, area, centre of mass,
face/edge/vertex counts), export, render PNG snapshots and replay reference recipes.
- **MCP server** `solidcraft-cli mcp`: AI agents model, measure and look at parts headless or
in the running app (see below).
## Build and run
```sh
cargo run --release -p solidcraft -- --sample # desktop app with a sample part
cargo run --release -p solidcraft-cli -- commands # the command registry
cargo run --release -p solidcraft-cli -- run examples/bracket.json --out bracket.step
cargo run --release -p solidcraft-cli -- snapshot examples/bracket.json --out bracket.png
cargo run --release -p solidcraft-cli -- render examples/render-studio.json --out product.png
cargo run --release -p solidcraft -- part.step # open a STEP file as a new design
cargo run --release -p solidcraft-cli -- eval part.step # measure an imported STEP file
cargo xtask ci # fmt, clippy, tests, icons, assets, layers, wasm
```
UI artwork changes follow the [icon specification](assets/icons/ui/STYLE_GUIDE.md).
The [included review tool](assets/icons/ui/README.md) discovers new icons and keeps
unaccepted artwork Open; run `cargo xtask icons` to check the collection.
Windows builds: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solidcraft`.
On Windows, macOS and Linux, SolidCraft detects a connected 3D mouse through its HID multi-axis
controller interface: a 3Dconnexion SpaceNavigator or SpaceMouse, or a homemade device that
declares itself one. Move the cap to pan and zoom, and tilt or twist it to rotate the 3D view.
While editing a sketch, the cap pans and zooms without changing the sketch view. The Navigation
page in Preferences has an on/off switch, a speed control and, for each of the cap's six axes, an
invert switch and a speed for the 3D mouse. The web app reads
it too, through the browser; Firefox needs a 3DxWare setting first (see
[Getting started › 3D mouse in the browser](docs/book/src/guide/getting-started.md#3d-mouse-in-the-browser)).
With 3DxWare on Windows, SolidCraft follows 3DxWare's profile for it when there is one, and
3DxWare's object mode (tilt to roll, twist to spin) until there is. The mouse-wheel turns that
3DxWare makes from the cap's motion are ignored, so the view doesn't zoom in steps as the cap tilts.
On Linux SolidCraft reads the 3D mouse through `spacenavd` when that daemon runs (its socket
`/var/run/spnav.sock`), so what you set in `spnavcfg` (swapped or inverted axes, sensitivity, dead
zones) applies as it does in other programs, and no udev rule is needed. With nothing set there,
the motion is the same as without the daemon. Without `spacenavd`, SolidCraft reads `/dev/hidrawN`
directly, so the logged-in user needs access to the device. A udev rule that adds the `uaccess`
tag does that, for example
`/etc/udev/rules.d/70-3d-mouse.rules` for USB devices with 3Dconnexion's vendor id, and for its
Bluetooth ones (where `idVendor` isn't set, so the HID id is matched instead):
```
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="256f", TAG+="uaccess"
SUBSYSTEM=="hidraw", KERNELS=="0005:256F:*", TAG+="uaccess"
```
Older SpaceNavigators use Logitech's vendor id `046d`, so add a rule with their product id (see
`lsusb`) instead of matching every Logitech device. A homemade device needs a rule with its own
vendor and product id. Reload with `sudo udevadm control --reload && sudo udevadm trigger`, then
replug the device. The Flatpak sandbox does not expose hidraw devices, so the 3D mouse only works
with native builds there for now.
SolidCraft finds the axes from the device's report descriptor, so it doesn't matter how a device
lays them out: translation and rotation in separate reports (3Dconnexion's wired devices), all six
in one (its wireless ones and most homemade devices), after the buttons, or narrower than 16 bits.
Each axis is scaled from the range the descriptor declares.
On macOS SolidCraft talks to the 3Dconnexion driver (3DxWare) when it is installed, through the
driver's client API (`crates/threedx-client`; the framework is loaded at run time, never bundled).
The driver keeps the device to itself, and sends it only to a program it can identify: SolidCraft
as an `.app` (the packaged app). Without the driver, or with its helper not running, SolidCraft reads the device through IOKit like on Windows.
SolidCraft does not follow 3DxWare's profile on macOS. Its default is 3DxWare's object mode.
In the browser: `cd apps/solidcraft-web && trunk build --release --public-url ./` (WebGPU, or
WebGL2 with `?webgl`; `?sample` opens the sample part).
Agents can control the document open in a browser tab through `window.solidcraft.execute(...)`
and `window.solidcraft.request(...)`; see the [browser command API](docs/browser-api.md).
## Scripts
A script is a JSON list of commands, the same ones the UI runs:
```json
{"commands": [
{"command": "sketch.create", "params": {"plane": "XY"}},
{"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [40, 30]}},
{"command": "solid.extrude", "params": {"distance": 20}},
{"command": "solid.fillet", "params": {"edges": [[0, 0, 10]], "radius": 3}}
]}
```
## AI agents (MCP)
`solidcraft-cli mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) server on
stdio. Headless it runs its own design session; with `--connect 127.0.0.1:PORT` it drives the app
started with `solidcraft --control PORT`, so you can watch the agent model. Tools:
`list_commands`, `execute`, `batch`, `inspect_design`, `measure`, `body_topology`,
`set_parameter`, `screenshot` (returns the image), `export`, `crash_reports`, `undo`, `redo`,
`new_design`, `open`, `save`. Tool calls reach files only in the open design's folder (when it lies inside your home
folder, never home itself or a hidden folder), SolidCraft's recovery folder and folders given with
`--allow-path DIR`. Claude Code: `claude mcp add solidcraft -- solidcraft-cli mcp`. Details, client
configuration and an example session: [docs/mcp.md](docs/mcp.md).
## Architecture
<!-- ANCHOR: architecture -->
| Layer | Crates | Role |
|---|---|---|
| L0 | `geom` | vectors, planes, profiles, meshes and their measures |
| L1 | `sketch`, `kernel`, `render` | constraint solver and profiles; the B-rep kernel boundary (truck); view math and the headless scene description |
| L2 | `doc` | parameters, expressions, the feature timeline and its incremental evaluation |
| L3 | `io` | design files, STL/OBJ/STEP/3MF export, STEP and 3MF/STL/OBJ/glTF import features |
| L4 | `engine` | the session and the command registry (everything is a command) |
| L5 | `gpu`, `ui-egui`, `mcp` | compute-shader evaluation of display tessellation plans; the swappable desktop front end; the MCP server (headless or bridged to the app) |
| apps | `solidcraft`, `solidcraft-cli` | desktop app, headless CLI |
<!-- ANCHOR_END: architecture -->
## Contributing
Coming from Autodesk Fusion? SolidCraft uses familiar parametric CAD workflows: constrained
sketches, solid features, an editable timeline, parameters and assemblies. See the
[roadmap](ROADMAP.md) for supported operations and current limitations.
See [CONTRIBUTING.md](CONTRIBUTING.md): how to build and test, the code map, the clean-room
and licence rules, and where help is wanted most.
## Licence
MIT OR Apache-2.0, at your option. Developed under the
[clean-room contribution rules](CLAUDE.md); third-party code and assets retain their
documented licences and attribution.
SolidCraft is independent. Behaviour is designed from public documentation, general CAD
conventions and user feedback. Reference tests use analytic values and open-source tools.
SolidCraft is not affiliated with, sponsored by or endorsed by Autodesk. Autodesk and
Fusion are trademarks of Autodesk, Inc.
+26 -8
View File
@@ -14,6 +14,10 @@ features → parametric timeline) as an open, pure-Rust, agent-drivable applicat
| Gates | `cargo xtask ci`: fmt, clippy -D warnings, tests, asset attribution, layering, wasm32 build — green |
| Time to a useful alpha (M0–M6) | ≈ **82 agent hours** remain (sum of the M1–M6 estimates below) |
Optional GPU viewport ambient occlusion is available in View settings and Graphics preferences,
with persistent settings, Performance/Quality graphics presets (Quality combines AO, Fine tessellation and adaptive 2× supersampling; manual combinations show Custom), and the
`view.ambient_occlusion` and `view.graphics_preset` commands. See [display settings](docs/ambient-occlusion.md).
## Scope (owner decision, 2026-10-07)
The issue batch adds preservation of blind pockets in through cuts, associative projected
@@ -48,14 +52,14 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
| M0 | Vertical slice | done | — |
| M1 | Sketch depth: command coverage implemented — projection/intersect/include as linked geometry (auto-project on faces, lost-reference warnings, break link), full SKETCH create/modify set (slots, polygons, tangent arcs/circles, ellipse, fit/control splines, conic, text, trim/extend/break, fillet/chamfer, offset, mirror, patterns, move/scale, blend curve, centerline), constraints incl. curvature and polygon with over-constraint refusal, driven/arc-length/linear-diameter dimensions, AutoConstrain, DXF/SVG insert, 3D sketch curves (Project To Surface, Intersection Curve, Isoparametric Curve, Spun Profile), exact free-form profiles (B-spline/NURBS edges, any spline degree, curves cut by crossings), sweeps and pipes along 3D sketch curves, canvases and decals, curvature combs. Exact free-form Extend and certified associative offsets now preserve geometry and IDs through edits, deletion and reopen. Occurrence-frame 3D wires now store explicit coordinate spaces and retain points and analytic fits through moves, refresh, undo and reopen. Constructive sketch solving (Owen 1991, Fudos & Hoffmann 1997) ahead of the numerical solver: points and rigid clusters are placed on the branch the sketch had, so dimension edits and drags keep their solution; edits that would turn a sketch over are refused; drags re-solve only the sketch per frame. Remaining workflow repairs: mixed offset junctions, legacy natural-spline seams, and damaged legacy wire links lacking frame provenance | in progress | 4 |
| M2 | Robust modelling: coplanar booleans in the kernel, holes (placed on faces and on sketch points), threads (cosmetic, then modelled), patterns, mirror, shell, draft, split, construction geometry | in progress | 16 |
| M3 | General fillets and chamfers (curved edges, chains, vertex blends); saddle rolling-ball surfaces now use continuous seam sections and arc-length fitting with shared contact boundaries and bounded accuracy checks | in progress | 30 |
| M3 | General fillets and chamfers (curved edges, chains, vertex blends); saddle rolling-ball surfaces now use continuous seam sections and arc-length fitting with shared contact boundaries and bounded accuracy checks; tiny rim edges remain selected instead of nearby wall seams when filleting (#2498) | in progress | 30 |
| M4 | Files: STEP import (done — [docs/step-import.md](docs/step-import.md)), FreeCAD/Rhino solids through cadmpeg (done, desktop and CLI), 3MF export and 3MF/STL import as mesh bodies (done), 3MF with B-rep face tags ([docs/formats/3mf-face-tags.md](docs/formats/3mf-face-tags.md), done), constant-section parametric mesh reconstruction, DXF sketches, OBJ import | in progress | 6 |
| M5 | Persistent naming of faces and edges (docs/book/src/dev/naming.md): faces named from their exact kernel-reported history (sketch curve sides, caps, revolve sections, primitive sides, blends, shell inner walls, pattern/mirror copies; split pieces told apart by the faces around them, lumps of a cut body named after their faces), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. sheet metal edges by base sketch line and flange side, shell inner faces, sweep and loft roles: done. kernel-reported face history: done (merged faces answer to every name; operations without history yet fall back to geometric names, flagged unverified; format 14 keeps older designs' geometric names). Left: history through sheet metal, plastic, surface and emboss features; FaceSource certificates through history | in progress | 20 |
| M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, dependency-checked single and multi-item reorder, suppress, rename, delete with dependents, re-resolved references; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names; optional compact named left feature history with rollback bar, multi-selection and persistent folders above the parts browser), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator, extrude taper), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearance controls now preserve body/face/component targeting, named transparent looks, reuse/search and override removal; preferences | in progress | 6 |
| M7 | Thin extrude (walls along open or closed curves: centre, inside, outside or two sides; mitred corners, crossing walls joined; #721), sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces and exact radial emboss/deboss of bounded orthogonal profiles on complete exterior cylinders/sleeves), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Unique eligible cylindrical targets are inferred for new Emboss features. Left: general curved-face emboss and exact (unfaceted) modelled threads | in progress | 12 |
| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops filled G0, plus `crown`), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane or a face), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis, Surface Extrude/Revolve/Sweep/Loft from open and closed curves, Offset, Unstitch and Reverse Normal; the continuity-driven core (`kernel/src/bridge.rs`, G0–G3 Hermite rows) with **Blend** (`surface.blend`, #764), **Match** (`surface.match`), **Boundary** (`surface.boundary`, #763 — two sketch curves exact with G0–G3, three or four as the flat patch of their loop, and curve networks: Coons and Gordon surfaces through sketch, 3D-sketch or edge curves, a point for a three-sided side, G1/G2 against the faces along one direction's edges, joined into their body on request; G1/G2 on two sides that meet at a corner waits for the Gregory-type corner), **Fair** (`surface.fair`, CV fairing), **continuity analysis** (`inspect.continuity`, #770; a toolbar tool with edge markers and a legend, and curvature combs along face isocurves) and a multi-section **loft** with per-end continuity and crown. Also in the Class-A pass: G1…G4 blend curves in the sketch, chord and variable-radius fillets on curved edges (with honest refusals where the fit cannot hold), and a G2 corner patch where filleted edges meet (kernel; the command refuses until the patch's sections can be sewn to the blends' contacts). Left: bias/form-factor fillet sections (design recorded), a Gregory-type corner, surface Fillet between separate surface bodies (#769), Ruled (#762), Untrim (#761), curved-face Extend (#760), loft guide rails (#201; sweep guide rails and two-rail sweeps are done, #771), and every remaining #757–#772 item | deferred | — |
| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops filled G0, plus `crown`), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane, a face or sketch curves swept onto the surface, keeping a side or the picked regions; mutual trim of two surfaces, extended to meet and stitched, #767), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis, Surface Extrude/Revolve/Sweep/Loft from open and closed curves, Offset, Unstitch and Reverse Normal; the continuity-driven core (`kernel/src/bridge.rs`, G0–G3 Hermite rows) with **Blend** (`surface.blend`, #764: edge chains along one or several faces, closed rims paired seam to seam or from picked seams, a dialog), **Match** (`surface.match`), **Boundary** (`surface.boundary`, #763 — two sketch curves exact with G0–G3, three or four as the flat patch of their loop, and curve networks: Coons and Gordon surfaces through sketch, 3D-sketch or edge curves, a point for a three-sided side, G1/G2 against the faces along one direction's edges, joined into their body on request; G1/G2 on two sides that meet at a corner waits for the Gregory-type corner), **Fair** (`surface.fair`, CV fairing), **continuity analysis** (`inspect.continuity`, #770; a toolbar tool with edge markers and a legend, and curvature combs along face isocurves) and a multi-section **loft** with per-end continuity and crown, and guide rails on solid and surface lofts (one or two, scaling or stretching the sections between; #201). Also in the Class-A pass: G1…G4 blend curves in the sketch, chord and variable-radius fillets on curved edges (with honest refusals where the fit cannot hold), and a G2 corner patch where filleted edges meet (kernel; the command refuses until the patch's sections can be sewn to the blends' contacts). Left: bias/form-factor fillet sections (design recorded), a Gregory-type corner, surface Fillet between separate surface bodies (#769), Ruled (#762), Untrim (#761), curved-face Extend (#760), and every remaining #757–#772 item | deferred | — |
| M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, closed kinematic loops (four-bar, slider-crank: the loop's other joints follow the driven one on its branch, and driving stops where a loop can no longer close), cams (`joint.cam`: a revolute cam drives a slider or revolute follower along a closed sketch profile or the cam's edges, knife, roller or flat followers, worked out again when the profile changes; drives, studies, closed loops, contact sets), As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), motion analysis (joint values and a point's path charted over a study as value, velocity and acceleration; the parts that collide along a study, with the steps where they do), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure; URDF export for robot simulators (links with meshes, mass and inertia; joint chains whose values match Drive Joints; docs/urdf.md). Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link, Cam and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 |
| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done. PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line); flanges meeting at a corner closed at any angle with the rule's gap; edge flanges with their own angle and height per edge in one feature; corner reliefs (notch, round, rectangular, tear) chosen by the rule; Convert to Sheet Metal from a solid or shelled body by its bend edges (flat faces, sharp bends) | in progress | 36 |
| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done. PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line); flanges meeting at a corner closed at any angle with the rule's gap; edge flanges with their own angle and height per edge in one feature; corner reliefs (notch, round, rectangular, tear) chosen by the rule; Convert to Sheet Metal from a solid or shelled body by its bend edges (flat faces, sharp bends) | in progress | 36 |
| M11 | 2D drawings | deferred | — |
| M12 | Release: cross-platform packages, verification and deliberate publication implemented; original current-UI demo refreshed. Trusted distribution signing and broader performance work remain | in progress | 16 |
| M14 | Configurations: a table of rows (parameter expressions and feature suppressions per row) switched with config.activate (undoable, all or nothing), kept through parameter renames and feature deletes (config.configure, config.table, config.column/row/activate). Per-row body materials and appearances (library looks, custom colour/opacity and clearing overrides), undoable activation and save/reopen: done. Ordered typed configuration rules, atomic validation and structured rule editing: done. Component appearance and certified semantic face style columns, atomic preview/apply and independent-copy remapping: done. Exact constructed sketch-side and immutable imported-face lineage now preserve legacy names and reject retargeting. Left: ambiguous shifted/two-sided caps | in progress | 4 |
@@ -85,6 +89,9 @@ completed drawings publish atomically. The browser still exports synchronously.
## Interactive modelling
- Modelled threads preview their cut before OK using the background worker on desktop.
Browser users apply the cut with OK to avoid blocking on every edit.
- Thread resolves a suggested metric size when a cylindrical face is picked. Diameter and
pitch selectors expose the existing metric designation parameter; custom designations stay
editable, and Automatic size returns to the face's suggestion. Cosmetic/modelled and
@@ -94,6 +101,8 @@ completed drawings publish atomically. The browser still exports synchronously.
drag continues, then catch up to the exact final value. Changes to the selected geometry,
operation or feature still discard obsolete results. Distance arrows and their value boxes
follow the current frame's input.
- Large fine modelled threads use pitch-based helix resolution and tighter faceted
reconstruction fallbacks, preserving thin crests and closed shells on large shafts.
## Physical material data
@@ -220,6 +229,9 @@ completed drawings publish atomically. The browser still exports synchronously.
Type a value and press Enter to commit one undo step; rejected values remain editable and
cancellation leaves the sketch unchanged. Fit keeps geometry clear of the Sketch Palette
and navigation controls, including enlarged displays and nested pending placements.
- Sketch Dimension takes a line and a circle or arc (#1803): it measures from the line to the
circle's edge (the gap, 0 when tangent, or how far the edge reaches past a line that cuts
it), drives it like any other dimension and keeps the circle on its side of the line.
- Sketch annotations, live drawing lengths and Measure use document units. Inches show
three decimals and retain a meaningful fourth digit. Bare length dimension entry captures
the current design unit; changing units leaves existing geometry and expressions intact.
@@ -338,13 +350,16 @@ completed drawings publish atomically. The browser still exports synchronously.
- Associative shell-face sketches require a unique certified planar source. Nested shell
attachments and ambiguous coincident sources remain unsupported.
- G2 fillets: exact quintic blends of straight edges between planar faces, with one perpendicular
planar end face at each end. The UI and `solid.fillet` expose `curvature` (alias `g2`); the size
is an equivalent circular setback. Curved faces/edges, sloped ends and corner junctions remain unsupported.
- G2 fillets: exact quintic blends of straight edges between planar faces, with one planar
end face at each end, including sloped ends (#2708). The UI and `solid.fillet` expose
`curvature` (alias `g2`); the size is an equivalent circular setback. Curved faces/edges,
multi-face ends and corner junctions remain unsupported.
- Fillets and chamfers: constant, variable-radius and chord-length fillets; a radius per edge,
rounds next to earlier rounds of another radius, and smooth corner patches where three rounds
meet (a sphere for equal radii); perpendicular corner fillets after a closed ring of earlier
rounds preserve the ring's radii and the enclosed planar face (#1557);
mitres between separate rounds of the same radius keep their endpoint tangent, so a later
fillet can select an upright and its adjoining bottom mitre as one chain (#2422);
straight edges (convex or concave) between planar faces with perpendicular
planar end faces (equal-distance chamfers also meet sloped planar end faces, including inner
basin rims within the rim width); convex fillets also meet sloped planar ends and reflex
@@ -453,10 +468,13 @@ completed drawings publish atomically. The browser still exports synchronously.
and author occurrence paths retain the chosen placements; missing/split faces refuse rather
than retarget. General free-form profiles, seams, stepped cylinders and other curved surfaces
remain unsupported. STEP round-trip and independent radial/volume checks cover this subset.
- Shell and offset faces work on bodies of planes, cylinders, cones and spheres (convex or not:
- Shell and offset faces work on bodies of planes, cylinders, cones, spheres and tori (convex or not:
filleted boxes, bosses, domes, countersinks; corners where four or more planes meet, such as a
floor falling to a drain, as long as the corner's edges are all convex or all concave); draft on convex planar bodies and on walls (planes, cylinders → cones) between caps; lofts through three or more sections are smooth (B-spline sides through every section), two
sections ruled.
sections ruled. Circular-meridian barrels retain curved inner walls through either axial
opening (#2421). Solid lofts preserve holes through sections with matching hole counts
(#2435), including inner walls and caps with openings; a hole cannot appear or disappear
between sections, and sections with holes cannot collapse to a point.
- Sketch corner tools: fillet and all three chamfer modes pick a connected corner even when
its endpoint overlaps the fixed origin or a loose point. Ordinary drawing still snaps to
+103 -52
View File
@@ -1,6 +1,6 @@
Third-party software in SolidCraft
SolidCraft (the desktop app, solidcraft-cli and the web app) is built from the 552 crates below.
SolidCraft (the desktop app, solidcraft-cli and the web app) is built from the 560 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.
@@ -75,6 +75,7 @@ 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)
cgmath 0.18.0 (Apache-2.0)
chardetng 0.1.17 (Apache-2.0 OR MIT)
chrono 0.4.45 (MIT OR Apache-2.0)
cipher 0.4.4 (MIT OR Apache-2.0)
clipboard-win 5.4.1 (BSL-1.0)
@@ -113,6 +114,7 @@ dlib 0.5.3 (MIT)
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)
@@ -331,6 +333,8 @@ read-fonts 0.41.0 (MIT OR Apache-2.0)
redox_syscall 0.4.1 (MIT)
redox_syscall 0.5.18 (MIT)
redox_syscall 0.9.4 (MIT)
ref-cast 1.0.27 (MIT OR Apache-2.0)
ref-cast-impl 1.0.27 (MIT OR Apache-2.0)
renderdoc-sys 1.1.0 (MIT OR Apache-2.0)
rfd 0.17.2 (MIT)
ring 0.17.14 (Apache-2.0 AND ISC)
@@ -349,6 +353,8 @@ rustls-platform-verifier-android 0.2.0 (MIT OR Apache-2.0)
rustls-webpki 0.103.15 (ISC)
rustversion 1.0.23 (MIT OR Apache-2.0)
schannel 0.1.29 (MIT)
schemars 1.2.3 (MIT)
schemars_derive 1.2.3 (MIT)
scoped-tls 1.0.1 (MIT/Apache-2.0)
scopeguard 1.2.0 (MIT OR Apache-2.0)
secret-service 4.0.0 (MIT OR Apache-2.0)
@@ -361,7 +367,9 @@ 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)
serde_derive 1.0.229 (MIT OR Apache-2.0)
serde_derive_internals 0.30.0 (MIT OR Apache-2.0)
serde_json 1.0.151 (MIT OR Apache-2.0)
serde_path_to_error 0.1.20 (MIT OR Apache-2.0)
serde_repr 0.1.21 (MIT OR Apache-2.0)
serde_spanned 1.1.2 (MIT OR Apache-2.0)
sha1 0.10.7 (MIT OR Apache-2.0)
@@ -578,19 +586,21 @@ OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
adler2 2.0.1, allocator-api2 0.2.21, async-channel 2.5.0, async-executor 1.14.0, async-fs 2.2.0,
async-io 2.6.0, async-lock 3.4.2, async-process 2.5.0, async-recursion 1.2.0, async-signal 0.2.14,
async-task 4.7.1, async-trait 0.1.92, atomic-waker 1.1.2, blocking 1.7.0, concurrent-queue 2.5.0,
displaydoc 0.2.7, endi 1.1.1, enumn 0.1.14, event-listener 5.4.2, event-listener-strategy 0.5.4,
fastrand 2.5.0, futures-lite 2.6.1, glow 0.17.0, hermit-abi 0.5.3, itoa 1.0.18, khronos-egl 6.0.0,
lazy_static 1.5.1, linux-raw-sys 0.12.1, linux-raw-sys 0.4.15, num_enum 0.7.6, num_enum_derive
0.7.6, once_cell 1.21.4, ordered-stream 0.2.0, parking 2.2.1, 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,
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_json 1.0.151, serde_repr 0.1.21,
simd_cesu8 1.2.0, smol_str 0.2.2, syn 1.0.109, 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.12.3, 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
displaydoc 0.2.7, dyn-clone 1.0.20, endi 1.1.1, enumn 0.1.14, event-listener 5.4.2,
event-listener-strategy 0.5.4, fastrand 2.5.0, futures-lite 2.6.1, glow 0.17.0, hermit-abi 0.5.3,
itoa 1.0.18, khronos-egl 6.0.0, lazy_static 1.5.1, linux-raw-sys 0.12.1, linux-raw-sys 0.4.15,
num_enum 0.7.6, num_enum_derive 0.7.6, once_cell 1.21.4, ordered-stream 0.2.0, parking 2.2.1,
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 1.0.109, 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.12.3, 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
@@ -1323,12 +1333,13 @@ See the License for the specific language governing permissions and
limitations under the License.
================================================================================
allocator-api2 0.2.21, async-trait 0.1.92, enumn 0.1.14, glifo 0.2.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, 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_json 1.0.151, serde_repr 0.1.21, 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.12.3, typeid 1.0.3, unicode-ident 1.0.26,
utf8-zero 0.8.1
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,
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
1.0.151, serde_path_to_error 0.1.20, serde_repr 0.1.21, 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.12.3, typeid 1.0.3,
unicode-ident 1.0.26, utf8-zero 0.8.1
================================================================================
Apache License
@@ -1821,8 +1832,8 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
================================================================================
approx 0.4.0, as-raw-xcb-connection 1.0.1, atspi 0.29.0, atspi-common 0.13.0, atspi-proxies 0.13.0,
cgmath 0.18.0, codespan-reporting 0.13.1, encoding_rs 0.8.42, kurbo 0.13.1, multiversion_no_op
1.0.0, nohash-hasher 0.2.0, peniko 0.6.1, polycool 0.4.0, raw-window-metal 1.1.0,
cgmath 0.18.0, chardetng 0.1.17, codespan-reporting 0.13.1, encoding_rs 0.8.42, kurbo 0.13.1,
multiversion_no_op 1.0.0, nohash-hasher 0.2.0, peniko 0.6.1, polycool 0.4.0, raw-window-metal 1.1.0,
rustls-platform-verifier 0.7.1, self_cell 1.3.0, siphasher 1.0.4, spade 2.15.1, static_assertions
1.1.0, ureq 3.4.2, ureq-proto 0.6.4, utf8_iter 1.0.4, x11rb 0.13.2, x11rb-protocol 0.13.2, zeroize
1.9.1, zeroize_derive 1.5.0, zune-core 0.5.3, zune-jpeg 0.5.15
@@ -3324,6 +3335,50 @@ http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
option. All files in the project carrying such notice may not be
copied, modified, or distributed except according to those terms.
================================================================================
chardetng 0.1.17
================================================================================
chardetng is copyright Mozilla Foundation.
Licensed under the Apache License, Version 2.0
<LICENSE-APACHE or
https://www.apache.org/licenses/LICENSE-2.0> or the MIT
license <LICENSE-MIT or https://opensource.org/licenses/MIT>,
at your option. All files in the project carrying such
notice may not be copied, modified, or distributed except
according to those terms.
================================================================================
chardetng 0.1.17, encoding_rs 0.8.42, multiversion_no_op 1.0.0, utf8_iter 1.0.4
================================================================================
Copyright Mozilla Foundation
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.
================================================================================
chrono 0.4.45
================================================================================
@@ -5010,36 +5065,6 @@ source code, are licensed under the BSD 3-Clause License
Test code within encoding_rs is dedicated to the Public Domain when so
designated (see the individual files for PD/CC0-dedicated sections).
================================================================================
encoding_rs 0.8.42, multiversion_no_op 1.0.0, utf8_iter 1.0.4
================================================================================
Copyright Mozilla Foundation
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.
================================================================================
enumflags2 0.7.12
================================================================================
@@ -11768,6 +11793,32 @@ The above copyright notice and this permission notice shall be included in all c
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.
================================================================================
schemars 1.2.3, schemars_derive 1.2.3
================================================================================
MIT License
Copyright (c) 2019 Graham Esau
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.
================================================================================
scopeguard 1.2.0
================================================================================
+107 -18
View File
@@ -4,14 +4,16 @@
//! solidcraft-cli run <script.json|design.solidcraft|part.step|mesh.3mf> [--in design|part.step|mesh.3mf] [--out FILE]... [--save FILE] [--quiet] [--results] [--timings] [--profile] [--json-out FILE] [--allow-errors]
//! solidcraft-cli eval <script.json|design.solidcraft|part.step> [--json-out FILE] (alias: inspect) measurements as JSON
//! solidcraft-cli script <design.solidcraft> [--out script.json] the design's history as a command script
//! solidcraft-cli bench-open <design.solidcraft | part.step> [--parse-only] [--display LEVEL [--edit NAME EXPR]] loading timings as JSON lines
//! solidcraft-cli bench-open <design.solidcraft | part.step> [--parse-only] [--save OUT.solidcraft] [--display LEVEL [--edit NAME EXPR]] loading timings as JSON lines
//! solidcraft-cli bench-agent [--scale F] [--save F] [--script-out F] a ~1,300-body design built command by command, timed
//! solidcraft-cli snapshot <script|design> [--in design|part.step] --out shot.png [--width W] [--height H] [--view iso|front|top|…|NAME | --camera JSON] [--no-grid] [--no-axes]
//! [--body NAME:opacity=0.3,color=#rrggbb]... [--tool NAME]... [--section z=27]
//! solidcraft-cli exec <command> [json-params] run one command on an empty design
//! solidcraft-cli commands the command registry as JSON
//! solidcraft-cli recipe <recipe.json> translate an oracle recipe to a script
//! solidcraft-cli oracle <case-dir>... replay recipes and compare with expected.json
//! solidcraft-cli mcp [--in design|script] [--connect HOST:PORT [--token T]] [--allow-path DIR]... [--allow-any-path]
//! [--param-check off|warn|strict]
//! MCP server on stdio (docs/mcp.md)
//! solidcraft-cli licences SolidCraft's licence and the third-party licences
//! solidcraft-cli icon --out F.png|F.ico [--size N] the app icon (packaging; drawn, not a file)
@@ -23,6 +25,7 @@ mod bench_agent;
mod crashes;
mod fuzz;
mod oracle;
mod preview;
mod recipe;
mod render;
mod spatial;
@@ -47,14 +50,21 @@ const USAGE: &str = "usage:
as plain UTF-8: a shell's > can re-encode it, e.g. Windows PowerShell adds a byte-order mark)
solidcraft-cli script <design.solidcraft|script.json> [--out script.json]
(the design's history as a command script that eval and run replay)
solidcraft-cli bench-open <design.solidcraft | part.step> [--parse-only] [--display LEVEL [--edit NAME EXPR]]
solidcraft-cli bench-open <design.solidcraft | part.step> [--parse-only] [--save OUT.solidcraft] [--display LEVEL [--edit NAME EXPR]]
solidcraft-cli bench-agent [--scale F] [--save design.solidcraft] [--script-out script.json]
solidcraft-cli snapshot <script|design> [--in design.solidcraft|part.step] --out shot.png [--width W] [--height H] [--view NAME | --camera JSON] [--no-grid] [--no-axes]
[--body NAME:opacity=0.3,color=#rrggbb]... [--tool NAME]... [--section z=27]
solidcraft-cli snapshot --in <design.solidcraft|part.step> --out shot.png ...
(as with run, --in starts from a design and the script runs on top of it; an option snapshot doesn't take is an error;
--view: iso, front, back, top, bottom, left, right, or the name of a view saved in the design with view.save;
--camera: {target: [x,y,z], yaw, pitch, distance, fov?} in mm and radians, as the MCP screenshot takes it (docs/mcp.md);
--no-grid and --no-axes leave the ground grid (on Z 0) and the origin's axes out of the picture)
--no-grid and --no-axes leave the ground grid (on Z 0) and the origin's axes out of the picture;
--body: how one body is drawn, as appearance.assign takes it: opacity 0-1 (below 1 is see-through, so what is inside
it shows) and color #rrggbb, either or both, once per body;
--tool: draw this body solid in the tool colour and every other body see-through, so a cut's or a join's tool body
is visible inside the material it meets; a later --body overrides it;
--section: cut the model as inspect.section does, so the inside shows: z=27 (the plane through that world
coordinate, keeping the smaller side), add ,flip to keep the other side, or XY, XZ, YZ, or its own JSON)
solidcraft-cli render <script|design> --out image.png [--setup NAME] [--width W] [--height H] [--transparent] [--overwrite] [--timeout SECONDS]
solidcraft-cli exec <command> [json-params]
solidcraft-cli commands
@@ -62,6 +72,7 @@ const USAGE: &str = "usage:
solidcraft-cli oracle <case-dir>... [--json]
solidcraft-cli fuzz [--from N] [--count N] [--jobs J] [--timeout S] [--steps N] [--minimise] [--out DIR] [--report FILE]
solidcraft-cli mcp [--in design.solidcraft|script.json] [--connect 127.0.0.1:PORT [--token T]] [--allow-path DIR]... [--allow-any-path]
[--param-check off|warn|strict]
solidcraft-cli crashes [list [--json] | show <id> | replay <id> | dir | clear [<id>]]
solidcraft-cli licences
solidcraft-cli gpu [--policy auto|hardware|software|cpu]
@@ -145,7 +156,14 @@ pub fn read_json(path: &str) -> Result<Value, String> {
/// A session from a design file, a STEP/3MF/STL file, a command script, or an oracle recipe.
pub fn load(path: &str) -> Result<Session, String> {
load_checked(path, solidcraft_engine::schema::ParamCheck::Off)
}
/// `load`, with scripts and recipes run at the parameter-check level `check`, which the
/// session keeps.
fn load_checked(path: &str, check: solidcraft_engine::schema::ParamCheck) -> Result<Session, String> {
let mut s = Session::default();
s.param_check = check;
if solidcraft_engine::io::is_step_path(path) || solidcraft_engine::io::is_mesh_path(path) {
s.execute("doc.open", &json!({"path": path})).map_err(|e| e.to_string())?;
return Ok(s);
@@ -155,16 +173,22 @@ pub fn load(path: &str) -> Result<Session, String> {
let doc = solidcraft_engine::io::read_design(&bytes).map_err(|e| format!("{path}: {e}"))?;
let mut session = Session::new(doc);
session.path = Some(path.to_owned());
session.param_check = check;
return Ok(session);
}
let v: Value = serde_json::from_slice(&bytes).map_err(|e| format!("{path}: {e}"))?;
if v.get("format").and_then(Value::as_str).is_some_and(|f| solidcraft_engine::doc::format::version_of(f).is_some()) {
s.execute("doc.open", &json!({"path": path})).map_err(|e| e.to_string())?;
} else if v.get("features").is_some() && v.get("commands").is_none() {
let script = recipe::to_script(&v)?;
s.run_script(&script).map_err(|e| e.to_string())?;
} else {
s.run_script(&v).map_err(|e| e.to_string())?;
let script = if v.get("features").is_some() && v.get("commands").is_none() { recipe::to_script(&v)? } else { v };
// Warn level: each step's `param_warnings` as it completes, even when a later step fails
// (stderr, so stdout stays the caller's).
let mut warn = |step: usize, r: &Value| {
for w in r.get("param_warnings").and_then(Value::as_array).into_iter().flatten() {
eprintln!("{path}: step {}: {}", step + 1, w.as_str().unwrap_or_default());
}
};
s.run_script_each(&script, &mut warn).map_err(|e| e.to_string())?;
}
Ok(s)
}
@@ -405,7 +429,12 @@ fn install_renderer() {
fn cmd_snapshot(args: &[String]) -> Result<(), String> {
install_renderer();
known_flags("snapshot", args, &["--in", "--out", "--width", "--height", "--view", "--camera"], &["--no-grid", "--no-axes"])?;
known_flags(
"snapshot",
args,
&["--in", "--out", "--width", "--height", "--view", "--camera", "--body", "--tool", "--section"],
&["--no-grid", "--no-axes"],
)?;
if positional(args).is_none() && flag(args, "--in").is_none() {
return Err(USAGE.to_string());
}
@@ -416,6 +445,9 @@ fn cmd_snapshot(args: &[String]) -> Result<(), String> {
};
let (w, h) = (size("--width", 1280)?, size("--height", 800)?);
let mut s = open(flag(args, "--in"), positional(args))?;
// How the bodies are drawn and where the model is cut, through the commands the app's
// Appearance dialog and Section Analysis run (`preview`).
preview::apply(&mut s, &flags(args, "--body"), &flags(args, "--tool"), flag(args, "--section"))?;
// The camera choices of the MCP `screenshot`, through the same command (`view.apply`): a
// standard view, a view saved in the design, or an explicit camera. A script that applies a
// view itself sets the camera too; without any, the iso view framing the model.
@@ -519,6 +551,17 @@ fn cmd_mcp(args: &[String]) -> Result<(), String> {
eprintln!("solidcraft mcp: the design's folder is left out because {why}; add it with --allow-path if you mean it");
}
}
// How a headless session treats calls that break a command's parameter contract (docs/mcp.md).
let param_check = match flag(args, "--param-check") {
None if args.iter().any(|a| a == "--param-check") => return Err("mcp: --param-check wants off, warn or strict".into()),
None => solidcraft_engine::schema::ParamCheck::Off,
Some(v) => {
solidcraft_engine::schema::ParamCheck::parse(v).ok_or_else(|| format!("mcp: --param-check wants off, warn or strict, not `{v}`"))?
}
};
if param_check != solidcraft_engine::schema::ParamCheck::Off && flag(args, "--connect").is_some() {
eprintln!("solidcraft mcp: --param-check applies to headless sessions; calls to the app are not checked yet");
}
let backend: Box<dyn solidcraft_mcp::Backend> = match (flag(args, "--connect"), flag(args, "--in")) {
(Some(_), Some(_)) => return Err("mcp: use either --connect or --in, not both".into()),
(Some(addr), None) => {
@@ -535,8 +578,12 @@ fn cmd_mcp(args: &[String]) -> Result<(), String> {
}
Box::new(remote)
}
(None, Some(path)) => Box::new(solidcraft_mcp::Headless::new(load(path)?)),
(None, None) => Box::new(solidcraft_mcp::Headless::default()),
(None, Some(path)) => Box::new(solidcraft_mcp::Headless::new(load_checked(path, param_check)?)),
(None, None) => {
let mut headless = solidcraft_mcp::Headless::default();
headless.session.param_check = param_check;
Box::new(headless)
}
};
let stdin = std::io::stdin();
let stdout = std::io::stdout();
@@ -607,37 +654,79 @@ pub(crate) fn cmd_bench_open(args: &[String]) -> Result<(), String> {
let t = std::time::Instant::now();
// The file as read, held until the timeline has evaluated it (as the import command does).
let mut read = None;
let doc = if step {
let (doc, saved) = if step {
let imported = solidcraft_engine::io::step_import_feature(&bytes, path).map_err(|e| e.to_string())?;
let mut doc = solidcraft_engine::doc::Document::new(&imported.name);
doc.add_feature(imported.kind, Some(&imported.name)).map_err(|e| e.to_string())?;
read = Some(imported.import);
doc
(doc, None)
} else {
solidcraft_engine::io::read_design(&bytes).map_err(|e| e.to_string())?
// As Open reads it: with the model the file keeps.
solidcraft_engine::io::read_design_file(path, None::<fn(solidcraft_engine::io::DesignPreview)>).map_err(|e| e.to_string())?
};
let parse_ms = t.elapsed().as_secs_f64() * 1000.0;
println!("{}", json!({"step": "parse", "ms": parse_ms, "features": doc.features.len()}));
println!("{}", json!({"step": "parse", "ms": parse_ms, "features": doc.features.len(), "saved_model": saved.is_some()}));
if args.iter().any(|a| a == "--parse-only") {
return Ok(());
}
let t = std::time::Instant::now();
let s = Session::new(doc);
let s = Session::opened(doc, saved.as_ref());
drop(read);
println!("{}", json!({"step": "evaluate", "ms": t.elapsed().as_secs_f64() * 1000.0}));
// Taken from the file: nothing evaluated (SOLIDCRAFT_NO_MODEL_CACHE evaluates anyway).
let adopted = saved.is_some() && s.model.last_recomputed == 0;
println!("{}", json!({"step": "evaluate", "ms": t.elapsed().as_secs_f64() * 1000.0, "adopted": adopted}));
for r in &s.model.results {
println!("{}", json!({"step": "feature", "id": r.id, "name": r.name, "ms": r.ms, "error": r.error}));
}
let t = std::time::Instant::now();
let st = s.world_state();
let place_ms = t.elapsed().as_secs_f64() * 1000.0;
// As the desktop app prepares a design it opened.
st.warm_meshes(|| true);
let triangles: usize = st.bodies.iter().map(|b| b.mesh().triangles.len()).sum();
println!("{}", json!({"step": "mesh", "ms": t.elapsed().as_secs_f64() * 1000.0, "bodies": st.bodies.len(), "triangles": triangles}));
println!(
"{}",
json!({"step": "mesh", "ms": t.elapsed().as_secs_f64() * 1000.0, "place_ms": place_ms, "bodies": st.bodies.len(), "triangles": triangles})
);
let flag = |name: &str| args.iter().position(|a| a == name);
// `--save PATH`: save the opened design without its model cache (to PATH.plain.solidcraft) and
// as the app does, with it, then read the cache back.
let s = match flag("--save").and_then(|i| args.get(i + 1)) {
Some(out) => {
let mut s = s;
// Without the cache first, for comparison.
let plain = format!("{out}.plain.solidcraft");
let t = std::time::Instant::now();
s.execute("file.save_as", &json!({"path": plain, "cache": false})).map_err(|e| e.to_string())?;
let plain_ms = t.elapsed().as_secs_f64() * 1000.0;
let plain_bytes = std::fs::metadata(&plain).map(|m| m.len()).unwrap_or(0);
let t = std::time::Instant::now();
s.execute("file.save_as", &json!({"path": out})).map_err(|e| e.to_string())?;
let save_ms = t.elapsed().as_secs_f64() * 1000.0;
let bytes = std::fs::read(out).map_err(|e| format!("{out}: {e}"))?;
let t = std::time::Instant::now();
let cached = solidcraft_engine::io::read_design_geometry(&bytes).map(|m| m.bodies.len());
let read_ms = t.elapsed().as_secs_f64() * 1000.0;
// Why bodies could not be kept exactly, when the cache was left out.
let mut refused = std::collections::BTreeMap::<String, usize>::new();
if cached.is_none() {
for b in &s.model.state().bodies {
if let Err(e) = solidcraft_engine::kernel::encode_native_body(&b.body) {
*refused.entry(e.to_string()).or_default() += 1;
}
}
}
println!(
"{}",
json!({"step": "save", "ms": save_ms, "bytes": bytes.len(), "plain_ms": plain_ms, "plain_bytes": plain_bytes, "cached_bodies": cached, "cache_read_ms": read_ms, "refused": refused})
);
s
}
None => s,
};
// `--display LEVEL`: the display plans the viewport draws at that display quality, made as
// the GPU viewport makes them; `--edit NAME EXPR` then changes a parameter and times the
// evaluation and the plans of the bodies it changed.
let flag = |name: &str| args.iter().position(|a| a == name);
let Some(level) = flag("--display").and_then(|i| args.get(i + 1)) else { return Ok(()) };
let q = solidcraft_engine::doc::display_quality::DisplayQuality::parse(level).ok_or("--display: coarse, normal, fine or ultra")?;
let plans = |st: &solidcraft_engine::doc::ModelState| {
+286
View File
@@ -0,0 +1,286 @@
//! What a `snapshot` draws see-through, what it draws in a colour of its own, and where it cuts
//! the model: the `--body`, `--tool` and `--section` options.
//!
//! None of this is a renderer of its own. Each option turns into the command the app itself runs
//! — `appearance.assign` for the Appearance dialog, `inspect.section` for Section Analysis — so
//! a picture of a tool body inside a translucent target is the picture the window would show
//! with the same appearances assigned, and a script can do the same thing without the options.
use serde_json::{Value, json};
use solidcraft_engine::Session;
/// How see-through the bodies around a `--tool` are drawn: enough to read the tool through them,
/// solid enough to keep the shape of the material it meets.
pub const TARGET_OPACITY: f64 = 0.3;
/// A `--tool` body's colour: the hue the viewport's feature preview uses for removed material,
/// so a cut's tool looks in a picture the way the preview looks in the window.
pub const TOOL_COLOR: &str = "#e8542c";
/// The colour of a body with no appearance of its own ([`solidcraft_engine::view::colors::BODY`]),
/// as `appearance.assign` takes it. Giving a body an opacity means giving it a look, and a look
/// carries a colour: this keeps the one it is already drawn in.
fn default_body_hex() -> String {
let c = solidcraft_engine::view::colors::BODY;
format!("#{:02x}{:02x}{:02x}", c.0, c.1, c.2)
}
/// How one body is drawn: `--body NAME:opacity=0.3,color=#cc3333`. Either key alone is enough;
/// the other keeps what the design already says.
#[derive(Debug, PartialEq)]
pub struct BodyLook {
pub name: String,
pub opacity: Option<f64>,
pub color: Option<String>,
}
/// `NAME:key=value[,key=value]`, or `NAME` on its own (which says nothing and is an error:
/// a body is named here to change how it is drawn).
pub fn parse_body(spec: &str) -> Result<BodyLook, String> {
let (name, rest) = spec
.split_once(':')
.ok_or_else(|| format!("--body `{spec}` must be NAME:key=value (`Target:opacity=0.3`, `Tool:color=#cc3333`, keys separated by commas)"))?;
let name = name.trim();
if name.is_empty() {
return Err(format!("--body `{spec}` names no body"));
}
let mut look = BodyLook { name: name.to_owned(), opacity: None, color: None };
for part in rest.split(',') {
let part = part.trim();
if part.is_empty() {
continue;
}
let (key, value) = part.split_once('=').ok_or_else(|| format!("--body {name}: `{part}` must be key=value (opacity, color)"))?;
let value = value.trim();
match key.trim().to_ascii_lowercase().as_str() {
"opacity" => {
let o: f64 = value.parse().map_err(|_| format!("--body {name}: opacity must be a number 0 to 1, not `{value}`"))?;
if !o.is_finite() || !(0.0..=1.0).contains(&o) {
return Err(format!("--body {name}: opacity must be 0 to 1, not `{value}`"));
}
look.opacity = Some(o);
}
"color" => {
let digits = value.strip_prefix('#').filter(|h| h.len() == 6 && h.chars().all(|c| c.is_ascii_hexdigit()));
let digits = digits.ok_or_else(|| format!("--body {name}: color must be #rrggbb, not `{value}`"))?;
look.color = Some(format!("#{}", digits.to_ascii_lowercase()));
}
other => return Err(format!("--body {name}: unknown key `{other}` (opacity, color)")),
}
}
if look.opacity.is_none() && look.color.is_none() {
return Err(format!("--body `{spec}` asks for nothing: give opacity, color, or both"));
}
Ok(look)
}
/// The `inspect.section` parameters a `--section` spec asks for: `z=27` (the plane through that
/// world coordinate, its normal along +Z), `XY` (a named plane through the origin), either with
/// `,flip` to keep the other side, or the command's own JSON object.
pub fn parse_section(spec: &str) -> Result<Value, String> {
let spec = spec.trim();
if spec.starts_with('{') {
return serde_json::from_str(spec).map_err(|e| format!("--section: not JSON: {e}"));
}
let mut parts = spec.split(',').map(str::trim);
let plane = parts.next().unwrap_or_default();
let mut flip = false;
for p in parts {
match p.to_ascii_lowercase().as_str() {
"" => {}
"flip" => flip = true,
other => return Err(format!("--section: unknown `{other}` (only `flip` follows the plane)")),
}
}
// A named plane through the world origin, as `inspect.section` takes it.
let mut params = match plane.to_ascii_uppercase().as_str() {
p @ ("XY" | "XZ" | "YZ") => json!({"plane": p}),
_ => {
// AXIS=VALUE: the plane whose normal is that axis, cutting at that world coordinate.
let (axis, at) = plane.split_once('=').ok_or_else(|| {
format!("--section `{spec}` must be z=27 (a world coordinate on an axis), XY, XZ, YZ, or the JSON inspect.section takes")
})?;
let at: f64 = at.trim().parse().map_err(|_| format!("--section {axis}: `{}` is not a number", at.trim()))?;
if !at.is_finite() {
return Err(format!("--section {axis}: `{at}` is not a finite coordinate"));
}
// The plane normal to each axis: Z is XY, Y is XZ, X is YZ.
let named = match axis.trim().to_ascii_lowercase().as_str() {
"x" => "YZ",
"y" => "XZ",
"z" => "XY",
other => return Err(format!("--section: unknown axis `{other}` (x, y or z)")),
};
json!({"plane": named, "at": at})
}
};
if flip {
params["flip"] = json!(true);
}
Ok(params)
}
/// The design's body names, for an error that says which ones there are.
fn body_names(s: &Session) -> Vec<String> {
s.model.state().bodies.iter().map(|b| b.name.clone()).collect()
}
/// The colour and opacity a body is drawn with now, as `appearance.assign` takes them: its own
/// appearance, else its component's or material's, else the default body colour.
fn current_look(s: &mut Session, name: &str) -> Result<(String, f64), String> {
let reply = s.execute("appearance.list", &json!({"body": name})).map_err(|e| e.to_string())?;
let body = reply.get("body");
let look = body.and_then(|b| b.get("appearance")).filter(|a| !a.is_null());
let hex = look
.and_then(|a| a.get("color"))
.and_then(Value::as_str)
.map(str::to_owned)
// No appearance, but an imported colour: `body.color` is that colour, 0..1 per channel.
.or_else(|| {
let c = body?.get("color")?.as_array()?;
let ch = |i: usize| -> Option<u8> { Some((c.get(i)?.as_f64()?.clamp(0.0, 1.0) * 255.0).round() as u8) };
Some(format!("#{:02x}{:02x}{:02x}", ch(0)?, ch(1)?, ch(2)?))
})
.unwrap_or_else(default_body_hex);
let opacity = look.and_then(|a| a.get("opacity")).and_then(Value::as_f64).filter(|o| o.is_finite()).unwrap_or(1.0);
Ok((hex, opacity))
}
/// Draw `name` with `look`, keeping whichever of colour and opacity it does not give.
fn assign(s: &mut Session, look: &BodyLook) -> Result<(), String> {
let name = &look.name;
if !body_names(s).iter().any(|b| b == name) {
return Err(format!("no body `{name}` in the design (it has {})", body_list(s)));
}
let (hex, opacity) = current_look(s, name)?;
let params = json!({
"bodies": [name],
"color": look.color.clone().unwrap_or(hex),
"opacity": look.opacity.unwrap_or(opacity),
});
s.execute("appearance.assign", &params).map(|_| ()).map_err(|e| format!("drawing `{name}`: {e}"))
}
/// The design's bodies, named, for an error message.
fn body_list(s: &Session) -> String {
let names = body_names(s);
if names.is_empty() { "no bodies".to_owned() } else { names.join(", ") }
}
/// Apply the options to the session, in the order a later one overrides an earlier: the `--tool`
/// preview first (it speaks for every body), then each `--body`, then the section.
pub fn apply(s: &mut Session, bodies: &[&str], tools: &[&str], section: Option<&str>) -> Result<(), String> {
// Everything is read before anything is drawn, so a misspelt option is an error rather than
// half a picture.
let looks: Vec<BodyLook> = bodies.iter().map(|spec| parse_body(spec)).collect::<Result<_, _>>()?;
let section = section.map(parse_section).transpose()?;
if !tools.is_empty() {
let names = body_names(s);
for t in tools {
if !names.iter().any(|b| b == t) {
return Err(format!("--tool: no body `{t}` in the design (it has {})", body_list(s)));
}
}
// Everything but the tool see-through, in the colour it already has, so the tool reads
// against the material it meets. Bodies that share a colour are assigned together: a
// design of many bodies then costs a command or two rather than one for each.
let mut around: std::collections::BTreeMap<String, Vec<String>> = std::collections::BTreeMap::new();
for name in names.iter().filter(|n| !tools.contains(&n.as_str())) {
around.entry(current_look(s, name)?.0).or_default().push(name.clone());
}
for (hex, sharing_it) in around {
let params = json!({"bodies": sharing_it, "color": hex, "opacity": TARGET_OPACITY});
s.execute("appearance.assign", &params).map_err(|e| format!("--tool: {e}"))?;
}
// Then the tool itself, solid in the tool colour.
let params = json!({"bodies": tools, "color": TOOL_COLOR, "opacity": 1.0});
s.execute("appearance.assign", &params).map_err(|e| format!("--tool: {e}"))?;
}
for look in &looks {
assign(s, look)?;
}
if let Some(params) = section {
s.execute("inspect.section", &params).map_err(|e| format!("--section: {e}"))?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
/// `--body` names a body and says how it is drawn. Either key alone is enough.
#[test]
fn a_body_spec_is_a_name_and_the_keys_appearance_assign_takes() {
let look = |s: &str| parse_body(s).unwrap();
assert_eq!(look("Target:opacity=0.3"), BodyLook { name: "Target".into(), opacity: Some(0.3), color: None });
assert_eq!(look("Tool:color=#CC3333"), BodyLook { name: "Tool".into(), opacity: None, color: Some("#cc3333".into()) });
assert_eq!(look("Tool: opacity = 1 , color = #00ff00 "), BodyLook { name: "Tool".into(), opacity: Some(1.0), color: Some("#00ff00".into()) });
// A body name with a space in it, as the browser shows them.
assert_eq!(look("Bracket arm:opacity=0").name, "Bracket arm");
assert_eq!(look("A:opacity=0").opacity, Some(0.0));
}
/// Every way of getting `--body` wrong is an error that says what it takes, never a panic
/// and never a silently ignored option.
#[test]
fn a_bad_body_spec_says_what_it_takes() {
for (spec, expect) in [
("Target", "NAME:key=value"),
(":opacity=0.3", "names no body"),
("Target:", "asks for nothing"),
("Target:opacity", "key=value"),
("Target:opacity=high", "0 to 1"),
("Target:opacity=1.5", "0 to 1"),
("Target:opacity=-0.2", "0 to 1"),
("Target:opacity=NaN", "0 to 1"),
("Target:opacity=inf", "0 to 1"),
("Target:color=red", "#rrggbb"),
("Target:color=#ccc", "#rrggbb"),
("Target:color=#gggggg", "#rrggbb"),
("Target:alpha=0.3", "unknown key `alpha`"),
] {
let e = parse_body(spec).err().unwrap_or_else(|| panic!("{spec} was accepted"));
assert!(e.contains(expect), "{spec}: {e}");
}
}
/// A `--section` spec is the plane `inspect.section` cuts with: an axis and a world
/// coordinate, a named plane, `flip` for the other side, or the command's own JSON.
#[test]
fn a_section_spec_is_the_plane_inspect_section_takes() {
let p = |s: &str| parse_section(s).unwrap();
// The plane normal to each axis, cutting at that coordinate.
assert_eq!(p("z=27"), json!({"plane": "XY", "at": 27.0}));
assert_eq!(p("y=-5.5"), json!({"plane": "XZ", "at": -5.5}));
assert_eq!(p("X=0"), json!({"plane": "YZ", "at": 0.0}));
assert_eq!(p("z=27,flip"), json!({"plane": "XY", "at": 27.0, "flip": true}));
// A named plane through the origin, and the command's own parameters.
assert_eq!(p("XY"), json!({"plane": "XY"}));
assert_eq!(p("yz,flip"), json!({"plane": "YZ", "flip": true}));
assert_eq!(p("{\"plane\": \"XY\", \"offset\": 4}"), json!({"plane": "XY", "offset": 4}));
}
#[test]
fn a_bad_section_spec_says_what_it_takes() {
for (spec, expect) in [
("27", "z=27"),
("w=4", "unknown axis `w`"),
("z=high", "is not a number"),
("z=inf", "not a finite coordinate"),
("z=27,upside-down", "unknown `upside-down`"),
("{\"plane\":", "not JSON"),
] {
let e = parse_section(spec).err().unwrap_or_else(|| panic!("{spec} was accepted"));
assert!(e.contains(expect), "{spec}: {e}");
}
}
/// The tool colour is the one the options document, in the form `appearance.assign` takes.
#[test]
fn the_tool_colour_is_a_hex_colour_the_command_takes() {
assert_eq!(parse_body(&format!("Tool:color={TOOL_COLOR}")).unwrap().color, Some(TOOL_COLOR.to_owned()));
assert!((0.0..1.0).contains(&TARGET_OPACITY), "a target must be see-through: {TARGET_OPACITY}");
assert_eq!(default_body_hex(), "#b0bac6");
}
}
+1 -2
View File
@@ -350,7 +350,7 @@ fn actions(s: &Session) -> Vec<Action> {
// The surface's normal at `p` (either way round): exact on analytic faces.
let normal_at = |f: u32, p: Vec3| -> Option<Vec3> {
match face_surface(&b.body, f as usize) {
Some(FaceSurface::Plane { normal, .. }) => Some(normal),
Some(FaceSurface::Plane { normal, .. }) => Some(*normal),
Some(FaceSurface::Cylinder { origin, axis, .. }) => {
let v = p - origin;
(v - axis * v.dot(axis)).normalized()
@@ -1665,7 +1665,6 @@ fn a_tangent_plane_on_a_drill_tip() {
}
#[test]
#[ignore = "fails until #568 is fixed"]
fn a_plane_on_a_patterned_counterbore_after_a_thickness_change() {
let mut c = vec![json!({"command": "parameters.change", "params": {"name": "thick", "expression": "10 mm"}})];
c.extend(rectangle(json!("XY"), [0., 0.], [80., 30.]));
@@ -0,0 +1,62 @@
//! The bracket example must cut two through holes, rather than successfully cutting empty
//! space beside the body. Check the shipped script and rebuild its saved design from scratch.
use std::f64::consts::PI;
use serde_json::Value;
use solidcraft_engine::geom::Vec3;
use solidcraft_engine::{Session, io, kernel};
fn assert_two_through_holes(session: &Session, stage: &str) {
let errors: Vec<_> = session.model.results.iter().filter_map(|r| r.error.as_ref().map(|e| (&r.name, e))).collect();
assert!(errors.is_empty(), "{stage}: every example feature evaluates: {errors:?}");
let cut = session.doc.features.last().expect("the example ends with the hole cut");
let before = session.model.state_before(cut.id);
let after = session.model.state();
assert_eq!(before.bodies.len(), 1, "{stage}: one bracket before cutting the holes");
assert_eq!(after.bodies.len(), 1, "{stage}: the holes leave one bracket");
let uncut = &before.body("Bracket").expect("the uncut bracket").body;
let bracket = &after.body("Bracket").expect("the finished bracket").body;
// Measure the actual state before the final cut so the existing edge fillet cancels out.
// Both holes have diameter 8 mm and pass through the 6 mm base. Kernel measurements are
// tessellated, so the relative tolerance applies to the removed material, not the body.
let removed = kernel::measure(uncut).unwrap().volume - kernel::measure(bracket).unwrap().volume;
let expected = 2.0 * PI * 4.0_f64.powi(2) * 6.0;
assert!((removed - expected).abs() < expected * 1e-3, "{stage}: the two holes remove {removed} mm³, expected {expected} mm³");
let before_mesh = uncut.tessellate(0.01).unwrap();
let after_mesh = bracket.tessellate(0.01).unwrap();
let down = Vec3::new(0.0, 0.0, -1.0);
for y in [-15.0, -45.0] {
let center = Vec3::new(25.0, y, 1.0);
assert_eq!(before_mesh.ray_crossings(center, down), 2, "{stage}: hole at y={y} starts over the solid base");
// Rays through each opening must miss the entire bracket, including its top and
// bottom caps. Points away from the centre guard against a narrow slit or shifted cut.
for (dx, dy) in [(0.0, 0.0), (3.0, 0.0), (-3.0, 0.0), (0.0, 3.0), (0.0, -3.0)] {
let ray = center + Vec3::new(dx, dy, 0.0);
assert_eq!(after_mesh.ray_crossings(ray, down), 0, "{stage}: the hole at y={y} is open through both caps at ({dx}, {dy})");
}
// Material just outside each radius remains, so neither opening is an edge notch.
for (dx, dy) in [(5.0, 0.0), (-5.0, 0.0), (0.0, 5.0), (0.0, -5.0)] {
let ray = center + Vec3::new(dx, dy, 0.0);
assert_eq!(after_mesh.ray_crossings(ray, down), 2, "{stage}: material remains around the hole at y={y}, offset ({dx}, {dy})");
}
}
}
#[test]
fn bracket_example_cuts_two_through_holes_and_reopens() {
let script: Value = serde_json::from_str(include_str!("../../../examples/bracket.json")).unwrap();
let mut session = Session::default();
session.run_script(&script).unwrap();
assert_two_through_holes(&session, "script");
// No geometry cache is supplied: reopening the container re-evaluates the saved timeline.
let saved = io::write_design_container(&session.doc).unwrap();
let document = io::read_design(&saved).unwrap();
assert_eq!(document.features, session.doc.features, "saving preserves the example's feature parameters");
let reopened = Session::new(document);
assert_two_through_holes(&reopened, "reopened");
}
+1 -1
View File
@@ -27,7 +27,7 @@ const KNOWN: [(&str, &str, &str); 2] = [
(
"m0-revolve-primitives",
"Body1 bbox",
"partial revolves turn toward the sketch normal since ac43365 (as Fusion's do); M0 turned them the other way",
"partial revolves turn toward the sketch normal since ac43365 (as most parametric CAD tools do); M0 turned them the other way",
),
("components-arm", "body_count", "Measure counted only the root component's bodies then; it measures every placed body now"),
];
@@ -0,0 +1,50 @@
//! `mcp --in script --param-check <level>`: the level applies to the script the session starts
//! from too, so a misspelled key in it is refused (strict) or reported on stderr (warn) instead of
//! silently ignored.
use std::process::{Command, Stdio};
#[test]
fn param_check_applies_to_the_starting_script() {
let dir = std::env::temp_dir().join(format!("solidcraft-mcp-param-check-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let script = dir.join("typo.json");
let box_ = r#"{"command": "solid.box", "params": {"length": 5, "width": 5, "height": 5, "operaton": "cut"}}"#;
std::fs::write(&script, format!(r#"{{"commands": [{box_}]}}"#)).unwrap();
let run = |level: &str| {
Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
.args(["mcp", "--in", script.to_str().unwrap(), "--param-check", level])
.stdin(Stdio::null())
.output()
.unwrap()
};
let strict = run("strict");
let stderr = String::from_utf8_lossy(&strict.stderr);
assert!(!strict.status.success(), "{stderr}");
assert!(stderr.contains("unknown key `operaton`; did you mean `operation`?"), "{stderr}");
// Warn: the script runs, and its warnings go to stderr.
let warn = run("warn");
let stderr = String::from_utf8_lossy(&warn.stderr);
assert!(warn.status.success(), "{stderr}");
assert!(stderr.contains("step 1: unknown key `operaton`; did you mean `operation`?"), "{stderr}");
// A warned step's warning is printed even when a later step fails.
let failing = dir.join("typo_then_failure.json");
std::fs::write(&failing, format!(r#"{{"commands": [{box_}, {{"command": "no.such_command"}}]}}"#)).unwrap();
let out = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
.args(["mcp", "--in", failing.to_str().unwrap(), "--param-check", "warn"])
.stdin(Stdio::null())
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(!out.status.success(), "{stderr}");
assert!(stderr.contains("step 1: unknown key `operaton`") && stderr.contains("step 2"), "{stderr}");
// Off: the script runs as before, and the server ends with its input.
let off = run("off");
assert!(off.status.success(), "{}", String::from_utf8_lossy(&off.stderr));
assert!(!String::from_utf8_lossy(&off.stderr).contains("operaton"));
// A bare flag is an error, not off.
let bare = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).args(["mcp", "--param-check"]).stdin(Stdio::null()).output().unwrap();
assert!(!bare.status.success());
assert!(String::from_utf8_lossy(&bare.stderr).contains("--param-check wants off, warn or strict"));
std::fs::remove_dir_all(&dir).ok();
}
+209
View File
@@ -0,0 +1,209 @@
//! A design opened from the model its file keeps is the design evaluated again: the same
//! results, the same bodies (to the bit), the same face names and volumes after every feature,
//! and the same after an edit made to both.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use serde_json::{Value, json};
use solidcraft_engine::Session;
use solidcraft_engine::doc::{ModelBody, ModelState};
fn corpus() -> Vec<PathBuf> {
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/corpus");
let mut v: Vec<PathBuf> =
std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "solidcraft")).collect();
v.sort();
v
}
fn temp(name: &str) -> PathBuf {
std::env::temp_dir().join(format!("solidcraft-model-cache-{}-{name}.solidcraft", std::process::id()))
}
fn open(path: &Path) -> Session {
let mut s = Session::default();
s.execute("doc.open", &json!({"path": path.to_string_lossy()})).unwrap_or_else(|e| panic!("{}: {e}", path.display()));
s
}
fn body(name: &str, b: &ModelBody) -> Value {
let measured = solidcraft_engine::kernel::measure(&b.body).ok();
json!({
"name": b.name, "feature": b.feature, "component": b.component, "source": b.source_name, "transferred": b.transferred,
"brep": solidcraft_engine::kernel::encode_native_body(&b.body).map(|bytes| format!("{:x}", sha(&bytes))).unwrap_or_else(|e| format!("{name}: {e}")),
"faces": *solidcraft_engine::doc::naming::face_names(b),
"volume": measured.as_ref().map(|m| m.volume.to_bits()), "area": measured.as_ref().map(|m| m.area.to_bits()),
"look": b.imported.as_ref().map(|l| format!("{l:?}")),
})
}
fn sha(bytes: &[u8]) -> u128 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
bytes.hash(&mut h);
let a = h.finish();
bytes.len().hash(&mut h);
(u128::from(a) << 64) | u128::from(h.finish())
}
fn state(name: &str, st: &Arc<ModelState>) -> Value {
json!({
"bodies": st.bodies.iter().map(|b| body(name, b)).collect::<Vec<_>>(),
"sketches": st.sketches.iter().map(|s| json!([s.feature, s.name, s.plane, s.sketch, s.report, s.profiles.len()])).collect::<Vec<_>>(),
"counter": st.body_counter, "sheets": st.sheets, "threads": st.threads, "construct": format!("{:?}", st.construct),
})
}
/// Everything a model holds that the rest of the app reads.
fn model(name: &str, s: &Session) -> Value {
let results: Vec<Value> = s
.model
.results
.iter()
.map(|r| json!({"id": r.id, "name": r.name, "error": r.error, "warning": r.warning, "skipped": r.skipped, "state": state(name, &r.state)}))
.collect();
json!({"results": results, "world": state(name, &s.world_state())})
}
/// The same edit on both: a parameter's value when the design has one, else a new body.
fn edit(s: &mut Session) {
let param = s.doc.params.first().map(|p| (p.name.clone(), p.expr.clone()));
match param {
Some((name, expr)) => {
s.execute("parameters.change", &json!({"name": name, "expression": format!("({expr}) * 1.05")})).unwrap();
}
None => {
s.execute("solid.box", &json!({"length": 7, "width": 8, "height": 9, "operation": "new"})).unwrap();
}
}
}
fn check(name: &str, mut built: Session) {
let path = temp(name);
built.execute("file.save_as", &json!({"path": path.to_string_lossy()})).unwrap();
let bytes = std::fs::read(&path).unwrap();
assert!(solidcraft_engine::io::read_design_geometry(&bytes).is_some(), "{name}: the save keeps the model");
let mut cached = open(&path);
assert_eq!(cached.model.last_recomputed, 0, "{name}: opened without evaluating");
let mut rebuilt = Session::new(solidcraft_engine::io::read_design(&bytes).unwrap());
assert!(rebuilt.model.last_recomputed > 0);
assert_eq!(model(name, &cached), model(name, &rebuilt), "{name}: opened from the file's model");
edit(&mut cached);
edit(&mut rebuilt);
assert_eq!(model(name, &cached), model(name, &rebuilt), "{name}: after the same edit");
cached.execute("edit.undo", &json!({})).unwrap();
rebuilt.execute("edit.undo", &json!({})).unwrap();
assert_eq!(model(name, &cached), model(name, &rebuilt), "{name}: after undoing it");
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(format!("{}.bak", path.display()));
}
#[test]
fn corpus_designs_open_from_their_model_as_evaluated() {
for p in corpus() {
let name = p.file_stem().unwrap_or_default().to_string_lossy().to_string();
let s = open(&p);
// Names saved before format 14 are found again through names worked out from geometry on
// demand, which a saved model does not keep.
let legacy = s.doc.to_json().contains(&format!("\"{}", solidcraft_engine::doc::naming::LEGACY_MARK));
if s.model.results.iter().any(|r| r.error.is_some()) || legacy {
// Not kept: a model with errors, or names saved before format 14.
let path = temp(&name);
let mut s = s;
s.execute("file.save_as", &json!({"path": path.to_string_lossy()})).unwrap();
assert!(solidcraft_engine::io::read_design_geometry(&std::fs::read(&path).unwrap()).is_none(), "{name}");
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(format!("{}.bak", path.display()));
continue;
}
check(&name, s);
}
}
#[test]
fn sample_designs_open_from_their_model_as_evaluated() {
for sample in solidcraft_engine::sample::samples() {
let mut s = Session::default();
for c in (sample.script)()["commands"].as_array().unwrap() {
s.execute(c["command"].as_str().unwrap(), &c["params"]).unwrap_or_else(|e| panic!("{}: {e}", sample.name));
}
check(sample.name, s);
}
}
#[test]
fn imported_assemblies_open_from_their_model_as_evaluated() {
// A STEP assembly of the hinge sample: parts placed several times.
let mut source = Session::default();
let hinge = solidcraft_engine::sample::samples().into_iter().find(|s| s.name == "Hinge Assembly").unwrap();
for c in (hinge.script)()["commands"].as_array().unwrap() {
source.execute(c["command"].as_str().unwrap(), &c["params"]).unwrap();
}
let step = std::env::temp_dir().join(format!("solidcraft-model-cache-{}-hinge.step", std::process::id()));
source.execute("file.export", &json!({"path": step.to_string_lossy()})).unwrap();
let mut s = Session::default();
s.execute("file.insert_step", &json!({"path": step.to_string_lossy()})).unwrap();
s.execute("file.insert_step", &json!({"path": step.to_string_lossy(), "matrix": [1, 0, 0, 200, 0, 1, 0, 0, 0, 0, 1, 0]})).unwrap();
let _ = std::fs::remove_file(&step);
check("imported hinge", s);
}
/// Opened from its model, a design evaluates only from the first feature an edit changes: the
/// saved states before it are kept as they were read (the same states, not evaluated again).
#[test]
fn an_edit_after_opening_evaluates_from_the_feature_it_changes() {
let plate = solidcraft_engine::sample::samples().into_iter().find(|s| s.name == "Sample Plate").unwrap();
let mut built = Session::default();
for c in (plate.script)()["commands"].as_array().unwrap() {
built.execute(c["command"].as_str().unwrap(), &c["params"]).unwrap();
}
let path = temp("incremental");
built.execute("file.save_as", &json!({"path": path.to_string_lossy()})).unwrap();
let n = built.doc.features.len();
assert!(n >= 4);
let mut s = open(&path);
assert_eq!(s.model.last_recomputed, 0);
let read: Vec<Arc<ModelState>> = s.model.results.iter().map(|r| r.state.clone()).collect();
// How many of the first features still have the states read from the file.
let kept = |s: &Session| s.model.results.iter().zip(&read).take_while(|(r, st)| Arc::ptr_eq(&r.state, st)).count();
// The marker moved back and forth: the features before it are as read.
s.execute("timeline.rollback", &json!({"position": n - 2})).unwrap();
assert_eq!(kept(&s), n - 2, "rolled back");
s.execute("timeline.rollback", &json!({})).unwrap();
assert_eq!(kept(&s), n, "rolled forward: the features after the marker take their saved results again");
// Suppressing the second-to-last feature keeps every state before it.
let k = s.doc.features[n - 2].id;
s.execute("timeline.suppress", &json!({"feature": k, "suppressed": true})).unwrap();
assert_eq!(kept(&s), n - 2, "evaluated from the suppressed feature on");
s.execute("edit.undo", &json!({})).unwrap();
// A new feature at the end starts from the final state read from the file.
let mut s = open(&path);
let read: Vec<Arc<ModelState>> = s.model.results.iter().map(|r| r.state.clone()).collect();
s.execute("solid.box", &json!({"length": 3, "width": 3, "height": 3, "operation": "new"})).unwrap();
assert_eq!(s.model.results.len(), n + 1);
assert!(s.model.results.iter().zip(&read).all(|(r, st)| Arc::ptr_eq(&r.state, st)), "only the new feature evaluated");
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(format!("{}.bak", path.display()));
}
/// An edit made after opening and undone finds the saved results again: undo evaluates nothing.
#[test]
fn undoing_an_edit_after_opening_reuses_the_saved_results() {
let plate = solidcraft_engine::sample::samples().into_iter().find(|s| s.name == "Sample Plate").unwrap();
let mut built = Session::default();
for c in (plate.script)()["commands"].as_array().unwrap() {
built.execute(c["command"].as_str().unwrap(), &c["params"]).unwrap();
}
let path = temp("undo");
built.execute("file.save_as", &json!({"path": path.to_string_lossy()})).unwrap();
let mut s = open(&path);
let read: Vec<Arc<ModelState>> = s.model.results.iter().map(|r| r.state.clone()).collect();
let first = s.doc.features[1].id;
s.execute("timeline.suppress", &json!({"feature": first, "suppressed": true})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.model.last_recomputed, 0, "taken from the saved results");
assert!(s.model.results.iter().zip(&read).all(|(r, st)| Arc::ptr_eq(&r.state, st)), "the very states read from the file");
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(format!("{}.bak", path.display()));
}
@@ -84,3 +84,42 @@ fn corpus_designs_replay_from_their_scripts() {
}
assert!(replayed >= 7, "replayed {replayed} corpus designs");
}
/// #1990: a sketch and its extrude may share a name, and the design's script still replays,
/// keeping the names: the replayed design's own script has the same steps.
#[test]
fn a_design_naming_a_sketch_and_its_extrude_alike_replays_from_its_script() {
let dir = std::env::temp_dir().join(format!("solidcraft-shared-names-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let cli = |args: &[&std::ffi::OsStr]| {
let out = std::process::Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).args(args).output().unwrap();
assert!(
out.status.success(),
"{}: {}",
args.iter().map(|a| a.to_string_lossy()).collect::<Vec<_>>().join(" "),
String::from_utf8_lossy(&out.stderr)
);
};
let script = dir.join("mini.json");
let saved = dir.join("mini.solidcraft");
let exported = dir.join("rt.json");
let replayed = dir.join("rt.solidcraft");
let again = dir.join("rt2.json");
let commands = json!({"commands": [
{"command": "sketch.create", "params": {"plane": "XY", "name": "Block"}},
{"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [20, 10]}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.extrude", "params": {"distance": "5", "name": "Block"}},
]});
std::fs::write(&script, commands.to_string()).unwrap();
cli(&["run".as_ref(), script.as_ref(), "--save".as_ref(), saved.as_ref()]);
cli(&["script".as_ref(), saved.as_ref(), "--out".as_ref(), exported.as_ref()]);
cli(&["run".as_ref(), exported.as_ref(), "--save".as_ref(), replayed.as_ref()]);
cli(&["script".as_ref(), replayed.as_ref(), "--out".as_ref(), again.as_ref()]);
let first: Value = serde_json::from_str(&std::fs::read_to_string(&exported).unwrap()).unwrap();
let second: Value = serde_json::from_str(&std::fs::read_to_string(&again).unwrap()).unwrap();
let _ = std::fs::remove_dir_all(&dir);
let names: Vec<&str> = first["commands"].as_array().unwrap().iter().filter_map(|c| c["params"]["feature"]["name"].as_str()).collect();
assert_eq!(names, ["Block", "Block"], "{first:#}");
assert_eq!(second["commands"], first["commands"], "the replayed design's script has the same steps");
}
@@ -0,0 +1,221 @@
//! `snapshot --body`, `--tool` and `--section`: a picture of a tool body inside the material it
//! meets, which is the one picture that says whether a cut reaches through.
//!
//! Each option is a shortcut for the command the app itself runs (`appearance.assign`,
//! `inspect.section`), so every test here asserts that the option's picture is the picture of a
//! script that runs those commands — not that the pixels match a stored image. Pictures are
//! compared with a tolerance, and the tests run on whatever renderer the machine has (a GPU, a
//! software adapter, or the CPU rasterizer), because they only ever compare two pictures this
//! same CLI drew.
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::atomic::{AtomicUsize, Ordering};
use serde_json::{Value, json};
static NEXT: AtomicUsize = AtomicUsize::new(0);
const SIZE: (u32, u32) = (200, 140);
/// A PNG's size and RGBA pixels.
fn decode(png: &[u8]) -> (u32, u32, Vec<u8>) {
let img = image::load_from_memory_with_format(png, image::ImageFormat::Png).unwrap().to_rgba8();
(img.width(), img.height(), img.into_raw())
}
/// Pixels of two same-sized pictures that differ by more than a little in some channel.
fn differing(a: &[u8], b: &[u8]) -> usize {
let ((wa, ha, a), (wb, hb, b)) = (decode(a), decode(b));
assert_eq!((wa, ha), (wb, hb), "pictures of different sizes");
a.as_chunks::<4>().0.iter().zip(b.as_chunks::<4>().0).filter(|(p, q)| p.iter().zip(q.iter()).any(|(x, y)| x.abs_diff(*y) > 16)).count()
}
/// The same picture: a renderer's output may differ by a few levels or a stray edge pixel.
#[track_caller]
fn assert_same(a: &[u8], b: &[u8], what: &str) {
let (n, total) = (differing(a, b), (SIZE.0 * SIZE.1) as usize);
assert!(n * 500 <= total, "{what}: {n} of {total} pixels differ");
}
/// Different pictures: more than stray pixels differ.
#[track_caller]
fn assert_differs(a: &[u8], b: &[u8], what: &str) {
let n = differing(a, b);
assert!(n >= 100, "{what}: only {n} pixels differ");
}
/// How much of a picture is the tool colour (`preview::TOOL_COLOR`, #e8542c): a red neither
/// the default body grey nor the background has, still recognisable once a see-through body in
/// front of it has tinted it (measured on this machine: (200, 108, 85) through a target at
/// opacity 0.3, against (164, 173, 184) for the plain grey).
fn tool_pixels(png: &[u8]) -> usize {
let (_, _, px) = decode(png);
px.as_chunks::<4>().0.iter().filter(|p| p[0] > 140 && p[0] > p[1].saturating_add(50) && p[0] > p[2].saturating_add(50)).count()
}
struct Scratch(PathBuf);
impl Scratch {
fn new() -> Self {
let dir =
std::env::temp_dir().join(format!("solidcraft-snapshot-appearance-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
std::fs::create_dir(&dir).unwrap();
Self(dir)
}
/// A plate with a pin standing in it: the tool body a cut would remove, reaching 6 mm short
/// of the far face, which is exactly what a picture is supposed to show and a volume is not.
/// `more` runs on top.
fn script(&self, name: &str, more: &[Value]) -> PathBuf {
let mut steps = vec![
json!({"command": "solid.box", "params": {"length": 50, "width": 34, "height": 20, "center": [0, 0, 10], "body_name": "Target"}}),
json!({"command": "solid.cylinder", "params": {"diameter": 12, "height": 14, "base": [0, 0, 0], "operation": "new_body", "body_name": "Tool"}}),
];
steps.extend(more.iter().cloned());
let path = self.0.join(name);
std::fs::write(&path, json!({"commands": steps}).to_string()).unwrap();
path
}
fn run(&self, script: &Path, out: &str, extra: &[&str]) -> Output {
let (w, h) = (SIZE.0.to_string(), SIZE.1.to_string());
Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
.arg("snapshot")
.arg(script)
.arg("--out")
.arg(self.0.join(out))
.args(["--width", &w, "--height", &h, "--no-grid", "--no-axes"])
.args(extra)
.output()
.unwrap()
}
/// The PNG `snapshot` writes.
fn snapshot(&self, script: &Path, out: &str, extra: &[&str]) -> Vec<u8> {
let output = self.run(script, out, extra);
assert!(output.status.success(), "{extra:?}: {}", String::from_utf8_lossy(&output.stderr));
let png = std::fs::read(self.0.join(out)).unwrap();
assert!(png.starts_with(b"\x89PNG"), "{extra:?}");
png
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// `--body NAME:opacity=…` draws that body see-through, and the picture is the one a script
/// gets by assigning the same appearance itself: the option is the command, not a second way
/// of drawing.
#[test]
fn a_body_drawn_see_through_is_the_picture_of_the_same_appearance_assigned() {
let dir = Scratch::new();
let plain = dir.script("plain.json", &[]);
let assigned =
dir.script("assigned.json", &[json!({"command": "appearance.assign", "params": {"bodies": ["Target"], "color": "#b0bac6", "opacity": 0.3}})]);
let solid = dir.snapshot(&plain, "solid.png", &[]);
let see_through = dir.snapshot(&plain, "see-through.png", &["--body", "Target:opacity=0.3"]);
assert_differs(&see_through, &solid, "--body opacity was ignored");
assert_same(&see_through, &dir.snapshot(&assigned, "by-script.png", &[]), "the same appearance assigned by a script");
// The option's colour is the design's too. It names the target, not the tool: the tool is
// inside the target, so a solid target hides whatever colour the tool is given.
let painted =
dir.script("painted.json", &[json!({"command": "appearance.assign", "params": {"bodies": ["Target"], "color": "#00aa33", "opacity": 1.0}})]);
let by_option = dir.snapshot(&plain, "painted.png", &["--body", "Target:color=#00aa33"]);
assert_differs(&by_option, &solid, "--body color was ignored");
assert_same(&by_option, &dir.snapshot(&painted, "painted-script.png", &[]), "the same colour assigned by a script");
}
/// `--tool NAME` is the whole point: the tool solid in the tool colour, every other body
/// see-through, so the tool shows inside the material it meets. The tool's colour covers more
/// of the picture than it does without the option, because the target no longer hides it.
#[test]
fn a_tool_shows_through_the_bodies_around_it() {
let dir = Scratch::new();
let script = dir.script("part.json", &[]);
let plain = dir.snapshot(&script, "plain.png", &["--view", "front"]);
let preview = dir.snapshot(&script, "preview.png", &["--view", "front", "--tool", "Tool"]);
assert_differs(&preview, &plain, "--tool was ignored");
// Seen from the front the plate hides the pin completely; the preview shows all of it.
assert_eq!(tool_pixels(&plain), 0, "nothing is the tool colour before the option");
assert!(tool_pixels(&preview) > 200, "the tool shows through the target: {} pixels", tool_pixels(&preview));
// It is the two commands it stands for, run in that order.
let by_script = dir.script(
"by-script.json",
&[
json!({"command": "appearance.assign", "params": {"bodies": ["Target"], "color": "#b0bac6", "opacity": 0.3}}),
json!({"command": "appearance.assign", "params": {"bodies": ["Tool"], "color": "#e8542c", "opacity": 1.0}}),
],
);
assert_same(&preview, &dir.snapshot(&by_script, "script.png", &["--view", "front"]), "the appearances --tool assigns");
// A later --body overrides what --tool said.
let overridden = dir.snapshot(&script, "override.png", &["--view", "front", "--tool", "Tool", "--body", "Tool:color=#2255cc"]);
assert!(tool_pixels(&overridden) < 50, "--body after --tool decides the tool's colour");
}
/// `--section` cuts the model where `inspect.section` would, and the same spec written as the
/// command's own JSON, or as the command in a script, gives the same picture.
#[test]
fn a_section_cuts_the_model_as_inspect_section_does() {
let dir = Scratch::new();
let script = dir.script("part.json", &[]);
let whole = dir.snapshot(&script, "whole.png", &[]);
let cut = dir.snapshot(&script, "cut.png", &["--section", "z=12"]);
assert_differs(&cut, &whole, "--section was ignored");
assert_same(&cut, &dir.snapshot(&script, "cut-json.png", &["--section", r#"{"plane": "XY", "at": 12}"#]), "the command's own JSON");
let by_script = dir.script("sectioned.json", &[json!({"command": "inspect.section", "params": {"plane": "XY", "at": 12}})]);
assert_same(&cut, &dir.snapshot(&by_script, "by-script.png", &[]), "inspect.section in a script");
// The other side of the same plane is a different picture.
let flipped = dir.snapshot(&script, "flipped.png", &["--section", "z=12,flip"]);
assert_differs(&flipped, &cut, "flip keeps the other side");
// A section through a see-through target still shows the tool inside it.
let both = dir.snapshot(&script, "both.png", &["--section", "z=12", "--tool", "Tool"]);
assert_differs(&both, &cut, "--tool and --section together");
assert!(tool_pixels(&both) > 100, "the tool is in the cut picture");
}
/// Every way of getting the options wrong is an error that says what it takes, and writes no
/// picture: a snapshot that quietly ignored an option would be a lie about the design.
#[test]
fn a_body_tool_or_section_that_cannot_be_used_is_an_error_and_writes_nothing() {
let dir = Scratch::new();
let script = dir.script("part.json", &[]);
let cases: [(&[&str], &str); 8] = [
(&["--body", "Nowhere:opacity=0.3"], "no body `Nowhere`"),
(&["--body", "Target"], "NAME:key=value"),
(&["--body", "Target:opacity=3"], "0 to 1"),
(&["--body", "Target:alpha=0.3"], "unknown key `alpha`"),
(&["--body", "Target:color=red"], "#rrggbb"),
(&["--tool", "Nowhere"], "no body `Nowhere`"),
(&["--section", "q=1"], "unknown axis `q`"),
(&["--section", "nonsense"], "z=27"),
];
for (extra, expect) in cases {
let output = dir.run(&script, "never.png", extra);
let stderr = String::from_utf8_lossy(&output.stderr);
assert_eq!(output.status.code(), Some(1), "{extra:?}: {stderr}");
assert!(stderr.contains(expect), "{extra:?}: {stderr}");
assert!(output.stdout.is_empty() && !dir.0.join("never.png").exists(), "{extra:?}");
}
// An error naming a body says which bodies the design has.
let stderr = String::from_utf8_lossy(&dir.run(&script, "never.png", &["--tool", "Nowhere"]).stderr).to_string();
assert!(stderr.contains("Target") && stderr.contains("Tool"), "{stderr}");
}
/// The usage text says what the options do.
#[test]
fn the_usage_names_the_body_tool_and_section_options() {
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).arg("snapshot").output().unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
for word in ["--body", "--tool", "--section", "appearance.assign", "inspect.section", "see-through"] {
assert!(stderr.contains(word), "usage lacks {word}: {stderr}");
}
}
+29 -1
View File
@@ -101,6 +101,10 @@ mod web {
/// On-demand CJK font: fetched only when a CJK language is selected.
font: solidcraft_ui_egui::fonts::CjkFontLoader,
font_result: FontLoadResult,
/// The fetched CJK font's bytes: kept so a language change can re-order the fallbacks.
cjk_bytes: Option<Vec<u8>>,
/// The language the fonts were last set up for (a CJK UI leads with the CJK face).
font_language: String,
space_mouse: SpaceMouse,
}
@@ -375,7 +379,10 @@ mod web {
let done = self.font_result.borrow_mut().take();
if let Some(res) = done {
let installed = match res {
Ok(bytes) => solidcraft_ui_egui::fonts::install_cjk_font(ctx, bytes),
Ok(bytes) => {
self.cjk_bytes = Some(bytes);
true
}
Err(err) => {
log::warn!("failed to fetch CJK font from {JAPANESE_FONT_PATH}: {err}");
false
@@ -387,11 +394,29 @@ mod web {
log::warn!("CJK font unavailable; showing English for this session");
}
self.font.finished(installed, &mut self.app.preferences.language);
self.apply_fonts(ctx);
ctx.request_repaint();
}
if self.font.tick(&mut self.app.preferences.language) {
self.fetch_font(ctx);
}
// The CJK face leads only a CJK UI: its Cyrillic glyphs are full-width, so switching
// to Ukrainian (or English) must put the Latin face back in front.
if self.app.preferences.language != self.font_language {
self.apply_fonts(ctx);
}
}
/// Set up the UI fonts for the live language, keeping the fetched CJK font as a fallback
/// (it leads only for a CJK UI, see [`solidcraft_ui_egui::fonts::install_cjk_font_for`]).
fn apply_fonts(&mut self, ctx: &egui::Context) {
match self.cjk_bytes.clone() {
Some(bytes) => {
solidcraft_ui_egui::fonts::install_cjk_font_for(ctx, bytes, &self.app.preferences.language);
}
None => solidcraft_ui_egui::fonts::setup_fonts(ctx),
}
self.font_language.clone_from(&self.app.preferences.language);
}
/// CJK text on screen with no CJK font yet (an English UI showing a Japanese name):
@@ -549,6 +574,7 @@ mod web {
app.home.show(rev);
}
let api = Api::install(sender, cc.egui_ctx.clone()).map_err(|e| format!("SolidCraft browser API: {e:?}"))?;
let font_language = app.preferences.language.clone();
Ok(Box::new(Shell {
app,
api,
@@ -560,6 +586,8 @@ mod web {
recents_synced: false,
font: Default::default(),
font_result: Rc::new(std::cell::RefCell::new(None)),
cjk_bytes: None,
font_language,
space_mouse: SpaceMouse::default(),
}))
}),
+99 -17
View File
@@ -7,7 +7,7 @@
//! navigation takes them with 3DxWare's object-mode mapping, as the Linux app does without a
//! profile.
use solidcraft_ui_egui::space_mouse::{Axes, FULL_SCALE};
use solidcraft_ui_egui::space_mouse::{Axes, Calibration, FULL_SCALE, calibrated};
/// Logitech's vendor id, which older 3Dconnexion devices carry, and those devices' product ids
/// (usb.ids). Logitech's own gamepads and wheels carry the same vendor id.
@@ -60,9 +60,43 @@ pub fn axes(values: &[f64]) -> Option<Axes> {
Some(Axes { translation: [raw(0)?, raw(1)?, raw(2)?], rotation: [raw(3)?, raw(4)?, raw(5)?] })
}
/// What a calibration of the gamepad with this id is saved under: `web` and its vendor and
/// product ids.
pub fn device_key(id: &str) -> Option<String> {
vendor_product(id).map(|(vendor, product)| format!("web {vendor:04x}:{product:04x}"))
}
/// The 3D mouse to read among the ones listed: the first device, else the first joystick.
pub fn choose(found: impl IntoIterator<Item = (Source, Axes)>) -> Option<Axes> {
found.into_iter().min_by_key(|(source, _)| *source).map(|(_, axes)| axes)
pub fn choose<T>(found: impl IntoIterator<Item = (Source, T)>) -> Option<T> {
found.into_iter().min_by_key(|(source, _)| *source).map(|(_, reading)| reading)
}
/// Whether the cap is moving as the next frame will see it: through the device's calibration
/// (as of the last frame) when it has one. The idle page wakes for a short-range device's push,
/// and not for a device resting off zero; the wheel filter goes by the same.
pub fn moving(axes: Axes, calibration: Option<Calibration>) -> bool {
calibrated(axes, calibration).moving()
}
/// The calibration as of the last frame, and the device it was for.
pub type Cached = (Option<String>, Option<Calibration>);
/// Whether the cap of `device` is moving, judged with the calibration kept from the last frame:
/// `None` when that was for another device, so another's calibration never judges this one. The
/// waker then lets a frame look, which brings the cache up to date.
pub fn moving_as_cached(device: &Option<String>, axes: Axes, cached: &Cached) -> Option<bool> {
(*device == cached.0).then(|| moving(axes, cached.1))
}
/// Whether the idle page's timer redraws: when the cap is moving as the last frame's calibration
/// judges it, when the device read isn't the one that calibration was for (a frame then judges
/// it with its own), and once when the device the last frame read is gone, so the page shows
/// that. With no device, and none last frame, it stays quiet.
pub fn wakes(reading: Option<(Option<String>, Axes)>, cached: &Cached) -> bool {
match reading {
Some((device, axes)) => moving_as_cached(&device, axes, cached).unwrap_or(true),
None => cached.0.is_some(),
}
}
#[cfg(target_arch = "wasm32")]
@@ -70,29 +104,29 @@ pub use browser::SpaceMouse;
#[cfg(target_arch = "wasm32")]
mod browser {
use std::cell::Cell;
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use solidcraft_ui_egui::SolveApp;
use solidcraft_ui_egui::space_mouse::{Axes, DriverMapping, navigate_frame};
use solidcraft_ui_egui::space_mouse::{Axes, DriverMapping, calibration, navigate_frame};
use wasm_bindgen::JsCast as _;
use wasm_bindgen::closure::Closure;
use solidcraft_ui_egui::space_mouse::WheelGuard;
use super::{axes, choose, source};
use super::{Cached, axes, choose, device_key, moving_as_cached, source, wakes};
/// How often the page looks for the cap starting to move while nothing else redraws.
const WAKE_MS: i32 = 50;
/// The 3D mouse the browser lists, if any.
fn read() -> Option<Axes> {
/// The 3D mouse the browser lists, if any: its calibration key and its axes.
fn read() -> Option<(Option<String>, Axes)> {
let pads = web_sys::window()?.navigator().get_gamepads().ok()?;
choose(pads.iter().filter_map(|pad| {
let pad = pad.dyn_into::<web_sys::Gamepad>().ok().filter(web_sys::Gamepad::connected)?;
let source = source(&pad.id())?;
let values: Vec<f64> = pad.axes().iter().map(|v| v.as_f64().unwrap_or(f64::NAN)).collect();
Some((source, axes(&values)?))
Some((source, (device_key(&pad.id()), axes(&values)?)))
}))
}
@@ -100,6 +134,8 @@ mod browser {
#[derive(Default)]
pub struct SpaceMouse {
axes: Axes,
/// The 3D mouse being read, as `SolveApp::space_mouse` keeps it.
device: Option<String>,
/// The previous frame's time and the step since it (seconds).
last: Option<f64>,
seconds: f64,
@@ -111,6 +147,8 @@ mod browser {
waking: bool,
/// Navigation is on (the waker reads it).
enabled: Rc<Cell<bool>>,
/// The calibration as of the last frame and its device (the waker and the wheel filter read it).
cached: Rc<RefCell<Cached>>,
}
impl SpaceMouse {
@@ -131,14 +169,18 @@ mod browser {
}
let reading = read();
if reading.is_some() && !self.waking {
self.waking = wake(ctx, &self.enabled);
self.waking = wake(ctx, &self.enabled, &self.cached);
}
// Inside the dead zone is rest: a cap resting just off zero doesn't keep redrawing.
self.axes = reading.filter(|axes| axes.moving()).unwrap_or_default();
self.wheel.filter(self.axes.moving(), now, &mut raw.events);
// As read: calibration needs the readings at rest too, and `navigate_frame` leaves a
// cap resting inside the dead zone alone.
(self.device, self.axes) = reading.unwrap_or_default();
let moving = moving_as_cached(&self.device, self.axes, &self.cached.borrow()).unwrap_or_else(|| self.axes.moving());
self.wheel.filter(moving, now, &mut raw.events);
}
pub fn logic(&self, app: &mut SolveApp, ctx: &egui::Context) {
app.space_mouse.device.clone_from(&self.device);
*self.cached.borrow_mut() = (self.device.clone(), calibration(app));
navigate_frame(app, ctx, DriverMapping::object_mode(), self.axes, self.seconds);
}
}
@@ -156,11 +198,11 @@ mod browser {
/// The browser sends no event when a gamepad moves, so a timer looks while the app is idle and
/// redraws when the cap starts moving; the frames that follow keep going on their own.
fn wake(ctx: &egui::Context, enabled: &Rc<Cell<bool>>) -> bool {
fn wake(ctx: &egui::Context, enabled: &Rc<Cell<bool>>, cached: &Rc<RefCell<Cached>>) -> bool {
let Some(window) = web_sys::window() else { return false };
let (ctx, enabled) = (ctx.clone(), Rc::clone(enabled));
let (ctx, enabled, cached) = (ctx.clone(), Rc::clone(enabled), Rc::clone(cached));
let tick = Closure::<dyn FnMut()>::new(move || {
if enabled.get() && read().is_some_and(Axes::moving) {
if enabled.get() && wakes(read(), &cached.borrow()) {
ctx.request_repaint();
}
});
@@ -192,6 +234,46 @@ mod tests {
}
}
#[test]
fn a_calibration_is_saved_under_the_ids_whichever_browser_lists_it() {
assert_eq!(device_key("SpaceMouse Pro (Vendor: 046d Product: c62b)").as_deref(), Some("web 046d:c62b"));
assert_eq!(device_key("46d-c62b-SpaceMouse Pro").as_deref(), Some("web 046d:c62b"));
assert_eq!(device_key("Xbox Wireless Controller"), None);
}
#[test]
fn the_idle_page_wakes_for_motion_as_calibrated_not_as_read() {
let axis =
|source, rest, full| solidcraft_ui_egui::space_mouse::AxisCalibration { source, reversed: false, rest, dead_zone: 0, full: [full; 2] };
// A short-range device: 15 of its 50 is under the raw dead zone, but 105 of 350 calibrated.
let short = Calibration { dead_zone_share: 0.0, axes: std::array::from_fn(|r| axis(r, 0, 50)) };
let push = Axes { translation: [15, 0, 0], rotation: [0; 3] };
assert!(!push.moving() && moving(push, Some(short)));
// A device resting at 100 on every axis: past the raw dead zone, but at rest calibrated.
let offset = Calibration { dead_zone_share: 0.0, axes: std::array::from_fn(|r| axis(r, 100, 350)) };
let resting = Axes { translation: [100; 3], rotation: [100; 3] };
assert!(resting.moving() && !moving(resting, Some(offset)));
// Without a calibration, as read.
assert!(!moving(push, None) && moving(resting, None));
// Kept from the last frame: only for the device it was for.
let (a, b) = (Some("web 256f:c63a".to_string()), Some("web 1234:5678".to_string()));
assert_eq!(moving_as_cached(&b, push, &(b.clone(), Some(short))), Some(true));
assert_eq!(moving_as_cached(&b, push, &(a.clone(), Some(offset))), None, "A's calibration doesn't judge B");
assert_eq!(moving_as_cached(&b, push, &(None, None)), None);
assert_eq!(moving_as_cached(&a, resting, &(a.clone(), None)), Some(true), "A was read without a calibration");
}
#[test]
fn the_idle_page_wakes_once_for_a_device_change_and_stays_quiet_without_one() {
let (a, b) = (Some("web 256f:c63a".to_string()), Some("web 1234:5678".to_string()));
let still = Axes::default();
let last_frame_read_a: Cached = (a.clone(), None);
assert!(!wakes(Some((a.clone(), still)), &last_frame_read_a), "A, at rest");
assert!(wakes(Some((b.clone(), still)), &last_frame_read_a), "A to B: a frame looks at B");
assert!(wakes(None, &last_frame_read_a), "A to none: a frame shows it's gone");
assert!(!wakes(None, &(None, None)), "none to none: quiet");
}
#[test]
fn gamepads_and_other_logitech_devices_are_not_3d_mice() {
for id in [
@@ -280,6 +362,6 @@ mod tests {
assert_eq!(choose([(Source::VirtualJoystick, other)]), Some(other));
// Two devices: the first listed.
assert_eq!(choose([(Source::Device, moving), (Source::Device, other)]), Some(moving));
assert_eq!(choose([]), None);
assert_eq!(choose::<Axes>([]), None);
}
}
+4 -1
View File
@@ -91,6 +91,8 @@ impl eframe::App for App {
fn on_exit(&mut self) {
// The agent chat's agent and everything it started end with the app.
solidcraft_ui_egui::agent_chat::shutdown(&mut self.solve);
// A save still being written ends before the app does.
solidcraft_ui_egui::save_job::tick(&mut self.solve, true);
solidcraft_ui_egui::recovery_ui::close(&mut self.solve);
solidcraft_engine::crash::end_session();
// An update asked to restart into the new version or to run its installer.
@@ -179,7 +181,8 @@ fn services(cc: &eframe::CreationContext<'_>) -> Services {
}
}
let all: Vec<&str> = all.iter().map(String::as_str).collect();
let future = file_parent.clone().add_filter(label, &all).pick_file();
// Any other file attaches by reference, so the list is not the only choice.
let future = file_parent.clone().add_filter(label, &all).add_filter("All files", &["*"]).pick_file();
Box::pin(async move { future.await.map(|p| p.path().to_string_lossy().into_owned()) })
})),
pick_program: Some(Box::new(move || {
+199 -44
View File
@@ -19,7 +19,7 @@ use quick_xml::Reader;
use quick_xml::events::Event;
use solidcraft_space_mouse::Layout;
use solidcraft_ui_egui::SolveApp;
use solidcraft_ui_egui::space_mouse::{Axes, AxisRule, DriverMapping, WheelGuard, navigate_frame};
use solidcraft_ui_egui::space_mouse::{Axes, AxisRule, Calibration, DriverMapping, WheelGuard, calibrated, calibration, navigate_frame};
const STALE_AFTER: Duration = Duration::from_millis(150);
/// Whether the 3Dconnexion driver (macOS) has the device: it then reports the cap, and reading
@@ -165,8 +165,36 @@ fn load_driver_mapping() -> Option<DriverMapping> {
parse_driver_mapping(&xml)
}
/// Where the reading comes from, which a calibration is saved under (`Source::key`).
#[derive(Clone, Copy, Debug, PartialEq)]
enum Source {
/// The device itself, with its vendor and product ids when known.
Hid(Option<(u16, u16)>),
/// spacenavd, which may have swapped or inverted axes on its own (`spnavcfg`).
#[cfg(target_os = "linux")]
Spacenavd,
/// The 3Dconnexion driver (macOS), which applies its own settings first.
#[cfg(target_os = "macos")]
Driver,
}
impl Source {
fn key(self) -> String {
match self {
Self::Hid(Some((vendor, product))) => format!("hid {vendor:04x}:{product:04x}"),
Self::Hid(None) => "hid".into(),
#[cfg(target_os = "linux")]
Self::Spacenavd => "spacenavd".into(),
#[cfg(target_os = "macos")]
Self::Driver => "3dxware".into(),
}
}
}
#[derive(Clone, Copy)]
struct Reading {
/// The device being read; none between devices.
device: Option<Source>,
axes: Axes,
/// When each of X, Y, Z, Rx, Ry and Rz last arrived. Each ages on its own: a device may send
/// translation and rotation in separate reports, or spread even one group over several.
@@ -179,15 +207,18 @@ struct Reading {
impl Reading {
fn new(now: Instant) -> Self {
Self { axes: Axes::default(), at: [now; 6], held: false }
Self { device: None, axes: Axes::default(), at: [now; 6], held: false }
}
fn current(self, now: Instant) -> Axes {
/// The axes at `now`. One with nothing fresh reads as `rest`: the device's own rest when it
/// is calibrated, so a device that rests off zero calibrates to no motion rather than drifting.
fn current(self, now: Instant, rest: Axes) -> Axes {
let mut axes = self.axes;
if !self.held {
for (value, at) in axes.translation.iter_mut().chain(axes.rotation.iter_mut()).zip(self.at) {
let rest = rest.translation.into_iter().chain(rest.rotation);
for ((value, at), rest) in axes.translation.iter_mut().chain(axes.rotation.iter_mut()).zip(self.at).zip(rest) {
if now.duration_since(at) >= STALE_AFTER {
*value = 0;
*value = rest;
}
}
}
@@ -195,6 +226,11 @@ impl Reading {
}
}
/// The raw axes at the device's rest, which calibrate to no motion; 0 without a calibration.
fn at_rest(calibration: Option<Calibration>) -> Axes {
calibration.map(|calibration| calibration.raw_rest().into()).unwrap_or_default()
}
/// The Generic Desktop "Multi-Axis Controller" usage names a 3D mouse's motion interface, whoever
/// made it, and excludes ordinary mice and the keyboard interfaces of 3D mice.
#[cfg(any(target_os = "windows", target_os = "macos"))]
@@ -233,11 +269,22 @@ fn publish(ctx: &Context, shared: &Mutex<Reading>, layout: Layout, report: &[u8]
#[cfg(target_os = "linux")]
fn publish_held(ctx: &Context, shared: &Mutex<Reading>, axes: Axes) {
if let Ok(mut reading) = shared.lock() {
*reading = Reading { axes, held: true, ..Reading::new(Instant::now()) };
*reading = Reading { device: reading.device, axes, held: true, ..Reading::new(Instant::now()) };
ctx.request_repaint();
}
}
/// A device is open: say which, so its calibration applies.
fn connected(ctx: &Context, shared: &Mutex<Reading>, source: Source) {
if let Ok(mut reading) = shared.lock() {
// Nothing has arrived from it yet, so every axis starts out stale.
let now = Instant::now();
let stale = now.checked_sub(STALE_AFTER).unwrap_or(now);
*reading = Reading { device: Some(source), at: [stale; 6], ..Reading::new(now) };
}
ctx.request_repaint();
}
/// The device is gone: stop reporting its last position.
fn reset(ctx: &Context, shared: &Mutex<Reading>) {
if let Ok(mut reading) = shared.lock() {
@@ -255,7 +302,7 @@ fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
loop {
if let Err(error) = api.refresh_devices() {
log::warn!("3D mouse: device scan failed: {error}");
} else if let Some((device, layout)) = api.device_list().filter(|info| is_3d_mouse(info)).find_map(|info| {
} else if let Some((device, layout, ids)) = api.device_list().filter(|info| is_3d_mouse(info)).find_map(|info| {
let device = info
.open_device(&api)
.inspect_err(|error| {
@@ -267,8 +314,9 @@ fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
})
.ok()?;
let layout = device_layout(&device, info.vendor_id())?;
Some((device, layout))
Some((device, layout, (info.vendor_id(), info.product_id())))
}) {
connected(&ctx, &shared, Source::Hid(Some(ids)));
let mut report = [0_u8; REPORT_BUFFER_BYTES];
loop {
match device.read_timeout(&mut report, 100) {
@@ -323,6 +371,7 @@ fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
loop {
if let Some(daemon) = spacenavd::connect() {
log::info!("3D mouse: reading spacenavd, which applies the spnavcfg settings");
connected(&ctx, &shared, Source::Spacenavd);
spacenavd::read(daemon, |axes| publish_held(&ctx, &shared, axes));
reset(&ctx, &shared);
} else if let Some(device) = hidraw::find() {
@@ -330,6 +379,7 @@ fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
Ok(mut file) => {
denied = None;
log::info!("3D mouse: reading {}", device.path.display());
connected(&ctx, &shared, Source::Hid(device.ids));
let mut report = [0_u8; REPORT_BUFFER_BYTES];
loop {
match file.read(&mut report) {
@@ -362,25 +412,34 @@ fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
fn start_driver_client(ctx: &Context, shared: &Arc<Mutex<Reading>>) {
use solidcraft_3dx_client::Event;
let (ctx, shared) = (ctx.clone(), Arc::clone(shared));
let started = solidcraft_3dx_client::start("SolidCraft", move |event| match event {
Event::DeviceAdded => DRIVER_CONNECTED.store(true, Ordering::Relaxed),
Event::DeviceRemoved => {
DRIVER_CONNECTED.store(false, Ordering::Relaxed);
reset(&ctx, &shared);
}
Event::Axes { translation, rotation } => {
if let Ok(mut reading) = shared.lock() {
let now = Instant::now();
reading.axes = Axes { translation, rotation };
reading.at = [now; 6];
ctx.request_repaint();
let started = solidcraft_3dx_client::start(
"SolidCraft",
move |event| match event {
Event::DeviceAdded => {
DRIVER_CONNECTED.store(true, Ordering::Relaxed);
connected(&ctx, &shared, Source::Driver);
}
}
Event::Buttons(_) => {}
});
match started {
Ok(()) => DRIVER_REGISTERED.store(true, Ordering::Relaxed),
Err(error) => log::info!("3D mouse: no driver client: {error}"),
Event::DeviceRemoved => {
DRIVER_CONNECTED.store(false, Ordering::Relaxed);
reset(&ctx, &shared);
}
Event::Axes { translation, rotation } => {
if let Ok(mut reading) = shared.lock() {
let now = Instant::now();
reading.axes = Axes { translation, rotation };
reading.at = [now; 6];
ctx.request_repaint();
}
}
Event::Buttons(_) => {}
},
|connected| match connected {
Ok(()) => DRIVER_REGISTERED.store(true, Ordering::Relaxed),
Err(error) => log::info!("3D mouse: no driver client: {error}"),
},
);
if let Err(error) = started {
log::info!("3D mouse: no driver client: {error}");
}
}
@@ -392,6 +451,9 @@ pub struct SpaceMouse {
/// Drops 3DxWare's emulated wheel while the cap moves (#1380).
wheel: WheelGuard,
started: Instant,
/// The calibration as of the last frame and the device it is for, for the wheel filter, which
/// runs before the app sees the frame.
calibration: Option<(Source, Calibration)>,
}
impl SpaceMouse {
@@ -412,6 +474,7 @@ impl SpaceMouse {
mapping_checked_at: now,
wheel: WheelGuard::default(),
started: now,
calibration: None,
}
}
@@ -425,7 +488,15 @@ impl SpaceMouse {
if !enabled {
return;
}
let moving = self.shared.lock().map(|reading| reading.current(now).moving()).unwrap_or(false);
let moving = self
.shared
.lock()
.map(|reading| {
// Only the calibration of the device being read: another's would misjudge it.
let calibration = self.calibration.filter(|(source, _)| Some(*source) == reading.device).map(|(_, calibration)| calibration);
calibrated(reading.current(now, at_rest(calibration)), calibration).moving()
})
.unwrap_or(false);
self.wheel.filter(moving, now.duration_since(self.started).as_secs_f64(), &mut raw.events);
}
@@ -445,11 +516,16 @@ impl SpaceMouse {
}
self.mapping_checked_at = now;
}
let axes = match self.shared.lock() {
Ok(reading) => reading.current(now),
Err(_) => return,
};
navigate_frame(app, ctx, self.driver_mapping, axes, seconds);
let Ok(reading) = self.shared.lock().map(|reading| *reading) else { return };
app.space_mouse.device = reading.device.map(Source::key);
let calibration = calibration(app);
self.calibration = reading.device.zip(calibration);
// While this device is being calibrated, an axis with nothing fresh reads 0, as a HID
// device's silence means: the old calibration's rest isn't fed back into the new one. A
// calibration of another device doesn't count; it ends in `navigate_frame`.
let calibrating = app.space_mouse.calibrating.as_ref().is_some_and(|(device, _)| app.space_mouse.device.as_ref() == Some(device));
let rest = if calibrating { Axes::default() } else { at_rest(calibration) };
navigate_frame(app, ctx, self.driver_mapping, reading.current(now, rest), seconds);
}
}
@@ -471,14 +547,14 @@ mod tests {
let mut reading = Reading::new(now);
let layout = Layout::default();
assert!(!decode_report(&[1, 1, 2], layout, &mut reading, now));
assert_eq!(reading.current(now), Axes::default());
assert_eq!(reading.current(now, Axes::default()), Axes::default());
assert!(decode_report(&[1, 0x9c, 0xff, 0x2c, 0x01, 0, 0], layout, &mut reading, now));
assert!(decode_report(&[2, 0, 0, 1, 0, 0xff, 0xff], layout, &mut reading, now));
assert_eq!(reading.current(now), Axes { translation: [-100, 300, 0], rotation: [0, 1, -1] });
assert_eq!(reading.current(now, Axes::default()), Axes { translation: [-100, 300, 0], rotation: [0, 1, -1] });
// Only rotation arrives again: translation goes stale, rotation stays current.
let later = now + STALE_AFTER + Duration::from_millis(1);
assert!(decode_report(&[2, 4, 0, 5, 0, 6, 0], layout, &mut reading, later));
assert_eq!(reading.current(later), Axes { translation: [0; 3], rotation: [4, 5, 6] });
assert_eq!(reading.current(later, Axes::default()), Axes { translation: [0; 3], rotation: [4, 5, 6] });
// A device sending all six in one report updates both at once.
let combined = solidcraft_space_mouse::Descriptor::parse(&[
0x05, 0x01, 0x09, 0x08, 0xA1, 0x01, 0x85, 0x01, 0x16, 0xA2, 0xFE, 0x26, 0x5E, 0x01, 0x75, 0x10, 0x95, 0x06, 0x19, 0x30, 0x29, 0x35, 0x81,
@@ -488,7 +564,7 @@ mod tests {
.expect("six axes declared");
let mut both = Reading::new(now);
assert!(decode_report(&[1, 1, 0, 2, 0, 3, 0, 4, 0, 5, 0, 6, 0], combined, &mut both, now));
assert_eq!(both.current(now), Axes { translation: [1, 2, 3], rotation: [4, 5, 6] });
assert_eq!(both.current(now, Axes::default()), Axes { translation: [1, 2, 3], rotation: [4, 5, 6] });
}
#[test]
@@ -507,11 +583,11 @@ mod tests {
let mut reading = Reading::new(now);
assert!(decode_report(&[1, 100, 0], split_x_y, &mut reading, now));
assert!(decode_report(&[2, 200, 0], split_x_y, &mut reading, now));
assert_eq!(reading.current(now).translation, [100, 200, 0]);
assert_eq!(reading.current(now, Axes::default()).translation, [100, 200, 0]);
// Then only X keeps coming: Y goes stale on its own.
let later = now + STALE_AFTER + Duration::from_millis(1);
assert!(decode_report(&[1, 50, 0], split_x_y, &mut reading, later));
assert_eq!(reading.current(later).translation, [50, 0, 0]);
assert_eq!(reading.current(later, Axes::default()).translation, [50, 0, 0]);
}
#[test]
@@ -524,8 +600,8 @@ mod tests {
let mut reading = Reading::new(now);
assert!(decode_report(&[1, 100, 0, 0, 0, 0, 0], layout, &mut reading, now));
// Just before the timeout X is still there; at exactly the timeout it's stale.
assert_eq!(reading.current(now + STALE_AFTER - Duration::from_millis(1)).translation, [100, 0, 0]);
assert_eq!(reading.current(now + STALE_AFTER).translation, [0; 3]);
assert_eq!(reading.current(now + STALE_AFTER - Duration::from_millis(1), Axes::default()).translation, [100, 0, 0]);
assert_eq!(reading.current(now + STALE_AFTER, Axes::default()).translation, [0; 3]);
// Button reports and reports with an id the layout doesn't know keep coming, but neither
// refreshes X: it goes stale on time all the same.
for step in 1..=10 {
@@ -533,7 +609,7 @@ mod tests {
assert!(decode_report(&[3, 1], layout, &mut reading, at));
assert!(!decode_report(&[0x17, 80], layout, &mut reading, at));
}
assert_eq!(reading.current(now + STALE_AFTER).translation, [0; 3]);
assert_eq!(reading.current(now + STALE_AFTER, Axes::default()).translation, [0; 3]);
}
#[test]
@@ -543,10 +619,10 @@ mod tests {
let later = now + STALE_AFTER * 20;
// spacenavd sends nothing while the values stay the same: the push lasts.
let held = Reading { axes, held: true, ..Reading::new(now) };
assert_eq!(held.current(later), axes);
assert_eq!(held.current(later, Axes::default()), axes);
// A HID device repeats its report: silence means the cap, or the device, is gone.
let hid = Reading { axes, ..Reading::new(now) };
assert_eq!(hid.current(later), Axes::default());
assert_eq!(hid.current(later, Axes::default()), Axes::default());
}
#[test]
@@ -618,8 +694,12 @@ mod tests {
mapping_checked_at: now,
wheel: WheelGuard::default(),
started: now,
calibration: None,
};
mouse.tick_at(&mut app, &Context::default(), now);
// A window that has run a frame with focus, as the app's always has by now.
let ctx = Context::default();
let _ = ctx.run_ui(egui::RawInput::default(), |_| {});
mouse.tick_at(&mut app, &ctx, now);
let DeviceMotion { motion, seconds } = app.viewport.device_motion.expect("the frame hands the page its motion");
assert_eq!(motion, Motion { translation: [0.0; 3], rotation: [0.0, 0.0, -1.0] });
assert!(seconds > 0.0);
@@ -642,9 +722,84 @@ mod tests {
mapping_checked_at: now,
wheel: WheelGuard::default(),
started: now,
calibration: None,
}
}
const DEVICE: Source = Source::Hid(Some((0x1234, 0x5678)));
/// A device whose raw axes all rest at 100, calibrated as such.
fn resting_at_100() -> Calibration {
Calibration {
dead_zone_share: 0.0,
axes: std::array::from_fn(|source| solidcraft_space_mouse::AxisCalibration {
source,
reversed: false,
rest: 100,
dead_zone: 0,
full: [350; 2],
}),
}
}
#[test]
fn a_calibrated_device_resting_off_zero_leaves_the_wheel_alone() {
// Read raw, X at 100 is the cap pushed past the dead zone: the wheel is dropped.
let now = Instant::now();
let mut resting = Reading::new(now);
resting.device = Some(DEVICE);
resting.axes = Axes { translation: [100; 3], rotation: [100; 3] };
let wheel_kept = |calibration| {
let mut mouse = mouse(resting, now);
mouse.calibration = calibration;
let mut raw = egui::RawInput { events: vec![wheel()], ..Default::default() };
mouse.raw_input_hook_at(true, &mut raw, now);
raw.events.len() == 1
};
assert!(!wheel_kept(None));
// Calibrated, it's at rest, so the wheel is the user's.
assert!(wheel_kept(Some((DEVICE, resting_at_100()))));
// Another device's calibration, kept from the last frame, isn't used for this one.
assert!(!wheel_kept(Some((Source::Hid(Some((0x256f, 0xc63a))), resting_at_100()))));
}
#[test]
fn a_calibrated_device_resting_off_zero_doesnt_drift_when_nothing_fresh_arrives() {
let mut app = SolveApp::new(solidcraft_engine::Session::default(), solidcraft_ui_egui::Services::default());
app.home.open = false;
app.preferences.space_mouse_calibrations.insert(DEVICE.key(), resting_at_100());
// A window that has run a frame with focus, as the app's always has by now.
let ctx = Context::default();
let mut frame = ctx.run_ui(egui::RawInput::default(), |_| {});
frame.textures_delta.clear();
let motion = |app: &mut SolveApp, reading: Reading, now: Instant| {
let mut mouse = mouse(reading, now);
mouse.last_frame = now - Duration::from_millis(20);
app.viewport.device_motion = None;
mouse.tick_at(app, &ctx, now);
app.viewport.device_motion.is_some()
};
// Connected, and nothing reported yet.
let shared = Mutex::new(Reading::new(Instant::now()));
connected(&Context::default(), &shared, DEVICE);
let fresh = *shared.lock().unwrap();
let now = Instant::now();
assert!(!motion(&mut app, fresh, now), "connected, nothing reported yet");
// The same, while another device's calibration window was open: it ends, and this one
// doesn't move on the first frame either.
app.space_mouse.calibrating = Some(("hid 256f:c63a".into(), solidcraft_space_mouse::Wizard::default()));
assert!(!motion(&mut app, fresh, now), "connected mid-calibration of another device");
assert!(app.space_mouse.calibrating.is_none());
// Translation reported at rest, rotation not for a while.
let old = now - STALE_AFTER - Duration::from_millis(1);
let partly = Reading { axes: Axes { translation: [100; 3], rotation: [0; 3] }, at: [now, now, now, old, old, old], ..fresh };
assert!(!motion(&mut app, partly, now), "translation at rest, rotation stale");
// Pushed, then the reports stopped.
let pushed = Reading { axes: Axes { translation: [450, 100, 100], rotation: [100; 3] }, at: [old; 6], ..fresh };
assert!(!motion(&mut app, pushed, now), "the push timed out");
assert!(motion(&mut app, Reading { at: [now; 6], ..pushed }, now), "a fresh push moves the view");
}
#[test]
fn the_wheel_3dxware_makes_from_the_cap_is_dropped() {
// The cap tilted forward: 3DxWare also sends wheel turns (#1380). They are dropped.
+27 -3
View File
@@ -1,6 +1,6 @@
//! Finding a 3D mouse on Linux. The kernel exposes every HID interface as `/dev/hidrawN`, with
//! its report descriptor under `/sys/class/hidraw/hidrawN/device/`. Reading it is plain file
//! I/O, so no udev or hidapi library is needed.
//! its report descriptor and ids under `/sys/class/hidraw/hidrawN/device/`. Reading them is plain
//! file I/O, so no udev or hidapi library is needed.
use std::path::{Path, PathBuf};
@@ -12,6 +12,8 @@ const DEV: &str = "/dev";
pub struct Device {
pub path: PathBuf,
pub layout: Layout,
/// Its vendor and product ids, when the kernel gives them.
pub ids: Option<(u16, u16)>,
}
/// The first hidraw interface that is a multi-axis controller with motion axes, whoever made it.
@@ -25,5 +27,27 @@ fn probe(sysfs: &Path, path: PathBuf) -> Option<Device> {
let descriptor = Descriptor::parse(&std::fs::read(sysfs.join("device/report_descriptor")).ok()?);
// A controller that declares no motion axis (buttons only, say) isn't read.
let layout = descriptor.is_multi_axis_controller().then(|| descriptor.layout()).flatten()?;
Some(Device { path, layout })
let ids = std::fs::read_to_string(sysfs.join("device/uevent")).ok().as_deref().and_then(ids);
Some(Device { path, layout, ids })
}
/// The vendor and product ids in a HID device's uevent: `HID_ID=bus:vendor:product`, in hex.
fn ids(uevent: &str) -> Option<(u16, u16)> {
let id = uevent.lines().find_map(|line| line.strip_prefix("HID_ID="))?;
let mut hex = id.split(':').skip(1).map(|part| u32::from_str_radix(part, 16).ok().and_then(|v| u16::try_from(v).ok()));
Some((hex.next()??, hex.next()??))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ids_come_from_the_uevent() {
let wireless = "DRIVER=hid-generic\nHID_ID=0005:0000256F:0000C63A\nHID_NAME=SpaceMouse Wireless BT\n";
assert_eq!(ids(wireless), Some((0x256f, 0xc63a)));
assert_eq!(ids("HID_ID=0003:0000046D\n"), None);
assert_eq!(ids("HID_ID=0003:0001046D:0000C62B\n"), None, "a vendor past 16 bits isn't a USB id");
assert_eq!(ids("HID_NAME=keyboard\n"), None);
}
}
+1
View File
@@ -35,3 +35,4 @@ sketch_scale = { master = "scale", accent = "sketch" }
sketch_text = { master = "text", accent = "sketch" }
surface_fair = { master = "spline_cv", accent = "surface" }
mesh_export = { master = "export", accent = "mesh" }
bom = "list"
+1
View File
@@ -218,6 +218,7 @@
"sheet": "774e30923ef6f37dd9c0b2bc3e45705e83b3a76700bf071cd41155a365d0daa4",
"sheet_rules": "b8c3a67c82be20fc00c109decc566e8755d70167e04e0a9fb92941f7b27aa993",
"shell": "a1959ea0d0441a479bf654b6e549e2e44a0bfdc9cda4eb46fe1fbfd884de7e38",
"silhouette": "2a848aed2f247b1c0439f830a34a513c93bc5493c40044c821d493d863ad996e",
"sketch": "b52e5862b1709a9a7e3ca00d7436d126fbfc01b38080006fa4e17dc581dfa103",
"sketch_break": "430582243311f304dbab397ce58c8c9aef9c78ed88d3e921a57e584ddb95d94c",
"sketch_chamfer": "80ab27a9929f9cf0ab2f827757e007173d74ddca6c19af238931c40d4cd874b4",
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Silhouette</title>
<path d="M8.62 18.5L32 32L32 59L8.62 45.5Z" fill="#b8c3cf"/>
<path d="M55.38 18.5L32 32L32 59L55.38 45.5Z" fill="#8e9faf"/>
<path d="M32 5L55.38 18.5L32 32L8.62 18.5Z" fill="#e2e8ef"/>
<path d="M8.62 18.5L32 32L55.38 18.5" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M32 32L32 59" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M32 5L55.38 18.5L55.38 45.5L32 59L8.62 45.5L8.62 18.5Z" fill="none" stroke="#ffffff" stroke-width="7.25" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M32 5L55.38 18.5L55.38 45.5L32 59L8.62 45.5L8.62 18.5Z" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 878 B

+2 -1
View File
@@ -113,7 +113,8 @@ def palette_css(palette):
def revision(path):
with open(path, 'rb') as source: return hashlib.sha256(source.read()).hexdigest()
# As save_colours reads it: text, so a CRLF checkout's line endings read as LF.
with open(path, encoding='utf-8') as source: return hashlib.sha256(source.read().encode()).hexdigest()
def save_colours(path, base_revision, changes):
+7 -4
View File
@@ -37,8 +37,11 @@ def art_rev():
paths += glob.glob(HERE + '/*.py')
paths += [D + '/palette.toml', D + '/aliases.toml', __file__]
for path in sorted(paths):
st = os.stat(path)
h.update(('%s %d %d;' % (os.path.relpath(path, ROOT), st.st_mtime_ns, st.st_size)).encode())
# Equal-length edits can share an mtime. Only the content identifies what
# the reviewer saw; file metadata must never authorize stale acceptance.
h.update(os.path.relpath(path, ROOT).replace(os.sep, '/').encode() + b'\0')
with open(path, 'rb') as source:
h.update(hashlib.sha256(review_state.lf(source.read())).digest())
return h.hexdigest()[:16]
@@ -82,7 +85,7 @@ def scan():
for f in glob.glob(ROOT + '/crates/ui-egui/src/**/*.rs', recursive=True):
t = open(f, encoding='utf-8').read()
for name in ui_icon_names(t):
uses[name].append('ui:' + os.path.relpath(f, ROOT + '/crates/ui-egui/src'))
uses[name].append('ui:' + os.path.relpath(f, ROOT + '/crates/ui-egui/src').replace(os.sep, '/'))
mk = ROOT + '/assets/icons/ui/source/markers.rs'
if os.path.exists(mk):
for name in re.findall(r'=> "(c_[a-z_]+)"', open(mk, encoding='utf-8').read()): uses[name].append('ui:sketch constraint glyph')
@@ -284,7 +287,7 @@ if '--serve' in args:
request_id = uuid.uuid4().hex
path = os.path.join(REQUESTS, request_id + '.json')
# Publish a complete snapshot atomically so the chat never reads half a request.
with open(path + '.tmp', 'w') as f:
with open(path + '.tmp', 'w', encoding='utf-8') as f:
json.dump({'version': 1, 'id': request_id,
'created_at': datetime.datetime.now(datetime.timezone.utc).isoformat(),
'notes': {i: r['notes'][i] for i in todo}}, f, indent=1)
+4 -6
View File
@@ -80,16 +80,14 @@ if os.path.exists(references):
for message in icon_palette.validation_errors(pal, names | set(al)):
err(d + '/palette.toml', message)
# an icon accepted as final on the review page must not change (un-accept it there to work on it again)
import json, hashlib
import json, review_state
rp = d + '/review.json'
if os.path.exists(rp):
fin = json.load(open(rp)).get('final', {})
fin = json.load(open(rp, encoding='utf-8')).get('final', {})
for iid, want in (fin.items() if isinstance(fin, dict) else []):
v = al.get(iid, iid); m = v.get('master') if isinstance(v, dict) else v
if not want: continue
h = hashlib.sha256()
for f in sorted(glob.glob(d + '/svg/*/' + m + '.svg')):
h.update(os.path.relpath(f, d).replace(os.sep, '/').encode()); h.update(open(f, 'rb').read())
if h.hexdigest() != want: err(rp, 'final icon %s changed (master %s): un-accept it on the review page before editing it' % (iid, m))
# The review page's own hash, so the two can't disagree.
if review_state.master_hash(m, d) != want: err(rp, 'final icon %s changed (master %s): un-accept it on the review page before editing it' % (iid, m))
print('\n'.join(bad) if bad else 'icons: %d masters, palette, naming and element rules ok' % len(files))
sys.exit(1 if bad else 0)
+2 -2
View File
@@ -14,9 +14,9 @@ for theme in ('light', 'dark'):
for fam in sorted(os.listdir(d + '/svg')):
h += '<div class="p %s"><h3>%s - %s</h3>' % (theme, fam, theme)
for f in sorted(glob.glob('%s/svg/%s/*.svg' % (d, fam))):
svg, icon = open(f).read(), os.path.basename(f)[:-4]
svg, icon = open(f, encoding='utf-8').read(), os.path.basename(f)[:-4]
h += '<div class=c>'
h += '<div style="width:64px;height:64px;margin:auto">%s</div>' % themed(svg, theme, icon, fam, 64).replace('<svg ', '<svg width=64 height=64 ', 1)
h += '<div class=s>' + ''.join('<span style="width:%dpx;height:%dpx;display:inline-block">%s</span>' % (s, s, themed(svg, theme, icon, fam, s).replace('<svg ', '<svg width=%d height=%d ' % (s, s), 1)) for s in (16, 20, 26)) + '</div>' + icon + '</div>'
h += '</div>'
open(out, 'w').write(h)
open(out, 'w', encoding='utf-8').write(h)
+7 -3
View File
@@ -16,10 +16,14 @@ def aliases():
def master_of(icon_id, al=None):
v = (aliases() if al is None else al).get(icon_id)
return (v['master'] if isinstance(v, dict) else v) if v else icon_id
def master_hash(master):
def lf(data):
"""Text as committed: a checkout with CRLF line endings (Git for Windows' default) hashes as LF."""
return data.replace(b'\r\n', b'\n')
def master_hash(master, design=None):
d = D if design is None else design
h = hashlib.sha256()
for f in sorted(glob.glob(D + '/svg/*/' + master + '.svg')):
h.update(os.path.relpath(f, D).replace(os.sep, '/').encode()); h.update(open(f, 'rb').read())
for f in sorted(glob.glob(d + '/svg/*/' + master + '.svg')):
h.update(os.path.relpath(f, d).replace(os.sep, '/').encode()); h.update(lf(open(f, 'rb').read()))
return h.hexdigest()
def load():
try: r = json.load(open(REVIEW, encoding='utf-8'))
+9
View File
@@ -89,6 +89,15 @@ pub(crate) fn pin_icon(p: &egui::Painter, r: Rect, on: bool, col: Color32) {
p.line_segment([at(0.0, -2.0), at(0.0, 7.0)], stroke);
}
/// A breadcrumb separator ("Sketch › Create"): a thin chevron about 3 by 6 px, centred on `c`.
/// Drawn rather than typed, because a font's "›" sits on the baseline in some languages
/// (Japanese), where it reads as a comma.
pub(crate) fn breadcrumb(p: &egui::Painter, c: egui::Pos2, col: Color32) {
let s = Stroke::new(1.0, col);
p.line_segment([pos2(c.x - 1.5, c.y - 3.0), pos2(c.x + 1.5, c.y)], s);
p.line_segment([pos2(c.x + 1.5, c.y), pos2(c.x - 1.5, c.y + 3.0)], s);
}
pub(crate) fn caret(p: &egui::Painter, c: egui::Pos2, col: Color32) {
p.add(egui::Shape::convex_polygon(vec![pos2(c.x - 3.5, c.y - 2.0), pos2(c.x + 3.5, c.y - 2.0), pos2(c.x, c.y + 2.5)], col, Stroke::NONE));
}
+3 -3
View File
@@ -1,10 +1,10 @@
//! Navigation drawing definitions. Camera projection and picking remain in the UI widgets.
use crate::theme::Tokens;
use egui::{Align2, Color32, FontId, Pos2, Rect, Shape, Stroke, pos2, vec2};
use solidcraft_engine::geom::Vec3;
use solidcraft_engine::geom::{Ring, Vec3};
pub(crate) struct Facet {
pub(crate) direction: Vec3,
pub(crate) points: Vec<Pos2>,
pub(crate) points: Ring<Pos2>,
pub(crate) outer_edges: Vec<bool>,
pub(crate) centre: Pos2,
pub(crate) label: String,
@@ -89,7 +89,7 @@ pub(crate) fn paint_cube(painter: &egui::Painter, faces: &[Facet], hovered: Opti
for &p in &face.points {
fill_mesh.colored_vertex(p, fill);
}
for (j, (&a, &b)) in face.points.iter().zip(face.points.iter().cycle().skip(1)).take(face.points.len()).enumerate() {
for (j, (&a, &b)) in face.points.edges().enumerate() {
fill_mesh.add_triangle(centre, centre + 1 + j as u32, centre + 1 + ((j + 1) % face.points.len()) as u32);
// Coplanar picking regions have no visible seams. Only actual cube
// edges and the hovered target's boundary receive an outline.
+51
View File
@@ -73,6 +73,7 @@
"Current orthographic view (1:1 mm)": "Aktuální pravoúhlý pohled (1:1 mm)",
"Current perspective view": "Aktuální perspektivní pohled",
"Curvature (G2)": "Křivost (G2)",
"Curvature change (G3)": "Změna křivosti (G3)",
"Customize Toolbar": "Přizpůsobit panel nástrojů",
"Delete all": "Smazat vše",
"Delete rule": "Smazat pravidlo",
@@ -98,6 +99,7 @@
"Export Current View as DXF…": "Exportovat aktuální pohled do DXF…",
"Export DXF…": "Exportovat DXF…",
"Export hidden edges as dashed lines": "Exportovat skryté hrany čárkovaně",
"Extend to meet": "Prodloužit k protnutí",
"Extents": "Rozsahy",
"Face": "Plocha",
"Face attachment lost": "Připojení k ploše ztraceno",
@@ -112,6 +114,7 @@
"Follow camera (Look At sketches)": "Sledovat kameru (náčrty natočené k pohledu)",
"Follow the camera (Look At sketches)": "Sledovat kameru (náčrty natočené k pohledu)",
"Font": "Písmo",
"From edges": "Výchozí hrany",
"Full Length": "Celá délka",
"Grid": "Mřížka",
"Height": "Výška",
@@ -126,7 +129,9 @@
"Interferes": "Koliduje",
"Issue title": "Název hlášení",
"Joint Origin1": "Počátek kloubu1",
"Keep (points on it)": "Ponechat (body na ní)",
"Keep Tools": "Ponechat nástroje",
"Keep of the other surface": "Ponechat z druhé plochy",
"Later": "Později",
"Leave it": "Nechat být",
"Legacy face attachment. Rebinding to a certified face creates an exact attachment.": "Starší typ připojení k ploše. Novým navázáním na ověřenou plochu vznikne přesné připojení.",
@@ -162,6 +167,7 @@
"Open GitHub draft": "Otevřít koncept na GitHubu",
"Open-source parametric 3D CAD in Rust. Licensed MIT OR Apache-2.0.": "Open-source parametrický 3D CAD v jazyce Rust. Licence MIT OR Apache-2.0.",
"Other side": "Druhá strana",
"Other surface (mutual trim)": "Druhá plocha (vzájemné oříznutí)",
"Outside": "Vně",
"Overall length": "Celková délka",
"Parameter values": "Hodnoty parametrů",
@@ -245,9 +251,13 @@
"Stand this part up?": "Postavit tento díl?",
"Step": "Krok",
"Steps": "Kroky",
"Stitch the kept pieces": "Sešít ponechané části",
"Stitch to both bodies": "Sešít s oběma tělesy",
"Style": "Styl",
"Suppressed": "Potlačeno",
"Surface": "Plocha",
"Swap Ends": "Prohodit konce",
"TRIM": "OŘÍZNOUT",
"Tangent (G1)": "Tečně (G1)",
"Tangent Chain": "Tečný řetězec",
"Tear": "Roztržení",
@@ -263,7 +273,9 @@
"This child is shared. Position changes apply to every copy of its parent.": "Tento potomek je sdílený. Změny polohy se projeví ve všech kopiích jeho rodiče.",
"This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "Tato plocha nemůže poskytnout trvalé připojení. Zvolte jinou plochu nebo výslovně použijte pevnou rovinu.",
"To Offset": "Odsazení k objektu",
"To edges": "Cílové hrany",
"Tolerance": "Tolerance",
"Trimming tool (plane, face or curves)": "Ořezávací nástroj (rovina, plocha nebo křivky)",
"Turn off section analysis before exporting the view.": "Před exportem pohledu vypněte analýzu řezu.",
"Type": "Typ",
"UNTRIM": "ZRUŠIT OŘEZ",
@@ -687,6 +699,15 @@
"joint.from_bodies_hint": "Každé vybrané těleso se přesune do vlastní nové komponenty a zůstane na místě.",
"joint.one_body": "Oba výběry jsou na {body}. Druhý vyberte na jiném dílu.",
"joint.one_component": "Tyto díly jsou tělesy jedné komponenty: {bodies}. Kloub pohybuje jednou komponentou vůči druhé, takže každý díl potřebuje vlastní komponentu.",
"messages.ago_h": "před {n} h",
"messages.ago_min": "před {n} min",
"messages.ago_s": "před {n} s",
"messages.chip_hint": "Klikněte pro všechny zprávy této relace",
"messages.clear": "Vymazat",
"messages.copy_all": "Kopírovat vše",
"messages.menu": "Zprávy…",
"messages.none": "Zatím žádné zprávy.",
"messages.title": "Zprávy",
"nav.touchpad.help": "Podržením Shift a pohybem ukazatele posouváte; podržením Alt/Option a pohybem otáčíte. Tlačítko myši není potřeba. Svislé posouvání přibližuje.",
"nav.touchpad.label": "Touchpad",
"nav.touchpad.orbit": "Otáčení (podržte Alt/Option a pohybujte; bez tlačítka myši)",
@@ -976,6 +997,7 @@
"ui.autosave_every": "Automaticky ukládat každých",
"ui.axes": "Osy",
"ui.axis": "Osa",
"ui.back": "Zpět",
"ui.base_point": "Základní bod",
"ui.blocked": "Blokováno",
"ui.bodies": "Tělesa",
@@ -1337,6 +1359,8 @@
"ui.save_mesh_pick_bodies": "Vyberte tělesa k uložení",
"ui.save_mesh_temp_failed": "Nelze vytvořit {folder}: {error}",
"ui.saved_to": "Uloženo {path}",
"ui.sbox_pin": "Připnout nahoru ve vyhledávání S",
"ui.sbox_unpin": "Odepnout",
"ui.scale": "Měřítko",
"ui.section_analysis": "Analýza řezu",
"ui.select": "Vybrat",
@@ -1379,8 +1403,34 @@
"ui.smooth_wheel_zoom": "Plynulé přiblížení kolečkem",
"ui.smooth_wheel_zoom_tip": "Vypnutím se každý pohyb kolečka projeví okamžitě, bez dobíhání",
"ui.snap": "Přichytávání",
"ui.space_mouse_calibrate": "Kalibrovat…",
"ui.space_mouse_calibrate_tip": "Pohybujte hlavicí podle pokynů v okně, aby se SolidCraft naučil, kterým směrem a jak daleko se pohybuje každá osa této 3D myši. Uloží se pro tuto 3D myš.",
"ui.space_mouse_calibrate_title": "Kalibrace 3D myši",
"ui.space_mouse_calibrated": "Zkalibrováno",
"ui.space_mouse_calibration": "Kalibrace 3D myši",
"ui.space_mouse_calibration_done": "Hotovo. Uložte ji a použije se vždy, když bude tato 3D myš připojená.",
"ui.space_mouse_calibration_no_response": "Jedna osa se skoro nepohnula, takže ji nelze zkalibrovat. Začněte znovu a pokaždé hlavicí pohněte dál.",
"ui.space_mouse_dead_zone": "Mrtvá zóna",
"ui.space_mouse_dead_zone_tip": "Jak daleko se musí hlavice pohnout, než se pohne pohled, jako podíl jejího plného rozsahu. Zvyšte ji, pokud pohled ujíždí, i když je hlavice v klidu.",
"ui.space_mouse_forget": "Zapomenout kalibraci",
"ui.space_mouse_hint": "Posun a otáčení pohledu současně; náčrty používají jen posun a přiblížení.",
"ui.space_mouse_navigate": "Navigace pomocí SpaceNavigator / SpaceMouse",
"ui.space_mouse_none": "3D myš nenalezena",
"ui.space_mouse_not_calibrated": "Nezkalibrováno",
"ui.space_mouse_reading": "Hodnoty",
"ui.space_mouse_step_lift": "Zvedněte hlavici nahoru, podržte a pusťte.",
"ui.space_mouse_step_press": "Zatlačte hlavici dolů, podržte a pusťte.",
"ui.space_mouse_step_pull": "Přitáhněte hlavici k sobě, podržte a pusťte.",
"ui.space_mouse_step_push": "Odtlačte hlavici od sebe, podržte a pusťte.",
"ui.space_mouse_step_rest": "Dejte ruku z hlavice a nechte ji v klidu.",
"ui.space_mouse_step_slide_left": "Posuňte hlavici doleva, podržte a pusťte.",
"ui.space_mouse_step_slide_right": "Posuňte hlavici doprava, podržte a pusťte.",
"ui.space_mouse_step_tilt_back": "Nakloňte hlavici dozadu (přední hrana nahoru), podržte a pusťte.",
"ui.space_mouse_step_tilt_forward": "Nakloňte hlavici dopředu (přední hrana dolů), podržte a pusťte.",
"ui.space_mouse_step_tilt_left": "Nakloňte hlavici doleva (levá hrana dolů), podržte a pusťte.",
"ui.space_mouse_step_tilt_right": "Nakloňte hlavici doprava (pravá hrana dolů), podržte a pusťte.",
"ui.space_mouse_step_twist_clockwise": "Otočte hlavici po směru hodinových ručiček, podržte a pusťte.",
"ui.space_mouse_step_twist_counterclockwise": "Otočte hlavici proti směru hodinových ručiček, podržte a pusťte.",
"ui.spacing": "Rozteč",
"ui.spacing_2": "Rozteč 2",
"ui.sphere": "Koule",
@@ -1388,6 +1438,7 @@
"ui.split_body": "Rozdělit těleso",
"ui.stand_up_tip": "Když se soubor STEP nebo IGES otevře s osou Z nahoru, nabídnout otočení o čtvrt otáčky kolem X",
"ui.start": "Start",
"ui.start_over": "Začít znovu",
"ui.start_radius": "Počáteční poloměr",
"ui.supersampling": "Supersampling (SSAA)",
"ui.supersampling_adaptive": "Jen když je pohled v klidu",
File diff suppressed because it is too large Load Diff
+52
View File
@@ -26,6 +26,7 @@
"Current orthographic view (1:1 mm)": "Aktuelle orthogonale Ansicht (1:1 mm)",
"Current perspective view": "Aktuelle perspektivische Ansicht",
"Curvature (G2)": "Krümmung (G2)",
"Curvature change (G3)": "Krümmungsänderung (G3)",
"Direction": "Richtung",
"Do not export hidden edges": "Verdeckte Kanten nicht exportieren",
"Don't ask again": "Nicht mehr fragen",
@@ -39,16 +40,20 @@
"Export Current View as DXF…": "Aktuelle Ansicht als DXF exportieren…",
"Export DXF…": "DXF exportieren…",
"Export hidden edges as dashed lines": "Verdeckte Kanten gestrichelt exportieren",
"Extend to meet": "Verlängern, bis sie sich treffen",
"Face": "Fläche",
"Faces": "Flächen",
"Find in Browser": "Im Browser suchen",
"Flat": "Flach",
"From edges": "Ausgangskanten",
"Hidden edges": "Verdeckte Kanten",
"Holes": "Löcher",
"Holes (edges on them)": "Löcher (ihre Kanten)",
"In place (surfaces)": "An Ort und Stelle (Flächen)",
"In plane": "In der Ebene",
"Inside": "Innen",
"Keep (points on it)": "Behalten (Punkte darauf)",
"Keep of the other surface": "Vom anderen Flächenkörper behalten",
"Length": "Länge",
"Max": "Max",
"Min": "Min",
@@ -58,6 +63,7 @@
"None": "Keine",
"Normal to sketch": "Normal zur Skizze",
"Notch": "Ausklinkung",
"Other surface (mutual trim)": "Anderer Flächenkörper (gegenseitig trimmen)",
"Outside": "Außen",
"Overall length": "Gesamtlänge",
"Pass through": "Durch Punkte",
@@ -87,12 +93,18 @@
"Sketch geometry and profiles": "Skizzengeometrie und Profile",
"Sketch points": "Skizzenpunkte",
"Slot width": "Langlochbreite",
"Stitch the kept pieces": "Behaltene Teile vernähen",
"Stitch to both bodies": "Mit beiden Körpern vernähen",
"Study": "Untersuchung",
"Surface": "Flächenkörper",
"TRIM": "TRIMMEN",
"Tangent (G1)": "Tangente (G1)",
"Tear": "Reißen",
"Thickness Outside": "Dicke außen",
"Thin": "Dünnwandig",
"Thin Extrude": "Dünnwandig extrudieren",
"To edges": "Zielkanten",
"Trimming tool (plane, face or curves)": "Trimmwerkzeug (Ebene, Fläche oder Kurven)",
"Turn off section analysis before exporting the view.": "Deaktivieren Sie die Schnittanalyse, bevor Sie die Ansicht exportieren.",
"Type": "Typ",
"UNTRIM": "TRIMMEN AUFHEBEN",
@@ -238,6 +250,7 @@
"cmd.joint_rigid_group": "Starrgruppe",
"cmd.joint_screw": "Schraube",
"cmd.joint_solve": "Gelenke lösen",
"cmd.joint_sweep": "Gelenk bis zum Kontakt durchfahren",
"cmd.library_apply": "Lokalen Bibliothekseintrag anwenden",
"cmd.library_export": "Designbibliothek exportieren",
"cmd.library_inspect": "Lokale Bibliothek prüfen",
@@ -425,8 +438,10 @@
"cmd.timeline_roll_to": "Verlaufsmarkierung verschieben",
"cmd.timeline_rollback": "Zeitleistenmarkierung verschieben",
"cmd.timeline_suppress": "Merkmal unterdrücken",
"cmd.view_ambient_occlusion": "Umgebungsverdeckung",
"cmd.view_apply": "Benannte Ansicht wiederherstellen",
"cmd.view_delete": "Benannte Ansicht löschen",
"cmd.view_graphics_preset": "Grafikprofil",
"cmd.view_list": "Benannte Ansichten auflisten",
"cmd.view_navigate": "Ansicht navigieren",
"cmd.view_rename": "Benannte Ansicht umbenennen",
@@ -756,6 +771,8 @@
"ui.ai_toolbar_button_note": "Die KI-Schaltfläche am rechten Ende der Werkzeugleiste. Strg+Umschalt+A und das Ansicht-Menü öffnen das Panel weiterhin.",
"ui.ai_undo_tip": "Alles, was der Agent für diese Nachricht geändert hat, in einem Schritt rückgängig machen",
"ui.all": "Alle",
"ui.ambient_occlusion": "Umgebungsverdeckung",
"ui.ambient_occlusion_help": "Sanfte Schattierung an Kontaktstellen und in Vertiefungen undurchsichtiger Flächen. Erfordert die GPU-Ansicht.",
"ui.analysis": "Analyse",
"ui.angle": "Winkel",
"ui.angle_decimals": "Winkel-Dezimalstellen",
@@ -770,6 +787,7 @@
"ui.autosave_every": "Autospeichern alle",
"ui.axes": "Achsen",
"ui.axis": "Achse",
"ui.back": "Zurück",
"ui.base_point": "Basispunkt",
"ui.blocked": "Verdeckt",
"ui.bodies": "Körper",
@@ -910,6 +928,11 @@
"ui.fully_constrained": "Vollständig bestimmt",
"ui.general": "Allgemein",
"ui.graphics": "Grafik",
"ui.graphics_custom": "Benutzerdefiniert",
"ui.graphics_performance": "Leistung",
"ui.graphics_preset": "Grafikprofil",
"ui.graphics_preset_help": "Qualität: Umgebungsverdeckung, feine Anzeigequalität und adaptives 2× Supersampling. Leistung: keine Umgebungsverdeckung, normale Anzeigequalität und kein Supersampling. Manuelle Änderungen: Benutzerdefiniert. MSAA bleibt unverändert.",
"ui.graphics_quality": "Qualität",
"ui.groupSelected": "Auswahl gruppieren",
"ui.heading_assemble": "ZUSAMMENBAU",
"ui.heading_box": "BLOCK",
@@ -1118,6 +1141,8 @@
"ui.save_mesh_pick_bodies": "Wähle die Körper zum Speichern aus",
"ui.save_mesh_temp_failed": "{folder} konnte nicht angelegt werden: {error}",
"ui.saved_to": "Gespeichert: {path}",
"ui.sbox_pin": "Oben im S-Feld anheften",
"ui.sbox_unpin": "Lösen",
"ui.scale": "Skalieren",
"ui.second_rail": "Zweite Leitkurve",
"ui.section_analysis": "Querschnittsanalyse",
@@ -1158,12 +1183,39 @@
"ui.slot": "Langloch",
"ui.smooth": "Glatt",
"ui.snap": "Einrasten",
"ui.space_mouse_calibrate": "Kalibrieren…",
"ui.space_mouse_calibrate_tip": "Bewegen Sie die Kappe so, wie das Fenster es verlangt. So lernt SolidCraft, in welche Richtung und wie weit jede Achse dieser 3D-Maus geht. Wird für diese 3D-Maus gespeichert.",
"ui.space_mouse_calibrate_title": "3D-Maus kalibrieren",
"ui.space_mouse_calibrated": "Kalibriert",
"ui.space_mouse_calibration": "3D-Maus-Kalibrierung",
"ui.space_mouse_calibration_done": "Fertig. Speichern Sie die Kalibrierung, damit sie immer gilt, wenn diese 3D-Maus angeschlossen ist.",
"ui.space_mouse_calibration_no_response": "Eine Achse hat sich kaum bewegt und lässt sich nicht kalibrieren. Beginnen Sie von vorn und bewegen Sie die Kappe jedes Mal weiter.",
"ui.space_mouse_dead_zone": "Totzone",
"ui.space_mouse_dead_zone_tip": "Wie weit sich die Kappe bewegen muss, bevor sich die Ansicht bewegt, als Anteil ihres vollen Wegs. Erhöhen Sie sie, wenn die Ansicht wandert, obwohl die Kappe ruht.",
"ui.space_mouse_forget": "Kalibrierung vergessen",
"ui.space_mouse_none": "Keine 3D-Maus gefunden",
"ui.space_mouse_not_calibrated": "Nicht kalibriert",
"ui.space_mouse_reading": "Werte",
"ui.space_mouse_step_lift": "Kappe nach oben ziehen, halten, dann loslassen.",
"ui.space_mouse_step_press": "Kappe nach unten drücken, halten, dann loslassen.",
"ui.space_mouse_step_pull": "Kappe zu sich heranziehen, halten, dann loslassen.",
"ui.space_mouse_step_push": "Kappe von sich wegschieben, halten, dann loslassen.",
"ui.space_mouse_step_rest": "Nehmen Sie die Hand von der Kappe und lassen Sie sie ruhen.",
"ui.space_mouse_step_slide_left": "Kappe nach links schieben, halten, dann loslassen.",
"ui.space_mouse_step_slide_right": "Kappe nach rechts schieben, halten, dann loslassen.",
"ui.space_mouse_step_tilt_back": "Kappe nach hinten kippen (Vorderkante hoch), halten, dann loslassen.",
"ui.space_mouse_step_tilt_forward": "Kappe nach vorn kippen (Vorderkante runter), halten, dann loslassen.",
"ui.space_mouse_step_tilt_left": "Kappe nach links kippen (linke Kante runter), halten, dann loslassen.",
"ui.space_mouse_step_tilt_right": "Kappe nach rechts kippen (rechte Kante runter), halten, dann loslassen.",
"ui.space_mouse_step_twist_clockwise": "Kappe im Uhrzeigersinn drehen, halten, dann loslassen.",
"ui.space_mouse_step_twist_counterclockwise": "Kappe gegen den Uhrzeigersinn drehen, halten, dann loslassen.",
"ui.spacing": "Abstand",
"ui.spacing_2": "Abstand 2",
"ui.sphere": "Sphäre",
"ui.spline": "Spline",
"ui.split_body": "Körper trennen",
"ui.start": "Start",
"ui.start_over": "Von vorn beginnen",
"ui.start_radius": "Startradius",
"ui.stretch": "Strecken",
"ui.supersampling": "Supersampling (SSAA)",
+95 -2
View File
@@ -31,6 +31,7 @@
"Arc length": "Arc length",
"Area": "Area",
"Attachment": "Attachment",
"BLEND SURFACE": "BLEND SURFACE",
"BOUNDARY SURFACE": "BOUNDARY SURFACE",
"BROWSER": "BROWSER",
"Bend Line Position": "Bend Line Position",
@@ -79,6 +80,7 @@
"Current orthographic view (1:1 mm)": "Current orthographic view (1:1 mm)",
"Current perspective view": "Current perspective view",
"Curvature (G2)": "Curvature (G2)",
"Curvature change (G3)": "Curvature change (G3)",
"Customize Toolbar": "Customize Toolbar",
"Delete all": "Delete all",
"Delete rule": "Delete rule",
@@ -100,6 +102,8 @@
"Edit Profile Sketch": "Edit Profile Sketch",
"Enable rule": "Enable rule",
"Enabled": "Enabled",
"End bulge": "End bulge",
"End continuity": "End continuity",
"Explode": "Explode",
"Explode Text": "Explode Text",
"Export Animation…": "Export Animation…",
@@ -107,6 +111,7 @@
"Export Current View as DXF…": "Export Current View as DXF…",
"Export DXF…": "Export DXF…",
"Export hidden edges as dashed lines": "Export hidden edges as dashed lines",
"Extend to meet": "Extend to meet",
"Extents": "Extents",
"Face": "Face",
"Face attachment lost": "Face attachment lost",
@@ -121,6 +126,7 @@
"Follow camera (Look At sketches)": "Follow camera (Look At sketches)",
"Follow the camera (Look At sketches)": "Follow the camera (Look At sketches)",
"Font": "Font",
"From edges": "From edges",
"Full Length": "Full Length",
"Grid": "Grid",
"Height": "Height",
@@ -135,7 +141,9 @@
"Interferes": "Interferes",
"Issue title": "Issue title",
"Joint Origin1": "Joint Origin1",
"Keep (points on it)": "Keep (points on it)",
"Keep Tools": "Keep Tools",
"Keep of the other surface": "Keep of the other surface",
"Later": "Later",
"Leave it": "Leave it",
"Legacy face attachment. Rebinding to a certified face creates an exact attachment.": "Legacy face attachment. Rebinding to a certified face creates an exact attachment.",
@@ -173,6 +181,7 @@
"Open GitHub draft": "Open GitHub draft",
"Open-source parametric 3D CAD in Rust. Licensed MIT OR Apache-2.0.": "Open-source parametric 3D CAD in Rust. Licensed MIT OR Apache-2.0.",
"Other side": "Other side",
"Other surface (mutual trim)": "Other surface (mutual trim)",
"Outside": "Outside",
"Overall length": "Overall length",
"Parameter values": "Parameter values",
@@ -258,12 +267,18 @@
"Stand it up": "Stand it up",
"Stand it up: a quarter turn about X, as one Move feature you can undo.": "Stand it up: a quarter turn about X, as one Move feature you can undo.",
"Stand this part up?": "Stand this part up?",
"Start bulge": "Start bulge",
"Start continuity": "Start continuity",
"Step": "Step",
"Steps": "Steps",
"Stitch the kept pieces": "Stitch the kept pieces",
"Stitch to both bodies": "Stitch to both bodies",
"Study": "Study",
"Style": "Style",
"Suppressed": "Suppressed",
"Surface": "Surface",
"Swap Ends": "Swap Ends",
"TRIM": "TRIM",
"Tangent (G1)": "Tangent (G1)",
"Tangent Chain": "Tangent Chain",
"Tear": "Tear",
@@ -279,8 +294,10 @@
"This child is shared. Position changes apply to every copy of its parent.": "This child is shared. Position changes apply to every copy of its parent.",
"This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.",
"To Offset": "To Offset",
"To edges": "To edges",
"Tolerance": "Tolerance",
"Too close": "Too close",
"Trimming tool (plane, face or curves)": "Trimming tool (plane, face or curves)",
"Turn off section analysis before exporting the view.": "Turn off section analysis before exporting the view.",
"Type": "Type",
"UNTRIM": "UNTRIM",
@@ -325,6 +342,7 @@
"cmd.appearance_assign": "Appearance",
"cmd.appearance_library": "Appearance Library",
"cmd.appearance_list": "List Appearances",
"cmd.assembly_bom": "Bill of Materials",
"cmd.body_rename": "Rename Body",
"cmd.browser_group": "New Group",
"cmd.browser_move": "Move Browser Items",
@@ -346,10 +364,13 @@
"cmd.component_insert_design": "Insert Design",
"cmd.component_links": "List Links",
"cmd.component_list": "List Components",
"cmd.component_metadata": "Part Properties",
"cmd.component_metadata_set": "Change Part Properties",
"cmd.component_move_bodies": "Move Bodies to Component",
"cmd.component_move_sketches": "Move Sketches to Component",
"cmd.component_paste_new": "Paste New",
"cmd.component_properties": "Component Physical Properties",
"cmd.component_relink": "Relink Source",
"cmd.component_rename": "Rename Component",
"cmd.component_revert_position": "Revert Position",
"cmd.component_unlock": "Unlock Contents",
@@ -401,6 +422,7 @@
"cmd.edit_checkpoint": "Checkpoint",
"cmd.edit_restore_checkpoint": "Restore Checkpoint",
"cmd.engine_commands": "List Commands",
"cmd.engine_describe": "Describe Command",
"cmd.explode_create": "Exploded View",
"cmd.explode_delete": "Delete Exploded View",
"cmd.explode_list": "List Exploded Views",
@@ -433,6 +455,7 @@
"cmd.joint_drive": "Drive Joints",
"cmd.joint_edit": "Edit Joint",
"cmd.joint_gear": "Gear",
"cmd.joint_inspect": "Inspect Joint",
"cmd.joint_limits": "Joint Limits",
"cmd.joint_list": "List Joints",
"cmd.joint_motion_link": "Motion Link",
@@ -442,6 +465,7 @@
"cmd.joint_rigid_group": "Rigid Group",
"cmd.joint_screw": "Screw",
"cmd.joint_solve": "Solve Joints",
"cmd.joint_sweep": "Sweep Joint",
"cmd.library_apply": "Apply Local Library Entry",
"cmd.library_export": "Export Design Library",
"cmd.library_inspect": "Inspect Local Library",
@@ -590,6 +614,7 @@
"cmd.sketch_spline_fit_point": "Fit Point Spline",
"cmd.sketch_spun_profile": "Spun Profile",
"cmd.sketch_toggle_driven": "Toggle Driven Dimension",
"cmd.sketch_trim_path": "Trim Along Path",
"cmd.solid_align": "Align",
"cmd.solid_boundary_fill": "Boundary Fill",
"cmd.solid_bounding_solid": "Bounding Solid",
@@ -632,8 +657,10 @@
"cmd.timeline_roll_to": "Roll History Marker",
"cmd.timeline_rollback": "Move Timeline Marker",
"cmd.timeline_suppress": "Suppress Feature",
"cmd.view_ambient_occlusion": "Ambient Occlusion",
"cmd.view_apply": "Restore Named View",
"cmd.view_delete": "Delete Named View",
"cmd.view_graphics_preset": "Graphics Preset",
"cmd.view_list": "List Named Views",
"cmd.view_navigate": "Navigate View",
"cmd.view_rename": "Rename Named View",
@@ -716,6 +743,15 @@
"joint.from_bodies_hint": "Each picked body moves into a new component of its own and stays where it is.",
"joint.one_body": "Both picks are on {body}. Pick the second one on another part.",
"joint.one_component": "These parts are bodies of one component: {bodies}. A joint moves one component against another, so each part needs a component of its own.",
"messages.ago_h": "{n} h ago",
"messages.ago_min": "{n} min ago",
"messages.ago_s": "{n} s ago",
"messages.chip_hint": "Click for all messages of this session",
"messages.clear": "Clear",
"messages.copy_all": "Copy all",
"messages.menu": "Messages…",
"messages.none": "No messages yet.",
"messages.title": "Messages",
"motion.checked_to": "Checked to step {step} only: the check ran out of its work budget",
"motion.collision": "{a} · {b}: steps {steps}",
"motion.duration_hint": "seconds (empty: per step)",
@@ -857,7 +893,7 @@
"ui.agent_chat_approval": "Allow the agent to do this?",
"ui.agent_chat_attach_view": "Attach view",
"ui.agent_chat_attach_view_tip": "Send a picture of the current view with the message",
"ui.agent_chat_attachable": "Pictures, text and CAD files",
"ui.agent_chat_attachable": "Pictures, PDFs, text and CAD files",
"ui.agent_chat_auth_agent": "Sign in with {name}",
"ui.agent_chat_auth_env": "Set {vars} in your environment and restart SolidCraft.",
"ui.agent_chat_auth_needed": "The agent asks you to sign in. You sign in with the agent itself; SolidCraft never sees your password or keys.",
@@ -889,7 +925,6 @@
"ui.agent_chat_file_error": "{name}: {error}",
"ui.agent_chat_file_heic": "{name}: HEIC pictures can't be read here; export it as JPEG or PNG first",
"ui.agent_chat_file_importing": "Importing into the design…",
"ui.agent_chat_file_kinds": "{name}: pictures (PNG, JPEG, GIF, WebP, BMP, TIFF), text files (SVG too) and CAD files (STEP, IGES, FreeCAD, Rhino, STL, 3MF, OBJ, glTF) can be attached",
"ui.agent_chat_file_loading": "Reading the file…",
"ui.agent_chat_file_not_a_file": "{name}: not a file",
"ui.agent_chat_file_not_a_picture": "{name}: not a picture",
@@ -1036,6 +1071,8 @@
"ui.ai_toolbar_button_note": "The AI button at the right end of the toolbar. Ctrl+Shift+A and the View menu still open the panel.",
"ui.ai_undo_tip": "Undo everything the agent changed for this message, as one step",
"ui.all": "All",
"ui.ambient_occlusion": "Ambient occlusion",
"ui.ambient_occlusion_help": "Soft shading where opaque surfaces meet. Requires the GPU viewport.",
"ui.analysis": "Analysis",
"ui.angle": "Angle",
"ui.angle_decimals": "Angle decimals",
@@ -1050,6 +1087,7 @@
"ui.autosave_every": "Autosave every",
"ui.axes": "Axes",
"ui.axis": "Axis",
"ui.back": "Back",
"ui.base_point": "Base point",
"ui.blocked": "Blocked",
"ui.bodies": "Bodies",
@@ -1196,6 +1234,11 @@
"ui.fully_constrained": "Fully constrained",
"ui.general": "General",
"ui.graphics": "Graphics",
"ui.graphics_custom": "Custom",
"ui.graphics_performance": "Performance",
"ui.graphics_preset": "Graphics Preset",
"ui.graphics_preset_help": "Quality: ambient occlusion, Fine display quality and adaptive 2× supersampling. Performance: no ambient occlusion, Normal display quality and supersampling off. Manual changes show Custom. MSAA stays unchanged.",
"ui.graphics_quality": "Quality",
"ui.grid_follows_view": "Grid follows the view",
"ui.grid_follows_view_hint": "Side views show the grid on the upright plane facing them instead of the ground",
"ui.ground_shadow": "Ground shadow",
@@ -1298,6 +1341,8 @@
"ui.loft": "Loft",
"ui.look_at": "Look At",
"ui.look_at_anim_desc": "Axis view, Look At, Home and Fit",
"ui.manipulator_free_alt": "Manipulator free drag also on Alt/Option",
"ui.manipulator_free_alt_tip": "On: Alt/Option or Ctrl/Cmd on extrude / gizmo arrows disables value snapping. Off: Ctrl/Cmd only.",
"ui.manufacture": "Manufacture",
"ui.material_review.axes": "Material-local axes; no body orientation is assigned.",
"ui.material_review.claims": "Sources and licences are unverified claims. Missing values remain unknown.",
@@ -1337,6 +1382,7 @@
"ui.newSketchGroup": "New Sketch Group",
"ui.new_body": "New Body",
"ui.new_design": "New Design",
"ui.no_geometry_yet": "No geometry yet",
"ui.no_slicer_found": "No slicer found",
"ui.normal": "Normal",
"ui.normalConstruction": "Normal / Construction",
@@ -1364,6 +1410,7 @@
"ui.pan_zoom_orbit": "Pan, zoom, orbit",
"ui.parallel": "Parallel",
"ui.parameters": "Parameters",
"ui.part_size_shared": "Rebuilds the part, replacing any added features. All occurrences of this component update.",
"ui.paste": "Paste",
"ui.path": "Path",
"ui.pattern_on_path": "Pattern on Path",
@@ -1375,6 +1422,12 @@
"ui.placement": "Placement",
"ui.plane": "Plane",
"ui.plane_at_angle": "Plane at Angle",
"ui.plugins": "Plugins",
"ui.plugins_desktop": "Plugins are available in the desktop app.",
"ui.plugins_empty": "No plugins installed.",
"ui.plugins_note": "Each plugin is a folder inside {path} with a plugin.json. SolidCraft reads that file and does not run any code from the folder. Turning a plugin off keeps it installed.",
"ui.plugins_skipped": "{count} folder(s) could not be read.",
"ui.plugins_version": "{version}",
"ui.point": "Point",
"ui.point_under_the_cursor": "Point under the cursor",
"ui.points": "Points",
@@ -1400,6 +1453,11 @@
"ui.rectangle": "Rectangle",
"ui.rectangular_pattern": "Rectangular Pattern",
"ui.redo": "Redo",
"ui.relink_design_changed": "The assembly changed while choosing a source. Start Relink Source again.",
"ui.relink_done": "Source relinked for all instances.",
"ui.relink_not_linked": "This component is not linked to a source design.",
"ui.relink_select_one": "Select one linked component or one of its instances first.",
"ui.relink_source_all": "Relink Source (All Instances)…",
"ui.removeFromToolbar": "Remove from Toolbar",
"ui.removeSection": "Remove Section",
"ui.render": "Render",
@@ -1423,6 +1481,8 @@
"ui.save_mesh_pick_bodies": "Select the bodies to save",
"ui.save_mesh_temp_failed": "Couldn't make {folder}: {error}",
"ui.saved_to": "Saved {path}",
"ui.sbox_pin": "Pin to the top of the S box",
"ui.sbox_unpin": "Unpin",
"ui.scale": "Scale",
"ui.second_rail": "Second Rail",
"ui.section_analysis": "Section Analysis",
@@ -1431,6 +1491,9 @@
"ui.selectTangentChain": "Select Tangent Chain",
"ui.select_other_hint": "Select Other: hold the left button still on the model (or right-click › Select Other…) to list everything under the cursor; click again on the same spot to select the next thing there.",
"ui.selection": "Selection",
"ui.selection_modifiers": "Selection modifiers",
"ui.selection_shift_add_only": "Shift+click adds without removing",
"ui.selection_shift_add_only_tip": "On: Shift adds items, Ctrl/Cmd toggles. Off (default): Shift and Ctrl/Cmd both toggle.",
"ui.send": "Send",
"ui.send_to_slicer": "Send to slicer",
"ui.sent_to_slicer": "Sent to {slicer}",
@@ -1455,6 +1518,7 @@
"ui.sketch": "Sketch",
"ui.sketch_dimension": "Sketch Dimension",
"ui.sketch_grid": "Sketch Grid",
"ui.sketch_modifiers": "Sketch modifiers",
"ui.sketch_scale": "Sketch Scale",
"ui.sketch_status": "Sketch Status",
"ui.sketches": "Sketches",
@@ -1466,8 +1530,34 @@
"ui.smooth_wheel_zoom": "Smooth wheel zoom",
"ui.smooth_wheel_zoom_tip": "Turn off to apply each wheel event immediately, without a smoothing tail",
"ui.snap": "Snap",
"ui.space_mouse_calibrate": "Calibrate…",
"ui.space_mouse_calibrate_tip": "Move the cap as the window asks, so SolidCraft learns which way each axis of this 3D mouse goes and how far. Saved for this 3D mouse.",
"ui.space_mouse_calibrate_title": "Calibrate 3D mouse",
"ui.space_mouse_calibrated": "Calibrated",
"ui.space_mouse_calibration": "3D mouse calibration",
"ui.space_mouse_calibration_done": "Done. Save it to use it whenever this 3D mouse is connected.",
"ui.space_mouse_calibration_no_response": "One axis barely moved, so it can't be calibrated. Start over and move the cap further each time.",
"ui.space_mouse_dead_zone": "Dead zone",
"ui.space_mouse_dead_zone_tip": "How far the cap has to move before the view does, as a share of its full travel. Raise it if the view drifts while the cap is at rest.",
"ui.space_mouse_forget": "Forget calibration",
"ui.space_mouse_hint": "Move and rotate the view together; sketches use pan and zoom only.",
"ui.space_mouse_navigate": "Navigate with SpaceNavigator / SpaceMouse",
"ui.space_mouse_none": "No 3D mouse found",
"ui.space_mouse_not_calibrated": "Not calibrated",
"ui.space_mouse_reading": "Reading",
"ui.space_mouse_step_lift": "Lift the cap up, hold it, then let go.",
"ui.space_mouse_step_press": "Press the cap down, hold it, then let go.",
"ui.space_mouse_step_pull": "Pull the cap toward you, hold it, then let go.",
"ui.space_mouse_step_push": "Push the cap away from you, hold it, then let go.",
"ui.space_mouse_step_rest": "Take your hand off the cap and leave it still.",
"ui.space_mouse_step_slide_left": "Slide the cap left, hold it, then let go.",
"ui.space_mouse_step_slide_right": "Slide the cap right, hold it, then let go.",
"ui.space_mouse_step_tilt_back": "Tilt the cap back (front edge up), hold it, then let go.",
"ui.space_mouse_step_tilt_forward": "Tilt the cap forward (front edge down), hold it, then let go.",
"ui.space_mouse_step_tilt_left": "Tilt the cap left (left edge down), hold it, then let go.",
"ui.space_mouse_step_tilt_right": "Tilt the cap right (right edge down), hold it, then let go.",
"ui.space_mouse_step_twist_clockwise": "Twist the cap clockwise, hold it, then let go.",
"ui.space_mouse_step_twist_counterclockwise": "Twist the cap anticlockwise, hold it, then let go.",
"ui.spacing": "Spacing",
"ui.spacing_2": "Spacing 2",
"ui.sphere": "Sphere",
@@ -1475,12 +1565,15 @@
"ui.split_body": "Split Body",
"ui.stand_up_tip": "After a STEP or IGES file opens with Z up, offer a quarter turn about X",
"ui.start": "Start",
"ui.start_over": "Start over",
"ui.start_radius": "Start Radius",
"ui.stretch": "Stretch",
"ui.supersampling": "Supersampling (SSAA)",
"ui.supersampling_adaptive": "Only when the view is still",
"ui.supersampling_off": "Off (1×)",
"ui.suppress": "Suppress",
"ui.suppress_auto_snap": "Suppress auto-snap while drawing / dragging",
"ui.suppress_auto_snap_note": "Default: Ctrl/Cmd or Alt/Option each suppress sketch snapping. Choose a single key to narrow it.",
"ui.sweep": "Sweep",
"ui.sweep_follow_direction": "Follow Direction",
"ui.sweep_keep_as_drawn": "Keep as Drawn",
+42
View File
@@ -77,6 +77,7 @@
"Current orthographic view (1:1 mm)": "Vista ortográfica actual (1:1 mm)",
"Current perspective view": "Vista en perspectiva actual",
"Curvature (G2)": "Curvatura (G2)",
"Curvature change (G3)": "Cambio de curvatura (G3)",
"Customize Toolbar": "Personalizar barra de herramientas",
"Delete all": "Eliminar todo",
"Delete rule": "Eliminar regla",
@@ -105,6 +106,7 @@
"Export Current View as DXF…": "Exportar vista actual como DXF…",
"Export DXF…": "Exportar DXF…",
"Export hidden edges as dashed lines": "Exportar aristas ocultas como líneas discontinuas",
"Extend to meet": "Extender hasta encontrarla",
"Extents": "Extensión",
"Face": "Cara",
"Face attachment lost": "Se ha perdido la vinculación de la cara",
@@ -119,6 +121,7 @@
"Follow camera (Look At sketches)": "Seguir cámara (orientar hacia los bocetos)",
"Follow the camera (Look At sketches)": "Seguir la cámara (orientar hacia los bocetos)",
"Font": "Fuente",
"From edges": "Aristas de origen",
"Full Length": "Longitud total",
"Grid": "Cuadrícula",
"Height": "Altura",
@@ -133,7 +136,9 @@
"Interferes": "Interfiere",
"Issue title": "Título de la incidencia",
"Joint Origin1": "Origen de unión1",
"Keep (points on it)": "Conservar (puntos sobre ella)",
"Keep Tools": "Conservar herramientas",
"Keep of the other surface": "Conservar de la otra superficie",
"Later": "Más tarde",
"Leave it": "Dejar como está",
"Legacy face attachment. Rebinding to a certified face creates an exact attachment.": "Vinculación antigua de cara. Volver a vincularla a una cara verificada crea una vinculación exacta.",
@@ -171,6 +176,7 @@
"Open GitHub draft": "Abrir borrador de GitHub",
"Open-source parametric 3D CAD in Rust. Licensed MIT OR Apache-2.0.": "CAD 3D paramétrico de código abierto desarrollado en Rust. Licencia MIT O Apache-2.0.",
"Other side": "Lado opuesto",
"Other surface (mutual trim)": "Otra superficie (recorte mutuo)",
"Outside": "Exterior",
"Overall length": "Longitud total",
"Parameter values": "Valores de parámetros",
@@ -258,10 +264,14 @@
"Stand this part up?": "¿Poner esta pieza en vertical?",
"Step": "Paso",
"Steps": "Pasos",
"Stitch the kept pieces": "Coser las piezas conservadas",
"Stitch to both bodies": "Coser a ambos cuerpos",
"Study": "Estudio",
"Style": "Estilo",
"Suppressed": "Suprimido",
"Surface": "Superficie",
"Swap Ends": "Intercambiar extremos",
"TRIM": "RECORTAR",
"Tangent (G1)": "Tangencia (G1)",
"Tangent Chain": "Cadena tangente",
"Tear": "Desgarro",
@@ -277,8 +287,10 @@
"This child is shared. Position changes apply to every copy of its parent.": "Este elemento secundario es compartido. Los cambios de posición se aplican a todas las copias de su elemento principal.",
"This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "Esta cara no permite una vinculación persistente. Elige otra cara o utiliza expresamente un plano fijo.",
"To Offset": "Hasta desfase",
"To edges": "Aristas de destino",
"Tolerance": "Tolerancia",
"Too close": "Demasiado cerca",
"Trimming tool (plane, face or curves)": "Herramienta de recorte (plano, cara o curvas)",
"Turn off section analysis before exporting the view.": "Desactiva el análisis de sección antes de exportar la vista.",
"Type": "Tipo",
"UNTRIM": "ANULAR RECORTE",
@@ -1004,6 +1016,7 @@
"ui.autosave_every": "Guardar automáticamente cada",
"ui.axes": "Ejes",
"ui.axis": "Eje",
"ui.back": "Atrás",
"ui.base_point": "Punto base",
"ui.blocked": "Bloqueado",
"ui.bodies": "Cuerpos",
@@ -1372,6 +1385,8 @@
"ui.save_mesh_pick_bodies": "Selecciona los cuerpos que se van a guardar",
"ui.save_mesh_temp_failed": "No se ha podido crear {folder}: {error}",
"ui.saved_to": "Guardado en {path}",
"ui.sbox_pin": "Fijar arriba en la búsqueda S",
"ui.sbox_unpin": "Desfijar",
"ui.scale": "Escala",
"ui.second_rail": "Segunda línea guía",
"ui.section_analysis": "Análisis de sección",
@@ -1415,8 +1430,34 @@
"ui.smooth_wheel_zoom": "Zoom suave con la rueda",
"ui.smooth_wheel_zoom_tip": "Desactívalo para aplicar cada movimiento de la rueda inmediatamente, sin suavizado posterior",
"ui.snap": "Referencia a objetos",
"ui.space_mouse_calibrate": "Calibrar…",
"ui.space_mouse_calibrate_tip": "Mueve el capuchón como pide la ventana para que SolidCraft aprenda hacia dónde y hasta dónde va cada eje de este ratón 3D. Se guarda para este ratón 3D.",
"ui.space_mouse_calibrate_title": "Calibrar ratón 3D",
"ui.space_mouse_calibrated": "Calibrado",
"ui.space_mouse_calibration": "Calibración del ratón 3D",
"ui.space_mouse_calibration_done": "Listo. Guárdala para usarla siempre que este ratón 3D esté conectado.",
"ui.space_mouse_calibration_no_response": "Un eje apenas se movió, así que no se puede calibrar. Vuelve a empezar y mueve el capuchón más lejos cada vez.",
"ui.space_mouse_dead_zone": "Zona muerta",
"ui.space_mouse_dead_zone_tip": "Cuánto tiene que moverse el capuchón antes de que se mueva la vista, como parte de su recorrido completo. Auméntala si la vista se desplaza con el capuchón en reposo.",
"ui.space_mouse_forget": "Olvidar calibración",
"ui.space_mouse_hint": "Desplaza y gira la vista a la vez; los bocetos solo permiten desplazamiento y zoom.",
"ui.space_mouse_navigate": "Navegar con SpaceNavigator / SpaceMouse",
"ui.space_mouse_none": "No se encontró ningún ratón 3D",
"ui.space_mouse_not_calibrated": "Sin calibrar",
"ui.space_mouse_reading": "Lectura",
"ui.space_mouse_step_lift": "Levanta el capuchón, mantenlo y suéltalo.",
"ui.space_mouse_step_press": "Presiona el capuchón hacia abajo, mantenlo y suéltalo.",
"ui.space_mouse_step_pull": "Tira del capuchón hacia ti, mantenlo y suéltalo.",
"ui.space_mouse_step_push": "Empuja el capuchón lejos de ti, mantenlo y suéltalo.",
"ui.space_mouse_step_rest": "Suelta el capuchón y déjalo quieto.",
"ui.space_mouse_step_slide_left": "Desliza el capuchón a la izquierda, mantenlo y suéltalo.",
"ui.space_mouse_step_slide_right": "Desliza el capuchón a la derecha, mantenlo y suéltalo.",
"ui.space_mouse_step_tilt_back": "Inclina el capuchón hacia atrás (borde delantero arriba), mantenlo y suéltalo.",
"ui.space_mouse_step_tilt_forward": "Inclina el capuchón hacia delante (borde delantero abajo), mantenlo y suéltalo.",
"ui.space_mouse_step_tilt_left": "Inclina el capuchón a la izquierda (borde izquierdo abajo), mantenlo y suéltalo.",
"ui.space_mouse_step_tilt_right": "Inclina el capuchón a la derecha (borde derecho abajo), mantenlo y suéltalo.",
"ui.space_mouse_step_twist_clockwise": "Gira el capuchón en sentido horario, mantenlo y suéltalo.",
"ui.space_mouse_step_twist_counterclockwise": "Gira el capuchón en sentido antihorario, mantenlo y suéltalo.",
"ui.spacing": "Espaciado",
"ui.spacing_2": "Espaciado 2",
"ui.sphere": "Esfera",
@@ -1424,6 +1465,7 @@
"ui.split_body": "Dividir cuerpo",
"ui.stand_up_tip": "Tras abrir un archivo STEP o IGES con Z hacia arriba, ofrecer un cuarto de vuelta alrededor de X",
"ui.start": "Iniciar",
"ui.start_over": "Volver a empezar",
"ui.start_radius": "Radio inicial",
"ui.stretch": "Estirar",
"ui.supersampling": "Supermuestreo (SSAA)",
+99
View File
@@ -31,6 +31,7 @@
"Arc length": "Longueur d'arc",
"Area": "Aire",
"Attachment": "Rattachement",
"BOUNDARY SURFACE": "SURFACE FRONTIÈRE",
"BROWSER": "NAVIGATEUR",
"Bend Line Position": "Position de la ligne de pli",
"Bend Position": "Position du pli",
@@ -73,10 +74,12 @@
"Counterbore depth": "Profondeur du lamage",
"Countersink angle": "Angle de la fraisure",
"Create Issue — review draft": "Créer un ticket — relire le brouillon",
"Cross curves": "Courbes transversales",
"Cube": "Cube",
"Current orthographic view (1:1 mm)": "Vue orthographique actuelle (1:1 mm)",
"Current perspective view": "Vue en perspective actuelle",
"Curvature (G2)": "Courbure (G2)",
"Curvature change (G3)": "Variation de courbure (G3)",
"Customize Toolbar": "Personnaliser la barre d'outils",
"Delete all": "Tout supprimer",
"Delete rule": "Supprimer la règle",
@@ -105,6 +108,7 @@
"Export Current View as DXF…": "Exporter la vue actuelle en DXF…",
"Export DXF…": "Exporter en DXF…",
"Export hidden edges as dashed lines": "Exporter les arêtes cachées en tirets",
"Extend to meet": "Prolonger jusqu'à la rencontre",
"Extents": "Étendues",
"Face": "Face",
"Face attachment lost": "Rattachement à la face perdu",
@@ -119,6 +123,7 @@
"Follow camera (Look At sketches)": "Suivre la caméra (vue normale aux esquisses)",
"Follow the camera (Look At sketches)": "Suivre la caméra (vue normale aux esquisses)",
"Font": "Police",
"From edges": "Arêtes de départ",
"Full Length": "Longueur totale",
"Grid": "Grille",
"Height": "Hauteur",
@@ -133,7 +138,9 @@
"Interferes": "Interfère",
"Issue title": "Titre du ticket",
"Joint Origin1": "Origine de liaison1",
"Keep (points on it)": "À conserver (points dessus)",
"Keep Tools": "Conserver les outils",
"Keep of the other surface": "À conserver de l'autre surface",
"Later": "Plus tard",
"Leave it": "Laisser tel quel",
"Legacy face attachment. Rebinding to a certified face creates an exact attachment.": "Rattachement à la face hérité. Le relier à une face certifiée crée un rattachement exact.",
@@ -171,6 +178,7 @@
"Open GitHub draft": "Ouvrir le brouillon GitHub",
"Open-source parametric 3D CAD in Rust. Licensed MIT OR Apache-2.0.": "CAO 3D paramétrique open source en Rust. Sous licence MIT OR Apache-2.0.",
"Other side": "Autre côté",
"Other surface (mutual trim)": "Autre surface (ajustement mutuel)",
"Outside": "Extérieur",
"Overall length": "Longueur hors tout",
"Parameter values": "Valeurs des paramètres",
@@ -258,10 +266,14 @@
"Stand this part up?": "Redresser cette pièce ?",
"Step": "Étape",
"Steps": "Étapes",
"Stitch the kept pieces": "Coudre les parties conservées",
"Stitch to both bodies": "Coudre aux deux corps",
"Study": "Étude",
"Style": "Style",
"Suppressed": "Désactivé",
"Surface": "Surface",
"Swap Ends": "Permuter les extrémités",
"TRIM": "AJUSTER",
"Tangent (G1)": "Tangence (G1)",
"Tangent Chain": "Chaîne tangente",
"Tear": "Déchirure",
@@ -277,8 +289,10 @@
"This child is shared. Position changes apply to every copy of its parent.": "Cet enfant est partagé. Les changements de position s'appliquent à toutes les copies de son parent.",
"This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "Cette face ne peut pas fournir de rattachement durable. Choisissez une autre face ou utilisez explicitement un plan fixe.",
"To Offset": "Jusqu'au décalage",
"To edges": "Arêtes d'arrivée",
"Tolerance": "Tolérance",
"Too close": "Trop proches",
"Trimming tool (plane, face or curves)": "Outil d'ajustement (plan, face ou courbes)",
"Turn off section analysis before exporting the view.": "Désactivez l'analyse de coupe avant d'exporter la vue.",
"Type": "Type",
"UNTRIM": "ANNULER L'AJUSTEMENT",
@@ -440,6 +454,7 @@
"cmd.joint_rigid_group": "Groupe rigide",
"cmd.joint_screw": "Vis",
"cmd.joint_solve": "Résoudre les liaisons",
"cmd.joint_sweep": "Balayer une liaison",
"cmd.library_apply": "Appliquer une entrée de la bibliothèque locale",
"cmd.library_export": "Exporter la bibliothèque de la conception",
"cmd.library_inspect": "Inspecter la bibliothèque locale",
@@ -557,6 +572,8 @@
"cmd.sketch_isoparametric_curve": "Courbe isoparamétrique",
"cmd.sketch_line3d": "Ligne 3D",
"cmd.sketch_line_midpoint": "Ligne par point milieu",
"cmd.sketch_loose_ends": "Extrémités libres",
"cmd.sketch_loose_ends_tie": "Faire coïncider les extrémités libres",
"cmd.sketch_move": "Déplacer/Copier des entités d'esquisse",
"cmd.sketch_move3d": "Déplacer un point 3D",
"cmd.sketch_move_point": "Faire glisser un point d'esquisse",
@@ -628,8 +645,10 @@
"cmd.timeline_roll_to": "Déplacer le marqueur d'historique",
"cmd.timeline_rollback": "Déplacer le marqueur de chronologie",
"cmd.timeline_suppress": "Désactiver la fonction",
"cmd.view_ambient_occlusion": "Occlusion ambiante",
"cmd.view_apply": "Restaurer la vue nommée",
"cmd.view_delete": "Supprimer la vue nommée",
"cmd.view_graphics_preset": "Préréglage graphique",
"cmd.view_list": "Lister les vues nommées",
"cmd.view_navigate": "Naviguer dans la vue",
"cmd.view_rename": "Renommer la vue nommée",
@@ -726,9 +745,11 @@
"new configuration": "nouvelle configuration",
"new name": "nouveau nom",
"new parameter": "nouveau paramètre",
"no hole": "sans perçage",
"no interference": "aucune interférence",
"no joint that moves yet": "aucune liaison mobile pour l'instant",
"no picture": "aucune image",
"none": "aucun",
"pattern.alignment_2": "Alignement 2",
"pattern.alignment_hint": "La quantité inclut l’original. Le centrage répartit les copies de chaque côté ; si la quantité est paire, l’inversion choisit le côté qui reçoit la copie supplémentaire.",
"pattern.direction_1": "Direction 1",
@@ -801,6 +822,8 @@
"sketch.degrees_of_freedom": "{count} degrés de liberté",
"sketch.draw_curves": "Dessiner en {linetype}",
"sketch.make_selection": "Convertir la sélection en {linetype}",
"sketch.open_ends": "{count} ouvertes",
"sketch.untied_ends": "{count} sans coïncidence",
"sketch_text.align_center": "Centre",
"sketch_text.align_left": "Gauche",
"sketch_text.align_right": "Droite",
@@ -813,6 +836,7 @@
"sketch_text.path_clear": "Détacher le texte de sa courbe",
"sketch_text.path_hint": "Une courbe de l'esquisse que le texte suit : cliquer ici, puis sur la courbe",
"sketch_text.use_font_file": "Utiliser le fichier de police {path}",
"solid": "plein (sans paroi)",
"the parts library is not available": "la bibliothèque de pièces n'est pas disponible",
"thread.automatic_size": "Taille automatique",
"thread.designation": "Désignation",
@@ -824,6 +848,7 @@
"timeline.rename_group": "Renommer le dossier de fonctions",
"timeline.reorder_many": "Réordonner les fonctions",
"timeline.ungroup": "Supprimer les dossiers de fonctions",
"to the face": "jusqu'à la face",
"to the next face": "jusqu'à la face suivante",
"toolbar.image": "IMAGE",
"toolbar.render": "RENDU",
@@ -839,12 +864,15 @@
"ui.activate": "Activer",
"ui.active_component": "Composant actif",
"ui.agent_chat": "Chat de l'agent",
"ui.agent_chat_add": "Joindre des fichiers",
"ui.agent_chat_add_file": "Choisir un fichier…",
"ui.agent_chat_allow": "Autoriser",
"ui.agent_chat_allowed": "Autorisé",
"ui.agent_chat_answered": "Réponse : {option}",
"ui.agent_chat_approval": "Autoriser l'agent à effectuer cette action ?",
"ui.agent_chat_attach_view": "Joindre la vue",
"ui.agent_chat_attach_view_tip": "Envoyer une image de la vue actuelle avec le message",
"ui.agent_chat_attachable": "Images, PDF, textes et fichiers de CAO",
"ui.agent_chat_auth_agent": "S'authentifier avec {name}",
"ui.agent_chat_auth_env": "Définir {vars} dans votre environnement, puis redémarrer SolidCraft.",
"ui.agent_chat_auth_needed": "L'agent demande une authentification. Vous vous authentifiez auprès de l'agent lui-même ; SolidCraft ne voit jamais votre mot de passe ni vos clés.",
@@ -864,11 +892,32 @@
"ui.agent_chat_deny": "Refuser",
"ui.agent_chat_desktop_only": "Le chat de l'agent nécessite l'application de bureau.",
"ui.agent_chat_detecting": "Recherche d'agents…",
"ui.agent_chat_dock": "Ancrer dans le panneau latéral",
"ui.agent_chat_dock_left": "Ancrer à gauche",
"ui.agent_chat_dock_right": "Ancrer à droite",
"ui.agent_chat_drop_hint": "Déposer pour joindre à votre prochain message",
"ui.agent_chat_edit": "Modifier",
"ui.agent_chat_exited": "L'agent s'est arrêté.",
"ui.agent_chat_failed_start": "L'agent n'a pas pu démarrer : {error}",
"ui.agent_chat_file_cad_not_read": "{name} : un fichier de CAO est importé dans la conception, pas lu",
"ui.agent_chat_file_empty_picture": "{name} : image vide",
"ui.agent_chat_file_error": "{name} : {error}",
"ui.agent_chat_file_heic": "{name} : les images HEIC ne peuvent pas être lues ici ; les exporter d'abord en JPEG ou PNG",
"ui.agent_chat_file_importing": "Importation dans la conception…",
"ui.agent_chat_file_loading": "Lecture du fichier…",
"ui.agent_chat_file_not_a_file": "{name} : n'est pas un fichier",
"ui.agent_chat_file_not_a_picture": "{name} : n'est pas une image",
"ui.agent_chat_file_not_utf8": "{name} : n'est pas un texte UTF-8",
"ui.agent_chat_file_picture_too_big": "{name} : une image ne peut pas dépasser {max} Mo",
"ui.agent_chat_file_text_too_big": "{name} : un fichier texte ne peut pas dépasser {max} Ko",
"ui.agent_chat_file_too_large": "{name} : trop volumineux",
"ui.agent_chat_file_too_large_to_send": "{name} : trop volumineux pour être envoyé, même réduit",
"ui.agent_chat_file_unreadable_picture": "{name} : image illisible ici",
"ui.agent_chat_files": "Fichiers : {files}",
"ui.agent_chat_files_left_out": "Un message ne peut contenir que {max} fichiers au plus ; non joints : {files}",
"ui.agent_chat_files_loading": "Lecture des fichiers en cours",
"ui.agent_chat_files_max": "{max} fichiers au plus par message",
"ui.agent_chat_import_failed": "{name} : l'importation a échoué : {error}",
"ui.agent_chat_intro": "Discuter avec un agent de programmation que vous utilisez déjà, ou avec un point d'accès de modèle de votre choix. Il travaille sur la conception ouverte avec les outils de SolidCraft : ses modifications apparaissent au fur et à mesure et s'annulent en une seule étape par message. Ce qu'il peut faire sans demander se règle dans Préférences › IA.",
"ui.agent_chat_key_in_env": "clé définie",
"ui.agent_chat_left": "Gauche",
@@ -881,14 +930,18 @@
"ui.agent_chat_no_cli": "solidcraft-cli est introuvable à côté de l'application : l'agent ne peut donc pas accéder à la conception.",
"ui.agent_chat_no_control": "Le point d'accès du chat de l'agent n'a pas démarré : l'agent ne peut donc pas accéder à la conception.",
"ui.agent_chat_no_images": "Cet agent n'accepte pas les images",
"ui.agent_chat_no_picture": "Il n'y a pas d'image dans le presse-papiers",
"ui.agent_chat_none_found": "Aucun agent de programmation trouvé. En installer un parmi ceux-ci, puis appuyer sur Actualiser :",
"ui.agent_chat_none_found_short": "Aucun agent trouvé",
"ui.agent_chat_not_installed": "Non installé",
"ui.agent_chat_not_signed_in": "non authentifié",
"ui.agent_chat_own_window": "Fenêtre séparée",
"ui.agent_chat_paste_picture": "Coller une image",
"ui.agent_chat_permission": "L'agent demande à exécuter : {title}",
"ui.agent_chat_pick": "Agent",
"ui.agent_chat_placeholder": "Demander à l'agent de modifier la conception…",
"ui.agent_chat_placeholder_busy": "Mettre un message en file d'attente pour quand l'agent aura terminé (Ctrl+Enter : envoyer maintenant)",
"ui.agent_chat_pop_out": "Ouvrir dans une fenêtre séparée, déplaçable vers un autre écran",
"ui.agent_chat_privacy": "Rien n'est envoyé ailleurs qu'à l'agent que vous lancez, sous votre propre compte.",
"ui.agent_chat_privacy_note": "SolidCraft n'envoie rien nulle part : l'agent que vous lancez dialogue avec son propre service, sous votre propre compte. SolidCraft ne voit ni ne stocke jamais de clé ni de mot de passe.",
"ui.agent_chat_queue": "Mettre en file",
@@ -898,6 +951,8 @@
"ui.agent_chat_refresh": "Actualiser",
"ui.agent_chat_refused": "Interdit à l'agent : {reason}",
"ui.agent_chat_remove": "Retirer de la file d'attente",
"ui.agent_chat_remove_file": "Retirer de ce message",
"ui.agent_chat_remove_imported": "Retirer de ce message ; la pièce reste dans la conception (Annuler la retire)",
"ui.agent_chat_right": "Droite",
"ui.agent_chat_save": "Enregistrer",
"ui.agent_chat_send": "Envoyer",
@@ -995,6 +1050,8 @@
"ui.ai_toolbar_button_note": "Le bouton IA à l'extrémité droite de la barre d'outils. Ctrl+Shift+A et le menu Vue ouvrent toujours le panneau.",
"ui.ai_undo_tip": "Annuler tout ce que l'agent a modifié pour ce message, en une seule étape",
"ui.all": "Tout",
"ui.ambient_occlusion": "Occlusion ambiante",
"ui.ambient_occlusion_help": "Ombrage doux aux jonctions des surfaces opaques. Nécessite la vue GPU.",
"ui.analysis": "Analyse",
"ui.angle": "Angle",
"ui.angle_decimals": "Décimales des angles",
@@ -1009,6 +1066,7 @@
"ui.autosave_every": "Enregistrement automatique toutes les",
"ui.axes": "Axes",
"ui.axis": "Axe",
"ui.back": "Retour",
"ui.base_point": "Point de base",
"ui.blocked": "Inaccessible",
"ui.bodies": "Corps",
@@ -1155,6 +1213,11 @@
"ui.fully_constrained": "Entièrement contrainte",
"ui.general": "Général",
"ui.graphics": "Graphisme",
"ui.graphics_custom": "Personnalisé",
"ui.graphics_performance": "Performances",
"ui.graphics_preset": "Préréglage graphique",
"ui.graphics_preset_help": "Qualité : occlusion ambiante, tessellation fine et suréchantillonnage adaptatif 2×. Performances : sans occlusion ambiante, tessellation normale et sans suréchantillonnage. Les réglages manuels affichent Personnalisé. MSAA reste inchangé.",
"ui.graphics_quality": "Qualité",
"ui.grid_follows_view": "La grille suit la vue",
"ui.grid_follows_view_hint": "Dans les vues de côté, la grille s'affiche sur le plan vertical qui leur fait face plutôt que sur le sol",
"ui.ground_shadow": "Ombre au sol",
@@ -1296,6 +1359,7 @@
"ui.newSketchGroup": "Nouveau groupe d'esquisses",
"ui.new_body": "Nouveau corps",
"ui.new_design": "Nouvelle conception",
"ui.no_geometry_yet": "Aucune géométrie pour l'instant",
"ui.no_slicer_found": "Aucun slicer trouvé",
"ui.normal": "Normal",
"ui.normalConstruction": "Normal / Construction",
@@ -1308,6 +1372,7 @@
"ui.one_side": "Un côté",
"ui.open": "Ouvrir",
"ui.open_ellipsis": "Ouvrir...",
"ui.open_ends_tip": "Ne touche rien",
"ui.open_preferences": "Ouvrir les préférences",
"ui.open_recent": "Ouvrir un fichier récent",
"ui.operation": "Opération",
@@ -1369,6 +1434,8 @@
"ui.reset_the_settings_on_this_page": "Réinitialiser les réglages de cette page",
"ui.restore": "Restaurer",
"ui.restore_defaults": "Rétablir les valeurs par défaut",
"ui.reverse_horizontal_pan_direction": "Inverser le sens du panoramique horizontal",
"ui.reverse_horizontal_pan_tip": "Le défilement horizontal à deux doigts et la molette latérale font le panoramique dans l'autre sens",
"ui.reverse_zoom_direction": "Inverser le sens du zoom",
"ui.revolve": "Révolution",
"ui.right": "Droite",
@@ -1379,6 +1446,8 @@
"ui.save_mesh_pick_bodies": "Sélectionner les corps à enregistrer",
"ui.save_mesh_temp_failed": "Impossible de créer {folder} : {error}",
"ui.saved_to": "Enregistré : {path}",
"ui.sbox_pin": "Épingler en haut de la boîte S",
"ui.sbox_unpin": "Désépingler",
"ui.scale": "Échelle",
"ui.second_rail": "Second rail",
"ui.section_analysis": "Analyse de coupe",
@@ -1422,8 +1491,34 @@
"ui.smooth_wheel_zoom": "Zoom fluide à la molette",
"ui.smooth_wheel_zoom_tip": "Désactiver pour appliquer chaque cran de molette immédiatement, sans lissage",
"ui.snap": "Accrochage",
"ui.space_mouse_calibrate": "Calibrer…",
"ui.space_mouse_calibrate_tip": "Déplacez le capuchon comme la fenêtre le demande, pour que SolidCraft apprenne dans quel sens et jusqu'où va chaque axe de cette souris 3D. Enregistré pour cette souris 3D.",
"ui.space_mouse_calibrate_title": "Calibrer la souris 3D",
"ui.space_mouse_calibrated": "Calibrée",
"ui.space_mouse_calibration": "Calibrage de la souris 3D",
"ui.space_mouse_calibration_done": "Terminé. Enregistrez-le pour l'utiliser chaque fois que cette souris 3D est connectée.",
"ui.space_mouse_calibration_no_response": "Un axe a à peine bougé, il ne peut donc pas être calibré. Recommencez en déplaçant le capuchon plus loin à chaque fois.",
"ui.space_mouse_dead_zone": "Zone morte",
"ui.space_mouse_dead_zone_tip": "De combien le capuchon doit bouger avant que la vue ne bouge, en part de sa course complète. Augmentez-la si la vue dérive alors que le capuchon est au repos.",
"ui.space_mouse_forget": "Oublier le calibrage",
"ui.space_mouse_hint": "Déplace et fait pivoter la vue en même temps ; dans les esquisses, seuls le panoramique et le zoom sont utilisés.",
"ui.space_mouse_navigate": "Naviguer avec SpaceNavigator / SpaceMouse",
"ui.space_mouse_none": "Aucune souris 3D trouvée",
"ui.space_mouse_not_calibrated": "Non calibrée",
"ui.space_mouse_reading": "Lecture",
"ui.space_mouse_step_lift": "Soulevez le capuchon, maintenez, puis relâchez.",
"ui.space_mouse_step_press": "Appuyez sur le capuchon, maintenez, puis relâchez.",
"ui.space_mouse_step_pull": "Tirez le capuchon vers vous, maintenez, puis relâchez.",
"ui.space_mouse_step_push": "Poussez le capuchon loin de vous, maintenez, puis relâchez.",
"ui.space_mouse_step_rest": "Lâchez le capuchon et laissez-le immobile.",
"ui.space_mouse_step_slide_left": "Glissez le capuchon vers la gauche, maintenez, puis relâchez.",
"ui.space_mouse_step_slide_right": "Glissez le capuchon vers la droite, maintenez, puis relâchez.",
"ui.space_mouse_step_tilt_back": "Inclinez le capuchon vers l'arrière (bord avant vers le haut), maintenez, puis relâchez.",
"ui.space_mouse_step_tilt_forward": "Inclinez le capuchon vers l'avant (bord avant vers le bas), maintenez, puis relâchez.",
"ui.space_mouse_step_tilt_left": "Inclinez le capuchon vers la gauche (bord gauche vers le bas), maintenez, puis relâchez.",
"ui.space_mouse_step_tilt_right": "Inclinez le capuchon vers la droite (bord droit vers le bas), maintenez, puis relâchez.",
"ui.space_mouse_step_twist_clockwise": "Tournez le capuchon dans le sens horaire, maintenez, puis relâchez.",
"ui.space_mouse_step_twist_counterclockwise": "Tournez le capuchon dans le sens antihoraire, maintenez, puis relâchez.",
"ui.spacing": "Espacement",
"ui.spacing_2": "Espacement 2",
"ui.sphere": "Sphère",
@@ -1431,6 +1526,7 @@
"ui.split_body": "Scinder le corps",
"ui.stand_up_tip": "Après l'ouverture d'un fichier STEP ou IGES avec Z vers le haut, proposer un quart de tour autour de X",
"ui.start": "Début",
"ui.start_over": "Recommencer",
"ui.start_radius": "Rayon initial",
"ui.stretch": "Étirer",
"ui.supersampling": "Suréchantillonnage (SSAA)",
@@ -1451,6 +1547,8 @@
"ui.theme": "Thème",
"ui.thickness": "Épaisseur",
"ui.thread": "Filetage",
"ui.tie_ends": "Coïncider",
"ui.tie_ends_tip": "Ajouter une coïncidence à toutes les extrémités qui n'en ont pas",
"ui.timeline_bottom": "Chronologie en bas",
"ui.timeline_left_note": "La disposition à gauche affiche la barre de retour en arrière au-dessus du navigateur des pièces et de la scène.",
"ui.timeline_left_sidebar": "Barre latérale gauche",
@@ -1473,6 +1571,7 @@
"ui.units_of_new_designs": "Unités des nouvelles conceptions",
"ui.unlock": "Déverrouiller",
"ui.unsuppress": "Réactiver",
"ui.untied_ends_tip": "Ne touche que par la position",
"ui.untitled": "Sans titre",
"ui.up_axis_note": "Axe orienté vers le haut dans les vues (vue d'axe, Accueil, Vue normale). Les conceptions ne sont pas modifiées.",
"ui.updateToCurrentView": "Mettre à jour avec la vue actuelle",
+42
View File
@@ -73,6 +73,7 @@
"Current orthographic view (1:1 mm)": "Vista ortográfica actual (1:1 mm)",
"Current perspective view": "Vista en perspectiva actual",
"Curvature (G2)": "Curvatura (G2)",
"Curvature change (G3)": "Cambio de curvatura (G3)",
"Customize Toolbar": "Personalizar a barra de ferramentas",
"Delete all": "Eliminar todo",
"Delete rule": "Eliminar a regra",
@@ -98,6 +99,7 @@
"Export Current View as DXF…": "Exportar a vista actual como DXF…",
"Export DXF…": "Exportar DXF…",
"Export hidden edges as dashed lines": "Exportar as arestas ocultas como liñas descontinuas",
"Extend to meet": "Estender ata atopala",
"Extents": "Extensións",
"Face": "Cara",
"Face attachment lost": "Perdeuse a ancoraxe á cara",
@@ -112,6 +114,7 @@
"Follow camera (Look At sketches)": "Seguir a cámara (Mirar cara aos bosquexos)",
"Follow the camera (Look At sketches)": "Seguir a cámara (Mirar cara aos bosquexos)",
"Font": "Tipo de letra",
"From edges": "Arestas de orixe",
"Full Length": "Lonxitude completa",
"Grid": "Grella",
"Height": "Altura",
@@ -126,7 +129,9 @@
"Interferes": "Interfire",
"Issue title": "Título da incidencia",
"Joint Origin1": "Orixe da unión1",
"Keep (points on it)": "Manter (puntos sobre ela)",
"Keep Tools": "Manter as ferramentas",
"Keep of the other surface": "Manter da outra superficie",
"Later": "Máis tarde",
"Leave it": "Deixalo",
"Legacy face attachment. Rebinding to a certified face creates an exact attachment.": "Ancoraxe á cara antiga. Volver vincular a unha cara certificada crea unha ancoraxe exacta.",
@@ -162,6 +167,7 @@
"Open GitHub draft": "Abrir o borrador de GitHub",
"Open-source parametric 3D CAD in Rust. Licensed MIT OR Apache-2.0.": "CAD 3D paramétrico de código aberto en Rust. Con licenza MIT OR Apache-2.0.",
"Other side": "Outro lado",
"Other surface (mutual trim)": "Outra superficie (recorte mutuo)",
"Outside": "Fóra",
"Overall length": "Lonxitude total",
"Parameter values": "Valores dos parámetros",
@@ -245,9 +251,13 @@
"Stand this part up?": "Poñer esta peza de pé?",
"Step": "Paso",
"Steps": "Pasos",
"Stitch the kept pieces": "Coser as pezas mantidas",
"Stitch to both bodies": "Coser aos dous corpos",
"Style": "Estilo",
"Suppressed": "Suprimida",
"Surface": "Superficie",
"Swap Ends": "Intercambiar os extremos",
"TRIM": "RECORTAR",
"Tangent (G1)": "Tanxente (G1)",
"Tangent Chain": "Cadea tanxente",
"Tear": "Rasgado",
@@ -263,7 +273,9 @@
"This child is shared. Position changes apply to every copy of its parent.": "Este fillo está compartido. Os cambios de posición aplícanse a todas as copias do seu pai.",
"This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "Esta cara non pode ofrecer unha ancoraxe duradeira. Escolle outra cara ou usa expresamente un plano fixo.",
"To Offset": "Ata o desprazamento",
"To edges": "Arestas de destino",
"Tolerance": "Tolerancia",
"Trimming tool (plane, face or curves)": "Ferramenta de recorte (plano, cara ou curvas)",
"Turn off section analysis before exporting the view.": "Desactiva a análise de sección antes de exportar a vista.",
"Type": "Tipo",
"UNTRIM": "DESRECORTAR",
@@ -976,6 +988,7 @@
"ui.autosave_every": "Gardar automaticamente cada",
"ui.axes": "Eixes",
"ui.axis": "Eixe",
"ui.back": "Atrás",
"ui.base_point": "Punto base",
"ui.blocked": "Bloqueado",
"ui.bodies": "Corpos",
@@ -1337,6 +1350,8 @@
"ui.save_mesh_pick_bodies": "Selecciona os corpos que queres gardar",
"ui.save_mesh_temp_failed": "Non se puido crear {folder}: {error}",
"ui.saved_to": "Gardado {path}",
"ui.sbox_pin": "Fixar arriba na caixa S",
"ui.sbox_unpin": "Desfixar",
"ui.scale": "Escala",
"ui.section_analysis": "Análise de sección",
"ui.select": "Seleccionar",
@@ -1379,8 +1394,34 @@
"ui.smooth_wheel_zoom": "Zoom suave coa roda",
"ui.smooth_wheel_zoom_tip": "Desactívao para aplicar cada movemento da roda de inmediato, sen suavizado",
"ui.snap": "Axuste",
"ui.space_mouse_calibrate": "Calibrar…",
"ui.space_mouse_calibrate_tip": "Move o capuchón como pide a xanela para que SolidCraft aprenda cara a onde e ata onde vai cada eixe deste rato 3D. Gárdase para este rato 3D.",
"ui.space_mouse_calibrate_title": "Calibrar o rato 3D",
"ui.space_mouse_calibrated": "Calibrado",
"ui.space_mouse_calibration": "Calibración do rato 3D",
"ui.space_mouse_calibration_done": "Feito. Gárdaa para usala sempre que este rato 3D estea conectado.",
"ui.space_mouse_calibration_no_response": "Un eixe case non se moveu, así que non se pode calibrar. Volve comezar e move o capuchón máis lonxe cada vez.",
"ui.space_mouse_dead_zone": "Zona morta",
"ui.space_mouse_dead_zone_tip": "Canto ten que moverse o capuchón antes de que se mova a vista, como parte do seu percorrido completo. Auméntaa se a vista se despraza co capuchón en repouso.",
"ui.space_mouse_forget": "Esquecer a calibración",
"ui.space_mouse_hint": "Move e rota a vista á vez; os bosquexos só usan desprazamento e zoom.",
"ui.space_mouse_navigate": "Navegar con SpaceNavigator / SpaceMouse",
"ui.space_mouse_none": "Non se atopou ningún rato 3D",
"ui.space_mouse_not_calibrated": "Sen calibrar",
"ui.space_mouse_reading": "Lectura",
"ui.space_mouse_step_lift": "Levanta o capuchón, mantéño e sóltao.",
"ui.space_mouse_step_press": "Preme o capuchón cara abaixo, mantéño e sóltao.",
"ui.space_mouse_step_pull": "Tira do capuchón cara a ti, mantéño e sóltao.",
"ui.space_mouse_step_push": "Empurra o capuchón lonxe de ti, mantéño e sóltao.",
"ui.space_mouse_step_rest": "Solta o capuchón e déixao quieto.",
"ui.space_mouse_step_slide_left": "Despraza o capuchón á esquerda, mantéño e sóltao.",
"ui.space_mouse_step_slide_right": "Despraza o capuchón á dereita, mantéño e sóltao.",
"ui.space_mouse_step_tilt_back": "Inclina o capuchón cara atrás (bordo dianteiro arriba), mantéño e sóltao.",
"ui.space_mouse_step_tilt_forward": "Inclina o capuchón cara adiante (bordo dianteiro abaixo), mantéño e sóltao.",
"ui.space_mouse_step_tilt_left": "Inclina o capuchón á esquerda (bordo esquerdo abaixo), mantéño e sóltao.",
"ui.space_mouse_step_tilt_right": "Inclina o capuchón á dereita (bordo dereito abaixo), mantéño e sóltao.",
"ui.space_mouse_step_twist_clockwise": "Xira o capuchón no sentido horario, mantéño e sóltao.",
"ui.space_mouse_step_twist_counterclockwise": "Xira o capuchón no sentido antihorario, mantéño e sóltao.",
"ui.spacing": "Espazado",
"ui.spacing_2": "Espazado 2",
"ui.sphere": "Esfera",
@@ -1388,6 +1429,7 @@
"ui.split_body": "Dividir o corpo",
"ui.stand_up_tip": "Despois de abrir un ficheiro STEP ou IGES con Z cara arriba, ofrecer un cuarto de volta arredor de X",
"ui.start": "Inicio",
"ui.start_over": "Volver comezar",
"ui.start_radius": "Raio inicial",
"ui.supersampling": "Supermostraxe (SSAA)",
"ui.supersampling_adaptive": "Só cando a vista está quieta",
+71
View File
@@ -31,6 +31,7 @@
"Arc length": "Lunghezza dell'arco",
"Area": "Area",
"Attachment": "Collegamento",
"BOUNDARY SURFACE": "SUPERFICIE DA CONTORNI",
"BROWSER": "STRUTTURA",
"Bend Line Position": "Posizione della linea di piega",
"Bend Position": "Posizione della piega",
@@ -73,10 +74,12 @@
"Counterbore depth": "Profondità della lamatura",
"Countersink angle": "Angolo di svasatura",
"Create Issue — review draft": "Crea segnalazione — rivedi bozza",
"Cross curves": "Curve trasversali",
"Cube": "Cubo",
"Current orthographic view (1:1 mm)": "Vista ortografica corrente (1:1 mm)",
"Current perspective view": "Vista prospettica corrente",
"Curvature (G2)": "Curvatura (G2)",
"Curvature change (G3)": "Variazione di curvatura (G3)",
"Customize Toolbar": "Personalizza barra degli strumenti",
"Delete all": "Elimina tutto",
"Delete rule": "Elimina regola",
@@ -86,6 +89,7 @@
"Density": "Densità",
"Designs you open or save appear here.": "I progetti aperti o salvati vengono visualizzati qui.",
"Determined by To Object. Choose Angle to edit the angular extent.": "Determinato da Fino all'oggetto. Scegli Angolo per modificare l'estensione angolare.",
"Direction": "Direzione",
"Discard": "Scarta",
"Do not export hidden edges": "Non esportare gli spigoli nascosti",
"Don't ask again": "Non chiedere più",
@@ -118,6 +122,7 @@
"Follow camera (Look At sketches)": "Segui la camera (Vista normale agli schizzi)",
"Follow the camera (Look At sketches)": "Segui la camera (Vista normale agli schizzi)",
"Font": "Carattere",
"From edges": "Spigoli di partenza",
"Full Length": "Lunghezza totale",
"Grid": "Griglia",
"Height": "Altezza",
@@ -127,6 +132,7 @@
"Home view": "Vista iniziale",
"In This Design": "In questo progetto",
"In place (surfaces)": "In posizione (superfici)",
"In plane": "Nel piano",
"Inside": "Interno",
"Interferes": "Interferisce",
"Issue title": "Titolo della segnalazione",
@@ -162,6 +168,7 @@
"No matching appearances": "Nessun aspetto corrispondente",
"No visible bodies to export.": "Nessun corpo visibile da esportare.",
"None": "Nessuno",
"Normal to sketch": "Normale allo schizzo",
"Not available yet": "Non ancora disponibile",
"Notch": "Intaglio",
"Opacity": "Opacità",
@@ -255,6 +262,7 @@
"Stand this part up?": "Raddrizzare questa parte?",
"Step": "Passo",
"Steps": "Passi",
"Stitch to both bodies": "Cuci a entrambi i corpi",
"Study": "Studio",
"Style": "Stile",
"Suppressed": "Sospeso",
@@ -274,6 +282,7 @@
"This child is shared. Position changes apply to every copy of its parent.": "Questo elemento figlio è condiviso. Le modifiche alla posizione si applicano a ogni copia del padre.",
"This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "Questa faccia non consente un collegamento persistente. Scegli un'altra faccia o usa esplicitamente un piano fisso.",
"To Offset": "Fino a una distanza",
"To edges": "Spigoli di arrivo",
"Tolerance": "Tolleranza",
"Too close": "Troppo vicini",
"Turn off section analysis before exporting the view.": "Disattiva l'analisi in sezione prima di esportare la vista.",
@@ -345,6 +354,7 @@
"cmd.component_move_sketches": "Sposta schizzi nel componente",
"cmd.component_paste_new": "Incolla come nuovo",
"cmd.component_properties": "Proprietà fisiche del componente",
"cmd.component_relink": "Ricollega sorgente",
"cmd.component_rename": "Rinomina componente",
"cmd.component_revert_position": "Ripristina posizione",
"cmd.component_unlock": "Sblocca contenuto",
@@ -419,6 +429,7 @@
"cmd.inspect_interference": "Interferenza",
"cmd.inspect_isocurve": "Analisi delle isocurve",
"cmd.inspect_minimum_radius": "Analisi del raggio minimo",
"cmd.inspect_silhouette": "Silhouette",
"cmd.inspect_zebra": "Analisi zebrata",
"cmd.joint_as_built": "Giunto in posizione",
"cmd.joint_cam": "Camma",
@@ -553,6 +564,8 @@
"cmd.sketch_isoparametric_curve": "Curva isoparametrica",
"cmd.sketch_line3d": "Linea 3D",
"cmd.sketch_line_midpoint": "Linea dal punto medio",
"cmd.sketch_loose_ends": "Estremità libere",
"cmd.sketch_loose_ends_tie": "Vincola estremità libere",
"cmd.sketch_move": "Sposta/Copia entità dello schizzo",
"cmd.sketch_move3d": "Sposta punto 3D",
"cmd.sketch_move_point": "Trascina punto dello schizzo",
@@ -703,6 +716,15 @@
"joint.from_bodies_hint": "Ogni corpo selezionato viene spostato in un nuovo componente distinto, mantenendo la sua posizione.",
"joint.one_body": "Entrambe le selezioni sono su {body}. Seleziona il secondo elemento su un'altra parte.",
"joint.one_component": "Queste parti sono corpi di un unico componente: {bodies}. Un giunto muove un componente rispetto a un altro, quindi ogni parte richiede un componente distinto.",
"messages.ago_h": "{n} h fa",
"messages.ago_min": "{n} min fa",
"messages.ago_s": "{n} s fa",
"messages.chip_hint": "Fai clic per tutti i messaggi di questa sessione",
"messages.clear": "Cancella",
"messages.copy_all": "Copia tutto",
"messages.menu": "Messaggi…",
"messages.none": "Ancora nessun messaggio.",
"messages.title": "Messaggi",
"motion.checked_to": "Controllato solo fino al passo {step}: il controllo ha esaurito il limite di calcolo",
"motion.collision": "{a} · {b}: passi {steps}",
"motion.duration_hint": "secondi (vuoto: per passo)",
@@ -792,6 +814,8 @@
"sketch.degrees_of_freedom": "{count} gradi di libertà",
"sketch.draw_curves": "Disegna come {linetype}",
"sketch.make_selection": "Converti la selezione in {linetype}",
"sketch.open_ends": "Aperte {count}",
"sketch.untied_ends": "Non vincolate {count}",
"sketch_text.align_center": "Centro",
"sketch_text.align_left": "Sinistra",
"sketch_text.align_right": "Destra",
@@ -1000,6 +1024,7 @@
"ui.autosave_every": "Salvataggio automatico ogni",
"ui.axes": "Assi",
"ui.axis": "Asse",
"ui.back": "Indietro",
"ui.base_point": "Punto base",
"ui.blocked": "Bloccato",
"ui.bodies": "Corpi",
@@ -1054,6 +1079,9 @@
"ui.construct": "Costruisci",
"ui.construction": "Costruzione",
"ui.context_menu": "Menu contestuale",
"ui.continuity_g0": "G0 spigolo vivo",
"ui.continuity_g1": "G1 tangenza",
"ui.continuity_g2": "G2 curvatura",
"ui.continuity_gap": "Gioco",
"ui.copy": "Copia",
"ui.corner": "Angolo",
@@ -1142,6 +1170,8 @@
"ui.fully_constrained": "Completamente vincolato",
"ui.general": "Generali",
"ui.graphics": "Grafica",
"ui.grid_follows_view": "La griglia segue la vista",
"ui.grid_follows_view_hint": "Le viste laterali mostrano la griglia sul piano verticale rivolto verso di esse anziché sul piano di appoggio",
"ui.ground_shadow": "Ombra sul piano di appoggio",
"ui.groupSelected": "Raggruppa selezionati",
"ui.heading_add_ins": "COMPONENTI AGGIUNTIVI",
@@ -1292,6 +1322,7 @@
"ui.one_side": "Un lato",
"ui.open": "Apri",
"ui.open_ellipsis": "Apri...",
"ui.open_ends_tip": "Non tocca altre curve",
"ui.open_preferences": "Apri preferenze",
"ui.open_recent": "Apri recenti",
"ui.operation": "Operazione",
@@ -1306,6 +1337,7 @@
"ui.pan_zoom_orbit": "Panoramica, zoom, orbita",
"ui.parallel": "Parallelo",
"ui.parameters": "Parametri",
"ui.part_size_shared": "Ricostruisce la parte, sostituendo le eventuali lavorazioni aggiunte. Si aggiornano tutte le occorrenze del componente.",
"ui.paste": "Incolla",
"ui.path": "Percorso",
"ui.pattern_on_path": "Ripetizione su percorso",
@@ -1342,6 +1374,11 @@
"ui.rectangle": "Rettangolo",
"ui.rectangular_pattern": "Ripetizione rettangolare",
"ui.redo": "Ripeti",
"ui.relink_design_changed": "L'assieme è cambiato durante la scelta del sorgente. Avvia di nuovo Ricollega sorgente.",
"ui.relink_done": "Sorgente ricollegato per tutte le istanze.",
"ui.relink_not_linked": "Questo componente non è collegato a un progetto sorgente.",
"ui.relink_select_one": "Seleziona un componente collegato o una delle sue istanze.",
"ui.relink_source_all": "Ricollega sorgente (tutte le istanze)…",
"ui.removeFromToolbar": "Rimuovi dalla barra degli strumenti",
"ui.removeSection": "Rimuovi sezione",
"ui.render": "Rendering",
@@ -1353,6 +1390,8 @@
"ui.reset_the_settings_on_this_page": "Reimposta le impostazioni di questa pagina",
"ui.restore": "Ripristina",
"ui.restore_defaults": "Ripristina valori predefiniti",
"ui.reverse_horizontal_pan_direction": "Inverti direzione della panoramica orizzontale",
"ui.reverse_horizontal_pan_tip": "Lo scorrimento orizzontale a due dita e la rotella del pollice eseguono la panoramica nella direzione opposta",
"ui.reverse_zoom_direction": "Inverti direzione dello zoom",
"ui.revolve": "Rivoluzione",
"ui.right": "Destra",
@@ -1363,6 +1402,8 @@
"ui.save_mesh_pick_bodies": "Seleziona i corpi da salvare",
"ui.save_mesh_temp_failed": "Impossibile creare {folder}: {error}",
"ui.saved_to": "Salvato {path}",
"ui.sbox_pin": "Fissa in cima al riquadro S",
"ui.sbox_unpin": "Sblocca",
"ui.scale": "Scala",
"ui.second_rail": "Seconda guida",
"ui.section_analysis": "Analisi in sezione",
@@ -1406,8 +1447,34 @@
"ui.smooth_wheel_zoom": "Zoom fluido con rotellina",
"ui.smooth_wheel_zoom_tip": "Disattiva per applicare immediatamente ogni evento della rotellina, senza coda di interpolazione",
"ui.snap": "Aggancio",
"ui.space_mouse_calibrate": "Calibra…",
"ui.space_mouse_calibrate_tip": "Muovi la manopola come chiede la finestra, così SolidCraft impara in che direzione e quanto va ogni asse di questo mouse 3D. Viene salvata per questo mouse 3D.",
"ui.space_mouse_calibrate_title": "Calibra mouse 3D",
"ui.space_mouse_calibrated": "Calibrato",
"ui.space_mouse_calibration": "Calibrazione del mouse 3D",
"ui.space_mouse_calibration_done": "Fatto. Salvala per usarla ogni volta che questo mouse 3D è collegato.",
"ui.space_mouse_calibration_no_response": "Un asse si è mosso appena, quindi non si può calibrare. Ricomincia e muovi la manopola più a fondo ogni volta.",
"ui.space_mouse_dead_zone": "Zona morta",
"ui.space_mouse_dead_zone_tip": "Quanto deve muoversi la manopola prima che si muova la vista, come quota della sua corsa completa. Aumentala se la vista si sposta con la manopola a riposo.",
"ui.space_mouse_forget": "Dimentica calibrazione",
"ui.space_mouse_hint": "Sposta e ruota la vista insieme; gli schizzi usano solo panoramica e zoom.",
"ui.space_mouse_navigate": "Naviga con SpaceNavigator / SpaceMouse",
"ui.space_mouse_none": "Nessun mouse 3D trovato",
"ui.space_mouse_not_calibrated": "Non calibrato",
"ui.space_mouse_reading": "Lettura",
"ui.space_mouse_step_lift": "Solleva la manopola, tienila, poi lasciala.",
"ui.space_mouse_step_press": "Premi la manopola verso il basso, tienila, poi lasciala.",
"ui.space_mouse_step_pull": "Tira la manopola verso di te, tienila, poi lasciala.",
"ui.space_mouse_step_push": "Spingi la manopola lontano da te, tienila, poi lasciala.",
"ui.space_mouse_step_rest": "Togli la mano dalla manopola e lasciala ferma.",
"ui.space_mouse_step_slide_left": "Fai scorrere la manopola a sinistra, tienila, poi lasciala.",
"ui.space_mouse_step_slide_right": "Fai scorrere la manopola a destra, tienila, poi lasciala.",
"ui.space_mouse_step_tilt_back": "Inclina la manopola all'indietro (bordo anteriore in alto), tienila, poi lasciala.",
"ui.space_mouse_step_tilt_forward": "Inclina la manopola in avanti (bordo anteriore in basso), tienila, poi lasciala.",
"ui.space_mouse_step_tilt_left": "Inclina la manopola a sinistra (bordo sinistro in basso), tienila, poi lasciala.",
"ui.space_mouse_step_tilt_right": "Inclina la manopola a destra (bordo destro in basso), tienila, poi lasciala.",
"ui.space_mouse_step_twist_clockwise": "Ruota la manopola in senso orario, tienila, poi lasciala.",
"ui.space_mouse_step_twist_counterclockwise": "Ruota la manopola in senso antiorario, tienila, poi lasciala.",
"ui.spacing": "Spaziatura",
"ui.spacing_2": "Spaziatura 2",
"ui.sphere": "Sfera",
@@ -1415,6 +1482,7 @@
"ui.split_body": "Dividi corpo",
"ui.stand_up_tip": "Dopo l'apertura di un file STEP o IGES con asse Z verticale, proponi un quarto di giro attorno a X",
"ui.start": "Inizio",
"ui.start_over": "Ricomincia",
"ui.start_radius": "Raggio iniziale",
"ui.stretch": "Stira",
"ui.supersampling": "Sovracampionamento (SSAA)",
@@ -1435,6 +1503,8 @@
"ui.theme": "Tema",
"ui.thickness": "Spessore",
"ui.thread": "Filettatura",
"ui.tie_ends": "Vincola",
"ui.tie_ends_tip": "Vincola tutte le estremità che si toccano solo per posizione",
"ui.timeline_bottom": "Sequenza temporale in basso",
"ui.timeline_left_note": "La disposizione a sinistra mostra la barra di ripristino sopra la struttura delle parti e della scena.",
"ui.timeline_left_sidebar": "Barra laterale sinistra",
@@ -1457,6 +1527,7 @@
"ui.units_of_new_designs": "Unità dei nuovi progetti",
"ui.unlock": "Sblocca",
"ui.unsuppress": "Riattiva",
"ui.untied_ends_tip": "Tocca un'altra curva solo per posizione, senza vincoli",
"ui.untitled": "Senza titolo",
"ui.up_axis_note": "L'asse verticale nelle viste (viste degli assi, Vista iniziale, Vista normale). I progetti restano invariati.",
"ui.updateToCurrentView": "Aggiorna alla vista corrente",
+95 -2
View File
@@ -31,6 +31,7 @@
"Arc length": "円弧の長さ",
"Area": "表面積",
"Attachment": "アタッチメント",
"BLEND SURFACE": "ブレンドサーフェス",
"BOUNDARY SURFACE": "境界サーフェス",
"BROWSER": "ブラウザ",
"Bend Line Position": "曲げ線位置",
@@ -79,6 +80,7 @@
"Current orthographic view (1:1 mm)": "現在の平行投影(実寸・mm)",
"Current perspective view": "現在の透視投影",
"Curvature (G2)": "曲率 (G2)",
"Curvature change (G3)": "曲率変化 (G3)",
"Customize Toolbar": "ツールバーのカスタマイズ",
"Delete all": "すべて削除",
"Delete rule": "ルールを削除",
@@ -100,6 +102,8 @@
"Edit Profile Sketch": "プロファイルスケッチを編集",
"Enable rule": "ルールを有効化",
"Enabled": "有効",
"End bulge": "終了側の膨らみ",
"End continuity": "終了側の連続性",
"Explode": "分解",
"Explode Text": "テキストを分解",
"Export Animation…": "アニメーションをエクスポート…",
@@ -107,6 +111,7 @@
"Export Current View as DXF…": "現在の表示をDXFでエクスポート…",
"Export DXF…": "DXFをエクスポート…",
"Export hidden edges as dashed lines": "点線で出力",
"Extend to meet": "交わるまで延長",
"Extents": "範囲",
"Face": "面",
"Face attachment lost": "面アタッチメントが失われました",
@@ -121,6 +126,7 @@
"Follow camera (Look At sketches)": "カメラ追従(スケッチを正面視)",
"Follow the camera (Look At sketches)": "カメラ追従(スケッチを正面視)",
"Font": "フォント",
"From edges": "開始側エッジ",
"Full Length": "全長",
"Grid": "グリッド",
"Height": "高さ",
@@ -135,7 +141,9 @@
"Interferes": "干渉あり",
"Issue title": "Issueタイトル",
"Joint Origin1": "ジョイント原点1",
"Keep (points on it)": "残す部分(その上の点)",
"Keep Tools": "ツールボディを保持",
"Keep of the other surface": "相手のサーフェスで残す部分",
"Later": "後で",
"Leave it": "そのままにする",
"Legacy face attachment. Rebinding to a certified face creates an exact attachment.": "従来の面アタッチメントです。確定された面に再バインドすると正確なアタッチメントが作成されます。",
@@ -173,6 +181,7 @@
"Open GitHub draft": "GitHubの下書きを開く",
"Open-source parametric 3D CAD in Rust. Licensed MIT OR Apache-2.0.": "Rust製オープンソース・パラメトリック3D CAD。ライセンス: MIT または Apache-2.0。",
"Other side": "反対側",
"Other surface (mutual trim)": "相手のサーフェス(相互トリム)",
"Outside": "外側",
"Overall length": "全長",
"Parameter values": "パラメータ値",
@@ -258,12 +267,18 @@
"Stand it up": "正立させる",
"Stand it up: a quarter turn about X, as one Move feature you can undo.": "正立させる: 取り消し可能な1つの移動フィーチャーとして、X軸周りに90度回転します。",
"Stand this part up?": "このパーツを正立させますか?",
"Start bulge": "開始側の膨らみ",
"Start continuity": "開始側の連続性",
"Step": "ステップ",
"Steps": "ステップ数",
"Stitch the kept pieces": "残した部分をステッチ",
"Stitch to both bodies": "両方のボディにステッチ",
"Study": "スタディ",
"Style": "スタイル",
"Suppressed": "抑制",
"Surface": "サーフェス",
"Swap Ends": "端点を入れ替え",
"TRIM": "トリム",
"Tangent (G1)": "接線 (G1)",
"Tangent Chain": "正接チェーン",
"Tear": "引き裂き",
@@ -279,8 +294,10 @@
"This child is shared. Position changes apply to every copy of its parent.": "この子要素は共有されています。位置の変更は親のすべてのコピーに適用されます。",
"This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "この面は安定したアタッチメントを提供できません。別の面を選択するか、固定平面を明示的に使用してください。",
"To Offset": "オフセットまで",
"To edges": "終了側エッジ",
"Tolerance": "公差",
"Too close": "近すぎる",
"Trimming tool (plane, face or curves)": "トリムツール(平面、面、または曲線)",
"Turn off section analysis before exporting the view.": "断面解析をオフにしてからエクスポートしてください。",
"Type": "タイプ",
"UNTRIM": "トリム解除",
@@ -325,6 +342,7 @@
"cmd.appearance_assign": "外観",
"cmd.appearance_library": "外観ライブラリ",
"cmd.appearance_list": "外観を一覧表示",
"cmd.assembly_bom": "部品表",
"cmd.body_rename": "ボディの名前を変更",
"cmd.browser_group": "新規グループ",
"cmd.browser_move": "ブラウザ項目を移動",
@@ -346,10 +364,13 @@
"cmd.component_insert_design": "デザインを挿入",
"cmd.component_links": "リンクを一覧表示",
"cmd.component_list": "コンポーネントを一覧表示",
"cmd.component_metadata": "部品プロパティ",
"cmd.component_metadata_set": "部品プロパティを変更",
"cmd.component_move_bodies": "ボディをコンポーネントに移動",
"cmd.component_move_sketches": "スケッチをコンポーネントに移動",
"cmd.component_paste_new": "新規として貼り付け",
"cmd.component_properties": "コンポーネントの物理的プロパティ",
"cmd.component_relink": "参照元を再リンク",
"cmd.component_rename": "コンポーネントの名前を変更",
"cmd.component_revert_position": "位置を元に戻す",
"cmd.component_unlock": "内容のロックを解除",
@@ -401,6 +422,7 @@
"cmd.edit_checkpoint": "チェックポイント",
"cmd.edit_restore_checkpoint": "チェックポイントを復元",
"cmd.engine_commands": "コマンドを一覧表示",
"cmd.engine_describe": "コマンドの詳細を表示",
"cmd.explode_create": "分解図",
"cmd.explode_delete": "分解図を削除",
"cmd.explode_list": "分解図を一覧表示",
@@ -433,6 +455,7 @@
"cmd.joint_drive": "ジョイントを駆動",
"cmd.joint_edit": "ジョイントを編集",
"cmd.joint_gear": "ギア",
"cmd.joint_inspect": "ジョイントを検査",
"cmd.joint_limits": "ジョイントの制限",
"cmd.joint_list": "ジョイントを一覧表示",
"cmd.joint_motion_link": "モーション リンク",
@@ -442,6 +465,7 @@
"cmd.joint_rigid_group": "リジッド グループ",
"cmd.joint_screw": "ねじ",
"cmd.joint_solve": "ジョイントを解決",
"cmd.joint_sweep": "ジョイントをスイープ",
"cmd.library_apply": "ローカルライブラリ項目を適用",
"cmd.library_export": "デザインライブラリを書き出す",
"cmd.library_inspect": "ローカルライブラリを確認",
@@ -590,6 +614,7 @@
"cmd.sketch_spline_fit_point": "通過点スプライン",
"cmd.sketch_spun_profile": "回転プロファイル",
"cmd.sketch_toggle_driven": "従動寸法の切り替え",
"cmd.sketch_trim_path": "パスに沿ってトリム",
"cmd.solid_align": "整列",
"cmd.solid_boundary_fill": "境界フィル",
"cmd.solid_bounding_solid": "バウンディング ソリッド",
@@ -632,8 +657,10 @@
"cmd.timeline_roll_to": "履歴マーカーを移動",
"cmd.timeline_rollback": "タイムライン マーカーを移動",
"cmd.timeline_suppress": "フィーチャーを抑制",
"cmd.view_ambient_occlusion": "アンビエントオクルージョン",
"cmd.view_apply": "名前付きビューを復元",
"cmd.view_delete": "名前付きビューを削除",
"cmd.view_graphics_preset": "グラフィックスプリセット",
"cmd.view_list": "名前付きビューを一覧表示",
"cmd.view_navigate": "ビューナビゲーション",
"cmd.view_rename": "名前付きビューの名前を変更",
@@ -716,6 +743,15 @@
"joint.from_bodies_hint": "選択した各ボディを、位置はそのままで専用の新しいコンポーネントに移動します。",
"joint.one_body": "両方の選択が {body} 上にあります。2 つ目は別の部品で選択してください。",
"joint.one_component": "これらの部品は 1 つのコンポーネントのボディです: {bodies}。ジョイントはコンポーネント同士を動かすため、部品ごとに専用のコンポーネントが必要です。",
"messages.ago_h": "{n} 時間前",
"messages.ago_min": "{n} 分前",
"messages.ago_s": "{n} 秒前",
"messages.chip_hint": "クリックするとこのセッションのすべてのメッセージを表示します",
"messages.clear": "クリア",
"messages.copy_all": "すべてコピー",
"messages.menu": "メッセージ…",
"messages.none": "まだメッセージはありません。",
"messages.title": "メッセージ",
"motion.checked_to": "ステップ {step} までしか確認していません: 確認の計算量の上限に達しました",
"motion.collision": "{a} · {b}: ステップ {steps}",
"motion.duration_hint": "秒 (空欄: ステップごと)",
@@ -857,7 +893,7 @@
"ui.agent_chat_approval": "エージェントにこの操作を許可しますか?",
"ui.agent_chat_attach_view": "ビューを添付",
"ui.agent_chat_attach_view_tip": "現在のビューの画像をメッセージと一緒に送ります",
"ui.agent_chat_attachable": "画像・テキスト・CAD ファイル",
"ui.agent_chat_attachable": "画像・PDF・テキスト・CAD ファイル",
"ui.agent_chat_auth_agent": "{name} でサインイン",
"ui.agent_chat_auth_env": "環境変数 {vars} を設定して SolidCraft を再起動してください。",
"ui.agent_chat_auth_needed": "エージェントがサインインを求めています。サインインはエージェント自身で行います。SolidCraft がパスワードやキーを見ることはありません。",
@@ -889,7 +925,6 @@
"ui.agent_chat_file_error": "{name}: {error}",
"ui.agent_chat_file_heic": "{name}: HEIC 画像はここでは読めません。先に JPEG か PNG に書き出してください",
"ui.agent_chat_file_importing": "デザインにインポート中…",
"ui.agent_chat_file_kinds": "{name}: 添付できるのは画像 (PNG、JPEG、GIF、WebP、BMP、TIFF)、テキストファイル (SVG も)、CAD ファイル (STEP、IGES、FreeCAD、Rhino、STL、3MF、OBJ、glTF) です",
"ui.agent_chat_file_loading": "ファイルを読み込み中…",
"ui.agent_chat_file_not_a_file": "{name}: ファイルではありません",
"ui.agent_chat_file_not_a_picture": "{name}: 画像ではありません",
@@ -1036,6 +1071,8 @@
"ui.ai_toolbar_button_note": "ツールバー右端の AI ボタン。Ctrl+Shift+A と表示メニューからも引き続きパネルを開けます。",
"ui.ai_undo_tip": "このメッセージでエージェントが行った変更を、1回でまとめて元に戻します",
"ui.all": "すべて",
"ui.ambient_occlusion": "アンビエントオクルージョン",
"ui.ambient_occlusion_help": "不透明な面が接する部分に柔らかい陰影を加えます。GPUビューポートが必要です。",
"ui.analysis": "解析",
"ui.angle": "角度",
"ui.angle_decimals": "角度の小数点以下の桁数",
@@ -1050,6 +1087,7 @@
"ui.autosave_every": "自動保存の間隔",
"ui.axes": "軸",
"ui.axis": "軸",
"ui.back": "戻る",
"ui.base_point": "基点",
"ui.blocked": "到達不可",
"ui.bodies": "ボディ",
@@ -1196,6 +1234,11 @@
"ui.fully_constrained": "完全拘束",
"ui.general": "一般",
"ui.graphics": "グラフィックス",
"ui.graphics_custom": "カスタム",
"ui.graphics_performance": "パフォーマンス",
"ui.graphics_preset": "グラフィックスプリセット",
"ui.graphics_preset_help": "品質:アンビエントオクルージョン、細かい表示品質、適応型2×スーパーサンプリング。パフォーマンス:AOなし、標準の表示品質、スーパーサンプリングなし。手動変更はカスタムと表示されます。MSAAは変更されません。",
"ui.graphics_quality": "品質",
"ui.grid_follows_view": "グリッドをビューに追従させる",
"ui.grid_follows_view_hint": "側面ビューでは、グリッドを地面ではなく、そのビューに向いた垂直平面に表示します",
"ui.ground_shadow": "地面の影",
@@ -1298,6 +1341,8 @@
"ui.loft": "ロフト",
"ui.look_at": "正面視",
"ui.look_at_anim_desc": "軸ビュー、正面視、ホーム、全体表示",
"ui.manipulator_free_alt": "マニピュレータのフリードラッグを Alt/Option でも有効",
"ui.manipulator_free_alt_tip": "オン: 押し出し/ギズモ矢印で Alt/Option または Ctrl/Cmd が値スナップを無効にします。オフ: Ctrl/Cmd のみ。",
"ui.manufacture": "製造",
"ui.material_review.axes": "材料ローカル軸。ボディの方向は割り当てません。",
"ui.material_review.claims": "出典とライセンスの申告は未確認です。欠落値は不明のままです。",
@@ -1337,6 +1382,7 @@
"ui.newSketchGroup": "新しいスケッチグループ",
"ui.new_body": "新規ボディ",
"ui.new_design": "新規デザイン",
"ui.no_geometry_yet": "ジオメトリはまだありません",
"ui.no_slicer_found": "スライサーが見つかりません",
"ui.normal": "標準",
"ui.normalConstruction": "通常 / コンストラクション",
@@ -1364,6 +1410,7 @@
"ui.pan_zoom_orbit": "パン、ズーム、オービット",
"ui.parallel": "平行",
"ui.parameters": "パラメータ",
"ui.part_size_shared": "パーツを再構築し、追加したフィーチャを置き換えます。このコンポーネントのすべての配置が更新されます。",
"ui.paste": "貼り付け",
"ui.path": "パス",
"ui.pattern_on_path": "パス上のパターン",
@@ -1375,6 +1422,12 @@
"ui.placement": "配置",
"ui.plane": "平面",
"ui.plane_at_angle": "傾斜平面",
"ui.plugins": "プラグイン",
"ui.plugins_desktop": "プラグインはデスクトップ版で使えます。",
"ui.plugins_empty": "プラグインは入っていません。",
"ui.plugins_note": "プラグインは {path} の中のフォルダで、plugin.json を置きます。SolidCraft はそのファイルを読むだけで、フォルダのコードは実行しません。オフにしてもインストールは残ります。",
"ui.plugins_skipped": "{count} 個のフォルダを読めませんでした。",
"ui.plugins_version": "{version}",
"ui.point": "点",
"ui.point_under_the_cursor": "カーソル下の点",
"ui.points": "点",
@@ -1400,6 +1453,11 @@
"ui.rectangle": "長方形",
"ui.rectangular_pattern": "矩形パターン",
"ui.redo": "やり直す",
"ui.relink_design_changed": "参照元の選択中にアセンブリが変更されました。再リンクをやり直してください。",
"ui.relink_done": "すべてのインスタンスの参照元を再リンクしました。",
"ui.relink_not_linked": "このコンポーネントは参照元の設計にリンクされていません。",
"ui.relink_select_one": "リンクされたコンポーネントまたはそのインスタンスを1つ選択してください。",
"ui.relink_source_all": "参照元を再リンク(すべてのインスタンス)…",
"ui.removeFromToolbar": "ツールバーから削除",
"ui.removeSection": "セクションを解除",
"ui.render": "レンダリング",
@@ -1423,6 +1481,8 @@
"ui.save_mesh_pick_bodies": "保存するボディを選択してください",
"ui.save_mesh_temp_failed": "{folder} を作成できません: {error}",
"ui.saved_to": "保存しました: {path}",
"ui.sbox_pin": "Sボックスの上部に固定",
"ui.sbox_unpin": "固定を解除",
"ui.scale": "尺度",
"ui.second_rail": "第2レール",
"ui.section_analysis": "断面解析",
@@ -1431,6 +1491,9 @@
"ui.selectTangentChain": "接線チェーンを選択",
"ui.select_other_hint": "その他を選択: モデル上で左ボタンを長押し(または右クリック › その他を選択…)するとカーソル下の要素が一覧表示されます。同じ場所を再度クリックすると次の要素が選択されます。",
"ui.selection": "選択",
"ui.selection_modifiers": "選択修飾キー",
"ui.selection_shift_add_only": "Shift+クリックで追加のみ(解除なし)",
"ui.selection_shift_add_only_tip": "オン: Shift は追加、Ctrl/Cmd はトグル。オフ(既定): Shift と Ctrl/Cmd の両方がトグル。",
"ui.send": "送信",
"ui.send_to_slicer": "スライサーに送る",
"ui.sent_to_slicer": "{slicer} に送信しました",
@@ -1455,6 +1518,7 @@
"ui.sketch": "スケッチ",
"ui.sketch_dimension": "スケッチ寸法",
"ui.sketch_grid": "スケッチ グリッド",
"ui.sketch_modifiers": "スケッチ修飾キー",
"ui.sketch_scale": "スケッチ尺度",
"ui.sketch_status": "スケッチの状態",
"ui.sketches": "スケッチ",
@@ -1466,8 +1530,34 @@
"ui.smooth_wheel_zoom": "滑らかなホイールズーム",
"ui.smooth_wheel_zoom_tip": "オフにすると、ホイール操作のたびにズームが即座に反映され、滑らかな減速効果は適用されません。",
"ui.snap": "スナップ",
"ui.space_mouse_calibrate": "キャリブレーション…",
"ui.space_mouse_calibrate_tip": "ウィンドウの指示どおりにキャップを動かすと、この3Dマウスの各軸の向きと移動量をSolidCraftが学習します。この3Dマウス用に保存されます。",
"ui.space_mouse_calibrate_title": "3Dマウスのキャリブレーション",
"ui.space_mouse_calibrated": "キャリブレーション済み",
"ui.space_mouse_calibration": "3Dマウスのキャリブレーション",
"ui.space_mouse_calibration_done": "完了しました。保存すると、この3Dマウスを接続したときに毎回使われます。",
"ui.space_mouse_calibration_no_response": "ほとんど動かなかった軸があるため、キャリブレーションできません。最初からやり直し、毎回キャップをもっと大きく動かしてください。",
"ui.space_mouse_dead_zone": "デッドゾーン",
"ui.space_mouse_dead_zone_tip": "視点が動き出すまでにキャップを動かす量(全ストロークに対する割合)。キャップに触れていないのに視点が動く場合は大きくしてください。",
"ui.space_mouse_forget": "キャリブレーションを削除",
"ui.space_mouse_hint": "視点の移動と回転を同時に行います(スケッチ中はパンとズームのみ)",
"ui.space_mouse_navigate": "SpaceNavigator / SpaceMouseで視点を操作",
"ui.space_mouse_none": "3Dマウスが見つかりません",
"ui.space_mouse_not_calibrated": "未キャリブレーション",
"ui.space_mouse_reading": "読み取り値",
"ui.space_mouse_step_lift": "キャップを持ち上げて保持し、離してください。",
"ui.space_mouse_step_press": "キャップを押し下げて保持し、離してください。",
"ui.space_mouse_step_pull": "キャップを手前に引いて保持し、離してください。",
"ui.space_mouse_step_push": "キャップを奥に押して保持し、離してください。",
"ui.space_mouse_step_rest": "キャップから手を離し、動かさないでください。",
"ui.space_mouse_step_slide_left": "キャップを左にスライドして保持し、離してください。",
"ui.space_mouse_step_slide_right": "キャップを右にスライドして保持し、離してください。",
"ui.space_mouse_step_tilt_back": "キャップを後ろに傾け(手前の縁を上げて)保持し、離してください。",
"ui.space_mouse_step_tilt_forward": "キャップを前に傾け(手前の縁を下げて)保持し、離してください。",
"ui.space_mouse_step_tilt_left": "キャップを左に傾け(左の縁を下げて)保持し、離してください。",
"ui.space_mouse_step_tilt_right": "キャップを右に傾け(右の縁を下げて)保持し、離してください。",
"ui.space_mouse_step_twist_clockwise": "キャップを時計回りにひねって保持し、離してください。",
"ui.space_mouse_step_twist_counterclockwise": "キャップを反時計回りにひねって保持し、離してください。",
"ui.spacing": "間隔",
"ui.spacing_2": "間隔 2",
"ui.sphere": "球",
@@ -1475,12 +1565,15 @@
"ui.split_body": "ボディを分割",
"ui.stand_up_tip": "Z軸上向きで開いたSTEPまたはIGESファイルに対し、X軸周りに90度回転させるか確認します",
"ui.start": "開始",
"ui.start_over": "最初からやり直す",
"ui.start_radius": "開始半径",
"ui.stretch": "伸縮",
"ui.supersampling": "スーパーサンプリング (SSAA)",
"ui.supersampling_adaptive": "ビューが静止しているときのみ",
"ui.supersampling_off": "オフ (1×)",
"ui.suppress": "抑制",
"ui.suppress_auto_snap": "作図・ドラッグ中の自動スナップを抑制",
"ui.suppress_auto_snap_note": "既定: Ctrl/Cmd または Alt/Option のどれでもスケッチスナップを抑制します。単一キーに絞ることもできます。",
"ui.sweep": "スイープ",
"ui.sweep_follow_direction": "方向に従う",
"ui.sweep_keep_as_drawn": "描いた向きを保持",
+52
View File
@@ -26,6 +26,7 @@
"Current orthographic view (1:1 mm)": "현재 직교 투영 뷰 (1:1 mm)",
"Current perspective view": "현재 원근 투영 뷰",
"Curvature (G2)": "곡률 (G2)",
"Curvature change (G3)": "곡률 변화 (G3)",
"Direction": "방향",
"Do not export hidden edges": "숨겨진 모서리 내보내지 않기",
"Don't ask again": "다시 묻지 않기",
@@ -39,16 +40,20 @@
"Export Current View as DXF…": "현재 뷰를 DXF로 내보내기…",
"Export DXF…": "DXF 내보내기…",
"Export hidden edges as dashed lines": "숨겨진 모서리를 파선으로 내보내기",
"Extend to meet": "만날 때까지 연장",
"Face": "면",
"Faces": "면",
"Find in Browser": "브라우저에서 찾기",
"Flat": "플랫",
"From edges": "시작 모서리",
"Hidden edges": "숨겨진 모서리",
"Holes": "구멍",
"Holes (edges on them)": "구멍 (구멍의 모서리)",
"In place (surfaces)": "제자리에서 (서피스)",
"In plane": "평면 내",
"Inside": "안쪽",
"Keep (points on it)": "유지할 부분(그 위의 점)",
"Keep of the other surface": "다른 곡면에서 유지할 부분",
"Length": "길이",
"Max": "최대",
"Min": "최소",
@@ -58,6 +63,7 @@
"None": "없음",
"Normal to sketch": "스케치에 수직",
"Notch": "노치",
"Other surface (mutual trim)": "다른 곡면(상호 트림)",
"Outside": "바깥쪽",
"Overall length": "전체 길이",
"Pass through": "통과점",
@@ -87,12 +93,18 @@
"Sketch geometry and profiles": "스케치 기하 및 프로파일",
"Sketch points": "스케치 점",
"Slot width": "슬롯 폭",
"Stitch the kept pieces": "유지한 부분 재봉",
"Stitch to both bodies": "두 바디에 재봉",
"Study": "연구",
"Surface": "곡면",
"TRIM": "트림",
"Tangent (G1)": "접선 (G1)",
"Tear": "찢김",
"Thickness Outside": "바깥쪽 두께",
"Thin": "얇은 벽",
"Thin Extrude": "얇은 벽 돌출",
"To edges": "끝 모서리",
"Trimming tool (plane, face or curves)": "트림 도구(평면, 면 또는 곡선)",
"Turn off section analysis before exporting the view.": "뷰를 내보내기 전에 단면 분석을 끄세요.",
"Type": "유형",
"UNTRIM": "트림 해제",
@@ -238,6 +250,7 @@
"cmd.joint_rigid_group": "강체 그룹",
"cmd.joint_screw": "나사",
"cmd.joint_solve": "관절 해석",
"cmd.joint_sweep": "관절 스윕",
"cmd.library_apply": "로컬 라이브러리 항목 적용",
"cmd.library_export": "설계 라이브러리 내보내기",
"cmd.library_inspect": "로컬 라이브러리 확인",
@@ -425,8 +438,10 @@
"cmd.timeline_roll_to": "이력 마커 이동",
"cmd.timeline_rollback": "타임라인 마커 이동",
"cmd.timeline_suppress": "특성 비활성화",
"cmd.view_ambient_occlusion": "앰비언트 오클루전",
"cmd.view_apply": "이름 있는 보기 복원",
"cmd.view_delete": "이름 있는 보기 삭제",
"cmd.view_graphics_preset": "그래픽 프리셋",
"cmd.view_list": "이름 있는 보기 목록",
"cmd.view_navigate": "보기 탐색",
"cmd.view_rename": "이름 있는 보기 이름 변경",
@@ -756,6 +771,8 @@
"ui.ai_toolbar_button_note": "도구 모음 오른쪽 끝의 AI 버튼. Ctrl+Shift+A와 보기 메뉴로도 패널을 열 수 있습니다.",
"ui.ai_undo_tip": "이 메시지에서 에이전트가 바꾼 모든 것을 한 번에 실행 취소",
"ui.all": "모두",
"ui.ambient_occlusion": "앰비언트 오클루전",
"ui.ambient_occlusion_help": "불투명한 표면이 만나는 곳에 부드러운 음영을 추가합니다. GPU 뷰포트가 필요합니다.",
"ui.analysis": "분석",
"ui.angle": "각도",
"ui.angle_decimals": "각도 소수점 자릿수",
@@ -770,6 +787,7 @@
"ui.autosave_every": "자동 저장 간격",
"ui.axes": "축",
"ui.axis": "축",
"ui.back": "뒤로",
"ui.base_point": "기준점",
"ui.blocked": "막힘",
"ui.bodies": "바디",
@@ -910,6 +928,11 @@
"ui.fully_constrained": "완전 구속",
"ui.general": "일반",
"ui.graphics": "그래픽",
"ui.graphics_custom": "사용자 지정",
"ui.graphics_performance": "성능",
"ui.graphics_preset": "그래픽 프리셋",
"ui.graphics_preset_help": "품질: 앰비언트 오클루전, 세밀한 표시 품질, 적응형 2× 슈퍼샘플링. 성능: 앰비언트 오클루전 끄기, 보통 표시 품질, 슈퍼샘플링 끄기. 수동 변경 시 사용자 지정으로 표시됩니다. MSAA는 유지됩니다.",
"ui.graphics_quality": "품질",
"ui.groupSelected": "선택 항목 그룹화",
"ui.heading_assemble": "조립",
"ui.heading_box": "박스",
@@ -1116,6 +1139,8 @@
"ui.save_mesh_pick_bodies": "저장할 바디를 선택하세요",
"ui.save_mesh_temp_failed": "{folder}을(를) 만들 수 없습니다: {error}",
"ui.saved_to": "저장됨: {path}",
"ui.sbox_pin": "S 상자 맨 위에 고정",
"ui.sbox_unpin": "고정 해제",
"ui.scale": "축척",
"ui.section_analysis": "단면 분석",
"ui.select": "선택",
@@ -1155,12 +1180,39 @@
"ui.slot": "슬롯",
"ui.smooth": "부드럽게",
"ui.snap": "스냅",
"ui.space_mouse_calibrate": "보정…",
"ui.space_mouse_calibrate_tip": "창의 안내대로 캡을 움직이면 SolidCraft가 이 3D 마우스의 각 축이 어느 방향으로 얼마나 움직이는지 학습합니다. 이 3D 마우스에 저장됩니다.",
"ui.space_mouse_calibrate_title": "3D 마우스 보정",
"ui.space_mouse_calibrated": "보정됨",
"ui.space_mouse_calibration": "3D 마우스 보정",
"ui.space_mouse_calibration_done": "완료했습니다. 저장하면 이 3D 마우스를 연결할 때마다 사용됩니다.",
"ui.space_mouse_calibration_no_response": "거의 움직이지 않은 축이 있어 보정할 수 없습니다. 처음부터 다시 하고 매번 캡을 더 멀리 움직이세요.",
"ui.space_mouse_dead_zone": "데드존",
"ui.space_mouse_dead_zone_tip": "보기가 움직이기 전에 캡이 움직여야 하는 정도로, 전체 이동 범위에 대한 비율입니다. 캡을 건드리지 않았는데 보기가 움직이면 높이세요.",
"ui.space_mouse_forget": "보정 삭제",
"ui.space_mouse_none": "3D 마우스를 찾을 수 없음",
"ui.space_mouse_not_calibrated": "보정 안 됨",
"ui.space_mouse_reading": "읽은 값",
"ui.space_mouse_step_lift": "캡을 위로 들어 유지한 다음 놓으세요.",
"ui.space_mouse_step_press": "캡을 아래로 눌러 유지한 다음 놓으세요.",
"ui.space_mouse_step_pull": "캡을 몸 쪽으로 당겨 유지한 다음 놓으세요.",
"ui.space_mouse_step_push": "캡을 앞쪽으로 밀어 유지한 다음 놓으세요.",
"ui.space_mouse_step_rest": "캡에서 손을 떼고 가만히 두세요.",
"ui.space_mouse_step_slide_left": "캡을 왼쪽으로 밀어 유지한 다음 놓으세요.",
"ui.space_mouse_step_slide_right": "캡을 오른쪽으로 밀어 유지한 다음 놓으세요.",
"ui.space_mouse_step_tilt_back": "캡을 뒤로 기울여(앞쪽 가장자리를 위로) 유지한 다음 놓으세요.",
"ui.space_mouse_step_tilt_forward": "캡을 앞으로 기울여(앞쪽 가장자리를 아래로) 유지한 다음 놓으세요.",
"ui.space_mouse_step_tilt_left": "캡을 왼쪽으로 기울여(왼쪽 가장자리를 아래로) 유지한 다음 놓으세요.",
"ui.space_mouse_step_tilt_right": "캡을 오른쪽으로 기울여(오른쪽 가장자리를 아래로) 유지한 다음 놓으세요.",
"ui.space_mouse_step_twist_clockwise": "캡을 시계 방향으로 돌려 유지한 다음 놓으세요.",
"ui.space_mouse_step_twist_counterclockwise": "캡을 시계 반대 방향으로 돌려 유지한 다음 놓으세요.",
"ui.spacing": "간격",
"ui.spacing_2": "간격 2",
"ui.sphere": "구",
"ui.spline": "스플라인",
"ui.split_body": "바디 분할",
"ui.start": "시작",
"ui.start_over": "처음부터 다시",
"ui.start_radius": "시작 반지름",
"ui.supersampling": "슈퍼샘플링 (SSAA)",
"ui.supersampling_adaptive": "뷰가 멈춰 있을 때만",
File diff suppressed because it is too large Load Diff
+2
View File
@@ -1384,6 +1384,8 @@
"ui.save_mesh_pick_bodies": "Zaznacz bryły do zapisania",
"ui.save_mesh_temp_failed": "Nie udało się utworzyć {folder}: {error}",
"ui.saved_to": "Zapisano {path}",
"ui.sbox_pin": "Przypnij na górze okna S",
"ui.sbox_unpin": "Odepnij",
"ui.scale": "Skala",
"ui.second_rail": "Druga prowadnica",
"ui.section_analysis": "Analiza przekroju",
+42
View File
@@ -73,6 +73,7 @@
"Current orthographic view (1:1 mm)": "Vista ortográfica atual (1:1 mm)",
"Current perspective view": "Vista em perspectiva atual",
"Curvature (G2)": "Curvatura (G2)",
"Curvature change (G3)": "Variação de curvatura (G3)",
"Customize Toolbar": "Personalizar barra de ferramentas",
"Delete all": "Excluir tudo",
"Delete rule": "Excluir regra",
@@ -98,6 +99,7 @@
"Export Current View as DXF…": "Exportar vista atual como DXF…",
"Export DXF…": "Exportar DXF…",
"Export hidden edges as dashed lines": "Exportar arestas ocultas como linhas tracejadas",
"Extend to meet": "Estender até encontrar",
"Extents": "Extensões",
"Face": "Face",
"Face attachment lost": "Fixação à face perdida",
@@ -112,6 +114,7 @@
"Follow camera (Look At sketches)": "Seguir a câmera (Olhar para os esboços)",
"Follow the camera (Look At sketches)": "Seguir a câmera (Olhar para os esboços)",
"Font": "Fonte",
"From edges": "Arestas de origem",
"Full Length": "Comprimento total",
"Grid": "Grade",
"Height": "Altura",
@@ -126,7 +129,9 @@
"Interferes": "Interfere",
"Issue title": "Título da issue",
"Joint Origin1": "Origem da junta1",
"Keep (points on it)": "Manter (pontos sobre ela)",
"Keep Tools": "Manter ferramentas",
"Keep of the other surface": "Manter da outra superfície",
"Later": "Depois",
"Leave it": "Deixar assim",
"Legacy face attachment. Rebinding to a certified face creates an exact attachment.": "Fixação de face antiga. Vincular novamente a uma face certificada cria uma fixação exata.",
@@ -162,6 +167,7 @@
"Open GitHub draft": "Abrir rascunho no GitHub",
"Open-source parametric 3D CAD in Rust. Licensed MIT OR Apache-2.0.": "CAD 3D paramétrico de código aberto em Rust. Licenciado sob MIT OU Apache-2.0.",
"Other side": "Outro lado",
"Other surface (mutual trim)": "Outra superfície (aparo mútuo)",
"Outside": "Fora",
"Overall length": "Comprimento total",
"Parameter values": "Valores dos parâmetros",
@@ -245,9 +251,13 @@
"Stand this part up?": "Colocar esta peça em pé?",
"Step": "Passo",
"Steps": "Passos",
"Stitch the kept pieces": "Costurar as partes mantidas",
"Stitch to both bodies": "Costurar aos dois corpos",
"Style": "Estilo",
"Suppressed": "Suprimido",
"Surface": "Superfície",
"Swap Ends": "Trocar extremidades",
"TRIM": "APARAR",
"Tangent (G1)": "Tangente (G1)",
"Tangent Chain": "Cadeia tangente",
"Tear": "Rasgo",
@@ -263,7 +273,9 @@
"This child is shared. Position changes apply to every copy of its parent.": "Este filho é compartilhado. Mudanças de posição se aplicam a todas as cópias do pai.",
"This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "Esta face não pode fornecer uma fixação duradoura. Escolha outra face ou use explicitamente um plano fixo.",
"To Offset": "Deslocamento final",
"To edges": "Arestas de destino",
"Tolerance": "Tolerância",
"Trimming tool (plane, face or curves)": "Ferramenta de aparo (plano, face ou curvas)",
"Turn off section analysis before exporting the view.": "Desative a análise de seção antes de exportar a vista.",
"Type": "Tipo",
"UNTRIM": "DESAPARAR",
@@ -956,6 +968,7 @@
"ui.autosave_every": "Salvar automaticamente a cada",
"ui.axes": "Eixos",
"ui.axis": "Eixo",
"ui.back": "Voltar",
"ui.base_point": "Ponto base",
"ui.blocked": "Bloqueado",
"ui.bodies": "Corpos",
@@ -1317,6 +1330,8 @@
"ui.save_mesh_pick_bodies": "Selecione os corpos a salvar",
"ui.save_mesh_temp_failed": "Não foi possível criar {folder}: {error}",
"ui.saved_to": "Salvo {path}",
"ui.sbox_pin": "Fixar no topo da caixa S",
"ui.sbox_unpin": "Desafixar",
"ui.scale": "Escala",
"ui.section_analysis": "Análise de seção",
"ui.select": "Selecionar",
@@ -1359,8 +1374,34 @@
"ui.smooth_wheel_zoom": "Zoom suave com a roda",
"ui.smooth_wheel_zoom_tip": "Desative para aplicar cada evento da roda na hora, sem rastro de suavização",
"ui.snap": "Encaixe",
"ui.space_mouse_calibrate": "Calibrar…",
"ui.space_mouse_calibrate_tip": "Mova a tampa como a janela pedir, para que o SolidCraft aprenda para onde e até onde vai cada eixo deste mouse 3D. Salvo para este mouse 3D.",
"ui.space_mouse_calibrate_title": "Calibrar mouse 3D",
"ui.space_mouse_calibrated": "Calibrado",
"ui.space_mouse_calibration": "Calibração do mouse 3D",
"ui.space_mouse_calibration_done": "Pronto. Salve para usá-la sempre que este mouse 3D estiver conectado.",
"ui.space_mouse_calibration_no_response": "Um eixo quase não se moveu, então não dá para calibrá-lo. Recomece e mova a tampa mais longe a cada vez.",
"ui.space_mouse_dead_zone": "Zona morta",
"ui.space_mouse_dead_zone_tip": "Quanto a tampa precisa se mover antes de a vista se mover, como fração do curso completo. Aumente se a vista se mexer com a tampa parada.",
"ui.space_mouse_forget": "Esquecer calibração",
"ui.space_mouse_hint": "Move e gira a vista juntos; esboços usam apenas deslocamento e zoom.",
"ui.space_mouse_navigate": "Navegar com SpaceNavigator / SpaceMouse",
"ui.space_mouse_none": "Nenhum mouse 3D encontrado",
"ui.space_mouse_not_calibrated": "Não calibrado",
"ui.space_mouse_reading": "Leitura",
"ui.space_mouse_step_lift": "Levante a tampa, segure e solte.",
"ui.space_mouse_step_press": "Pressione a tampa para baixo, segure e solte.",
"ui.space_mouse_step_pull": "Puxe a tampa na sua direção, segure e solte.",
"ui.space_mouse_step_push": "Empurre a tampa para longe de você, segure e solte.",
"ui.space_mouse_step_rest": "Tire a mão da tampa e deixe-a parada.",
"ui.space_mouse_step_slide_left": "Deslize a tampa para a esquerda, segure e solte.",
"ui.space_mouse_step_slide_right": "Deslize a tampa para a direita, segure e solte.",
"ui.space_mouse_step_tilt_back": "Incline a tampa para trás (borda da frente para cima), segure e solte.",
"ui.space_mouse_step_tilt_forward": "Incline a tampa para a frente (borda da frente para baixo), segure e solte.",
"ui.space_mouse_step_tilt_left": "Incline a tampa para a esquerda (borda esquerda para baixo), segure e solte.",
"ui.space_mouse_step_tilt_right": "Incline a tampa para a direita (borda direita para baixo), segure e solte.",
"ui.space_mouse_step_twist_clockwise": "Gire a tampa no sentido horário, segure e solte.",
"ui.space_mouse_step_twist_counterclockwise": "Gire a tampa no sentido anti-horário, segure e solte.",
"ui.spacing": "Espaçamento",
"ui.spacing_2": "Espaçamento 2",
"ui.sphere": "Esfera",
@@ -1368,6 +1409,7 @@
"ui.split_body": "Dividir corpo",
"ui.stand_up_tip": "Depois que um arquivo STEP ou IGES abrir com Z para cima, oferecer um quarto de volta em torno de X",
"ui.start": "Início",
"ui.start_over": "Recomeçar",
"ui.start_radius": "Raio inicial",
"ui.supersampling": "Supersampling (SSAA)",
"ui.supersampling_adaptive": "Só quando a vista está parada",
+52
View File
@@ -67,6 +67,7 @@
"Create Issue — review draft": "Создать Issue — проверить черновик",
"Cube": "Куб",
"Curvature (G2)": "Кривизна (G2)",
"Curvature change (G3)": "Изменение кривизны (G3)",
"Customize Toolbar": "Настроить панель инструментов",
"Delete all": "Удалить все",
"Delete rule": "Удалить правило",
@@ -88,6 +89,7 @@
"Enabled": "Включено",
"Explode": "Разобрать",
"Export Animation…": "Экспортировать анимацию…",
"Extend to meet": "Продлить до пересечения",
"Extents": "Пределы",
"Face attachment lost": "Привязка к грани потеряна",
"Faces": "Грани",
@@ -101,6 +103,7 @@
"Follow camera (Look At sketches)": "Следовать за камерой (эскизы «Взгляд на»)",
"Follow the camera (Look At sketches)": "Следовать за камерой (эскизы «Взгляд на»)",
"Font": "Шрифт",
"From edges": "Начальные рёбра",
"Full Length": "Полная длина",
"Grid": "Сетка",
"Height": "Высота",
@@ -110,7 +113,9 @@
"Interferes": "Пересекается",
"Issue title": "Заголовок Issue",
"Joint Origin1": "Точка сопряжения1",
"Keep (points on it)": "Оставить (точки на нём)",
"Keep Tools": "Оставить инструменты",
"Keep of the other surface": "Оставить от другой поверхности",
"Later": "Позже",
"Leave it": "Оставить как есть",
"Legacy face attachment. Rebinding to a certified face creates an exact attachment.": "Устаревшая привязка к грани. Перепривязка к сертифицированной грани создаёт точную привязку.",
@@ -142,6 +147,7 @@
"Open GitHub draft": "Открыть черновик на GitHub",
"Open-source parametric 3D CAD in Rust. Licensed MIT OR Apache-2.0.": "Открытая параметрическая 3D CAD на Rust. Лицензия MIT OR Apache-2.0.",
"Other side": "Другая сторона",
"Other surface (mutual trim)": "Другая поверхность (взаимная обрезка)",
"Parameter values": "Значения параметров",
"Pass through": "Через точки",
"Path": "Траектория",
@@ -218,10 +224,14 @@
"Stand this part up?": "Поставить эту деталь вертикально?",
"Step": "Шаг",
"Steps": "Шаги",
"Stitch the kept pieces": "Сшить оставленные части",
"Stitch to both bodies": "Сшить с обоими телами",
"Study": "Исследование",
"Style": "Стиль",
"Suppressed": "Подавлено",
"Surface": "Поверхность",
"Swap Ends": "Поменять концы",
"TRIM": "ОБРЕЗКА",
"Tangent (G1)": "Касание (G1)",
"Tangent Chain": "Касательная цепочка",
"Tear": "Разрыв",
@@ -234,7 +244,9 @@
"This child is shared. Position changes apply to every copy of its parent.": "Этот дочерний объект общий. Изменения положения применятся ко всем копиям родителя.",
"This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "Эта грань не может обеспечить надёжную привязку. Выберите другую грань или явно используйте фиксированную плоскость.",
"To Offset": "До смещения",
"To edges": "Конечные рёбра",
"Tolerance": "Допуск",
"Trimming tool (plane, face or curves)": "Инструмент обрезки (плоскость, грань или кривые)",
"Type": "Тип",
"Updating the preview": "Обновление предпросмотра",
"Use active author placement": "Использовать активное исходное положение",
@@ -389,6 +401,7 @@
"cmd.joint_rigid_group": "Жёсткая группа",
"cmd.joint_screw": "Винт",
"cmd.joint_solve": "Решить сопряжения",
"cmd.joint_sweep": "Прогон сопряжения",
"cmd.material_assign": "Физический материал",
"cmd.material_create": "Новый физический материал",
"cmd.material_delete": "Удалить физический материал",
@@ -568,8 +581,10 @@
"cmd.timeline_roll_to": "Переместить маркер истории",
"cmd.timeline_rollback": "Сдвинуть маркер шкалы",
"cmd.timeline_suppress": "Подавить операцию",
"cmd.view_ambient_occlusion": "Окружающее затенение",
"cmd.view_apply": "Вернуть именованный вид",
"cmd.view_delete": "Удалить именованный вид",
"cmd.view_graphics_preset": "Графический профиль",
"cmd.view_list": "Список именованных видов",
"cmd.view_navigate": "Навигация вида",
"cmd.view_rename": "Переименовать именованный вид",
@@ -750,6 +765,8 @@
"ui.agent_cursor": "Курсор агента",
"ui.agent_cursor_speed": "Скорость курсора агента",
"ui.all": "Все",
"ui.ambient_occlusion": "Окружающее затенение",
"ui.ambient_occlusion_help": "Мягкое затенение в местах соприкосновения непрозрачных поверхностей. Требуется видовой экран GPU.",
"ui.analysis": "Анализ",
"ui.angle": "Угол",
"ui.angle_decimals": "Знаков после запятой (углы)",
@@ -764,6 +781,7 @@
"ui.autosave_every": "Автосохранение каждые",
"ui.axes": "Оси",
"ui.axis": "Ось",
"ui.back": "Назад",
"ui.base_point": "Базовая точка",
"ui.bodies": "Тела",
"ui.box": "Бокс",
@@ -899,6 +917,11 @@
"ui.fully_constrained": "Полностью определён",
"ui.general": "Общие",
"ui.graphics": "Графика",
"ui.graphics_custom": "Настраиваемый",
"ui.graphics_performance": "Производительность",
"ui.graphics_preset": "Графический профиль",
"ui.graphics_preset_help": "Качество: окружающее затенение, детальная тесселяция и адаптивный суперсэмплинг 2×. Производительность: без затенения, обычная тесселяция и без суперсэмплинга. Ручные изменения отображаются как настраиваемые. MSAA не меняется.",
"ui.graphics_quality": "Качество",
"ui.ground_shadow": "Тень на земле",
"ui.groupSelected": "Группировать выбранное",
"ui.heading_add_ins": "НАДСТРОЙКИ",
@@ -1116,6 +1139,8 @@
"ui.save_mesh_pick_bodies": "Выберите тела для сохранения",
"ui.save_mesh_temp_failed": "Не удалось создать {folder}: {error}",
"ui.saved_to": "Сохранено: {path}",
"ui.sbox_pin": "Закрепить вверху панели S",
"ui.sbox_unpin": "Открепить",
"ui.scale": "Масштаб",
"ui.section_analysis": "Анализ сечения",
"ui.select": "Выбрать",
@@ -1158,8 +1183,34 @@
"ui.smooth_wheel_zoom": "Плавное масштабирование колесом",
"ui.smooth_wheel_zoom_tip": "Отключите, чтобы каждое движение колеса применялось сразу, без сглаживания",
"ui.snap": "Привязка",
"ui.space_mouse_calibrate": "Калибровать…",
"ui.space_mouse_calibrate_tip": "Двигайте колпачок так, как просит окно, чтобы SolidCraft узнал, куда и насколько далеко движется каждая ось этой 3D-мыши. Сохраняется для этой 3D-мыши.",
"ui.space_mouse_calibrate_title": "Калибровка 3D-мыши",
"ui.space_mouse_calibrated": "Откалибрована",
"ui.space_mouse_calibration": "Калибровка 3D-мыши",
"ui.space_mouse_calibration_done": "Готово. Сохраните её, чтобы она применялась всякий раз, когда подключена эта 3D-мышь.",
"ui.space_mouse_calibration_no_response": "Одна ось почти не сдвинулась, поэтому её нельзя откалибровать. Начните заново и каждый раз двигайте колпачок дальше.",
"ui.space_mouse_dead_zone": "Мёртвая зона",
"ui.space_mouse_dead_zone_tip": "Насколько нужно сдвинуть колпачок, прежде чем сдвинется вид, в доле от полного хода. Увеличьте, если вид смещается, когда колпачок в покое.",
"ui.space_mouse_forget": "Забыть калибровку",
"ui.space_mouse_hint": "Совместное перемещение и вращение вида; в эскизе — только панорама и масштаб.",
"ui.space_mouse_navigate": "Навигация через SpaceNavigator / SpaceMouse",
"ui.space_mouse_none": "3D-мышь не найдена",
"ui.space_mouse_not_calibrated": "Не откалибрована",
"ui.space_mouse_reading": "Показания",
"ui.space_mouse_step_lift": "Поднимите колпачок вверх, удержите и отпустите.",
"ui.space_mouse_step_press": "Нажмите на колпачок вниз, удержите и отпустите.",
"ui.space_mouse_step_pull": "Потяните колпачок на себя, удержите и отпустите.",
"ui.space_mouse_step_push": "Толкните колпачок от себя, удержите и отпустите.",
"ui.space_mouse_step_rest": "Уберите руку с колпачка и не трогайте его.",
"ui.space_mouse_step_slide_left": "Сдвиньте колпачок влево, удержите и отпустите.",
"ui.space_mouse_step_slide_right": "Сдвиньте колпачок вправо, удержите и отпустите.",
"ui.space_mouse_step_tilt_back": "Наклоните колпачок назад (передний край вверх), удержите и отпустите.",
"ui.space_mouse_step_tilt_forward": "Наклоните колпачок вперёд (передний край вниз), удержите и отпустите.",
"ui.space_mouse_step_tilt_left": "Наклоните колпачок влево (левый край вниз), удержите и отпустите.",
"ui.space_mouse_step_tilt_right": "Наклоните колпачок вправо (правый край вниз), удержите и отпустите.",
"ui.space_mouse_step_twist_clockwise": "Поверните колпачок по часовой стрелке, удержите и отпустите.",
"ui.space_mouse_step_twist_counterclockwise": "Поверните колпачок против часовой стрелки, удержите и отпустите.",
"ui.spacing": "Интервал",
"ui.spacing_2": "Интервал 2",
"ui.sphere": "Сфера",
@@ -1167,6 +1218,7 @@
"ui.split_body": "Разделить тело",
"ui.stand_up_tip": "Если файл STEP или IGES открывается с Z вверх, предложить поворот на четверть оборота вокруг X",
"ui.start": "Старт",
"ui.start_over": "Начать заново",
"ui.start_radius": "Начальный радиус",
"ui.suppress": "Подавить",
"ui.sweep": "Сдвиг",
+43
View File
@@ -71,6 +71,7 @@
"Countersink angle": "Konik havşa açısı",
"Create Issue — review draft": "Sorun oluştur — taslağı gözden geçirin",
"Cube": "Küp",
"Curvature change (G3)": "Eğrilik değişimi (G3)",
"Customize Toolbar": "Araç çubuğunu özelleştir",
"Delete all": "Tümünü sil",
"Delete rule": "Kuralı sil",
@@ -92,6 +93,7 @@
"Enabled": "Etkin",
"Explode": "Patlat",
"Export Animation…": "Animasyonu dışa aktar…",
"Extend to meet": "Buluşana kadar uzat",
"Extents": "Kapsamlar",
"Face": "Yüz",
"Face attachment lost": "Yüze bağlama kayboldu",
@@ -106,6 +108,7 @@
"Follow camera (Look At sketches)": "Kamera takibi (eskizlerde Bak)",
"Follow the camera (Look At sketches)": "Kamera takibi (eskizlerde Bak)",
"Font": "Yazı tipi",
"From edges": "Başlangıç kenarları",
"Full Length": "Tam uzunluk",
"Grid": "Izgara",
"Height": "Yükseklik",
@@ -119,7 +122,9 @@
"Interferes": "Girişim var",
"Issue title": "Sorun başlığı",
"Joint Origin1": "Bağlantı orijini1",
"Keep (points on it)": "Koru (üzerindeki noktalar)",
"Keep Tools": "Araçları koru",
"Keep of the other surface": "Diğer yüzeyden korunacak",
"Later": "Daha sonra",
"Leave it": "Olduğu gibi bırak",
"Legacy face attachment. Rebinding to a certified face creates an exact attachment.": "Eski tip yüz bağlaması. Doğrulanmış bir yüze yeniden bağlamak tam bir bağlama oluşturur.",
@@ -154,6 +159,7 @@
"Open GitHub draft": "GitHub taslağını aç",
"Open-source parametric 3D CAD in Rust. Licensed MIT OR Apache-2.0.": "Rust ile yazılmış açık kaynaklı parametrik 3D CAD. Lisans: MIT OR Apache-2.0.",
"Other side": "Diğer taraf",
"Other surface (mutual trim)": "Diğer yüzey (karşılıklı kırpma)",
"Outside": "Dış",
"Overall length": "Toplam uzunluk",
"Parameter values": "Parametre değerleri",
@@ -236,10 +242,14 @@
"Stand this part up?": "Bu parça dik çevrilsin mi?",
"Step": "Adım",
"Steps": "Adımlar",
"Stitch the kept pieces": "Korunan parçaları dik",
"Stitch to both bodies": "Her iki gövdeye dik",
"Study": "Etüt",
"Style": "Stil",
"Suppressed": "Bastırılmış",
"Surface": "Yüzey",
"Swap Ends": "Uçları değiştir",
"TRIM": "KIRP",
"Tangent Chain": "Teğet zincir",
"The dashed sketch is a saved plane guide. Select a new face to repair the attachment.": "Kesikli eskiz, kaydedilmiş bir düzlem kılavuzudur. Bağlamayı onarmak için yeni bir yüz seçin.",
"The saved author occurrence is missing. Select a valid author placement before rebinding.": "Kaydedilmiş kaynak örnek eksik. Yeniden bağlamadan önce geçerli bir kaynak yerleşimi seçin.",
@@ -253,7 +263,9 @@
"This child is shared. Position changes apply to every copy of its parent.": "Bu alt öğe paylaşılıyor. Konum değişiklikleri üst öğesinin tüm kopyalarına uygulanır.",
"This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "Bu yüz kalıcı bir bağlama sağlayamaz. Başka bir yüz seçin veya açıkça sabit bir düzlem kullanın.",
"To Offset": "Ofsete kadar",
"To edges": "Bitiş kenarları",
"Tolerance": "Tolerans",
"Trimming tool (plane, face or curves)": "Kırpma aracı (düzlem, yüz veya eğriler)",
"Type": "Tür",
"UNTRIM": "KIRPMAYI KALDIR",
"Updating the preview": "Önizleme güncelleniyor",
@@ -399,6 +411,7 @@
"cmd.joint_origin": "Bağlantı orijini",
"cmd.joint_rigid_group": "Rijit grup",
"cmd.joint_solve": "Bağlantıları çöz",
"cmd.joint_sweep": "Bağlantıyı tara",
"cmd.library_apply": "Yerel kitaplık girişini uygula",
"cmd.library_export": "Tasarım kitaplığını dışa aktar",
"cmd.library_inspect": "Yerel kitaplığı incele",
@@ -905,6 +918,7 @@
"ui.autosave_every": "Otomatik kaydetme aralığı",
"ui.axes": "Eksenler",
"ui.axis": "Eksen",
"ui.back": "Geri",
"ui.base_point": "Temel nokta",
"ui.blocked": "Engellenmiş",
"ui.bodies": "Gövdeler",
@@ -1264,6 +1278,8 @@
"ui.save_mesh_pick_bodies": "Kaydedilecek gövdeleri seçin",
"ui.save_mesh_temp_failed": "{folder} oluşturulamadı: {error}",
"ui.saved_to": "{path} konumuna kaydedildi",
"ui.sbox_pin": "S kutusunun en üstüne sabitle",
"ui.sbox_unpin": "Sabitlemeyi kaldır",
"ui.scale": "Ölçekle",
"ui.section_analysis": "Kesit analizi",
"ui.select": "Seç",
@@ -1306,8 +1322,34 @@
"ui.smooth_wheel_zoom": "Yumuşak tekerlek yakınlaştırması",
"ui.smooth_wheel_zoom_tip": "Her tekerlek hareketini yumuşatma olmadan hemen uygulamak için kapatın",
"ui.snap": "Yakala",
"ui.space_mouse_calibrate": "Kalibre et…",
"ui.space_mouse_calibrate_tip": "Pencerenin istediği gibi başlığı hareket ettirin; SolidCraft bu 3B farenin her ekseninin hangi yöne ve ne kadar gittiğini öğrensin. Bu 3B fare için kaydedilir.",
"ui.space_mouse_calibrate_title": "3B fareyi kalibre et",
"ui.space_mouse_calibrated": "Kalibre edildi",
"ui.space_mouse_calibration": "3B fare kalibrasyonu",
"ui.space_mouse_calibration_done": "Bitti. Bu 3B fare her bağlandığında kullanılması için kaydedin.",
"ui.space_mouse_calibration_no_response": "Bir eksen neredeyse hiç hareket etmedi, bu yüzden kalibre edilemiyor. Baştan başlayın ve başlığı her seferinde daha fazla hareket ettirin.",
"ui.space_mouse_dead_zone": "Ölü bölge",
"ui.space_mouse_dead_zone_tip": "Görünüm hareket etmeden önce başlığın ne kadar hareket etmesi gerektiği, tam hareket aralığının oranı olarak. Başlık dururken görünüm kayıyorsa artırın.",
"ui.space_mouse_forget": "Kalibrasyonu unut",
"ui.space_mouse_hint": "Görünümü aynı anda kaydırın ve döndürün; eskizlerde yalnızca kaydırma ve yakınlaştırma kullanılır.",
"ui.space_mouse_navigate": "SpaceNavigator / SpaceMouse ile gezin",
"ui.space_mouse_none": "3B fare bulunamadı",
"ui.space_mouse_not_calibrated": "Kalibre edilmedi",
"ui.space_mouse_reading": "Okunan değer",
"ui.space_mouse_step_lift": "Başlığı yukarı kaldırın, tutun, sonra bırakın.",
"ui.space_mouse_step_press": "Başlığı aşağı bastırın, tutun, sonra bırakın.",
"ui.space_mouse_step_pull": "Başlığı kendinize doğru çekin, tutun, sonra bırakın.",
"ui.space_mouse_step_push": "Başlığı sizden uzağa itin, tutun, sonra bırakın.",
"ui.space_mouse_step_rest": "Elinizi başlıktan çekin ve hareketsiz bırakın.",
"ui.space_mouse_step_slide_left": "Başlığı sola kaydırın, tutun, sonra bırakın.",
"ui.space_mouse_step_slide_right": "Başlığı sağa kaydırın, tutun, sonra bırakın.",
"ui.space_mouse_step_tilt_back": "Başlığı geriye eğin (ön kenar yukarı), tutun, sonra bırakın.",
"ui.space_mouse_step_tilt_forward": "Başlığı öne eğin (ön kenar aşağı), tutun, sonra bırakın.",
"ui.space_mouse_step_tilt_left": "Başlığı sola eğin (sol kenar aşağı), tutun, sonra bırakın.",
"ui.space_mouse_step_tilt_right": "Başlığı sağa eğin (sağ kenar aşağı), tutun, sonra bırakın.",
"ui.space_mouse_step_twist_clockwise": "Başlığı saat yönünde çevirin, tutun, sonra bırakın.",
"ui.space_mouse_step_twist_counterclockwise": "Başlığı saat yönünün tersine çevirin, tutun, sonra bırakın.",
"ui.spacing": "Aralık",
"ui.spacing_2": "Aralık 2",
"ui.sphere": "Küre",
@@ -1315,6 +1357,7 @@
"ui.split_body": "Gövdeyi böl",
"ui.stand_up_tip": "Bir STEP veya IGES dosyası Z yukarı olarak açıldığında X etrafında çeyrek tur döndürmeyi öner",
"ui.start": "Başlangıç",
"ui.start_over": "Baştan başla",
"ui.start_radius": "Başlangıç yarıçapı",
"ui.supersampling": "Süper örnekleme (SSAA)",
"ui.supersampling_adaptive": "Yalnızca görünüm hareketsizken",
+139
View File
@@ -31,6 +31,7 @@
"Arc length": "Довжина дуги",
"Area": "Площа",
"Attachment": "Прив'язка",
"BOUNDARY SURFACE": "ПОВЕРХНЯ ЗА МЕЖАМИ",
"BROWSER": "БРАУЗЕР",
"Bend Line Position": "Положення лінії згину",
"Bend Position": "Положення згину",
@@ -73,10 +74,12 @@
"Counterbore depth": "Глибина циліндричного розширення",
"Countersink angle": "Кут зенкування",
"Create Issue — review draft": "Створити повідомлення про проблему — перегляд чернетки",
"Cross curves": "Поперечні криві",
"Cube": "Куб",
"Current orthographic view (1:1 mm)": "Поточний ортографічний вид (1:1 mm)",
"Current perspective view": "Поточний перспективний вид",
"Curvature (G2)": "Кривина (G2)",
"Curvature change (G3)": "Зміна кривини (G3)",
"Customize Toolbar": "Налаштувати панель інструментів",
"Delete all": "Видалити все",
"Delete rule": "Видалити правило",
@@ -89,6 +92,7 @@
"Direction": "Напрямок",
"Discard": "Відхилити",
"Do not export hidden edges": "Не експортувати приховані ребра",
"Don't ask again": "Більше не питати",
"Double-click to rename": "Двічі клацніть, щоб перейменувати",
"Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing": "Подвійне клацання середньою кнопкою у виді: головний (головний вид, що охоплює показане), вписування (охопити показане, зберігаючи орієнтацію) або нічого",
"Drive Joint": "Керувати з'єднанням",
@@ -98,11 +102,13 @@
"Enable rule": "Увімкнути правило",
"Enabled": "Увімкнено",
"Explode": "Рознести",
"Explode Text": "Розібрати текст",
"Export Animation…": "Експортувати анімацію…",
"Export Current View as DXF": "Експортувати поточний вид як DXF",
"Export Current View as DXF…": "Експортувати поточний вид як DXF…",
"Export DXF…": "Експорт DXF…",
"Export hidden edges as dashed lines": "Експортувати приховані ребра як штрихові лінії",
"Extend to meet": "Подовжити до перетину",
"Extents": "Межі",
"Face": "Грань",
"Face attachment lost": "Втрачено прив'язку до грані",
@@ -117,6 +123,7 @@
"Follow camera (Look At sketches)": "Стежити за камерою (ескізи наведення)",
"Follow the camera (Look At sketches)": "Стежити за камерою (ескізи наведення)",
"Font": "Шрифт",
"From edges": "Початкові ребра",
"Full Length": "Повна довжина",
"Grid": "Сітка",
"Height": "Висота",
@@ -131,7 +138,9 @@
"Interferes": "Перетинається",
"Issue title": "Заголовок повідомлення",
"Joint Origin1": "Початок з'єднання1",
"Keep (points on it)": "Залишити (точки на ньому)",
"Keep Tools": "Зберегти інструменти",
"Keep of the other surface": "Залишити від іншої поверхні",
"Later": "Пізніше",
"Leave it": "Залишити як є",
"Legacy face attachment. Rebinding to a certified face creates an exact attachment.": "Застаріла прив'язка до грані. Повторна прив'язка до перевіреної грані створює точну прив'язку.",
@@ -156,6 +165,7 @@
"Move earlier": "Перемістити раніше",
"Move later": "Перемістити пізніше",
"Name": "Назва",
"Nearest": "Найближчі",
"New study": "Нове дослідження",
"No crash reports.": "Немає звітів про збої.",
"No matching appearances": "Немає відповідних виглядів",
@@ -168,6 +178,7 @@
"Open GitHub draft": "Відкрити чернетку GitHub",
"Open-source parametric 3D CAD in Rust. Licensed MIT OR Apache-2.0.": "Параметричний 3D-CAD з відкритим вихідним кодом на Rust. Ліцензовано за MIT OR Apache-2.0.",
"Other side": "Інший бік",
"Other surface (mutual trim)": "Інша поверхня (взаємне обрізання)",
"Outside": "Назовні",
"Overall length": "Загальна довжина",
"Parameter values": "Значення параметрів",
@@ -184,6 +195,7 @@
"Position (0 to 1)": "Положення (від 0 до 1)",
"Position (G0)": "Положення (G0)",
"Position along the edge, 0 at the start end, 1 at the other": "Положення вздовж ребра: 0 на початку, 1 на іншому кінці",
"Preferences › Design turns the offer back on": "Налаштування › Дизайн повертає пропозицію",
"Preview Row": "Рядок перегляду",
"Preview leaves the current design unchanged.": "Попередній перегляд не змінює поточний проєкт.",
"Profiles/Curves": "Профілі/криві",
@@ -254,10 +266,14 @@
"Stand this part up?": "Вирівняти цю деталь?",
"Step": "Крок",
"Steps": "Кроки",
"Stitch the kept pieces": "Зшити залишені частини",
"Stitch to both bodies": "Зшити з обома тілами",
"Study": "Дослідження",
"Style": "Стиль",
"Suppressed": "Придушено",
"Surface": "Поверхня",
"Swap Ends": "Поміняти кінці місцями",
"TRIM": "ОБРІЗАННЯ",
"Tangent (G1)": "Дотик (G1)",
"Tangent Chain": "Дотичний ланцюг",
"Tear": "Розрив",
@@ -273,7 +289,10 @@
"This child is shared. Position changes apply to every copy of its parent.": "Цей дочірній об'єкт спільний. Зміни положення застосовуються до кожної копії батьківського.",
"This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "Ця грань не може забезпечити надійну прив'язку. Виберіть іншу грань або явно використайте фіксовану площину.",
"To Offset": "До зсуву",
"To edges": "Кінцеві ребра",
"Tolerance": "Допуск",
"Too close": "Занадто близько",
"Trimming tool (plane, face or curves)": "Інструмент обрізання (площина, грань або криві)",
"Turn off section analysis before exporting the view.": "Вимкніть аналіз перерізу перед експортом виду.",
"Type": "Тип",
"UNTRIM": "ЗНЯТИ ОБРІЗАННЯ",
@@ -407,6 +426,7 @@
"cmd.file_slicers": "Встановлені слайсери",
"cmd.inspect_accessibility": "Аналіз доступності",
"cmd.inspect_center_of_mass": "Центр мас",
"cmd.inspect_clearance": "Зазор",
"cmd.inspect_compare": "Порівняти тіла",
"cmd.inspect_continuity": "Аналіз неперервності",
"cmd.inspect_curvature_comb": "Аналіз гребеня кривизни",
@@ -416,6 +436,7 @@
"cmd.inspect_interference": "Перевірка перетинів",
"cmd.inspect_isocurve": "Аналіз ізокривих",
"cmd.inspect_minimum_radius": "Аналіз мінімального радіуса",
"cmd.inspect_silhouette": "Силует",
"cmd.inspect_zebra": "Зебра-аналіз",
"cmd.joint_as_built": "З'єднання за поточним положенням",
"cmd.joint_cam": "Кулачок",
@@ -433,6 +454,7 @@
"cmd.joint_rigid_group": "Жорстка група",
"cmd.joint_screw": "Гвинт",
"cmd.joint_solve": "Розв'язати з'єднання",
"cmd.joint_sweep": "Прогін з'єднання",
"cmd.library_apply": "Застосувати елемент локальної бібліотеки",
"cmd.library_export": "Експортувати бібліотеку проєкту",
"cmd.library_inspect": "Перевірити локальну бібліотеку",
@@ -550,6 +572,8 @@
"cmd.sketch_isoparametric_curve": "Ізопараметрична крива",
"cmd.sketch_line3d": "3D-пряма",
"cmd.sketch_line_midpoint": "Пряма за середньою точкою",
"cmd.sketch_loose_ends": "Вільні кінці",
"cmd.sketch_loose_ends_tie": "Зв'язати вільні кінці",
"cmd.sketch_move": "Перемістити/копіювати елементи ескізу",
"cmd.sketch_move3d": "Перемістити 3D-точку",
"cmd.sketch_move_point": "Перетягнути точку ескізу",
@@ -594,8 +618,10 @@
"cmd.surface_blend": "Сполучна поверхня",
"cmd.surface_boundary": "Поверхня за межами",
"cmd.surface_extrude": "Видавлювання поверхні",
"cmd.surface_fair": "Згладжування поверхні",
"cmd.surface_fillet": "Заокруглення поверхні",
"cmd.surface_loft": "Лофт поверхні",
"cmd.surface_match": "Вирівнювання поверхні",
"cmd.surface_offset": "Зсунута поверхня",
"cmd.surface_patch": "Латка",
"cmd.surface_reverse_normal": "Обернути нормаль",
@@ -619,8 +645,10 @@
"cmd.timeline_roll_to": "Перемістити маркер історії",
"cmd.timeline_rollback": "Перемістити маркер часового ланцюжка",
"cmd.timeline_suppress": "Придушити операцію",
"cmd.view_ambient_occlusion": "Окружне затінення",
"cmd.view_apply": "Відновити іменований вид",
"cmd.view_delete": "Видалити іменований вид",
"cmd.view_graphics_preset": "Набір графіки",
"cmd.view_list": "Список іменованих видів",
"cmd.view_navigate": "Навігація видом",
"cmd.view_rename": "Перейменувати іменований вид",
@@ -642,7 +670,12 @@
"coupling.rack": "Рейка",
"coupling.radius_1": "Радіус 1",
"coupling.radius_2": "Радіус 2",
"dialog.bodies_count": "Тіл: {count}",
"dialog.clearance_invalid": "зазор має бути довжиною 0 або більше",
"dialog.compute": "Обчислити",
"dialog.compute_again": "Обчислити знову",
"dialog.computing": "Обчислення… {seconds} с",
"dialog.could_not_be_checked": "не вдалося перевірити",
"dialog.custom_axis": "↖ Власна вісь",
"dialog.edit_title": "РЕДАГУВАННЯ {title}",
"dialog.select": "↖ Вибрати",
@@ -655,6 +688,18 @@
"drive.stopped_by_contact": "Зупинка на {value}: {a} пройшло б крізь {b}",
"drive.stopped_by_loop": "Зупинка на {value}: контур через {joint} далі не замикається",
"drive.unconnected": "Нічого не рухається: {part} не з'єднано ні з чим закріпленим",
"dxf.cancel_wait": "Очікування зупинки експорту…",
"dxf.cancelled": "Експорт DXF скасовано",
"dxf.cancelling": "Скасування експорту DXF",
"dxf.exporting": "Експортування DXF",
"dxf.invalid_tolerance": "Допуск кривої має бути від 0,0001 до 1 мм.",
"dxf.progress.index": "Підготовка поверхонь (2/5)",
"dxf.progress.meshes": "Підготовка сіток (1/5)",
"dxf.progress.occlusion": "Обчислення невидимих ліній (4/5)",
"dxf.progress.projection": "Проєктування геометрії (3/5)",
"dxf.progress.write": "Запис DXF (5/5)",
"dxf.tolerance": "Допуск кривої",
"dxf.tolerance_help": "Лише видимі тіла. Криві подаються відрізками. Менший допуск дає точніші криві, але потребує більше часу й пам'яті.",
"expression, e.g. 25 mm": "вираз, напр. 25 mm",
"fits the face, e.g. M8": "відповідає грані, напр. M8",
"from the rule": "з правила",
@@ -700,9 +745,11 @@
"new configuration": "нова конфігурація",
"new name": "нова назва",
"new parameter": "новий параметр",
"no hole": "без отвору",
"no interference": "немає перетинів",
"no joint that moves yet": "ще немає рухомого з'єднання",
"no picture": "немає зображення",
"none": "немає",
"pattern.alignment_2": "Вирівнювання 2",
"pattern.alignment_hint": "Кількість включає оригінал. Центрування розподіляє копії з обох боків; за парної кількості зміна напрямку визначає бік із додатковою копією.",
"pattern.direction_1": "Напрямок 1",
@@ -775,6 +822,8 @@
"sketch.degrees_of_freedom": "{count} ступенів вільності",
"sketch.draw_curves": "Малювати як {linetype}",
"sketch.make_selection": "Перетворити виділене на {linetype}",
"sketch.open_ends": "Відкритих: {count}",
"sketch.untied_ends": "Незв'язаних: {count}",
"sketch_text.align_center": "По центру",
"sketch_text.align_left": "Ліворуч",
"sketch_text.align_right": "Праворуч",
@@ -787,13 +836,19 @@
"sketch_text.path_clear": "Зняти текст з його кривої",
"sketch_text.path_hint": "Крива ескізу, якої дотримується текст: клацніть тут, а потім на кривій",
"sketch_text.use_font_file": "Використати файл шрифту {path}",
"solid": "суцільне",
"the parts library is not available": "бібліотека деталей недоступна",
"thread.automatic_size": "Автоматичний розмір",
"thread.designation": "Позначення",
"thread.iso_metric": "Метрична ISO",
"thread.select_size": "Вибрати розмір",
"timeline.group": "Нова тека операцій",
"timeline.move_many": "Перемістити елементи часового ланцюжка",
"timeline.move_to_group": "Перемістити операції до теки",
"timeline.rename_group": "Перейменувати теку операцій",
"timeline.reorder_many": "Перевпорядкувати операції",
"timeline.ungroup": "Прибрати теки операцій",
"to the face": "до грані",
"to the next face": "до наступної грані",
"toolbar.image": "ЗОБРАЖЕННЯ",
"toolbar.render": "РЕНДЕР",
@@ -809,12 +864,15 @@
"ui.activate": "Активувати",
"ui.active_component": "Активний компонент",
"ui.agent_chat": "Чат з агентом",
"ui.agent_chat_add": "Прикріпити файли",
"ui.agent_chat_add_file": "Вибрати файл…",
"ui.agent_chat_allow": "Дозволити",
"ui.agent_chat_allowed": "Дозволено",
"ui.agent_chat_answered": "Відповідь: {option}",
"ui.agent_chat_approval": "Дозволити агенту це зробити?",
"ui.agent_chat_attach_view": "Прикріпити вид",
"ui.agent_chat_attach_view_tip": "Надіслати знімок поточного виду разом із повідомленням",
"ui.agent_chat_attachable": "Зображення, текст і CAD-файли",
"ui.agent_chat_auth_agent": "Увійти через {name}",
"ui.agent_chat_auth_env": "Задайте {vars} у своєму середовищі та перезапустіть SolidCraft.",
"ui.agent_chat_auth_needed": "Агент просить вас увійти. Ви входите через сам агент; SolidCraft ніколи не бачить вашого пароля чи ключів.",
@@ -834,11 +892,32 @@
"ui.agent_chat_deny": "Відхилити",
"ui.agent_chat_desktop_only": "Чат з агентом потребує настільної програми.",
"ui.agent_chat_detecting": "Пошук агентів…",
"ui.agent_chat_dock": "Закріпити на бічній панелі",
"ui.agent_chat_dock_left": "Закріпити ліворуч",
"ui.agent_chat_dock_right": "Закріпити праворуч",
"ui.agent_chat_drop_hint": "Перетягніть, щоб прикріпити до наступного повідомлення",
"ui.agent_chat_edit": "Редагувати",
"ui.agent_chat_exited": "Агент зупинився.",
"ui.agent_chat_failed_start": "Не вдалося запустити агента: {error}",
"ui.agent_chat_file_cad_not_read": "{name}: CAD-файл імпортується в проєкт, а не читається",
"ui.agent_chat_file_empty_picture": "{name}: порожнє зображення",
"ui.agent_chat_file_error": "{name}: {error}",
"ui.agent_chat_file_heic": "{name}: зображення HEIC тут не читаються; спершу експортуйте його як JPEG або PNG",
"ui.agent_chat_file_importing": "Імпортування в проєкт…",
"ui.agent_chat_file_loading": "Читання файлу…",
"ui.agent_chat_file_not_a_file": "{name}: не файл",
"ui.agent_chat_file_not_a_picture": "{name}: не зображення",
"ui.agent_chat_file_not_utf8": "{name}: не текст UTF-8",
"ui.agent_chat_file_picture_too_big": "{name}: зображення може бути щонайбільше {max} МБ",
"ui.agent_chat_file_text_too_big": "{name}: текстовий файл може бути щонайбільше {max} КБ",
"ui.agent_chat_file_too_large": "{name}: завеликий",
"ui.agent_chat_file_too_large_to_send": "{name}: завеликий для надсилання, навіть зменшеним",
"ui.agent_chat_file_unreadable_picture": "{name}: не зображення, яке це може прочитати",
"ui.agent_chat_files": "Файли: {files}",
"ui.agent_chat_files_left_out": "З одним повідомленням надсилається щонайбільше {max} файлів; пропущено: {files}",
"ui.agent_chat_files_loading": "Очікування читання файлів",
"ui.agent_chat_files_max": "Щонайбільше {max} файлів на одне повідомлення",
"ui.agent_chat_import_failed": "{name}: не вдалося імпортувати: {error}",
"ui.agent_chat_intro": "Спілкуйтеся з агентом для програмування, яким уже користуєтеся, або з обраною вами модельною кінцевою точкою. Він працює з відкритим проєктом через інструменти SolidCraft: його зміни з'являються в міру виконання й скасовуються одним кроком на повідомлення. Що він може робити без запиту, задано в Налаштування > ШІ.",
"ui.agent_chat_key_in_env": "ключ задано",
"ui.agent_chat_left": "Ліворуч",
@@ -851,14 +930,18 @@
"ui.agent_chat_no_cli": "solidcraft-cli не знайдено поруч із програмою, тож агент не може дістатися до проєкту.",
"ui.agent_chat_no_control": "Кінцева точка чату з агентом не запустилася, тож агент не може дістатися до проєкту.",
"ui.agent_chat_no_images": "Цей агент не приймає зображень",
"ui.agent_chat_no_picture": "У буфері обміну немає зображення",
"ui.agent_chat_none_found": "Агента для програмування не знайдено. Установіть один із цих, а потім натисніть «Оновити»:",
"ui.agent_chat_none_found_short": "Агента не знайдено",
"ui.agent_chat_not_installed": "Не встановлено",
"ui.agent_chat_not_signed_in": "не виконано вхід",
"ui.agent_chat_own_window": "Власне вікно",
"ui.agent_chat_paste_picture": "Вставити зображення",
"ui.agent_chat_permission": "Агент просить виконати: {title}",
"ui.agent_chat_pick": "Агент",
"ui.agent_chat_placeholder": "Попросіть агента змінити проєкт…",
"ui.agent_chat_placeholder_busy": "Поставте повідомлення в чергу, доки агент завершить (Ctrl+Enter: надіслати зараз)",
"ui.agent_chat_pop_out": "Відкрити у власному вікні, яке можна перемістити на інший екран",
"ui.agent_chat_privacy": "Нічого нікуди не надсилається, крім агента, який ви запускаєте, під вашим власним обліковим записом.",
"ui.agent_chat_privacy_note": "SolidCraft нічого нікуди не надсилає: агент, який ви запускаєте, спілкується з власним сервісом під вашим обліковим записом. SolidCraft ніколи не бачить і не зберігає ключ чи пароль.",
"ui.agent_chat_queue": "У чергу",
@@ -868,6 +951,8 @@
"ui.agent_chat_refresh": "Оновити",
"ui.agent_chat_refused": "Не дозволено для агента: {reason}",
"ui.agent_chat_remove": "Прибрати з черги",
"ui.agent_chat_remove_file": "Прибрати з цього повідомлення",
"ui.agent_chat_remove_imported": "Прибрати з цього повідомлення; деталь залишається в проєкті (скасування її прибирає)",
"ui.agent_chat_right": "Праворуч",
"ui.agent_chat_save": "Зберегти",
"ui.agent_chat_send": "Надіслати",
@@ -965,6 +1050,8 @@
"ui.ai_toolbar_button_note": "Кнопка ШІ в правому кінці панелі інструментів. Ctrl+Shift+A і меню «Вид» усе одно відкривають панель.",
"ui.ai_undo_tip": "Скасувати все, що агент змінив для цього повідомлення, одним кроком",
"ui.all": "Усі",
"ui.ambient_occlusion": "Окружне затінення",
"ui.ambient_occlusion_help": "М'яке затінення в місцях, де стикаються непрозорі поверхні. Потребує вікна перегляду на GPU.",
"ui.analysis": "Аналіз",
"ui.angle": "Кут",
"ui.angle_decimals": "Знаки після коми для кутів",
@@ -979,9 +1066,11 @@
"ui.autosave_every": "Автозбереження кожні",
"ui.axes": "Осі",
"ui.axis": "Вісь",
"ui.back": "Назад",
"ui.base_point": "Базова точка",
"ui.blocked": "Заблоковані",
"ui.bodies": "Тіла",
"ui.bodies_all_if_none": "Тіла (усі, якщо не вибрано)",
"ui.box": "Куб",
"ui.break": "Розірвати",
"ui.calibrate": "Калібрувати",
@@ -1033,6 +1122,9 @@
"ui.construct": "Побудувати",
"ui.construction": "Конструкційна",
"ui.context_menu": "Контекстне меню",
"ui.continuity_g0": "G0 злам",
"ui.continuity_g1": "G1 дотичність",
"ui.continuity_g2": "G2 кривина",
"ui.continuity_gap": "Проміжок",
"ui.copy": "Копіювати",
"ui.corner": "Кут",
@@ -1121,6 +1213,13 @@
"ui.fully_constrained": "Повністю визначений",
"ui.general": "Загальні",
"ui.graphics": "Графіка",
"ui.graphics_custom": "Власний",
"ui.graphics_performance": "Продуктивність",
"ui.graphics_preset": "Набір графіки",
"ui.graphics_preset_help": "Якість: окружне затінення, висока якість відображення та адаптивна наддискретизація 2×. Продуктивність: без окружного затінення, звичайна якість відображення та вимкнена наддискретизація. Ручні зміни показують «Власний». MSAA не змінюється.",
"ui.graphics_quality": "Якість",
"ui.grid_follows_view": "Сітка слідує за видом",
"ui.grid_follows_view_hint": "Бічні види показують сітку на вертикальній площині, повернутій до них, а не на землі",
"ui.ground_shadow": "Тінь на землі",
"ui.groupSelected": "Згрупувати виділене",
"ui.heading_add_ins": "НАДБУДОВИ",
@@ -1147,6 +1246,7 @@
"ui.heading_home": "ГОЛОВНА",
"ui.heading_insert": "ВСТАВИТИ",
"ui.heading_inspect": "ПЕРЕВІРКА",
"ui.heading_interference": "ПЕРЕТИНИ",
"ui.heading_loft": "ЛОФТ",
"ui.heading_make": "ВИГОТОВЛЕННЯ",
"ui.heading_measure": "ВИМІРЮВАННЯ",
@@ -1271,6 +1371,7 @@
"ui.one_side": "Один бік",
"ui.open": "Відкрити",
"ui.open_ellipsis": "Відкрити...",
"ui.open_ends_tip": "Нічого не торкається",
"ui.open_preferences": "Відкрити налаштування",
"ui.open_recent": "Відкрити недавні",
"ui.operation": "Операція",
@@ -1315,6 +1416,8 @@
"ui.radial_context_menu": "Кругове контекстне меню",
"ui.radius": "Радіус",
"ui.radius_type": "Тип радіуса",
"ui.rail": "Напрямна",
"ui.rail_fit": "Підгонка за напрямною",
"ui.reachable": "Досяжні",
"ui.rectangle": "Прямокутник",
"ui.rectangular_pattern": "Прямокутний масив",
@@ -1330,6 +1433,8 @@
"ui.reset_the_settings_on_this_page": "Скинути налаштування на цій сторінці",
"ui.restore": "Відновити",
"ui.restore_defaults": "Відновити типові",
"ui.reverse_horizontal_pan_direction": "Обернути напрямок горизонтального панорамування",
"ui.reverse_horizontal_pan_tip": "Горизонтальна прокрутка двома пальцями та бічне колесо панорамують у зворотний бік",
"ui.reverse_zoom_direction": "Змінити напрямок масштабування",
"ui.revolve": "Обертання",
"ui.right": "Справа",
@@ -1340,7 +1445,10 @@
"ui.save_mesh_pick_bodies": "Виберіть тіла для збереження",
"ui.save_mesh_temp_failed": "Не вдалося створити {folder}: {error}",
"ui.saved_to": "Збережено {path}",
"ui.sbox_pin": "Закріпити вгорі поля S",
"ui.sbox_unpin": "Відкріпити",
"ui.scale": "Масштаб",
"ui.second_rail": "Друга напрямна",
"ui.section_analysis": "Аналіз перерізу",
"ui.select": "Вибрати",
"ui.selectOther": "Вибрати інше…",
@@ -1382,8 +1490,34 @@
"ui.smooth_wheel_zoom": "Плавне масштабування колесом",
"ui.smooth_wheel_zoom_tip": "Вимкніть, щоб застосовувати кожну подію колеса негайно, без плавного завершення",
"ui.snap": "Прив'язка",
"ui.space_mouse_calibrate": "Калібрувати…",
"ui.space_mouse_calibrate_tip": "Рухайте ковпачок так, як просить вікно, щоб SolidCraft дізнався, куди й наскільки далеко рухається кожна вісь цієї 3D-миші. Зберігається для цієї 3D-миші.",
"ui.space_mouse_calibrate_title": "Калібрування 3D-миші",
"ui.space_mouse_calibrated": "Відкалібровано",
"ui.space_mouse_calibration": "Калібрування 3D-миші",
"ui.space_mouse_calibration_done": "Готово. Збережіть його, щоб воно застосовувалося щоразу, коли підключено цю 3D-мишу.",
"ui.space_mouse_calibration_no_response": "Одна вісь майже не зрушила, тому її не можна відкалібрувати. Почніть спочатку й щоразу рухайте ковпачок далі.",
"ui.space_mouse_dead_zone": "Мертва зона",
"ui.space_mouse_dead_zone_tip": "Наскільки треба зсунути ковпачок, перш ніж зрушить вигляд, у частці від повного ходу. Збільште, якщо вигляд зміщується, коли ковпачок у спокої.",
"ui.space_mouse_forget": "Забути калібрування",
"ui.space_mouse_hint": "Переміщуйте й обертайте вид разом; в ескізах доступні лише панорамування та масштаб.",
"ui.space_mouse_navigate": "Навігація за допомогою SpaceNavigator / SpaceMouse",
"ui.space_mouse_none": "3D-мишу не знайдено",
"ui.space_mouse_not_calibrated": "Не відкалібровано",
"ui.space_mouse_reading": "Показники",
"ui.space_mouse_step_lift": "Підніміть ковпачок угору, утримайте й відпустіть.",
"ui.space_mouse_step_press": "Натисніть ковпачок униз, утримайте й відпустіть.",
"ui.space_mouse_step_pull": "Потягніть ковпачок до себе, утримайте й відпустіть.",
"ui.space_mouse_step_push": "Штовхніть ковпачок від себе, утримайте й відпустіть.",
"ui.space_mouse_step_rest": "Приберіть руку з ковпачка й не торкайтеся його.",
"ui.space_mouse_step_slide_left": "Зсуньте ковпачок ліворуч, утримайте й відпустіть.",
"ui.space_mouse_step_slide_right": "Зсуньте ковпачок праворуч, утримайте й відпустіть.",
"ui.space_mouse_step_tilt_back": "Нахиліть ковпачок назад (передній край угору), утримайте й відпустіть.",
"ui.space_mouse_step_tilt_forward": "Нахиліть ковпачок уперед (передній край униз), утримайте й відпустіть.",
"ui.space_mouse_step_tilt_left": "Нахиліть ковпачок ліворуч (лівий край униз), утримайте й відпустіть.",
"ui.space_mouse_step_tilt_right": "Нахиліть ковпачок праворуч (правий край униз), утримайте й відпустіть.",
"ui.space_mouse_step_twist_clockwise": "Поверніть ковпачок за годинниковою стрілкою, утримайте й відпустіть.",
"ui.space_mouse_step_twist_counterclockwise": "Поверніть ковпачок проти годинникової стрілки, утримайте й відпустіть.",
"ui.spacing": "Крок",
"ui.spacing_2": "Крок 2",
"ui.sphere": "Куля",
@@ -1391,7 +1525,9 @@
"ui.split_body": "Розділити тіло",
"ui.stand_up_tip": "Після відкриття файлу STEP або IGES з напрямком Z вгору пропонувати поворот на чверть оберту навколо X",
"ui.start": "Початок",
"ui.start_over": "Почати спочатку",
"ui.start_radius": "Початковий радіус",
"ui.stretch": "Розтягнути",
"ui.supersampling": "Наддискретизація (SSAA)",
"ui.supersampling_adaptive": "Лише коли вид нерухомий",
"ui.supersampling_off": "Вимкнено (1×)",
@@ -1410,6 +1546,8 @@
"ui.theme": "Тема",
"ui.thickness": "Товщина",
"ui.thread": "Різьба",
"ui.tie_ends": "Зв'язати",
"ui.tie_ends_tip": "Обмежити всі незв'язані кінці",
"ui.timeline_bottom": "Часовий ланцюжок знизу",
"ui.timeline_left_note": "Ліве компонування показує смугу відкату над браузером деталей і сцени.",
"ui.timeline_left_sidebar": "Ліва бічна панель",
@@ -1432,6 +1570,7 @@
"ui.units_of_new_designs": "Одиниці нових проєктів",
"ui.unlock": "Розблокувати",
"ui.unsuppress": "Скасувати придушення",
"ui.untied_ends_tip": "Торкається лише за положенням",
"ui.untitled": "Без назви",
"ui.up_axis_note": "Яка вісь спрямована вгору у видах (вид осі, головний, наведення). Проєкти не змінюються.",
"ui.updateToCurrentView": "Оновити до поточного виду",
File diff suppressed because it is too large Load Diff
+95 -2
View File
@@ -31,6 +31,7 @@
"Arc length": "弧长",
"Area": "面积",
"Attachment": "附着",
"BLEND SURFACE": "混合曲面",
"BOUNDARY SURFACE": "边界曲面",
"BROWSER": "浏览器",
"Bend Line Position": "弯折线位置",
@@ -79,6 +80,7 @@
"Current orthographic view (1:1 mm)": "当前正交视图(1:1毫米)",
"Current perspective view": "当前透视视图",
"Curvature (G2)": "曲率 (G2)",
"Curvature change (G3)": "曲率变化 (G3)",
"Customize Toolbar": "自定义工具栏",
"Delete all": "全部删除",
"Delete rule": "删除规则",
@@ -100,6 +102,8 @@
"Edit Profile Sketch": "编辑轮廓草图",
"Enable rule": "启用规则",
"Enabled": "已启用",
"End bulge": "终止鼓度",
"End continuity": "终止连续性",
"Explode": "爆炸",
"Explode Text": "分解文字",
"Export Animation…": "导出动画…",
@@ -107,6 +111,7 @@
"Export Current View as DXF…": "将当前视图导出为DXF…",
"Export DXF…": "导出DXF…",
"Export hidden edges as dashed lines": "以虚线导出隐藏线",
"Extend to meet": "延伸至相交",
"Extents": "范围",
"Face": "面",
"Face attachment lost": "面附着丢失",
@@ -121,6 +126,7 @@
"Follow camera (Look At sketches)": "跟随相机(注视草图)",
"Follow the camera (Look At sketches)": "跟随相机(注视草图)",
"Font": "字体",
"From edges": "起始边",
"Full Length": "全长",
"Grid": "网格",
"Height": "高度",
@@ -135,7 +141,9 @@
"Interferes": "发生干涉",
"Issue title": "Issue 标题",
"Joint Origin1": "接头原点1",
"Keep (points on it)": "保留(其上的点)",
"Keep Tools": "保留工具",
"Keep of the other surface": "另一曲面保留的部分",
"Later": "稍后",
"Leave it": "保持原样",
"Legacy face attachment. Rebinding to a certified face creates an exact attachment.": "旧版面附着。重新绑定到经验证的面可创建精确附着。",
@@ -173,6 +181,7 @@
"Open GitHub draft": "打开 GitHub 草稿",
"Open-source parametric 3D CAD in Rust. Licensed MIT OR Apache-2.0.": "使用 Rust 编写的开源参数化 3D CAD。基于 MIT OR Apache-2.0 许可。",
"Other side": "另一侧",
"Other surface (mutual trim)": "另一曲面(相互修剪)",
"Outside": "外侧",
"Overall length": "总长",
"Parameter values": "参数值",
@@ -258,12 +267,18 @@
"Stand it up": "将其立起",
"Stand it up: a quarter turn about X, as one Move feature you can undo.": "将其立起:绕 X 轴旋转四分之一周,作为一个可撤销的移动特征。",
"Stand this part up?": "立起此零件?",
"Start bulge": "起始鼓度",
"Start continuity": "起始连续性",
"Step": "步骤",
"Steps": "步数",
"Stitch the kept pieces": "缝合保留的部分",
"Stitch to both bodies": "缝合到两个实体",
"Study": "研究",
"Style": "样式",
"Suppressed": "已抑制",
"Surface": "曲面",
"Swap Ends": "交换端点",
"TRIM": "修剪",
"Tangent (G1)": "相切 (G1)",
"Tangent Chain": "相切链",
"Tear": "撕裂",
@@ -279,8 +294,10 @@
"This child is shared. Position changes apply to every copy of its parent.": "此子项是共享的。位置更改会应用到其父项的每个副本。",
"This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "此面无法提供持久附着。请选择其他面或显式使用固定平面。",
"To Offset": "偏移",
"To edges": "终止边",
"Tolerance": "公差",
"Too close": "过近",
"Trimming tool (plane, face or curves)": "修剪工具(平面、面或曲线)",
"Turn off section analysis before exporting the view.": "请关闭截面分析后再导出视图。",
"Type": "类型",
"UNTRIM": "取消修剪",
@@ -325,6 +342,7 @@
"cmd.appearance_assign": "外观",
"cmd.appearance_library": "外观库",
"cmd.appearance_list": "列出外观",
"cmd.assembly_bom": "物料清单",
"cmd.body_rename": "重命名实体",
"cmd.browser_group": "新建组",
"cmd.browser_move": "移动浏览器项目",
@@ -346,10 +364,13 @@
"cmd.component_insert_design": "插入设计",
"cmd.component_links": "列出链接",
"cmd.component_list": "列出组件",
"cmd.component_metadata": "零件属性",
"cmd.component_metadata_set": "更改零件属性",
"cmd.component_move_bodies": "将实体移至组件",
"cmd.component_move_sketches": "将草图移至组件",
"cmd.component_paste_new": "粘贴为新对象",
"cmd.component_properties": "组件物理属性",
"cmd.component_relink": "重新链接源文件",
"cmd.component_rename": "重命名组件",
"cmd.component_revert_position": "恢复位置",
"cmd.component_unlock": "解锁内容",
@@ -401,6 +422,7 @@
"cmd.edit_checkpoint": "检查点",
"cmd.edit_restore_checkpoint": "恢复检查点",
"cmd.engine_commands": "列出命令",
"cmd.engine_describe": "描述命令",
"cmd.explode_create": "爆炸图",
"cmd.explode_delete": "删除爆炸图",
"cmd.explode_list": "列出爆炸图",
@@ -433,6 +455,7 @@
"cmd.joint_drive": "驱动接头",
"cmd.joint_edit": "编辑接头",
"cmd.joint_gear": "齿轮",
"cmd.joint_inspect": "检查接头",
"cmd.joint_limits": "接头限制",
"cmd.joint_list": "列出接头",
"cmd.joint_motion_link": "运动关联",
@@ -442,6 +465,7 @@
"cmd.joint_rigid_group": "刚性组",
"cmd.joint_screw": "螺旋",
"cmd.joint_solve": "解算接头",
"cmd.joint_sweep": "扫掠接头",
"cmd.library_apply": "应用本地库条目",
"cmd.library_export": "导出设计库",
"cmd.library_inspect": "检查本地库",
@@ -590,6 +614,7 @@
"cmd.sketch_spline_fit_point": "拟合点样条曲线",
"cmd.sketch_spun_profile": "旋转轮廓",
"cmd.sketch_toggle_driven": "切换被驱动尺寸",
"cmd.sketch_trim_path": "沿路径修剪",
"cmd.solid_align": "对齐",
"cmd.solid_boundary_fill": "边界填充",
"cmd.solid_bounding_solid": "包围实体",
@@ -632,8 +657,10 @@
"cmd.timeline_roll_to": "滚动历史标记",
"cmd.timeline_rollback": "移动时间线标记",
"cmd.timeline_suppress": "抑制特征",
"cmd.view_ambient_occlusion": "环境光遮蔽",
"cmd.view_apply": "还原命名视图",
"cmd.view_delete": "删除命名视图",
"cmd.view_graphics_preset": "图形预设",
"cmd.view_list": "列出命名视图",
"cmd.view_navigate": "导航视图",
"cmd.view_rename": "重命名命名视图",
@@ -716,6 +743,15 @@
"joint.from_bodies_hint": "每个所选实体移入各自的新组件,位置保持不变。",
"joint.one_body": "两次选择都在 {body} 上。请在另一个零件上选择第二个。",
"joint.one_component": "这些零件是同一组件的实体:{bodies}。接头使一个组件相对另一个组件运动,因此每个零件都需要自己的组件。",
"messages.ago_h": "{n} 小时前",
"messages.ago_min": "{n} 分钟前",
"messages.ago_s": "{n} 秒前",
"messages.chip_hint": "点击查看本次会话的所有消息",
"messages.clear": "清除",
"messages.copy_all": "全部复制",
"messages.menu": "消息…",
"messages.none": "暂无消息。",
"messages.title": "消息",
"motion.checked_to": "仅检查到第 {step} 步:检查已用完其工作量上限",
"motion.collision": "{a} · {b}:第 {steps} 步",
"motion.duration_hint": "秒(留空:按步)",
@@ -857,7 +893,7 @@
"ui.agent_chat_approval": "允许智能体执行此操作吗?",
"ui.agent_chat_attach_view": "附加视图",
"ui.agent_chat_attach_view_tip": "随消息发送当前视图的图片",
"ui.agent_chat_attachable": "图片、文本和 CAD 文件",
"ui.agent_chat_attachable": "图片、PDF、文本和 CAD 文件",
"ui.agent_chat_auth_agent": "使用 {name} 登录",
"ui.agent_chat_auth_env": "请在环境中设置 {vars},然后重启 SolidCraft。",
"ui.agent_chat_auth_needed": "智能体要求你登录。登录在智能体本身完成;SolidCraft 从不接触你的密码或密钥。",
@@ -889,7 +925,6 @@
"ui.agent_chat_file_error": "{name}:{error}",
"ui.agent_chat_file_heic": "{name}:这里无法读取 HEIC 图片;请先导出为 JPEG 或 PNG",
"ui.agent_chat_file_importing": "正在导入设计…",
"ui.agent_chat_file_kinds": "{name}:可以附加图片(PNG、JPEG、GIF、WebP、BMP、TIFF)、文本文件(包括 SVG)和 CAD 文件(STEP、IGES、FreeCAD、Rhino、STL、3MF、OBJ、glTF)",
"ui.agent_chat_file_loading": "正在读取文件…",
"ui.agent_chat_file_not_a_file": "{name}:不是文件",
"ui.agent_chat_file_not_a_picture": "{name}:不是图片",
@@ -1036,6 +1071,8 @@
"ui.ai_toolbar_button_note": "工具栏右端的 AI 按钮。Ctrl+Shift+A 和视图菜单仍可打开面板。",
"ui.ai_undo_tip": "将智能体为此消息所做的全部更改一步撤销",
"ui.all": "全部",
"ui.ambient_occlusion": "环境光遮蔽",
"ui.ambient_occlusion_help": "在不透明表面相接处添加柔和阴影。需要 GPU 视口。",
"ui.analysis": "分析",
"ui.angle": "角度",
"ui.angle_decimals": "角度小数位",
@@ -1050,6 +1087,7 @@
"ui.autosave_every": "自动保存间隔",
"ui.axes": "轴",
"ui.axis": "轴",
"ui.back": "返回",
"ui.base_point": "基点",
"ui.blocked": "被遮挡",
"ui.bodies": "实体",
@@ -1196,6 +1234,11 @@
"ui.fully_constrained": "完全约束",
"ui.general": "常规",
"ui.graphics": "图形",
"ui.graphics_custom": "自定义",
"ui.graphics_performance": "性能",
"ui.graphics_preset": "图形预设",
"ui.graphics_preset_help": "质量:环境光遮蔽、精细显示质量和自适应2×超采样。性能:关闭环境光遮蔽、普通显示质量和关闭超采样。手动修改显示为自定义。MSAA保持不变。",
"ui.graphics_quality": "质量",
"ui.grid_follows_view": "网格跟随视图",
"ui.grid_follows_view_hint": "在侧视图中,将网格显示在朝向该视图的竖直平面上,而不是地面上",
"ui.ground_shadow": "地面阴影",
@@ -1298,6 +1341,8 @@
"ui.loft": "放样",
"ui.look_at": "朝向",
"ui.look_at_anim_desc": "轴视图、注视、主视图与适合窗口",
"ui.manipulator_free_alt": "操纵器自由拖动也适用于 Alt/Option",
"ui.manipulator_free_alt_tip": "开:在拉伸/小控件箭头上按 Alt/Option 或 Ctrl/Cmd 可关闭数值捕捉。关:仅 Ctrl/Cmd。",
"ui.manufacture": "制造",
"ui.material_review.axes": "材料局部坐标轴;不指定实体方向。",
"ui.material_review.claims": "来源和许可证声明未经核实。缺失值保持未知。",
@@ -1337,6 +1382,7 @@
"ui.newSketchGroup": "新建草图组",
"ui.new_body": "新建实体",
"ui.new_design": "新建设计",
"ui.no_geometry_yet": "尚无几何图形",
"ui.no_slicer_found": "未找到切片软件",
"ui.normal": "法向",
"ui.normalConstruction": "普通 / 构造",
@@ -1364,6 +1410,7 @@
"ui.pan_zoom_orbit": "平移、缩放、环绕",
"ui.parallel": "平行",
"ui.parameters": "参数",
"ui.part_size_shared": "重新生成零件并替换额外添加的特征。此零部件的所有实例都会更新。",
"ui.paste": "粘贴",
"ui.path": "路径",
"ui.pattern_on_path": "路径阵列",
@@ -1375,6 +1422,12 @@
"ui.placement": "放置",
"ui.plane": "平面",
"ui.plane_at_angle": "角度平面",
"ui.plugins": "插件",
"ui.plugins_desktop": "插件仅在桌面版中可用。",
"ui.plugins_empty": "尚未安装插件。",
"ui.plugins_note": "每个插件是 {path} 里的一个文件夹,其中有 plugin.json。SolidCraft 只读取该文件,不运行文件夹里的代码。关闭插件仍会保留安装。",
"ui.plugins_skipped": "有 {count} 个文件夹无法读取。",
"ui.plugins_version": "{version}",
"ui.point": "点",
"ui.point_under_the_cursor": "光标下的点",
"ui.points": "点",
@@ -1400,6 +1453,11 @@
"ui.rectangle": "矩形",
"ui.rectangular_pattern": "矩形阵列",
"ui.redo": "重做",
"ui.relink_design_changed": "选择源文件时装配已更改。请重新开始链接。",
"ui.relink_done": "已为所有实例重新链接源文件。",
"ui.relink_not_linked": "此组件未链接到源设计。",
"ui.relink_select_one": "请先选择一个链接组件或其实例。",
"ui.relink_source_all": "重新链接源文件(所有实例)…",
"ui.removeFromToolbar": "从工具栏移除",
"ui.removeSection": "移除剖面",
"ui.render": "渲染",
@@ -1423,6 +1481,8 @@
"ui.save_mesh_pick_bodies": "请选择要保存的实体",
"ui.save_mesh_temp_failed": "无法创建 {folder}:{error}",
"ui.saved_to": "已保存:{path}",
"ui.sbox_pin": "固定到 S 框顶部",
"ui.sbox_unpin": "取消固定",
"ui.scale": "缩放",
"ui.second_rail": "第二导轨",
"ui.section_analysis": "截面分析",
@@ -1431,6 +1491,9 @@
"ui.selectTangentChain": "选择相切链",
"ui.select_other_hint": "“选择其他”:在模型上按住左键(或右键单击 › 选择其他…)列出光标下的所有对象;在同一位置再次单击可选择该处的下一个对象。",
"ui.selection": "选择",
"ui.selection_modifiers": "选择修饰键",
"ui.selection_shift_add_only": "Shift+点击仅添加、不取消选择",
"ui.selection_shift_add_only_tip": "开:Shift 添加,Ctrl/Cmd 切换。关(默认):Shift 与 Ctrl/Cmd 均可切换。",
"ui.send": "发送",
"ui.send_to_slicer": "发送到切片软件",
"ui.sent_to_slicer": "已发送到 {slicer}",
@@ -1455,6 +1518,7 @@
"ui.sketch": "草图",
"ui.sketch_dimension": "草图尺寸",
"ui.sketch_grid": "草图网格",
"ui.sketch_modifiers": "草图修饰键",
"ui.sketch_scale": "草图比例",
"ui.sketch_status": "草图状态",
"ui.sketches": "草图",
@@ -1466,8 +1530,34 @@
"ui.smooth_wheel_zoom": "平滑滚轮缩放",
"ui.smooth_wheel_zoom_tip": "关闭后每个滚轮事件立即生效,不做平滑延迟",
"ui.snap": "捕捉",
"ui.space_mouse_calibrate": "校准…",
"ui.space_mouse_calibrate_tip": "按窗口提示移动摇杆帽,SolidCraft 会记住这个 3D 鼠标每个轴的方向和行程。校准结果按此 3D 鼠标保存。",
"ui.space_mouse_calibrate_title": "校准 3D 鼠标",
"ui.space_mouse_calibrated": "已校准",
"ui.space_mouse_calibration": "3D 鼠标校准",
"ui.space_mouse_calibration_done": "完成。保存后,每次连接此 3D 鼠标都会使用它。",
"ui.space_mouse_calibration_no_response": "有一个轴几乎没有移动,因此无法校准。请重新开始,并且每次把摇杆帽移得更远。",
"ui.space_mouse_dead_zone": "死区",
"ui.space_mouse_dead_zone_tip": "视图开始移动前摇杆帽需要移动的距离,以其全行程的比例表示。如果摇杆帽静止时视图仍在漂移,请调大。",
"ui.space_mouse_forget": "清除校准",
"ui.space_mouse_hint": "同时移动和旋转视图;草图中仅支持平移和缩放。",
"ui.space_mouse_navigate": "使用 SpaceNavigator / SpaceMouse 导航",
"ui.space_mouse_none": "未找到 3D 鼠标",
"ui.space_mouse_not_calibrated": "未校准",
"ui.space_mouse_reading": "读数",
"ui.space_mouse_step_lift": "向上提起摇杆帽,保持住,然后松开。",
"ui.space_mouse_step_press": "向下按压摇杆帽,保持住,然后松开。",
"ui.space_mouse_step_pull": "把摇杆帽拉向自己,保持住,然后松开。",
"ui.space_mouse_step_push": "把摇杆帽推离自己,保持住,然后松开。",
"ui.space_mouse_step_rest": "请把手从摇杆帽上移开,让它保持静止。",
"ui.space_mouse_step_slide_left": "向左推动摇杆帽,保持住,然后松开。",
"ui.space_mouse_step_slide_right": "向右推动摇杆帽,保持住,然后松开。",
"ui.space_mouse_step_tilt_back": "向后倾斜摇杆帽(前缘向上),保持住,然后松开。",
"ui.space_mouse_step_tilt_forward": "向前倾斜摇杆帽(前缘向下),保持住,然后松开。",
"ui.space_mouse_step_tilt_left": "向左倾斜摇杆帽(左缘向下),保持住,然后松开。",
"ui.space_mouse_step_tilt_right": "向右倾斜摇杆帽(右缘向下),保持住,然后松开。",
"ui.space_mouse_step_twist_clockwise": "顺时针扭转摇杆帽,保持住,然后松开。",
"ui.space_mouse_step_twist_counterclockwise": "逆时针扭转摇杆帽,保持住,然后松开。",
"ui.spacing": "间距",
"ui.spacing_2": "间距 2",
"ui.sphere": "球体",
@@ -1475,12 +1565,15 @@
"ui.split_body": "分割实体",
"ui.stand_up_tip": "当 Z 朝上的 STEP 或 IGES 文件打开后,提示绕 X 轴旋转四分之一周",
"ui.start": "开始",
"ui.start_over": "重新开始",
"ui.start_radius": "起始半径",
"ui.stretch": "伸展",
"ui.supersampling": "超级采样 (SSAA)",
"ui.supersampling_adaptive": "仅在视图静止时",
"ui.supersampling_off": "关 (1×)",
"ui.suppress": "抑制",
"ui.suppress_auto_snap": "绘制/拖动时抑制自动捕捉",
"ui.suppress_auto_snap_note": "默认:Ctrl/Cmd 或 Alt/Option 均可抑制草图捕捉。也可改成只认其中一个键。",
"ui.sweep": "扫掠",
"ui.sweep_follow_direction": "跟随方向",
"ui.sweep_keep_as_drawn": "保持绘制方向",
@@ -6,7 +6,7 @@
<metadata_license>CC0-1.0</metadata_license>
<project_license>MIT OR Apache-2.0</project_license>
<name>SolidCraft</name>
<summary>Parametric 3D CAD in the style of Fusion</summary>
<summary>Independent parametric 3D CAD</summary>
<developer id="io.github.bherbruck">
<name>SolidCraft contributors</name>
</developer>
+19
View File
@@ -0,0 +1,19 @@
# Sample previews
The editable authority is the built-in command scripts in
`crates/engine/src/sample.rs`. The six 240 × 150 PNGs in `exports/` are generated
by SolidCraft's thumbnail renderer from those original designs, using the same
isometric camera as Recent. They are embedded by `home.rs`, so displaying Home
does not evaluate sample geometry or read pixels back from the GPU. Selecting a
sample builds its editable design through the normal commands.
Regenerate after changing a sample script:
```sh
cargo test --profile ci -p solidcraft-ui-egui --lib home::tests::regenerate_sample_thumbnails -- --ignored --exact --nocapture
```
`exports/scripts.json` records SHA-256 hashes of the scripts. The regular tests
check the hashes and decode all six pictures with the same bounds as Recent.
The generated PNGs and hash file are necessary shipped exports; benchmark
screenshots and other review output stay outside the asset tree.
Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

+8
View File
@@ -0,0 +1,8 @@
[
"1911ce16bbcc8a961b013f53f184fa556705779dbebee2d89f7508463f60da15",
"394116b0b022dcf8f1bb469f486f9d3443d5e6abb0fb947b6946264f998f4b76",
"13f68dd015a1efd9dd9c720d94796048671579de49958821d003be5ae17b9d59",
"4a98200a71f7301a2e289ef4df34e3527e5acc4535671b5f955606712583fdb1",
"0d5fd0fd99d606215845a625ac56c8899a407527ef289a427424f064872ab25b",
"54d29037a7d599a07602e8884a628b52ccbb055a8c870c9b7ce7cc5c7390247a"
]
+76 -5
View File
@@ -10,6 +10,7 @@ struct U {
acc_u: vec4<f32>, // accessibility map axes (xyz, offset)
acc_v: vec4<f32>,
eye: vec4<f32>, // eye position, w = 1 for perspective (0: orthographic, looking along -back)
ao: vec4<f32>, // radius in world units (zero: disabled)
// analysis colours (`theme::AnalysisColors`): draft positive, between, negative; reachable,
// blocked; curvature flat, middle, tight
ana_c: array<vec4<f32>, 8>,
@@ -28,6 +29,7 @@ fn near_z(c: vec4<f32>) -> f32 {
}
@group(1) @binding(2) var acc_t: texture_2d<f32>;
@group(2) @binding(0) var ao_depth: texture_2d<u32>;
fn clipped(p: vec3<f32>) -> bool {
return length(u.clip.xyz) > 0.5 && dot(u.clip.xyz, p) > u.clip.w;
@@ -119,7 +121,7 @@ fn solid(i: TOut, front: bool) -> vec4<f32> {
let fw = fwidth(ndv);
if (clipped(i.wp)) { discard; }
if (!front && length(u.clip.xyz) > 0.5) {
// The cut face in its body's colour (as Fusion's "from component"), tinted toward the
// The cut face in its body's colour ("from component" colouring), tinted toward the
// cap colour so it reads as a cut, hatched at 45 degrees on screen.
let base = mix(i.c.rgb, u.cap.rgb, 0.35);
let h = fract((i.pos.x + i.pos.y) / 9.0);
@@ -128,7 +130,7 @@ fn solid(i: TOut, front: bool) -> vec4<f32> {
if (u.ana.x > 0.5 && length(i.n) > 0.5) {
return analysis(i, k);
}
let c = shade(i);
let c = lit(i, occlusion(i));
// Silhouettes (styles with edges): where a curved surface turns away from the eye, a line
// about 1.5 px wide, on the visible surface only (depth-correct).
if (u.screen.w > 0.5 && length(i.n) > 0.5 && fw > 1e-6) {
@@ -138,6 +140,67 @@ fn solid(i: TOut, front: bool) -> vec4<f32> {
return c;
}
// A single-sample opaque depth map, independent of the window's MSAA and its UI offsets.
// Preserve the depth's bits in R32Uint: GLSL cannot textureLoad a depth texture,
// and float colour attachments are not available on every GL adapter.
struct AoDepth {
@builtin(frag_depth) depth: f32,
@location(0) value: u32,
};
@fragment
fn fs_ao_depth(i: TOut, @builtin(front_facing) front: bool) -> AoDepth {
if (clipped(i.wp) || length(i.n) < 0.5) { discard; }
var depth = i.pos.z;
if (!front && length(u.clip.xyz) > 0.5) { depth = cap_depth(i.wp, i.pos.z); }
return AoDepth(depth, bitcast<u32>(depth));
}
// Deterministic cosine-weighted hemisphere samples. Projecting the sample and a point one
// radius toward the eye supplies a depth range in both orthographic and perspective views;
// distant foreground geometry cannot cast a dark halo onto a background surface.
fn occlusion(i: TOut) -> f32 {
let radius = u.ao.x;
if (radius <= 0.0 || length(i.n) < 0.5) { return 1.0; }
var n = normalize(i.n);
if (dot(n, u.back.xyz) < 0.0) { n = -n; }
let axis = select(vec3<f32>(0.0, 0.0, 1.0), vec3<f32>(0.0, 1.0, 0.0), abs(n.z) > 0.9);
let tangent = normalize(cross(axis, n));
let bitangent = cross(n, tangent);
let size = vec2<i32>(textureDimensions(ao_depth));
// The map is a 1 × 1 stand-in until a frame draws into it (the product preview draws no
// prepass): sampling it tells nothing, so leave the surface unoccluded.
if (size.x < 2 || size.y < 2) { return 1.0; }
// One comparison for both depth directions: depths read as 0 near / 1 far (mirrored when
// the buffer runs reversed). The map is cleared to its far plane either way, so a texel
// with nothing in it reads as 1 and the `d < 1.0` test below skips it.
let flip = reversed();
var blocked = 0.0;
for (var j = 0u; j < 32u; j = j + 1u) {
let f = (f32(j) + 0.5) / 32.0;
let angle = f32(j) * 2.39996323;
let direction = tangent * (cos(angle) * sqrt(f)) + bitangent * (sin(angle) * sqrt(f)) + n * sqrt(1.0 - f);
let reach = radius * (0.2 + 0.8 * fract(f32(j) * 0.61803399 + 0.5));
let sample = i.wp + n * (radius * 0.025) + direction * reach;
let q = u.vp * vec4<f32>(sample, 1.0);
let near = u.vp * vec4<f32>(sample + u.back.xyz * radius, 1.0);
if (q.w <= 0.0 || near.w <= 0.0) { continue; }
let ndc = q.xyz / q.w;
let uv = vec2<f32>(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);
if (any(uv < vec2<f32>(0.0)) || any(uv >= vec2<f32>(1.0)) || ndc.z < 0.0 || ndc.z > 1.0) { continue; }
let d0 = bitcast<f32>(textureLoad(ao_depth, vec2<i32>(uv * vec2<f32>(size)), 0).x);
let zs = select(ndc.z, 1.0 - ndc.z, flip);
let zn = select(near.z / near.w, 1.0 - near.z / near.w, flip);
let d = select(d0, 1.0 - d0, flip);
let span = max(zs - zn, 1e-7);
let delta = (zs - d) / span;
if (d < 1.0 && delta > 0.005 && delta < 1.0) {
blocked = blocked + smoothstep(0.005, 0.15, delta) * (1.0 - smoothstep(0.5, 1.0, delta));
}
}
return 1.0 - 0.85 * blocked / 32.0;
}
/// How fast the normal turns across the pixel (per mm).
fn curvature(i: TOut) -> f32 {
let nn = normalize(i.n);
@@ -230,6 +293,10 @@ fn fs_tri_all(i: TOut) -> @location(0) vec4<f32> {
}
fn shade(i: TOut) -> vec4<f32> {
return lit(i, 1.0);
}
fn lit(i: TOut, visibility: f32) -> vec4<f32> {
// A zero normal marks flat colour (selection fills, translucent planes).
if (length(i.n) < 0.5) {
return out_color(i.c);
@@ -242,7 +309,7 @@ fn shade(i: TOut) -> vec4<f32> {
let fill = max(dot(n, v), 0.0);
let h = normalize(key + v);
let spec = pow(max(dot(n, h), 0.0), 40.0) * 0.25;
let k = 0.42 + 0.38 * diff + 0.25 * fill;
let k = 0.42 + 0.38 * diff + 0.25 * fill - (0.42 + 0.25 * fill) * (1.0 - visibility);
return out_color(vec4<f32>(min(i.c.rgb * k + vec3<f32>(spec), vec3<f32>(1.0)), i.c.a));
}
@@ -288,10 +355,14 @@ fn vs_line(@builtin(vertex_index) vi: u32, @location(0) a: vec3<f32>, @location(
let t = ts[vi];
let s = ss[vi];
let base = select(ca, cb, t > 0.5);
let off = nrm * s * w * 0.5 + dir * (t * 2.0 - 1.0) * w * 0.5;
// A line thinner than a pixel is drawn a pixel wide, faded by the width it lacks: a narrower
// quad covers no pixel centre when it falls between two rows, so without multisampling thin
// lines (the grid's) came and went as the view zoomed (#2823).
let wd = max(w, 1.0);
let off = nrm * s * wd * 0.5 + dir * (t * 2.0 - 1.0) * wd * 0.5;
var o: LOut;
o.pos = vec4<f32>(base.xy + off / half * base.w, base.z, base.w);
o.c = c;
o.c = vec4<f32>(c.rgb, c.a * (w / wd));
o.wp = select(pa, pb, t > 0.5);
return o;
}
+1 -1
View File
@@ -32,7 +32,7 @@ pub(crate) fn remap_sheet_metadata(st: &mut ModelState, names: &[String], transf
.filter(|(_, t)| names.contains(t.body.as_str()))
.map(|(i, t)| {
let point = apply_point(transform, t.axis_point);
let axis = apply_vector(transform, t.axis).normalized().ok_or_else(|| DocError::Invalid("invalid transferred thread axis".into()))?;
let axis = apply_vector(transform, *t.axis).unit().ok_or_else(|| DocError::Invalid("invalid transferred thread axis".into()))?;
if !point.is_finite() {
return Err(DocError::Invalid("invalid transferred thread placement".into()));
}
+1 -1
View File
@@ -1,4 +1,4 @@
//! Canvases: reference images placed on a plane (Fusion's Insert Canvas), kept in the design.
//! Canvases: reference images placed on a plane (Insert Canvas), kept in the design.
//! The image bytes travel inside the design file (base64), so it reopens without the original.
use serde::{Deserialize, Serialize};
+5
View File
@@ -95,6 +95,11 @@ fn remap_geo(
}
}
GeoRef::Face { name: Some(n), .. } | GeoRef::Edge { name: Some(n), .. } => *n = remap_topology(n, ids, bodies),
GeoRef::Vertex { edges: Some(names), .. } => {
for n in names {
*n = remap_topology(n, ids, bodies);
}
}
GeoRef::ConstructPoint { name } | GeoRef::Axis { name } => mapped_name(name, names),
GeoRef::Plane { plane } => remap_plane(plane, ids, names, bodies, depth + 1)?,
_ => {}
+2 -1
View File
@@ -823,7 +823,8 @@ impl Document {
let stem = |index: usize| names.faces.get(index).map(|name| crate::naming::strip_piece(name));
let seed: Vec<usize> =
indices.into_iter().filter(|index| stem(*index).is_some_and(|name| name.starts_with(&own) && !name.contains('@'))).collect();
let ambiguous = || DocError::Unknown("attached planar face certificate is missing or ambiguous".into());
let feature = self.feature(origin).map_or_else(|| format!("F{origin}"), |feature| feature.name.clone());
let ambiguous = || DocError::Invalid(format!("the attached flat face of {feature} on `{}` is missing or ambiguous", body.name));
let first = seed.first().and_then(|index| stem(*index)).ok_or_else(ambiguous)?;
if seed.iter().any(|index| stem(*index) != Some(first)) {
return Err(ambiguous());
+19 -1
View File
@@ -185,6 +185,24 @@ impl Document {
}
Ok(())
}
/// A face target for messages: its role on its feature where the certificate tells it, else
/// its name, and its body (`the top cap of E1 on `Body1``).
pub(crate) fn describe_face_target(&self, target: &ConfigFaceTarget) -> String {
let feature = self.feature(target.origin_feature).map_or_else(|| format!("F{}", target.origin_feature), |f| f.name.clone());
let end = |high: bool| if high { "top" } else { "bottom" };
let role = match &target.certificate {
Some(super::FaceCertificate::ExtrudeCap { high, .. }) | Some(super::FaceCertificate::CylinderCap { high }) => {
format!("the {} cap", end(*high))
}
Some(super::FaceCertificate::Box { axis, high }) => {
format!("the {}{} side", if *high { "+" } else { "-" }, ["x", "y", "z"].get(usize::from(*axis)).copied().unwrap_or("?"))
}
Some(super::FaceCertificate::ExtrudeSide { curve, .. }) => format!("the side along `{curve}`"),
_ => format!("face `{}`", target.name),
};
format!("{role} of {feature} on `{}`", target.body)
}
/// Verify the current named pieces describe one surface, not ambiguous generated roles.
pub fn configuration_face_indices(&self, body: &crate::ModelBody, target: &ConfigFaceTarget) -> Result<Vec<usize>> {
self.certify_config_face(target)?;
@@ -248,7 +266,7 @@ impl Document {
})
.collect();
if indices.is_empty() {
return Err(DocError::Unknown("attached planar face certificate is missing or ambiguous".into()));
return Err(DocError::Invalid(format!("{} no longer exists", self.describe_face_target(target))));
}
return self.certified_pieces(body, indices, target.origin_feature);
}
+70 -15
View File
@@ -1,9 +1,10 @@
//! Construction geometry from the model: references to vertices, edges, faces, planes, axes,
//! points and sketch entities, resolved on each evaluation, and the axes, points and planes
//! built from them (Fusion's CONSTRUCT panel).
//! built from them (the CONSTRUCT panel).
//!
//! Face and edge references carry the persistent name of what was picked (see `naming`), so
//! they follow the model when earlier features change; the picked point is the fallback.
//! Face and edge references carry the persistent name of what was picked (see `naming`), and a
//! vertex the names of the edges that meet there, so they follow the model when earlier features
//! change; the picked point is the fallback.
use std::collections::BTreeMap;
@@ -23,8 +24,13 @@ pub enum GeoRef {
Point { p: Vec3 },
/// The design origin.
Origin,
/// A body vertex (the one nearest `at`).
Vertex { at: Vec3 },
/// A body vertex: where the edges named `edges` meet (the end they share), else the one
/// nearest `at`.
Vertex {
at: Vec3,
#[serde(default, skip_serializing_if = "Option::is_none")]
edges: Option<Vec<String>>,
},
/// A body edge: the one named `name`, else the one nearest `at`.
Edge {
at: Vec3,
@@ -86,14 +92,14 @@ pub enum PointDef {
}
/// Resolved construction geometry, kept in the model state by feature.
#[derive(Clone, Debug, PartialEq)]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum ConstructGeom {
Plane(Plane),
Axis { origin: Vec3, dir: Vec3 },
Point(Vec3),
}
#[derive(Clone, Debug, PartialEq)]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Construct {
pub feature: u64,
pub name: String,
@@ -170,6 +176,33 @@ fn edge_points(st: &ModelState, at: Vec3, name: &Option<String>) -> Result<Vec<V
}
}
/// The most edge names a vertex reference keeps.
const MAX_VERTEX_EDGES: usize = 16;
/// Where the edges named `names` meet: the end every one of them has, nearest `at` (two edges
/// between the same two vertices share both). `None` when an edge is gone or they share no end.
fn named_vertex(st: &ModelState, at: Vec3, names: &[String]) -> Option<Vec3> {
if names.len() < 2 || names.len() > MAX_VERTEX_EDGES {
return None;
}
let ends: Vec<[Vec3; 2]> = names
.iter()
.map(|n| {
// The edge with that name itself, never the one nearest the old pick.
let on = bodies(st).find_map(|b| crate::naming::find_edge(b, n, at).ok())?.0;
let pts = edge_points(st, on, &None).ok()?;
Some([*pts.first()?, *pts.last()?])
})
.collect::<Option<_>>()?;
let (first, rest) = ends.split_first()?;
let tol = |p: Vec3| 1e-6 * (1.0 + p.len());
first
.iter()
.copied()
.filter(|p| p.is_finite() && rest.iter().all(|e| e.iter().any(|q| q.dist(*p) <= tol(*p))))
.min_by(|p, q| p.dist(at).total_cmp(&q.dist(at)))
}
/// A polyline that is straight: its line.
fn as_line(pts: &[Vec3]) -> Option<(Vec3, Vec3)> {
let (a, b) = (*pts.first()?, *pts.last()?);
@@ -228,7 +261,10 @@ pub(crate) fn point(ctx: &Ctx, g: &GeoRef) -> Result<Vec3> {
match g {
GeoRef::Point { p } => Ok(*p),
GeoRef::Origin => Ok(Vec3::ZERO),
GeoRef::Vertex { at } => {
GeoRef::Vertex { at, edges } => {
if let Some(v) = edges.as_deref().and_then(|names| named_vertex(ctx.st, *at, names)) {
return Ok(v);
}
let mut best: Option<(f64, Vec3)> = None;
for b in bodies(ctx.st) {
let tol = (b.body.size() * 1e-3).max(1e-3);
@@ -285,9 +321,9 @@ pub(crate) fn line(ctx: &Ctx, g: &GeoRef) -> Result<(Vec3, Vec3)> {
GeoRef::Face { at, name } => {
let (b, fi, _) = face_of(ctx.st, *at, name)?;
match kernel::face_surface(&b.body, fi) {
Some(FaceSurface::Cylinder { origin, axis, .. }) => Ok((origin, axis)),
Some(FaceSurface::Cone { apex, axis, .. }) => Ok((apex, axis)),
Some(FaceSurface::Torus { center, axis, .. }) => Ok((center, axis)),
Some(FaceSurface::Cylinder { origin, axis, .. }) => Ok((origin, *axis)),
Some(FaceSurface::Cone { apex, axis, .. }) => Ok((apex, *axis)),
Some(FaceSurface::Torus { center, axis, .. }) => Ok((center, *axis)),
_ => Err(bad("the face is not a cylinder, cone or torus")),
}
}
@@ -367,7 +403,7 @@ fn path_points(ctx: &Ctx, g: &GeoRef) -> Result<Vec<Vec3>> {
/// The surface's unit normal at (the point of the surface nearest) `p`, and that point.
fn surface_normal(s: FaceSurface, p: Vec3) -> Option<(Vec3, Vec3)> {
match s {
FaceSurface::Plane { normal, point } => Some((p - normal * (p - point).dot(normal), normal)),
FaceSurface::Plane { normal, point } => Some((p - normal * (p - point).dot(normal), *normal)),
FaceSurface::Cylinder { origin, axis, radius } => {
let w = p - origin;
let r = (w - axis * w.dot(axis)).normalized()?;
@@ -403,7 +439,7 @@ fn face_surface_at(ctx: &Ctx, g: &GeoRef) -> Result<(FaceSurface, Vec3)> {
}
GeoRef::Plane { .. } => {
let pl = plane(ctx, g)?;
Ok((FaceSurface::Plane { normal: pl.normal(), point: pl.origin }, pl.origin))
Ok((FaceSurface::Plane { normal: pl.normal().unit().ok_or_else(|| bad("degenerate plane"))?, point: pl.origin }, pl.origin))
}
_ => Err(bad("pick a face")),
}
@@ -553,13 +589,14 @@ impl GeoRef {
pub fn map_points(&mut self, pt: &mut dyn FnMut(&mut Vec3), plane: &mut dyn FnMut(&mut PlaneRef)) {
match self {
GeoRef::Point { p } => pt(p),
GeoRef::Vertex { at } | GeoRef::Edge { at, .. } | GeoRef::Face { at, .. } => pt(at),
GeoRef::Vertex { at, .. } | GeoRef::Edge { at, .. } | GeoRef::Face { at, .. } => pt(at),
GeoRef::Plane { plane: p } => plane(p),
_ => {}
}
}
/// Picked faces and edges get their persistent names (and only those still missing one).
/// Picked faces and edges get their persistent names, and picked vertices the names of the
/// edges that meet there (only those still missing them).
pub fn name_picks(&mut self, st: &ModelState) {
match self {
GeoRef::Face { at, name: name @ None } => {
@@ -574,6 +611,24 @@ impl GeoRef {
}
}
}
GeoRef::Vertex { at, edges: edges @ None } => {
for b in bodies(st) {
let tol = (b.body.size() * 1e-3).max(1e-3);
let m = b.mesh();
let ends_here = |e: &Vec<Vec3>| [e.first(), e.last()].into_iter().flatten().any(|q| q.dist(*at) <= tol);
let mut meet: Vec<String> =
m.edges.iter().zip(crate::naming::edge_names(b)).filter(|(e, n)| !n.is_empty() && ends_here(e)).map(|(_, n)| n).collect();
if meet.is_empty() {
continue;
}
meet.sort();
meet.dedup();
if (2..=MAX_VERTEX_EDGES).contains(&meet.len()) {
*edges = Some(meet);
}
break;
}
}
GeoRef::Plane { plane } => plane.name_picks(st),
_ => {}
}
+154 -20
View File
@@ -233,7 +233,9 @@ pub struct Extent {
pub convention: ExtrudeConvention,
#[serde(default)]
pub direction: Direction,
/// Optional second side distance (two-sided extrude), along the negative normal.
/// Optional second side distance (two-sided extrude): the other way from the first side (against
/// the normal, or along it when the first side goes against it: `direction: negative`, a
/// negative distance, or a To Object below the sketch).
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(length = "Distance 2")]
pub distance2: Option<Expr>,
@@ -299,8 +301,8 @@ impl SweepOrientation {
}
}
/// A sweep's guide rail: a chain of curves of a sketch, followed from the end nearest the path's
/// start.
/// A sweep's or a loft's guide rail: a chain of curves of a sketch, followed from the end nearest
/// the path's start (the loft's first section).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, solidcraft_derive::Walk)]
pub struct SweepRail {
pub sketch: SketchId,
@@ -740,6 +742,12 @@ pub enum FeatureKind {
operation: Operation,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
targets: Vec<BodyName>,
/// Guide rails (one or two) the loft follows between its sections.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
rails: Vec<SweepRail>,
/// How the rails reshape the sections between.
#[serde(default, skip_serializing_if = "RailFit::is_scale")]
rail_fit: RailFit,
},
/// Sweep profiles along a chain of sketch curves.
Sweep {
@@ -812,11 +820,11 @@ pub enum FeatureKind {
#[serde(default, skip_serializing_if = "Option::is_none")]
line: Option<String>,
},
/// A construction axis (Fusion's CONSTRUCT axes).
/// A construction axis (CONSTRUCT axes).
ConstructionAxis {
def: AxisDef,
},
/// A construction point (Fusion's CONSTRUCT points).
/// A construction point (CONSTRUCT points).
ConstructionPoint {
def: PointDef,
},
@@ -1372,6 +1380,12 @@ pub enum FeatureKind {
continuity: [Continuity; 2],
#[serde(default, skip_serializing_if = "loft_crown_is_plain")]
crown: [f64; 2],
/// Guide rails (one or two) the loft follows between its sections.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
rails: Vec<SweepRail>,
/// How the rails reshape the sections between.
#[serde(default, skip_serializing_if = "RailFit::is_scale")]
rail_fit: RailFit,
},
/// The faces at `faces` copied out at a signed `distance` along their normals as a new
/// surface body (0 copies them); `chain` takes each pick's whole smooth chain of faces;
@@ -1441,6 +1455,11 @@ pub enum FeatureKind {
/// Points the blend leaves towards, when the faces at the picks do not say: [from, to].
#[serde(default, skip_serializing_if = "Option::is_none")]
align: Option<[Position; 2]>,
/// Where each chain's stations start, when two closed chains should not pair the way
/// that keeps the blend from twisting: a point on each chain, [from, to]. An open chain
/// starts at the end nearer its point.
#[serde(default, skip_serializing_if = "Option::is_none")]
seam: Option<[Position; 2]>,
/// Sew the blend onto its two bodies where the boundaries meet.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
stitch: bool,
@@ -1511,14 +1530,31 @@ pub enum FeatureKind {
#[walk(length = "Tolerance")]
tolerance: Expr,
},
/// A surface body trimmed by a plane, or by a face of a body (`tool`), keeping the side of
/// `keep`.
/// A surface body trimmed by a plane, by a face of a body (`tool`, its surface extended) or
/// by sketch curves swept onto it (`curves`), keeping the side of `keep`; with `keep_more`,
/// the regions (pieces that hang together once cut) `keep` and those points pick instead.
/// With `with`, a mutual trim: `body` and `with` (one face each) cut each other, `with`
/// keeping the side of its one `keep_with` point or the regions of several; `extend`
/// extends one that stops short of the other to meet it, and `stitch` sews the two kept
/// pieces into `body` (`with` is then gone).
SurfaceTrim {
body: BodyName,
plane: PlaneRef,
#[serde(default, skip_serializing_if = "Option::is_none")]
tool: Option<FaceAt>,
keep: Position,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
keep_more: Vec<Position>,
#[serde(default, skip_serializing_if = "Option::is_none")]
curves: Option<TrimCurves>,
#[serde(default, skip_serializing_if = "Option::is_none")]
with: Option<BodyName>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
keep_with: Vec<Position>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
stitch: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
extend: bool,
},
Move {
bodies: Vec<BodyName>,
@@ -1689,6 +1725,18 @@ impl FilletStyle {
}
}
/// Sketch curves a trim cuts with: chains of the curves `curves` (every curve of the sketch
/// that is not construction geometry when empty) swept along `direction` (the sketch's normal
/// when none) onto the surface.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, solidcraft_derive::Walk)]
pub struct TrimCurves {
pub sketch: SketchId,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub curves: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub direction: Option<Dir>,
}
/// A face picked by a point on it, on a named body.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, solidcraft_derive::Walk)]
pub struct FaceAt {
@@ -1823,13 +1871,21 @@ pub struct SketchPoints {
/// A component: a node of the design tree that owns sketches, features and bodies. The root
/// (id 0) is the design itself and is not stored.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Component {
pub id: u64,
pub name: String,
/// Parent component (0 = the root).
#[serde(default)]
pub parent: u64,
/// The definition's part properties (part number, description, revision…) and other tools'
/// data, under the same rules as the design's own `metadata`. Every occurrence shares them;
/// the bill of materials reads them.
#[serde(default, skip_serializing_if = "crate::Metadata::is_empty")]
pub metadata: crate::Metadata,
/// Free text properties of the definition (material grade, finish, supplier…), by name.
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub properties: std::collections::BTreeMap<String, String>,
}
/// One loft section: profiles of a sketch.
@@ -2065,7 +2121,7 @@ impl FeatureKind {
)
}
/// Fusion-like type name (timeline tooltip, `inspect`).
/// Short type name (timeline tooltip, `inspect`).
pub fn type_name(&self) -> &'static str {
match self {
FeatureKind::BodyTransfer { .. } => "BodyTransferFeature",
@@ -2593,6 +2649,23 @@ impl Document {
*self == normalized
}
/// Only components' part properties (metadata and free properties) changed: nothing to
/// evaluate. The same whole-Document proof as [`Document::differs_only_in_render`].
pub fn differs_only_in_part_properties(&self, previous: &Document) -> bool {
if self.components.len() != previous.components.len()
|| self.components.iter().zip(&previous.components).any(|(a, b)| a.id != b.id)
|| self.components == previous.components
{
return false;
}
let mut normalized = previous.clone();
for (n, c) in normalized.components.iter_mut().zip(&self.components) {
n.metadata = c.metadata.clone();
n.properties = c.properties.clone();
}
*self == normalized
}
/// The colour a body is shown and exported in (sRGB as 0–1 fractions): its appearance,
/// else its component's, else its material's look, else the colour it was imported with.
/// (Faces may have their own: `face_looks`.)
@@ -2691,6 +2764,23 @@ impl Document {
if body.transferred || body.source_name.is_some() { body.component } else { self.legacy_body_component(&body.name, body.feature) }
}
/// The components that own a body in `state` ([`Document::model_body_component`] of each),
/// in one pass: the timeline is looked up once, not once per body.
pub fn body_owners(&self, state: &crate::ModelState) -> std::collections::HashSet<u64> {
let features: std::collections::HashMap<u64, u64> = self.features.iter().rev().map(|f| (f.id, f.component)).collect();
state
.bodies
.iter()
.map(|b| {
if b.transferred || b.source_name.is_some() {
b.component
} else {
self.body_components.get(&b.name).copied().unwrap_or_else(|| features.get(&b.feature).copied().unwrap_or(0))
}
})
.collect()
}
/// Recorded transfers already converted geometry into the new local frame.
pub fn model_body_offset(&self, body: &crate::ModelBody) -> Option<&crate::Mat> {
if body.transferred { None } else { self.body_offsets.get(&body.name) }
@@ -2716,7 +2806,7 @@ impl Document {
format!("Component{k}")
}
};
self.components.push(Component { id, name, parent });
self.components.push(Component { id, name, parent, ..Default::default() });
self.add_occurrence(id, parent, crate::IDENTITY)?;
Ok(id)
}
@@ -2843,9 +2933,10 @@ impl Document {
/// Insert a feature as given, keeping its id, name, parameter names and stored edge and
/// face names, at the marker (or the end), with the model parameters its sketch dimensions
/// use: a feature replayed from a script (`timeline.add`) is the feature the design had.
/// An id of 0 or an empty name gets a new one. Refused, changing nothing, when the id, the
/// name or a parameter name is already used, or the feature belongs to a component this
/// design does not have.
/// An id of 0 or an empty name gets a new one. The name is kept even when another feature
/// has it, as a sketch and the feature built from it often share one (#1990). Refused,
/// changing nothing, when the id or a parameter name is already used, or the feature
/// belongs to a component this design does not have.
pub fn insert_feature(&mut self, mut f: Feature, params: Vec<Param>) -> Result<u64> {
if self.features.len() >= MAX_FEATURES {
return Err(DocError::Invalid("too many features".into()));
@@ -2860,8 +2951,6 @@ impl Document {
f.name = f.name.trim().to_string();
if f.name.is_empty() {
f.name = self.unique_name(f.kind.base_name());
} else if self.features.iter().any(|x| x.name == f.name) {
return Err(DocError::Invalid(format!("a feature named `{}` already exists", f.name)));
}
if f.component != 0 && !self.components.iter().any(|c| c.id == f.component) {
return Err(DocError::Invalid(format!("feature `{}` belongs to an unknown component", f.name)));
@@ -3195,8 +3284,10 @@ impl Document {
}
}
/// Apply parameter values to every dimension in a sketch (in place).
/// Apply parameter values to every dimension in a sketch, and to its points placed by
/// parameters (in place).
pub fn apply_dimension_values(&self, vals: &std::collections::BTreeMap<String, expr::Value>, sk: &mut Sketch) -> Result<()> {
self.apply_point_values(vals, sk)?;
for c in &mut sk.constraints {
if let Some(p) = &c.param {
let kind = if c.kind.is_angle() { Kind::Angle } else { Kind::Length };
@@ -3210,6 +3301,27 @@ impl Document {
Ok(())
}
/// Move the points placed by parameters to where their expressions come to (in place). Such
/// a point is fixed, so the solve keeps it there.
pub fn apply_point_values(&self, vals: &std::collections::BTreeMap<String, expr::Value>, sk: &mut Sketch) -> Result<()> {
use crate::eval::val;
for p in &mut sk.points {
let Some(exprs) = p.exprs.as_deref() else { continue };
let mut at = p.pos;
for (e, slot, what) in [(&exprs.x, &mut at.x, "X"), (&exprs.y, &mut at.y, "Y")] {
if let Some(e) = e {
*slot = val(vals, e, Kind::Length).map_err(|error| DocError::Expr(format!("point {} {what}: {error}", p.id)))?;
}
}
if !(at.is_finite() && at.x.abs() < solidcraft_sketch::MAX_COORD && at.y.abs() < solidcraft_sketch::MAX_COORD) {
return Err(DocError::Invalid(format!("point {} is placed out of range", p.id)));
}
p.pos = at;
p.fixed = true;
}
Ok(())
}
/// Give the texts of a sketch the numbers their expressions come to (in place): a text's
/// height, place and angle that were typed as expressions. A text's outlines are rebuilt from
/// its numbers when the sketch's links are refreshed, so this comes before that. A number
@@ -3260,20 +3372,42 @@ impl Document {
sk: &mut Sketch,
stored: &solidcraft_sketch::DrivingValues,
moved_from: Option<&Sketch>,
) -> Result<solidcraft_sketch::SolveReport> {
self.solve_sketch_with_analysis(vals, sk, stored, moved_from, solidcraft_sketch::Analysis::Full)
}
/// [`Self::solve_sketch`] with the diagnostics needed by an internal edit. Parameter
/// application, continuation and offset refreshes run regardless of the requested level.
/// Model evaluation uses `solve_sketch` so its published report always has full diagnostics.
pub fn solve_sketch_with_analysis(
&self,
vals: &std::collections::BTreeMap<String, expr::Value>,
sk: &mut Sketch,
stored: &solidcraft_sketch::DrivingValues,
moved_from: Option<&Sketch>,
analysis: solidcraft_sketch::Analysis,
) -> Result<solidcraft_sketch::SolveReport> {
self.apply_dimension_values(vals, sk)?;
let was = |sk: &Sketch| moved_from.and_then(|from| sk.positions_in(from));
let mut report = solidcraft_sketch::solve_after_edit(sk, stored, was(sk).as_deref());
if !sk.offsets.is_empty() || !sk.freeform_offsets.is_empty() {
let offsets = !sk.offsets.is_empty() || !sk.freeform_offsets.is_empty();
// Offsets always require another solve after refresh. Only that last report is
// published; the first report only decides which state the second solve starts from.
let first_analysis = if offsets { solidcraft_sketch::Analysis::StatusOnly } else { analysis };
let mut report = solidcraft_sketch::solve_after_edit_with_analysis(sk, stored, was(sk).as_deref(), first_analysis);
if offsets {
// Source dimensions may have moved curves during the solve.
sk.refresh_offsets()?;
report = if report.ok() { solidcraft_sketch::solve(sk) } else { solidcraft_sketch::solve_after_edit(sk, stored, was(sk).as_deref()) };
report = if report.ok() {
solidcraft_sketch::solve_with(sk, &solidcraft_sketch::Hold::default(), None, analysis, None)
} else {
solidcraft_sketch::solve_after_edit_with_analysis(sk, stored, was(sk).as_deref(), analysis)
};
}
Ok(report)
}
}
/// Density of the default material (g/cm³), as Fusion's default.
/// Density of the default material (g/cm³), as the default.
#[path = "../../../assets/materials/library.rs"]
mod materials;
pub use materials::{DEFAULT_DENSITY, MATERIALS, material_color};
+311 -76
View File
@@ -1,12 +1,15 @@
//! Timeline evaluation.
#[path = "placed_mesh.rs"]
mod placed_mesh;
use std::collections::BTreeMap;
use std::hash::{Hash, Hasher};
use std::sync::{Arc, OnceLock};
use serde::Serialize;
use serde::{Deserialize, Serialize};
use solidcraft_geom::tess::TessPlan;
use solidcraft_geom::{Mesh, Plane, Region2, Vec2, Vec3, regions_apart};
use solidcraft_geom::{Mesh, Plane, Region2, UnitVec3, Vec2, Vec3, regions_apart};
use solidcraft_kernel::{self as kernel, Body, BoolOp};
use solidcraft_sketch::{CurveKind, Profile, SolveReport, find_profiles};
@@ -28,6 +31,8 @@ pub struct ModelBody {
// Timeline snapshots are cloned before meshes are requested. Share the cell itself so
// meshing any snapshot also warms every unchanged copy of this body.
mesh: Arc<OnceLock<Arc<Mesh>>>,
/// Lazy canonical reference triangles for a proper rigid occurrence placement.
mesh_placement: Option<Arc<placed_mesh::PlacedMesh>>,
/// The display plan (none when the body has no plan: a mesh body, or one the planner
/// refuses), shared like the mesh. A GPU viewport draws it.
plan: Arc<[OnceLock<Option<Arc<TessPlan>>>; 4]>,
@@ -50,6 +55,11 @@ pub struct ModelBody {
/// [`Body::retained_bytes`] of `body`, measured when a feature made or changed it (0: not
/// measured), so the evaluation cache's budget does not walk every body after every feature.
bytes: usize,
/// In a world state: the [`ModelBody::geometry_key`] of the model body this one places (0: none).
source_key: u64,
/// [`Body::outline_bounds`] of `body` (with the body it was measured on), worked out once
/// and shared by clones.
outline: Arc<OnceLock<(Body, solidcraft_geom::Aabb3)>>,
}
impl ModelBody {
@@ -61,6 +71,7 @@ impl ModelBody {
body,
feature,
mesh: Arc::new(OnceLock::new()),
mesh_placement: None,
plan: Arc::new(Default::default()),
plan_mesh: Arc::new(Default::default()),
key: 0,
@@ -70,8 +81,34 @@ impl ModelBody {
transferred: false,
imported: None,
bytes: 0,
source_key: 0,
outline: Arc::new(OnceLock::new()),
}
}
/// A box around the body without meshing it ([`Body::outline_bounds`]), worked out once.
pub fn outline_bounds(&self) -> solidcraft_geom::Aabb3 {
match self.outline.get_or_init(|| (self.body.clone(), self.body.outline_bounds())) {
(measured, b) if measured.same(&self.body) => *b,
// The body was replaced in this cell's holder since: measure it again.
_ => self.body.outline_bounds(),
}
}
/// Identity of this body's geometry where it stands: the same for a body evaluated or
/// placed again from the same inputs at the same place, so work measured on it can be
/// reused (none for a body the evaluation cache does not key).
pub fn geometry_key(&self) -> Option<u64> {
use std::hash::{Hash, Hasher};
let key = if self.key != 0 { self.key } else { self.source_key };
if key == 0 {
return None;
}
let mut h = std::collections::hash_map::DefaultHasher::new();
key.hash(&mut h);
self.source_transform.iter().flatten().for_each(|v| v.to_bits().hash(&mut h));
Some(h.finish())
}
pub fn definition_name(&self) -> &str {
self.source_name.as_deref().unwrap_or(&self.name)
}
@@ -83,9 +120,10 @@ impl ModelBody {
// finding their surface, picks naming their face, sketch planes), and a different
// triangulation moved those and changed what designs build. A GPU viewport draws the
// plan ([`ModelBody::display_plan`]).
self.mesh
.get_or_init(|| Arc::new(self.body.reference_display_mesh_angular(self.display_tol(), kernel::DISPLAY_ANGLE).unwrap_or_default()))
.clone()
match self.mesh_placement.as_deref() {
Some(placed) => self.mesh.get_or_init(|| placed.mesh()).clone(),
None => placed_mesh::reference_mesh(&self.body, &self.mesh, self.display_tol()),
}
}
/// The display plan of this body at the viewport's display quality
@@ -98,16 +136,12 @@ impl ModelBody {
/// The display plan at a given display quality, made once per level.
pub fn display_plan_at(&self, q: crate::display_quality::DisplayQuality) -> Option<Arc<TessPlan>> {
self.plan
.get(q.index())?
.get_or_init(|| match self.body.display_plan_angular(q.chord(self.body.size()), q.angle()) {
Ok(plan) => Some(Arc::new(plan)),
Err(e) => {
log::debug!("{}: no display plan ({e})", self.name);
None
}
})
.clone()
let cell = self.plan.get(q.index())?;
match self.mesh_placement.as_deref() {
// A placed part shows its definition's plan, moved (planned once for every placement).
Some(placed) => cell.get_or_init(|| placed.plan(q, &self.name)).clone(),
None => placed_mesh::definition_plan(&self.body, cell, q, &self.name),
}
}
/// The display plan's mesh, evaluated on the CPU once per level: the same triangles a GPU
@@ -122,7 +156,10 @@ impl ModelBody {
pub fn plan_mesh_at(&self, q: crate::display_quality::DisplayQuality) -> Option<Arc<Mesh>> {
self.plan_mesh
.get(q.index())?
.get_or_init(|| self.display_plan_at(q).and_then(|plan| self.body.display_mesh_of(&plan).ok()).map(Arc::new))
.get_or_init(|| match self.mesh_placement.as_deref() {
Some(placed) => placed.plan_mesh(q, &self.name),
None => self.display_plan_at(q).and_then(|plan| self.body.display_mesh_of(&plan).ok()).map(Arc::new),
})
.clone()
}
@@ -145,9 +182,10 @@ impl ModelBody {
Some((Arc::new(plan), Arc::new(mesh)))
}
/// The chord tolerance of [`ModelBody::mesh`] (mm): 1/1000 of the body's size, at least 1 µm.
/// The chord tolerance of [`ModelBody::mesh`] (mm): 1/1000 of the definition's size, at least
/// 1 µm. Proper rigid placements retain that tolerance along with the reference triangles.
pub fn display_tol(&self) -> f64 {
(self.body.size() * 1e-3).max(1e-3)
self.mesh_placement.as_ref().map(|placed| placed.tolerance).unwrap_or_else(|| (self.body.size() * 1e-3).max(1e-3))
}
/// Already computed display geometry; saving must not trigger cold tessellation.
@@ -170,7 +208,7 @@ pub struct SolvedSketch {
}
/// A cosmetic thread: on which cylinder, its size, and the threaded part of the axis.
#[derive(Clone, Debug, PartialEq, Serialize)]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ThreadInfo {
pub feature: u64,
pub body: String,
@@ -179,7 +217,7 @@ pub struct ThreadInfo {
pub pitch: f64,
pub internal: bool,
pub axis_point: Vec3,
pub axis: Vec3,
pub axis: UnitVec3,
/// Threaded part along the axis from `axis_point`.
pub start: f64,
pub end: f64,
@@ -356,7 +394,21 @@ fn thread_groove(t: &ThreadInfo, radius: f64, open: (bool, bool)) -> Result<soli
(r_root, p / 8.0, r_out, (p / 4.0 + 2.0 * (r_out - r_root) * slope) / 2.0)
};
if w0.max(w1) * 2.0 >= p * 0.98 {
return Err(DocError::Invalid("the face is too far from the thread's diameter".into()));
// The groove would be wider than a pitch: the face is far inside the thread (a hole
// narrower than its minor diameter, a shaft thicker than its major one, #2688).
let (m, dia) = (&t.designation, 2.0 * radius);
let alt = thread_for(dia, t.internal).map(|(n, q)| format_thread(n, q)).filter(|a| a != m);
let shown = show_mm(dia);
return Err(DocError::Invalid(if t.internal {
let alt = alt.map(|a| format!(", or thread this hole {a}")).unwrap_or_default();
format!(
"this {shown} mm hole is too narrow for a modelled {m} thread (its teeth would fill the hole); drill {} mm for {m}{alt}",
show_mm(d - p)
)
} else {
let alt = alt.map(|a| format!(", or thread this shaft {a}")).unwrap_or_default();
format!("this {shown} mm shaft is too thick for a modelled {m} thread; make the shaft {} mm for {m}{alt}", show_mm(d))
}));
}
let k = t.axis;
let u = k.cross(if k.x.abs() < 0.9 { Vec3::new(1.0, 0.0, 0.0) } else { Vec3::new(0.0, 1.0, 0.0) });
@@ -365,10 +417,21 @@ fn thread_groove(t: &ThreadInfo, radius: f64, open: (bool, bool)) -> Result<soli
// at the threaded length's end.
let (s0, s1) = (if open.0 { t.start - p } else { t.start }, if open.1 { t.end + p } else { t.end });
let base = t.axis_point + k * s0;
let plane = Plane { origin: base, x: u, y: k };
let plane = Plane { origin: base, x: u, y: *k };
let section = [Vec2::new(r0, -w0), Vec2::new(r1, -w1), Vec2::new(r1, w1), Vec2::new(r0, w0)];
// Sections 7.5° apart: the chords sag 0.2% of the radius.
Ok(kernel::sweep_helix_mesh(&plane, &section, base, k, p, (s1 - s0) / p, 48)?)
// Keep radial chord error below 1% of the pitch, rather than a fixed fraction of the
// diameter. At 48 stations a large fine thread cuts too deeply; its outer tool facets
// can even dip under the shaft surface. The existing coarse sizes still use 48.
let outer = r0.abs().max(r1.abs());
if !outer.is_finite() {
return Err(DocError::Invalid("thread dimensions are too large".into()));
}
let angle = (1.0 - (p * 0.01 / outer).min(1.0)).acos();
let stations = (std::f64::consts::PI / angle).ceil().max(48.0);
if !stations.is_finite() || stations > 720.0 {
return Err(DocError::Invalid("the modelled thread is too fine for this diameter; use a cosmetic thread".into()));
}
Ok(kernel::sweep_helix_mesh(&plane, &section, base, *k, p, (s1 - s0) / p, stations as usize)?)
}
/// A constant fillet of `edges`; with [`FilletFit::Reduce`](crate::FilletFit::Reduce), a radius too
@@ -552,7 +615,7 @@ impl ModelState {
/// [`quantize`]); the rest keep theirs.
fn stamp(&mut self, before: &Arc<ModelState>, f: &Feature, key: u64, mats: Vec<Mat>) {
let feature = f.id;
let depth = before.bodies.iter().filter_map(|b| b.names.origin.as_ref().map(|o| o.depth)).max().unwrap_or(0) + 1;
let depth = before.bodies.iter().filter_map(|b| b.names.depth()).max().unwrap_or(0) + 1;
let sheets = if f.kind.is_sheet() { self.sheets.clone() } else { Vec::new() };
let origin = Arc::new(crate::naming::NamingOrigin { feature: f.clone(), before: before.clone(), depth, mats, sheets });
// Pieces a cut left are named after their faces, not the kernel's shell order (unless
@@ -1021,23 +1084,26 @@ fn placement_digest(doc: &Document, f: &Feature) -> u64 {
};
let wired = |id: &crate::SketchId| doc.sketch(id.0).is_ok_and(|sketch| !sketch.wires.is_empty());
let rails: &[crate::SweepRail] = match &f.kind {
FeatureKind::Sweep { rails, .. } | FeatureKind::SurfaceSweep { rails, .. } => rails,
FeatureKind::Sweep { rails, .. }
| FeatureKind::SurfaceSweep { rails, .. }
| FeatureKind::Loft { rails, .. }
| FeatureKind::SurfaceLoft { rails, .. } => rails,
_ => &[],
};
if let Some(path) = path
&& (wired(&path) || rails.iter().any(|r| wired(&r.sketch)))
{
if path.as_ref().is_some_and(wired) || rails.iter().any(|r| wired(&r.sketch)) {
let mut hash = std::collections::hash_map::DefaultHasher::new();
let owner = doc.feature(path.0).map(|feature| feature.component).unwrap_or(0);
let owner = path.and_then(|path| doc.feature(path.0)).map(|feature| feature.component).unwrap_or(0);
for frame in [doc.component_transform(f.component), doc.component_transform(owner)] {
frame.iter().flatten().for_each(|value| value.to_bits().hash(&mut hash));
}
if let FeatureKind::Sweep { sketch, rails, .. } | FeatureKind::SurfaceSweep { sketch, rails, .. } = &f.kind {
// The profile's and the rails' components place them too.
for sketch in std::iter::once(sketch).chain(rails.iter().map(|r| &r.sketch)) {
let owner = doc.feature(sketch.0).map(|feature| feature.component).unwrap_or(0);
doc.component_transform(owner).iter().flatten().for_each(|value| value.to_bits().hash(&mut hash));
}
// A sweep's profile and the rails' components place them too.
let profile = match &f.kind {
FeatureKind::Sweep { sketch, .. } | FeatureKind::SurfaceSweep { sketch, .. } => Some(sketch),
_ => None,
};
for sketch in profile.into_iter().chain(rails.iter().map(|r| &r.sketch)) {
let owner = doc.feature(sketch.0).map(|feature| feature.component).unwrap_or(0);
doc.component_transform(owner).iter().flatten().for_each(|value| value.to_bits().hash(&mut hash));
}
return hash.finish();
}
@@ -1190,6 +1256,23 @@ impl Model {
})
}
/// Whether the results are `doc`'s evaluation: one per feature, each on the inputs `doc`
/// gives it, none stale, so [`Model::evaluate`] would keep every one of them. A document
/// changed without an evaluation since is not.
pub fn is_current(&self, doc: &Document) -> bool {
if self.results.len() != doc.features.len() || self.is_stale() {
return false;
}
let vals = evaluation_values(doc).0;
let steps = StepDigests::of(doc, &self.step_digests);
let marker = doc.marker.unwrap_or(usize::MAX);
let mut prev = 0;
doc.features.iter().zip(&self.results).enumerate().all(|(i, (f, r))| {
prev = chain_fingerprint(doc, &vals, f, prev, i >= marker, &steps);
r.id == f.id && r.fingerprint == prev
})
}
/// Re-evaluate the document, reusing results up to the first changed feature.
pub fn evaluate(&mut self, doc: &Document) {
let (vals, perr) = evaluation_values(doc);
@@ -1333,9 +1416,20 @@ impl Model {
}),
});
if r.is_ok() {
// Profiles picked by curves that no longer bound a profile exactly were found
// by their best match: say which.
let thin = matches!(&f.kind, FeatureKind::Extrude { thin: Some(_), .. });
for (sketch, sel) in f.kind.profile_sels() {
if let (false, Some(ss)) = (thin, state.sketch(sketch)) {
for note in crate::profile_fallback_notes(ss, sel) {
append_warning(&mut warning, note);
}
}
}
if let Some(note) = boolean_diagnostics.warning() {
append_warning(&mut warning, note);
}
track_altered_sheets(&state, &mut next, f, &mut warning);
} else {
boolean_diagnostics = Default::default();
}
@@ -1422,15 +1516,12 @@ pub(crate) fn val(vals: &BTreeMap<String, Value>, e: &str, k: Kind) -> Result<f6
Document::eval_in(vals, e, k)
}
/// A loft builds each section from its outline alone, so a section with a hole (a ring) is
/// refused rather than lofted as if it were filled.
/// Collapsing the outline and its holes onto one apex cannot make a manifold solid.
fn refuse_loft_holes(r: &Region2) -> Result<()> {
if r.holes.is_empty() {
return Ok(());
}
Err(DocError::Invalid(
"not supported yet: a loft section with a hole; loft the outline and the hole as two bodies and cut one from the other".into(),
))
Err(DocError::Invalid("not supported yet: lofting a section with holes to a point".into()))
}
pub fn select_profiles(ss: &SolvedSketch, sel: &ProfileSel) -> Result<Vec<Region2>> {
@@ -1454,8 +1545,11 @@ pub fn select_profiles(ss: &SolvedSketch, sel: &ProfileSel) -> Result<Vec<Region
}
v
}
// A curve loop that no longer bounds a profile exactly falls back to the best match; the
// feature's warning says so (`profile_fallback_notes`).
ProfileSel::Curves { .. } | ProfileSel::OffsetBoundaries { .. } | ProfileSel::WholeBoundaries { .. } => {
crate::profile_selection_indices(ss, sel)?
crate::profile_selection_indices_lenient(ss, sel)?
.0
.into_iter()
.map(|index| {
ps.get(index)
@@ -1542,6 +1636,52 @@ fn copy_body_name(state: &ModelState, f: &Feature, k: usize, source: &str) -> St
unique_body_name(state, base)
}
/// Keeps each sheet metal body's flat pattern true to the body (see `SheetBody::altered_by`).
/// A feature that changes a sheet body's material but not its sheet model (a fillet, a join, a
/// mirror combined into it) marks the sheet with its name, and says so. A feature that rebuilds
/// the body from the model (a sheet metal feature, or machining the model keeps as cut-outs,
/// which changes the model) clears the mark, and says that the change it drops is not kept.
/// Features that move whole bodies or only mark their faces (a cosmetic thread, not a modelled
/// one, which cuts material) leave the flat pattern as it was.
fn track_altered_sheets(before: &ModelState, after: &mut ModelState, f: &Feature, warning: &mut Option<String>) {
if matches!(
f.kind,
FeatureKind::Move { .. }
| FeatureKind::Align { .. }
| FeatureKind::Rest { .. }
| FeatureKind::BodyTransfer { .. }
| FeatureKind::SplitFace { .. }
| FeatureKind::Thread { modeled: false, .. }
) {
return;
}
let ModelState { sheets, bodies, .. } = after;
for sh in sheets.iter_mut() {
let Some(old) = before.sheets.iter().find(|s| s.body == sh.body) else { continue };
let (Some(was), Some(now)) = (before.body(&sh.body), bodies.iter().find(|b| b.name == sh.body)) else { continue };
if was.body.same_shape(&now.body) {
continue;
}
if f.kind.is_sheet() || sh != old {
if let Some(lost) = sh.altered_by.take() {
append_warning(
warning,
format!("`{}` is rebuilt from its sheet metal features: what {lost} changed is not kept (not supported yet)", sh.body),
);
}
} else if sh.altered_by.is_none() {
sh.altered_by = Some(f.name.clone());
append_warning(
warning,
format!(
"`{}` is sheet metal: its flat pattern, DXF and bend table can't include this change yet and are refused; a later sheet metal feature rebuilds the body without it",
sh.body
),
);
}
}
}
/// Add tool bodies to the model according to the operation.
fn sheet_boolean_unchanged(body: &Body, tools: &[Body], op: Operation) -> Result<()> {
if sheet_boolean_changes(body, tools, op)? {
@@ -1864,12 +2004,7 @@ pub fn through_all_distance(st: &ModelState, plane: &Plane, targets: &[String])
/// Sample points inside a region (sketch coordinates).
fn region_samples(r: &Region2) -> Vec<Vec2> {
let mut out = vec![r.interior_point()];
let pts = r.outer.polyline(1e-2);
let (mut lo, mut hi) = (Vec2::new(f64::INFINITY, f64::INFINITY), Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY));
for p in &pts {
lo = Vec2::new(lo.x.min(p.x), lo.y.min(p.y));
hi = Vec2::new(hi.x.max(p.x), hi.y.max(p.y));
}
let solidcraft_geom::Aabb2 { min: lo, max: hi } = r.outer.polyline(1e-2).bounds();
for i in 0..5 {
for j in 0..5 {
let p = Vec2::new(lo.x + (hi.x - lo.x) * (0.1 + 0.2 * i as f64), lo.y + (hi.y - lo.y) * (0.1 + 0.2 * j as f64));
@@ -2390,7 +2525,7 @@ fn faces_through(st: &ModelState, p: Vec3, reach: f64) -> Vec<(f64, Vec3, Option
}
for (f, (d, facet)) in near {
let (exact, off) = match kernel::face_surface(&b.body, f as usize) {
Some(kernel::FaceSurface::Plane { normal, point }) => (Some(normal), Some((p - point).dot(normal).abs())),
Some(kernel::FaceSurface::Plane { normal, point }) => (Some(*normal), Some((p - point).dot(normal).abs())),
Some(kernel::FaceSurface::Cylinder { origin, axis, .. }) => {
let v = p - origin;
((v - axis * v.dot(axis)).normalized(), None)
@@ -2884,6 +3019,38 @@ fn hole_tools(
Ok(hole_tools_and_prisms(vals, st, position, dir, diameter, depth, hole)?.into_iter().map(|(tool, _)| tool).collect())
}
/// The scale a hole's tools are sized by: the largest body the hole's axis meets (#2904), so a
/// large body elsewhere in the model changes neither the tool's reach above the face (and so
/// the countersink's outline) nor its depth. A hole whose axis meets no body is sized by the
/// largest body of the model, as before.
fn hole_axis_size(st: &ModelState, position: &Vec3, dir: Vec3) -> f64 {
let along = |b: &ModelBody| {
// The axis (an unbounded line) against the body's box, grown by the display tolerance.
let bb = b.mesh().bounds();
if bb.is_empty() {
return false;
}
let pad = 2.0 * b.display_tol();
let (mut lo, mut hi) = (f64::NEG_INFINITY, f64::INFINITY);
for (o, d, min, max) in
[(position.x, dir.x, bb.min.x, bb.max.x), (position.y, dir.y, bb.min.y, bb.max.y), (position.z, dir.z, bb.min.z, bb.max.z)]
{
if d.abs() < 1e-12 {
if o < min - pad || o > max + pad {
return false;
}
} else {
let (a, c) = ((min - pad - o) / d, (max + pad - o) / d);
lo = lo.max(a.min(c));
hi = hi.min(a.max(c));
}
}
lo <= hi
};
let met = st.bodies.iter().filter(|b| along(b)).map(|b| b.body.size()).fold(0.0, f64::max);
if met > 0.0 { met } else { st.bodies.iter().map(|b| b.body.size()).fold(1.0, f64::max) }
}
/// [`hole_tools`], each with its exact geometry when it is a straight cylinder along the axis
/// (a plain hole, a counterbore, a drill point left in the air past a through hole), so sheet
/// metal can keep it as a flat cut-out or pocket. Each tool's faces are named by the part of the
@@ -2902,7 +3069,7 @@ fn hole_tools_and_prisms(
if !(r > 1e-6) {
return Err(DocError::Invalid("hole diameter must be positive".into()));
}
let size = st.bodies.iter().map(|b| b.body.size()).fold(1.0, f64::max);
let size = hole_axis_size(st, position, dir);
let top = (size * 0.01).max(0.1);
let bottom = match depth {
Some(d) => -val(vals, d, Kind::Length)?,
@@ -3660,8 +3827,11 @@ fn feature_tools_with_constructor(
let m = crate::rigid(Vec3::ZERO, origin_w, axis_w, -start);
regions.iter().zip(bodies).map(|(r, body)| Ok(tool(r, kernel::transform_matrix(&body, m)?, Some(&body)))).collect()
}
FeatureKind::Loft { sections, .. } if sections.iter().any(|s| s.point.is_some()) => {
FeatureKind::Loft { sections, rails, .. } if sections.iter().any(|s| s.point.is_some()) => {
// A profile lofted to a sketch point (an apex).
if !rails.is_empty() {
return Err(DocError::Invalid("not supported yet: guide rails on a loft to a point".into()));
}
let [a, b] = &sections[..] else { return Err(DocError::Invalid("not supported yet: a point section in a loft of more than two".into())) };
let (prof, pt) = if a.point.is_some() { (b, a) } else { (a, b) };
let ss = st.sketch(prof.sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {}", prof.sketch)))?;
@@ -3714,7 +3884,7 @@ fn feature_tools_with_constructor(
let pitch = if *clockwise { -p } else { p };
Ok(unnamed(vec![kernel::sweep_helix(&plane, &region, *base, axis, pitch, n)?]))
}
FeatureKind::Loft { sections, .. } => {
FeatureKind::Loft { sections, rails, rail_fit, .. } => {
let mut secs = Vec::new();
let mut sketches = Vec::new();
for sec in sections {
@@ -3723,14 +3893,27 @@ fn feature_tools_with_constructor(
.ok_or_else(|| DocError::Unknown(format!("sketch {} (it must come earlier in the timeline)", sec.sketch)))?;
let regions = solidcraft_sketch::merge_regions(&select_profiles(ss, &sec.profiles)?);
let [r] = &regions[..] else { return Err(DocError::Invalid("each loft section must be one profile".into())) };
refuse_loft_holes(r)?;
secs.push((ss.plane, r.outer.clone()));
secs.push((ss.plane, r.clone()));
sketches.push(ss);
}
// Its caps are the first and last sections, its sides named by the curves they run
// between.
let body = kernel::loft(&secs)?;
let roles = crate::lineage::SectionRoles { sections: sketches.into_iter().zip(secs.iter().map(|(_, lp)| lp)).collect() };
let body = if rails.is_empty() {
kernel::loft_regions(&secs)?
} else {
// Rails shape the sections' outlines; sections with holes wait for rails that
// carry the holes too.
if secs.iter().any(|(_, r)| !r.holes.is_empty()) {
return Err(DocError::Invalid("not supported yet: guide rails on a loft whose sections have holes".into()));
}
let outlines: Vec<_> = secs.iter().map(|(p, r)| (*p, r.outer.clone())).collect();
// The rails run from the end at the first section.
let start = outlines.first().and_then(|(p, lp)| lp.segs.first().map(|seg| p.to_world(seg.start()))).unwrap_or_default();
kernel::loft_with_rails(&outlines, &rail_polylines(doc, st, rails, f.component, start)?, kernel_rail_fit(*rail_fit))?
};
let roles = crate::lineage::SectionRoles {
sections: sketches.into_iter().zip(secs.iter().map(|(_, r)| std::iter::once(&r.outer).chain(&r.holes).collect())).collect(),
};
Ok(vec![roles.tool(body)])
}
FeatureKind::Sweep { sketch, profiles, path_sketch, path, orientation, twist, scale, rails, rail_fit, .. } => {
@@ -3840,20 +4023,24 @@ pub(crate) fn sweep_control(
Some(k) => val(vals, k, Kind::Unitless)?,
None => 1.0,
};
let rail_fit = match rail_fit {
crate::RailFit::Scale => kernel::RailFit::Scale,
crate::RailFit::Stretch => kernel::RailFit::Stretch,
};
let ctl = kernel::SweepControl { orientation, twist, scale, rails, rail_fit };
let ctl = kernel::SweepControl { orientation, twist, scale, rails, rail_fit: kernel_rail_fit(rail_fit) };
ctl.check()?;
Ok(ctl)
}
/// A sweep's guide rails as polylines in the feature's frame, each a chain of its sketch's curves
/// (planar or 3D) from the end nearest `start`, the start of the path.
/// How guide rails reshape a sweep's profile or a loft's sections, for the kernel.
pub(crate) fn kernel_rail_fit(fit: crate::RailFit) -> kernel::RailFit {
match fit {
crate::RailFit::Scale => kernel::RailFit::Scale,
crate::RailFit::Stretch => kernel::RailFit::Stretch,
}
}
/// A sweep's or a loft's guide rails as polylines in the feature's frame, each a chain of its sketch's curves
/// (planar or 3D) from the end nearest `start` (the start of the path, the loft's first section).
pub(crate) fn rail_polylines(doc: &Document, st: &ModelState, rails: &[crate::SweepRail], consumer: u64, start: Vec3) -> Result<Vec<Vec<Vec3>>> {
if rails.len() > 2 {
return Err(DocError::Invalid("a sweep takes one or two guide rails".into()));
return Err(DocError::Invalid("a sweep or a loft takes one or two guide rails".into()));
}
rails
.iter()
@@ -4064,9 +4251,17 @@ fn path_segments(ps: &SolvedSketch, ids: &[String], start: Vec3) -> Result<Vec<k
/// 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.
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), _) => (-val(vals, d2, Kind::Length)?, d),
(Some(d2), _) => {
let first = if direction == crate::Direction::Negative { -d } else { d };
let d2 = val(vals, d2, Kind::Length)?;
(if first < 0.0 { d2 } else { -d2 }, first)
}
(None, crate::Direction::Positive) => (0.0, d),
(None, crate::Direction::Negative) => (-d, 0.0),
(None, crate::Direction::Symmetric) => {
@@ -4356,6 +4551,9 @@ fn replay(
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).
let targets = if targets.is_empty() { src.participants.clone() } else { targets };
let mut copies = Vec::new();
if let FeatureKind::Hole { diameter, depth, hole, to, .. } = &src.kind {
// A hole is drilled again at each copy of its spot (found on the surface there), so
@@ -4398,8 +4596,11 @@ fn replay(
Ok(())
}
#[path = "model_cache.rs"]
mod model_cache;
#[path = "eval_more.rs"]
mod more;
pub use model_cache::SavedModel;
#[path = "eval_surface.rs"]
mod surface;
@@ -4763,7 +4964,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
None => state_in_frame(doc, st, f.component),
};
let (plane, face_warning) = match (anchor, plane) {
(Some(_), plane) => (doc.resolve_attached_plane(vals, plane, f.component, st, &fst)?, None),
(Some(_), plane) => doc.resolve_attached_plane_noted(vals, plane, f.component, st, &fst)?,
// A flat face of a sheet: the panel's exact plane, from its flat region and fold.
(None, plane) if crate::sheet::plane_sheet_face(plane).is_some() => {
(doc.resolve_plane_in(vals, &sheet_face_plane_in_frame(doc, st, f.component, plane)?, 0, Some(&fst))?, None)
@@ -4922,7 +5123,11 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
let pl = match crate::face_anchor::plane_face_anchor(plane) {
Some(anchor) => {
let fst = crate::state_in_face_anchor_frame(doc, st, anchor, f.component)?;
doc.resolve_attached_plane(vals, plane, f.component, st, &fst)?
let (pl, note) = doc.resolve_attached_plane_noted(vals, plane, f.component, st, &fst)?;
if let Some(note) = note {
append_warning(warning, note);
}
pl
}
// A plane of numbers alone reads no body: placing them all is the design's size.
None if plane_is_fixed(plane) => doc.resolve_plane_in(vals, plane, 0, Some(st))?,
@@ -5008,7 +5213,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
if curves.is_empty() {
return Err(DocError::Invalid(format!("sketch `{}` has no curves to split with", ss.name)));
}
kernel::split_faces(&mb.body, &picked, &kernel::SplitTool::Curves { plane: ss.plane, curves })?
kernel::split_faces(&mb.body, &picked, &kernel::SplitTool::Curves { plane: ss.plane, curves, along: None })?
}
(None, None) => {
let pl = doc.resolve_plane_in(vals, plane, 0, Some(st))?;
@@ -5707,11 +5912,19 @@ impl PlacedBodies {
Some((_, placed)) => {
let mut fresh = ModelBody::new(name, placed.body.clone(), b.feature);
fresh.mesh = placed.mesh.clone();
fresh.mesh_placement = placed.mesh_placement.clone();
fresh.plan = placed.plan.clone();
fresh.plan_mesh = placed.plan_mesh.clone();
fresh
}
None => ModelBody::new(name, kernel::transform_matrix(&b.body, *m).unwrap_or_else(|_| b.body.clone()), b.feature),
None => match kernel::place_matrix(&b.body, *m) {
Ok(body) => {
let mut fresh = ModelBody::new(name, body, b.feature);
fresh.mesh_placement = placed_mesh::PlacedMesh::new(b, m).map(Arc::new);
fresh
}
Err(_) => ModelBody::new(name, b.body.clone(), b.feature),
},
}
}
}
@@ -5746,26 +5959,46 @@ pub fn world_state_reusing(doc: &Document, st: &ModelState, placed_before: &mut
path
};
let mut out = ModelState { body_counter: st.body_counter, ..Default::default() };
for (comp, path, m) in doc.placements() {
let placements = doc.placements();
// A body moved in from another component keeps its place (its offset).
let body_matrix = |b: &ModelBody, m: &crate::Mat| doc.model_body_offset(b).map(|o| crate::mat_mul(m, o)).unwrap_or(*m);
// Every body placed elsewhere than its definition, placed on all cores: a large assembly
// places hundreds of parts, each a B-rep transform.
let mut jobs = Vec::new();
for (comp, path, m) in &placements {
for b in st.bodies.iter().filter(|b| doc.model_body_component(b) == *comp) {
let bm = body_matrix(b, m);
if !crate::is_identity(&bm) {
jobs.push((b, bm, doc.world_body_name(st, &b.name, *comp, path)));
}
}
}
let mut moved = kernel::map_on_threads(&jobs, 16, |(b, bm, name)| placed_before.placed(b, bm, name.clone())).into_iter().zip(&jobs);
for (comp, path, m) in placements {
let primary = path == first_path(comp);
let ident = crate::is_identity(&m);
for b in st.bodies.iter().filter(|b| doc.model_body_component(b) == comp) {
let name = doc.world_body_name(st, &b.name, comp, &path);
// A body moved in from another component keeps its place (its offset).
let bm = doc.model_body_offset(b).map(|o| crate::mat_mul(&m, o)).unwrap_or(m);
let bm = body_matrix(b, &m);
// Identical geometry also shares its display mesh cell with the definition.
// A changed placement keeps a distinct mesh, at that placement's coordinates.
let mut placed = if crate::is_identity(&bm) {
let mut placed = b.clone();
placed.name = name;
placed.name = doc.world_body_name(st, &b.name, comp, &path);
placed
} else {
let placed = placed_before.placed(b, &bm, name);
// The same order as the jobs: this body's, made above (again here if it failed).
let placed = match moved.next() {
Some((Some(placed), _)) => placed,
Some((None, (_, _, name))) => placed_before.placed(b, &bm, name.clone()),
None => placed_before.placed(b, &bm, doc.world_body_name(st, &b.name, comp, &path)),
};
placed_now.by_source.insert(placed_key(b, &bm), (b.clone(), placed.clone()));
placed
};
// World states keep their previous display-only evaluation-key semantics.
placed.key = 0;
// With the model body's own frame, so its place is told apart from the placement's.
placed.source_key = b.geometry_key().unwrap_or(0);
placed.source_name = Some(b.definition_name().to_string());
placed.source_transform = crate::mat_mul(&bm, &b.source_transform);
placed.component = comp;
@@ -5805,7 +6038,7 @@ pub fn world_state_reusing(doc: &Document, st: &ModelState, placed_before: &mut
let mut t2 = t.clone();
let frame = st.body(&t.body).and_then(|body| doc.model_body_offset(body)).map(|offset| crate::mat_mul(&m, offset)).unwrap_or(m);
t2.axis_point = crate::apply_point(&frame, t.axis_point);
t2.axis = crate::apply_vector(&frame, t.axis);
t2.axis = crate::apply_vector(&frame, *t.axis).unit().unwrap_or(t.axis);
out.threads.push(t2);
}
}
@@ -5874,6 +6107,8 @@ mod world_placement_cache_tests {
assert!(Arc::ptr_eq(&source.mesh, &first.mesh), "unchanged geometry must retain the shared display mesh cell");
assert_eq!(first.key, 0);
assert_eq!(placed.key, 0);
// Each placement has its own geometry identity, for work measured on it.
assert!(first.geometry_key().is_some() && first.geometry_key() != placed.geometry_key());
assert!(!Arc::ptr_eq(&source.mesh, &placed.mesh), "a transformed mesh cannot be reused without mapping coordinates");
assert_eq!(first.source_name.as_deref(), Some("Block"));
assert_eq!(placed.source_name.as_deref(), Some("Block"));
+40 -35
View File
@@ -5,7 +5,7 @@ use super::*;
use crate::EmbossFace;
use crate::apply_point;
use crate::sheet;
use solidcraft_geom::{Aabb3, Loop2, Seg2};
use solidcraft_geom::{Aabb3, Loop2, Ring, Seg2};
pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState) -> Result<()> {
match &f.kind {
@@ -97,20 +97,22 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
let tn = tp.normal();
let i = body_at(st, body, &crate::plain(faces))?;
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
let mut b = mb.body.clone();
// Each face moved is a step of the body's lineage, as Offset Face's is: without it no face of
// the result could take a sketch's attachment.
let mut b = mb.traced();
for p in faces {
let n =
planar_normal_at(&b, p.0).ok_or_else(|| DocError::Invalid("not supported yet: replacing a face that is not planar".into()))?;
let n = planar_normal_at(&b.body, p.0)
.ok_or_else(|| DocError::Invalid("not supported yet: replacing a face that is not planar".into()))?;
if n.dot(tn).abs() < 1.0 - 1e-9 {
return Err(DocError::Invalid("not supported yet: a target that is not parallel to the face".into()));
}
let d = (tp.origin - p.0).dot(n);
if d.abs() > 1e-9 {
b = kernel::offset_faces(&b, &[p.0], d)?;
b = Traced::step(kernel::offset_faces(&b.body, &[p.0], d)?, &[&b]);
}
}
if let Some(slot) = st.bodies.get_mut(i) {
*slot = ModelBody::new(mb.name, b, mb.feature);
*slot = ModelBody::traced_new(mb.name, b, mb.feature);
}
Ok(())
}
@@ -739,8 +741,10 @@ pub(super) fn sheet_eval(
let t = hi - lo;
// The face outline: boundary edges of the triangles on that face's plane, where it runs
// along a round wall an arc of the wall's circle (not chords: the plate check below
// compares its area with the exact volume).
let plane = Plane::from_normal(face.0 + n * lo, n * -1.0).ok_or_else(|| DocError::Invalid("face".into()))?;
// compares its area with the exact volume). The sheet's frame lies on the picked face
// (its outer level, `hi`) with z into the material, as with bend edges, so the sheet
// takes the plate's place: its thickness runs from `hi` back to `lo`.
let plane = Plane::from_normal(face.0 + n * hi, n * -1.0).ok_or_else(|| DocError::Invalid("face".into()))?;
// Recesses in one face (an engraved label) become pockets; the outline is the other face's.
let recesses = plate_recesses(&mb.body, &mesh, &plane, face.0, n, (lo, hi), tol)?;
let mut walls = std::collections::HashMap::new();
@@ -773,11 +777,6 @@ pub(super) fn sheet_eval(
}
}
fn poly_area(p: &[Vec2]) -> f64 {
let m = p.len();
(0..m).filter_map(|i| Some(p.get(i)?.cross(*p.get((i + 1) % m)?))).sum::<f64>() / 2.0
}
/// Current unmodified extrusion caps, including placement, certify the tool's depth.
/// Tessellation can drift in thickness at distant coordinates and cannot certify a slab.
fn sheet_tool_span(body: &Body, source: &Plane) -> Option<(f64, f64, f64, Option<(Plane, Region2)>)> {
@@ -1361,9 +1360,7 @@ fn mesh_box(mb: &ModelBody) -> Option<Aabb3> {
fn padded_box(mesh: &solidcraft_geom::Mesh, tol: f64) -> Option<Aabb3> {
let b = mesh.bounds();
let pad = 2.0 * tol + 1e-6;
let pad = Vec3::new(pad, pad, pad);
(!b.is_empty() && b.min.is_finite() && b.max.is_finite()).then(|| Aabb3 { min: b.min - pad, max: b.max + pad })
(!b.is_empty() && b.min.is_finite() && b.max.is_finite()).then(|| b.inflated(2.0 * tol + 1e-6))
}
/// Whether two boxes are known to be apart (an unknown box is never apart).
@@ -1403,7 +1400,7 @@ fn parted_note(sh: &sheet::SheetBody, material: &[Region2], before: &sheet::Shee
/// Outline loops (in `plane` coordinates) of the mesh triangles lying in the plane at distance
/// `level` from `at` along `n`: the edges used by one of them only, chained.
fn face_loops(mesh: &solidcraft_geom::Mesh, plane: &Plane, at: Vec3, n: Vec3, level: f64, tol: f64) -> Vec<Vec<Vec2>> {
fn face_loops(mesh: &solidcraft_geom::Mesh, plane: &Plane, at: Vec3, n: Vec3, level: f64, tol: f64) -> Vec<Ring<Vec2>> {
face_outline(mesh, plane, at, n, level, tol).into_iter().map(|l| l.into_iter().map(|(p, _)| p).collect()).collect()
}
@@ -1441,7 +1438,7 @@ fn face_outline(mesh: &solidcraft_geom::Mesh, plane: &Plane, at: Vec3, n: Vec3,
}
/// The loops (in `plane` coordinates) that bound the triangles `on` of the mesh.
fn boundary_loops(mesh: &solidcraft_geom::Mesh, plane: &Plane, on: &[[u32; 3]]) -> Vec<Vec<Vec2>> {
fn boundary_loops(mesh: &solidcraft_geom::Mesh, plane: &Plane, on: &[[u32; 3]]) -> Vec<Ring<Vec2>> {
chain_loops(mesh, plane, on, |_, _| None).into_iter().map(|l| l.into_iter().map(|(p, _)| p).collect()).collect()
}
@@ -1638,7 +1635,7 @@ fn plate_recesses(body: &Body, mesh: &Mesh, plane: &Plane, at: Vec3, n: Vec3, (l
for (f, d, ..) in &floors {
let on: Vec<[u32; 3]> = fine.triangles.iter().zip(&fine.tri_face).filter(|(_, g)| **g as usize == *f).map(|(tri, _)| *tri).collect();
let mut loops = boundary_loops(&fine, plane, &on);
loops.sort_by(|a, b| poly_area(b).abs().total_cmp(&poly_area(a).abs()));
loops.sort_by(|a, b| b.signed_area().abs().total_cmp(&a.signed_area().abs()));
let mut it = loops.into_iter();
let Some(outline) = it.next() else { continue };
let region = Region2 { outer: Loop2::polygon(&outline).ccw(), holes: it.map(|l| Loop2::polygon(&l).ccw().reversed()).collect() };
@@ -1835,24 +1832,24 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
let tol = (mb.body.size() * 1e-3).max(1e-3);
let mesh = mb.body.tessellate(tol)?;
let mut loops = face_loops(&mesh, &plane, face.0, n, 0.0, tol);
loops.sort_by(|a, b| poly_area(b).abs().total_cmp(&poly_area(a).abs()));
let ccw = |l: &[Vec2]| if poly_area(l) < 0.0 { l.iter().rev().copied().collect::<Vec<_>>() } else { l.to_vec() };
loops.sort_by(|a, b| b.signed_area().abs().total_cmp(&a.signed_area().abs()));
let ccw = |l: &Ring<Vec2>| if l.signed_area() < 0.0 { l.iter().rev().copied().collect::<Ring<_>>() } else { l.clone() };
// The inner edge is an opening (the cavity under a rim): not the foot of a boss or a
// wall standing on the face, whose side the band would run against.
let opening = |l: &[Vec2]| {
let opening = |l: &Ring<Vec2>| {
let eps = (tol * 4.0).max(1e-3);
l.iter().zip(l.iter().cycle().skip(1)).filter_map(|(a, b)| Some(((*a + *b) * 0.5, (*b - *a).normalized()?))).take(3).any(|(m, t)| {
l.edges().filter_map(|(a, b)| Some(((*a + *b) * 0.5, (*b - *a).normalized()?))).take(3).any(|(m, t)| {
// Just over the face, inside the loop (to the left of a ccw loop's edges).
let q = plane.to_world(m + Vec2::new(-t.y, t.x) * eps) + n * eps;
!mesh.contains(q)
})
};
let outer = loops.first().map(|l| ccw(l));
let openings: Vec<Vec<Vec2>> = loops.iter().skip(1).map(|l| ccw(l)).filter(|l| opening(l)).collect();
let outer = loops.first().map(ccw);
let openings: Vec<Ring<Vec2>> = loops.iter().skip(1).map(ccw).filter(|l| opening(l)).collect();
// A rim runs round one opening, the cavity; smaller ones (a screw hole) may sit in it.
// Openings alike in size (a plate's holes) leave no edge that is the rim's.
let alike = match openings.as_slice() {
[a, b, ..] => poly_area(b).abs() > 0.5 * poly_area(a).abs(),
[a, b, ..] => b.signed_area().abs() > 0.5 * a.signed_area().abs(),
_ => false,
};
let (Some(outer), Some(inner), false) = (outer, openings.first().cloned(), alike) else {
@@ -1877,16 +1874,24 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
// The band along the chosen edge, inside the rim face. A groove's cutter reaches past
// the edge into the free side (the cavity, or outside), so no wall coincides.
let free = if *groove { bw } else { 0.0 };
let band = if *outside {
Region2 {
outer: Loop2::polygon(&offset_left(&outer, -free)).ccw(),
holes: vec![Loop2::polygon(&offset_left(&outer, bw)).ccw().reversed()],
}
let (plane, band) = if let Ok(exact) = kernel::rim_band(&mb.body, face.0, bw, free, *outside) {
exact
} else if *outside {
(
plane,
Region2 {
outer: Loop2::polygon(&offset_left(&outer, -free)).ccw(),
holes: vec![Loop2::polygon(&offset_left(&outer, bw)).ccw().reversed()],
},
)
} else {
Region2 {
outer: Loop2::polygon(&offset_left(&inner, -bw)).ccw(),
holes: vec![Loop2::polygon(&offset_left(&inner, free)).ccw().reversed()],
}
(
plane,
Region2 {
outer: Loop2::polygon(&offset_left(&inner, -bw)).ccw(),
holes: vec![Loop2::polygon(&offset_left(&inner, free)).ccw().reversed()],
},
)
};
let delta = (h * 0.05).clamp(1e-3, 0.5);
if *groove {
+84 -10
View File
@@ -67,20 +67,46 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
}
apply_op(st, f, tools, *operation, &crate::plain(targets))
}
FeatureKind::SurfaceTrim { body, plane, tool, keep } => {
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}`")))?;
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
let trimmed = match tool {
Some(t) => {
let picks: Vec<Vec3> = std::iter::once(keep.0).chain(keep_more.iter().map(|p| p.0)).collect();
let keeps = if keep_more.is_empty() { kernel::TrimKeep::Side(keep.0) } else { kernel::TrimKeep::Regions(&picks) };
if let Some(other) = with {
return mutual_trim(st, i, other, keeps, &crate::plain(keep_with), *stitch, *extend);
}
let trimmed = match (tool, curves) {
(Some(t), _) => {
let tb = st.body(&t.body).ok_or_else(|| DocError::Unknown(format!("body `{}`", t.body)))?;
let face = crate::appearance::face_index_at(tb, t.point.0)
.ok_or_else(|| DocError::Invalid(format!("no face of `{}` at the tool point", t.body)))?;
kernel::trim(&mb.body, &kernel::SplitTool::Face { body: &tb.body, face }, keep.0)?
kernel::trim_keep(&mb.body, &kernel::SplitTool::Face { body: &tb.body, face }, keeps)?
}
None => kernel::trim(&mb.body, &kernel::SplitTool::Plane(doc.resolve_plane_in(vals, plane, 0, Some(st))?), keep.0)?,
(None, Some(c)) => {
let ss = st
.sketch(c.sketch.0)
.ok_or_else(|| DocError::Unknown(format!("sketch {} (it must come earlier in the timeline)", c.sketch)))?;
let ss = sketch_in_feature_frame(doc, ss, f.component)?;
let ids: Vec<String> = if c.curves.is_empty() {
ss.sketch.curves.iter().filter(|k| !k.construction).map(|k| k.id.clone()).collect()
} else {
c.curves.clone()
};
let chains = curve_chains(&ss, &ids)?;
if chains.is_empty() {
return Err(DocError::Invalid(format!("sketch `{}` has no curves to trim with", ss.name)));
}
kernel::trim_keep(
&mb.body,
&kernel::SplitTool::Curves { plane: ss.plane, curves: chains, along: c.direction.map(|d| d.0) },
keeps,
)?
}
(None, None) => kernel::trim_keep(&mb.body, &kernel::SplitTool::Plane(doc.resolve_plane_in(vals, plane, 0, Some(st))?), keeps)?,
};
let made = Traced::step(trimmed, &[&mb.traced()]);
if let Some(slot) = st.bodies.get_mut(i) {
*slot = ModelBody::new(mb.name, trimmed, mb.feature);
*slot = ModelBody::traced_new(mb.name, made, mb.feature);
}
Ok(())
}
@@ -115,7 +141,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
}
Ok(())
}
FeatureKind::SurfaceBlend { from, to, continuity, bulge, align, stitch } => {
FeatureKind::SurfaceBlend { from, to, continuity, bulge, align, seam, stitch } => {
let a = st.body(&from.body).ok_or_else(|| DocError::Unknown(format!("body `{}`", from.body)))?.body.clone();
let b = st.body(&to.body).ok_or_else(|| DocError::Unknown(format!("body `{}`", to.body)))?.body.clone();
if from.edges.is_empty() || to.edges.is_empty() {
@@ -129,6 +155,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
*continuity,
*bulge,
align.map(|[p, q]| [p.0, q.0]),
seam.map(|[p, q]| [p.0, q.0]),
BLEND_STATIONS,
*stitch,
)?;
@@ -362,11 +389,14 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
push_sheet(st, f, traced);
Ok(())
}
FeatureKind::SurfaceLoft { sections, continuity, crown } => {
FeatureKind::SurfaceLoft { sections, continuity, crown, rails, rail_fit } => {
// A loft with edge chains for sections, or one that has to leave its ends a particular
// way, is built through `loft_blend` from the frames of every section; the plain one
// (sketch chains only, both ends G0) is the loft of old.
if *continuity != [kernel::Continuity::G0, kernel::Continuity::G0] || sections.iter().any(|s| s.body.is_some()) {
if !rails.is_empty() {
return Err(DocError::Invalid("not supported yet: guide rails on a loft with edge sections or end continuity".into()));
}
return loft_blend(st, f, sections, *continuity, *crown, doc);
}
let mut secs = Vec::with_capacity(sections.len());
@@ -391,10 +421,17 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
secs.push((ss.plane, chain));
sketches.push(ss);
}
let body = kernel::loft_sheet(&secs)?;
let body = if rails.is_empty() {
kernel::loft_sheet(&secs)?
} else {
// The rails run from the end at the first section.
let start = secs.first().and_then(|(p, c)| c.first().map(|seg| p.to_world(seg.start()))).unwrap_or_default();
let rails = crate::eval::rail_polylines(doc, st, rails, f.component, start)?;
kernel::loft_sheet_with_rails(&secs, &rails, crate::eval::kernel_rail_fit(*rail_fit))?
};
// Each wall named by the section curves it runs between, as a loft's sides are.
let loops: Vec<solidcraft_geom::Loop2> = secs.iter().map(|(_, c)| solidcraft_geom::Loop2 { segs: c.clone() }).collect();
let traced = crate::lineage::SectionRoles { sections: sketches.iter().zip(&loops).collect() }.tool(body);
let traced = crate::lineage::SectionRoles { sections: sketches.iter().zip(&loops).map(|(s, lp)| (s, vec![lp])).collect() }.tool(body);
push_sheet(st, f, traced);
Ok(())
}
@@ -669,6 +706,43 @@ fn boundary_frames(
Ok(out)
}
/// Mutual Trim: the body at `i` and `other` trimmed by each other (each keeping its picks), the
/// kept pieces sewn into the first when `stitch`; their faces named after the faces they are
/// pieces of.
fn mutual_trim(st: &mut ModelState, i: usize, other: &str, keeps: kernel::TrimKeep, keep_with: &[Vec3], stitch: bool, extend: bool) -> Result<()> {
let j = st.bodies.iter().position(|b| b.name == other).ok_or_else(|| DocError::Unknown(format!("body `{other}`")))?;
if i == j {
return Err(DocError::Invalid("a surface can't trim itself: pick another surface".into()));
}
let (Some(a), Some(b)) = (st.bodies.get(i).cloned(), st.bodies.get(j).cloned()) else { return Err(DocError::Invalid("body".into())) };
let keeps_with = match keep_with {
[] => return Err(DocError::Invalid(format!("pick the side of `{other}` to keep"))),
[p] => kernel::TrimKeep::Side(*p),
ps => kernel::TrimKeep::Regions(ps),
};
let (ta, tb) = kernel::mutual_trim(&a.body, &b.body, keeps, keeps_with, extend)?;
let (ta, tb) = (Traced::step(ta, &[&a.traced()]), Traced::step(tb, &[&b.traced()]));
if stitch {
let sewn = kernel::stitch(&[&ta.body, &tb.body], 0.0)?;
if sewn.edge_count() >= ta.body.edge_count() + tb.body.edge_count() {
return Err(DocError::Invalid("the trimmed surfaces don't meet along an edge: they can't be stitched".into()));
}
let sewn = Traced::step(sewn, &[&ta, &tb]);
if let Some(slot) = st.bodies.get_mut(i) {
*slot = ModelBody::traced_new(a.name, sewn, a.feature);
}
st.bodies.retain(|x| x.name != other);
} else {
if let Some(slot) = st.bodies.get_mut(i) {
*slot = ModelBody::traced_new(a.name, ta, a.feature);
}
if let Some(slot) = st.bodies.get_mut(j) {
*slot = ModelBody::traced_new(b.name, tb, b.feature);
}
}
Ok(())
}
/// What a surface builder sweeps: the boundary loops of the picked profiles, then the picked
/// curves chained end to end.
fn sheet_chains(ss: &SolvedSketch, profiles: Option<&ProfileSel>, curves: &[String]) -> Result<Vec<Vec<Seg2>>> {
+15 -16
View File
@@ -395,7 +395,7 @@ fn lex_spans(s: &str) -> Result<Vec<(Tok, usize, usize)>, DocError> {
out.push((Tok::Op('-'), at(i), at(i + 1)));
i += 1;
} else if c == ';' {
// Fusion separates function arguments with `;` in some locales.
// Some CAD tools separate function arguments with `;` in some locales.
out.push((Tok::Op(','), at(i), at(i + 1)));
i += 1;
} else if "+-*/^(),%".contains(c) {
@@ -897,10 +897,6 @@ pub fn eval_params(params: &[ParamDef], doc_len: f64) -> (BTreeMap<String, Value
if cx.stack.len() > 512 {
return Err(DocError::Expr("parameters nested too deeply".into()));
}
let Some((kind, _)) = unit_info(&p.unit).or(Some((Kind::Length, 1.0))) else {
return Err(DocError::Expr(format!("unknown unit `{}`", p.unit)));
};
let defaults = Defaults::for_unit(&p.unit, cx.doc_len);
cx.stack.push(name.to_string());
let mut vals: BTreeMap<String, Value> = BTreeMap::new();
let mut err = None;
@@ -918,17 +914,7 @@ pub fn eval_params(params: &[ParamDef], doc_len: f64) -> (BTreeMap<String, Value
cx.stack.pop();
let r = match err {
Some(e) => Err(e),
None => {
eval_with_defaults(&p.expr, &|n| vals.get(n).copied().ok_or_else(|| DocError::Expr(format!("unknown parameter `{n}`"))), defaults)
.and_then(|v| {
let x = v.to_kind_in(kind, defaults)?;
Ok(match kind {
Kind::Length => Value::length(x),
Kind::Angle => Value::angle(x),
Kind::Unitless => Value::num(x),
})
})
}
None => eval_def(p, &|n| vals.get(n).copied().ok_or_else(|| DocError::Expr(format!("unknown parameter `{n}`"))), cx.doc_len),
};
match &r {
Ok(v) => {
@@ -949,6 +935,19 @@ pub fn eval_params(params: &[ParamDef], doc_len: f64) -> (BTreeMap<String, Value
(cx.done, cx.errors)
}
/// One parameter's value: its expression in its unit, with the values of the parameters it
/// refers to from `lookup`.
pub fn eval_def(p: &ParamDef, lookup: &dyn Fn(&str) -> Result<Value, DocError>, doc_len: f64) -> Result<Value, DocError> {
let (kind, _) = unit_info(&p.unit).unwrap_or((Kind::Length, 1.0));
let defaults = Defaults::for_unit(&p.unit, doc_len);
let x = eval_with_defaults(&p.expr, lookup, defaults)?.to_kind_in(kind, defaults)?;
Ok(match kind {
Kind::Length => Value::length(x),
Kind::Angle => Value::angle(x),
Kind::Unitless => Value::num(x),
})
}
/// Dependency edges between parameters: (parameter, parameter it references).
pub fn param_edges(params: &[ParamDef]) -> Vec<(String, String)> {
let mut out = Vec::new();
+111 -11
View File
@@ -1,4 +1,5 @@
//! Exact ordinary face attachments. Missing identities never invoke a proximity fallback.
//! Exact ordinary face attachments. Missing identities never invoke a proximity fallback; the one
//! fallback is an extrude cap whose sketch loop changed (`Document::anchored_face_indices`).
use crate::config::ConfigFaceTarget;
use crate::{DocError, Document, EmbossFace, FeatureKind, Model, ModelBody, ModelState, PlaneRef, Result};
use serde::{Deserialize, Serialize};
@@ -104,22 +105,38 @@ impl Document {
state: &ModelState,
framed: &ModelState,
) -> Result<Plane> {
fn replace(doc: &Document, plane: &mut PlaneRef, author: u64, state: &ModelState, depth: usize) -> Result<()> {
self.resolve_attached_plane_noted(vals, plane, author, state, framed).map(|(plane, _)| plane)
}
/// [`Document::resolve_attached_plane`] with the note of an attachment found by fallback
/// ([`Document::anchored_face_indices`]).
pub fn resolve_attached_plane_noted(
&self,
vals: &std::collections::BTreeMap<String, crate::expr::Value>,
plane: &PlaneRef,
author: u64,
state: &ModelState,
framed: &ModelState,
) -> Result<(Plane, Option<String>)> {
fn replace(doc: &Document, plane: &mut PlaneRef, author: u64, state: &ModelState, depth: usize) -> Result<Option<String>> {
if depth > 64 {
return Err(DocError::Invalid("attached plane nesting exceeds budget".into()));
}
match plane {
PlaneRef::Face { anchor: Some(anchor), .. } => *plane = PlaneRef::Custom { plane: doc.resolve_face_anchor(anchor, author, state)? },
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } | PlaneRef::Flipped { base, .. } => {
replace(doc, base, author, state, depth + 1)?
PlaneRef::Face { anchor: Some(anchor), .. } => {
let (resolved, note) = doc.resolve_face_anchor_noted(anchor, author, state)?;
*plane = PlaneRef::Custom { plane: resolved };
Ok(note)
}
_ => {}
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } | PlaneRef::Flipped { base, .. } => {
replace(doc, base, author, state, depth + 1)
}
_ => Ok(None),
}
Ok(())
}
let mut resolved = plane.clone();
replace(self, &mut resolved, author, state, 0)?;
self.resolve_plane_in(vals, &resolved, 0, Some(framed))
let note = replace(self, &mut resolved, author, state, 0)?;
Ok((self.resolve_plane_in(vals, &resolved, 0, Some(framed))?, note))
}
}
@@ -193,11 +210,92 @@ impl Document {
Ok(anchor)
}
/// The faces an attachment stands for, and a note when they were found by fallback.
///
/// An extrude's cap is certified by the loop of sketch curves around it, so editing that
/// sketch's outline (a curve that now cuts the profile, a side deleted and drawn again, a
/// notch trimmed into it) leaves no face with the exact certificate. The attachment then
/// takes the cap of the same (solid) extrude, at the same end, from the same sketch, on the
/// same body, whose loop shares the most of the stored curves, at least half of them (a tie
/// is not guessed). It never does while the exact cap is still on another body (profiles
/// reordered: the bodies swapped names). The note names what changed. Every other identity
/// stays exact.
pub(crate) fn anchored_face_indices(
&self,
state: &ModelState,
body: &ModelBody,
target: &ConfigFaceTarget,
) -> Result<(Vec<usize>, Option<String>)> {
let error = match self.configuration_face_indices(body, target) {
Ok(indices) => return Ok((indices, None)),
Err(error) => error,
};
let Some(crate::config::FaceCertificate::ExtrudeCap { sketch, high, outer, holes }) = &target.certificate else { return Err(error) };
let exact = |source: &Option<solidcraft_kernel::FaceSource>| {
matches!(source, Some(solidcraft_kernel::FaceSource::CertifiedExtrudeCap { producer, sketch: from, high: end, outer: around, holes: inside, .. })
if *producer == target.origin_feature && from == sketch && end == high && around == outer && inside == holes)
};
if state.bodies.iter().any(|other| other.body.face_sources().is_some_and(|sources| sources.iter().any(exact))) {
return Err(error);
}
// A thin wall's cap is signed by the curves the wall follows: a wall along other curves
// is another wall, whose cap never stands for this one.
let solid = self.feature(target.origin_feature).is_some_and(|feature| matches!(feature.kind, FeatureKind::Extrude { thin: None, .. }));
if !solid || self.certify_config_face(target).is_err() || body.body.is_mesh() || body.definition_name() != target.body {
return Err(error);
}
// The producer's caps at that end, grouped by the loop each was made from.
let mut caps: std::collections::BTreeMap<&Vec<String>, Vec<usize>> = std::collections::BTreeMap::new();
for (index, source) in body.body.face_sources().into_iter().flatten().enumerate() {
if let Some(solidcraft_kernel::FaceSource::CertifiedExtrudeCap { producer, sketch: from, high: end, outer: around, .. }) = source
&& *producer == target.origin_feature
&& from == sketch
&& end == high
{
caps.entry(around).or_default().push(index);
}
}
let stored: std::collections::BTreeSet<&str> = outer.iter().map(String::as_str).collect();
let shared = |around: &[String]| around.iter().filter(|id| stored.contains(id.as_str())).count();
let best = caps.keys().map(|around| shared(around)).max().unwrap_or(0);
if best == 0 || best.saturating_mul(2) < stored.len() {
return Err(error);
}
let mut chosen = caps.iter().filter(|(around, _)| shared(around) == best);
let (Some((around, indices)), None) = (chosen.next(), chosen.next()) else { return Err(error) };
let indices = self.certified_pieces(body, indices.clone(), target.origin_feature).map_err(|_| error)?;
let now: std::collections::BTreeSet<&str> = around.iter().map(String::as_str).collect();
let list = |ids: Vec<&str>| ids.iter().map(|id| format!("`{id}`")).collect::<Vec<_>>().join(", ");
let mut changes = Vec::new();
let gone: Vec<&str> = stored.difference(&now).copied().collect();
if !gone.is_empty() {
changes.push(format!("{} no longer around it", list(gone)));
}
let new: Vec<&str> = now.difference(&stored).copied().collect();
if !new.is_empty() {
changes.push(format!("{} new", list(new)));
}
let feature = self.feature(target.origin_feature).map_or_else(|| format!("F{}", target.origin_feature), |f| f.name.clone());
let note = format!(
"the face this sketch sits on, {}, changed when its sketch was edited ({}); using the {} cap of {feature} as it is now",
self.describe_face_target(target),
changes.join(", "),
if *high { "top" } else { "bottom" },
);
Ok((indices, Some(note)))
}
pub fn resolve_face_anchor(&self, anchor: &FaceAnchor, author: u64, state: &ModelState) -> Result<Plane> {
self.resolve_face_anchor_noted(anchor, author, state).map(|(plane, _)| plane)
}
/// [`Document::resolve_face_anchor`] with the note of an attachment found by fallback
/// ([`Document::anchored_face_indices`]).
pub fn resolve_face_anchor_noted(&self, anchor: &FaceAnchor, author: u64, state: &ModelState) -> Result<(Plane, Option<String>)> {
anchor.validate()?;
let body =
state.body(&anchor.target.body).ok_or_else(|| DocError::Unknown(format!("attached body `{}` no longer exists", anchor.target.body)))?;
let indices = self.configuration_face_indices(body, &anchor.target)?;
let (indices, note) = self.anchored_face_indices(state, body, &anchor.target)?;
let first = indices.first().copied().ok_or_else(|| DocError::Unknown("attached face no longer exists".into()))?;
let certificate = anchor.target.certificate.as_ref();
let plane = self
@@ -214,7 +312,9 @@ impl Document {
let canonical = map_plane(&anchor.source_to_author(self, author, body)?, &plane)?;
let origin = canonical.origin + canonical.x * anchor.coordinates[0] + canonical.y * anchor.coordinates[1];
let x = canonical.x * anchor.x_axis[0] + canonical.y * anchor.x_axis[1];
Plane::new(origin, x, canonical.normal().cross(x)).ok_or_else(|| DocError::Invalid("attached sketch basis is degenerate".into()))
let plane =
Plane::new(origin, x, canonical.normal().cross(x)).ok_or_else(|| DocError::Invalid("attached sketch basis is degenerate".into()))?;
Ok((plane, note))
}
pub fn sketch_face_attachment(&self, feature: u64, model: &Model) -> FaceAttachmentStatus {
+20 -4
View File
@@ -159,10 +159,16 @@ pub(super) fn fixtures() -> Vec<(&'static str, Value)> {
"orientation": "parallel", "twist": "surface_sweep.twist", "scale": "surface_sweep.scale"}),
),
("surface_loft", json!({"type": "surface_loft", "sections": []})),
("surface_blend", json!({"type": "surface_blend", "from": {"body": "B", "edges": [o]}, "to": {"body": "C", "edges": [o]}, "align": [o, o]})),
(
"surface_blend",
json!({"type": "surface_blend", "from": {"body": "B", "edges": [o]}, "to": {"body": "C", "edges": [o]}, "align": [o, o], "seam": [o, o]}),
),
("surface_offset", json!({"type": "surface_offset", "faces": [o], "distance": "soff.distance", "tolerance": "soff.tolerance"})),
// The Class-A surface family (surface.blend/boundary/match/fair).
("surface_blend", json!({"type": "surface_blend", "from": {"body": "B", "edges": [o]}, "to": {"body": "C", "edges": [o]}, "align": [o, o]})),
(
"surface_blend",
json!({"type": "surface_blend", "from": {"body": "B", "edges": [o]}, "to": {"body": "C", "edges": [o]}, "align": [o, o], "seam": [o, o]}),
),
("surface_boundary", json!({"type": "surface_boundary", "sketch": 1, "curves": ["l1", "l2"]})),
(
"surface_network",
@@ -188,6 +194,15 @@ pub(super) fn fixtures() -> Vec<(&'static str, Value)> {
"direction": [0, 0, 1], "face": [1, 0, 0]}),
),
("surface_trim", json!({"type": "surface_trim", "body": "B", "plane": plane("trim"), "keep": o})),
(
"surface_trim_curves",
json!({"type": "surface_trim", "body": "B", "plane": plane("trim"), "keep": o, "keep_more": [o],
"curves": {"sketch": 1, "curves": ["l1"], "direction": [0, 0, 1]}}),
),
(
"surface_trim_mutual",
json!({"type": "surface_trim", "body": "B", "plane": plane("trim"), "keep": o, "with": "B", "keep_with": [o, o], "stitch": true, "extend": true}),
),
("move", json!({"type": "move", "bodies": ["B"], "translate": ["move.x", "move.y", "move.z"], "angle": "move.angle"})),
("import", json!({"type": "import", "file": "a.step", "step": ""})),
("mesh_convert", json!({"type": "mesh_convert", "body": "B", "tolerance": "mesh_convert.tolerance"})),
@@ -211,10 +226,11 @@ pub(super) fn with_refs(name: &str, mut v: Value) -> Value {
"combine" => json!({"target": "B", "tools": ["B"]}),
"pattern_rectangular" | "pattern_circular" | "pattern_path" | "mirror" => json!({"bodies": ["B"]}),
"hole_simple" | "hole_drilled" | "hole_counterbore" | "hole_countersink" => json!({"points": {"sketch": 3, "ids": ["p"]}}),
"loft" => json!({"sections": [{"sketch": 4, "profiles": {"type": "all"}}, {"sketch": 5, "profiles": {"type": "all"}}], "targets": ["B"]}),
"loft" => json!({"sections": [{"sketch": 4, "profiles": {"type": "all"}}, {"sketch": 5, "profiles": {"type": "all"}}], "targets": ["B"],
"rails": [{"sketch": 6, "curves": ["l1"]}]}),
"split" | "surface_trim" => json!({"tool": tool}),
"split_line" => json!({"sketch": 6, "line": "l1"}),
"surface_loft" => json!({"sections": [{"sketch": 4}, {"sketch": 5}]}),
"surface_loft" => json!({"sections": [{"sketch": 4}, {"sketch": 5}], "rails": [{"sketch": 6, "curves": ["l1"]}]}),
"split_face" => json!({"body": "B", "tool": tool, "sketch": 6}),
"emboss" => json!({"face": {"body": "B", "at": o}, "targets": ["B"]}),
"sheet_fold" => json!({"sketch": 7, "body": "B"}),
@@ -375,6 +375,16 @@ surface_trim
Angle = trim.angle (Angle)
Offset = trim.offset (Length)
expressions: trim.angle, trim.offset
surface_trim_curves
inputs:
Angle = trim.angle (Angle)
Offset = trim.offset (Length)
expressions: trim.angle, trim.offset
surface_trim_mutual
inputs:
Angle = trim.angle (Angle)
Offset = trim.offset (Length)
expressions: trim.angle, trim.offset
move
inputs:
X distance = move.x (Length)
@@ -150,12 +150,12 @@ surface_sweep
surface_sweep_options
surface_loft
surface_blend
moves: align[0][0], align[0][1], align[0][2], align[1][0], align[1][1], align[1][2], from.edges[0][0], from.edges[0][1], from.edges[0][2], to.edges[0][0], to.edges[0][1], to.edges[0][2]
moves: align[0][0], align[0][1], align[0][2], align[1][0], align[1][1], align[1][2], from.edges[0][0], from.edges[0][1], from.edges[0][2], seam[0][0], seam[0][1], seam[0][2], seam[1][0], seam[1][1], seam[1][2], to.edges[0][0], to.edges[0][1], to.edges[0][2]
surface_offset
moves: faces[0][0], faces[0][1], faces[0][2]
face points: [0, 0, 0]
surface_blend
moves: align[0][0], align[0][1], align[0][2], align[1][0], align[1][1], align[1][2], from.edges[0][0], from.edges[0][1], from.edges[0][2], to.edges[0][0], to.edges[0][1], to.edges[0][2]
moves: align[0][0], align[0][1], align[0][2], align[1][0], align[1][1], align[1][2], from.edges[0][0], from.edges[0][1], from.edges[0][2], seam[0][0], seam[0][1], seam[0][2], seam[1][0], seam[1][1], seam[1][2], to.edges[0][0], to.edges[0][1], to.edges[0][2]
surface_boundary
surface_network
moves: u[1].edges[0][0], u[1].edges[0][1], u[1].edges[0][2], v[1].point[0], v[1].point[1], v[1].point[2]
@@ -183,6 +183,10 @@ surface_ruled
face points: [1, 0, 0]
surface_trim
moves: keep[0], keep[1], keep[2], 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]
surface_trim_curves
moves: curves.direction[0], curves.direction[2], keep[0], keep[1], keep[2], keep_more[0][0], keep_more[0][1], keep_more[0][2], plane.axis_dir[0], plane.axis_dir[1], plane.axis_origin[0], plane.axis_origin[1], plane.axis_origin[2]
surface_trim_mutual
moves: keep[0], keep[1], keep[2], keep_with[0][0], keep_with[0][1], keep_with[0][2], keep_with[1][0], keep_with[1][1], keep_with[1][2], plane.axis_dir[0], plane.axis_dir[1], plane.axis_origin[0], plane.axis_origin[1], plane.axis_origin[2]
move
moves: rotate_axis[1], rotate_axis[2]
import
@@ -60,8 +60,8 @@ hole_countersink
remap_refs: points.sketch
thread
loft
sketches: [4, 5]
remap_refs: sections[0].sketch, sections[1].sketch
sketches: [4, 5, 6]
remap_refs: rails[0].sketch, sections[0].sketch, sections[1].sketch
remap_bodies: targets[0]
sweep
sketches: [1, 2]
@@ -171,8 +171,8 @@ surface_sweep_options
sketches: [1, 2]
remap_refs: path_sketch, sketch
surface_loft
sketches: [4, 5]
remap_refs: sections[0].sketch, sections[1].sketch
sketches: [4, 5, 6]
remap_refs: rails[0].sketch, sections[0].sketch, sections[1].sketch
surface_blend
remap_bodies: from.body
surface_offset
@@ -208,6 +208,13 @@ surface_ruled
surface_trim
remap_refs: plane.base.base.name
remap_bodies: body, tool.body
surface_trim_curves
sketches: [1]
remap_refs: curves.sketch, plane.base.base.name
remap_bodies: body
surface_trim_mutual
remap_refs: plane.base.base.name
remap_bodies: body, with
move
remap_bodies: bodies[0]
import
+1 -1
View File
@@ -16,7 +16,7 @@
//! - 5: joins, cuts and intersects change only their own component's bodies unless bodies are
//! named as `participants`; older features in designs with components get `participants:
//! ["*"]` (every body), which is what they did, so those designs keep their shape.
//! - 4: joint origins mate by default (B's z against A's, as Fusion does); `flip` now means
//! - 4: joint origins mate by default (B's z against A's); `flip` now means
//! "don't mate". Older joints keep their placement: their `flip` is inverted.
//! - 6: XZ origin planes use sketch y = -Z and normal +Y. Older XZ references become
//! explicit planes retaining their previous axes, including offset-plane expressions.
@@ -0,0 +1,5 @@
//! A component can carry its own part properties: `metadata` (`part_number`, `description`,
//! `revision`… and other tools' reverse-DNS keys, under the rules of the design's own `metadata`)
//! and `properties` (free text, by name), which every occurrence shares and the bill of materials
//! (`assembly.bom`) reads. Older designs need no transformation: a component without them has none.
//! The last release asks for an update instead of dropping them on save.
@@ -0,0 +1,4 @@
//! A joint keeps the expressions its offset, angle and values were typed as (`exprs`) and works
//! them out again whenever the design changes, so it follows the parameters they name. Older
//! designs need no transformation: a joint without `exprs` keeps its numbers as before. The last
//! release refuses a design saved with this (the newer format number).
@@ -0,0 +1,4 @@
//! A point joint origin (`Snap::Point`) can save `x`, the direction its frame's x takes (laid
//! square to z). Older designs need no transformation: a point origin without `x` takes its x
//! from z alone, as before; the last release asks for an update instead of dropping the `x` and
//! turning the part.
@@ -0,0 +1,6 @@
//! A joint can be a relation that ties only some directions or points of its two parts:
//! `parallel`, `perpendicular`, `angle` (its `angle` is then the tilt between the two z axes),
//! `point_on_plane`, `point_on_line` and `line_on_plane`, each with the values its free motion
//! needs. Several relations between one pair of parts close loops the solver meets together.
//! Older designs need no transformation; the last release asks for an update instead of meeting
//! a joint type it can't read.
@@ -0,0 +1,5 @@
//! A sketch can hold a `line_circle_distance` dimension (#1803): the distance from a line `l` to
//! the edge of a circle or arc `c`, saved with its `value` and, when the line cuts the circle,
//! `inside` (the value is then how far the edge reaches past the line). Older designs need no
//! transformation: they have no such dimension; 0.16.x (which reads up to 27) asks for an update
//! instead of meeting a constraint type it doesn't know.
@@ -0,0 +1,3 @@
//! Lofts (`Loft`, `SurfaceLoft`) take guide rails: `rails` (one or two `{sketch, curves}` chains,
//! as a sweep's) and `rail_fit` (`scale` or `stretch`). Both default to none and `scale`, so older
//! designs read unchanged.
@@ -0,0 +1,5 @@
//! A sketch point can be placed by parameters: `exprs` (`x` and/or `y`, expressions over the
//! design's parameters, in the design's units when a number in them has no unit) on a point that
//! is `fixed` where they come to, and moved there again before each solve when the parameters
//! change. Older designs need no transformation: their points have no `exprs`. The last release
//! asks for an update instead of opening a design with this format number.
@@ -0,0 +1,4 @@
//! A `SurfaceBlend` (Blend Surface, #764) may save `seam`: a point on each of its two edge chains
//! where their stations start. Older designs need no transformation: a blend without `seam` pairs
//! its chains by itself; the last release asks for an update instead of meeting a seam it can't
//! read.
@@ -0,0 +1,6 @@
//! A `SurfaceTrim` can keep several regions (`keep_more`: more points; with them, the pieces that
//! hang together once cut that `keep` and these points pick are kept instead of the side of
//! `keep`), cut with sketch curves swept onto the surface (`curves`: `sketch`, optional `curves`
//! ids and `direction`), or trim another surface and be trimmed by it (`with`, `keep_with` points,
//! optional `stitch` and `extend`). Older designs need no transformation: a trim without them
//! saves as before; 0.14.x asks for an update instead of ignoring them and trimming by the plane.
@@ -0,0 +1,49 @@
//! A two-sided extrude (`distance2`) follows its `direction` (#2700): with `direction: negative`
//! the first distance goes against the sketch normal and `distance2` along it, so either side can
//! be the through-all or To Object one. Older builds ignored `direction` when an extrude had a
//! second distance (the first always went along the normal), so this step makes the two-sided
//! `negative` extrudes of older designs `positive`, which keeps their shape. 0.15.x asks for an
//! update instead of opening a design with a reversed two-sided extrude.
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;
}
if let Some(extent) = feature.get_mut("extent").and_then(Value::as_object_mut)
&& extent.get("distance2").is_some_and(|d| !d.is_null())
&& extent.get("direction").and_then(Value::as_str) == Some("negative")
{
extent.insert("direction".into(), Value::from("positive"));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use serde_json::json;
#[test]
fn older_reversed_two_sided_extrudes_keep_their_shape() {
let mut v = json!({"features": [
{"type": "extrude", "extent": {"distance": "10 mm", "direction": "negative", "distance2": "5 mm"}},
{"type": "surface_extrude", "extent": {"distance": "10 mm", "direction": "negative", "distance2": "5 mm"}},
{"type": "extrude", "extent": {"distance": "10 mm", "direction": "negative"}},
{"type": "extrude", "extent": {"distance": "10 mm", "direction": "symmetric", "distance2": "5 mm"}},
{"type": "revolve", "extent": {"direction": "negative", "distance2": "5 mm"}},
{"type": "sketch"}
]});
super::step(&mut v).unwrap();
let dirs: Vec<Option<&str>> = v["features"].as_array().unwrap().iter().map(|f| f["extent"]["direction"].as_str()).collect();
assert_eq!(dirs, [Some("positive"), Some("positive"), Some("negative"), Some("symmetric"), Some("negative"), None]);
// A design with no features is left alone.
let mut empty = json!({"name": "Untitled"});
super::step(&mut empty).unwrap();
assert_eq!(empty, json!({"name": "Untitled"}));
}
}
@@ -0,0 +1,4 @@
//! A construction reference to a body vertex saves the names of the edges that meet there
//! (`edges` beside `at`), so a plane, axis or point on a vertex follows it when an earlier change
//! moves it. Older designs need no transformation: a vertex reference without `edges` is the
//! vertex nearest its point, as before. 0.15.x asks for an update instead of reading `edges`.
+367 -29
View File
@@ -1,11 +1,13 @@
//! Joints between occurrences: joint origins (snap frames on component geometry, re-found on
//! every solve so they follow edits), joints (rigid, revolute, slider, cylindrical, pin-slot,
//! planar, ball) with their motion values and limits, motion links (gears, racks and screws are
//! planar, ball, and relations that tie only part of a placement, `joints_relations.rs`) with
//! their motion values and limits, motion links (gears, racks and screws are
//! links whose ratio comes from radii or a pitch; a cam's follower follows a table worked out from
//! its profile, `joints_cam.rs`), and the solver that places occurrences from the joint graph.
//!
//! A joint makes B's joint frame coincide with A's frame moved by the joint's motion:
//! `T_B · F_b = T_A · F_a · M(q) · Flip`, so `T_B = T_A · F_a · M(q) · Flip · F_b⁻¹`, where `T`
//! `T_B · F_b = T_A · F_a · M(q) · Flip · Tail(q)`, so `T_B = T_A · F_a · M(q) · Flip · Tail(q) · F_b⁻¹`
//! (`Tail` is a relation's tilt and turns after the mate; the identity for the others), where `T`
//! are occurrence world transforms, `F` the joint frames in each component's own frame, and `q`
//! the joint's values (angles in radians, distances in mm). The solver walks the graph from
//! grounded occurrences (and the root, which never moves). Joints that close a loop (a four-bar
@@ -15,7 +17,7 @@
use std::collections::{BTreeMap, BTreeSet, VecDeque};
use serde::{Deserialize, Serialize};
use solidcraft_geom::Vec3;
use solidcraft_geom::{Aabb3, Vec3};
use solidcraft_sketch::linalg;
@@ -74,11 +76,14 @@ pub enum Snap {
#[serde(default, skip_serializing_if = "Option::is_none")]
corner: Option<String>,
},
/// A point, z along `z` (default +Z).
/// A point, z along `z` (default +Z) and x toward `x` laid square to z (default: one chosen
/// from z alone).
Point {
point: Vec3,
#[serde(default, skip_serializing_if = "Option::is_none")]
z: Option<Vec3>,
#[serde(default, skip_serializing_if = "Option::is_none")]
x: Option<Vec3>,
},
}
@@ -173,7 +178,7 @@ impl Snap {
match self {
Snap::Frame { origin, z, x } => origin.is_finite() && z.is_finite() && x.is_none_or(|x| x.is_finite()),
Snap::FaceCenter { pick, x, .. } | Snap::CircleCenter { pick, x, .. } => pick.is_finite() && x_ok(x),
Snap::Point { point, z } => point.is_finite() && z.is_none_or(|z| z.is_finite()),
Snap::Point { point, z, x } => point.is_finite() && z.is_none_or(|z| z.is_finite()) && x.is_none_or(|x| x.is_finite()),
Snap::Edge { pick, .. } => pick.is_finite(),
}
}
@@ -203,6 +208,20 @@ pub enum JointKind {
PinSlot,
Planar,
Ball,
/// B's z stays parallel to A's (against it unless flipped); B moves and turns about z freely.
Parallel,
/// B's z stays square to A's; B moves freely and turns about either z.
Perpendicular,
/// B's z stays at the joint's `angle` from mated (about their common x); B moves freely and
/// turns about either z.
Angle,
/// B's origin stays on A's xy plane (`offset` off it); B slides on it and turns any way.
PointOnPlane,
/// B's origin stays on A's z axis; B slides along it and turns any way.
PointOnLine,
/// B's z axis lies in A's xy plane (`offset` off it: a cylinder's radius makes it tangent);
/// B slides on the plane, turns about A's z and about its own axis.
LineOnPlane,
}
impl JointKind {
@@ -215,6 +234,12 @@ impl JointKind {
"pinslot" => JointKind::PinSlot,
"planar" => JointKind::Planar,
"ball" => JointKind::Ball,
"parallel" => JointKind::Parallel,
"perpendicular" | "square" => JointKind::Perpendicular,
"angle" => JointKind::Angle,
"pointonplane" | "pointonface" => JointKind::PointOnPlane,
"pointonline" | "pointonaxis" => JointKind::PointOnLine,
"lineonplane" | "lineonface" => JointKind::LineOnPlane,
_ => return None,
})
}
@@ -228,10 +253,29 @@ impl JointKind {
JointKind::PinSlot => &["angle", "slide"],
JointKind::Planar => &["x", "y", "angle"],
JointKind::Ball => &["yaw", "pitch", "roll"],
JointKind::Parallel => &["x", "y", "z", "angle"],
JointKind::Perpendicular | JointKind::Angle => &["x", "y", "z", "turn", "spin"],
JointKind::PointOnPlane => &["x", "y", "yaw", "pitch", "roll"],
JointKind::PointOnLine => &["slide", "yaw", "pitch", "roll"],
JointKind::LineOnPlane => &["x", "y", "turn", "spin"],
}
}
pub fn is_angle(self, i: usize) -> bool {
matches!(self.dofs().get(i), Some(&("angle" | "yaw" | "pitch" | "roll")))
matches!(self.dofs().get(i), Some(&("angle" | "yaw" | "pitch" | "roll" | "turn" | "spin")))
}
/// A relation that only ties some directions or points (parallel, on a plane…): made where
/// the parts are, its values start as the nearest that meet it, so B moves no more than it
/// must.
pub fn is_relation(self) -> bool {
matches!(
self,
JointKind::Parallel
| JointKind::Perpendicular
| JointKind::Angle
| JointKind::PointOnPlane
| JointKind::PointOnLine
| JointKind::LineOnPlane
)
}
}
@@ -260,6 +304,92 @@ pub struct Joint {
pub angle: f64,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub suppressed: bool,
/// The expressions the offset, the angle and the values were typed as: they are worked out
/// again whenever the design changes, so a joint follows the parameters it is defined by.
#[serde(default, skip_serializing_if = "JointExprs::is_empty")]
pub exprs: JointExprs,
}
/// Expressions a joint's numbers follow (over the design's parameters): the offset (a length),
/// the angle and each value (angles or lengths as the joint's values are). A number typed in
/// place of one drops it.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct JointExprs {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub offset: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub angle: Option<String>,
/// By value index, as in `Joint::values`.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub values: Vec<Option<String>>,
}
impl JointExprs {
pub fn is_empty(&self) -> bool {
self.offset.is_none() && self.angle.is_none() && self.values.iter().all(Option::is_none)
}
/// The expression value `i` follows.
pub fn value(&self, i: usize) -> Option<&str> {
self.values.get(i).and_then(Option::as_deref)
}
/// Value `i` follows `e` (or nothing).
pub fn set_value(&mut self, i: usize, e: Option<String>) {
if e.is_some() && self.values.len() <= i {
self.values.resize(i + 1, None);
}
if let Some(slot) = self.values.get_mut(i) {
*slot = e;
}
while self.values.last().is_some_and(Option::is_none) {
self.values.pop();
}
}
}
/// Largest offset or value an expression may give a joint (mm, or radians for an angle).
const MAX_EXPR_VALUE: f64 = 1e9;
/// A joint's offset, angle and values worked out from its expressions (those without one keep
/// their numbers), or why one can't be: an unknown name, a wrong unit, or a number too large.
pub fn expressed(doc: &Document, j: &Joint) -> Result<(f64, f64, Vec<f64>)> {
let eval = |e: &str, kind: crate::expr::Kind, what: &str| -> Result<f64> {
let v = doc.eval(e, kind).map_err(|err| DocError::Invalid(format!("joint {}: {what} `{e}`: {err}", j.name)))?;
if v.is_finite() && v.abs() < MAX_EXPR_VALUE { Ok(v) } else { Err(DocError::Invalid(format!("joint {}: {what} `{e}` gives {v}", j.name))) }
};
let x = &j.exprs;
let offset = match &x.offset {
Some(e) => eval(e, crate::expr::Kind::Length, "offset")?,
None => j.offset,
};
let angle = match &x.angle {
Some(e) => eval(e, crate::expr::Kind::Angle, "angle")?,
None => j.angle,
};
let mut values = j.values.clone();
for (i, e) in x.values.iter().enumerate().take(j.kind.dofs().len()) {
let Some(e) = e else { continue };
let kind = if j.kind.is_angle(i) { crate::expr::Kind::Angle } else { crate::expr::Kind::Length };
let name = j.kind.dofs().get(i).copied().unwrap_or("value");
let v = eval(e, kind, name)?;
if values.len() <= i {
values.resize(i + 1, 0.0);
}
if let Some(slot) = values.get_mut(i) {
*slot = v;
}
}
Ok((offset, angle, values))
}
/// Values the loop solver leaves alone: those a motion link sets and those an expression does.
fn set_values(asm: &Assembly) -> BTreeSet<(u64, usize)> {
let mut set: BTreeSet<(u64, usize)> = asm.links.iter().map(|l| (l.b, l.ib)).collect();
for j in &asm.joints {
set.extend(j.exprs.values.iter().enumerate().filter(|(_, e)| e.is_some()).map(|(i, _)| (j.id, i)));
}
set
}
/// Value of joint `b` (index `ib`) = ratio · value of joint `a` (index `ia`) + offset, or for a
@@ -446,6 +576,49 @@ pub struct Assembly {
}
impl Assembly {
/// Every expression the joints and motion links keep, with the joint (or the driven joint of
/// a link) and what it sets: `offset`, `angle`, a value's name, or a coupling's length.
pub fn exprs(&self) -> Vec<(u64, String, &str)> {
let mut out = Vec::new();
for j in &self.joints {
let x = &j.exprs;
out.extend(x.offset.as_deref().map(|e| (j.id, "offset".to_string(), e)));
out.extend(x.angle.as_deref().map(|e| (j.id, "angle".to_string(), e)));
for (i, e) in x.values.iter().enumerate() {
if let Some(e) = e {
out.push((j.id, j.kind.dofs().get(i).copied().unwrap_or("value").to_string(), e.as_str()));
}
}
}
for l in &self.links {
for s in l.coupling.as_ref().map(Coupling::sizes).unwrap_or_default() {
if let Size::Expr(e) = s {
out.push((l.b, "coupling".to_string(), e.as_str()));
}
}
}
out
}
/// The expressions of `exprs`, to rewrite (a renamed parameter).
pub fn exprs_mut(&mut self) -> Vec<&mut String> {
let mut out = Vec::new();
for j in &mut self.joints {
let x = &mut j.exprs;
out.extend(x.offset.as_mut());
out.extend(x.angle.as_mut());
out.extend(x.values.iter_mut().flatten());
}
for l in &mut self.links {
for s in l.coupling.as_mut().map(Coupling::sizes_mut).unwrap_or_default() {
if let Size::Expr(e) = s {
out.push(e);
}
}
}
out
}
pub fn is_empty(&self) -> bool {
self.joints.is_empty()
&& self.origins.is_empty()
@@ -470,6 +643,10 @@ fn rot_z(a: f64) -> Mat {
let (s, c) = a.sin_cos();
[[c, s, 0.0, 0.0], [-s, c, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]]
}
fn rot_x(a: f64) -> Mat {
let (s, c) = a.sin_cos();
[[1.0, 0.0, 0.0, 0.0], [0.0, c, s, 0.0], [0.0, -s, c, 0.0], [0.0, 0.0, 0.0, 1.0]]
}
fn rot_y(a: f64) -> Mat {
let (s, c) = a.sin_cos();
[[c, 0.0, -s, 0.0], [0.0, 1.0, 0.0, 0.0], [s, 0.0, c, 0.0], [0.0, 0.0, 0.0, 1.0]]
@@ -491,9 +668,40 @@ pub fn motion(kind: JointKind, q: &[f64]) -> Mat {
JointKind::PinSlot => mat_mul(&shift(v(1), 0.0, 0.0), &rot_z(v(0))),
JointKind::Planar => mat_mul(&shift(v(0), v(1), 0.0), &rot_z(v(2))),
JointKind::Ball => mat_mul(&mat_mul(&rot_z(v(0)), &rot_y(v(1))), &rot_z(v(2))),
JointKind::Parallel => mat_mul(&shift(v(0), v(1), v(2)), &rot_z(v(3))),
JointKind::Perpendicular | JointKind::Angle => mat_mul(&shift(v(0), v(1), v(2)), &rot_z(v(3))),
// Their turn is about B's origin, after the offset (`tail`).
JointKind::PointOnPlane => shift(v(0), v(1), 0.0),
JointKind::PointOnLine => shift(0.0, 0.0, v(0)),
JointKind::LineOnPlane => mat_mul(&shift(v(0), v(1), 0.0), &rot_z(v(2))),
}
}
fn turn_zyx(yaw: f64, pitch: f64, roll: f64) -> Mat {
mat_mul(&mat_mul(&rot_z(yaw), &rot_y(pitch)), &rot_x(roll))
}
/// What follows the mate (`flip_of`) in a joint's link: for a relation that tilts B's z, the tilt
/// and B's turn about its own z (`spin`); for a point relation, B's turn about its origin (yaw,
/// pitch and roll about z, y and x: free of gimbal lock at the start, all 0); nothing for the
/// other joints.
fn tail(j: &Joint, q: &[f64]) -> Mat {
let v = |i: usize| q.get(i).copied().unwrap_or(0.0);
match j.kind {
JointKind::Perpendicular => mat_mul(&rot_x(std::f64::consts::FRAC_PI_2), &rot_z(v(4))),
JointKind::Angle => mat_mul(&rot_x(j.angle), &rot_z(v(4))),
JointKind::LineOnPlane => mat_mul(&rot_y(std::f64::consts::FRAC_PI_2), &rot_z(v(3))),
JointKind::PointOnPlane => turn_zyx(v(2), v(3), v(4)),
JointKind::PointOnLine => turn_zyx(v(1), v(2), v(3)),
_ => IDENTITY,
}
}
/// B's joint frame in A's for the joint's values: `M(q) · Flip · tail`.
fn link_of(j: &Joint, q: &[f64]) -> Mat {
mat_mul(&mat_mul(&motion(j.kind, q), &flip_of(j)), &tail(j, q))
}
/// Bodies of a component in the model (its own frame).
fn component_bodies<'a>(doc: &Document, st: &'a ModelState, component: u64) -> Vec<&'a crate::ModelBody> {
st.bodies.iter().filter(|b| doc.model_body_component(b) == component).collect()
@@ -544,7 +752,7 @@ pub fn resolve_origin(doc: &Document, st: &ModelState, o: &JointOrigin) -> Resul
let bad = || DocError::Invalid("joint origin: degenerate frame".into());
match &o.snap {
Snap::Frame { origin, z, x } => frame(*origin, *z, *x).ok_or_else(bad),
Snap::Point { point, z } => frame(*point, z.unwrap_or(Vec3::Z), None).ok_or_else(bad),
Snap::Point { point, z, x } => frame(*point, z.unwrap_or(Vec3::Z), *x).ok_or_else(bad),
Snap::FaceCenter { pick, face, x } => {
// A named planar face gives its own centre and normal; another face (or none) the
// planar face nearest the point.
@@ -641,7 +849,10 @@ pub struct Solution {
pub conflicts: Vec<(u64, String, String)>,
/// Joints that could not be applied (bad origin…): (joint id, name, why).
pub errors: Vec<(u64, String, String)>,
/// Remaining degrees of freedom per occurrence (6 = free, 0 = fixed).
/// Remaining degrees of freedom per occurrence (6 = free, 0 = fixed): how many independent
/// values still move it, from the fixed occurrences, with loops kept closed and a value a
/// motion link sets moving with the values its link chain starts from (a driven gear has its
/// driving gear's one, a screw's travel its turn's).
pub dof: BTreeMap<u64, usize>,
/// Occurrences fixed in place (grounded, or the root's).
pub fixed: Vec<u64>,
@@ -725,14 +936,16 @@ pub fn joint_values(asm: &Assembly) -> BTreeMap<u64, Vec<f64>> {
/// The turn and offset from A's joint frame to B's: mated (B's z against A's, as faces meet)
/// unless flipped.
fn flip_of(j: &Joint) -> Mat {
let mut m = rot_z(j.angle);
// An angle relation's `angle` is its tilt (`tail`); a perpendicular one has none.
let turn = if matches!(j.kind, JointKind::Angle | JointKind::Perpendicular) { 0.0 } else { j.angle };
let mut m = rot_z(turn);
m[3][2] = j.offset;
if !j.flip { mat_mul(&m, &[[1.0, 0.0, 0.0, 0.0], [0.0, -1.0, 0.0, 0.0], [0.0, 0.0, -1.0, 0.0], [0.0, 0.0, 0.0, 1.0]]) } else { m }
}
/// B's world transform from A's (or A's from B's, going the other way) for the joint's values.
fn through(j: &Joint, (fa, fb): &(Mat, Mat), q: &[f64], from_a: bool, t: &Mat) -> Option<Mat> {
let link = mat_mul(&mat_mul(&motion(j.kind, q), &flip_of(j)), &mat_inverse(fb)?);
let link = mat_mul(&link_of(j, q), &mat_inverse(fb)?);
if from_a {
Some(mat_mul(&mat_mul(t, fa), &link))
} else {
@@ -799,6 +1012,10 @@ impl Tree<'_> {
/// on at most: larger ones are only checked.
const MAX_LOOP_VALUES: usize = 64;
const MAX_LOOPS: usize = 100;
/// Loop-closing joints one solve takes on at most, over all its groups: a part placed by several
/// relations closes a loop with each one after the first, so a panel of parts has many small
/// groups.
const MAX_CLOSING: usize = 5_000;
/// Residual evaluations one solve may spend on all its loops.
const MAX_LOOP_WORK: usize = 200_000;
@@ -832,6 +1049,46 @@ fn link_chains(asm: &Assembly, tree: &Tree, around: &BTreeSet<u64>, linked: &BTr
(feeds, starts)
}
/// The values that move each value a motion link sets: the values its link chain starts from,
/// which no link sets (none for a chain that comes back round on itself, which only a hand-edited
/// design has). A driven gear's turn is its driving gear's: one degree of freedom, not two.
fn link_sources(asm: &Assembly) -> BTreeMap<(u64, usize), BTreeSet<(u64, usize)>> {
let mut into: BTreeMap<(u64, usize), Vec<(u64, usize)>> = BTreeMap::new();
for l in &asm.links {
into.entry((l.b, l.ib)).or_default().push((l.a, l.ia));
}
let mut sources = BTreeMap::new();
if into.is_empty() {
return sources;
}
// A link from a value its joint doesn't have (a hand-edited design) moves nothing.
let real: BTreeSet<(u64, usize)> = asm.joints.iter().flat_map(|j| (0..j.kind.dofs().len()).map(|i| (j.id, i))).collect();
for &v in into.keys() {
// Back along the links, as far as a link chain carries a value (`MAX_LINK_PASSES`).
let (mut from, mut seen) = (BTreeSet::new(), BTreeSet::from([v]));
let mut frontier = vec![v];
for _ in 0..MAX_LINK_PASSES {
let mut next = Vec::new();
for w in frontier.iter().filter_map(|w| into.get(w)).flatten() {
if !seen.insert(*w) {
continue;
}
if into.contains_key(w) {
next.push(*w);
} else if real.contains(w) {
from.insert(*w);
}
}
if next.is_empty() {
break;
}
frontier = next;
}
sources.insert(v, from);
}
sources
}
/// The held values among `touches` that move: (joint, index, from, to), `from` within the
/// joint's limits. A suppressed joint still sets values through its motion links.
fn held_moves(asm: &Assembly, held: &Held, touches: &BTreeSet<(u64, usize)>, vals: &Values) -> Vec<(u64, usize, f64, f64)> {
@@ -855,13 +1112,13 @@ fn held_moves(asm: &Assembly, held: &Held, touches: &BTreeSet<(u64, usize)>, val
/// moves; a group that can't close without moving is left to the conflict check).
fn close_loops(tree: &Tree, closing: &[u64], asm: &Assembly, held: &Held, vals: &mut Values) -> (Values, BTreeSet<u64>) {
let (mut changed, mut stuck) = (BTreeMap::new(), BTreeSet::new());
if closing.len() > MAX_LOOPS {
if closing.len() > MAX_CLOSING {
return (Values::new(), stuck);
}
let linked: BTreeSet<(u64, usize)> = asm.links.iter().map(|l| (l.b, l.ib)).collect();
let linked = set_values(asm);
let groups = loop_groups(tree, closing, asm, &linked);
let spent = std::cell::Cell::new(0usize);
for (group, around, touches) in &groups {
for (group, around, touches) in groups.iter().filter(|(c, _, _)| c.len() <= MAX_LOOPS) {
let moves = held_moves(asm, held, touches, vals);
match close_group(tree, group, around, &moves, asm, held, &linked, &spent, vals) {
Some(m) => changed.extend(m),
@@ -952,15 +1209,14 @@ fn limit_bounds(tree: &Tree, vars: &[(u64, usize)]) -> Vec<(f64, f64)> {
/// The loops' size: the diagonal of the box around their joints' A frames, 1 mm to 1 km. Points
/// that far apart weigh turns like moves.
fn loop_scale(tree: &Tree, around: &BTreeSet<u64>, vals: &Values) -> f64 {
let (mut lo, mut hi) = (Vec3::new(f64::MAX, f64::MAX, f64::MAX), Vec3::new(f64::MIN, f64::MIN, f64::MIN));
for id in around {
let (Some(j), Some((fa, _))) = (tree.joints.get(id), tree.frames.get(id)) else { continue };
let Some(t) = tree.world(j.a.occurrence, vals) else { continue };
let p = apply_point(&mat_mul(&t, fa), Vec3::ZERO);
lo = Vec3::new(lo.x.min(p.x), lo.y.min(p.y), lo.z.min(p.z));
hi = Vec3::new(hi.x.max(p.x), hi.y.max(p.y), hi.z.max(p.z));
}
if lo.x <= hi.x { (hi - lo).len().clamp(1.0, 1e6) } else { 1.0 }
let joints: Aabb3 = around
.iter()
.filter_map(|id| {
let (j, (fa, _)) = (tree.joints.get(id)?, tree.frames.get(id)?);
Some(apply_point(&mat_mul(&tree.world(j.a.occurrence, vals)?, fa), Vec3::ZERO))
})
.collect();
if joints.is_empty() { 1.0 } else { joints.diagonal().clamp(1.0, 1e6) }
}
/// How far each closing joint is from meeting: three points of B's joint frame, `scale` apart,
@@ -1050,11 +1306,12 @@ fn nearby_pose(tree: &Tree, groups: &[Group], asm: &Assembly, linked: &BTreeSet<
/// pose alone counts 0. The tree's count stands where a loop group is over the solver's caps or
/// the work runs out.
fn loop_dof(tree: &Tree, closing: &[u64], asm: &Assembly, vals: &Values, dof: &mut BTreeMap<u64, usize>) {
if closing.len() > MAX_LOOPS {
if closing.len() > MAX_CLOSING {
return;
}
let linked: BTreeSet<(u64, usize)> = asm.links.iter().map(|l| (l.b, l.ib)).collect();
let groups = loop_groups(tree, closing, asm, &linked);
let linked = set_values(asm);
let mut groups = loop_groups(tree, closing, asm, &linked);
groups.retain(|(c, _, _)| c.len() <= MAX_LOOPS);
// The joints loops depend on: those around them, those whose values they read, and those a
// motion link carries any of these values into.
let mut relevant: BTreeSet<u64> = groups.iter().flat_map(|(_, a, t)| a.iter().copied().chain(t.iter().map(|(j, _)| *j))).collect();
@@ -1463,10 +1720,28 @@ struct Built<'a> {
fn build<'a>(doc: &'a Document, st: &ModelState, vals: &Values, sol: &mut Solution) -> Built<'a> {
let asm = &doc.assembly;
let joints: Vec<&Joint> = asm.joints.iter().filter(|j| !j.suppressed).take(MAX_JOINTS).collect();
// Joint frames in component coordinates (errors leave the joint out).
// Joint frames in component coordinates (errors leave the joint out). An origin several joints
// share (a panel face every part sits on) is found once.
let mut frames: BTreeMap<u64, (Mat, Mat)> = BTreeMap::new();
let mut found: BTreeMap<(u64, String), std::result::Result<Mat, String>> = BTreeMap::new();
let mut resolve = |o: &JointOrigin| -> Result<Mat> {
let Ok(comp) = component_of(doc, o.occurrence) else { return resolve_origin(doc, st, o) };
found
.entry((comp, format!("{:?}", o.snap)))
.or_insert_with(|| resolve_origin(doc, st, o).map_err(|e| e.to_string()))
.clone()
.map_err(DocError::Invalid)
};
for j in &joints {
match (resolve_origin(doc, st, &j.a), resolve_origin(doc, st, &j.b)) {
// A joint whose expressions can't be worked out (a parameter gone) is left out rather
// than placed by numbers that no longer say what it was defined by.
if !j.exprs.is_empty()
&& let Err(e) = expressed(doc, j)
{
sol.errors.push((j.id, j.name.clone(), e.to_string()));
continue;
}
match (resolve(&j.a), resolve(&j.b)) {
(Ok(fa), Ok(fb)) => {
frames.insert(j.id, (fa, fb));
}
@@ -1495,6 +1770,14 @@ fn build<'a>(doc: &'a Document, st: &ModelState, vals: &Values, sol: &mut Soluti
Tree { joints: asm.joints.iter().map(|j| (j.id, j)).collect(), frames, fixed: placed.clone(), parent: BTreeMap::new(), order: Vec::new() };
let mut used: BTreeSet<u64> = BTreeSet::new();
let mut queue: VecDeque<u64> = placed.keys().copied().collect();
// An occurrence's degrees of freedom are the distinct values that move it: those of the
// joints on its path, a value a motion link sets standing for the values its link chain
// starts from (a driven gear turns with its driving gear's one value). Kept per occurrence as
// a count of the values no link reads or sets, which no other path value can repeat, and the
// set of the ones that may be repeated (links' sources).
let sources = link_sources(asm);
let shared: BTreeSet<(u64, usize)> = sources.values().flatten().copied().collect();
let mut moved_by: BTreeMap<u64, (usize, BTreeSet<(u64, usize)>)> = BTreeMap::new();
// The joints at each occurrence, in the joints' order (a large assembly is not walked joint by
// joint for every occurrence).
let mut at: BTreeMap<u64, Vec<&Joint>> = BTreeMap::new();
@@ -1525,7 +1808,18 @@ fn build<'a>(doc: &'a Document, st: &ModelState, vals: &Values, sol: &mut Soluti
placed.insert(other, m);
tree.parent.insert(other, (occ, j.id, from_a));
tree.order.push(other);
dof.insert(other, dof.get(&occ).copied().unwrap_or(0) + j.kind.dofs().len());
let (mut own, mut common) = moved_by.get(&occ).cloned().unwrap_or_default();
for v in (0..j.kind.dofs().len()).map(|i| (j.id, i)) {
match sources.get(&v) {
Some(from) => common.extend(from.iter().copied()),
None if shared.contains(&v) => {
common.insert(v);
}
None => own += 1,
}
}
dof.insert(other, own + common.len());
moved_by.insert(other, (own, common));
queue.push_back(other);
}
}
@@ -1598,7 +1892,30 @@ mod reach;
#[path = "joints_face.rs"]
mod face;
pub use face::{end_faces, face_name_at, named_face_spot, pin_end, round_end_at};
#[path = "joints_relations.rs"]
mod relations;
pub use reach::{REACH_TOL, Reach, Reached, reach};
pub use relations::relation_values;
/// The rest angle that turns B least as joint `j` is made, as B sits now: none or a half turn
/// about the joint's z. Origins whose x is chosen for them (a face, a circle, a point without
/// x) get opposite x when their z are opposite, so two faces that already meet would, at angle
/// 0, spin B a half turn about their shared normal.
pub fn least_turn_angle(doc: &Document, st: &ModelState, j: &Joint) -> f64 {
let (Ok(fa), Ok(fb)) = (resolve_origin(doc, st, &j.a), resolve_origin(doc, st, &j.b)) else { return 0.0 };
let (ta, tb) = (occurrence_world(doc, j.a.occurrence), occurrence_world(doc, j.b.occurrence));
let mut q = j.values.clone();
q.resize(j.kind.dofs().len(), 0.0);
clamp(j, &mut q);
let turn = |angle: f64| {
let k = Joint { angle, ..j.clone() };
through(&k, &(fa, fb), &q, true, &ta).map(|m| mismatch(&m, &tb).1)
};
match (turn(0.0), turn(std::f64::consts::PI)) {
(Some(none), Some(half)) if half + 1e-9 < none => std::f64::consts::PI,
_ => 0.0,
}
}
/// The frame matrix for an explicit origin / z / x (public for commands).
pub fn frame_of(o: Vec3, z: Vec3, x: Option<Vec3>) -> Option<Mat> {
@@ -1617,7 +1934,7 @@ mod tests {
/// Revolute joints 1…n (joint 1 at `start`, the others at 0) linked 1 → 2 → … → n with
/// ratio 2, the links stored from the end of the chain; joint 3 limited to ±50.
fn chain(n: u64, start: f64) -> Assembly {
let origin = JointOrigin { occurrence: 0, snap: Snap::Point { point: Vec3::ZERO, z: None } };
let origin = JointOrigin { occurrence: 0, snap: Snap::Point { point: Vec3::ZERO, z: None, x: None } };
let joints = (1..=n)
.map(|id| Joint {
id,
@@ -1631,6 +1948,7 @@ mod tests {
offset: 0.0,
angle: 0.0,
suppressed: false,
exprs: Default::default(),
})
.collect();
let links =
@@ -1638,6 +1956,26 @@ mod tests {
Assembly { joints, links, ..Assembly::default() }
}
/// A linked value moves with the free value its chain starts from, however the chain is
/// stored; a chain that comes back round on itself, or a link from a value its joint lacks
/// (hand-edited designs), moves it with nothing.
#[test]
fn linked_values_move_with_their_chains_start() {
let mut asm = chain(5, 0.0);
let sources = link_sources(&asm);
for b in 2..=5 {
assert_eq!(sources.get(&(b, 0)), Some(&BTreeSet::from([(1, 0)])), "J{b}: {sources:?}");
}
assert!(!sources.contains_key(&(1, 0)));
let link = |a: u64, ia: usize, b: u64| MotionLink { a, ia, b, ib: 0, ratio: 1.0, offset: 0.0, coupling: None, lift: Vec::new() };
asm.links.push(link(5, 0, 1));
assert!(link_sources(&asm).values().all(BTreeSet::is_empty), "{:?}", link_sources(&asm));
let mut asm = chain(2, 0.0);
asm.links = vec![link(1, 7, 2)];
assert_eq!(link_sources(&asm).get(&(2, 0)), Some(&BTreeSet::new()));
assert!(link_sources(&Assembly::default()).is_empty());
}
/// Links carry a value down a chain stored from its end, each linked value within its limits
/// as it is set, and applying them again changes nothing.
#[test]
+27 -54
View File
@@ -15,7 +15,7 @@
use std::f64::consts::TAU;
use serde::{Deserialize, Serialize};
use solidcraft_geom::{Vec2, Vec3};
use solidcraft_geom::{Aabb2, Aabb3, Ring, Vec2, Vec3};
use super::{JointKind, JointOrigin, MotionLink, Size, component_bodies, component_of, flip_of, occurrence_world, size_of};
use crate::{DocError, Document, IDENTITY, Mat, ModelState, Result, apply_point, apply_vector, mat_inverse, mat_mul};
@@ -108,7 +108,7 @@ fn rot(v: Vec2, a: f64) -> Vec2 {
/// Where a translating follower coming along `-d` from far along its line through `g` first
/// touches the closed polygon `poly`: the largest `t` of its point `g + t·d` (its roller's centre
/// for `r > 0`; for `flat`, its face square to `d`). `None` when it misses the profile.
pub fn first_contact_along(poly: &[Vec2], g: Vec2, d: Vec2, r: f64, flat: bool) -> Option<f64> {
pub fn first_contact_along(poly: &Ring<Vec2>, g: Vec2, d: Vec2, r: f64, flat: bool) -> Option<f64> {
let n = d.perp();
let mut best: Option<f64> = None;
let mut take = |t: f64| {
@@ -122,9 +122,7 @@ pub fn first_contact_along(poly: &[Vec2], g: Vec2, d: Vec2, r: f64, flat: bool)
}
return best;
}
let count = poly.len();
for i in 0..count {
let (Some(&p), Some(&q)) = (poly.get(i), poly.get((i + 1) % count)) else { continue };
for (&p, &q) in poly.edges() {
let (a, b) = ((p - g).dot(n), (q - g).dot(n));
// Edges wholly outside the band the follower sweeps.
if a.min(b) > r || a.max(b) < -r {
@@ -167,10 +165,8 @@ pub fn first_contact_along(poly: &[Vec2], g: Vec2, d: Vec2, r: f64, flat: bool)
/// The angles in [0, 2π) where an oscillating follower's tip touches the closed polygon `poly`:
/// its tip (a roller's centre for `r > 0`) at `arm` from the pivot `pivot`, turned from the angle
/// `start` the way `turn` says (+1 counter-clockwise), sorted.
fn touches_turning(poly: &[Vec2], pivot: Vec2, arm: f64, r: f64, start: f64, turn: f64) -> Vec<f64> {
fn touches_turning(poly: &Ring<Vec2>, pivot: Vec2, arm: f64, r: f64, start: f64, turn: f64) -> Vec<f64> {
let tip = |a: f64| pivot + Vec2::from_angle(a) * arm;
let count = poly.len();
let edge = |i: usize| Some((*poly.get(i)?, *poly.get((i + 1) % count)?));
let mut out = Vec::new();
let mut take = |a: f64, keep: &dyn Fn(Vec2) -> bool| {
let turned = (turn * (a - start)).rem_euclid(TAU);
@@ -187,7 +183,7 @@ fn touches_turning(poly: &[Vec2], pivot: Vec2, arm: f64, r: f64, start: f64, tur
let (base, spread) = (w.angle(), (h / len).clamp(-1.0, 1.0).acos());
vec![base + spread, base - spread]
};
for (p, q) in (0..count).filter_map(edge) {
for (&p, &q) in poly.edges() {
let near = dist_to_segment(pivot, p, q);
let far = (p - pivot).len().max((q - pivot).len());
if near > arm + r || far < arm - r {
@@ -216,19 +212,17 @@ fn touches_turning(poly: &[Vec2], pivot: Vec2, arm: f64, r: f64, start: f64, tur
/// Whether an oscillating follower's tip, turned `turned` from `start` the way `turn` says,
/// overlaps the profile by more than `slack`.
fn overlaps_turning(poly: &[Vec2], pivot: Vec2, arm: f64, r: f64, (start, turn): (f64, f64), turned: f64, slack: f64) -> bool {
fn overlaps_turning(poly: &Ring<Vec2>, pivot: Vec2, arm: f64, r: f64, (start, turn): (f64, f64), turned: f64, slack: f64) -> bool {
let at = pivot + Vec2::from_angle(start + turn * turned) * arm;
let n = poly.len();
let gap =
(0..n).filter_map(|i| Some((*poly.get(i)?, *poly.get((i + 1) % n)?))).map(|(p, q)| dist_to_segment(at, p, q)).fold(f64::INFINITY, f64::min);
let gap = poly.edges().map(|(p, q)| dist_to_segment(at, *p, *q)).fold(f64::INFINITY, f64::min);
// Inside counts only clear of the boundary: a knife on an edge touches it.
gap < r - slack || (gap > slack && inside(poly, at))
gap < r - slack || (gap > slack && poly.contains(at))
}
/// Where an oscillating follower first touches the closed polygon `poly` turning from `start`
/// (the angle it turns through, in (0, 2π)): see `touches_turning`. `None` when it misses the
/// profile or starts on it.
pub fn first_contact_turning(poly: &[Vec2], pivot: Vec2, arm: f64, r: f64, start: f64, turn: f64) -> Option<f64> {
pub fn first_contact_turning(poly: &Ring<Vec2>, pivot: Vec2, arm: f64, r: f64, start: f64, turn: f64) -> Option<f64> {
if overlaps_turning(poly, pivot, arm, r, (start, turn), 0.0, -f64::EPSILON) {
return None;
}
@@ -239,7 +233,7 @@ pub fn first_contact_turning(poly: &[Vec2], pivot: Vec2, arm: f64, r: f64, start
/// after the cam turned on a little: pushed back to where the profile now lets it be when its tip
/// overlaps the profile (by more than `slack`), else on to where it next touches. It stays on the
/// part of the profile it rides, however the profile crosses its tip's circle elsewhere.
pub fn next_contact_turning(poly: &[Vec2], pivot: Vec2, arm: f64, r: f64, (start, turn): (f64, f64), from: f64, slack: f64) -> Option<f64> {
pub fn next_contact_turning(poly: &Ring<Vec2>, pivot: Vec2, arm: f64, r: f64, (start, turn): (f64, f64), from: f64, slack: f64) -> Option<f64> {
let touches = touches_turning(poly, pivot, arm, r, start, turn);
// A touch where the tip is clear of the rest of the profile (a roller inside the profile is a
// radius from the parallels of the edges near the one it overlaps too).
@@ -257,23 +251,10 @@ fn dist_to_segment(x: Vec2, p: Vec2, q: Vec2) -> f64 {
x.dist(p + d * t)
}
/// Even-odd containment in a closed polygon.
fn inside(poly: &[Vec2], x: Vec2) -> bool {
let n = poly.len();
let mut inside = false;
for i in 0..n {
let (Some(a), Some(b)) = (poly.get(i), poly.get((i + 1) % n)) else { continue };
if (a.y > x.y) != (b.y > x.y) && x.x < a.x + (x.y - a.y) / (b.y - a.y) * (b.x - a.x) {
inside = !inside;
}
}
inside
}
/// Everything a cam's table is worked out from, in the cam's fixed frame C.
struct Setup {
/// The profile at cam value 0.
poly: Vec<Vec2>,
poly: Ring<Vec2>,
/// +1 when the cam turns by +value about C's z (it is the joint's b side), −1 otherwise.
turn: f64,
/// A translating follower's line (a point and its unit direction, the slider's +z), or an
@@ -517,19 +498,11 @@ fn sampled(lp: &solidcraft_geom::Loop2, tol: f64) -> Option<Vec<Vec2>> {
/// The diagonal of the box around some points (0 for none).
fn extent<'a>(pts: impl Iterator<Item = &'a Vec3>) -> f64 {
let (lo, hi) = pts.fold((Vec3::new(f64::MAX, f64::MAX, f64::MAX), Vec3::new(f64::MIN, f64::MIN, f64::MIN)), |(lo, hi), p| {
(Vec3::new(lo.x.min(p.x), lo.y.min(p.y), lo.z.min(p.z)), Vec3::new(hi.x.max(p.x), hi.y.max(p.y), hi.z.max(p.z)))
});
if lo.x <= hi.x { (hi - lo).len() } else { 0.0 }
pts.copied().collect::<Aabb3>().diagonal()
}
fn bounds_2d(pts: &[Vec2]) -> f64 {
let (mut lo, mut hi) = (Vec2::new(f64::MAX, f64::MAX), Vec2::new(f64::MIN, f64::MIN));
for p in pts {
lo = Vec2::new(lo.x.min(p.x), lo.y.min(p.y));
hi = Vec2::new(hi.x.max(p.x), hi.y.max(p.y));
}
if lo.x <= hi.x { (hi - lo).len() } else { 0.0 }
pts.iter().copied().collect::<Aabb2>().diagonal()
}
/// The chord error a profile is sampled to: 0.1 µm per 100 mm of its size, at least 0.1 µm.
@@ -569,7 +542,7 @@ fn setup(doc: &Document, st: &ModelState, l: &MotionLink, c: &Cam) -> Result<Set
if v.iter().any(|p| (p.z - mean).abs() > 1e-6 * size + 1e-9) {
return Err(bad("the profile must lie in a plane square to the cam's axis"));
}
let poly: Vec<Vec2> = v.iter().map(|p| Vec2::new(p.x, p.y)).collect();
let poly: Ring<Vec2> = v.iter().map(|p| Vec2::new(p.x, p.y)).collect();
if !poly.iter().all(|p| p.is_finite()) || area(&poly).abs() <= 1e-12 * size * size {
return Err(bad("the profile encloses no area"));
}
@@ -709,9 +682,9 @@ fn follower_is_b(doc: &Document, cam_joint: &super::Joint, fol: &super::Joint, (
/// A polygon's signed area, from its first point (so a small profile far from the axis keeps its
/// digits).
fn area(poly: &[Vec2]) -> f64 {
let (n, o) = (poly.len(), poly.first().copied().unwrap_or(Vec2::ZERO));
(0..n).filter_map(|i| Some((*poly.get(i)? - o).cross(*poly.get((i + 1) % n)? - o))).sum::<f64>() / 2.0
fn area(poly: &Ring<Vec2>) -> f64 {
let o = poly.first().copied().unwrap_or(Vec2::ZERO);
poly.edges().map(|(p, q)| (*p - o).cross(*q - o)).sum::<f64>() / 2.0
}
/// The follower's places at the samples 0 ..= `CAM_SAMPLES` of a turn, `place(u, prev)` giving
@@ -816,7 +789,7 @@ mod tests {
use super::*;
/// A circle of radius `r` about `c` as a polygon of `n` points (its sag is r·(1 − cos π/n)).
fn circle(c: Vec2, r: f64, n: usize) -> Vec<Vec2> {
fn circle(c: Vec2, r: f64, n: usize) -> Ring<Vec2> {
(0..n).map(|k| c + Vec2::from_angle(TAU * k as f64 / n as f64) * r).collect()
}
@@ -874,10 +847,9 @@ mod tests {
}
// Followed on from where it touches, it stays on the part of the profile it rides: a C
// whose two arms both cross the tip's circle, the tip resting on the second arm.
let c: Vec<Vec2> = [(-10.0, 20.0), (30.0, 20.0), (30.0, 14.0), (-4.0, 14.0), (-4.0, -14.0), (30.0, -14.0), (30.0, -20.0), (-10.0, -20.0)]
.into_iter()
.map(|(x, y)| Vec2::new(x, y))
.collect();
let c = Ring::from(
[[-10.0, 20.0], [30.0, 20.0], [30.0, 14.0], [-4.0, 14.0], [-4.0, -14.0], [30.0, -14.0], [30.0, -20.0], [-10.0, -20.0]].map(Vec2::from),
);
let (pv, l) = (Vec2::new(40.0, 0.0), 22.0);
let touches = touches_turning(&c, pv, l, 0.0, 0.0, 1.0);
let on = |t: f64| (pv + Vec2::from_angle(t) * l).y;
@@ -887,12 +859,12 @@ mod tests {
let stay = next_contact_turning(&c, pv, l, 0.0, (0.0, 1.0), second, 1e-9).unwrap_or(f64::NAN);
assert!((stay - second).abs() < 1e-12, "{stay} vs {second}");
// A knife on an edge touches it, it doesn't overlap it; just inside, it does.
let square: Vec<Vec2> = [(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)].into_iter().map(|(x, y)| Vec2::new(x, y)).collect();
let square = Ring::from([[-1.0, -1.0], [1.0, -1.0], [1.0, 1.0], [-1.0, 1.0]].map(Vec2::from));
let (pv, l) = (Vec2::new(0.0, -4.0), 3.0);
let up = std::f64::consts::FRAC_PI_2;
assert!(!overlaps_turning(&square, pv, l, 0.0, (up, 1.0), 0.0, 1e-9));
assert!(overlaps_turning(&square, pv, l + 0.01, 0.0, (up, 1.0), 0.0, 1e-9));
assert!((area(&square.iter().map(|p| *p * 0.004 + Vec2::new(524_288.0, 524_288.0)).collect::<Vec<_>>()) - 6.4e-5).abs() < 1e-12);
assert!((area(&square.iter().map(|p| *p * 0.004 + Vec2::new(524_288.0, 524_288.0)).collect::<Ring<_>>()) - 6.4e-5).abs() < 1e-12);
// An arm that can't reach, and one that starts on the cam.
let poly = circle(Vec2::new(e, 0.0), r, 512);
assert_eq!(first_contact_turning(&poly, pivot, 5.0, 0.0, start, 1.0), None);
@@ -903,7 +875,7 @@ mod tests {
/// comes back after the turn: refused. On the eccentric circle it follows smoothly.
#[test]
fn a_follower_that_jumps_is_refused() {
let arm_round = |poly: &[Vec2], pivot: Vec2, arm: f64| {
let arm_round = |poly: &Ring<Vec2>, pivot: Vec2, arm: f64| {
let start = pivot.angle();
let place = |u: f64, prev: Option<f64>| -> Result<f64> {
let q = -TAU * u / CAM_SAMPLES as f64;
@@ -916,7 +888,7 @@ mod tests {
};
round(&place, true, 1e-6)
};
let lobes: Vec<Vec2> = (0..240)
let lobes: Ring<Vec2> = (0..240)
.map(|k| {
let t = TAU * f64::from(k) / 240.0;
Vec2::from_angle(t) * (28.0 + 2.0 / 3.0 + 13.0 * (3.0 * t).cos())
@@ -954,7 +926,7 @@ mod tests {
/// a guide only on the cam's own side is refused.
#[test]
fn the_guide_is_the_side_fewer_moving_joints_from_the_frame() {
let origin = JointOrigin { occurrence: 0, snap: super::super::Snap::Point { point: Vec3::ZERO, z: None } };
let origin = JointOrigin { occurrence: 0, snap: super::super::Snap::Point { point: Vec3::ZERO, z: None, x: None } };
let joint = |id: u64, kind: JointKind, a: u64, b: u64| super::super::Joint {
id,
name: format!("J{id}"),
@@ -967,6 +939,7 @@ mod tests {
offset: 0.0,
angle: 0.0,
suppressed: false,
exprs: Default::default(),
};
let occ = |id: u64, grounded: bool| crate::Occurrence { id, component: id, parent: 0, name: format!("O{id}"), transform: IDENTITY, grounded };
// 1 frame (grounded), 2 cam, 3-6 a rigid chain to the guide 7, 8 the piston, 9 crank, 10 rod.

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